diff --git a/.changeset/fix-nft-resource-owner.md b/.changeset/fix-nft-resource-owner.md new file mode 100644 index 00000000..384205b7 --- /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 and expose owner lookup failures. diff --git a/packages/mcp-server/src/core/resources.ts b/packages/mcp-server/src/core/resources.ts index c7918e20..228e337a 100644 --- a/packages/mcp-server/src/core/resources.ts +++ b/packages/mcp-server/src/core/resources.ts @@ -545,15 +545,12 @@ export function registerEVMResources(server: McpServer) { const nftInfo = await services.getERC721TokenMetadata(tokenAddress, tokenId, network); - // Get owner separately let owner = 'Unknown'; + let ownerError: string | undefined; try { - const isOwner = await services.isNFTOwner(tokenAddress, params.address as Address, tokenId, network); - if (isOwner) { - owner = params.address as string; - } - } catch (_e) { - // Owner info not available + owner = await services.getERC721Owner(tokenAddress, tokenId, network); + } catch (error) { + ownerError = error instanceof Error ? error.message : String(error); } return { @@ -566,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/services/balance.ts b/packages/mcp-server/src/core/services/balance.ts index 3ab82404..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 @@ -121,6 +133,19 @@ 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 readERC721Owner(validatedTokenAddress, tokenId, network); +} + /** * Check if an address owns a specific NFT * @param tokenAddress NFT contract address @@ -134,15 +159,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 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..c5ba9418 100644 --- a/packages/mcp-server/src/core/tools.ts +++ b/packages/mcp-server/src/core/tools.ts @@ -1038,28 +1038,15 @@ 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; + let ownerError: string | undefined; 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)] - }); - } catch (_e) { - // Ownership info not available + owner = await services.getERC721Owner(tokenAddress, parsedTokenId, network); + } catch (error) { + ownerError = error instanceof Error ? error.message : String(error); } return { @@ -1072,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 31551b16..e1fa6d05 100644 --- a/packages/mcp-server/src/tests/core/resources.test.ts +++ b/packages/mcp-server/src/tests/core/resources.test.ts @@ -1,5 +1,94 @@ -import { describe, expect, it } from 'bun:test'; -import { parseBlockNumber } from '../../core/resources.js'; +import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test'; +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'; +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 }; + +// 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') { + (jest.spyOn(mod as Record, key) as jest.Mock).mockImplementation(() => { + throw new Error(`Unmocked function: ${key}`); + }); + } + } +}; + +const ADDRESS = '0x1234567890123456789012345678901234567890' as Address; +const TOKEN = '0x0987654321098765432109876543210987654321' as Address; +const TX_HASH = '0xabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabca'; +const BLOCK_HASH = '0xdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefdefd'; +const NETWORK = 'sei-testnet'; +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: string | ResourceTemplate, handler: ResourceHandler) => { + registered.set(name, { template, handler }); + }) + } as unknown as McpServer; + + return { server, registered }; +} + +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', () => { @@ -11,3 +100,321 @@ describe('parseBlockNumber', () => { expect(() => parseBlockNumber(value)).toThrow(`Invalid block number: ${value}`); }); }); + +describe('registerEVMResources', () => { + let registered: Map; + + 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 registration.handler({ href: `evm://test/${name}` }, params); + }; + + beforeEach(() => { + spyFunctions(chains); + spyFunctions(services); + + (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: 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, blockNumber: 123n }); + (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.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); + + const mockServer = createMockResourceServer(); + registerEVMResources(mockServer.server); + registered = mockServer.registered; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('registers every EVM resource', () => { + 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 () => { + 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(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(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(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(JSON.parse(textOf(result))).toEqual({ 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(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, blockNumber: '123' }); + }); + + 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 its current 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: 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))).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 () => { + (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: [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, getMock, prefix, kind) => { + const fn = getMock(); + if (kind === 'sync') { + fn.mockImplementation(() => { + throw new Error('fail'); + }); + } else { + fn.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, getMock, prefix, kind) => { + const fn = getMock(); + if (kind === 'sync') { + fn.mockImplementation(() => { + throw 'nope'; + }); + } else { + fn.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', { ...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); + }); +}); 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..825333c5 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,47 @@ 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' + ); + }); + + 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', () => { test('should return true if address owns the NFT', async () => { // Import mocked modules @@ -126,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..ca12fb34 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,34 @@ 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)).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 () => { @@ -2410,6 +2436,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'); 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..e99caa42 --- /dev/null +++ b/packages/sei-global-wallet/src/lib/__tests__/config.spec.ts @@ -0,0 +1,16 @@ +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'); + 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'); + expect(config.walletIcon).toMatch(/^data:image\/svg\+xml;base64,.+/); + }); +});