From 182e9bd3f5aeb4b4f9ecb16110d16002bf27283e Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Wed, 19 Aug 2026 00:44:33 +0200 Subject: [PATCH 1/9] test: re-land SDK unit coverage on remaining packages (PLT-347) Port the still-applicable tests from closed PR #313 onto the current package set, skipping ledger/gas/IBC which are gone. Co-authored-by: Cursor --- packages/create-sei/src/templates.test.ts | 65 ++++++++++++++++ .../src/precompiles/__tests__/abis.spec.ts | 77 +++++++++++++++++++ .../precompiles/__tests__/addresses.spec.ts | 52 +++++++++++++ .../src/networks/__tests__/index.spec.ts | 16 ++++ .../src/tokens/__tests__/index.spec.ts | 28 +++++++ .../src/lib/__tests__/config.spec.ts | 32 ++++++++ 6 files changed, 270 insertions(+) create mode 100644 packages/create-sei/src/templates.test.ts create mode 100644 packages/precompiles/src/precompiles/__tests__/abis.spec.ts create mode 100644 packages/precompiles/src/precompiles/__tests__/addresses.spec.ts create mode 100644 packages/sei-global-wallet/src/lib/__tests__/config.spec.ts diff --git a/packages/create-sei/src/templates.test.ts b/packages/create-sei/src/templates.test.ts new file mode 100644 index 00000000..69a7c061 --- /dev/null +++ b/packages/create-sei/src/templates.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'bun:test'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +const PACKAGE_ROOT = path.resolve(import.meta.dir, '..'); +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/precompiles/src/precompiles/__tests__/abis.spec.ts b/packages/precompiles/src/precompiles/__tests__/abis.spec.ts new file mode 100644 index 00000000..799bed74 --- /dev/null +++ b/packages/precompiles/src/precompiles/__tests__/abis.spec.ts @@ -0,0 +1,77 @@ +import { + ADDRESS_PRECOMPILE_ABI, + BANK_PRECOMPILE_ABI, + DISTRIBUTION_PRECOMPILE_ABI, + GOVERNANCE_PRECOMPILE_ABI, + JSON_PRECOMPILE_ABI, + P256_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', 'submitProposal', 'voteWeighted']], + ['JSON', JSON_PRECOMPILE_ABI, ['extractAsBytes', 'extractAsBytesList', 'extractAsUint256', 'extractAsBytesFromArray']], + ['P256', P256_PRECOMPILE_ABI, ['verify']], + ['POINTER', POINTER_PRECOMPILE_ABI, ['addCW20Pointer', 'addCW721Pointer', 'addCW1155Pointer', 'addNativePointer']], + ['POINTERVIEW', POINTERVIEW_PRECOMPILE_ABI, ['getCW20Pointer', 'getCW721Pointer', 'getCW1155Pointer', 'getNativePointer']], + ['SOLO', SOLO_PRECOMPILE_ABI, ['claim', 'claimSpecific']], + ['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][] = PRECOMPILE_ABIS.map(([name, abi]) => [name, 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 00000000..cbc0b717 --- /dev/null +++ b/packages/precompiles/src/precompiles/__tests__/addresses.spec.ts @@ -0,0 +1,52 @@ +import { + ADDRESS_PRECOMPILE_ADDRESS, + BANK_PRECOMPILE_ADDRESS, + DISTRIBUTION_PRECOMPILE_ADDRESS, + GOVERNANCE_PRECOMPILE_ADDRESS, + JSON_PRECOMPILE_ADDRESS, + P256_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], + ['JSON', JSON_PRECOMPILE_ADDRESS], + ['P256', P256_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/networks/__tests__/index.spec.ts b/packages/registry/src/networks/__tests__/index.spec.ts index cc32d9b1..6163e473 100644 --- a/packages/registry/src/networks/__tests__/index.spec.ts +++ b/packages/registry/src/networks/__tests__/index.spec.ts @@ -40,4 +40,20 @@ describe('Networks configuration', () => { expect(testnet.evm_ws?.some(({ provider, url }) => provider === 'dRPC' && url === 'wss://sei-testnet.drpc.org')).toBeTrue(); expect(testnet.explorers?.some(({ name }) => name === 'Seiscan')).toBeTrue(); }); + + it('should have RPC URLs starting with https:// or wss://', () => { + for (const networkConfig of Object.values(NETWORKS)) { + for (const endpoint of networkConfig.rpc) { + expect(endpoint.url.startsWith('https://') || endpoint.url.startsWith('wss://')).toBe(true); + } + } + }); + + 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 2e1f9baf..e6a580ed 100644 --- a/packages/registry/src/tokens/__tests__/index.spec.ts +++ b/packages/registry/src/tokens/__tests__/index.spec.ts @@ -56,3 +56,31 @@ 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 assets of Object.values(TOKEN_LIST)) { + for (const asset of assets) { + if (asset.images?.png) { + expect(asset.images.png).toMatch(/^https:\/\//); + } + if (asset.images?.svg) { + expect(asset.images.svg).toMatch(/^https:\/\//); + } + } + } + }); + + 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 00000000..a2921a3f --- /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:/); + }); +}); From dcd5ce6f0723433a180b905940dd7ef80976ff01 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Wed, 19 Aug 2026 00:48:36 +0200 Subject: [PATCH 2/9] test: satisfy ABI stateMutability typecheck (PLT-347) Co-authored-by: Cursor --- packages/precompiles/src/precompiles/__tests__/abis.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/precompiles/src/precompiles/__tests__/abis.spec.ts b/packages/precompiles/src/precompiles/__tests__/abis.spec.ts index 799bed74..fcda75d7 100644 --- a/packages/precompiles/src/precompiles/__tests__/abis.spec.ts +++ b/packages/precompiles/src/precompiles/__tests__/abis.spec.ts @@ -55,7 +55,7 @@ describe('Precompile ABIs — function entry structure', () => { 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); + expect(['view', 'nonpayable', 'payable', 'pure']).toContain(fn.stateMutability ?? ''); } }); }); From a558505c5b4867298da524d0a3185c37421ba90d Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Wed, 19 Aug 2026 01:08:29 +0200 Subject: [PATCH 3/9] test(mcp-server): cover registerEVMResources handlers (PLT-347) Exercise all 19 MCP resource handlers, including error and non-Error paths, so the remaining coverage hole in resources.ts is closed. Co-authored-by: Cursor --- .../src/tests/core/resources.test.ts | 371 +++++++++++++++++- 1 file changed, 369 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/src/tests/core/resources.test.ts b/packages/mcp-server/src/tests/core/resources.test.ts index 31551b16..e7875695 100644 --- a/packages/mcp-server/src/tests/core/resources.test.ts +++ b/packages/mcp-server/src/tests/core/resources.test.ts @@ -1,5 +1,64 @@ -import { describe, expect, it } from 'bun:test'; -import { parseBlockNumber } from '../../core/resources.js'; +import { beforeEach, describe, expect, it, jest } from 'bun:test'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Address } from 'viem'; +import * as chains from '../../core/chains.js'; +import { parseBlockNumber, registerEVMResources } from '../../core/resources.js'; +import * as services from '../../core/services/index.js'; + +type ResourceResult = { contents: Array<{ uri: string; text: string }> }; +type ResourceHandler = (uri: { href: string }, params?: Record) => Promise; + +const spyFunctions = (mod: object) => { + for (const [key, value] of Object.entries(mod)) { + if (typeof value === 'function') { + (jest.spyOn(mod as Record, key) as jest.Mock).mockImplementation(() => undefined); + } + } +}; + +spyFunctions(chains); +spyFunctions(services); + +const resetSpiedFunctions = (mod: object) => { + for (const value of Object.values(mod)) { + if (typeof value === 'function') { + const mock = value as jest.Mock; + mock.mockReset(); + mock.mockImplementation(() => undefined); + } + } +}; + +const { getRpcUrl, getSupportedNetworks } = chains; + +const ADDRESS = '0x1234567890123456789012345678901234567890' as Address; +const TOKEN = '0x0987654321098765432109876543210987654321' as Address; +const TX_HASH = '0xabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabca'; +const BLOCK_HASH = '0xdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefd'; +const FAT_PARAMS = { + network: 'sei-testnet', + address: ADDRESS, + tokenAddress: TOKEN, + blockNumber: '123', + blockHash: BLOCK_HASH, + txHash: TX_HASH, + tokenId: '7' +}; + +function createMockResourceServer(): { server: McpServer; registered: Map } { + const registered = new Map(); + const server = { + resource: jest.fn((name: string, _template: unknown, handler: ResourceHandler) => { + registered.set(name, handler); + }) + } as unknown as McpServer; + + return { server, registered }; +} + +function textOf(result: ResourceResult): string { + return result.contents[0].text; +} describe('parseBlockNumber', () => { it('accepts decimal and hexadecimal block numbers', () => { @@ -11,3 +70,311 @@ describe('parseBlockNumber', () => { expect(() => parseBlockNumber(value)).toThrow(`Invalid block number: ${value}`); }); }); + +describe('registerEVMResources', () => { + let registered: Map; + + const invoke = async (name: string, params: Record = FAT_PARAMS): Promise => { + const handler = registered.get(name); + if (!handler) { + throw new Error(`Resource ${name} was not registered`); + } + return handler({ href: `evm://test/${name}` }, params); + }; + + beforeEach(() => { + resetSpiedFunctions(chains); + resetSpiedFunctions(services); + + (getRpcUrl as jest.Mock).mockReturnValue('https://rpc.sei.io'); + (getSupportedNetworks as jest.Mock).mockReturnValue(['sei', 'sei-testnet']); + (services.getChainId as jest.Mock).mockResolvedValue(1329); + (services.getBlockNumber as jest.Mock).mockResolvedValue(100n); + (services.getBlockByNumber as jest.Mock).mockResolvedValue({ number: 123 }); + (services.getBlockByHash as jest.Mock).mockResolvedValue({ hash: BLOCK_HASH }); + (services.getLatestBlock as jest.Mock).mockResolvedValue({ number: 999 }); + (services.getBalance as jest.Mock).mockResolvedValue({ wei: 1n, sei: '0.000000000000000001' }); + (services.getERC20Balance as jest.Mock).mockResolvedValue({ + raw: 1000n, + formatted: '1.0', + token: { symbol: 'TEST', decimals: 18 } + }); + (services.getTransaction as jest.Mock).mockResolvedValue({ hash: TX_HASH }); + (services.getERC20TokenInfo as jest.Mock).mockResolvedValue({ + name: 'Test', + symbol: 'TEST', + decimals: 18, + totalSupply: '1000', + formattedTotalSupply: '1000' + }); + (services.getERC721TokenMetadata as jest.Mock).mockResolvedValue({ + name: 'NFT', + symbol: 'NFTS', + tokenURI: 'ipfs://nft' + }); + (services.isNFTOwner as jest.Mock).mockResolvedValue(false); + (services.getERC1155TokenURI as jest.Mock).mockResolvedValue('ipfs://1155'); + (services.getERC1155Balance as jest.Mock).mockResolvedValue(5n); + jest.spyOn(services.helpers, 'formatJson').mockImplementation((data: unknown) => JSON.stringify(data)); + + const mockServer = createMockResourceServer(); + registerEVMResources(mockServer.server); + registered = mockServer.registered; + }); + + it('registers every EVM resource', () => { + expect([...registered.keys()]).toEqual([ + 'chain_info_by_network', + 'sei_chain_info', + 'evm_block_by_number', + 'block_by_hash', + 'evm_latest_block', + 'default_latest_block', + 'evm_address_native_balance', + 'default_sei_balance', + 'erc20_balance', + 'default_erc20_balance', + 'evm_transaction_details', + 'default_transaction_by_hash', + 'supported_networks', + 'erc20_token_details', + 'erc20_token_address_balance', + 'erc721_nft_token_details', + 'erc721_nft_ownership_check', + 'erc1155_token_metadata_uri', + 'erc1155_token_address_balance' + ]); + }); + + it('returns chain info for a named network', async () => { + const result = await invoke('chain_info_by_network'); + expect(JSON.parse(textOf(result))).toEqual({ + network: 'sei-testnet', + chainId: 1329, + blockNumber: '100', + rpcUrl: 'https://rpc.sei.io' + }); + expect(services.getChainId).toHaveBeenCalledWith('sei-testnet'); + }); + + it('returns default chain info for Sei mainnet', async () => { + const result = await invoke('sei_chain_info'); + expect(JSON.parse(textOf(result))).toEqual({ + network: 'sei', + chainId: 1329, + blockNumber: '100', + rpcUrl: 'https://rpc.sei.io' + }); + expect(services.getChainId).toHaveBeenCalledWith('sei'); + }); + + it('returns a block by number', async () => { + const result = await invoke('evm_block_by_number'); + expect(textOf(result)).toBe(JSON.stringify({ number: 123 })); + expect(services.getBlockByNumber).toHaveBeenCalledWith(123, 'sei-testnet'); + }); + + it('returns a block by hash', async () => { + const result = await invoke('block_by_hash'); + expect(textOf(result)).toBe(JSON.stringify({ hash: BLOCK_HASH })); + expect(services.getBlockByHash).toHaveBeenCalledWith(BLOCK_HASH, 'sei-testnet'); + }); + + it('returns the latest block for a named network', async () => { + const result = await invoke('evm_latest_block'); + expect(textOf(result)).toBe(JSON.stringify({ number: 999 })); + expect(services.getLatestBlock).toHaveBeenCalledWith('sei-testnet'); + }); + + it('returns the default latest block', async () => { + const result = await invoke('default_latest_block'); + expect(textOf(result)).toBe(JSON.stringify({ number: 999 })); + expect(services.getLatestBlock).toHaveBeenCalledWith('sei'); + }); + + it('returns a native balance for a named network', async () => { + const result = await invoke('evm_address_native_balance'); + expect(JSON.parse(textOf(result))).toEqual({ + network: 'sei-testnet', + address: ADDRESS, + balance: { wei: '1', ether: '0.000000000000000001' } + }); + }); + + it('returns the default native balance', async () => { + const result = await invoke('default_sei_balance'); + expect(JSON.parse(textOf(result)).network).toBe('sei'); + expect(services.getBalance).toHaveBeenCalledWith(ADDRESS, 'sei'); + }); + + it('returns an ERC20 balance for a named network', async () => { + const result = await invoke('erc20_balance'); + expect(JSON.parse(textOf(result))).toEqual({ + network: 'sei-testnet', + address: ADDRESS, + tokenAddress: TOKEN, + balance: { raw: '1000', formatted: '1.0', decimals: 18 } + }); + }); + + it('returns the default ERC20 balance', async () => { + const result = await invoke('default_erc20_balance'); + expect(JSON.parse(textOf(result)).network).toBe('sei'); + expect(services.getERC20Balance).toHaveBeenCalledWith(TOKEN, ADDRESS, 'sei'); + }); + + it('returns a transaction for a named network', async () => { + const result = await invoke('evm_transaction_details'); + expect(textOf(result)).toBe(JSON.stringify({ hash: TX_HASH })); + expect(services.getTransaction).toHaveBeenCalledWith(TX_HASH, 'sei-testnet'); + }); + + it('returns the default transaction', async () => { + const result = await invoke('default_transaction_by_hash'); + expect(services.getTransaction).toHaveBeenCalledWith(TX_HASH, 'sei'); + expect(JSON.parse(textOf(result))).toEqual({ hash: TX_HASH }); + }); + + it('lists supported networks', async () => { + const result = await invoke('supported_networks'); + expect(JSON.parse(textOf(result))).toEqual({ supportedNetworks: ['sei', 'sei-testnet'] }); + }); + + it('returns ERC20 token details', async () => { + const result = await invoke('erc20_token_details'); + expect(JSON.parse(textOf(result))).toEqual({ + address: TOKEN, + network: 'sei-testnet', + name: 'Test', + symbol: 'TEST', + decimals: 18, + totalSupply: '1000', + formattedTotalSupply: '1000' + }); + }); + + it('returns an ERC20 balanceOf resource', async () => { + const result = await invoke('erc20_token_address_balance'); + expect(JSON.parse(textOf(result))).toEqual({ + tokenAddress: TOKEN, + owner: ADDRESS, + network: 'sei-testnet', + raw: '1000', + formatted: '1.0', + symbol: 'TEST', + decimals: 18 + }); + }); + + it('returns NFT metadata with unknown owner when the caller is not the owner', async () => { + const result = await invoke('erc721_nft_token_details'); + expect(JSON.parse(textOf(result))).toEqual({ + contract: TOKEN, + tokenId: '7', + network: 'sei-testnet', + name: 'NFT', + symbol: 'NFTS', + tokenURI: 'ipfs://nft', + owner: 'Unknown' + }); + }); + + it('returns NFT metadata with the caller as owner when isNFTOwner is true', async () => { + (services.isNFTOwner as jest.Mock).mockResolvedValue(true); + const result = await invoke('erc721_nft_token_details'); + expect(JSON.parse(textOf(result)).owner).toBe(ADDRESS); + }); + + it('keeps NFT owner as Unknown when the ownership check fails', async () => { + (services.isNFTOwner as jest.Mock).mockRejectedValue(new Error('owner lookup failed')); + const result = await invoke('erc721_nft_token_details'); + expect(JSON.parse(textOf(result)).owner).toBe('Unknown'); + }); + + it('checks NFT ownership', async () => { + (services.isNFTOwner as jest.Mock).mockResolvedValue(true); + const result = await invoke('erc721_nft_ownership_check'); + expect(JSON.parse(textOf(result))).toEqual({ + contract: TOKEN, + tokenId: '7', + owner: ADDRESS, + network: 'sei-testnet', + isOwner: true + }); + }); + + it('returns an ERC1155 token URI', async () => { + const result = await invoke('erc1155_token_metadata_uri'); + expect(JSON.parse(textOf(result))).toEqual({ + contract: TOKEN, + tokenId: '7', + network: 'sei-testnet', + uri: 'ipfs://1155' + }); + }); + + it('returns an ERC1155 balance', async () => { + const result = await invoke('erc1155_token_address_balance'); + expect(JSON.parse(textOf(result))).toEqual({ + contract: TOKEN, + tokenId: '7', + owner: ADDRESS, + network: 'sei-testnet', + balance: '5' + }); + }); + + const errorCases: [string, unknown, string, 'async' | 'sync'][] = [ + ['chain_info_by_network', services.getChainId, 'Error fetching chain info', 'async'], + ['sei_chain_info', services.getChainId, 'Error fetching chain info', 'async'], + ['evm_block_by_number', services.getBlockByNumber, 'Error fetching block', 'async'], + ['block_by_hash', services.getBlockByHash, 'Error fetching block with hash', 'async'], + ['evm_latest_block', services.getLatestBlock, 'Error fetching latest block', 'async'], + ['default_latest_block', services.getLatestBlock, 'Error fetching latest block', 'async'], + ['evm_address_native_balance', services.getBalance, 'Error fetching Sei balance', 'async'], + ['default_sei_balance', services.getBalance, 'Error fetching Sei balance', 'async'], + ['erc20_balance', services.getERC20Balance, 'Error fetching ERC20 balance', 'async'], + ['default_erc20_balance', services.getERC20Balance, 'Error fetching ERC20 balance', 'async'], + ['evm_transaction_details', services.getTransaction, 'Error fetching transaction', 'async'], + ['default_transaction_by_hash', services.getTransaction, 'Error fetching transaction', 'async'], + ['supported_networks', getSupportedNetworks, 'Error fetching supported networks', 'sync'], + ['erc20_token_details', services.getERC20TokenInfo, 'Error fetching ERC20 token info', 'async'], + ['erc20_token_address_balance', services.getERC20Balance, 'Error fetching ERC20 token balance', 'async'], + ['erc721_nft_token_details', services.getERC721TokenMetadata, 'Error fetching NFT info', 'async'], + ['erc721_nft_ownership_check', services.isNFTOwner, 'Error checking NFT ownership', 'async'], + ['erc1155_token_metadata_uri', services.getERC1155TokenURI, 'Error fetching ERC1155 token URI', 'async'], + ['erc1155_token_address_balance', services.getERC1155Balance, 'Error fetching ERC1155 token balance', 'async'] + ]; + + it.each(errorCases)('%s returns the Error message', async (name, fn, prefix, kind) => { + if (kind === 'sync') { + (fn as jest.Mock).mockImplementation(() => { + throw new Error('fail'); + }); + } else { + (fn as jest.Mock).mockRejectedValue(new Error('fail')); + } + + const result = await invoke(name); + expect(textOf(result)).toBe(`${prefix}: fail`); + expect(result.contents[0].uri).toBe(`evm://test/${name}`); + }); + + it.each(errorCases)('%s stringifies a non-Error rejection', async (name, fn, prefix, kind) => { + if (kind === 'sync') { + (fn as jest.Mock).mockImplementation(() => { + throw 'nope'; + }); + } else { + (fn as jest.Mock).mockRejectedValue('nope'); + } + + const result = await invoke(name); + expect(textOf(result)).toBe(`${prefix}: nope`); + }); + + it('surfaces invalid block numbers through the block-by-number error path', async () => { + const result = await invoke('evm_block_by_number', { ...FAT_PARAMS, blockNumber: 'abc' }); + expect(textOf(result)).toBe('Error fetching block: Invalid block number: abc'); + }); +}); From 5a22e67b48ae1f9d9e202cca1099f5b02758ef8a Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Thu, 20 Aug 2026 11:01:04 +0200 Subject: [PATCH 4/9] test: address coverage review feedback (PLT-347) Co-authored-by: Cursor --- packages/create-sei/src/templates.test.ts | 65 -------- .../src/tests/core/resources.test.ts | 154 +++++++++--------- .../src/precompiles/__tests__/abis.spec.ts | 77 --------- .../precompiles/__tests__/addresses.spec.ts | 52 ------ .../src/networks/__tests__/index.spec.ts | 16 -- .../src/tokens/__tests__/index.spec.ts | 28 ---- .../src/lib/__tests__/config.spec.ts | 11 +- 7 files changed, 78 insertions(+), 325 deletions(-) delete mode 100644 packages/create-sei/src/templates.test.ts delete mode 100644 packages/precompiles/src/precompiles/__tests__/abis.spec.ts delete mode 100644 packages/precompiles/src/precompiles/__tests__/addresses.spec.ts diff --git a/packages/create-sei/src/templates.test.ts b/packages/create-sei/src/templates.test.ts deleted file mode 100644 index 69a7c061..00000000 --- a/packages/create-sei/src/templates.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'bun:test'; -import { promises as fs } from 'node:fs'; -import path from 'node:path'; - -const PACKAGE_ROOT = path.resolve(import.meta.dir, '..'); -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/mcp-server/src/tests/core/resources.test.ts b/packages/mcp-server/src/tests/core/resources.test.ts index e7875695..91eb4068 100644 --- a/packages/mcp-server/src/tests/core/resources.test.ts +++ b/packages/mcp-server/src/tests/core/resources.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, jest } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { Address } from 'viem'; import * as chains from '../../core/chains.js'; @@ -16,34 +16,33 @@ const spyFunctions = (mod: object) => { } }; -spyFunctions(chains); -spyFunctions(services); - -const resetSpiedFunctions = (mod: object) => { - for (const value of Object.values(mod)) { - if (typeof value === 'function') { - const mock = value as jest.Mock; - mock.mockReset(); - mock.mockImplementation(() => undefined); - } - } -}; - -const { getRpcUrl, getSupportedNetworks } = chains; - const ADDRESS = '0x1234567890123456789012345678901234567890' as Address; const TOKEN = '0x0987654321098765432109876543210987654321' as Address; const TX_HASH = '0xabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabca'; const BLOCK_HASH = '0xdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefd'; -const FAT_PARAMS = { - network: 'sei-testnet', - address: ADDRESS, - tokenAddress: TOKEN, - blockNumber: '123', - blockHash: BLOCK_HASH, - txHash: TX_HASH, - tokenId: '7' -}; +const NETWORK = 'sei-testnet'; +const RESOURCE_PARAMS = { + chain_info_by_network: { network: NETWORK }, + sei_chain_info: {}, + evm_block_by_number: { network: NETWORK, blockNumber: '123' }, + block_by_hash: { network: NETWORK, blockHash: BLOCK_HASH }, + evm_latest_block: { network: NETWORK }, + default_latest_block: {}, + evm_address_native_balance: { network: NETWORK, address: ADDRESS }, + default_sei_balance: { address: ADDRESS }, + erc20_balance: { network: NETWORK, address: ADDRESS, tokenAddress: TOKEN }, + default_erc20_balance: { address: ADDRESS, tokenAddress: TOKEN }, + evm_transaction_details: { network: NETWORK, txHash: TX_HASH }, + default_transaction_by_hash: { txHash: TX_HASH }, + supported_networks: {}, + erc20_token_details: { network: NETWORK, tokenAddress: TOKEN }, + erc20_token_address_balance: { network: NETWORK, tokenAddress: TOKEN, address: ADDRESS }, + erc721_nft_token_details: { network: NETWORK, tokenAddress: TOKEN, tokenId: '7' }, + erc721_nft_ownership_check: { network: NETWORK, tokenAddress: TOKEN, tokenId: '7', address: ADDRESS }, + erc1155_token_metadata_uri: { network: NETWORK, tokenAddress: TOKEN, tokenId: '7' }, + erc1155_token_address_balance: { network: NETWORK, tokenAddress: TOKEN, tokenId: '7', address: ADDRESS } +} satisfies Record>; +type ResourceName = keyof typeof RESOURCE_PARAMS; function createMockResourceServer(): { server: McpServer; registered: Map } { const registered = new Map(); @@ -74,7 +73,7 @@ describe('parseBlockNumber', () => { describe('registerEVMResources', () => { let registered: Map; - const invoke = async (name: string, params: Record = FAT_PARAMS): Promise => { + const invoke = async (name: ResourceName, params: Record = RESOURCE_PARAMS[name]): Promise => { const handler = registered.get(name); if (!handler) { throw new Error(`Resource ${name} was not registered`); @@ -83,23 +82,23 @@ describe('registerEVMResources', () => { }; beforeEach(() => { - resetSpiedFunctions(chains); - resetSpiedFunctions(services); + spyFunctions(chains); + spyFunctions(services); - (getRpcUrl as jest.Mock).mockReturnValue('https://rpc.sei.io'); - (getSupportedNetworks as jest.Mock).mockReturnValue(['sei', 'sei-testnet']); + (chains.getRpcUrl as jest.Mock).mockReturnValue('https://rpc.sei.io'); + (chains.getSupportedNetworks as jest.Mock).mockReturnValue(['sei', 'sei-testnet']); (services.getChainId as jest.Mock).mockResolvedValue(1329); (services.getBlockNumber as jest.Mock).mockResolvedValue(100n); - (services.getBlockByNumber as jest.Mock).mockResolvedValue({ number: 123 }); - (services.getBlockByHash as jest.Mock).mockResolvedValue({ hash: BLOCK_HASH }); - (services.getLatestBlock as jest.Mock).mockResolvedValue({ number: 999 }); + (services.getBlockByNumber as jest.Mock).mockResolvedValue({ number: 123n }); + (services.getBlockByHash as jest.Mock).mockResolvedValue({ hash: BLOCK_HASH, number: 123n }); + (services.getLatestBlock as jest.Mock).mockResolvedValue({ number: 999n }); (services.getBalance as jest.Mock).mockResolvedValue({ wei: 1n, sei: '0.000000000000000001' }); (services.getERC20Balance as jest.Mock).mockResolvedValue({ raw: 1000n, formatted: '1.0', token: { symbol: 'TEST', decimals: 18 } }); - (services.getTransaction as jest.Mock).mockResolvedValue({ hash: TX_HASH }); + (services.getTransaction as jest.Mock).mockResolvedValue({ hash: TX_HASH, blockNumber: 123n }); (services.getERC20TokenInfo as jest.Mock).mockResolvedValue({ name: 'Test', symbol: 'TEST', @@ -115,13 +114,16 @@ describe('registerEVMResources', () => { (services.isNFTOwner as jest.Mock).mockResolvedValue(false); (services.getERC1155TokenURI as jest.Mock).mockResolvedValue('ipfs://1155'); (services.getERC1155Balance as jest.Mock).mockResolvedValue(5n); - jest.spyOn(services.helpers, 'formatJson').mockImplementation((data: unknown) => JSON.stringify(data)); const mockServer = createMockResourceServer(); registerEVMResources(mockServer.server); registered = mockServer.registered; }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('registers every EVM resource', () => { expect([...registered.keys()]).toEqual([ 'chain_info_by_network', @@ -170,25 +172,25 @@ describe('registerEVMResources', () => { it('returns a block by number', async () => { const result = await invoke('evm_block_by_number'); - expect(textOf(result)).toBe(JSON.stringify({ number: 123 })); + expect(JSON.parse(textOf(result))).toEqual({ number: '123' }); expect(services.getBlockByNumber).toHaveBeenCalledWith(123, 'sei-testnet'); }); it('returns a block by hash', async () => { const result = await invoke('block_by_hash'); - expect(textOf(result)).toBe(JSON.stringify({ hash: BLOCK_HASH })); + expect(JSON.parse(textOf(result))).toEqual({ hash: BLOCK_HASH, number: '123' }); expect(services.getBlockByHash).toHaveBeenCalledWith(BLOCK_HASH, 'sei-testnet'); }); it('returns the latest block for a named network', async () => { const result = await invoke('evm_latest_block'); - expect(textOf(result)).toBe(JSON.stringify({ number: 999 })); + expect(JSON.parse(textOf(result))).toEqual({ number: '999' }); expect(services.getLatestBlock).toHaveBeenCalledWith('sei-testnet'); }); it('returns the default latest block', async () => { const result = await invoke('default_latest_block'); - expect(textOf(result)).toBe(JSON.stringify({ number: 999 })); + expect(JSON.parse(textOf(result))).toEqual({ number: '999' }); expect(services.getLatestBlock).toHaveBeenCalledWith('sei'); }); @@ -225,14 +227,14 @@ describe('registerEVMResources', () => { it('returns a transaction for a named network', async () => { const result = await invoke('evm_transaction_details'); - expect(textOf(result)).toBe(JSON.stringify({ hash: TX_HASH })); + expect(JSON.parse(textOf(result))).toEqual({ hash: TX_HASH, blockNumber: '123' }); expect(services.getTransaction).toHaveBeenCalledWith(TX_HASH, 'sei-testnet'); }); it('returns the default transaction', async () => { const result = await invoke('default_transaction_by_hash'); expect(services.getTransaction).toHaveBeenCalledWith(TX_HASH, 'sei'); - expect(JSON.parse(textOf(result))).toEqual({ hash: TX_HASH }); + expect(JSON.parse(textOf(result))).toEqual({ hash: TX_HASH, blockNumber: '123' }); }); it('lists supported networks', async () => { @@ -266,7 +268,7 @@ describe('registerEVMResources', () => { }); }); - it('returns NFT metadata with unknown owner when the caller is not the owner', async () => { + it('returns NFT metadata with unknown owner because the resource template has no address parameter', async () => { const result = await invoke('erc721_nft_token_details'); expect(JSON.parse(textOf(result))).toEqual({ contract: TOKEN, @@ -279,18 +281,6 @@ describe('registerEVMResources', () => { }); }); - it('returns NFT metadata with the caller as owner when isNFTOwner is true', async () => { - (services.isNFTOwner as jest.Mock).mockResolvedValue(true); - const result = await invoke('erc721_nft_token_details'); - expect(JSON.parse(textOf(result)).owner).toBe(ADDRESS); - }); - - it('keeps NFT owner as Unknown when the ownership check fails', async () => { - (services.isNFTOwner as jest.Mock).mockRejectedValue(new Error('owner lookup failed')); - const result = await invoke('erc721_nft_token_details'); - expect(JSON.parse(textOf(result)).owner).toBe('Unknown'); - }); - it('checks NFT ownership', async () => { (services.isNFTOwner as jest.Mock).mockResolvedValue(true); const result = await invoke('erc721_nft_ownership_check'); @@ -324,35 +314,36 @@ describe('registerEVMResources', () => { }); }); - const errorCases: [string, unknown, string, 'async' | 'sync'][] = [ - ['chain_info_by_network', services.getChainId, 'Error fetching chain info', 'async'], - ['sei_chain_info', services.getChainId, 'Error fetching chain info', 'async'], - ['evm_block_by_number', services.getBlockByNumber, 'Error fetching block', 'async'], - ['block_by_hash', services.getBlockByHash, 'Error fetching block with hash', 'async'], - ['evm_latest_block', services.getLatestBlock, 'Error fetching latest block', 'async'], - ['default_latest_block', services.getLatestBlock, 'Error fetching latest block', 'async'], - ['evm_address_native_balance', services.getBalance, 'Error fetching Sei balance', 'async'], - ['default_sei_balance', services.getBalance, 'Error fetching Sei balance', 'async'], - ['erc20_balance', services.getERC20Balance, 'Error fetching ERC20 balance', 'async'], - ['default_erc20_balance', services.getERC20Balance, 'Error fetching ERC20 balance', 'async'], - ['evm_transaction_details', services.getTransaction, 'Error fetching transaction', 'async'], - ['default_transaction_by_hash', services.getTransaction, 'Error fetching transaction', 'async'], - ['supported_networks', getSupportedNetworks, 'Error fetching supported networks', 'sync'], - ['erc20_token_details', services.getERC20TokenInfo, 'Error fetching ERC20 token info', 'async'], - ['erc20_token_address_balance', services.getERC20Balance, 'Error fetching ERC20 token balance', 'async'], - ['erc721_nft_token_details', services.getERC721TokenMetadata, 'Error fetching NFT info', 'async'], - ['erc721_nft_ownership_check', services.isNFTOwner, 'Error checking NFT ownership', 'async'], - ['erc1155_token_metadata_uri', services.getERC1155TokenURI, 'Error fetching ERC1155 token URI', 'async'], - ['erc1155_token_address_balance', services.getERC1155Balance, 'Error fetching ERC1155 token balance', 'async'] + const errorCases: [ResourceName, () => jest.Mock, string, 'async' | 'sync'][] = [ + ['chain_info_by_network', () => services.getChainId as jest.Mock, 'Error fetching chain info', 'async'], + ['sei_chain_info', () => services.getChainId as jest.Mock, 'Error fetching chain info', 'async'], + ['evm_block_by_number', () => services.getBlockByNumber as jest.Mock, 'Error fetching block', 'async'], + ['block_by_hash', () => services.getBlockByHash as jest.Mock, 'Error fetching block with hash', 'async'], + ['evm_latest_block', () => services.getLatestBlock as jest.Mock, 'Error fetching latest block', 'async'], + ['default_latest_block', () => services.getLatestBlock as jest.Mock, 'Error fetching latest block', 'async'], + ['evm_address_native_balance', () => services.getBalance as jest.Mock, 'Error fetching Sei balance', 'async'], + ['default_sei_balance', () => services.getBalance as jest.Mock, 'Error fetching Sei balance', 'async'], + ['erc20_balance', () => services.getERC20Balance as jest.Mock, 'Error fetching ERC20 balance', 'async'], + ['default_erc20_balance', () => services.getERC20Balance as jest.Mock, 'Error fetching ERC20 balance', 'async'], + ['evm_transaction_details', () => services.getTransaction as jest.Mock, 'Error fetching transaction', 'async'], + ['default_transaction_by_hash', () => services.getTransaction as jest.Mock, 'Error fetching transaction', 'async'], + ['supported_networks', () => chains.getSupportedNetworks as jest.Mock, 'Error fetching supported networks', 'sync'], + ['erc20_token_details', () => services.getERC20TokenInfo as jest.Mock, 'Error fetching ERC20 token info', 'async'], + ['erc20_token_address_balance', () => services.getERC20Balance as jest.Mock, 'Error fetching ERC20 token balance', 'async'], + ['erc721_nft_token_details', () => services.getERC721TokenMetadata as jest.Mock, 'Error fetching NFT info', 'async'], + ['erc721_nft_ownership_check', () => services.isNFTOwner as jest.Mock, 'Error checking NFT ownership', 'async'], + ['erc1155_token_metadata_uri', () => services.getERC1155TokenURI as jest.Mock, 'Error fetching ERC1155 token URI', 'async'], + ['erc1155_token_address_balance', () => services.getERC1155Balance as jest.Mock, 'Error fetching ERC1155 token balance', 'async'] ]; - it.each(errorCases)('%s returns the Error message', async (name, fn, prefix, kind) => { + it.each(errorCases)('%s returns the Error message', async (name, getMock, prefix, kind) => { + const fn = getMock(); if (kind === 'sync') { - (fn as jest.Mock).mockImplementation(() => { + fn.mockImplementation(() => { throw new Error('fail'); }); } else { - (fn as jest.Mock).mockRejectedValue(new Error('fail')); + fn.mockRejectedValue(new Error('fail')); } const result = await invoke(name); @@ -360,13 +351,14 @@ describe('registerEVMResources', () => { expect(result.contents[0].uri).toBe(`evm://test/${name}`); }); - it.each(errorCases)('%s stringifies a non-Error rejection', async (name, fn, prefix, kind) => { + it.each(errorCases)('%s stringifies a non-Error rejection', async (name, getMock, prefix, kind) => { + const fn = getMock(); if (kind === 'sync') { - (fn as jest.Mock).mockImplementation(() => { + fn.mockImplementation(() => { throw 'nope'; }); } else { - (fn as jest.Mock).mockRejectedValue('nope'); + fn.mockRejectedValue('nope'); } const result = await invoke(name); @@ -374,7 +366,7 @@ describe('registerEVMResources', () => { }); it('surfaces invalid block numbers through the block-by-number error path', async () => { - const result = await invoke('evm_block_by_number', { ...FAT_PARAMS, blockNumber: 'abc' }); + const result = await invoke('evm_block_by_number', { ...RESOURCE_PARAMS.evm_block_by_number, blockNumber: 'abc' }); expect(textOf(result)).toBe('Error fetching block: Invalid block number: abc'); }); }); diff --git a/packages/precompiles/src/precompiles/__tests__/abis.spec.ts b/packages/precompiles/src/precompiles/__tests__/abis.spec.ts deleted file mode 100644 index fcda75d7..00000000 --- a/packages/precompiles/src/precompiles/__tests__/abis.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - ADDRESS_PRECOMPILE_ABI, - BANK_PRECOMPILE_ABI, - DISTRIBUTION_PRECOMPILE_ABI, - GOVERNANCE_PRECOMPILE_ABI, - JSON_PRECOMPILE_ABI, - P256_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', 'submitProposal', 'voteWeighted']], - ['JSON', JSON_PRECOMPILE_ABI, ['extractAsBytes', 'extractAsBytesList', 'extractAsUint256', 'extractAsBytesFromArray']], - ['P256', P256_PRECOMPILE_ABI, ['verify']], - ['POINTER', POINTER_PRECOMPILE_ABI, ['addCW20Pointer', 'addCW721Pointer', 'addCW1155Pointer', 'addNativePointer']], - ['POINTERVIEW', POINTERVIEW_PRECOMPILE_ABI, ['getCW20Pointer', 'getCW721Pointer', 'getCW1155Pointer', 'getNativePointer']], - ['SOLO', SOLO_PRECOMPILE_ABI, ['claim', 'claimSpecific']], - ['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][] = PRECOMPILE_ABIS.map(([name, abi]) => [name, 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 deleted file mode 100644 index cbc0b717..00000000 --- a/packages/precompiles/src/precompiles/__tests__/addresses.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - ADDRESS_PRECOMPILE_ADDRESS, - BANK_PRECOMPILE_ADDRESS, - DISTRIBUTION_PRECOMPILE_ADDRESS, - GOVERNANCE_PRECOMPILE_ADDRESS, - JSON_PRECOMPILE_ADDRESS, - P256_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], - ['JSON', JSON_PRECOMPILE_ADDRESS], - ['P256', P256_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/networks/__tests__/index.spec.ts b/packages/registry/src/networks/__tests__/index.spec.ts index 6163e473..cc32d9b1 100644 --- a/packages/registry/src/networks/__tests__/index.spec.ts +++ b/packages/registry/src/networks/__tests__/index.spec.ts @@ -40,20 +40,4 @@ describe('Networks configuration', () => { expect(testnet.evm_ws?.some(({ provider, url }) => provider === 'dRPC' && url === 'wss://sei-testnet.drpc.org')).toBeTrue(); expect(testnet.explorers?.some(({ name }) => name === 'Seiscan')).toBeTrue(); }); - - it('should have RPC URLs starting with https:// or wss://', () => { - for (const networkConfig of Object.values(NETWORKS)) { - for (const endpoint of networkConfig.rpc) { - expect(endpoint.url.startsWith('https://') || endpoint.url.startsWith('wss://')).toBe(true); - } - } - }); - - 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 e6a580ed..2e1f9baf 100644 --- a/packages/registry/src/tokens/__tests__/index.spec.ts +++ b/packages/registry/src/tokens/__tests__/index.spec.ts @@ -56,31 +56,3 @@ 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 assets of Object.values(TOKEN_LIST)) { - for (const asset of assets) { - if (asset.images?.png) { - expect(asset.images.png).toMatch(/^https:\/\//); - } - if (asset.images?.svg) { - expect(asset.images.svg).toMatch(/^https:\/\//); - } - } - } - }); - - 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 index a2921a3f..c6f6bc50 100644 --- a/packages/sei-global-wallet/src/lib/__tests__/config.spec.ts +++ b/packages/sei-global-wallet/src/lib/__tests__/config.spec.ts @@ -21,12 +21,11 @@ describe('sei-global-wallet config', () => { expect(config.eip6963.rdns).toMatch(/^io\.sei\./); }); - it('walletIcon is a non-empty string', () => { + it('walletIcon is an SVG data URI', () => { 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:/); + if (typeof config.walletIcon !== 'string') { + throw new TypeError('Expected walletIcon to be a string'); + } + expect(config.walletIcon).toMatch(/^data:image\/svg\+xml;base64,.+/); }); }); From 890a1213e088ea66289f2f14f2685d5d50a8a4a2 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Thu, 20 Aug 2026 11:10:26 +0200 Subject: [PATCH 5/9] fix(mcp-server): return current NFT resource owner (PLT-347) Co-authored-by: Cursor --- .changeset/fix-nft-resource-owner.md | 5 + packages/mcp-server/src/core/resources.ts | 6 +- .../mcp-server/src/core/services/balance.ts | 31 ++-- .../src/tests/core/resources.test.ts | 138 ++++++++++-------- .../src/tests/core/services/balance.test.ts | 24 ++- .../src/lib/__tests__/config.spec.ts | 24 +-- 6 files changed, 137 insertions(+), 91 deletions(-) create mode 100644 .changeset/fix-nft-resource-owner.md diff --git a/.changeset/fix-nft-resource-owner.md b/.changeset/fix-nft-resource-owner.md new file mode 100644 index 00000000..708071a6 --- /dev/null +++ b/.changeset/fix-nft-resource-owner.md @@ -0,0 +1,5 @@ +--- +'@sei-js/mcp-server': patch +--- + +Return the current ERC-721 owner from NFT detail resources. diff --git a/packages/mcp-server/src/core/resources.ts b/packages/mcp-server/src/core/resources.ts index c7918e20..42395088 100644 --- a/packages/mcp-server/src/core/resources.ts +++ b/packages/mcp-server/src/core/resources.ts @@ -545,13 +545,9 @@ export function registerEVMResources(server: McpServer) { const nftInfo = await services.getERC721TokenMetadata(tokenAddress, tokenId, network); - // Get owner separately let owner = 'Unknown'; try { - const isOwner = await services.isNFTOwner(tokenAddress, params.address as Address, tokenId, network); - if (isOwner) { - owner = params.address as string; - } + owner = await services.getERC721Owner(tokenAddress, tokenId, network); } catch (_e) { // Owner info not available } diff --git a/packages/mcp-server/src/core/services/balance.ts b/packages/mcp-server/src/core/services/balance.ts index 3ab82404..aa93d751 100644 --- a/packages/mcp-server/src/core/services/balance.ts +++ b/packages/mcp-server/src/core/services/balance.ts @@ -121,6 +121,27 @@ export async function getERC20Balance( }; } +/** + * Get the current owner of a specific NFT + * @param tokenAddress NFT contract address + * @param tokenId Token ID to query + * @param network Network name or chain ID + * @returns Current owner address + */ +export async function getERC721Owner(tokenAddress: string, tokenId: bigint, network = DEFAULT_NETWORK): Promise
{ + const validatedTokenAddress = services.helpers.validateAddress(tokenAddress); + + return (await readContract( + { + address: validatedTokenAddress, + abi: erc721Abi, + functionName: 'ownerOf', + args: [tokenId] + }, + network + )) as Address; +} + /** * Check if an address owns a specific NFT * @param tokenAddress NFT contract address @@ -134,15 +155,7 @@ export async function isNFTOwner(tokenAddress: string, ownerAddress: string, tok const validatedOwnerAddress = services.helpers.validateAddress(ownerAddress); try { - const actualOwner = (await readContract( - { - address: validatedTokenAddress, - abi: erc721Abi, - functionName: 'ownerOf', - args: [tokenId] - }, - network - )) as Address; + const actualOwner = await getERC721Owner(validatedTokenAddress, tokenId, network); return actualOwner.toLowerCase() === validatedOwnerAddress.toLowerCase(); } catch (error: unknown) { diff --git a/packages/mcp-server/src/tests/core/resources.test.ts b/packages/mcp-server/src/tests/core/resources.test.ts index 91eb4068..ec002c3d 100644 --- a/packages/mcp-server/src/tests/core/resources.test.ts +++ b/packages/mcp-server/src/tests/core/resources.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { Address } from 'viem'; import * as chains from '../../core/chains.js'; import { parseBlockNumber, registerEVMResources } from '../../core/resources.js'; @@ -7,6 +7,7 @@ import * as services from '../../core/services/index.js'; type ResourceResult = { contents: Array<{ uri: string; text: string }> }; type ResourceHandler = (uri: { href: string }, params?: Record) => Promise; +type ResourceRegistration = { template: string | ResourceTemplate; handler: ResourceHandler }; const spyFunctions = (mod: object) => { for (const [key, value] of Object.entries(mod)) { @@ -21,34 +22,55 @@ const TOKEN = '0x0987654321098765432109876543210987654321' as Address; const TX_HASH = '0xabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabca'; const BLOCK_HASH = '0xdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefd'; const NETWORK = 'sei-testnet'; -const RESOURCE_PARAMS = { - chain_info_by_network: { network: NETWORK }, - sei_chain_info: {}, - evm_block_by_number: { network: NETWORK, blockNumber: '123' }, - block_by_hash: { network: NETWORK, blockHash: BLOCK_HASH }, - evm_latest_block: { network: NETWORK }, - default_latest_block: {}, - evm_address_native_balance: { network: NETWORK, address: ADDRESS }, - default_sei_balance: { address: ADDRESS }, - erc20_balance: { network: NETWORK, address: ADDRESS, tokenAddress: TOKEN }, - default_erc20_balance: { address: ADDRESS, tokenAddress: TOKEN }, - evm_transaction_details: { network: NETWORK, txHash: TX_HASH }, - default_transaction_by_hash: { txHash: TX_HASH }, - supported_networks: {}, - erc20_token_details: { network: NETWORK, tokenAddress: TOKEN }, - erc20_token_address_balance: { network: NETWORK, tokenAddress: TOKEN, address: ADDRESS }, - erc721_nft_token_details: { network: NETWORK, tokenAddress: TOKEN, tokenId: '7' }, - erc721_nft_ownership_check: { network: NETWORK, tokenAddress: TOKEN, tokenId: '7', address: ADDRESS }, - erc1155_token_metadata_uri: { network: NETWORK, tokenAddress: TOKEN, tokenId: '7' }, - erc1155_token_address_balance: { network: NETWORK, tokenAddress: TOKEN, tokenId: '7', address: ADDRESS } -} satisfies Record>; -type ResourceName = keyof typeof RESOURCE_PARAMS; - -function createMockResourceServer(): { server: McpServer; registered: Map } { - const registered = new Map(); +const RESOURCE_CASES = { + chain_info_by_network: { uri: 'evm://{network}/chain', params: { network: NETWORK } }, + sei_chain_info: { uri: 'evm://chain', params: {} }, + evm_block_by_number: { uri: 'evm://{network}/block/{blockNumber}', params: { network: NETWORK, blockNumber: '123' } }, + block_by_hash: { uri: 'evm://{network}/block/hash/{blockHash}', params: { network: NETWORK, blockHash: BLOCK_HASH } }, + evm_latest_block: { uri: 'evm://{network}/block/latest', params: { network: NETWORK } }, + default_latest_block: { uri: 'evm://block/latest', params: {} }, + evm_address_native_balance: { uri: 'evm://{network}/address/{address}/balance', params: { network: NETWORK, address: ADDRESS } }, + default_sei_balance: { uri: 'evm://address/{address}/sei-balance', params: { address: ADDRESS } }, + erc20_balance: { + uri: 'evm://{network}/address/{address}/token/{tokenAddress}/balance', + params: { network: NETWORK, address: ADDRESS, tokenAddress: TOKEN } + }, + default_erc20_balance: { + uri: 'evm://address/{address}/token/{tokenAddress}/balance', + params: { address: ADDRESS, tokenAddress: TOKEN } + }, + evm_transaction_details: { uri: 'evm://{network}/tx/{txHash}', params: { network: NETWORK, txHash: TX_HASH } }, + default_transaction_by_hash: { uri: 'evm://tx/{txHash}', params: { txHash: TX_HASH } }, + supported_networks: { uri: 'evm://networks', params: {} }, + erc20_token_details: { uri: 'evm://{network}/token/{tokenAddress}', params: { network: NETWORK, tokenAddress: TOKEN } }, + erc20_token_address_balance: { + uri: 'evm://{network}/token/{tokenAddress}/balanceOf/{address}', + params: { network: NETWORK, tokenAddress: TOKEN, address: ADDRESS } + }, + erc721_nft_token_details: { + uri: 'evm://{network}/nft/{tokenAddress}/{tokenId}', + params: { network: NETWORK, tokenAddress: TOKEN, tokenId: '7' } + }, + erc721_nft_ownership_check: { + uri: 'evm://{network}/nft/{tokenAddress}/{tokenId}/isOwnedBy/{address}', + params: { network: NETWORK, tokenAddress: TOKEN, tokenId: '7', address: ADDRESS } + }, + erc1155_token_metadata_uri: { + uri: 'evm://{network}/erc1155/{tokenAddress}/{tokenId}/uri', + params: { network: NETWORK, tokenAddress: TOKEN, tokenId: '7' } + }, + erc1155_token_address_balance: { + uri: 'evm://{network}/erc1155/{tokenAddress}/{tokenId}/balanceOf/{address}', + params: { network: NETWORK, tokenAddress: TOKEN, tokenId: '7', address: ADDRESS } + } +} satisfies Record }>; +type ResourceName = keyof typeof RESOURCE_CASES; + +function createMockResourceServer(): { server: McpServer; registered: Map } { + const registered = new Map(); const server = { - resource: jest.fn((name: string, _template: unknown, handler: ResourceHandler) => { - registered.set(name, handler); + resource: jest.fn((name: string, template: string | ResourceTemplate, handler: ResourceHandler) => { + registered.set(name, { template, handler }); }) } as unknown as McpServer; @@ -59,6 +81,10 @@ function textOf(result: ResourceResult): string { return result.contents[0].text; } +function uriOf(template: string | ResourceTemplate): string { + return typeof template === 'string' ? template : template.uriTemplate.toString(); +} + describe('parseBlockNumber', () => { it('accepts decimal and hexadecimal block numbers', () => { expect(parseBlockNumber('6699')).toBe(6699); @@ -71,14 +97,14 @@ describe('parseBlockNumber', () => { }); describe('registerEVMResources', () => { - let registered: Map; + let registered: Map; - const invoke = async (name: ResourceName, params: Record = RESOURCE_PARAMS[name]): Promise => { - const handler = registered.get(name); - if (!handler) { + const invoke = async (name: ResourceName, params: Record = RESOURCE_CASES[name].params): Promise => { + const registration = registered.get(name); + if (!registration) { throw new Error(`Resource ${name} was not registered`); } - return handler({ href: `evm://test/${name}` }, params); + return registration.handler({ href: `evm://test/${name}` }, params); }; beforeEach(() => { @@ -111,6 +137,7 @@ describe('registerEVMResources', () => { symbol: 'NFTS', tokenURI: 'ipfs://nft' }); + (services.getERC721Owner as jest.Mock).mockResolvedValue(ADDRESS); (services.isNFTOwner as jest.Mock).mockResolvedValue(false); (services.getERC1155TokenURI as jest.Mock).mockResolvedValue('ipfs://1155'); (services.getERC1155Balance as jest.Mock).mockResolvedValue(5n); @@ -125,27 +152,17 @@ describe('registerEVMResources', () => { }); it('registers every EVM resource', () => { - expect([...registered.keys()]).toEqual([ - 'chain_info_by_network', - 'sei_chain_info', - 'evm_block_by_number', - 'block_by_hash', - 'evm_latest_block', - 'default_latest_block', - 'evm_address_native_balance', - 'default_sei_balance', - 'erc20_balance', - 'default_erc20_balance', - 'evm_transaction_details', - 'default_transaction_by_hash', - 'supported_networks', - 'erc20_token_details', - 'erc20_token_address_balance', - 'erc721_nft_token_details', - 'erc721_nft_ownership_check', - 'erc1155_token_metadata_uri', - 'erc1155_token_address_balance' - ]); + expect([...registered.keys()].sort()).toEqual(Object.keys(RESOURCE_CASES).sort()); + }); + + it('registers every EVM resource with its expected URI template', () => { + for (const [name, { uri }] of Object.entries(RESOURCE_CASES)) { + const registration = registered.get(name); + if (!registration) { + throw new Error(`Resource ${name} was not registered`); + } + expect(uriOf(registration.template)).toBe(uri); + } }); it('returns chain info for a named network', async () => { @@ -268,7 +285,7 @@ describe('registerEVMResources', () => { }); }); - it('returns NFT metadata with unknown owner because the resource template has no address parameter', async () => { + it('returns NFT metadata with its current owner', async () => { const result = await invoke('erc721_nft_token_details'); expect(JSON.parse(textOf(result))).toEqual({ contract: TOKEN, @@ -277,8 +294,15 @@ describe('registerEVMResources', () => { name: 'NFT', symbol: 'NFTS', tokenURI: 'ipfs://nft', - owner: 'Unknown' + owner: ADDRESS }); + expect(services.getERC721Owner).toHaveBeenCalledWith(TOKEN, 7n, 'sei-testnet'); + }); + + it('returns NFT metadata with an unknown owner when owner lookup fails', async () => { + (services.getERC721Owner as jest.Mock).mockRejectedValue(new Error('owner lookup failed')); + const result = await invoke('erc721_nft_token_details'); + expect(JSON.parse(textOf(result)).owner).toBe('Unknown'); }); it('checks NFT ownership', async () => { @@ -366,7 +390,7 @@ describe('registerEVMResources', () => { }); it('surfaces invalid block numbers through the block-by-number error path', async () => { - const result = await invoke('evm_block_by_number', { ...RESOURCE_PARAMS.evm_block_by_number, blockNumber: 'abc' }); + const result = await invoke('evm_block_by_number', { ...RESOURCE_CASES.evm_block_by_number.params, blockNumber: 'abc' }); expect(textOf(result)).toBe('Error fetching block: Invalid block number: abc'); }); }); diff --git a/packages/mcp-server/src/tests/core/services/balance.test.ts b/packages/mcp-server/src/tests/core/services/balance.test.ts index 50bf45f4..a1e9e339 100644 --- a/packages/mcp-server/src/tests/core/services/balance.test.ts +++ b/packages/mcp-server/src/tests/core/services/balance.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, jest, test } from 'bun:test'; -import { getBalance, getERC20Balance, getERC721Balance, getERC1155Balance, isNFTOwner } from '../../../core/services'; +import { getBalance, getERC20Balance, getERC721Balance, getERC721Owner, getERC1155Balance, isNFTOwner } from '../../../core/services'; // Create valid test addresses with proper type assertions const VALID_ADDRESS = '0x1234567890123456789012345678901234567890' as `0x${string}`; @@ -93,6 +93,28 @@ describe('Balance Service', () => { }); }); + describe('getERC721Owner', () => { + test('should return the current NFT owner', async () => { + const { readContract } = await import('../../../core/services/contracts.js'); + const { utils } = await import('../../../core/services/utils.js'); + + (readContract as jest.Mock).mockResolvedValue(VALID_OWNER_ADDRESS); + (utils.validateAddress as jest.Mock).mockImplementation((address) => address as `0x${string}`); + + const result = await getERC721Owner(VALID_TOKEN_ADDRESS, 1n); + + expect(result).toBe(VALID_OWNER_ADDRESS); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: VALID_TOKEN_ADDRESS, + functionName: 'ownerOf', + args: [1n] + }), + 'sei' + ); + }); + }); + describe('isNFTOwner', () => { test('should return true if address owns the NFT', async () => { // Import mocked modules diff --git a/packages/sei-global-wallet/src/lib/__tests__/config.spec.ts b/packages/sei-global-wallet/src/lib/__tests__/config.spec.ts index c6f6bc50..f0d00afe 100644 --- a/packages/sei-global-wallet/src/lib/__tests__/config.spec.ts +++ b/packages/sei-global-wallet/src/lib/__tests__/config.spec.ts @@ -1,28 +1,14 @@ 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('matches the published wallet identity', () => { + expect(config.walletName).toBe('Sei Global Wallet'); + expect(config.walletUrl).toBe('https://global-wallet.sei.io'); + expect(config.environmentId).toBe('36b63d10-7ba6-49a3-9614-22f471b9283c'); + expect(config.eip6963.rdns).toBe('io.sei.global-wallet'); }); it('walletIcon is an SVG data URI', () => { - expect(typeof config.walletIcon).toBe('string'); if (typeof config.walletIcon !== 'string') { throw new TypeError('Expected walletIcon to be a string'); } From a1902e5a5591c29dea640386f0f8e81782ef01a7 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Thu, 20 Aug 2026 11:16:50 +0200 Subject: [PATCH 6/9] test(mcp-server): fail loudly on missing resource mocks (PLT-347) Co-authored-by: Cursor --- packages/mcp-server/src/core/services/balance.ts | 3 +-- packages/mcp-server/src/tests/core/resources.test.ts | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/mcp-server/src/core/services/balance.ts b/packages/mcp-server/src/core/services/balance.ts index aa93d751..6bc6e1cb 100644 --- a/packages/mcp-server/src/core/services/balance.ts +++ b/packages/mcp-server/src/core/services/balance.ts @@ -151,11 +151,10 @@ export async function getERC721Owner(tokenAddress: string, tokenId: bigint, netw * @returns True if the address owns the NFT */ export async function isNFTOwner(tokenAddress: string, ownerAddress: string, tokenId: bigint, network = DEFAULT_NETWORK): Promise { - const validatedTokenAddress = services.helpers.validateAddress(tokenAddress); const validatedOwnerAddress = services.helpers.validateAddress(ownerAddress); try { - const actualOwner = await getERC721Owner(validatedTokenAddress, tokenId, network); + const actualOwner = await getERC721Owner(tokenAddress, tokenId, network); return actualOwner.toLowerCase() === validatedOwnerAddress.toLowerCase(); } catch (error: unknown) { diff --git a/packages/mcp-server/src/tests/core/resources.test.ts b/packages/mcp-server/src/tests/core/resources.test.ts index ec002c3d..504b0d54 100644 --- a/packages/mcp-server/src/tests/core/resources.test.ts +++ b/packages/mcp-server/src/tests/core/resources.test.ts @@ -12,7 +12,9 @@ type ResourceRegistration = { template: string | ResourceTemplate; handler: Reso const spyFunctions = (mod: object) => { for (const [key, value] of Object.entries(mod)) { if (typeof value === 'function') { - (jest.spyOn(mod as Record, key) as jest.Mock).mockImplementation(() => undefined); + (jest.spyOn(mod as Record, key) as jest.Mock).mockImplementation(() => { + throw new Error(`Unmocked function: ${key}`); + }); } } }; From 170558b52e73d070110203e40ef69d0e2ff16e19 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Thu, 20 Aug 2026 11:24:24 +0200 Subject: [PATCH 7/9] fix(mcp-server): preserve NFT ownership validation (PLT-347) Co-authored-by: Cursor --- .../mcp-server/src/core/services/balance.ts | 25 +++++++++------ packages/mcp-server/src/core/tools.ts | 20 ++---------- .../src/tests/core/services/balance.test.ts | 31 +++++++++++++++++++ .../mcp-server/src/tests/core/tools.test.ts | 14 +++++++++ 4 files changed, 63 insertions(+), 27 deletions(-) diff --git a/packages/mcp-server/src/core/services/balance.ts b/packages/mcp-server/src/core/services/balance.ts index 6bc6e1cb..4af17181 100644 --- a/packages/mcp-server/src/core/services/balance.ts +++ b/packages/mcp-server/src/core/services/balance.ts @@ -61,6 +61,18 @@ const erc1155Abi = [ } ] as const; +async function readERC721Owner(tokenAddress: Address, tokenId: bigint, network: string): Promise
{ + return (await readContract( + { + address: tokenAddress, + abi: erc721Abi, + functionName: 'ownerOf', + args: [tokenId] + }, + network + )) as Address; +} + /** * Get the Sei balance for an address * @param address Sei address @@ -131,15 +143,7 @@ export async function getERC20Balance( export async function getERC721Owner(tokenAddress: string, tokenId: bigint, network = DEFAULT_NETWORK): Promise
{ const validatedTokenAddress = services.helpers.validateAddress(tokenAddress); - return (await readContract( - { - address: validatedTokenAddress, - abi: erc721Abi, - functionName: 'ownerOf', - args: [tokenId] - }, - network - )) as Address; + return readERC721Owner(validatedTokenAddress, tokenId, network); } /** @@ -151,10 +155,11 @@ export async function getERC721Owner(tokenAddress: string, tokenId: bigint, netw * @returns True if the address owns the NFT */ export async function isNFTOwner(tokenAddress: string, ownerAddress: string, tokenId: bigint, network = DEFAULT_NETWORK): Promise { + const validatedTokenAddress = services.helpers.validateAddress(tokenAddress); const validatedOwnerAddress = services.helpers.validateAddress(ownerAddress); try { - const actualOwner = await getERC721Owner(tokenAddress, tokenId, network); + const actualOwner = await readERC721Owner(validatedTokenAddress, tokenId, network); return actualOwner.toLowerCase() === validatedOwnerAddress.toLowerCase(); } catch (error: unknown) { diff --git a/packages/mcp-server/src/core/tools.ts b/packages/mcp-server/src/core/tools.ts index 1fc669a5..8c20655d 100644 --- a/packages/mcp-server/src/core/tools.ts +++ b/packages/mcp-server/src/core/tools.ts @@ -1038,26 +1038,12 @@ function registerWalletTools(server: McpServer) { }, async ({ tokenAddress, tokenId, network = DEFAULT_NETWORK }) => { try { - const nftInfo = await services.getERC721TokenMetadata(tokenAddress as Address, BigInt(tokenId), network); + const parsedTokenId = BigInt(tokenId); + const nftInfo = await services.getERC721TokenMetadata(tokenAddress as Address, parsedTokenId, network); - // Check ownership separately let owner: `0x${string}` | null = null; try { - // This may fail if tokenId doesn't exist - owner = await services.getPublicClient(network).readContract({ - address: tokenAddress as Address, - abi: [ - { - inputs: [{ type: 'uint256' }], - name: 'ownerOf', - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' - } - ], - functionName: 'ownerOf', - args: [BigInt(tokenId)] - }); + owner = await services.getERC721Owner(tokenAddress, parsedTokenId, network); } catch (_e) { // Ownership info not available } diff --git a/packages/mcp-server/src/tests/core/services/balance.test.ts b/packages/mcp-server/src/tests/core/services/balance.test.ts index a1e9e339..825333c5 100644 --- a/packages/mcp-server/src/tests/core/services/balance.test.ts +++ b/packages/mcp-server/src/tests/core/services/balance.test.ts @@ -113,6 +113,25 @@ describe('Balance Service', () => { 'sei' ); }); + + test('should propagate owner lookup errors', async () => { + const { readContract } = await import('../../../core/services/contracts.js'); + const { utils } = await import('../../../core/services/utils.js'); + + (readContract as jest.Mock).mockRejectedValue(new Error('NFT does not exist')); + (utils.validateAddress as jest.Mock).mockImplementation((address) => address as `0x${string}`); + + await expect(getERC721Owner(VALID_TOKEN_ADDRESS, 1n)).rejects.toThrow('NFT does not exist'); + }); + + test('should propagate token address validation errors', async () => { + const { utils } = await import('../../../core/services/utils.js'); + (utils.validateAddress as jest.Mock).mockImplementation(() => { + throw new Error('Invalid address'); + }); + + await expect(getERC721Owner('invalid', 1n)).rejects.toThrow('Invalid address'); + }); }); describe('isNFTOwner', () => { @@ -148,6 +167,18 @@ describe('Balance Service', () => { expect(result).toBe(false); }); + test('should propagate token address validation errors', async () => { + const { utils } = await import('../../../core/services/utils.js'); + (utils.validateAddress as jest.Mock).mockImplementation((address) => { + if (address === 'invalid') { + throw new Error('Invalid address'); + } + return address as `0x${string}`; + }); + + await expect(isNFTOwner('invalid', VALID_OWNER_ADDRESS, 1n)).rejects.toThrow('Invalid address'); + }); + test('should return false if there is an error', async () => { // Import mocked modules const { readContract } = await import('../../../core/services/contracts.js'); diff --git a/packages/mcp-server/src/tests/core/tools.test.ts b/packages/mcp-server/src/tests/core/tools.test.ts index 88e7e7ed..d72bfba1 100644 --- a/packages/mcp-server/src/tests/core/tools.test.ts +++ b/packages/mcp-server/src/tests/core/tools.test.ts @@ -2290,6 +2290,7 @@ describe('EVM Tools', () => { beforeEach(() => { (services.getERC20TokenInfo as jest.Mock).mockResolvedValue(mockTokenInfo as never); (services.getERC721TokenMetadata as jest.Mock).mockResolvedValue(mockNftInfo as never); + (services.getERC721Owner as jest.Mock).mockResolvedValue(mockAddress as never); }); test('get_token_info - success path', async () => { @@ -2370,9 +2371,21 @@ describe('EVM Tools', () => { const response = await testToolSuccess(tool, { tokenAddress: mockTokenAddress, tokenId: mockTokenId, network: mockNetwork }); expect(services.getERC721TokenMetadata).toHaveBeenCalledWith(mockTokenAddress, BigInt(mockTokenId), mockNetwork); + expect(services.getERC721Owner).toHaveBeenCalledWith(mockTokenAddress, BigInt(mockTokenId), mockNetwork); expect(response).toHaveProperty('content'); expect(response.content[0]).toHaveProperty('type', 'text'); + expect(JSON.parse(response.content[0].text).owner).toBe(mockAddress); + }); + + test('get_nft_info - returns Unknown when owner lookup fails', async () => { + const tool = checkToolExists('get_nft_info'); + if (!tool) return; + + (services.getERC721Owner as jest.Mock).mockRejectedValue(new Error('owner lookup failed')); + const response = await tool.handler({ tokenAddress: mockTokenAddress, tokenId: mockTokenId, network: mockNetwork }); + + expect(JSON.parse(response.content[0].text).owner).toBe('Unknown'); }); test('get_nft_info - error path', async () => { @@ -2410,6 +2423,7 @@ describe('EVM Tools', () => { BigInt(mockTokenId), 'sei' // DEFAULT_NETWORK ); + expect(services.getERC721Owner).toHaveBeenCalledWith(mockTokenAddress, BigInt(mockTokenId), 'sei'); expect(response).toHaveProperty('content'); expect(response.content[0]).toHaveProperty('type', 'text'); From dc657b9a476282c527a70c56dc9cc946916f4c50 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Thu, 20 Aug 2026 11:31:32 +0200 Subject: [PATCH 8/9] fix(mcp-server): expose NFT owner lookup failures (PLT-347) Co-authored-by: Cursor --- .changeset/fix-nft-resource-owner.md | 2 +- packages/mcp-server/src/core/resources.ts | 8 +++++--- packages/mcp-server/src/core/tools.ts | 8 +++++--- .../mcp-server/src/tests/core/resources.test.ts | 14 +++++++++++++- packages/mcp-server/src/tests/core/tools.test.ts | 15 ++++++++++++++- .../src/lib/__tests__/config.spec.ts | 5 ++--- 6 files changed, 40 insertions(+), 12 deletions(-) diff --git a/.changeset/fix-nft-resource-owner.md b/.changeset/fix-nft-resource-owner.md index 708071a6..384205b7 100644 --- a/.changeset/fix-nft-resource-owner.md +++ b/.changeset/fix-nft-resource-owner.md @@ -2,4 +2,4 @@ '@sei-js/mcp-server': patch --- -Return the current ERC-721 owner from NFT detail resources. +Return the current ERC-721 owner from NFT detail resources and expose owner lookup failures. diff --git a/packages/mcp-server/src/core/resources.ts b/packages/mcp-server/src/core/resources.ts index 42395088..228e337a 100644 --- a/packages/mcp-server/src/core/resources.ts +++ b/packages/mcp-server/src/core/resources.ts @@ -546,10 +546,11 @@ export function registerEVMResources(server: McpServer) { const nftInfo = await services.getERC721TokenMetadata(tokenAddress, tokenId, network); let owner = 'Unknown'; + let ownerError: string | undefined; try { owner = await services.getERC721Owner(tokenAddress, tokenId, network); - } catch (_e) { - // Owner info not available + } catch (error) { + ownerError = error instanceof Error ? error.message : String(error); } return { @@ -562,7 +563,8 @@ export function registerEVMResources(server: McpServer) { tokenId: tokenId.toString(), network, ...nftInfo, - owner + owner, + ...(ownerError === undefined ? {} : { ownerError }) }, null, 2 diff --git a/packages/mcp-server/src/core/tools.ts b/packages/mcp-server/src/core/tools.ts index 8c20655d..c5ba9418 100644 --- a/packages/mcp-server/src/core/tools.ts +++ b/packages/mcp-server/src/core/tools.ts @@ -1042,10 +1042,11 @@ function registerWalletTools(server: McpServer) { const nftInfo = await services.getERC721TokenMetadata(tokenAddress as Address, parsedTokenId, network); let owner: `0x${string}` | null = null; + let ownerError: string | undefined; try { owner = await services.getERC721Owner(tokenAddress, parsedTokenId, network); - } catch (_e) { - // Ownership info not available + } catch (error) { + ownerError = error instanceof Error ? error.message : String(error); } return { @@ -1058,7 +1059,8 @@ function registerWalletTools(server: McpServer) { tokenId, network, ...nftInfo, - owner: owner || 'Unknown' + owner: owner || 'Unknown', + ...(ownerError === undefined ? {} : { ownerError }) }, null, 2 diff --git a/packages/mcp-server/src/tests/core/resources.test.ts b/packages/mcp-server/src/tests/core/resources.test.ts index 504b0d54..8e29d3ad 100644 --- a/packages/mcp-server/src/tests/core/resources.test.ts +++ b/packages/mcp-server/src/tests/core/resources.test.ts @@ -9,6 +9,9 @@ type ResourceResult = { contents: Array<{ uri: string; text: string }> }; type ResourceHandler = (uri: { href: string }, params?: Record) => Promise; type ResourceRegistration = { template: string | ResourceTemplate; handler: ResourceHandler }; +// Default spies throw so a new handler dependency fails loudly. That mutates the real +// `chains`/`services` module namespaces, which is only safe because this package's `test` +// script and the root `test:coverage` job both run `bun test --isolate`. const spyFunctions = (mod: object) => { for (const [key, value] of Object.entries(mod)) { if (typeof value === 'function') { @@ -304,7 +307,16 @@ describe('registerEVMResources', () => { it('returns NFT metadata with an unknown owner when owner lookup fails', async () => { (services.getERC721Owner as jest.Mock).mockRejectedValue(new Error('owner lookup failed')); const result = await invoke('erc721_nft_token_details'); - expect(JSON.parse(textOf(result)).owner).toBe('Unknown'); + expect(JSON.parse(textOf(result))).toMatchObject({ + owner: 'Unknown', + ownerError: 'owner lookup failed' + }); + }); + + it('stringifies non-Error NFT owner lookup failures', async () => { + (services.getERC721Owner as jest.Mock).mockRejectedValue('owner unavailable'); + const result = await invoke('erc721_nft_token_details'); + expect(JSON.parse(textOf(result)).ownerError).toBe('owner unavailable'); }); it('checks NFT ownership', async () => { diff --git a/packages/mcp-server/src/tests/core/tools.test.ts b/packages/mcp-server/src/tests/core/tools.test.ts index d72bfba1..ca12fb34 100644 --- a/packages/mcp-server/src/tests/core/tools.test.ts +++ b/packages/mcp-server/src/tests/core/tools.test.ts @@ -2385,7 +2385,20 @@ describe('EVM Tools', () => { (services.getERC721Owner as jest.Mock).mockRejectedValue(new Error('owner lookup failed')); const response = await tool.handler({ tokenAddress: mockTokenAddress, tokenId: mockTokenId, network: mockNetwork }); - expect(JSON.parse(response.content[0].text).owner).toBe('Unknown'); + expect(JSON.parse(response.content[0].text)).toMatchObject({ + owner: 'Unknown', + ownerError: 'owner lookup failed' + }); + }); + + test('get_nft_info - stringifies non-Error owner lookup failures', async () => { + const tool = checkToolExists('get_nft_info'); + if (!tool) return; + + (services.getERC721Owner as jest.Mock).mockRejectedValue('owner unavailable'); + const response = await tool.handler({ tokenAddress: mockTokenAddress, tokenId: mockTokenId, network: mockNetwork }); + + expect(JSON.parse(response.content[0].text).ownerError).toBe('owner unavailable'); }); test('get_nft_info - error path', async () => { diff --git a/packages/sei-global-wallet/src/lib/__tests__/config.spec.ts b/packages/sei-global-wallet/src/lib/__tests__/config.spec.ts index f0d00afe..e99caa42 100644 --- a/packages/sei-global-wallet/src/lib/__tests__/config.spec.ts +++ b/packages/sei-global-wallet/src/lib/__tests__/config.spec.ts @@ -1,6 +1,7 @@ import { config } from '../config'; describe('sei-global-wallet config', () => { + // These values intentionally pin the published EIP-6963 identity. it('matches the published wallet identity', () => { expect(config.walletName).toBe('Sei Global Wallet'); expect(config.walletUrl).toBe('https://global-wallet.sei.io'); @@ -9,9 +10,7 @@ describe('sei-global-wallet config', () => { }); it('walletIcon is an SVG data URI', () => { - if (typeof config.walletIcon !== 'string') { - throw new TypeError('Expected walletIcon to be a string'); - } + expect(typeof config.walletIcon).toBe('string'); expect(config.walletIcon).toMatch(/^data:image\/svg\+xml;base64,.+/); }); }); From e488c02b49df13169a4567c312dd95134fda0c94 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Thu, 20 Aug 2026 11:32:49 +0200 Subject: [PATCH 9/9] test(mcp-server): cover non-numeric NFT token IDs (PLT-347) Co-authored-by: Cursor --- packages/mcp-server/src/tests/core/resources.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/mcp-server/src/tests/core/resources.test.ts b/packages/mcp-server/src/tests/core/resources.test.ts index 8e29d3ad..e1fa6d05 100644 --- a/packages/mcp-server/src/tests/core/resources.test.ts +++ b/packages/mcp-server/src/tests/core/resources.test.ts @@ -407,4 +407,14 @@ describe('registerEVMResources', () => { const result = await invoke('evm_block_by_number', { ...RESOURCE_CASES.evm_block_by_number.params, blockNumber: 'abc' }); expect(textOf(result)).toBe('Error fetching block: Invalid block number: abc'); }); + + it('surfaces invalid token IDs through the ERC-721 error path', async () => { + const result = await invoke('erc721_nft_token_details', { ...RESOURCE_CASES.erc721_nft_token_details.params, tokenId: 'abc' }); + expect(textOf(result)).toMatch(/^Error fetching NFT info: .*bigint/i); + }); + + it('surfaces invalid token IDs through the ERC-1155 error path', async () => { + const result = await invoke('erc1155_token_metadata_uri', { ...RESOURCE_CASES.erc1155_token_metadata_uri.params, tokenId: 'abc' }); + expect(textOf(result)).toMatch(/^Error fetching ERC1155 token URI: .*bigint/i); + }); });