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,.+/);
+ });
+});