diff --git a/packages/create-sei/src/templates.test.ts b/packages/create-sei/src/templates.test.ts new file mode 100644 index 000000000..9eeab89fc --- /dev/null +++ b/packages/create-sei/src/templates.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from '@jest/globals'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +const PACKAGE_ROOT = path.resolve(__dirname, '..'); +const TEMPLATES_DIR = path.join(PACKAGE_ROOT, 'templates'); +const EXTENSIONS_DIR = path.join(PACKAGE_ROOT, 'extensions'); + +const EXPECTED_TEMPLATES = ['next-template']; +const EXPECTED_EXTENSIONS = ['precompiles']; + +describe('Templates', () => { + it('templates directory exists', async () => { + const stat = await fs.stat(TEMPLATES_DIR); + expect(stat.isDirectory()).toBe(true); + }); + + it.each(EXPECTED_TEMPLATES)('%s template directory exists', async (template) => { + const templatePath = path.join(TEMPLATES_DIR, template); + const stat = await fs.stat(templatePath); + expect(stat.isDirectory()).toBe(true); + }); + + it.each(EXPECTED_TEMPLATES)('%s template has a valid package.json', async (template) => { + const pkgPath = path.join(TEMPLATES_DIR, template, 'package.json'); + const contents = await fs.readFile(pkgPath, 'utf-8'); + const parsed = JSON.parse(contents); + expect(typeof parsed.name).toBe('string'); + expect(parsed.name.trim().length).toBeGreaterThan(0); + expect(typeof parsed.version).toBe('string'); + }); + + it.each(EXPECTED_TEMPLATES)('%s template has a tsconfig.json', async (template) => { + const tsconfigPath = path.join(TEMPLATES_DIR, template, 'tsconfig.json'); + const stat = await fs.stat(tsconfigPath); + expect(stat.isFile()).toBe(true); + }); + + it.each(EXPECTED_TEMPLATES)('%s template has a src/ directory', async (template) => { + const srcPath = path.join(TEMPLATES_DIR, template, 'src'); + const stat = await fs.stat(srcPath); + expect(stat.isDirectory()).toBe(true); + }); +}); + +describe('Extensions', () => { + it('extensions directory exists', async () => { + const stat = await fs.stat(EXTENSIONS_DIR); + expect(stat.isDirectory()).toBe(true); + }); + + it.each(EXPECTED_EXTENSIONS)('%s extension directory exists', async (extension) => { + const extensionPath = path.join(EXTENSIONS_DIR, extension); + const stat = await fs.stat(extensionPath); + expect(stat.isDirectory()).toBe(true); + }); + + it.each(EXPECTED_EXTENSIONS)('%s extension has a valid package.json', async (extension) => { + const pkgPath = path.join(EXTENSIONS_DIR, extension, 'package.json'); + const contents = await fs.readFile(pkgPath, 'utf-8'); + const parsed = JSON.parse(contents); + expect(typeof parsed.name).toBe('string'); + expect(parsed.name.trim().length).toBeGreaterThan(0); + }); +}); diff --git a/packages/ledger/src/cosmos/__tests__/seiLedgerOfflineAminoSigner.spec.ts b/packages/ledger/src/cosmos/__tests__/seiLedgerOfflineAminoSigner.spec.ts index ef26b2b04..d5fa822f9 100644 --- a/packages/ledger/src/cosmos/__tests__/seiLedgerOfflineAminoSigner.spec.ts +++ b/packages/ledger/src/cosmos/__tests__/seiLedgerOfflineAminoSigner.spec.ts @@ -9,6 +9,9 @@ const mockR = new Uint8Array([0, 1, 2]); // should be stripped const mockS = new Uint8Array([0, 3, 4]); // should be stripped const path = "m/44'/118'/0'/0/0"; +/** BIP44 derivation path pattern: m/purpose'/coin_type'/account'/change/index */ +const BIP44_PATTERN = /^m\/44'\/\d+'\/\d+'\/\d+\/\d+$/; + const mockSeiApp = { getCosmosAddress: jest.fn().mockResolvedValue({ address: mockAddress, @@ -20,6 +23,28 @@ const mockSeiApp = { }) }; +describe('BIP44 derivation paths', () => { + it('standard Cosmos derivation path matches BIP44 pattern', () => { + expect("m/44'/118'/0'/0/0").toMatch(BIP44_PATTERN); + }); + + it('EVM-compatible derivation path matches BIP44 pattern', () => { + expect("m/44'/60'/0'/0/0").toMatch(BIP44_PATTERN); + }); + + it('non-zero account indices match BIP44 pattern', () => { + expect("m/44'/118'/1'/0/0").toMatch(BIP44_PATTERN); + expect("m/44'/118'/0'/0/5").toMatch(BIP44_PATTERN); + }); + + it('path is passed through to getCosmosAddress unchanged', async () => { + const customPath = "m/44'/118'/0'/0/3"; + const signer = new SeiLedgerOfflineAminoSigner(mockSeiApp as never, customPath); + await signer.getAccounts(); + expect(mockSeiApp.getCosmosAddress).toHaveBeenCalledWith(customPath); + }); +}); + describe('SeiLedgerOfflineAminoSigner', () => { let signer: SeiLedgerOfflineAminoSigner; diff --git a/packages/precompiles/src/precompiles/__tests__/abis.spec.ts b/packages/precompiles/src/precompiles/__tests__/abis.spec.ts new file mode 100644 index 000000000..5baa2a7a7 --- /dev/null +++ b/packages/precompiles/src/precompiles/__tests__/abis.spec.ts @@ -0,0 +1,91 @@ +import { + ADDRESS_PRECOMPILE_ABI, + BANK_PRECOMPILE_ABI, + DISTRIBUTION_PRECOMPILE_ABI, + GOVERNANCE_PRECOMPILE_ABI, + IBC_PRECOMPILE_ABI, + JSON_PRECOMPILE_ABI, + ORACLE_PRECOMPILE_ABI, + POINTER_PRECOMPILE_ABI, + POINTERVIEW_PRECOMPILE_ABI, + SOLO_PRECOMPILE_ABI, + STAKING_PRECOMPILE_ABI, + WASM_PRECOMPILE_ABI +} from '../index'; + +type AbiEntry = { type: string; name?: string; inputs?: readonly unknown[]; outputs?: readonly unknown[]; stateMutability?: string }; +type Abi = readonly AbiEntry[]; + +function getFunctionNames(abi: Abi): string[] { + return abi.filter((entry) => entry.type === 'function').map((entry) => entry.name!); +} + +function getFunctions(abi: Abi): AbiEntry[] { + return abi.filter((entry) => entry.type === 'function'); +} + +const PRECOMPILE_ABIS: [string, Abi, string[]][] = [ + ['ADDRESS', ADDRESS_PRECOMPILE_ABI, ['getSeiAddr', 'getEvmAddr', 'associate', 'associatePubKey']], + ['BANK', BANK_PRECOMPILE_ABI, ['send', 'sendNative', 'balance', 'all_balances', 'supply', 'decimals', 'name', 'symbol']], + ['DISTRIBUTION', DISTRIBUTION_PRECOMPILE_ABI, ['setWithdrawAddress', 'withdrawDelegationRewards', 'withdrawMultipleDelegationRewards', 'rewards']], + ['GOVERNANCE', GOVERNANCE_PRECOMPILE_ABI, ['vote', 'deposit']], + ['IBC', IBC_PRECOMPILE_ABI, ['transfer']], + ['JSON', JSON_PRECOMPILE_ABI, ['extractAsBytes', 'extractAsBytesList']], + ['ORACLE', ORACLE_PRECOMPILE_ABI, ['getExchangeRates', 'getOracleTwaps']], + ['POINTER', POINTER_PRECOMPILE_ABI, ['addCW20Pointer', 'addCW721Pointer', 'addNativePointer']], + ['POINTERVIEW', POINTERVIEW_PRECOMPILE_ABI, ['getCW20Pointer', 'getCW721Pointer']], + ['STAKING', STAKING_PRECOMPILE_ABI, ['delegate', 'undelegate', 'redelegate', 'delegation']], + ['WASM', WASM_PRECOMPILE_ABI, ['execute', 'execute_batch']] +]; + +describe('Precompile ABIs — function names', () => { + it.each(PRECOMPILE_ABIS)('%s ABI contains all expected function names', (_name, abi, expectedFunctions) => { + const actualFunctions = getFunctionNames(abi as Abi); + for (const fn of expectedFunctions) { + expect(actualFunctions).toContain(fn); + } + }); +}); + +describe('Precompile ABIs — function entry structure', () => { + it.each(PRECOMPILE_ABIS)('%s ABI functions each have inputs, outputs, and stateMutability', (_name, abi) => { + const functions = getFunctions(abi as Abi); + expect(functions.length).toBeGreaterThan(0); + + for (const fn of functions) { + expect(Array.isArray(fn.inputs)).toBe(true); + expect(Array.isArray(fn.outputs)).toBe(true); + expect(typeof fn.stateMutability).toBe('string'); + expect(['view', 'nonpayable', 'payable', 'pure']).toContain(fn.stateMutability); + } + }); +}); + +describe('Precompile ABIs — top-level structure', () => { + const ALL_ABIS: [string, Abi][] = [ + ['ADDRESS', ADDRESS_PRECOMPILE_ABI], + ['BANK', BANK_PRECOMPILE_ABI], + ['DISTRIBUTION', DISTRIBUTION_PRECOMPILE_ABI], + ['GOVERNANCE', GOVERNANCE_PRECOMPILE_ABI], + ['IBC', IBC_PRECOMPILE_ABI], + ['JSON', JSON_PRECOMPILE_ABI], + ['ORACLE', ORACLE_PRECOMPILE_ABI], + ['POINTER', POINTER_PRECOMPILE_ABI], + ['POINTERVIEW', POINTERVIEW_PRECOMPILE_ABI], + ['SOLO', SOLO_PRECOMPILE_ABI], + ['STAKING', STAKING_PRECOMPILE_ABI], + ['WASM', WASM_PRECOMPILE_ABI] + ]; + + it.each(ALL_ABIS)('%s ABI is a non-empty array', (_name, abi) => { + expect(Array.isArray(abi)).toBe(true); + expect((abi as Abi).length).toBeGreaterThan(0); + }); + + it.each(ALL_ABIS)('%s ABI entries each have a type field', (_name, abi) => { + for (const entry of abi as Abi) { + expect(typeof entry.type).toBe('string'); + expect(entry.type.length).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/precompiles/src/precompiles/__tests__/addresses.spec.ts b/packages/precompiles/src/precompiles/__tests__/addresses.spec.ts new file mode 100644 index 000000000..579329adc --- /dev/null +++ b/packages/precompiles/src/precompiles/__tests__/addresses.spec.ts @@ -0,0 +1,54 @@ +import { + ADDRESS_PRECOMPILE_ADDRESS, + BANK_PRECOMPILE_ADDRESS, + DISTRIBUTION_PRECOMPILE_ADDRESS, + GOVERNANCE_PRECOMPILE_ADDRESS, + IBC_PRECOMPILE_ADDRESS, + JSON_PRECOMPILE_ADDRESS, + ORACLE_PRECOMPILE_ADDRESS, + POINTER_PRECOMPILE_ADDRESS, + POINTERVIEW_PRECOMPILE_ADDRESS, + SOLO_PRECOMPILE_ADDRESS, + STAKING_PRECOMPILE_ADDRESS, + WASM_PRECOMPILE_ADDRESS +} from '../index'; + +const PRECOMPILE_ADDRESSES: [string, string][] = [ + ['ADDRESS', ADDRESS_PRECOMPILE_ADDRESS], + ['BANK', BANK_PRECOMPILE_ADDRESS], + ['DISTRIBUTION', DISTRIBUTION_PRECOMPILE_ADDRESS], + ['GOVERNANCE', GOVERNANCE_PRECOMPILE_ADDRESS], + ['IBC', IBC_PRECOMPILE_ADDRESS], + ['JSON', JSON_PRECOMPILE_ADDRESS], + ['ORACLE', ORACLE_PRECOMPILE_ADDRESS], + ['POINTER', POINTER_PRECOMPILE_ADDRESS], + ['POINTERVIEW', POINTERVIEW_PRECOMPILE_ADDRESS], + ['SOLO', SOLO_PRECOMPILE_ADDRESS], + ['STAKING', STAKING_PRECOMPILE_ADDRESS], + ['WASM', WASM_PRECOMPILE_ADDRESS] +]; + +/** Validates an ERC-55 checksummed Ethereum address: 0x + exactly 40 hex characters. */ +function isValidEthAddress(address: string): boolean { + return /^0x[0-9a-fA-F]{40}$/.test(address); +} + +describe('Precompile addresses', () => { + it.each(PRECOMPILE_ADDRESSES)('%s address is a valid 42-character Ethereum address', (_name, address) => { + expect(typeof address).toBe('string'); + expect(isValidEthAddress(address)).toBe(true); + }); + + it('all precompile addresses are unique', () => { + const addresses = PRECOMPILE_ADDRESSES.map(([, addr]) => addr.toLowerCase()); + const unique = new Set(addresses); + expect(unique.size).toBe(addresses.length); + }); + + it('all precompile addresses start with 0x000000000000000000000000000000000000', () => { + // Sei precompiles live in the reserved 0x1000–0x10FF range + for (const [, address] of PRECOMPILE_ADDRESSES) { + expect(address.toLowerCase()).toMatch(/^0x0{36}/); + } + }); +}); diff --git a/packages/registry/src/gas/__tests__/index.spec.ts b/packages/registry/src/gas/__tests__/index.spec.ts index 35e93e4bc..16d70a942 100644 --- a/packages/registry/src/gas/__tests__/index.spec.ts +++ b/packages/registry/src/gas/__tests__/index.spec.ts @@ -32,4 +32,19 @@ describe('GasInfo Tests', () => { expect(pacific1.min_gas_price).toBeGreaterThanOrEqual(0.01); expect(pacific1.module_adjustments.dex.sudo_gas_price).toBeLessThanOrEqual(0.02); }); + + it('all networks have a positive min_gas_price', () => { + for (const [network, info] of Object.entries(GAS_INFO)) { + expect(info.min_gas_price).toBeGreaterThan(0); + if (info.min_gas_price <= 0) { + throw new Error(`Network ${network} has non-positive min_gas_price: ${info.min_gas_price}`); + } + } + }); + + it('all networks use usei as the fee denom', () => { + for (const info of Object.values(GAS_INFO)) { + expect(info.denom).toBe('usei'); + } + }); }); diff --git a/packages/registry/src/ibc/__tests__/index.spec.ts b/packages/registry/src/ibc/__tests__/index.spec.ts index 4d26f5b9d..4a2f7068c 100644 --- a/packages/registry/src/ibc/__tests__/index.spec.ts +++ b/packages/registry/src/ibc/__tests__/index.spec.ts @@ -31,4 +31,28 @@ describe('IBCInfo Tests', () => { expect(firstChannel.counterparty_chain_name).not.toBe(''); expect(firstChannel.dst_channel.startsWith('channel-')).toBeTruthy(); }); + + it('all channel IDs follow the channel-N format across all networks', () => { + const channelPattern = /^channel-\d+$/; + for (const [network, channels] of Object.entries(IBC_INFO)) { + for (const channel of channels) { + expect(channel.src_channel).toMatch(channelPattern); + expect(channel.dst_channel).toMatch(channelPattern); + if (!channelPattern.test(channel.src_channel)) { + throw new Error(`Network ${network} has invalid src_channel: ${channel.src_channel}`); + } + if (!channelPattern.test(channel.dst_channel)) { + throw new Error(`Network ${network} has invalid dst_channel: ${channel.dst_channel}`); + } + } + } + }); + + it('all counterparty chain names are non-empty strings', () => { + for (const channels of Object.values(IBC_INFO)) { + for (const channel of channels) { + expect(channel.counterparty_chain_name.trim().length).toBeGreaterThan(0); + } + } + }); }); diff --git a/packages/registry/src/networks/__tests__/index.spec.ts b/packages/registry/src/networks/__tests__/index.spec.ts index 0cf794290..ab57cf812 100644 --- a/packages/registry/src/networks/__tests__/index.spec.ts +++ b/packages/registry/src/networks/__tests__/index.spec.ts @@ -24,4 +24,25 @@ describe('Networks configuration', () => { } } }); + + it('should have RPC URLs starting with https:// or wss://', () => { + for (const [networkId, networkConfig] of Object.entries(NETWORKS)) { + for (const endpoint of networkConfig.rpc) { + const url = endpoint.url; + const isValid = url.startsWith('https://') || url.startsWith('wss://'); + expect(isValid).toBe(true); + if (!isValid) { + throw new Error(`Network ${networkId} has invalid RPC URL: ${url}`); + } + } + } + }); + + it('should have a non-empty provider name for each RPC endpoint', () => { + for (const networkConfig of Object.values(NETWORKS)) { + for (const endpoint of networkConfig.rpc) { + expect(endpoint.provider.trim().length).toBeGreaterThan(0); + } + } + }); }); diff --git a/packages/registry/src/tokens/__tests__/index.spec.ts b/packages/registry/src/tokens/__tests__/index.spec.ts index 50f182954..5fdbceac5 100644 --- a/packages/registry/src/tokens/__tests__/index.spec.ts +++ b/packages/registry/src/tokens/__tests__/index.spec.ts @@ -44,3 +44,37 @@ it('should contain the "sei" asset with correct properties in each network', () } } }); + +describe('Token image URL validation', () => { + it('all token image URLs use https:// scheme', () => { + for (const [network, assets] of Object.entries(TOKEN_LIST)) { + for (const asset of assets) { + if (asset.images?.png) { + expect(asset.images.png).toMatch(/^https:\/\//); + if (!asset.images.png.startsWith('https://')) { + throw new Error(`Network ${network} token "${asset.symbol}" has non-https PNG image URL: ${asset.images.png}`); + } + } + if (asset.images?.svg) { + expect(asset.images.svg).toMatch(/^https:\/\//); + if (!asset.images.svg.startsWith('https://')) { + throw new Error(`Network ${network} token "${asset.symbol}" has non-https SVG image URL: ${asset.images.svg}`); + } + } + } + } + }); + + it('all token image URLs are non-empty when present', () => { + for (const assets of Object.values(TOKEN_LIST)) { + for (const asset of assets) { + if (asset.images?.png) { + expect(asset.images.png.trim().length).toBeGreaterThan(0); + } + if (asset.images?.svg) { + expect(asset.images.svg.trim().length).toBeGreaterThan(0); + } + } + } + }); +}); diff --git a/packages/sei-global-wallet/src/lib/__tests__/config.spec.ts b/packages/sei-global-wallet/src/lib/__tests__/config.spec.ts new file mode 100644 index 000000000..a2921a3fa --- /dev/null +++ b/packages/sei-global-wallet/src/lib/__tests__/config.spec.ts @@ -0,0 +1,32 @@ +import { config } from '../config'; + +describe('sei-global-wallet config', () => { + it('walletName is a non-empty string', () => { + expect(typeof config.walletName).toBe('string'); + expect(config.walletName.trim().length).toBeGreaterThan(0); + }); + + it('walletUrl starts with https://', () => { + expect(typeof config.walletUrl).toBe('string'); + expect(config.walletUrl).toMatch(/^https:\/\//); + }); + + it('environmentId is a non-empty string', () => { + expect(typeof config.environmentId).toBe('string'); + expect(config.environmentId.trim().length).toBeGreaterThan(0); + }); + + it('eip6963.rdns matches the io.sei.* pattern', () => { + expect(typeof config.eip6963.rdns).toBe('string'); + expect(config.eip6963.rdns).toMatch(/^io\.sei\./); + }); + + it('walletIcon is a non-empty string', () => { + expect(typeof config.walletIcon).toBe('string'); + expect((config.walletIcon as string).trim().length).toBeGreaterThan(0); + }); + + it('walletIcon is a valid data URI', () => { + expect(config.walletIcon as string).toMatch(/^data:/); + }); +});