From 206d3ec3506797e2d395cdca2c63f490d2f0c0ae Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:04 +1000 Subject: [PATCH 01/18] test: add test configuration --- jest.config.js | 24 ++++++++++++++++++++++++ package.json | 6 +++++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 jest.config.js diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..c343bf1 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,24 @@ +/** @type {import("jest").Config} **/ +export default { + testEnvironment: "node", + extensionsToTreatAsEsm: [".ts"], + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1" + }, + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + useESM: true, + tsconfig: { + module: "ESNext", + moduleResolution: "NodeNext" + } + } + ] + }, + injectGlobals: true, + transformIgnorePatterns: [ + "node_modules/(?!(viem|zod|@modelcontextprotocol)/)" + ] +}; \ No newline at end of file diff --git a/package.json b/package.json index 1919e8d..ea8eaa2 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,12 @@ "devDependencies": { "@types/bun": "latest", "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", "@types/node": "^22.0.0", - "conventional-changelog-cli": "^5.0.0" + "conventional-changelog-cli": "^5.0.0", + "jest": "^30.4.2", + "ts-jest": "^29.4.9", + "typescript": "^5.9.3" }, "peerDependencies": { "typescript": "^5.8.2" From c14863caa8dbd27da903294df1c12ee46788ffbb Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:04 +1000 Subject: [PATCH 02/18] test: add src/index.test.ts --- src/index.test.ts | 239 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 src/index.test.ts diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..2ccc6c2 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,239 @@ +import { jest } from "@jest/globals"; + +describe("src/index.ts", () => { + const originalConsoleError = console.error; + const originalConsoleLog = console.log; + + beforeEach(() => { + jest.resetModules(); + console.error = jest.fn(); + console.log = jest.fn(); + jest.spyOn(process, "exit").mockImplementation((() => {}) as never); + }); + + afterEach(() => { + console.error = originalConsoleError; + console.log = originalConsoleLog; + jest.restoreAllMocks(); + }); + + describe("main() function - success path", () => { + it("should start the server and connect to transport successfully", async () => { + // Create mocks + const mockConnect = jest.fn(); + const mockServer = { connect: mockConnect }; + const mockStartServer = jest.fn(); + mockStartServer.mockImplementation(() => Promise.resolve(mockServer)); + const mockTransportInstance = {}; + const MockStdioServerTransport = jest.fn(); + MockStdioServerTransport.mockImplementation(() => mockTransportInstance); + + // Set up mocks using doMock before import + (jest as any).doMock("./server/server.js", () => { + return { + __esModule: true, + default: mockStartServer + }; + }); + + (jest as any).doMock("@modelcontextprotocol/sdk/server/stdio.js", () => { + return { + StdioServerTransport: MockStdioServerTransport + }; + }); + + // Import the module to trigger main() execution + await import("./index.js"); + // Wait for async operations + await new Promise(resolve => setTimeout(resolve, 100)); + + // Verify startServer was called + expect(mockStartServer).toHaveBeenCalledTimes(1); + + // Verify StdioServerTransport was instantiated + expect(MockStdioServerTransport).toHaveBeenCalledTimes(1); + + // Verify server.connect was called with the transport + expect(mockConnect).toHaveBeenCalledTimes(1); + expect(mockConnect).toHaveBeenCalledWith(mockTransportInstance); + + // Verify success message was logged + expect(console.error).toHaveBeenCalledWith("EVM MCP Server running on stdio"); + }); + }); + + describe("main() function - error handling", () => { + it("should handle errors during server startup and exit with code 1", async () => { + const testError = new Error("Server startup failed"); + const mockConnect = jest.fn(); + const mockServer = { connect: mockConnect }; + const mockStartServer = jest.fn(); + mockStartServer.mockImplementation(() => Promise.reject(testError)); + const mockTransportInstance = {}; + const MockStdioServerTransport = jest.fn(); + MockStdioServerTransport.mockImplementation(() => mockTransportInstance); + + (jest as any).doMock("./server/server.js", () => { + return { + __esModule: true, + default: mockStartServer + }; + }); + + (jest as any).doMock("@modelcontextprotocol/sdk/server/stdio.js", () => { + return { + StdioServerTransport: MockStdioServerTransport + }; + }); + + // Import the module to trigger main() execution + await import("./index.js"); + // Wait for async operations + await new Promise(resolve => setTimeout(resolve, 100)); + + // Verify error was logged + expect(console.error).toHaveBeenCalledWith( + "Error starting MCP server:", + testError + ); + + // Verify process.exit was called with code 1 + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("should handle errors during server.connect", async () => { + const connectError = new Error("Connection failed"); + const mockConnect = jest.fn(); + mockConnect.mockImplementation(() => Promise.reject(connectError)); + const mockServer = { connect: mockConnect }; + const mockStartServer = jest.fn(); + mockStartServer.mockImplementation(() => Promise.resolve(mockServer)); + const mockTransportInstance = {}; + const MockStdioServerTransport = jest.fn(); + MockStdioServerTransport.mockImplementation(() => mockTransportInstance); + + (jest as any).doMock("./server/server.js", () => { + return { + __esModule: true, + default: mockStartServer + }; + }); + + (jest as any).doMock("@modelcontextprotocol/sdk/server/stdio.js", () => { + return { + StdioServerTransport: MockStdioServerTransport + }; + }); + + // Import the module to trigger main() execution + await import("./index.js"); + // Wait for async operations + await new Promise(resolve => setTimeout(resolve, 100)); + + // Verify error was logged + expect(console.error).toHaveBeenCalledWith( + "Error starting MCP server:", + connectError + ); + + // Verify process.exit was called with code 1 + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("should handle errors in main's try-catch block", async () => { + const testError = new Error("Error in main"); + const mockConnect = jest.fn(); + const mockServer = { connect: mockConnect }; + const mockStartServer = jest.fn(); + mockStartServer.mockImplementation(() => Promise.reject(testError)); + const mockTransportInstance = {}; + const MockStdioServerTransport = jest.fn(); + MockStdioServerTransport.mockImplementation(() => mockTransportInstance); + + (jest as any).doMock("./server/server.js", () => { + return { + __esModule: true, + default: mockStartServer + }; + }); + + (jest as any).doMock("@modelcontextprotocol/sdk/server/stdio.js", () => { + return { + StdioServerTransport: MockStdioServerTransport + }; + }); + + // Import the module to trigger main() execution + await import("./index.js"); + // Wait for async operations + await new Promise(resolve => setTimeout(resolve, 100)); + + // Error from startServer is caught by the inner try-catch in main() + // which logs "Error starting MCP server:" + expect(console.error).toHaveBeenCalledWith( + "Error starting MCP server:", + testError + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + }); + + describe("module structure", () => { + it("should import startServer from server module", async () => { + const mockConnect = jest.fn(); + const mockServer = { connect: mockConnect }; + const mockStartServer = jest.fn(); + mockStartServer.mockImplementation(() => Promise.resolve(mockServer)); + const mockTransportInstance = {}; + const MockStdioServerTransport = jest.fn(); + MockStdioServerTransport.mockImplementation(() => mockTransportInstance); + + (jest as any).doMock("./server/server.js", () => { + return { + __esModule: true, + default: mockStartServer + }; + }); + + (jest as any).doMock("@modelcontextprotocol/sdk/server/stdio.js", () => { + return { + StdioServerTransport: MockStdioServerTransport + }; + }); + + // The module should import successfully + const indexModule = await import("./index.js"); + expect(indexModule).toBeDefined(); + }); + + it("should use StdioServerTransport for transport", async () => { + const mockConnect = jest.fn(); + const mockServer = { connect: mockConnect }; + const mockStartServer = jest.fn(); + mockStartServer.mockImplementation(() => Promise.resolve(mockServer)); + const mockTransportInstance = {}; + const MockStdioServerTransport = jest.fn(); + MockStdioServerTransport.mockImplementation(() => mockTransportInstance); + + (jest as any).doMock("./server/server.js", () => { + return { + __esModule: true, + default: mockStartServer + }; + }); + + (jest as any).doMock("@modelcontextprotocol/sdk/server/stdio.js", () => { + return { + StdioServerTransport: MockStdioServerTransport + }; + }); + + // Import the module to trigger main() execution + await import("./index.js"); + // Wait for async operations + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(MockStdioServerTransport).toHaveBeenCalled(); + }); + }); +}); From 55d679eb3a935bc0176b7769c48e1029bca7f072 Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:04 +1000 Subject: [PATCH 03/18] test: add src/core/services/transactions.test.ts --- src/core/services/transactions.test.ts | 208 +++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 src/core/services/transactions.test.ts diff --git a/src/core/services/transactions.test.ts b/src/core/services/transactions.test.ts new file mode 100644 index 0000000..1baf22e --- /dev/null +++ b/src/core/services/transactions.test.ts @@ -0,0 +1,208 @@ +import { + getTransaction, + getTransactionReceipt, + getTransactionCount, + estimateGas, + getChainId +} from './transactions.js'; +import { getPublicClient } from './clients.js'; +import type { TransactionReceipt } from 'viem'; + +jest.mock('./clients.js', () => ({ + getPublicClient: jest.fn() +})); + +const mockGetPublicClient = getPublicClient as jest.MockedFunction; + +describe('transactions', () => { + const mockHash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' as `0x${string}`; + const mockAddress = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb' as `0x${string}`; + const mockNetwork = 'ethereum'; + const mockChainId = 1; + const mockGas = BigInt(21000); + const mockNonce = BigInt(5); + + let mockClient: { + getTransaction: jest.Mock; + getTransactionReceipt: jest.Mock; + getTransactionCount: jest.Mock; + estimateGas: jest.Mock; + getChainId: jest.Mock; + }; + + beforeEach(() => { + mockClient = { + getTransaction: jest.fn(), + getTransactionReceipt: jest.fn(), + getTransactionCount: jest.fn(), + estimateGas: jest.fn(), + getChainId: jest.fn() + }; + mockGetPublicClient.mockReturnValue(mockClient as unknown as ReturnType); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getTransaction', () => { + it('should get a transaction by hash', async () => { + const mockTransaction = { hash: mockHash, from: mockAddress }; + mockClient.getTransaction.mockResolvedValue(mockTransaction); + + const result = await getTransaction(mockHash, mockNetwork); + + expect(getPublicClient).toHaveBeenCalledWith(mockNetwork); + expect(mockClient.getTransaction).toHaveBeenCalledWith({ hash: mockHash }); + expect(result).toEqual(mockTransaction); + }); + + it('should use default network when not provided', async () => { + const mockTransaction = { hash: mockHash, from: mockAddress }; + mockClient.getTransaction.mockResolvedValue(mockTransaction); + + await getTransaction(mockHash); + + expect(getPublicClient).toHaveBeenCalledWith('ethereum'); + }); + }); + + describe('getTransactionReceipt', () => { + it('should get a transaction receipt by hash', async () => { + const mockReceipt = { + hash: mockHash, + status: 'success', + gasUsed: mockGas, + blockHash: mockHash, + blockNumber: BigInt(1000), + contractAddress: null, + cumulativeGasUsed: mockGas, + effectiveGasPrice: BigInt(1000000000), + from: mockAddress, + to: mockAddress, + type: 2, + logsBloom: '0x0', + logs: [], + transactionHash: mockHash, + transactionIndex: 0 + } as unknown as TransactionReceipt; + mockClient.getTransactionReceipt.mockResolvedValue(mockReceipt); + + const result = await getTransactionReceipt(mockHash, mockNetwork); + + expect(getPublicClient).toHaveBeenCalledWith(mockNetwork); + expect(mockClient.getTransactionReceipt).toHaveBeenCalledWith({ hash: mockHash }); + expect(result).toEqual(mockReceipt); + }); + + it('should use default network when not provided', async () => { + const mockReceipt = { + hash: mockHash, + status: 'success', + blockHash: mockHash, + blockNumber: BigInt(1000), + contractAddress: null, + cumulativeGasUsed: mockGas, + effectiveGasPrice: BigInt(1000000000), + from: mockAddress, + to: mockAddress, + type: 2, + logsBloom: '0x0', + logs: [], + transactionHash: mockHash, + transactionIndex: 0 + } as unknown as TransactionReceipt; + mockClient.getTransactionReceipt.mockResolvedValue(mockReceipt); + + await getTransactionReceipt(mockHash); + + expect(getPublicClient).toHaveBeenCalledWith('ethereum'); + }); + }); + + describe('getTransactionCount', () => { + it('should get the transaction count for an address', async () => { + mockClient.getTransactionCount.mockResolvedValue(mockNonce); + + const result = await getTransactionCount(mockAddress, mockNetwork); + + expect(getPublicClient).toHaveBeenCalledWith(mockNetwork); + expect(mockClient.getTransactionCount).toHaveBeenCalledWith({ address: mockAddress }); + expect(result).toBe(5); + }); + + it('should use default network when not provided', async () => { + mockClient.getTransactionCount.mockResolvedValue(mockNonce); + + await getTransactionCount(mockAddress); + + expect(getPublicClient).toHaveBeenCalledWith('ethereum'); + }); + + it('should convert bigint nonce to number', async () => { + const largeNonce = BigInt(1000); + mockClient.getTransactionCount.mockResolvedValue(largeNonce); + + const result = await getTransactionCount(mockAddress); + + expect(result).toBe(1000); + expect(typeof result).toBe('number'); + }); + }); + + describe('estimateGas', () => { + it('should estimate gas for a transaction', async () => { + const mockParams = { + from: mockAddress, + to: mockAddress, + value: BigInt(1000) + }; + mockClient.estimateGas.mockResolvedValue(mockGas); + + const result = await estimateGas(mockParams, mockNetwork); + + expect(getPublicClient).toHaveBeenCalledWith(mockNetwork); + expect(mockClient.estimateGas).toHaveBeenCalledWith(mockParams); + expect(result).toBe(mockGas); + }); + + it('should use default network when not provided', async () => { + const mockParams = { from: mockAddress, to: mockAddress }; + mockClient.estimateGas.mockResolvedValue(mockGas); + + await estimateGas(mockParams); + + expect(getPublicClient).toHaveBeenCalledWith('ethereum'); + }); + }); + + describe('getChainId', () => { + it('should get the chain ID', async () => { + mockClient.getChainId.mockResolvedValue(mockChainId); + + const result = await getChainId(mockNetwork); + + expect(getPublicClient).toHaveBeenCalledWith(mockNetwork); + expect(mockClient.getChainId).toHaveBeenCalled(); + expect(result).toBe(mockChainId); + }); + + it('should use default network when not provided', async () => { + mockClient.getChainId.mockResolvedValue(mockChainId); + + await getChainId(); + + expect(getPublicClient).toHaveBeenCalledWith('ethereum'); + }); + + it('should convert bigint chainId to number', async () => { + const bigChainId = BigInt(137); + mockClient.getChainId.mockResolvedValue(bigChainId); + + const result = await getChainId(); + + expect(result).toBe(137); + expect(typeof result).toBe('number'); + }); + }); +}); From e081d69a99987416b4067727373613d22bc808ff Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:04 +1000 Subject: [PATCH 04/18] test: add src/core/services/tokens.test.ts --- src/core/services/clients.test.ts | 248 ++++++++++++++++++++++++++++++ src/core/services/tokens.test.ts | 204 ++++++++++++++++++++++++ 2 files changed, 452 insertions(+) create mode 100644 src/core/services/clients.test.ts create mode 100644 src/core/services/tokens.test.ts diff --git a/src/core/services/clients.test.ts b/src/core/services/clients.test.ts new file mode 100644 index 0000000..c06a71f --- /dev/null +++ b/src/core/services/clients.test.ts @@ -0,0 +1,248 @@ +// Mock the chains module +jest.mock('../chains.js', () => ({ + getChain: jest.fn(), + getRpcUrl: jest.fn(), +})); + +// Mock viem modules +jest.mock('viem', () => ({ + createPublicClient: jest.fn(), + createWalletClient: jest.fn(), + http: jest.fn(), +})); + +jest.mock('viem/accounts', () => ({ + privateKeyToAccount: jest.fn(), +})); + +import { createPublicClient, createWalletClient, http } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { getChain, getRpcUrl } from '../chains.js'; + +describe('clients', () => { + const mockChain = { id: 1, name: 'Ethereum' }; + const mockRpcUrl = 'https://mock-rpc.com'; + const mockAccount = { + address: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + publicKey: '0xmockPublicKey' + }; + const mockPrivateKey = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + + describe('getPublicClient', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Setup default mocks + (getChain as jest.Mock).mockReturnValue(mockChain); + (getRpcUrl as jest.Mock).mockReturnValue(mockRpcUrl); + (http as jest.Mock).mockReturnValue({ type: 'http' }); + (privateKeyToAccount as jest.Mock).mockReturnValue(mockAccount); + }); + + it('should create a new public client for a network', () => { + const mockPublicClient = { type: 'public', chain: mockChain }; + (createPublicClient as jest.Mock).mockReturnValue(mockPublicClient); + + jest.isolateModules(() => { + const { getPublicClient } = require('./clients.js'); + const client = getPublicClient('ethereum'); + + expect(getChain).toHaveBeenCalledWith('ethereum'); + expect(getRpcUrl).toHaveBeenCalledWith('ethereum'); + expect(http).toHaveBeenCalledWith(mockRpcUrl); + expect(createPublicClient).toHaveBeenCalledWith({ + chain: mockChain, + transport: { type: 'http' } + }); + expect(client).toBe(mockPublicClient); + }); + }); + + it('should use default network "ethereum" when no network is provided', () => { + const mockPublicClient = { type: 'public', chain: mockChain }; + (createPublicClient as jest.Mock).mockReturnValue(mockPublicClient); + + jest.isolateModules(() => { + const { getPublicClient } = require('./clients.js'); + getPublicClient(); + + expect(getChain).toHaveBeenCalledWith('ethereum'); + expect(getRpcUrl).toHaveBeenCalledWith('ethereum'); + }); + }); + + it('should cache the public client and return cached instance on subsequent calls', () => { + const mockPublicClient = { type: 'public', chain: mockChain }; + (createPublicClient as jest.Mock).mockReturnValue(mockPublicClient); + + jest.isolateModules(() => { + const { getPublicClient } = require('./clients.js'); + const firstCall = getPublicClient('ethereum'); + const secondCall = getPublicClient('ethereum'); + + expect(firstCall).toBe(secondCall); + expect(createPublicClient).toHaveBeenCalledTimes(1); + }); + }); + + it('should cache clients separately for different networks', () => { + const mockPublicClient1 = { type: 'public', chain: mockChain, id: 1 }; + const mockPublicClient2 = { type: 'public', chain: mockChain, id: 2 }; + let callCount = 0; + (createPublicClient as jest.Mock).mockImplementation(() => { + callCount++; + return callCount === 1 ? mockPublicClient1 : mockPublicClient2; + }); + + jest.isolateModules(() => { + const { getPublicClient } = require('./clients.js'); + const ethereumClient = getPublicClient('ethereum'); + const optimismClient = getPublicClient('optimism'); + + expect(ethereumClient).not.toBe(optimismClient); + expect(createPublicClient).toHaveBeenCalledTimes(2); + }); + }); + + it('should return cached client even when called with different case network names', () => { + const mockPublicClient1 = { type: 'public', chain: mockChain, id: 1 }; + const mockPublicClient2 = { type: 'public', chain: mockChain, id: 2 }; + let callCount = 0; + (createPublicClient as jest.Mock).mockImplementation(() => { + callCount++; + return callCount === 1 ? mockPublicClient1 : mockPublicClient2; + }); + + jest.isolateModules(() => { + const { getPublicClient } = require('./clients.js'); + const client1 = getPublicClient('Ethereum'); + const client2 = getPublicClient('ethereum'); + + // Note: cache key is String(network), so case matters + expect(createPublicClient).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('getWalletClient', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Setup default mocks + (getChain as jest.Mock).mockReturnValue(mockChain); + (getRpcUrl as jest.Mock).mockReturnValue(mockRpcUrl); + (http as jest.Mock).mockReturnValue({ type: 'http' }); + (privateKeyToAccount as jest.Mock).mockReturnValue(mockAccount); + }); + + it('should create a wallet client with private key and network', () => { + const mockWalletClient = { type: 'wallet', chain: mockChain }; + (createWalletClient as jest.Mock).mockReturnValue(mockWalletClient); + + jest.isolateModules(() => { + const { getWalletClient } = require('./clients.js'); + const client = getWalletClient(mockPrivateKey, 'ethereum'); + + expect(getChain).toHaveBeenCalledWith('ethereum'); + expect(getRpcUrl).toHaveBeenCalledWith('ethereum'); + expect(privateKeyToAccount).toHaveBeenCalledWith(mockPrivateKey); + expect(createWalletClient).toHaveBeenCalledWith({ + account: mockAccount, + chain: mockChain, + transport: { type: 'http' } + }); + expect(client).toBe(mockWalletClient); + }); + }); + + it('should use default network "ethereum" when no network is provided', () => { + const mockWalletClient = { type: 'wallet', chain: mockChain }; + (createWalletClient as jest.Mock).mockReturnValue(mockWalletClient); + + jest.isolateModules(() => { + const { getWalletClient } = require('./clients.js'); + getWalletClient(mockPrivateKey); + + expect(getChain).toHaveBeenCalledWith('ethereum'); + expect(getRpcUrl).toHaveBeenCalledWith('ethereum'); + }); + }); + + it('should create a new wallet client each time (no caching)', () => { + const mockWalletClient1 = { type: 'wallet', chain: mockChain, id: 1 }; + const mockWalletClient2 = { type: 'wallet', chain: mockChain, id: 2 }; + let callCount = 0; + (createWalletClient as jest.Mock).mockImplementation(() => { + callCount++; + return callCount === 1 ? mockWalletClient1 : mockWalletClient2; + }); + + jest.isolateModules(() => { + const { getWalletClient } = require('./clients.js'); + const firstCall = getWalletClient(mockPrivateKey, 'ethereum'); + const secondCall = getWalletClient(mockPrivateKey, 'ethereum'); + + expect(firstCall).not.toBe(secondCall); + expect(createWalletClient).toHaveBeenCalledTimes(2); + }); + }); + + it('should work with different private keys', () => { + const mockWalletClient = { type: 'wallet', chain: mockChain }; + (createWalletClient as jest.Mock).mockReturnValue(mockWalletClient); + + const privateKey2 = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; + const mockAccount2 = { address: '0x70997970C51812dc3A010C7d01b50e0d17dc79C9', publicKey: '0xmockPublicKey2' }; + (privateKeyToAccount as jest.Mock).mockReturnValueOnce(mockAccount2); + + jest.isolateModules(() => { + const { getWalletClient } = require('./clients.js'); + const client1 = getWalletClient(mockPrivateKey, 'ethereum'); + const client2 = getWalletClient(privateKey2, 'ethereum'); + + expect(privateKeyToAccount).toHaveBeenCalledWith(mockPrivateKey); + expect(privateKeyToAccount).toHaveBeenCalledWith(privateKey2); + }); + }); + }); + + describe('getAddressFromPrivateKey', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Setup default mocks + (getChain as jest.Mock).mockReturnValue(mockChain); + (getRpcUrl as jest.Mock).mockReturnValue(mockRpcUrl); + (http as jest.Mock).mockReturnValue({ type: 'http' }); + (privateKeyToAccount as jest.Mock).mockReturnValue(mockAccount); + }); + + it('should return the address derived from a private key', () => { + jest.isolateModules(() => { + const { getAddressFromPrivateKey } = require('./clients.js'); + const address = getAddressFromPrivateKey(mockPrivateKey); + + expect(privateKeyToAccount).toHaveBeenCalledWith(mockPrivateKey); + expect(address).toBe(mockAccount.address); + }); + }); + + it('should work with different private keys', () => { + const privateKey2 = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; + const mockAccount2 = { address: '0x70997970C51812dc3A010C7d01b50e0d17dc79C9', publicKey: '0xmockPublicKey2' }; + + (privateKeyToAccount as jest.Mock) + .mockReturnValueOnce(mockAccount) + .mockReturnValueOnce(mockAccount2); + + jest.isolateModules(() => { + const { getAddressFromPrivateKey } = require('./clients.js'); + const address1 = getAddressFromPrivateKey(mockPrivateKey); + const address2 = getAddressFromPrivateKey(privateKey2); + + expect(address1).toBe('0x70997970C51812dc3A010C7d01b50e0d17dc79C8'); + expect(address2).toBe('0x70997970C51812dc3A010C7d01b50e0d17dc79C9'); + }); + }); + }); +}); diff --git a/src/core/services/tokens.test.ts b/src/core/services/tokens.test.ts new file mode 100644 index 0000000..7e08679 --- /dev/null +++ b/src/core/services/tokens.test.ts @@ -0,0 +1,204 @@ +import { getERC20TokenInfo, getERC721TokenMetadata, getERC1155TokenURI } from './tokens.js'; +import { getPublicClient } from './clients.js'; +import { getContract, formatUnits } from 'viem'; + +jest.mock('./clients.js'); +jest.mock('viem'); + +const mockGetPublicClient = getPublicClient as jest.MockedFunction; +const mockGetContract = getContract as jest.MockedFunction; +const mockFormatUnits = formatUnits as jest.MockedFunction; + +describe('tokens', () => { + const mockTokenAddress = '0x1234567890123456789012345678901234567890' as `0x${string}`; + const mockNetwork = 'ethereum'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getERC20TokenInfo', () => { + const mockName = 'Test Token'; + const mockSymbol = 'TEST'; + const mockDecimals = 18; + const mockTotalSupply = BigInt('1000000000000000000000000'); + const mockFormattedTotalSupply = '1000000.0'; + + const mockRead = { + name: jest.fn(), + symbol: jest.fn(), + decimals: jest.fn(), + totalSupply: jest.fn() + }; + + const mockContract = { + read: mockRead + }; + + beforeEach(() => { + mockGetPublicClient.mockReturnValue({} as never); + mockGetContract.mockReturnValue(mockContract as never); + mockFormatUnits.mockReturnValue(mockFormattedTotalSupply); + }); + + it('should return ERC20 token info', async () => { + mockRead.name.mockResolvedValue(mockName); + mockRead.symbol.mockResolvedValue(mockSymbol); + mockRead.decimals.mockResolvedValue(mockDecimals); + mockRead.totalSupply.mockResolvedValue(mockTotalSupply); + + const result = await getERC20TokenInfo(mockTokenAddress, mockNetwork); + + expect(mockGetPublicClient).toHaveBeenCalledWith(mockNetwork); + expect(mockGetContract).toHaveBeenCalledWith({ + address: mockTokenAddress, + abi: expect.any(Array), + client: expect.any(Object) + }); + expect(mockFormatUnits).toHaveBeenCalledWith(mockTotalSupply, mockDecimals); + expect(result).toEqual({ + name: mockName, + symbol: mockSymbol, + decimals: mockDecimals, + totalSupply: mockTotalSupply, + formattedTotalSupply: mockFormattedTotalSupply + }); + }); + + it('should use default network when not provided', async () => { + mockRead.name.mockResolvedValue(mockName); + mockRead.symbol.mockResolvedValue(mockSymbol); + mockRead.decimals.mockResolvedValue(mockDecimals); + mockRead.totalSupply.mockResolvedValue(mockTotalSupply); + + await getERC20TokenInfo(mockTokenAddress); + + expect(mockGetPublicClient).toHaveBeenCalledWith('ethereum'); + }); + + it('should handle different decimals correctly', async () => { + const usdcTotalSupply = BigInt('1000000000000'); + mockRead.name.mockResolvedValue('USDC'); + mockRead.symbol.mockResolvedValue('USDC'); + mockRead.decimals.mockResolvedValue(6); + mockRead.totalSupply.mockResolvedValue(usdcTotalSupply); + mockFormatUnits.mockReturnValue('1000000.0'); + + const result = await getERC20TokenInfo(mockTokenAddress, mockNetwork); + + expect(result.decimals).toBe(6); + expect(result.formattedTotalSupply).toBe('1000000.0'); + expect(mockFormatUnits).toHaveBeenCalledWith(usdcTotalSupply, 6); + }); + }); + + describe('getERC721TokenMetadata', () => { + const mockTokenId = BigInt(1); + const mockName = 'Test NFT'; + const mockSymbol = 'TNFT'; + const mockTokenURI = 'ipfs://QmTest123'; + + const mockRead = { + name: jest.fn(), + symbol: jest.fn(), + tokenURI: jest.fn() + }; + + const mockContract = { + read: mockRead + }; + + beforeEach(() => { + mockGetPublicClient.mockReturnValue({} as never); + mockGetContract.mockReturnValue(mockContract as never); + }); + + it('should return ERC721 token metadata', async () => { + mockRead.name.mockResolvedValue(mockName); + mockRead.symbol.mockResolvedValue(mockSymbol); + mockRead.tokenURI.mockResolvedValue(mockTokenURI); + + const result = await getERC721TokenMetadata(mockTokenAddress, mockTokenId, mockNetwork); + + expect(mockGetPublicClient).toHaveBeenCalledWith(mockNetwork); + expect(mockGetContract).toHaveBeenCalledWith({ + address: mockTokenAddress, + abi: expect.any(Array), + client: expect.any(Object) + }); + expect(result).toEqual({ + name: mockName, + symbol: mockSymbol, + tokenURI: mockTokenURI + }); + }); + + it('should use default network when not provided', async () => { + mockRead.name.mockResolvedValue(mockName); + mockRead.symbol.mockResolvedValue(mockSymbol); + mockRead.tokenURI.mockResolvedValue(mockTokenURI); + + await getERC721TokenMetadata(mockTokenAddress, mockTokenId); + + expect(mockGetPublicClient).toHaveBeenCalledWith('ethereum'); + }); + + it('should pass tokenId as array argument to tokenURI', async () => { + mockRead.name.mockResolvedValue(mockName); + mockRead.symbol.mockResolvedValue(mockSymbol); + mockRead.tokenURI.mockResolvedValue(mockTokenURI); + + await getERC721TokenMetadata(mockTokenAddress, mockTokenId, mockNetwork); + + expect(mockRead.tokenURI).toHaveBeenCalledWith([mockTokenId]); + }); + }); + + describe('getERC1155TokenURI', () => { + const mockTokenId = BigInt(42); + const mockTokenURI = 'ipfs://QmERC1155Test'; + + const mockRead = { + uri: jest.fn() + }; + + const mockContract = { + read: mockRead + }; + + beforeEach(() => { + mockGetPublicClient.mockReturnValue({} as never); + mockGetContract.mockReturnValue(mockContract as never); + }); + + it('should return ERC1155 token URI', async () => { + mockRead.uri.mockResolvedValue(mockTokenURI); + + const result = await getERC1155TokenURI(mockTokenAddress, mockTokenId, mockNetwork); + + expect(mockGetPublicClient).toHaveBeenCalledWith(mockNetwork); + expect(mockGetContract).toHaveBeenCalledWith({ + address: mockTokenAddress, + abi: expect.any(Array), + client: expect.any(Object) + }); + expect(result).toBe(mockTokenURI); + }); + + it('should use default network when not provided', async () => { + mockRead.uri.mockResolvedValue(mockTokenURI); + + await getERC1155TokenURI(mockTokenAddress, mockTokenId); + + expect(mockGetPublicClient).toHaveBeenCalledWith('ethereum'); + }); + + it('should pass tokenId as array argument to uri', async () => { + mockRead.uri.mockResolvedValue(mockTokenURI); + + await getERC1155TokenURI(mockTokenAddress, mockTokenId, mockNetwork); + + expect(mockRead.uri).toHaveBeenCalledWith([mockTokenId]); + }); + }); +}); From c2dc5bdf1ef356c122ac6b07d7dba26f0c6f8994 Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:04 +1000 Subject: [PATCH 05/18] test: add src/core/services/utils.test.ts --- src/core/services/utils.test.ts | 90 +++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/core/services/utils.test.ts diff --git a/src/core/services/utils.test.ts b/src/core/services/utils.test.ts new file mode 100644 index 0000000..68ab6b0 --- /dev/null +++ b/src/core/services/utils.test.ts @@ -0,0 +1,90 @@ +import { utils } from './utils.js'; + +describe('utils', () => { + describe('formatBigInt', () => { + it('should format a bigint to a string', () => { + expect(utils.formatBigInt(123n)).toBe('123'); + }); + + it('should format a large bigint to a string', () => { + expect(utils.formatBigInt(9007199254740991n)).toBe('9007199254740991'); + }); + + it('should format zero bigint to string', () => { + expect(utils.formatBigInt(0n)).toBe('0'); + }); + }); + + describe('formatJson', () => { + it('should format an object to JSON with bigint handling', () => { + const obj = { value: 100n, name: 'test' }; + expect(utils.formatJson(obj)).toBe(JSON.stringify(obj, (_, value) => + typeof value === 'bigint' ? value.toString() : value, 2)); + }); + + it('should format a simple object without bigint', () => { + const obj = { name: 'test', count: 42 }; + expect(utils.formatJson(obj)).toBe(JSON.stringify(obj, null, 2)); + }); + + it('should format nested objects with bigint handling', () => { + const obj = { nested: { value: 50n }, total: 100n }; + expect(utils.formatJson(obj)).toBe(JSON.stringify(obj, (_, value) => + typeof value === 'bigint' ? value.toString() : value, 2)); + }); + }); + + describe('formatNumber', () => { + it('should format a number with commas', () => { + expect(utils.formatNumber(1000)).toBe('1,000'); + }); + + it('should format a string number with commas', () => { + expect(utils.formatNumber('1000')).toBe('1,000'); + }); + + it('should format a large number with commas', () => { + expect(utils.formatNumber(1000000)).toBe('1,000,000'); + }); + + it('should format a decimal number', () => { + expect(utils.formatNumber(1234.56)).toBe('1,234.56'); + }); + }); + + describe('hexToNumber', () => { + it('should convert a hex string to a number', () => { + expect(utils.hexToNumber('0xff')).toBe(255); + }); + + it('should convert hex string without prefix to a number', () => { + expect(utils.hexToNumber('ff')).toBe(255); + }); + + it('should convert zero hex to zero', () => { + expect(utils.hexToNumber('0x0')).toBe(0); + }); + + it('should convert larger hex to number', () => { + expect(utils.hexToNumber('0x10')).toBe(16); + }); + }); + + describe('numberToHex', () => { + it('should convert a number to a hex string', () => { + expect(utils.numberToHex(255)).toBe('0xff'); + }); + + it('should convert zero to hex string', () => { + expect(utils.numberToHex(0)).toBe('0x0'); + }); + + it('should convert 16 to hex string', () => { + expect(utils.numberToHex(16)).toBe('0x10'); + }); + + it('should convert larger number to hex string', () => { + expect(utils.numberToHex(4096)).toBe('0x1000'); + }); + }); +}); From f14b5b7b033b9c20fe1c8c3d32d90288b8944994 Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:04 +1000 Subject: [PATCH 06/18] test: add src/core/services/ens.test.ts --- src/core/services/ens.test.ts | 141 ++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 src/core/services/ens.test.ts diff --git a/src/core/services/ens.test.ts b/src/core/services/ens.test.ts new file mode 100644 index 0000000..2224524 --- /dev/null +++ b/src/core/services/ens.test.ts @@ -0,0 +1,141 @@ +import { resolveAddress } from './ens.js'; +import { normalize } from 'viem/ens'; +import { getPublicClient } from './clients.js'; + +jest.mock('viem/ens', () => ({ + normalize: jest.fn(), +})); + +jest.mock('./clients.js', () => ({ + getPublicClient: jest.fn(), +})); + +describe('resolveAddress', () => { + const mockGetEnsAddress = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (getPublicClient as jest.Mock).mockReturnValue({ + getEnsAddress: mockGetEnsAddress, + }); + }); + + describe('when given a valid Ethereum address', () => { + it('should return the address unchanged (lowercase)', async () => { + const address = '0x1234567890123456789012345678901234567890'; + const result = await resolveAddress(address); + expect(result).toBe(address); + expect(getPublicClient).not.toHaveBeenCalled(); + expect(normalize).not.toHaveBeenCalled(); + }); + + it('should return the address unchanged (uppercase)', async () => { + const address = '0xABCDEF1234567890ABCDEF1234567890ABCDEF12'; + const result = await resolveAddress(address); + expect(result).toBe(address); + }); + + it('should return the address unchanged with mixed case', async () => { + const address = '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12'; + const result = await resolveAddress(address); + expect(result).toBe(address); + }); + }); + + describe('when given an ENS name', () => { + const ensName = 'example.eth'; + const resolvedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const normalizedEns = 'example.eth'; + + beforeEach(() => { + (normalize as jest.Mock).mockReturnValue(normalizedEns); + }); + + it('should resolve a valid ENS name to an address', async () => { + mockGetEnsAddress.mockResolvedValue(resolvedAddress); + + const result = await resolveAddress(ensName); + + expect(normalize).toHaveBeenCalledWith(ensName); + expect(getPublicClient).toHaveBeenCalledWith('ethereum'); + expect(mockGetEnsAddress).toHaveBeenCalledWith({ name: normalizedEns }); + expect(result).toBe(resolvedAddress); + }); + + it('should use the specified network when provided', async () => { + mockGetEnsAddress.mockResolvedValue(resolvedAddress); + + await resolveAddress(ensName, 'sepolia'); + + expect(getPublicClient).toHaveBeenCalledWith('sepolia'); + }); + + it('should throw an error if ENS resolution returns null', async () => { + mockGetEnsAddress.mockResolvedValue(null); + + await expect(resolveAddress(ensName)).rejects.toThrow( + `ENS name ${ensName} could not be resolved to an address` + ); + }); + + it('should throw an error if ENS resolution fails', async () => { + const errorMessage = 'ENS name not found'; + mockGetEnsAddress.mockRejectedValue(new Error(errorMessage)); + + await expect(resolveAddress(ensName)).rejects.toThrow( + `Failed to resolve ENS name ${ensName}: ${errorMessage}` + ); + }); + + it('should throw an error if normalize fails', async () => { + const normalizeError = new Error('Invalid ENS name'); + (normalize as jest.Mock).mockImplementation(() => { + throw normalizeError; + }); + + await expect(resolveAddress(ensName)).rejects.toThrow( + `Failed to resolve ENS name ${ensName}: ${normalizeError.message}` + ); + }); + }); + + describe('when given an invalid input', () => { + it('should throw an error for a string that is not a valid address or ENS name', async () => { + const invalidInput = 'not-an-address-or-ens'; + + await expect(resolveAddress(invalidInput)).rejects.toThrow( + `Invalid address or ENS name: ${invalidInput}` + ); + }); + + it('should throw an error for an empty string', async () => { + await expect(resolveAddress('')).rejects.toThrow( + 'Invalid address or ENS name: ' + ); + }); + + it('should throw an error for an address with incorrect length', async () => { + const invalidAddress = '0x1234567890'; + + await expect(resolveAddress(invalidAddress)).rejects.toThrow( + `Invalid address or ENS name: ${invalidAddress}` + ); + }); + + it('should throw an error for an address with invalid hex characters', async () => { + const invalidAddress = '0xGGGG567890123456789012345678901234567890'; + + await expect(resolveAddress(invalidAddress)).rejects.toThrow( + `Invalid address or ENS name: ${invalidAddress}` + ); + }); + + it('should throw an error for an address missing 0x prefix', async () => { + const invalidAddress = '1234567890123456789012345678901234567890'; + + await expect(resolveAddress(invalidAddress)).rejects.toThrow( + `Invalid address or ENS name: ${invalidAddress}` + ); + }); + }); +}); From 35f0fb670f3a076438d9f5b2698202a0bd051026 Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:04 +1000 Subject: [PATCH 07/18] test: add src/core/services/index.test.ts --- src/core/services/index.test.ts | 297 ++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 src/core/services/index.test.ts diff --git a/src/core/services/index.test.ts b/src/core/services/index.test.ts new file mode 100644 index 0000000..ad58914 --- /dev/null +++ b/src/core/services/index.test.ts @@ -0,0 +1,297 @@ +import * as services from './index.js'; + +describe('services/index', () => { + describe('exports from clients.js', () => { + it('should export getPublicClient', () => { + expect(services.getPublicClient).toBeDefined(); + expect(typeof services.getPublicClient).toBe('function'); + }); + + it('should export getWalletClient', () => { + expect(services.getWalletClient).toBeDefined(); + expect(typeof services.getWalletClient).toBe('function'); + }); + + it('should export getAddressFromPrivateKey', () => { + expect(services.getAddressFromPrivateKey).toBeDefined(); + expect(typeof services.getAddressFromPrivateKey).toBe('function'); + }); + }); + + describe('exports from balance.js', () => { + it('should export getETHBalance', () => { + expect(services.getETHBalance).toBeDefined(); + expect(typeof services.getETHBalance).toBe('function'); + }); + + it('should export getERC20Balance', () => { + expect(services.getERC20Balance).toBeDefined(); + expect(typeof services.getERC20Balance).toBe('function'); + }); + + it('should export isNFTOwner', () => { + expect(services.isNFTOwner).toBeDefined(); + expect(typeof services.isNFTOwner).toBe('function'); + }); + + it('should export getERC721Balance', () => { + expect(services.getERC721Balance).toBeDefined(); + expect(typeof services.getERC721Balance).toBe('function'); + }); + + it('should export getERC1155Balance', () => { + expect(services.getERC1155Balance).toBeDefined(); + expect(typeof services.getERC1155Balance).toBe('function'); + }); + }); + + describe('exports from transfer.js', () => { + it('should export transferETH', () => { + expect(services.transferETH).toBeDefined(); + expect(typeof services.transferETH).toBe('function'); + }); + + it('should export transferERC20', () => { + expect(services.transferERC20).toBeDefined(); + expect(typeof services.transferERC20).toBe('function'); + }); + + it('should export approveERC20', () => { + expect(services.approveERC20).toBeDefined(); + expect(typeof services.approveERC20).toBe('function'); + }); + + it('should export transferERC721', () => { + expect(services.transferERC721).toBeDefined(); + expect(typeof services.transferERC721).toBe('function'); + }); + + it('should export transferERC1155', () => { + expect(services.transferERC1155).toBeDefined(); + expect(typeof services.transferERC1155).toBe('function'); + }); + }); + + describe('exports from blocks.js', () => { + it('should export getBlockNumber', () => { + expect(services.getBlockNumber).toBeDefined(); + expect(typeof services.getBlockNumber).toBe('function'); + }); + + it('should export getBlockByNumber', () => { + expect(services.getBlockByNumber).toBeDefined(); + expect(typeof services.getBlockByNumber).toBe('function'); + }); + + it('should export getBlockByHash', () => { + expect(services.getBlockByHash).toBeDefined(); + expect(typeof services.getBlockByHash).toBe('function'); + }); + + it('should export getLatestBlock', () => { + expect(services.getLatestBlock).toBeDefined(); + expect(typeof services.getLatestBlock).toBe('function'); + }); + }); + + describe('exports from transactions.js', () => { + it('should export getTransaction', () => { + expect(services.getTransaction).toBeDefined(); + expect(typeof services.getTransaction).toBe('function'); + }); + + it('should export getTransactionReceipt', () => { + expect(services.getTransactionReceipt).toBeDefined(); + expect(typeof services.getTransactionReceipt).toBe('function'); + }); + + it('should export getTransactionCount', () => { + expect(services.getTransactionCount).toBeDefined(); + expect(typeof services.getTransactionCount).toBe('function'); + }); + + it('should export estimateGas', () => { + expect(services.estimateGas).toBeDefined(); + expect(typeof services.estimateGas).toBe('function'); + }); + + it('should export getChainId', () => { + expect(services.getChainId).toBeDefined(); + expect(typeof services.getChainId).toBe('function'); + }); + }); + + describe('exports from contracts.js', () => { + it('should export readContract', () => { + expect(services.readContract).toBeDefined(); + expect(typeof services.readContract).toBe('function'); + }); + + it('should export writeContract', () => { + expect(services.writeContract).toBeDefined(); + expect(typeof services.writeContract).toBe('function'); + }); + + it('should export getLogs', () => { + expect(services.getLogs).toBeDefined(); + expect(typeof services.getLogs).toBe('function'); + }); + + it('should export isContract', () => { + expect(services.isContract).toBeDefined(); + expect(typeof services.isContract).toBe('function'); + }); + + it('should export multicall', () => { + expect(services.multicall).toBeDefined(); + expect(typeof services.multicall).toBe('function'); + }); + }); + + describe('exports from tokens.js', () => { + it('should export getERC20TokenInfo', () => { + expect(services.getERC20TokenInfo).toBeDefined(); + expect(typeof services.getERC20TokenInfo).toBe('function'); + }); + + it('should export getERC721TokenMetadata', () => { + expect(services.getERC721TokenMetadata).toBeDefined(); + expect(typeof services.getERC721TokenMetadata).toBe('function'); + }); + + it('should export getERC1155TokenURI', () => { + expect(services.getERC1155TokenURI).toBeDefined(); + expect(typeof services.getERC1155TokenURI).toBe('function'); + }); + }); + + describe('exports from ens.js', () => { + it('should export resolveAddress', () => { + expect(services.resolveAddress).toBeDefined(); + expect(typeof services.resolveAddress).toBe('function'); + }); + }); + + describe('exports from abi.js', () => { + it('should export fetchContractABI', () => { + expect(services.fetchContractABI).toBeDefined(); + expect(typeof services.fetchContractABI).toBe('function'); + }); + + it('should export parseABI', () => { + expect(services.parseABI).toBeDefined(); + expect(typeof services.parseABI).toBe('function'); + }); + + it('should export getReadableFunctions', () => { + expect(services.getReadableFunctions).toBeDefined(); + expect(typeof services.getReadableFunctions).toBe('function'); + }); + + it('should export getFunctionFromABI', () => { + expect(services.getFunctionFromABI).toBeDefined(); + expect(typeof services.getFunctionFromABI).toBe('function'); + }); + }); + + describe('exports from wallet.js', () => { + it('should export getConfiguredAccount', () => { + expect(services.getConfiguredAccount).toBeDefined(); + expect(typeof services.getConfiguredAccount).toBe('function'); + }); + + it('should export getConfiguredPrivateKey', () => { + expect(services.getConfiguredPrivateKey).toBeDefined(); + expect(typeof services.getConfiguredPrivateKey).toBe('function'); + }); + + it('should export getWalletAddressFromKey', () => { + expect(services.getWalletAddressFromKey).toBeDefined(); + expect(typeof services.getWalletAddressFromKey).toBe('function'); + }); + + it('should export getConfiguredWallet', () => { + expect(services.getConfiguredWallet).toBeDefined(); + expect(typeof services.getConfiguredWallet).toBe('function'); + }); + + it('should export signMessage', () => { + expect(services.signMessage).toBeDefined(); + expect(typeof services.signMessage).toBe('function'); + }); + + it('should export signTypedData', () => { + expect(services.signTypedData).toBeDefined(); + expect(typeof services.signTypedData).toBe('function'); + }); + }); + + describe('exports from utils.js', () => { + it('should export helpers object', () => { + expect(services.helpers).toBeDefined(); + expect(typeof services.helpers).toBe('object'); + }); + + it('should export parseEther via helpers', () => { + expect(services.helpers.parseEther).toBeDefined(); + expect(typeof services.helpers.parseEther).toBe('function'); + }); + + it('should export formatEther via helpers', () => { + expect(services.helpers.formatEther).toBeDefined(); + expect(typeof services.helpers.formatEther).toBe('function'); + }); + + it('should export formatBigInt via helpers', () => { + expect(services.helpers.formatBigInt).toBeDefined(); + expect(typeof services.helpers.formatBigInt).toBe('function'); + }); + + it('should export formatJson via helpers', () => { + expect(services.helpers.formatJson).toBeDefined(); + expect(typeof services.helpers.formatJson).toBe('function'); + }); + + it('should export formatNumber via helpers', () => { + expect(services.helpers.formatNumber).toBeDefined(); + expect(typeof services.helpers.formatNumber).toBe('function'); + }); + + it('should export hexToNumber via helpers', () => { + expect(services.helpers.hexToNumber).toBeDefined(); + expect(typeof services.helpers.hexToNumber).toBe('function'); + }); + + it('should export numberToHex via helpers', () => { + expect(services.helpers.numberToHex).toBeDefined(); + expect(typeof services.helpers.numberToHex).toBe('function'); + }); + }); + + describe('re-exported viem types', () => { + it('should have Address type available (runtime check skipped for types)', () => { + // Types are compile-time only, so we verify the module exports exist + expect(services).toBeDefined(); + }); + + it('should have Hash type available (runtime check skipped for types)', () => { + expect(services).toBeDefined(); + }); + + it('should have Hex type available (runtime check skipped for types)', () => { + expect(services).toBeDefined(); + }); + + it('should have Block type available (runtime check skipped for types)', () => { + expect(services).toBeDefined(); + }); + + it('should have TransactionReceipt type available (runtime check skipped for types)', () => { + expect(services).toBeDefined(); + }); + + it('should have Log type available (runtime check skipped for types)', () => { + expect(services).toBeDefined(); + }); + }); +}); From bed80d4d35a7061ea9b77e8d9b460e29f020799c Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:05 +1000 Subject: [PATCH 08/18] test: add src/core/services/contracts.test.ts --- src/core/services/contracts.test.ts | 323 ++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 src/core/services/contracts.test.ts diff --git a/src/core/services/contracts.test.ts b/src/core/services/contracts.test.ts new file mode 100644 index 0000000..a11012d --- /dev/null +++ b/src/core/services/contracts.test.ts @@ -0,0 +1,323 @@ +import { + readContract, + writeContract, + getLogs, + isContract, + multicall +} from './contracts.js'; +import { getPublicClient, getWalletClient } from './clients.js'; +import { resolveAddress } from './ens.js'; +import type { Address, Hex } from 'viem'; + +// Mock dependencies +jest.mock('./clients.js', () => ({ + getPublicClient: jest.fn(), + getWalletClient: jest.fn() +})); + +jest.mock('./ens.js', () => ({ + resolveAddress: jest.fn() +})); + +const mockGetPublicClient = getPublicClient as jest.MockedFunction; +const mockGetWalletClient = getWalletClient as jest.MockedFunction; +const mockResolveAddress = resolveAddress as jest.MockedFunction; + +describe('contracts', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('readContract', () => { + it('should read from a contract on default network', async () => { + const mockReadContract = jest.fn().mockResolvedValue('result'); + const mockClient = { readContract: mockReadContract }; + mockGetPublicClient.mockReturnValue(mockClient as any); + + const params = { + address: '0x1234567890123456789012345678901234567890' as Address, + abi: [] as const, + functionName: 'balanceOf', + args: ['0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Address] + }; + + const result = await readContract(params); + + expect(mockGetPublicClient).toHaveBeenCalledWith('ethereum'); + expect(mockReadContract).toHaveBeenCalledWith(params); + expect(result).toBe('result'); + }); + + it('should read from a contract on specified network', async () => { + const mockReadContract = jest.fn().mockResolvedValue('result'); + const mockClient = { readContract: mockReadContract }; + mockGetPublicClient.mockReturnValue(mockClient as any); + + const params = { + address: '0x1234567890123456789012345678901234567890' as Address, + abi: [] as const, + functionName: 'name' + }; + + const result = await readContract(params, 'polygon'); + + expect(mockGetPublicClient).toHaveBeenCalledWith('polygon'); + expect(mockReadContract).toHaveBeenCalledWith(params); + expect(result).toBe('result'); + }); + }); + + describe('writeContract', () => { + it('should write to a contract on default network', async () => { + const mockWriteContract = jest.fn().mockResolvedValue('0xtxhash'); + const mockClient = { writeContract: mockWriteContract }; + mockGetWalletClient.mockReturnValue(mockClient as any); + + const privateKey = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' as Hex; + const params = { + address: '0x1234567890123456789012345678901234567890' as Address, + abi: [] as const, + functionName: 'transfer', + args: ['0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Address, 1000n] + }; + + const result = await writeContract(privateKey, params); + + expect(mockGetWalletClient).toHaveBeenCalledWith(privateKey, 'ethereum'); + expect(mockWriteContract).toHaveBeenCalledWith(params); + expect(result).toBe('0xtxhash'); + }); + + it('should write to a contract on specified network', async () => { + const mockWriteContract = jest.fn().mockResolvedValue('0xtxhash2'); + const mockClient = { writeContract: mockWriteContract }; + mockGetWalletClient.mockReturnValue(mockClient as any); + + const privateKey = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' as Hex; + const params = { + address: '0x1234567890123456789012345678901234567890' as Address, + abi: [] as const, + functionName: 'approve', + args: ['0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Address, 500n] + }; + + const result = await writeContract(privateKey, params, 'arbitrum'); + + expect(mockGetWalletClient).toHaveBeenCalledWith(privateKey, 'arbitrum'); + expect(mockWriteContract).toHaveBeenCalledWith(params); + expect(result).toBe('0xtxhash2'); + }); + }); + + describe('getLogs', () => { + it('should get logs on default network', async () => { + const mockGetLogs = jest.fn().mockResolvedValue([{ logIndex: '0x0' }]); + const mockClient = { getLogs: mockGetLogs }; + mockGetPublicClient.mockReturnValue(mockClient as any); + + const params = { + address: '0x1234567890123456789012345678901234567890' as Address, + fromBlock: 1000000n, + toBlock: 1000100n + }; + + const result = await getLogs(params); + + expect(mockGetPublicClient).toHaveBeenCalledWith('ethereum'); + expect(mockGetLogs).toHaveBeenCalledWith(params); + expect(result).toEqual([{ logIndex: '0x0' }]); + }); + + it('should get logs on specified network', async () => { + const mockGetLogs = jest.fn().mockResolvedValue([{ logIndex: '0x1' }, { logIndex: '0x2' }]); + const mockClient = { getLogs: mockGetLogs }; + mockGetPublicClient.mockReturnValue(mockClient as any); + + const params = { + address: '0x1234567890123456789012345678901234567890' as Address, + fromBlock: 500000n, + toBlock: 500100n + }; + + const result = await getLogs(params, 'base'); + + expect(mockGetPublicClient).toHaveBeenCalledWith('base'); + expect(mockGetLogs).toHaveBeenCalledWith(params); + expect(result).toEqual([{ logIndex: '0x1' }, { logIndex: '0x2' }]); + }); + }); + + describe('isContract', () => { + it('should return true for a contract address', async () => { + mockResolveAddress.mockResolvedValue('0x1234567890123456789012345678901234567890'); + const mockGetBytecode = jest.fn().mockResolvedValue('0x60806040'); + const mockClient = { getBytecode: mockGetBytecode }; + mockGetPublicClient.mockReturnValue(mockClient as any); + + const result = await isContract('0x1234567890123456789012345678901234567890'); + + expect(mockResolveAddress).toHaveBeenCalledWith('0x1234567890123456789012345678901234567890', 'ethereum'); + expect(mockGetBytecode).toHaveBeenCalledWith({ address: '0x1234567890123456789012345678901234567890' }); + expect(result).toBe(true); + }); + + it('should return false for an EOA address', async () => { + mockResolveAddress.mockResolvedValue('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'); + const mockGetBytecode = jest.fn().mockResolvedValue('0x'); + const mockClient = { getBytecode: mockGetBytecode }; + mockGetPublicClient.mockReturnValue(mockClient as any); + + const result = await isContract('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'); + + expect(mockResolveAddress).toHaveBeenCalledWith('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', 'ethereum'); + expect(mockGetBytecode).toHaveBeenCalledWith({ address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' }); + expect(result).toBe(false); + }); + + it('should return false when bytecode is undefined', async () => { + mockResolveAddress.mockResolvedValue('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'); + const mockGetBytecode = jest.fn().mockResolvedValue(undefined); + const mockClient = { getBytecode: mockGetBytecode }; + mockGetPublicClient.mockReturnValue(mockClient as any); + + const result = await isContract('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'); + + expect(result).toBe(false); + }); + + it('should resolve ENS name and check if contract', async () => { + mockResolveAddress.mockResolvedValue('0x1234567890123456789012345678901234567890'); + const mockGetBytecode = jest.fn().mockResolvedValue('0x60806040'); + const mockClient = { getBytecode: mockGetBytecode }; + mockGetPublicClient.mockReturnValue(mockClient as any); + + const result = await isContract('uniswap.eth'); + + expect(mockResolveAddress).toHaveBeenCalledWith('uniswap.eth', 'ethereum'); + expect(mockGetBytecode).toHaveBeenCalledWith({ address: '0x1234567890123456789012345678901234567890' }); + expect(result).toBe(true); + }); + + it('should use specified network for ENS resolution and bytecode check', async () => { + mockResolveAddress.mockResolvedValue('0x1234567890123456789012345678901234567890'); + const mockGetBytecode = jest.fn().mockResolvedValue('0x60806040'); + const mockClient = { getBytecode: mockGetBytecode }; + mockGetPublicClient.mockReturnValue(mockClient as any); + + const result = await isContract('example.eth', 'polygon'); + + expect(mockResolveAddress).toHaveBeenCalledWith('example.eth', 'polygon'); + expect(mockGetPublicClient).toHaveBeenCalledWith('polygon'); + expect(result).toBe(true); + }); + }); + + describe('multicall', () => { + it('should batch multiple contract read calls with default options', async () => { + const mockMulticall = jest.fn().mockResolvedValue([ + { result: 100n, status: 'success' }, + { result: 'Token Name', status: 'success' } + ]); + const mockClient = { multicall: mockMulticall }; + mockGetPublicClient.mockReturnValue(mockClient as any); + + const contracts = [ + { + address: '0x1234567890123456789012345678901234567890' as `0x${string}`, + abi: [], + functionName: 'balanceOf', + args: ['0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'] + }, + { + address: '0x1234567890123456789012345678901234567890' as `0x${string}`, + abi: [], + functionName: 'name' + } + ]; + + const result = await multicall(contracts); + + expect(mockGetPublicClient).toHaveBeenCalledWith('ethereum'); + expect(mockMulticall).toHaveBeenCalledWith({ + contracts, + allowFailure: true + }); + expect(result).toEqual([ + { result: 100n, status: 'success' }, + { result: 'Token Name', status: 'success' } + ]); + }); + + it('should batch calls with allowFailure set to false', async () => { + const mockMulticall = jest.fn().mockResolvedValue([ + { result: 500n }, + { result: 'Symbol' } + ]); + const mockClient = { multicall: mockMulticall }; + mockGetPublicClient.mockReturnValue(mockClient as any); + + const contracts = [ + { + address: '0x1234567890123456789012345678901234567890' as `0x${string}`, + abi: [], + functionName: 'totalSupply' + }, + { + address: '0x1234567890123456789012345678901234567890' as `0x${string}`, + abi: [], + functionName: 'symbol' + } + ]; + + const result = await multicall(contracts, false); + + expect(mockMulticall).toHaveBeenCalledWith({ + contracts, + allowFailure: false + }); + expect(result).toEqual([ + { result: 500n }, + { result: 'Symbol' } + ]); + }); + + it('should batch calls on specified network', async () => { + const mockMulticall = jest.fn().mockResolvedValue([ + { result: 1000n, status: 'success' } + ]); + const mockClient = { multicall: mockMulticall }; + mockGetPublicClient.mockReturnValue(mockClient as any); + + const contracts = [ + { + address: '0x1234567890123456789012345678901234567890' as `0x${string}`, + abi: [], + functionName: 'getReserves' + } + ]; + + const result = await multicall(contracts, true, 'optimism'); + + expect(mockGetPublicClient).toHaveBeenCalledWith('optimism'); + expect(mockMulticall).toHaveBeenCalledWith({ + contracts, + allowFailure: true + }); + expect(result).toEqual([{ result: 1000n, status: 'success' }]); + }); + + it('should handle empty contracts array', async () => { + const mockMulticall = jest.fn().mockResolvedValue([]); + const mockClient = { multicall: mockMulticall }; + mockGetPublicClient.mockReturnValue(mockClient as any); + + const result = await multicall([]); + + expect(mockMulticall).toHaveBeenCalledWith({ + contracts: [], + allowFailure: true + }); + expect(result).toEqual([]); + }); + }); +}); From 6a9576f1c501cd37a876d5b92b8da5d80ab4e52e Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:05 +1000 Subject: [PATCH 09/18] test: add src/core/services/abi.test.ts --- src/core/services/abi.test.ts | 354 ++++++++++++++++++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 src/core/services/abi.test.ts diff --git a/src/core/services/abi.test.ts b/src/core/services/abi.test.ts new file mode 100644 index 0000000..c0f2b61 --- /dev/null +++ b/src/core/services/abi.test.ts @@ -0,0 +1,354 @@ +import { + fetchContractABI, + parseABI, + getReadableFunctions, + getFunctionFromABI +} from './abi.js'; +import { resolveChainId, getSupportedNetworks } from '../chains.js'; + +// Mock dependencies +jest.mock('../chains.js', () => ({ + resolveChainId: jest.fn(), + getSupportedNetworks: jest.fn() +})); + +const mockResolveChainId = resolveChainId as jest.MockedFunction; +const mockGetSupportedNetworks = getSupportedNetworks as jest.MockedFunction; + +// Mock global fetch +const mockFetch = jest.fn(); +(global as any).fetch = mockFetch; + +describe('abi', () => { + const mockContractAddress = '0x1234567890123456789012345678901234567890'; + const mockABI = JSON.stringify([ + { + inputs: [{ name: 'owner', type: 'address' }], + name: 'balanceOf', + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' + }, + { + inputs: [], + name: 'name', + outputs: [{ type: 'string' }], + stateMutability: 'view', + type: 'function' + }, + { + inputs: [], + name: 'symbol', + outputs: [{ type: 'string' }], + stateMutability: 'pure', + type: 'function' + }, + { + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' } + ], + name: 'transfer', + outputs: [{ type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function' + } + ]); + + beforeEach(() => { + jest.clearAllMocks(); + // Reset environment variable + delete process.env.ETHERSCAN_API_KEY; + }); + + describe('fetchContractABI', () => { + it('should throw error when ETHERSCAN_API_KEY is not set', async () => { + await expect(fetchContractABI(mockContractAddress)) + .rejects + .toThrow('ETHERSCAN_API_KEY environment variable is not set'); + }); + + it('should fetch ABI from Etherscan with default network (ethereum)', async () => { + process.env.ETHERSCAN_API_KEY = 'test-api-key'; + mockResolveChainId.mockReturnValue(1); + mockFetch.mockResolvedValue({ + json: () => Promise.resolve({ status: '1', result: mockABI }) + }); + + const result = await fetchContractABI(mockContractAddress); + + expect(mockResolveChainId).toHaveBeenCalledWith('ethereum'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('https://api.etherscan.io/v2/api') + ); + expect(result).toBe(mockABI); + }); + + it('should fetch ABI from Etherscan with specified network', async () => { + process.env.ETHERSCAN_API_KEY = 'test-api-key'; + mockResolveChainId.mockReturnValue(137); + mockFetch.mockResolvedValue({ + json: () => Promise.resolve({ status: '1', result: mockABI }) + }); + + const result = await fetchContractABI(mockContractAddress, 'polygon'); + + expect(mockResolveChainId).toHaveBeenCalledWith('polygon'); + expect(result).toBe(mockABI); + }); + + it('should throw error when network is not supported', async () => { + process.env.ETHERSCAN_API_KEY = 'test-api-key'; + mockResolveChainId.mockImplementation(() => { + throw new Error('Invalid network'); + }); + mockGetSupportedNetworks.mockReturnValue(['ethereum', 'polygon', 'arbitrum']); + + await expect(fetchContractABI(mockContractAddress, 'invalid-network')) + .rejects + .toThrow('Network "invalid-network" is not supported. Supported: ethereum, polygon, arbitrum'); + }); + + it('should throw error when Etherscan returns status 0 with error message', async () => { + process.env.ETHERSCAN_API_KEY = 'test-api-key'; + mockResolveChainId.mockReturnValue(1); + mockFetch.mockResolvedValue({ + json: () => Promise.resolve({ status: '0', result: 'Contract not verified' }) + }); + + await expect(fetchContractABI(mockContractAddress)) + .rejects + .toThrow('Failed to fetch ABI: Contract not verified'); + }); + + it('should throw error when Etherscan returns no result', async () => { + process.env.ETHERSCAN_API_KEY = 'test-api-key'; + mockResolveChainId.mockReturnValue(1); + mockFetch.mockResolvedValue({ + json: () => Promise.resolve({ status: '1', result: null }) + }); + + await expect(fetchContractABI(mockContractAddress)) + .rejects + .toThrow('No ABI found for this contract'); + }); + + it('should throw error when fetch fails with network error', async () => { + process.env.ETHERSCAN_API_KEY = 'test-api-key'; + mockResolveChainId.mockReturnValue(1); + mockFetch.mockRejectedValue(new Error('Network error')); + + await expect(fetchContractABI(mockContractAddress)) + .rejects + .toThrow('Failed to fetch ABI: Network error'); + }); + + it('should throw error when fetch returns non-JSON response', async () => { + process.env.ETHERSCAN_API_KEY = 'test-api-key'; + mockResolveChainId.mockReturnValue(1); + mockFetch.mockImplementation(() => { + throw new TypeError('Unexpected token in JSON'); + }); + + await expect(fetchContractABI(mockContractAddress)) + .rejects + .toThrow('Failed to fetch ABI: Unexpected token in JSON'); + }); + + it('should handle unknown error type', async () => { + process.env.ETHERSCAN_API_KEY = 'test-api-key'; + mockResolveChainId.mockReturnValue(1); + mockFetch.mockImplementation(() => { + throw 'string error'; + }); + + await expect(fetchContractABI(mockContractAddress)) + .rejects + .toBe('string error'); + }); + }); + + describe('parseABI', () => { + it('should parse valid ABI JSON string', () => { + const result = parseABI(mockABI); + expect(result).toEqual(JSON.parse(mockABI)); + expect(Array.isArray(result)).toBe(true); + }); + + it('should throw error for invalid JSON', () => { + const invalidJSON = '{ invalid json }'; + expect(() => parseABI(invalidJSON)) + .toThrow('Invalid ABI JSON:'); + }); + + it('should throw error when ABI is not an array', () => { + const notArray = JSON.stringify({ name: 'not-an-array' }); + expect(() => parseABI(notArray)) + .toThrow('ABI must be a JSON array'); + }); + + it('should throw error for empty string', () => { + expect(() => parseABI('')) + .toThrow('Invalid ABI JSON:'); + }); + + it('should throw error for null input', () => { + expect(() => parseABI('null')) + .toThrow('ABI must be a JSON array'); + }); + + it('should parse empty ABI array', () => { + const emptyABI = '[]'; + const result = parseABI(emptyABI); + expect(result).toEqual([]); + }); + }); + + describe('getReadableFunctions', () => { + it('should return only view and pure functions', () => { + const abi = JSON.parse(mockABI); + const result = getReadableFunctions(abi); + expect(result).toEqual(['balanceOf', 'name', 'symbol']); + }); + + it('should return empty array for ABI with no readable functions', () => { + const abi = [ + { + inputs: [{ name: 'to', type: 'address' }], + name: 'transfer', + outputs: [{ type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function' + } + ]; + const result = getReadableFunctions(abi); + expect(result).toEqual([]); + }); + + it('should return empty array for empty ABI', () => { + const result = getReadableFunctions([]); + expect(result).toEqual([]); + }); + + it('should filter out non-function types', () => { + const abi = [ + { + inputs: [], + name: 'Transfer', + type: 'event' + }, + { + inputs: [], + name: 'name', + outputs: [{ type: 'string' }], + stateMutability: 'view', + type: 'function' + } + ]; + const result = getReadableFunctions(abi); + expect(result).toEqual(['name']); + }); + + it('should handle functions without name property', () => { + const abi = [ + { + inputs: [], + stateMutability: 'view', + type: 'function' + }, + { + inputs: [], + name: 'getName', + stateMutability: 'view', + type: 'function' + } + ]; + const result = getReadableFunctions(abi); + expect(result).toEqual(['getName']); + }); + + it('should include both view and pure functions', () => { + const abi = [ + { + inputs: [], + name: 'viewFunc', + stateMutability: 'view', + type: 'function' + }, + { + inputs: [], + name: 'pureFunc', + stateMutability: 'pure', + type: 'function' + } + ]; + const result = getReadableFunctions(abi); + expect(result).toEqual(['viewFunc', 'pureFunc']); + }); + }); + + describe('getFunctionFromABI', () => { + it('should find and return a function by name', () => { + const abi = JSON.parse(mockABI); + const result = getFunctionFromABI(abi, 'balanceOf'); + expect(result).toEqual(abi[0]); + expect(result.name).toBe('balanceOf'); + }); + + it('should throw error when function is not found', () => { + const abi = JSON.parse(mockABI); + expect(() => getFunctionFromABI(abi, 'nonExistentFunction')) + .toThrow('Function "nonExistentFunction" not found in ABI'); + }); + + it('should throw error with correct function name in message', () => { + const abi = JSON.parse(mockABI); + expect(() => getFunctionFromABI(abi, 'missingFunc')) + .toThrow('Function "missingFunc" not found in ABI'); + }); + + it('should work with empty ABI array', () => { + expect(() => getFunctionFromABI([], 'anyFunction')) + .toThrow('Function "anyFunction" not found in ABI'); + }); + + it('should find function when multiple functions have similar names', () => { + const abi = [ + { + inputs: [], + name: 'balanceOf', + stateMutability: 'view', + type: 'function' + }, + { + inputs: [], + name: 'balanceOfUnderlying', + stateMutability: 'view', + type: 'function' + } + ]; + const result = getFunctionFromABI(abi, 'balanceOf'); + expect(result.name).toBe('balanceOf'); + expect(result).toBe(abi[0]); + }); + + it('should return the first matching function', () => { + const abi = [ + { + inputs: [], + name: 'duplicate', + stateMutability: 'view', + type: 'function' + }, + { + inputs: [], + name: 'duplicate', + stateMutability: 'pure', + type: 'function' + } + ]; + const result = getFunctionFromABI(abi, 'duplicate'); + expect(result).toBe(abi[0]); + }); + }); +}); From acd8d9ae8f9824397486cc4afe1931f4e4d57ea3 Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:05 +1000 Subject: [PATCH 10/18] test: add src/core/services/balance.test.ts --- src/core/services/balance.test.ts | 405 ++++++++++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 src/core/services/balance.test.ts diff --git a/src/core/services/balance.test.ts b/src/core/services/balance.test.ts new file mode 100644 index 0000000..e0ddf4c --- /dev/null +++ b/src/core/services/balance.test.ts @@ -0,0 +1,405 @@ +import { formatEther, formatUnits, getContract } from 'viem'; +import { getPublicClient } from './clients.js'; +import { readContract } from './contracts.js'; +import { resolveAddress } from './ens.js'; + +jest.mock('viem', () => ({ + formatEther: jest.fn(), + formatUnits: jest.fn(), + getContract: jest.fn(), +})); + +jest.mock('./clients.js', () => ({ + getPublicClient: jest.fn(), +})); + +jest.mock('./contracts.js', () => ({ + readContract: jest.fn(), +})); + +jest.mock('./ens.js', () => ({ + resolveAddress: jest.fn(), +})); + +describe('getETHBalance', () => { + const mockGetBalance = jest.fn(); + const mockAddress = '0x1234567890123456789012345678901234567890'; + const mockEnsName = 'example.eth'; + const mockBalance = 1000000000000000000n; + const mockFormattedEther = '1.0'; + + beforeEach(() => { + jest.clearAllMocks(); + (resolveAddress as jest.Mock).mockResolvedValue(mockAddress); + (getPublicClient as jest.Mock).mockReturnValue({ + getBalance: mockGetBalance, + }); + (formatEther as jest.Mock).mockReturnValue(mockFormattedEther); + }); + + describe('when given a valid Ethereum address', () => { + it('should return the ETH balance in wei and ether', async () => { + mockGetBalance.mockResolvedValue(mockBalance); + + const { getETHBalance } = await import('./balance.js'); + const result = await getETHBalance(mockAddress); + + expect(resolveAddress).toHaveBeenCalledWith(mockAddress, 'ethereum'); + expect(getPublicClient).toHaveBeenCalledWith('ethereum'); + expect(mockGetBalance).toHaveBeenCalledWith({ address: mockAddress }); + expect(formatEther).toHaveBeenCalledWith(mockBalance); + expect(result).toEqual({ + wei: mockBalance, + ether: mockFormattedEther, + }); + }); + + it('should use the specified network when provided', async () => { + mockGetBalance.mockResolvedValue(mockBalance); + + const { getETHBalance } = await import('./balance.js'); + await getETHBalance(mockAddress, 'sepolia'); + + expect(resolveAddress).toHaveBeenCalledWith(mockAddress, 'sepolia'); + expect(getPublicClient).toHaveBeenCalledWith('sepolia'); + }); + }); + + describe('when given an ENS name', () => { + it('should resolve the ENS name and return the balance', async () => { + mockGetBalance.mockResolvedValue(mockBalance); + + const { getETHBalance } = await import('./balance.js'); + const result = await getETHBalance(mockEnsName); + + expect(resolveAddress).toHaveBeenCalledWith(mockEnsName, 'ethereum'); + expect(result).toEqual({ + wei: mockBalance, + ether: mockFormattedEther, + }); + }); + }); +}); + +describe('getERC20Balance', () => { + const mockTokenAddress = '0x1234567890123456789012345678901234567890'; + const mockOwnerAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const mockTokenEns = 'token.eth'; + const mockOwnerEns = 'owner.eth'; + const mockBalance = 1000000n; + const mockSymbol = 'TKN'; + const mockDecimals = 18; + const mockFormattedBalance = '1.0'; + + const mockContract = { + read: { + balanceOf: jest.fn(), + symbol: jest.fn(), + decimals: jest.fn(), + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (resolveAddress as jest.Mock) + .mockResolvedValueOnce(mockTokenAddress) + .mockResolvedValueOnce(mockOwnerAddress); + (getContract as jest.Mock).mockReturnValue(mockContract); + mockContract.read.balanceOf.mockResolvedValue(mockBalance); + mockContract.read.symbol.mockResolvedValue(mockSymbol); + mockContract.read.decimals.mockResolvedValue(mockDecimals); + (formatUnits as jest.Mock).mockReturnValue(mockFormattedBalance); + }); + + describe('when given valid addresses', () => { + it('should return the ERC20 balance with token info', async () => { + const { getERC20Balance } = await import('./balance.js'); + const result = await getERC20Balance(mockTokenAddress, mockOwnerAddress); + + expect(resolveAddress).toHaveBeenCalledWith(mockTokenAddress, 'ethereum'); + expect(resolveAddress).toHaveBeenCalledWith(mockOwnerAddress, 'ethereum'); + expect(getContract).toHaveBeenCalledWith({ + address: mockTokenAddress, + abi: expect.any(Array), + client: expect.any(Object), + }); + expect(mockContract.read.balanceOf).toHaveBeenCalledWith([mockOwnerAddress]); + expect(mockContract.read.symbol).toHaveBeenCalled(); + expect(mockContract.read.decimals).toHaveBeenCalled(); + expect(formatUnits).toHaveBeenCalledWith(mockBalance, mockDecimals); + expect(result).toEqual({ + raw: mockBalance, + formatted: mockFormattedBalance, + token: { + symbol: mockSymbol, + decimals: mockDecimals, + }, + }); + }); + + it('should use the specified network when provided', async () => { + const { getERC20Balance } = await import('./balance.js'); + await getERC20Balance(mockTokenAddress, mockOwnerAddress, 'sepolia'); + + expect(resolveAddress).toHaveBeenCalledWith(mockTokenAddress, 'sepolia'); + expect(resolveAddress).toHaveBeenCalledWith(mockOwnerAddress, 'sepolia'); + expect(getPublicClient).toHaveBeenCalledWith('sepolia'); + }); + }); + + describe('when given ENS names', () => { + it('should resolve ENS names and return the balance', async () => { + (resolveAddress as jest.Mock) + .mockResolvedValueOnce(mockTokenAddress) + .mockResolvedValueOnce(mockOwnerAddress); + + const { getERC20Balance } = await import('./balance.js'); + const result = await getERC20Balance(mockTokenEns, mockOwnerEns); + + expect(resolveAddress).toHaveBeenCalledWith(mockTokenEns, 'ethereum'); + expect(resolveAddress).toHaveBeenCalledWith(mockOwnerEns, 'ethereum'); + expect(result).toEqual({ + raw: mockBalance, + formatted: mockFormattedBalance, + token: { + symbol: mockSymbol, + decimals: mockDecimals, + }, + }); + }); + }); +}); + +describe('isNFTOwner', () => { + const mockTokenAddress = '0x1234567890123456789012345678901234567890'; + const mockOwnerAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const mockTokenEns = 'nft.eth'; + const mockOwnerEns = 'owner.eth'; + const mockTokenId = 123n; + const mockActualOwner = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + + beforeEach(() => { + jest.clearAllMocks(); + (resolveAddress as jest.Mock) + .mockResolvedValueOnce(mockTokenAddress) + .mockResolvedValueOnce(mockOwnerAddress); + }); + + describe('when the address owns the NFT', () => { + it('should return true', async () => { + (readContract as jest.Mock).mockResolvedValue(mockActualOwner); + + const { isNFTOwner } = await import('./balance.js'); + const result = await isNFTOwner(mockTokenAddress, mockOwnerAddress, mockTokenId); + + expect(resolveAddress).toHaveBeenCalledWith(mockTokenAddress, 'ethereum'); + expect(resolveAddress).toHaveBeenCalledWith(mockOwnerAddress, 'ethereum'); + expect(readContract).toHaveBeenCalledWith( + { + address: mockTokenAddress, + abi: expect.any(Array), + functionName: 'ownerOf', + args: [mockTokenId], + }, + 'ethereum' + ); + expect(result).toBe(true); + }); + + it('should return true when addresses match (case insensitive)', async () => { + const uppercaseOwner = '0xABCDEFABCDEFABCDEFABCDEFABCDEFABCD'; + (resolveAddress as jest.Mock) + .mockResolvedValueOnce(mockTokenAddress) + .mockResolvedValueOnce(uppercaseOwner); + (readContract as jest.Mock).mockResolvedValue(mockActualOwner); + + const { isNFTOwner } = await import('./balance.js'); + const result = await isNFTOwner(mockTokenAddress, uppercaseOwner, mockTokenId); + + expect(result).toBe(true); + }); + }); + + describe('when the address does not own the NFT', () => { + it('should return false', async () => { + const differentOwner = '0x1111111111111111111111111111111111111111'; + (readContract as jest.Mock).mockResolvedValue(differentOwner); + + const { isNFTOwner } = await import('./balance.js'); + const result = await isNFTOwner(mockTokenAddress, mockOwnerAddress, mockTokenId); + + expect(result).toBe(false); + }); + }); + + describe('when an error occurs', () => { + it('should return false and log the error', async () => { + const errorMessage = 'Token not found'; + (readContract as jest.Mock).mockRejectedValue(new Error(errorMessage)); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const { isNFTOwner } = await import('./balance.js'); + const result = await isNFTOwner(mockTokenAddress, mockOwnerAddress, mockTokenId); + + expect(consoleSpy).toHaveBeenCalledWith( + `Error checking NFT ownership: ${errorMessage}` + ); + expect(result).toBe(false); + + consoleSpy.mockRestore(); + }); + }); + + describe('when given ENS names', () => { + it('should resolve ENS names and check ownership', async () => { + (resolveAddress as jest.Mock) + .mockResolvedValueOnce(mockTokenAddress) + .mockResolvedValueOnce(mockOwnerAddress); + (readContract as jest.Mock).mockResolvedValue(mockActualOwner); + + const { isNFTOwner } = await import('./balance.js'); + const result = await isNFTOwner(mockTokenEns, mockOwnerEns, mockTokenId); + + expect(resolveAddress).toHaveBeenCalledWith(mockTokenEns, 'ethereum'); + expect(resolveAddress).toHaveBeenCalledWith(mockOwnerEns, 'ethereum'); + expect(result).toBe(true); + }); + + it('should use the specified network when provided', async () => { + (resolveAddress as jest.Mock) + .mockResolvedValueOnce(mockTokenAddress) + .mockResolvedValueOnce(mockOwnerAddress); + (readContract as jest.Mock).mockResolvedValue(mockActualOwner); + + const { isNFTOwner } = await import('./balance.js'); + await isNFTOwner(mockTokenEns, mockOwnerEns, mockTokenId, 'sepolia'); + + expect(resolveAddress).toHaveBeenCalledWith(mockTokenEns, 'sepolia'); + expect(resolveAddress).toHaveBeenCalledWith(mockOwnerEns, 'sepolia'); + }); + }); +}); + +describe('getERC721Balance', () => { + const mockTokenAddress = '0x1234567890123456789012345678901234567890'; + const mockOwnerAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const mockTokenEns = 'nft.eth'; + const mockOwnerEns = 'owner.eth'; + const mockBalance = 5n; + + beforeEach(() => { + jest.clearAllMocks(); + (resolveAddress as jest.Mock) + .mockResolvedValueOnce(mockTokenAddress) + .mockResolvedValueOnce(mockOwnerAddress); + (readContract as jest.Mock).mockResolvedValue(mockBalance); + }); + + describe('when given valid addresses', () => { + it('should return the number of NFTs owned', async () => { + const { getERC721Balance } = await import('./balance.js'); + const result = await getERC721Balance(mockTokenAddress, mockOwnerAddress); + + expect(resolveAddress).toHaveBeenCalledWith(mockTokenAddress, 'ethereum'); + expect(resolveAddress).toHaveBeenCalledWith(mockOwnerAddress, 'ethereum'); + expect(readContract).toHaveBeenCalledWith( + { + address: mockTokenAddress, + abi: expect.any(Array), + functionName: 'balanceOf', + args: [mockOwnerAddress], + }, + 'ethereum' + ); + expect(result).toBe(mockBalance); + }); + + it('should use the specified network when provided', async () => { + const { getERC721Balance } = await import('./balance.js'); + await getERC721Balance(mockTokenAddress, mockOwnerAddress, 'sepolia'); + + expect(resolveAddress).toHaveBeenCalledWith(mockTokenAddress, 'sepolia'); + expect(resolveAddress).toHaveBeenCalledWith(mockOwnerAddress, 'sepolia'); + }); + }); + + describe('when given ENS names', () => { + it('should resolve ENS names and return the balance', async () => { + (resolveAddress as jest.Mock) + .mockResolvedValueOnce(mockTokenAddress) + .mockResolvedValueOnce(mockOwnerAddress); + + const { getERC721Balance } = await import('./balance.js'); + const result = await getERC721Balance(mockTokenEns, mockOwnerEns); + + expect(resolveAddress).toHaveBeenCalledWith(mockTokenEns, 'ethereum'); + expect(resolveAddress).toHaveBeenCalledWith(mockOwnerEns, 'ethereum'); + expect(result).toBe(mockBalance); + }); + }); +}); + +describe('getERC1155Balance', () => { + const mockTokenAddress = '0x1234567890123456789012345678901234567890'; + const mockOwnerAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const mockTokenEns = 'token.eth'; + const mockOwnerEns = 'owner.eth'; + const mockTokenId = 456n; + const mockBalance = 100n; + + beforeEach(() => { + jest.clearAllMocks(); + (resolveAddress as jest.Mock) + .mockResolvedValueOnce(mockTokenAddress) + .mockResolvedValueOnce(mockOwnerAddress); + (readContract as jest.Mock).mockResolvedValue(mockBalance); + }); + + describe('when given valid addresses', () => { + it('should return the ERC1155 token balance', async () => { + const { getERC1155Balance } = await import('./balance.js'); + const result = await getERC1155Balance( + mockTokenAddress, + mockOwnerAddress, + mockTokenId + ); + + expect(resolveAddress).toHaveBeenCalledWith(mockTokenAddress, 'ethereum'); + expect(resolveAddress).toHaveBeenCalledWith(mockOwnerAddress, 'ethereum'); + expect(readContract).toHaveBeenCalledWith( + { + address: mockTokenAddress, + abi: expect.any(Array), + functionName: 'balanceOf', + args: [mockOwnerAddress, mockTokenId], + }, + 'ethereum' + ); + expect(result).toBe(mockBalance); + }); + + it('should use the specified network when provided', async () => { + const { getERC1155Balance } = await import('./balance.js'); + await getERC1155Balance(mockTokenAddress, mockOwnerAddress, mockTokenId, 'sepolia'); + + expect(resolveAddress).toHaveBeenCalledWith(mockTokenAddress, 'sepolia'); + expect(resolveAddress).toHaveBeenCalledWith(mockOwnerAddress, 'sepolia'); + }); + }); + + describe('when given ENS names', () => { + it('should resolve ENS names and return the balance', async () => { + (resolveAddress as jest.Mock) + .mockResolvedValueOnce(mockTokenAddress) + .mockResolvedValueOnce(mockOwnerAddress); + + const { getERC1155Balance } = await import('./balance.js'); + const result = await getERC1155Balance(mockTokenEns, mockOwnerEns, mockTokenId); + + expect(resolveAddress).toHaveBeenCalledWith(mockTokenEns, 'ethereum'); + expect(resolveAddress).toHaveBeenCalledWith(mockOwnerEns, 'ethereum'); + expect(result).toBe(mockBalance); + }); + }); +}); From 9440363be1d20c58fca24abefaf79a3a5a9de45d Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:05 +1000 Subject: [PATCH 11/18] test: add src/core/services/transfer.test.ts --- src/core/services/transfer.test.ts | 413 +++++++++++++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 src/core/services/transfer.test.ts diff --git a/src/core/services/transfer.test.ts b/src/core/services/transfer.test.ts new file mode 100644 index 0000000..57a479a --- /dev/null +++ b/src/core/services/transfer.test.ts @@ -0,0 +1,413 @@ +import { parseEther, parseUnits, type Address, type Hash } from 'viem'; +import { transferETH, transferERC20, approveERC20, transferERC721, transferERC1155 } from './transfer.js'; +import { getPublicClient, getWalletClient } from './clients.js'; +import { resolveAddress } from './ens.js'; +import { getContract } from 'viem'; + +// Mock dependencies +jest.mock('./clients.js', () => ({ + getPublicClient: jest.fn(), + getWalletClient: jest.fn() +})); + +jest.mock('./ens.js', () => ({ + resolveAddress: jest.fn() +})); + +jest.mock('viem', () => { + const actual = jest.requireActual('viem'); + return { + ...actual, + getContract: jest.fn() + }; +}); + +describe('Transfer Services', () => { + const mockPrivateKey = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + const mockPrivateKeyNoPrefix = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + const mockToAddress = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const mockToAddressWithZero = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0'; + const mockTokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const mockNftAddress = '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D'; + const mockTxHash = '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' as Hash; + const mockAccount = { address: mockToAddressWithZero as Address }; + const mockChain = { id: 1, name: 'Ethereum' }; + + const mockPublicClient = { + readContract: jest.fn() + }; + + const mockWalletClient = { + account: mockAccount, + chain: mockChain, + sendTransaction: jest.fn(), + writeContract: jest.fn() + }; + + const mockContract = { + read: { + decimals: jest.fn(), + symbol: jest.fn(), + name: jest.fn() + } + }; + + beforeEach(() => { + jest.clearAllMocks(); + (getPublicClient as jest.Mock).mockReturnValue(mockPublicClient); + (getWalletClient as jest.Mock).mockReturnValue(mockWalletClient); + (getContract as jest.Mock).mockReturnValue(mockContract); + (resolveAddress as jest.Mock).mockImplementation(async (addressOrEns: string) => { + if (addressOrEns.includes('.eth')) { + return mockToAddressWithZero; + } + return addressOrEns as Address; + }); + }); + + describe('transferETH', () => { + it('should transfer ETH with a valid address', async () => { + (mockWalletClient.sendTransaction as jest.Mock).mockResolvedValue(mockTxHash); + + const result = await transferETH(mockPrivateKey, mockToAddressWithZero, '1.5', 'ethereum'); + + expect(result).toBe(mockTxHash); + expect(mockWalletClient.sendTransaction).toHaveBeenCalledWith({ + to: mockToAddressWithZero, + value: parseEther('1.5'), + account: mockAccount, + chain: mockChain + }); + }); + + it('should resolve ENS name to address', async () => { + (mockWalletClient.sendTransaction as jest.Mock).mockResolvedValue(mockTxHash); + (resolveAddress as jest.Mock).mockResolvedValue(mockToAddressWithZero); + + const result = await transferETH(mockPrivateKey, 'recipient.eth', '0.5', 'ethereum'); + + expect(resolveAddress).toHaveBeenCalledWith('recipient.eth', 'ethereum'); + expect(result).toBe(mockTxHash); + }); + + it('should handle private key without 0x prefix', async () => { + (mockWalletClient.sendTransaction as jest.Mock).mockResolvedValue(mockTxHash); + + const result = await transferETH(mockPrivateKeyNoPrefix, mockToAddressWithZero, '1.0', 'ethereum'); + + expect(result).toBe(mockTxHash); + expect(getWalletClient).toHaveBeenCalledWith(mockPrivateKey, 'ethereum'); + }); + + it('should use default network when not specified', async () => { + (mockWalletClient.sendTransaction as jest.Mock).mockResolvedValue(mockTxHash); + + await transferETH(mockPrivateKey, mockToAddressWithZero, '1.0'); + + expect(getWalletClient).toHaveBeenCalledWith(mockPrivateKey, 'ethereum'); + }); + + it('should convert amount to wei correctly', async () => { + (mockWalletClient.sendTransaction as jest.Mock).mockResolvedValue(mockTxHash); + + await transferETH(mockPrivateKey, mockToAddressWithZero, '2.5', 'ethereum'); + + expect(mockWalletClient.sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + value: parseEther('2.5') + }) + ); + }); + }); + + describe('transferERC20', () => { + const mockAmount = '100'; + const mockDecimals = 6; + const mockSymbol = 'USDC'; + const mockRawAmount = parseUnits(mockAmount, mockDecimals); + + beforeEach(() => { + mockContract.read.decimals.mockResolvedValue(mockDecimals); + mockContract.read.symbol.mockResolvedValue(mockSymbol); + (mockWalletClient.writeContract as jest.Mock).mockResolvedValue(mockTxHash); + }); + + it('should transfer ERC20 tokens successfully', async () => { + const result = await transferERC20(mockTokenAddress, mockToAddressWithZero, mockAmount, mockPrivateKey, 'ethereum'); + + expect(result).toEqual({ + txHash: mockTxHash, + amount: { + raw: mockRawAmount, + formatted: mockAmount + }, + token: { + symbol: mockSymbol, + decimals: mockDecimals + } + }); + }); + + it('should resolve ENS names for token and recipient', async () => { + (resolveAddress as jest.Mock).mockImplementation(async (addressOrEns: string) => { + if (addressOrEns === 'token.eth') return mockTokenAddress as Address; + if (addressOrEns === 'recipient.eth') return mockToAddressWithZero as Address; + return addressOrEns as Address; + }); + + await transferERC20('token.eth', 'recipient.eth', mockAmount, mockPrivateKey, 'ethereum'); + + expect(resolveAddress).toHaveBeenCalledWith('token.eth', 'ethereum'); + expect(resolveAddress).toHaveBeenCalledWith('recipient.eth', 'ethereum'); + }); + + it('should handle private key without 0x prefix', async () => { + await transferERC20(mockTokenAddress, mockToAddressWithZero, mockAmount, mockPrivateKeyNoPrefix, 'ethereum'); + + expect(getWalletClient).toHaveBeenCalledWith(mockPrivateKey, 'ethereum'); + }); + + it('should use default network when not specified', async () => { + await transferERC20(mockTokenAddress, mockToAddressWithZero, mockAmount, mockPrivateKey); + + expect(getPublicClient).toHaveBeenCalledWith('ethereum'); + expect(getWalletClient).toHaveBeenCalledWith(mockPrivateKey, 'ethereum'); + }); + + it('should fetch token decimals and symbol', async () => { + await transferERC20(mockTokenAddress, mockToAddressWithZero, mockAmount, mockPrivateKey, 'ethereum'); + + expect(mockContract.read.decimals).toHaveBeenCalled(); + expect(mockContract.read.symbol).toHaveBeenCalled(); + }); + + it('should call writeContract with correct parameters', async () => { + await transferERC20(mockTokenAddress, mockToAddressWithZero, mockAmount, mockPrivateKey, 'ethereum'); + + expect(mockWalletClient.writeContract).toHaveBeenCalledWith({ + address: mockTokenAddress, + abi: expect.any(Array), + functionName: 'transfer', + args: [mockToAddressWithZero, mockRawAmount], + account: mockAccount, + chain: mockChain + }); + }); + }); + + describe('approveERC20', () => { + const mockAmount = '500'; + const mockDecimals = 18; + const mockSymbol = 'DAI'; + const mockSpenderAddress = '0x1234567890123456789012345678901234567890'; + const mockRawAmount = parseUnits(mockAmount, mockDecimals); + + beforeEach(() => { + mockContract.read.decimals.mockResolvedValue(mockDecimals); + mockContract.read.symbol.mockResolvedValue(mockSymbol); + (mockWalletClient.writeContract as jest.Mock).mockResolvedValue(mockTxHash); + }); + + it('should approve ERC20 token spending successfully', async () => { + const result = await approveERC20(mockTokenAddress, mockSpenderAddress, mockAmount, mockPrivateKey, 'ethereum'); + + expect(result).toEqual({ + txHash: mockTxHash, + amount: { + raw: mockRawAmount, + formatted: mockAmount + }, + token: { + symbol: mockSymbol, + decimals: mockDecimals + } + }); + }); + + it('should resolve ENS names for token and spender', async () => { + (resolveAddress as jest.Mock).mockImplementation(async (addressOrEns: string) => { + if (addressOrEns === 'token.eth') return mockTokenAddress as Address; + if (addressOrEns === 'spender.eth') return mockSpenderAddress as Address; + return addressOrEns as Address; + }); + + await approveERC20('token.eth', 'spender.eth', mockAmount, mockPrivateKey, 'ethereum'); + + expect(resolveAddress).toHaveBeenCalledWith('token.eth', 'ethereum'); + expect(resolveAddress).toHaveBeenCalledWith('spender.eth', 'ethereum'); + }); + + it('should handle private key without 0x prefix', async () => { + await approveERC20(mockTokenAddress, mockSpenderAddress, mockAmount, mockPrivateKeyNoPrefix, 'ethereum'); + + expect(getWalletClient).toHaveBeenCalledWith(mockPrivateKey, 'ethereum'); + }); + + it('should use default network when not specified', async () => { + await approveERC20(mockTokenAddress, mockSpenderAddress, mockAmount, mockPrivateKey); + + expect(getPublicClient).toHaveBeenCalledWith('ethereum'); + expect(getWalletClient).toHaveBeenCalledWith(mockPrivateKey, 'ethereum'); + }); + + it('should call writeContract with approve function', async () => { + await approveERC20(mockTokenAddress, mockSpenderAddress, mockAmount, mockPrivateKey, 'ethereum'); + + expect(mockWalletClient.writeContract).toHaveBeenCalledWith({ + address: mockTokenAddress, + abi: expect.any(Array), + functionName: 'approve', + args: [mockSpenderAddress, mockRawAmount], + account: mockAccount, + chain: mockChain + }); + }); + }); + + describe('transferERC721', () => { + const mockTokenId = 12345n; + const mockTokenName = 'Bored Ape Yacht Club'; + const mockTokenSymbol = 'BAYC'; + + beforeEach(() => { + mockContract.read.name.mockResolvedValue(mockTokenName); + mockContract.read.symbol.mockResolvedValue(mockTokenSymbol); + (mockWalletClient.writeContract as jest.Mock).mockResolvedValue(mockTxHash); + }); + + it('should transfer ERC721 NFT successfully', async () => { + const result = await transferERC721(mockNftAddress, mockToAddressWithZero, mockTokenId, mockPrivateKey, 'ethereum'); + + expect(result).toEqual({ + txHash: mockTxHash, + tokenId: mockTokenId.toString(), + token: { + name: mockTokenName, + symbol: mockTokenSymbol + } + }); + }); + + it('should resolve ENS names for token and recipient', async () => { + (resolveAddress as jest.Mock).mockImplementation(async (addressOrEns: string) => { + if (addressOrEns === 'nft.eth') return mockNftAddress as Address; + if (addressOrEns === 'recipient.eth') return mockToAddressWithZero as Address; + return addressOrEns as Address; + }); + + await transferERC721('nft.eth', 'recipient.eth', mockTokenId, mockPrivateKey, 'ethereum'); + + expect(resolveAddress).toHaveBeenCalledWith('nft.eth', 'ethereum'); + expect(resolveAddress).toHaveBeenCalledWith('recipient.eth', 'ethereum'); + }); + + it('should handle private key without 0x prefix', async () => { + await transferERC721(mockNftAddress, mockToAddressWithZero, mockTokenId, mockPrivateKeyNoPrefix, 'ethereum'); + + expect(getWalletClient).toHaveBeenCalledWith(mockPrivateKey, 'ethereum'); + }); + + it('should use default network when not specified', async () => { + await transferERC721(mockNftAddress, mockToAddressWithZero, mockTokenId, mockPrivateKey); + + expect(getWalletClient).toHaveBeenCalledWith(mockPrivateKey, 'ethereum'); + }); + + it('should use sender address as from address in transferFrom', async () => { + await transferERC721(mockNftAddress, mockToAddressWithZero, mockTokenId, mockPrivateKey, 'ethereum'); + + expect(mockWalletClient.writeContract).toHaveBeenCalledWith({ + address: mockNftAddress, + abi: expect.any(Array), + functionName: 'transferFrom', + args: [mockToAddressWithZero, mockToAddressWithZero, mockTokenId], + account: mockAccount, + chain: mockChain + }); + }); + + it('should handle metadata fetch errors gracefully', async () => { + mockContract.read.name.mockRejectedValue(new Error('Metadata error')); + mockContract.read.symbol.mockRejectedValue(new Error('Metadata error')); + + const result = await transferERC721(mockNftAddress, mockToAddressWithZero, mockTokenId, mockPrivateKey, 'ethereum'); + + expect(result).toEqual({ + txHash: mockTxHash, + tokenId: mockTokenId.toString(), + token: { + name: 'Unknown', + symbol: 'NFT' + } + }); + }); + }); + + describe('transferERC1155', () => { + const mockTokenId = 67890n; + const mockAmount = '5'; + + beforeEach(() => { + (mockWalletClient.writeContract as jest.Mock).mockResolvedValue(mockTxHash); + }); + + it('should transfer ERC1155 tokens successfully', async () => { + const result = await transferERC1155(mockNftAddress, mockToAddressWithZero, mockTokenId, mockAmount, mockPrivateKey, 'ethereum'); + + expect(result).toEqual({ + txHash: mockTxHash, + tokenId: mockTokenId.toString(), + amount: mockAmount + }); + }); + + it('should resolve ENS names for token and recipient', async () => { + (resolveAddress as jest.Mock).mockImplementation(async (addressOrEns: string) => { + if (addressOrEns === 'token.eth') return mockNftAddress as Address; + if (addressOrEns === 'recipient.eth') return mockToAddressWithZero as Address; + return addressOrEns as Address; + }); + + await transferERC1155('token.eth', 'recipient.eth', mockTokenId, mockAmount, mockPrivateKey, 'ethereum'); + + expect(resolveAddress).toHaveBeenCalledWith('token.eth', 'ethereum'); + expect(resolveAddress).toHaveBeenCalledWith('recipient.eth', 'ethereum'); + }); + + it('should handle private key without 0x prefix', async () => { + await transferERC1155(mockNftAddress, mockToAddressWithZero, mockTokenId, mockAmount, mockPrivateKeyNoPrefix, 'ethereum'); + + expect(getWalletClient).toHaveBeenCalledWith(mockPrivateKey, 'ethereum'); + }); + + it('should use default network when not specified', async () => { + await transferERC1155(mockNftAddress, mockToAddressWithZero, mockTokenId, mockAmount, mockPrivateKey); + + expect(getWalletClient).toHaveBeenCalledWith(mockPrivateKey, 'ethereum'); + }); + + it('should convert amount string to bigint', async () => { + await transferERC1155(mockNftAddress, mockToAddressWithZero, mockTokenId, mockAmount, mockPrivateKey, 'ethereum'); + + expect(mockWalletClient.writeContract).toHaveBeenCalledWith({ + address: mockNftAddress, + abi: expect.any(Array), + functionName: 'safeTransferFrom', + args: [mockToAddressWithZero, mockToAddressWithZero, mockTokenId, BigInt(mockAmount), '0x'], + account: mockAccount, + chain: mockChain + }); + }); + + it('should use sender address as from address in safeTransferFrom', async () => { + await transferERC1155(mockNftAddress, mockToAddressWithZero, mockTokenId, mockAmount, mockPrivateKey, 'ethereum'); + + expect(mockWalletClient.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: 'safeTransferFrom', + args: expect.arrayContaining([mockToAddressWithZero, mockToAddressWithZero]) + }) + ); + }); + }); +}); From c038a1b4fad3f77265b110c78c643e8744860851 Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:05 +1000 Subject: [PATCH 12/18] test: add src/core/services/blocks.test.ts --- src/core/services/blocks.test.ts | 154 +++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/core/services/blocks.test.ts diff --git a/src/core/services/blocks.test.ts b/src/core/services/blocks.test.ts new file mode 100644 index 0000000..91ad4a0 --- /dev/null +++ b/src/core/services/blocks.test.ts @@ -0,0 +1,154 @@ +import { + getBlockNumber, + getBlockByNumber, + getBlockByHash, + getLatestBlock +} from './blocks.js'; +import { getPublicClient } from './clients.js'; +import type { Block, Hash } from 'viem'; + +jest.mock('./clients.js', () => ({ + getPublicClient: jest.fn() +})); + +const mockGetPublicClient = getPublicClient as jest.MockedFunction; + +describe('blocks service', () => { + const mockBlockNumber = 18000000n; + const mockBlock: Block = { + hash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' as Hash, + parentHash: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' as Hash, + number: mockBlockNumber, + timestamp: 1700000000n, + nonce: '0x0000000000000000', + difficulty: 0n, + gasLimit: 30000000n, + gasUsed: 15000000n, + miner: '0x0000000000000000000000000000000000000000', + extraData: '0x', + baseFeePerGas: 1000000000n, + transactions: [], + size: 1000n, + stateRoot: '0x' + '0'.repeat(64) as Hash, + receiptsRoot: '0x' + '0'.repeat(64) as Hash, + transactionsRoot: '0x' + '0'.repeat(64) as Hash, + logsBloom: ('0x' + '0'.repeat(512)) as `0x${string}`, + totalDifficulty: 0n, + sha3Uncles: '0x' + '0'.repeat(64) as Hash, + uncles: [], + mixHash: '0x' + '0'.repeat(64) as Hash, + blobGasUsed: 0n, + excessBlobGas: 0n, + sealFields: [] + }; + + const mockClient = { + getBlockNumber: jest.fn(), + getBlock: jest.fn() + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetPublicClient.mockReturnValue(mockClient as never); + }); + + describe('getBlockNumber', () => { + it('should return block number for default network (ethereum)', async () => { + mockClient.getBlockNumber.mockResolvedValue(mockBlockNumber); + + const result = await getBlockNumber(); + + expect(getPublicClient).toHaveBeenCalledWith('ethereum'); + expect(mockClient.getBlockNumber).toHaveBeenCalled(); + expect(result).toBe(mockBlockNumber); + }); + + it('should return block number for specified network', async () => { + mockClient.getBlockNumber.mockResolvedValue(mockBlockNumber); + + const result = await getBlockNumber('polygon'); + + expect(getPublicClient).toHaveBeenCalledWith('polygon'); + expect(mockClient.getBlockNumber).toHaveBeenCalled(); + expect(result).toBe(mockBlockNumber); + }); + }); + + describe('getBlockByNumber', () => { + const blockNumber = 18000000; + + it('should return block for specified block number with default network', async () => { + mockClient.getBlock.mockResolvedValue(mockBlock); + + const result = await getBlockByNumber(blockNumber); + + expect(getPublicClient).toHaveBeenCalledWith('ethereum'); + expect(mockClient.getBlock).toHaveBeenCalledWith({ blockNumber: BigInt(blockNumber) }); + expect(result).toEqual(mockBlock); + }); + + it('should return block for specified block number and network', async () => { + mockClient.getBlock.mockResolvedValue(mockBlock); + + const result = await getBlockByNumber(blockNumber, 'arbitrum'); + + expect(getPublicClient).toHaveBeenCalledWith('arbitrum'); + expect(mockClient.getBlock).toHaveBeenCalledWith({ blockNumber: BigInt(blockNumber) }); + expect(result).toEqual(mockBlock); + }); + + it('should handle different block numbers correctly', async () => { + mockClient.getBlock.mockResolvedValue(mockBlock); + + await getBlockByNumber(12345); + + expect(mockClient.getBlock).toHaveBeenCalledWith({ blockNumber: 12345n }); + }); + }); + + describe('getBlockByHash', () => { + const blockHash: Hash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + + it('should return block for specified hash with default network', async () => { + mockClient.getBlock.mockResolvedValue(mockBlock); + + const result = await getBlockByHash(blockHash); + + expect(getPublicClient).toHaveBeenCalledWith('ethereum'); + expect(mockClient.getBlock).toHaveBeenCalledWith({ blockHash }); + expect(result).toEqual(mockBlock); + }); + + it('should return block for specified hash and network', async () => { + mockClient.getBlock.mockResolvedValue(mockBlock); + + const result = await getBlockByHash(blockHash, 'optimism'); + + expect(getPublicClient).toHaveBeenCalledWith('optimism'); + expect(mockClient.getBlock).toHaveBeenCalledWith({ blockHash }); + expect(result).toEqual(mockBlock); + }); + }); + + describe('getLatestBlock', () => { + it('should return latest block for default network', async () => { + mockClient.getBlock.mockResolvedValue(mockBlock); + + const result = await getLatestBlock(); + + expect(getPublicClient).toHaveBeenCalledWith('ethereum'); + expect(mockClient.getBlock).toHaveBeenCalledWith(); + expect(result).toEqual(mockBlock); + }); + + it('should return latest block for specified network', async () => { + mockClient.getBlock.mockResolvedValue(mockBlock); + + const result = await getLatestBlock('base'); + + expect(getPublicClient).toHaveBeenCalledWith('base'); + expect(mockClient.getBlock).toHaveBeenCalledWith(); + expect(result).toEqual(mockBlock); + }); + }); +}); From 65f50da46245e785d631111f2d3e7d0aba89bcc8 Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:05 +1000 Subject: [PATCH 13/18] test: add src/core/services/wallet.test.ts --- src/core/services/wallet.test.ts | 312 +++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 src/core/services/wallet.test.ts diff --git a/src/core/services/wallet.test.ts b/src/core/services/wallet.test.ts new file mode 100644 index 0000000..6b774da --- /dev/null +++ b/src/core/services/wallet.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + getConfiguredAccount, + getConfiguredPrivateKey, + getWalletAddressFromKey, + getConfiguredWallet, + signMessage, + signTypedData +} from './wallet.js'; + +describe('wallet', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('getConfiguredAccount', () => { + it('should throw error when neither private key nor mnemonic is set', () => { + delete process.env.EVM_PRIVATE_KEY; + delete process.env.EVM_MNEMONIC; + + expect(() => getConfiguredAccount()).toThrow( + 'Neither EVM_PRIVATE_KEY nor EVM_MNEMONIC environment variable is set' + ); + }); + + it('should create account from private key with 0x prefix', () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + delete process.env.EVM_MNEMONIC; + + const account = getConfiguredAccount(); + + expect(account).toBeDefined(); + expect(account.address).toBeDefined(); + }); + + it('should create account from private key without 0x prefix', () => { + process.env.EVM_PRIVATE_KEY = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + delete process.env.EVM_MNEMONIC; + + const account = getConfiguredAccount(); + + expect(account).toBeDefined(); + expect(account.address).toBeDefined(); + }); + + it('should create account from mnemonic with default index 0', () => { + process.env.EVM_MNEMONIC = 'test test test test test test test test test test test junk'; + delete process.env.EVM_PRIVATE_KEY; + + const account = getConfiguredAccount(); + + expect(account).toBeDefined(); + expect(account.address).toBeDefined(); + }); + + it('should create account from mnemonic with custom account index', () => { + process.env.EVM_MNEMONIC = 'test test test test test test test test test test test junk'; + process.env.EVM_ACCOUNT_INDEX = '1'; + delete process.env.EVM_PRIVATE_KEY; + + const account = getConfiguredAccount(); + + expect(account).toBeDefined(); + expect(account.address).toBeDefined(); + }); + + it('should throw error for invalid account index (negative)', () => { + process.env.EVM_MNEMONIC = 'test test test test test test test test test test test junk'; + process.env.EVM_ACCOUNT_INDEX = '-1'; + + expect(() => getConfiguredAccount()).toThrow( + 'Invalid EVM_ACCOUNT_INDEX: "-1". Must be a non-negative integer.' + ); + }); + + it('should accept account index that parses to valid integer (1.5 -> 1)', () => { + // Note: parseInt('1.5', 10) returns 1, which is a valid non-negative integer + // The code uses parseInt which truncates decimals, so this does not throw + process.env.EVM_MNEMONIC = 'test test test test test test test test test test test junk'; + process.env.EVM_ACCOUNT_INDEX = '1.5'; + + const account = getConfiguredAccount(); + + expect(account).toBeDefined(); + expect(account.address).toBeDefined(); + }); + + it('should throw error for invalid account index (NaN)', () => { + process.env.EVM_MNEMONIC = 'test test test test test test test test test test test junk'; + process.env.EVM_ACCOUNT_INDEX = 'abc'; + + expect(() => getConfiguredAccount()).toThrow( + 'Invalid EVM_ACCOUNT_INDEX: "abc". Must be a non-negative integer.' + ); + }); + + it('should prioritize private key over mnemonic when both are set', () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + process.env.EVM_MNEMONIC = 'test test test test test test test test test test test junk'; + + const account = getConfiguredAccount(); + + expect(account).toBeDefined(); + }); + }); + + describe('getConfiguredPrivateKey', () => { + it('should return private key when using private key config', () => { + const testPrivateKey = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + process.env.EVM_PRIVATE_KEY = testPrivateKey; + delete process.env.EVM_MNEMONIC; + + const privateKey = getConfiguredPrivateKey(); + + expect(privateKey).toBe(testPrivateKey); + }); + + it('should return private key with 0x prefix when input lacks prefix', () => { + process.env.EVM_PRIVATE_KEY = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + delete process.env.EVM_MNEMONIC; + + const privateKey = getConfiguredPrivateKey(); + + expect(privateKey).toBe('0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'); + }); + + it('should extract private key from mnemonic-derived account', () => { + process.env.EVM_MNEMONIC = 'test test test test test test test test test test test junk'; + delete process.env.EVM_PRIVATE_KEY; + + const privateKey = getConfiguredPrivateKey(); + + expect(privateKey).toBeDefined(); + expect(privateKey).toMatch(/^0x[0-9a-f]{64}$/i); + }); + }); + + describe('getWalletAddressFromKey', () => { + it('should return address from private key config', () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + delete process.env.EVM_MNEMONIC; + + const address = getWalletAddressFromKey(); + + expect(address).toBeDefined(); + expect(address).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); + + it('should return address from mnemonic config', () => { + process.env.EVM_MNEMONIC = 'test test test test test test test test test test test junk'; + delete process.env.EVM_PRIVATE_KEY; + + const address = getWalletAddressFromKey(); + + expect(address).toBeDefined(); + expect(address).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); + }); + + describe('getConfiguredWallet', () => { + it('should return wallet object with address from private key', () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + delete process.env.EVM_MNEMONIC; + + const wallet = getConfiguredWallet(); + + expect(wallet).toBeDefined(); + expect(wallet.address).toBeDefined(); + expect(wallet.address).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); + + it('should return wallet object with address from mnemonic', () => { + process.env.EVM_MNEMONIC = 'test test test test test test test test test test test junk'; + delete process.env.EVM_PRIVATE_KEY; + + const wallet = getConfiguredWallet(); + + expect(wallet).toBeDefined(); + expect(wallet.address).toBeDefined(); + expect(wallet.address).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); + }); + + describe('signMessage', () => { + it('should sign message with private key account', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + delete process.env.EVM_MNEMONIC; + + const message = 'Hello, World!'; + const signature = await signMessage(message); + + expect(signature).toBeDefined(); + expect(signature).toMatch(/^0x[0-9a-fA-F]+$/); + }); + + it('should sign message with mnemonic-derived account', async () => { + process.env.EVM_MNEMONIC = 'test test test test test test test test test test test junk'; + delete process.env.EVM_PRIVATE_KEY; + + const message = 'Hello, World!'; + const signature = await signMessage(message); + + expect(signature).toBeDefined(); + expect(signature).toMatch(/^0x[0-9a-fA-F]+$/); + }); + + it('should sign empty message', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + delete process.env.EVM_MNEMONIC; + + const signature = await signMessage(''); + + expect(signature).toBeDefined(); + expect(signature).toMatch(/^0x[0-9a-fA-F]+$/); + }); + + it('should sign hex data message', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + delete process.env.EVM_MNEMONIC; + + const message = '0xdeadbeef'; + const signature = await signMessage(message); + + expect(signature).toBeDefined(); + expect(signature).toMatch(/^0x[0-9a-fA-F]+$/); + }); + }); + + describe('signTypedData', () => { + const domain = { + name: 'TestDApp', + version: '1', + chainId: 1, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC' as const + }; + + const types = { + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' } + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person' }, + { name: 'contents', type: 'string' } + ] + }; + + const message = { + from: { + name: 'Cow', + wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826' + }, + to: { + name: 'Bob', + wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB' + }, + contents: 'Hello, Bob!' + }; + + it('should sign typed data with private key account', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + delete process.env.EVM_MNEMONIC; + + const signature = await signTypedData(domain, types, 'Mail', message); + + expect(signature).toBeDefined(); + expect(signature).toMatch(/^0x[0-9a-fA-F]+$/); + }); + + it('should sign typed data with mnemonic-derived account', async () => { + process.env.EVM_MNEMONIC = 'test test test test test test test test test test test junk'; + delete process.env.EVM_PRIVATE_KEY; + + const signature = await signTypedData(domain, types, 'Mail', message); + + expect(signature).toBeDefined(); + expect(signature).toMatch(/^0x[0-9a-fA-F]+$/); + }); + + it('should sign typed data with minimal domain', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + delete process.env.EVM_MNEMONIC; + + const minimalDomain = { + name: 'Test' + }; + + const simpleTypes = { + Simple: [ + { name: 'value', type: 'string' } + ] + }; + + const simpleMessage = { + value: 'test' + }; + + const signature = await signTypedData(minimalDomain, simpleTypes, 'Simple', simpleMessage); + + expect(signature).toBeDefined(); + expect(signature).toMatch(/^0x[0-9a-fA-F]+$/); + }); + }); +}); From 068098ff61442d2896ca021702ee31a7274ca810 Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:05 +1000 Subject: [PATCH 14/18] test: add src/core/prompts.test.ts --- src/core/prompts.test.ts | 489 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 src/core/prompts.test.ts diff --git a/src/core/prompts.test.ts b/src/core/prompts.test.ts new file mode 100644 index 0000000..b8154e0 --- /dev/null +++ b/src/core/prompts.test.ts @@ -0,0 +1,489 @@ +import { registerEVMPrompts } from './prompts.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +jest.mock('@modelcontextprotocol/sdk/server/mcp.js'); + +describe('registerEVMPrompts', () => { + let mockServer: jest.Mocked; + let registerPromptMock: jest.Mock; + + beforeEach(() => { + registerPromptMock = jest.fn(); + mockServer = { + registerPrompt: registerPromptMock, + } as unknown as jest.Mocked; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should register all EVM prompts with the MCP server', () => { + registerEVMPrompts(mockServer); + + expect(registerPromptMock).toHaveBeenCalledTimes(10); + }); + + describe('prepare_transfer prompt', () => { + it('should register prepare_transfer prompt with correct schema', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'prepare_transfer' + ); + expect(call).toBeDefined(); + expect(call![1].description).toContain('token transfer'); + expect(call![1].argsSchema).toBeDefined(); + }); + + it('should generate correct prompt for native token transfer', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'prepare_transfer' + ); + const promptFn = call![2]; + const result = promptFn({ + tokenType: 'native', + recipient: '0x1234567890123456789012345678901234567890', + amount: '1.5', + network: 'ethereum', + }); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe('user'); + expect(result.messages[0].content.type).toBe('text'); + expect(result.messages[0].content.text).toContain('Token Transfer Task'); + expect(result.messages[0].content.text).toContain('get_balance'); + expect(result.messages[0].content.text).toContain('transfer_native'); + }); + + it('should generate correct prompt for ERC20 token transfer', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'prepare_transfer' + ); + const promptFn = call![2]; + const result = promptFn({ + tokenType: 'erc20', + recipient: '0x1234567890123456789012345678901234567890', + amount: '100', + network: 'polygon', + tokenAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + }); + + expect(result.messages[0].content.text).toContain('Token Transfer Task'); + expect(result.messages[0].content.text).toContain('get_token_balance'); + expect(result.messages[0].content.text).toContain('get_allowance'); + expect(result.messages[0].content.text).toContain('transfer_erc20'); + }); + + it('should use default network when not provided', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'prepare_transfer' + ); + const promptFn = call![2]; + const result = promptFn({ + tokenType: 'native', + recipient: '0x1234567890123456789012345678901234567890', + amount: '1.0', + }); + + expect(result.messages[0].content.text).toContain('ethereum'); + }); + }); + + describe('diagnose_transaction prompt', () => { + it('should register diagnose_transaction prompt with correct schema', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'diagnose_transaction' + ); + expect(call).toBeDefined(); + expect(call![1].description).toContain('debugging'); + }); + + it('should generate correct prompt for transaction diagnosis', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'diagnose_transaction' + ); + const promptFn = call![2]; + const result = promptFn({ + txHash: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + network: 'ethereum', + }); + + expect(result.messages[0].content.text).toContain('Transaction Diagnosis'); + expect(result.messages[0].content.text).toContain('get_transaction'); + expect(result.messages[0].content.text).toContain('get_transaction_receipt'); + expect(result.messages[0].content.text).toContain('get_gas_price'); + }); + + it('should use default network when not provided', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'diagnose_transaction' + ); + const promptFn = call![2]; + const result = promptFn({ + txHash: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + }); + + expect(result.messages[0].content.text).toContain('ethereum'); + }); + }); + + describe('analyze_wallet prompt', () => { + it('should register analyze_wallet prompt with correct schema', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'analyze_wallet' + ); + expect(call).toBeDefined(); + expect(call![1].description).toContain('asset'); + }); + + it('should generate correct prompt for wallet analysis', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'analyze_wallet' + ); + const promptFn = call![2]; + const result = promptFn({ + address: '0x1234567890123456789012345678901234567890', + network: 'ethereum', + }); + + expect(result.messages[0].content.text).toContain('Wallet Analysis'); + expect(result.messages[0].content.text).toContain('get_balance'); + }); + + it('should handle specific token addresses', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'analyze_wallet' + ); + const promptFn = call![2]; + const result = promptFn({ + address: '0x1234567890123456789012345678901234567890', + tokens: '0xaaa,0xbbb,0xccc', + }); + + expect(result.messages[0].content.text).toContain('get_token_balance'); + expect(result.messages[0].content.text).toContain('0xaaa'); + expect(result.messages[0].content.text).toContain('0xbbb'); + expect(result.messages[0].content.text).toContain('0xccc'); + }); + + it('should use default network when not provided', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'analyze_wallet' + ); + const promptFn = call![2]; + const result = promptFn({ + address: '0x1234567890123456789012345678901234567890', + }); + + expect(result.messages[0].content.text).toContain('ethereum'); + }); + }); + + describe('audit_approvals prompt', () => { + it('should register audit_approvals prompt with correct schema', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'audit_approvals' + ); + expect(call).toBeDefined(); + expect(call![1].description).toContain('security'); + }); + + it('should generate correct prompt for approval audit', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'audit_approvals' + ); + const promptFn = call![2]; + const result = promptFn({ + address: '0x1234567890123456789012345678901234567890', + tokenAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + network: 'ethereum', + }); + + expect(result.messages[0].content.text).toContain('Token Approval Audit'); + expect(result.messages[0].content.text).toContain('get_allowance'); + }); + + it('should use get_wallet_address when no address provided', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'audit_approvals' + ); + const promptFn = call![2]; + const result = promptFn({ + tokenAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + }); + + expect(result.messages[0].content.text).toContain('get_wallet_address'); + }); + }); + + describe('fetch_and_analyze_abi prompt', () => { + it('should register fetch_and_analyze_abi prompt with correct schema', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'fetch_and_analyze_abi' + ); + expect(call).toBeDefined(); + expect(call![1].description).toContain('ABI'); + }); + + it('should generate correct prompt for ABI analysis', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'fetch_and_analyze_abi' + ); + const promptFn = call![2]; + const result = promptFn({ + contractAddress: '0x1234567890123456789012345678901234567890', + network: 'ethereum', + }); + + expect(result.messages[0].content.text).toContain('ABI Fetch and Analysis'); + expect(result.messages[0].content.text).toContain('get_contract_abi'); + }); + + it('should include specific function analysis when findFunction provided', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'fetch_and_analyze_abi' + ); + const promptFn = call![2]; + const result = promptFn({ + contractAddress: '0x1234567890123456789012345678901234567890', + findFunction: 'swap', + }); + + expect(result.messages[0].content.text).toContain('swap'); + }); + }); + + describe('explore_contract prompt', () => { + it('should register explore_contract prompt with correct schema', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'explore_contract' + ); + expect(call).toBeDefined(); + expect(call![1].description).toContain('Analyze contract'); + }); + + it('should generate correct prompt for contract exploration without ABI', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'explore_contract' + ); + const promptFn = call![2]; + const result = promptFn({ + contractAddress: '0x1234567890123456789012345678901234567890', + network: 'ethereum', + }); + + expect(result.messages[0].content.text).toContain('Contract Exploration'); + expect(result.messages[0].content.text).toContain('read_contract'); + }); + + it('should include ABI fetch when fetchAbi is true', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'explore_contract' + ); + const promptFn = call![2]; + const result = promptFn({ + contractAddress: '0x1234567890123456789012345678901234567890', + fetchAbi: 'true', + }); + + expect(result.messages[0].content.text).toContain('get_contract_abi'); + }); + }); + + describe('interact_with_contract prompt', () => { + it('should register interact_with_contract prompt with correct schema', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'interact_with_contract' + ); + expect(call).toBeDefined(); + expect(call![1].description).toContain('write operations'); + }); + + it('should generate correct prompt for contract interaction', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'interact_with_contract' + ); + const promptFn = call![2]; + const result = promptFn({ + contractAddress: '0x1234567890123456789012345678901234567890', + functionName: 'mint', + network: 'ethereum', + }); + + expect(result.messages[0].content.text).toContain('Smart Contract Interaction'); + expect(result.messages[0].content.text).toContain('mint'); + expect(result.messages[0].content.text).toContain('write_contract'); + }); + + it('should include arguments when provided', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'interact_with_contract' + ); + const promptFn = call![2]; + const result = promptFn({ + contractAddress: '0x1234567890123456789012345678901234567890', + functionName: 'transfer', + args: '0xrecipient,1000', + }); + + expect(result.messages[0].content.text).toContain('0xrecipient'); + expect(result.messages[0].content.text).toContain('1000'); + }); + + it('should include value when provided', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'interact_with_contract' + ); + const promptFn = call![2]; + const result = promptFn({ + contractAddress: '0x1234567890123456789012345678901234567890', + functionName: 'deposit', + value: '1.5', + }); + + expect(result.messages[0].content.text).toContain('1.5 ETH'); + }); + }); + + describe('explain_evm_concept prompt', () => { + it('should register explain_evm_concept prompt with correct schema', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'explain_evm_concept' + ); + expect(call).toBeDefined(); + expect(call![1].description).toContain('Explain'); + }); + + it('should generate correct prompt for concept explanation', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'explain_evm_concept' + ); + const promptFn = call![2]; + const result = promptFn({ + concept: 'gas', + }); + + expect(result.messages[0].content.text).toContain('Concept Explanation: gas'); + }); + }); + + describe('compare_networks prompt', () => { + it('should register compare_networks prompt with correct schema', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'compare_networks' + ); + expect(call).toBeDefined(); + expect(call![1].description).toContain('Compare'); + }); + + it('should generate correct prompt for network comparison', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'compare_networks' + ); + const promptFn = call![2]; + const result = promptFn({ + networks: 'ethereum,polygon,arbitrum', + }); + + expect(result.messages[0].content.text).toContain('Network Comparison'); + expect(result.messages[0].content.text).toContain('ethereum'); + expect(result.messages[0].content.text).toContain('polygon'); + expect(result.messages[0].content.text).toContain('arbitrum'); + }); + }); + + describe('check_network_status prompt', () => { + it('should register check_network_status prompt with correct schema', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'check_network_status' + ); + expect(call).toBeDefined(); + expect(call![1].description).toContain('health'); + }); + + it('should generate correct prompt for network status check', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'check_network_status' + ); + const promptFn = call![2]; + const result = promptFn({ + network: 'ethereum', + }); + + expect(result.messages[0].content.text).toContain('Network Status Check'); + expect(result.messages[0].content.text).toContain('get_chain_info'); + expect(result.messages[0].content.text).toContain('get_gas_price'); + }); + + it('should use default network when not provided', () => { + registerEVMPrompts(mockServer); + + const call = registerPromptMock.mock.calls.find( + (c) => c[0] === 'check_network_status' + ); + const promptFn = call![2]; + const result = promptFn({}); + + expect(result.messages[0].content.text).toContain('ethereum'); + }); + }); +}); From 52bd4d0bb60794c69fd91d42828d2ad2d264ad4b Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:05 +1000 Subject: [PATCH 15/18] test: add src/core/chains.test.ts --- src/core/chains.test.ts | 263 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 src/core/chains.test.ts diff --git a/src/core/chains.test.ts b/src/core/chains.test.ts new file mode 100644 index 0000000..9a99e10 --- /dev/null +++ b/src/core/chains.test.ts @@ -0,0 +1,263 @@ +import { + DEFAULT_RPC_URL, + DEFAULT_CHAIN_ID, + chainMap, + networkNameMap, + rpcUrlMap, + resolveChainId, + getChain, + getRpcUrl, + getSupportedNetworks, +} from './chains.js'; + +describe('chains', () => { + describe('constants', () => { + it('should have correct DEFAULT_RPC_URL', () => { + expect(DEFAULT_RPC_URL).toBe('https://eth.llamarpc.com'); + }); + + it('should have correct DEFAULT_CHAIN_ID', () => { + expect(DEFAULT_CHAIN_ID).toBe(1); + }); + }); + + describe('chainMap', () => { + it('should contain mainnet chains', () => { + expect(chainMap[1]).toBeDefined(); // ethereum + expect(chainMap[10]).toBeDefined(); // optimism + expect(chainMap[42161]).toBeDefined(); // arbitrum + expect(chainMap[8453]).toBeDefined(); // base + expect(chainMap[137]).toBeDefined(); // polygon + }); + + it('should contain testnet chains', () => { + expect(chainMap[11155111]).toBeDefined(); // sepolia + expect(chainMap[11155420]).toBeDefined(); // optimism sepolia + expect(chainMap[421614]).toBeDefined(); // arbitrum sepolia + expect(chainMap[84532]).toBeDefined(); // base sepolia + }); + }); + + describe('networkNameMap', () => { + it('should map mainnet names to chain IDs', () => { + expect(networkNameMap['ethereum']).toBe(1); + expect(networkNameMap['mainnet']).toBe(1); + expect(networkNameMap['optimism']).toBe(10); + expect(networkNameMap['base']).toBe(8453); + expect(networkNameMap['polygon']).toBe(137); + }); + + it('should map testnet names to chain IDs', () => { + expect(networkNameMap['sepolia']).toBe(11155111); + expect(networkNameMap['optimism-sepolia']).toBe(11155420); + expect(networkNameMap['base-sepolia']).toBe(84532); + }); + + it('should map short aliases to chain IDs', () => { + expect(networkNameMap['eth']).toBe(1); + expect(networkNameMap['op']).toBe(10); + expect(networkNameMap['arb']).toBe(42161); + expect(networkNameMap['matic']).toBe(137); + expect(networkNameMap['avax']).toBe(43114); + }); + }); + + describe('rpcUrlMap', () => { + it('should contain RPC URLs for mainnet chains', () => { + expect(rpcUrlMap[1]).toBe('https://eth.llamarpc.com'); + expect(rpcUrlMap[10]).toBe('https://mainnet.optimism.io'); + expect(rpcUrlMap[42161]).toBe('https://arb1.arbitrum.io/rpc'); + expect(rpcUrlMap[8453]).toBe('https://mainnet.base.org'); + }); + + it('should contain RPC URLs for testnet chains', () => { + expect(rpcUrlMap[11155111]).toBe('https://sepolia.drpc.org'); + expect(rpcUrlMap[11155420]).toBe('https://sepolia.optimism.io'); + expect(rpcUrlMap[421614]).toBe('https://sepolia-rpc.arbitrum.io/rpc'); + }); + }); + + describe('resolveChainId', () => { + it('should return the same number when given a number', () => { + expect(resolveChainId(1)).toBe(1); + expect(resolveChainId(10)).toBe(10); + expect(resolveChainId(42161)).toBe(42161); + expect(resolveChainId(11155111)).toBe(11155111); + }); + + it('should resolve mainnet network names to chain IDs', () => { + expect(resolveChainId('ethereum')).toBe(1); + expect(resolveChainId('mainnet')).toBe(1); + expect(resolveChainId('optimism')).toBe(10); + expect(resolveChainId('base')).toBe(8453); + expect(resolveChainId('polygon')).toBe(137); + expect(resolveChainId('arbitrum')).toBe(42161); + }); + + it('should resolve testnet network names to chain IDs', () => { + expect(resolveChainId('sepolia')).toBe(11155111); + expect(resolveChainId('optimism-sepolia')).toBe(11155420); + expect(resolveChainId('base-sepolia')).toBe(84532); + expect(resolveChainId('arbitrum-sepolia')).toBe(421614); + }); + + it('should resolve short aliases to chain IDs', () => { + expect(resolveChainId('eth')).toBe(1); + expect(resolveChainId('op')).toBe(10); + expect(resolveChainId('arb')).toBe(42161); + expect(resolveChainId('matic')).toBe(137); + expect(resolveChainId('avax')).toBe(43114); + }); + + it('should be case-insensitive', () => { + expect(resolveChainId('ETHEREUM')).toBe(1); + expect(resolveChainId('Optimism')).toBe(10); + expect(resolveChainId('BASE')).toBe(8453); + expect(resolveChainId('SePolia')).toBe(11155111); + }); + + it('should resolve numeric strings to numbers', () => { + expect(resolveChainId('1')).toBe(1); + expect(resolveChainId('10')).toBe(10); + expect(resolveChainId('42161')).toBe(42161); + expect(resolveChainId('11155111')).toBe(11155111); + }); + + it('should default to DEFAULT_CHAIN_ID for unknown network names', () => { + expect(resolveChainId('unknown-network')).toBe(DEFAULT_CHAIN_ID); + expect(resolveChainId('nonexistent')).toBe(DEFAULT_CHAIN_ID); + expect(resolveChainId('')).toBe(DEFAULT_CHAIN_ID); + }); + }); + + describe('getChain', () => { + it('should return chain config for number input', () => { + const chain1 = getChain(1); + expect(chain1).toBeDefined(); + expect(chain1.id).toBe(1); + + const chain10 = getChain(10); + expect(chain10).toBeDefined(); + expect(chain10.id).toBe(10); + }); + + it('should return chain config for string input', () => { + const ethChain = getChain('ethereum'); + expect(ethChain).toBeDefined(); + expect(ethChain.id).toBe(1); + + const opChain = getChain('optimism'); + expect(opChain).toBeDefined(); + expect(opChain.id).toBe(10); + }); + + it('should return mainnet by default when no argument provided', () => { + const chain = getChain(); + expect(chain).toBeDefined(); + expect(chain.id).toBe(1); + }); + + it('should return mainnet for unknown chain IDs', () => { + const chain = getChain(999999); + expect(chain).toBeDefined(); + expect(chain.id).toBe(1); + }); + + it('should throw error for unknown network names', () => { + expect(() => getChain('unknown-network')).toThrow('Unsupported network: unknown-network'); + expect(() => getChain('nonexistent')).toThrow('Unsupported network: nonexistent'); + }); + + it('should be case-insensitive for string input', () => { + const chain1 = getChain('ETHEREUM'); + expect(chain1.id).toBe(1); + + const chain2 = getChain('Optimism'); + expect(chain2.id).toBe(10); + }); + }); + + describe('getRpcUrl', () => { + it('should return RPC URL for number input', () => { + expect(getRpcUrl(1)).toBe('https://eth.llamarpc.com'); + expect(getRpcUrl(10)).toBe('https://mainnet.optimism.io'); + expect(getRpcUrl(42161)).toBe('https://arb1.arbitrum.io/rpc'); + expect(getRpcUrl(8453)).toBe('https://mainnet.base.org'); + }); + + it('should return RPC URL for string input', () => { + expect(getRpcUrl('ethereum')).toBe('https://eth.llamarpc.com'); + expect(getRpcUrl('optimism')).toBe('https://mainnet.optimism.io'); + expect(getRpcUrl('base')).toBe('https://mainnet.base.org'); + }); + + it('should return default RPC URL by default when no argument provided', () => { + expect(getRpcUrl()).toBe(DEFAULT_RPC_URL); + }); + + it('should return default RPC URL for unknown chain IDs', () => { + expect(getRpcUrl(999999)).toBe(DEFAULT_RPC_URL); + expect(getRpcUrl(123456)).toBe(DEFAULT_RPC_URL); + }); + + it('should resolve network names to RPC URLs', () => { + expect(getRpcUrl('polygon')).toBe('https://polygon-rpc.com'); + expect(getRpcUrl('arbitrum')).toBe('https://arb1.arbitrum.io/rpc'); + expect(getRpcUrl('avalanche')).toBe('https://api.avax.network/ext/bc/C/rpc'); + }); + + it('should be case-insensitive', () => { + expect(getRpcUrl('ETHEREUM')).toBe('https://eth.llamarpc.com'); + expect(getRpcUrl('Optimism')).toBe('https://mainnet.optimism.io'); + expect(getRpcUrl('BASE')).toBe('https://mainnet.base.org'); + }); + + it('should resolve numeric strings to RPC URLs', () => { + expect(getRpcUrl('1')).toBe('https://eth.llamarpc.com'); + expect(getRpcUrl('10')).toBe('https://mainnet.optimism.io'); + }); + }); + + describe('getSupportedNetworks', () => { + it('should return an array of strings', () => { + const networks = getSupportedNetworks(); + expect(Array.isArray(networks)).toBe(true); + expect(networks.every((n: string) => typeof n === 'string')).toBe(true); + }); + + it('should filter out short aliases (length <= 2)', () => { + const networks = getSupportedNetworks(); + // Length <= 2 aliases should be filtered out + expect(networks).not.toContain('op'); // length 2 + // Note: 'eth' (length 3), 'arb' (length 3), 'xdai' (length 4) are NOT filtered since filter is length > 2 + }); + + it('should include full network names', () => { + const networks = getSupportedNetworks(); + expect(networks).toContain('ethereum'); + expect(networks).toContain('optimism'); + expect(networks).toContain('arbitrum'); + expect(networks).toContain('base'); + expect(networks).toContain('polygon'); + expect(networks).toContain('sepolia'); + }); + + it('should return sorted array', () => { + const networks = getSupportedNetworks(); + const sorted = [...networks].sort(); + expect(networks).toEqual(sorted); + }); + + it('should include both mainnets and testnets', () => { + const networks = getSupportedNetworks(); + // Mainnets + expect(networks).toContain('ethereum'); + expect(networks).toContain('optimism'); + expect(networks).toContain('arbitrum'); + // Testnets + expect(networks).toContain('sepolia'); + expect(networks).toContain('optimism-sepolia'); + expect(networks).toContain('base-sepolia'); + }); + }); +}); From 347c47619028bb812bd65d627a9e952ffde52bfd Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:05 +1000 Subject: [PATCH 16/18] test: add src/core/resources.test.ts --- src/core/resources.test.ts | 127 +++++++ src/core/tools.test.ts | 682 +++++++++++++++++++++++++++++++++++++ 2 files changed, 809 insertions(+) create mode 100644 src/core/resources.test.ts create mode 100644 src/core/tools.test.ts diff --git a/src/core/resources.test.ts b/src/core/resources.test.ts new file mode 100644 index 0000000..1960562 --- /dev/null +++ b/src/core/resources.test.ts @@ -0,0 +1,127 @@ +import { registerEVMResources } from './resources.js'; +import { getSupportedNetworks } from './chains.js'; + +// Mock the chains module +jest.mock('./chains.js', () => ({ + getSupportedNetworks: jest.fn(), +})); + +describe('resources', () => { + describe('registerEVMResources', () => { + let mockServer: { registerResource: jest.Mock }; + + beforeEach(() => { + mockServer = { + registerResource: jest.fn(), + }; + jest.clearAllMocks(); + }); + + it('should register the supported_networks resource', () => { + registerEVMResources(mockServer as any); + + expect(mockServer.registerResource).toHaveBeenCalledTimes(1); + expect(mockServer.registerResource).toHaveBeenCalledWith( + 'supported_networks', + 'evm://networks', + { + description: 'Get list of all supported EVM networks and their configuration', + mimeType: 'application/json', + }, + expect.any(Function), + ); + }); + + it('should return supported networks in the resource handler', async () => { + const mockNetworks = ['ethereum', 'optimism', 'arbitrum', 'base']; + (getSupportedNetworks as jest.Mock).mockReturnValue(mockNetworks); + + registerEVMResources(mockServer as any); + + // Get the handler function from the registerResource call (5th argument, index 4) + const callArgs = mockServer.registerResource.mock.calls[0]; + const handler = callArgs[callArgs.length - 1]; + const result = await handler({ href: 'evm://networks' }); + + expect(getSupportedNetworks).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + contents: [{ + uri: 'evm://networks', + text: JSON.stringify({ supportedNetworks: mockNetworks }, null, 2), + }], + }); + }); + + it('should handle errors when getSupportedNetworks throws', async () => { + const errorMessage = 'Network error'; + (getSupportedNetworks as jest.Mock).mockImplementation(() => { + throw new Error(errorMessage); + }); + + registerEVMResources(mockServer as any); + + const callArgs = mockServer.registerResource.mock.calls[0]; + const handler = callArgs[callArgs.length - 1]; + const result = await handler({ href: 'evm://networks' }); + + expect(getSupportedNetworks).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + contents: [{ + uri: 'evm://networks', + text: `Error: ${errorMessage}`, + }], + }); + }); + + it('should handle non-Error exceptions', async () => { + (getSupportedNetworks as jest.Mock).mockImplementation(() => { + throw 'Unknown error'; + }); + + registerEVMResources(mockServer as any); + + const callArgs = mockServer.registerResource.mock.calls[0]; + const handler = callArgs[callArgs.length - 1]; + const result = await handler({ href: 'evm://networks' }); + + expect(getSupportedNetworks).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + contents: [{ + uri: 'evm://networks', + text: 'Error: Unknown error', + }], + }); + }); + + it('should use the correct URI from the request', async () => { + const mockNetworks = ['ethereum']; + (getSupportedNetworks as jest.Mock).mockReturnValue(mockNetworks); + + registerEVMResources(mockServer as any); + + const callArgs = mockServer.registerResource.mock.calls[0]; + const handler = callArgs[callArgs.length - 1]; + const customUri = 'evm://custom-networks'; + const result = await handler({ href: customUri }); + + expect(result.contents[0].uri).toBe(customUri); + }); + + it('should return empty array when getSupportedNetworks returns empty', async () => { + (getSupportedNetworks as jest.Mock).mockReturnValue([]); + + registerEVMResources(mockServer as any); + + const callArgs = mockServer.registerResource.mock.calls[0]; + const handler = callArgs[callArgs.length - 1]; + const result = await handler({ href: 'evm://networks' }); + + expect(result).toEqual({ + contents: [{ + uri: 'evm://networks', + text: JSON.stringify({ supportedNetworks: [] }, null, 2), + }], + }); + }); + }); +}); diff --git a/src/core/tools.test.ts b/src/core/tools.test.ts new file mode 100644 index 0000000..ca99100 --- /dev/null +++ b/src/core/tools.test.ts @@ -0,0 +1,682 @@ +// @ts-nocheck +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerEVMTools } from './tools.js'; + +jest.mock('./services/index.js', () => { + return { + getChainId: jest.fn(), + getBlockNumber: jest.fn(), + getPublicClient: jest.fn(), + resolveAddress: jest.fn(), + getETHBalance: jest.fn(), + getERC20Balance: jest.fn(), + getBlockByHash: jest.fn(), + getBlockByNumber: jest.fn(), + getLatestBlock: jest.fn(), + getTransaction: jest.fn(), + fetchContractABI: jest.fn(), + parseABI: jest.fn(), + getFunctionFromABI: jest.fn(), + writeContract: jest.fn(), + transferETH: jest.fn(), + transferERC20: jest.fn(), + approveERC20: jest.fn(), + getERC721TokenMetadata: jest.fn(), + getERC1155Balance: jest.fn(), + signMessage: jest.fn(), + signTypedData: jest.fn(), + multicall: jest.fn(), + getConfiguredWallet: jest.fn(), + getWalletAddressFromKey: jest.fn(), + getConfiguredPrivateKey: jest.fn(), + helpers: { formatJson: jest.fn((obj) => JSON.stringify(obj, (key, value) => typeof value === 'bigint' ? value.toString() : value, 2)) }, + getReadableFunctions: jest.fn(), + }; +}); + +jest.mock('./chains.js', () => ({ + getSupportedNetworks: jest.fn(() => ['ethereum', 'optimism', 'arbitrum', 'base', 'polygon']), + getRpcUrl: jest.fn((network) => `https://rpc.${network || 'ethereum'}.com`) +})); + +jest.mock('viem/ens', () => ({ + normalize: jest.fn((name) => name.toLowerCase()) +})); + +import * as services from './services/index.js'; + +describe('tools', () => { + let server; + let mockRegisterTool; + + beforeEach(() => { + mockRegisterTool = jest.fn(); + server = { registerTool: mockRegisterTool }; + jest.clearAllMocks(); + }); + + afterEach(() => { jest.resetModules(); }); + + describe('registerEVMTools', () => { + it('should register all EVM tools with the MCP server', () => { + registerEVMTools(server); + const registeredTools = mockRegisterTool.mock.calls.map(call => call[0]); + expect(registeredTools).toContain('get_wallet_address'); + expect(registeredTools).toContain('get_chain_info'); + expect(registeredTools).toContain('get_supported_networks'); + expect(registeredTools).toContain('get_gas_price'); + expect(registeredTools).toContain('resolve_ens_name'); + expect(registeredTools).toContain('lookup_ens_address'); + expect(registeredTools).toContain('get_block'); + expect(registeredTools).toContain('get_latest_block'); + expect(registeredTools).toContain('get_balance'); + expect(registeredTools).toContain('get_token_balance'); + expect(registeredTools).toContain('get_allowance'); + expect(registeredTools).toContain('get_transaction'); + expect(registeredTools).toContain('get_transaction_receipt'); + expect(registeredTools).toContain('wait_for_transaction'); + expect(registeredTools).toContain('get_contract_abi'); + expect(registeredTools).toContain('read_contract'); + expect(registeredTools).toContain('write_contract'); + expect(registeredTools).toContain('multicall'); + expect(registeredTools).toContain('transfer_native'); + expect(registeredTools).toContain('transfer_erc20'); + expect(registeredTools).toContain('approve_token_spending'); + expect(registeredTools).toContain('get_nft_info'); + expect(registeredTools).toContain('get_erc1155_balance'); + expect(registeredTools).toContain('sign_message'); + expect(registeredTools).toContain('sign_typed_data'); + expect(registeredTools.length).toBeGreaterThanOrEqual(23); + }); + + describe('get_wallet_address tool', () => { + it('should return wallet address when wallet is configured', async () => { + services.getWalletAddressFromKey.mockReturnValue('0x1234567890123456789012345678901234567890'); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_wallet_address')?.[2]; + expect(handler).toBeDefined(); + const result = await handler({}); + expect(result).toEqual({ + content: [{ type: "text", text: JSON.stringify({ address: '0x1234567890123456789012345678901234567890', message: "This is the wallet that will be used for all transactions" }, null, 2) }] + }); + }); + it('should handle errors gracefully', async () => { + services.getWalletAddressFromKey.mockImplementation(() => { throw new Error('Wallet not configured'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_wallet_address')?.[2]; + const result = await handler({}); + expect(result).toEqual({ content: [{ type: "text", text: "Error: Wallet not configured" }], isError: true }); + }); + }); + + describe('get_chain_info tool', () => { + it('should return chain info for specified network', async () => { + services.getChainId.mockResolvedValue(1); + services.getBlockNumber.mockResolvedValue(BigInt(18000000)); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_chain_info')?.[2]; + const result = await handler({ network: 'ethereum' }); + expect(result).toEqual({ + content: [{ type: "text", text: JSON.stringify({ network: 'ethereum', chainId: 1, blockNumber: '18000000', rpcUrl: 'https://rpc.ethereum.com' }, null, 2) }] + }); + }); + it('should default to ethereum network when not specified', async () => { + services.getChainId.mockResolvedValue(1); + services.getBlockNumber.mockResolvedValue(BigInt(18000000)); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_chain_info')?.[2]; + await handler({}); + expect(services.getChainId).toHaveBeenCalledWith('ethereum'); + }); + it('should handle errors gracefully', async () => { + services.getChainId.mockImplementation(() => { throw new Error('Network not found'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_chain_info')?.[2]; + const result = await handler({ network: 'unknown' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error fetching chain info: Network not found" }], isError: true }); + }); + }); + + describe('get_supported_networks tool', () => { + it('should return list of supported networks', async () => { + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_supported_networks')?.[2]; + const result = await handler({}); + expect(result).toEqual({ content: [{ type: "text", text: JSON.stringify({ supportedNetworks: ['ethereum', 'optimism', 'arbitrum', 'base', 'polygon'] }, null, 2) }] }); + }); + }); + + describe('get_gas_price tool', () => { + it('should return gas prices for specified network', async () => { + const mockClient = { getGasPrice: jest.fn().mockResolvedValue(BigInt(20000000000)), estimateMaxPriorityFeePerGas: jest.fn().mockResolvedValue(BigInt(1500000000)) }; + services.getPublicClient.mockResolvedValue(mockClient); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_gas_price')?.[2]; + const result = await handler({ network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: JSON.stringify({ network: 'ethereum', baseFeePerGas: '20000000000', priorityFeePerGas: '1500000000', currency: 'wei' }, null, 2) }] }); + }); + it('should handle missing priority fee', async () => { + const mockClient = { getGasPrice: jest.fn().mockResolvedValue(BigInt(20000000000)), estimateMaxPriorityFeePerGas: jest.fn().mockResolvedValue(null) }; + services.getPublicClient.mockResolvedValue(mockClient); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_gas_price')?.[2]; + const result = await handler({ network: 'ethereum' }); + expect(result.content[0].text).toContain('"priorityFeePerGas": "N/A"'); + }); + it('should handle errors gracefully', async () => { + services.getPublicClient.mockImplementation(() => { throw new Error('Client not available'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_gas_price')?.[2]; + const result = await handler({ network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error fetching gas prices: Client not available" }], isError: true }); + }); + }); + + describe('resolve_ens_name tool', () => { + it('should resolve ENS name to address', async () => { + services.resolveAddress.mockResolvedValue('0x1234567890123456789012345678901234567890'); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'resolve_ens_name')?.[2]; + const result = await handler({ ensName: 'vitalik.eth', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: JSON.stringify({ ensName: 'vitalik.eth', normalizedName: 'vitalik.eth', resolvedAddress: '0x1234567890123456789012345678901234567890', network: 'ethereum' }, null, 2) }] }); + }); + it('should reject ENS name without dot', async () => { + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'resolve_ens_name')?.[2]; + const result = await handler({ ensName: 'vitalik', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error: \"vitalik\" is not a valid ENS name. ENS names must contain a dot (e.g., 'name.eth')." }], isError: true }); + }); + it('should handle errors gracefully', async () => { + services.resolveAddress.mockImplementation(() => { throw new Error('ENS name not found'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'resolve_ens_name')?.[2]; + const result = await handler({ ensName: 'nonexistent.eth', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error resolving ENS name: ENS name not found" }], isError: true }); + }); + }); + + describe('lookup_ens_address tool', () => { + it('should lookup ENS name for address', async () => { + const mockClient = { getEnsName: jest.fn().mockResolvedValue('vitalik.eth') }; + services.getPublicClient.mockResolvedValue(mockClient); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'lookup_ens_address')?.[2]; + const result = await handler({ address: '0x1234567890123456789012345678901234567890', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: JSON.stringify({ address: '0x1234567890123456789012345678901234567890', ensName: 'vitalik.eth', network: 'ethereum' }, null, 2) }] }); + }); + it('should handle address with no ENS name', async () => { + const mockClient = { getEnsName: jest.fn().mockResolvedValue(null) }; + services.getPublicClient.mockResolvedValue(mockClient); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'lookup_ens_address')?.[2]; + const result = await handler({ address: '0x1234567890123456789012345678901234567890', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: JSON.stringify({ address: '0x1234567890123456789012345678901234567890', ensName: 'No ENS name found', network: 'ethereum' }, null, 2) }] }); + }); + it('should handle errors gracefully', async () => { + services.getPublicClient.mockImplementation(() => { throw new Error('Client not available'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'lookup_ens_address')?.[2]; + const result = await handler({ address: '0x1234567890123456789012345678901234567890', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error looking up ENS name: Client not available" }], isError: true }); + }); + }); + + describe('get_block tool', () => { + it('should get block by number', async () => { + const mockBlock = { number: 18000000, hash: '0xabc' }; + services.getBlockByNumber.mockResolvedValue(mockBlock); + services.helpers.formatJson.mockReturnValue(JSON.stringify(mockBlock)); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_block')?.[2]; + const result = await handler({ blockIdentifier: '18000000', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: JSON.stringify(mockBlock) }] }); + }); + it('should get block by hash', async () => { + const mockBlock = { number: 18000000, hash: '0xabc123' }; + services.getBlockByHash.mockResolvedValue(mockBlock); + services.helpers.formatJson.mockReturnValue(JSON.stringify(mockBlock)); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_block')?.[2]; + await handler({ blockIdentifier: '0xabc123def456789012345678901234567890123456789012345678901234abcd', network: 'ethereum' }); + expect(services.getBlockByHash).toHaveBeenCalledWith('0xabc123def456789012345678901234567890123456789012345678901234abcd', 'ethereum'); + }); + it('should handle errors gracefully', async () => { + services.getBlockByNumber.mockImplementation(() => { throw new Error('Block not found'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_block')?.[2]; + const result = await handler({ blockIdentifier: '999999999', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error fetching block: Block not found" }], isError: true }); + }); + }); + + describe('get_latest_block tool', () => { + it('should return latest block', async () => { + const mockBlock = { number: 18000000, hash: '0xabc' }; + services.getLatestBlock.mockResolvedValue(mockBlock); + services.helpers.formatJson.mockReturnValue(JSON.stringify(mockBlock)); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_latest_block')?.[2]; + const result = await handler({ network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: JSON.stringify(mockBlock) }] }); + }); + it('should handle errors gracefully', async () => { + services.getLatestBlock.mockImplementation(() => { throw new Error('Failed to fetch block'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_latest_block')?.[2]; + const result = await handler({ network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error fetching latest block: Failed to fetch block" }], isError: true }); + }); + }); + + describe('get_balance tool', () => { + it('should return native token balance', async () => { + services.getETHBalance.mockResolvedValue({ wei: BigInt('1000000000000000000'), ether: '1.0' }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_balance')?.[2]; + const result = await handler({ address: '0x1234567890123456789012345678901234567890', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: JSON.stringify({ network: 'ethereum', address: '0x1234567890123456789012345678901234567890', balance: { wei: '1000000000000000000', ether: '1.0' } }, null, 2) }] }); + }); + it('should handle errors gracefully', async () => { + services.getETHBalance.mockImplementation(() => { throw new Error('Failed to fetch balance'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_balance')?.[2]; + const result = await handler({ address: '0x1234567890123456789012345678901234567890', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error fetching balance: Failed to fetch balance" }], isError: true }); + }); + }); + + describe('get_token_balance tool', () => { + it('should return ERC20 token balance', async () => { + services.getERC20Balance.mockResolvedValue({ raw: BigInt(1000000), formatted: '1.0', token: { symbol: 'USDC', decimals: 6 } }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_token_balance')?.[2]; + const result = await handler({ address: '0x1234567890123456789012345678901234567890', tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: JSON.stringify({ network: 'ethereum', tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', address: '0x1234567890123456789012345678901234567890', balance: { raw: '1000000', formatted: '1.0', symbol: 'USDC', decimals: 6 } }, null, 2) }] }); + }); + it('should handle errors gracefully', async () => { + services.getERC20Balance.mockImplementation(() => { throw new Error('Failed to fetch token balance'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_token_balance')?.[2]; + const result = await handler({ address: '0x1234567890123456789012345678901234567890', tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error fetching token balance: Failed to fetch token balance" }], isError: true }); + }); + }); + + describe('get_allowance tool', () => { + it('should return token allowance', async () => { + const mockClient = { readContract: jest.fn().mockResolvedValue(BigInt(1000000)) }; + services.getPublicClient.mockResolvedValue(mockClient); + services.getConfiguredWallet.mockReturnValue({ address: '0xOwner123' }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_allowance')?.[2]; + const result = await handler({ tokenAddress: '0xToken123', spenderAddress: '0xSpender123', ownerAddress: '0xOwner123', network: 'ethereum' }); + expect(result.content[0].text).toContain('"allowance": "1000000"'); + }); + it('should use configured wallet when ownerAddress not provided', async () => { + const mockClient = { readContract: jest.fn().mockResolvedValue(BigInt(0)) }; + services.getPublicClient.mockResolvedValue(mockClient); + services.getConfiguredWallet.mockReturnValue({ address: '0xConfiguredWallet' }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_allowance')?.[2]; + await handler({ tokenAddress: '0xToken123', spenderAddress: '0xSpender123', network: 'ethereum' }); + expect(mockClient.readContract).toHaveBeenCalledWith(expect.objectContaining({ args: ['0xConfiguredWallet', '0xSpender123'] })); + }); + it('should handle errors gracefully', async () => { + services.getPublicClient.mockImplementation(() => { throw new Error('Client not available'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_allowance')?.[2]; + const result = await handler({ tokenAddress: '0xToken123', spenderAddress: '0xSpender123', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error fetching allowance: Client not available" }], isError: true }); + }); + }); + + describe('get_transaction tool', () => { + it('should return transaction details', async () => { + const mockTx = { hash: '0xtx123', from: '0x123', to: '0x456' }; + services.getTransaction.mockResolvedValue(mockTx); + services.helpers.formatJson.mockReturnValue(JSON.stringify(mockTx)); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_transaction')?.[2]; + const result = await handler({ txHash: '0xtx123', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: JSON.stringify(mockTx) }] }); + }); + it('should handle errors gracefully', async () => { + services.getTransaction.mockImplementation(() => { throw new Error('Transaction not found'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_transaction')?.[2]; + const result = await handler({ txHash: '0xtx123', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error fetching transaction: Transaction not found" }], isError: true }); + }); + }); + + describe('get_transaction_receipt tool', () => { + it('should return transaction receipt', async () => { + const mockClient = { getTransactionReceipt: jest.fn().mockResolvedValue({ status: 'success', blockNumber: BigInt(18000000), gasUsed: BigInt(21000) }) }; + services.getPublicClient.mockResolvedValue(mockClient); + services.helpers.formatJson.mockImplementation((obj) => JSON.stringify(obj, (key, value) => typeof value === 'bigint' ? value.toString() : value)); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_transaction_receipt')?.[2]; + const result = await handler({ txHash: '0xtx123', network: 'ethereum' }); + expect(result.content[0].text).toContain('success'); + }); + it('should handle errors gracefully', async () => { + services.getPublicClient.mockImplementation(() => { throw new Error('Client not available'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_transaction_receipt')?.[2]; + const result = await handler({ txHash: '0xtx123', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error fetching transaction receipt: Client not available" }], isError: true }); + }); + }); + + describe('wait_for_transaction tool', () => { + it('should wait for transaction confirmation', async () => { + const mockClient = { waitForTransactionReceipt: jest.fn().mockResolvedValue({ status: 'success', blockNumber: BigInt(18000000), gasUsed: BigInt(21000) }) }; + services.getPublicClient.mockResolvedValue(mockClient); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'wait_for_transaction')?.[2]; + const result = await handler({ txHash: '0xtx123', confirmations: 1, network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: JSON.stringify({ network: 'ethereum', txHash: '0xtx123', status: 'confirmed', blockNumber: '18000000', gasUsed: '21000', confirmations: 1 }, null, 2) }] }); + }); + it('should handle failed transactions', async () => { + const mockClient = { waitForTransactionReceipt: jest.fn().mockResolvedValue({ status: 'reverted', blockNumber: BigInt(18000000), gasUsed: BigInt(21000) }) }; + services.getPublicClient.mockResolvedValue(mockClient); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'wait_for_transaction')?.[2]; + const result = await handler({ txHash: '0xtx123', network: 'ethereum' }); + expect(result.content[0].text).toContain('"status": "failed"'); + }); + it('should handle errors gracefully', async () => { + services.getPublicClient.mockImplementation(() => { throw new Error('Client not available'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'wait_for_transaction')?.[2]; + const result = await handler({ txHash: '0xtx123', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error waiting for transaction: Client not available" }], isError: true }); + }); + }); + + describe('get_contract_abi tool', () => { + it('should return contract ABI', async () => { + const mockABI = [{ name: 'transfer', type: 'function' }]; + services.fetchContractABI.mockResolvedValue(JSON.stringify(mockABI)); + services.parseABI.mockReturnValue(mockABI); + services.getReadableFunctions.mockReturnValue(['transfer']); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_contract_abi')?.[2]; + const result = await handler({ contractAddress: '0xContract123', network: 'ethereum' }); + expect(result.content[0].text).toContain('"totalFunctions": 1'); + }); + it('should handle errors gracefully', async () => { + services.fetchContractABI.mockImplementation(() => { throw new Error('Contract not verified'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_contract_abi')?.[2]; + const result = await handler({ contractAddress: '0xContract123', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error fetching ABI: Contract not verified" }], isError: true }); + }); + }); + + describe('read_contract tool', () => { + it('should read contract function with provided ABI', async () => { + const mockClient = { readContract: jest.fn().mockResolvedValue('TestToken') }; + services.getPublicClient.mockResolvedValue(mockClient); + services.parseABI.mockReturnValue([{ name: 'name', type: 'function' }]); + services.getFunctionFromABI.mockReturnValue({ name: 'name', type: 'function' }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'read_contract')?.[2]; + const result = await handler({ contractAddress: '0xContract123', functionName: 'name', abiJson: '[{"name":"name","type":"function"}]' }); + expect(result.content[0].text).toContain('"result": "TestToken"'); + }); + it('should use built-in common functions when ABI not provided', async () => { + const mockClient = { readContract: jest.fn().mockResolvedValue(BigInt(1000000)) }; + services.getPublicClient.mockResolvedValue(mockClient); + services.fetchContractABI.mockImplementation(() => { throw new Error('Not verified'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'read_contract')?.[2]; + const result = await handler({ contractAddress: '0xContract123', functionName: 'totalSupply' }); + expect(result.content[0].text).toContain('"abiSource": "auto-fetched or built-in"'); + }); + it('should error for unknown function without ABI', async () => { + services.getPublicClient.mockResolvedValue({}); + services.fetchContractABI.mockImplementation(() => { throw new Error('Not verified'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'read_contract')?.[2]; + const result = await handler({ contractAddress: '0xContract123', functionName: 'unknownFunction' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Could not auto-fetch ABI'); + }); + it('should handle invalid ABI gracefully', async () => { + services.parseABI.mockImplementation(() => { throw new Error('Invalid JSON'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'read_contract')?.[2]; + const result = await handler({ contractAddress: '0xContract123', functionName: 'name', abiJson: 'invalid json' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error parsing provided ABI: Invalid JSON" }], isError: true }); + }); + }); + + describe('write_contract tool', () => { + const originalEnv = process.env; + beforeEach(() => { process.env = { ...originalEnv }; }); + afterEach(() => { process.env = originalEnv; }); + it('should write to contract with provided ABI', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + const mockClient = { estimateGas: jest.fn() }; + services.getPublicClient.mockResolvedValue(mockClient); + services.parseABI.mockReturnValue([{ name: 'transfer', type: 'function', stateMutability: 'nonpayable' }]); + services.getFunctionFromABI.mockReturnValue({ name: 'transfer', type: 'function', stateMutability: 'nonpayable' }); + services.writeContract.mockResolvedValue('0xtxHash123'); + services.getWalletAddressFromKey.mockReturnValue('0xWallet123'); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'write_contract')?.[2]; + const result = await handler({ contractAddress: '0xContract123', functionName: 'transfer', args: ['0xRecipient123', '1000'], abiJson: '[{"name":"transfer","type":"function","stateMutability":"nonpayable"}]' }); + expect(result.content[0].text).toContain('"txHash": "0xtxHash123"'); + }); + it('should reject view functions', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + services.getPublicClient.mockResolvedValue({}); + services.parseABI.mockReturnValue([{ name: 'balanceOf', type: 'function', stateMutability: 'view' }]); + services.getFunctionFromABI.mockReturnValue({ name: 'balanceOf', type: 'function', stateMutability: 'view' }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'write_contract')?.[2]; + const result = await handler({ contractAddress: '0xContract123', functionName: 'balanceOf', abiJson: '[{"name":"balanceOf","type":"function","stateMutability":"view"}]' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('is a view function'); + }); + it('should handle errors gracefully', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + services.getPublicClient.mockImplementation(() => { throw new Error('Client not available'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'write_contract')?.[2]; + const result = await handler({ contractAddress: '0xContract123', functionName: 'transfer', args: ['0xRecipient123', '1000'] }); + expect(result).toEqual({ content: [{ type: "text", text: "Error writing to contract: Client not available" }], isError: true }); + }); + }); + + describe('multicall tool', () => { + it('should execute multiple contract calls in batch', async () => { + services.parseABI.mockReturnValue([{ name: 'name', type: 'function', stateMutability: 'view' }]); + services.getFunctionFromABI.mockReturnValue({ name: 'name', type: 'function', stateMutability: 'view' }); + services.multicall.mockResolvedValue([{ status: 'success', result: 'Token1' }, { status: 'success', result: 'Token2' }]); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'multicall')?.[2]; + const result = await handler({ calls: [{ contractAddress: '0xToken1', functionName: 'name' }, { contractAddress: '0xToken2', functionName: 'name' }] }); + expect(result.content[0].text).toContain('"totalCalls": 2'); + expect(result.content[0].text).toContain('"successfulCalls": 2'); + }); + it('should handle failed calls with allowFailure', async () => { + services.parseABI.mockReturnValue([{ name: 'name', type: 'function', stateMutability: 'view' }]); + services.getFunctionFromABI.mockReturnValue({ name: 'name', type: 'function', stateMutability: 'view' }); + services.multicall.mockResolvedValue([{ status: 'success', result: 'Token1' }, { status: 'failure', error: { message: 'Reverted' } }]); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'multicall')?.[2]; + const result = await handler({ calls: [{ contractAddress: '0xToken1', functionName: 'name' }, { contractAddress: '0xToken2', functionName: 'name' }], allowFailure: true }); + expect(result.content[0].text).toContain('"failedCalls": 1'); + }); + it('should handle errors gracefully', async () => { + services.parseABI.mockImplementation(() => { throw new Error('Invalid ABI'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'multicall')?.[2]; + const result = await handler({ calls: [{ contractAddress: '0xToken1', functionName: 'name', abiJson: 'invalid' }] }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error executing multicall'); + }); + }); + + describe('transfer_native tool', () => { + const originalEnv = process.env; + beforeEach(() => { process.env = { ...originalEnv }; }); + afterEach(() => { process.env = originalEnv; }); + it('should transfer native tokens', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + services.transferETH.mockResolvedValue('0xtxHash123'); + services.getWalletAddressFromKey.mockReturnValue('0xWallet123'); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'transfer_native')?.[2]; + const result = await handler({ to: '0xRecipient123', amount: '0.5', network: 'ethereum' }); + expect(result.content[0].text).toContain('"txHash": "0xtxHash123"'); + }); + it('should handle errors gracefully', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + services.transferETH.mockImplementation(() => { throw new Error('Insufficient balance'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'transfer_native')?.[2]; + const result = await handler({ to: '0xRecipient123', amount: '0.5', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error transferring native tokens: Insufficient balance" }], isError: true }); + }); + }); + + describe('transfer_erc20 tool', () => { + const originalEnv = process.env; + beforeEach(() => { process.env = { ...originalEnv }; }); + afterEach(() => { process.env = originalEnv; }); + it('should transfer ERC20 tokens', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + services.transferERC20.mockResolvedValue({ amount: { raw: BigInt(100000000), formatted: '100' }, token: { symbol: 'USDC', decimals: 6 }, txHash: '0xtxHash123' }); + services.getWalletAddressFromKey.mockReturnValue('0xWallet123'); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'transfer_erc20')?.[2]; + const result = await handler({ tokenAddress: '0xUSDC', to: '0xRecipient123', amount: '100', network: 'ethereum' }); + expect(result.content[0].text).toContain('"txHash": "0xtxHash123"'); + }); + it('should handle errors gracefully', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + services.transferERC20.mockImplementation(() => { throw new Error('Insufficient allowance'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'transfer_erc20')?.[2]; + const result = await handler({ tokenAddress: '0xUSDC', to: '0xRecipient123', amount: '100', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error transferring ERC20 tokens: Insufficient allowance" }], isError: true }); + }); + }); + + describe('approve_token_spending tool', () => { + const originalEnv = process.env; + beforeEach(() => { process.env = { ...originalEnv }; }); + afterEach(() => { process.env = originalEnv; }); + it('should approve token spending', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + services.approveERC20.mockResolvedValue('0xtxHash123'); + services.getWalletAddressFromKey.mockReturnValue('0xWallet123'); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'approve_token_spending')?.[2]; + const result = await handler({ tokenAddress: '0xUSDC', spenderAddress: '0xSpender123', amount: '1000000', network: 'ethereum' }); + expect(result.content[0].text).toContain('"txHash": "0xtxHash123"'); + }); + it('should handle errors gracefully', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + services.approveERC20.mockImplementation(() => { throw new Error('Approval failed'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'approve_token_spending')?.[2]; + const result = await handler({ tokenAddress: '0xUSDC', spenderAddress: '0xSpender123', amount: '1000000', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error approving token spending: Approval failed" }], isError: true }); + }); + }); + + describe('get_nft_info tool', () => { + it('should return NFT metadata', async () => { + services.getERC721TokenMetadata.mockResolvedValue({ name: 'Cool NFT', symbol: 'CNFT', tokenURI: 'https://example.com/nft.json' }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_nft_info')?.[2]; + const result = await handler({ contractAddress: '0xNFT123', tokenId: '1', network: 'ethereum' }); + expect(result.content[0].text).toContain('"name": "Cool NFT"'); + }); + it('should handle errors gracefully', async () => { + services.getERC721TokenMetadata.mockImplementation(() => { throw new Error('NFT not found'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_nft_info')?.[2]; + const result = await handler({ contractAddress: '0xNFT123', tokenId: '1', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error fetching NFT info: NFT not found" }], isError: true }); + }); + }); + + describe('get_erc1155_balance tool', () => { + it('should return ERC1155 balance', async () => { + services.getERC1155Balance.mockResolvedValue(BigInt(5)); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_erc1155_balance')?.[2]; + const result = await handler({ contractAddress: '0xERC1155', tokenId: '1', address: '0xOwner123', network: 'ethereum' }); + expect(result.content[0].text).toContain('"balance": "5"'); + }); + it('should handle errors gracefully', async () => { + services.getERC1155Balance.mockImplementation(() => { throw new Error('Failed to fetch balance'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'get_erc1155_balance')?.[2]; + const result = await handler({ contractAddress: '0xERC1155', tokenId: '1', address: '0xOwner123', network: 'ethereum' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error fetching ERC1155 balance: Failed to fetch balance" }], isError: true }); + }); + }); + + describe('sign_message tool', () => { + const originalEnv = process.env; + beforeEach(() => { process.env = { ...originalEnv }; }); + afterEach(() => { process.env = originalEnv; }); + it('should sign a message', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + services.signMessage.mockResolvedValue('0xSignature123'); + services.getWalletAddressFromKey.mockReturnValue('0xWallet123'); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'sign_message')?.[2]; + const result = await handler({ message: 'Hello, World!' }); + expect(result.content[0].text).toContain('"signature": "0xSignature123"'); + }); + it('should handle errors gracefully', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + services.signMessage.mockImplementation(() => { throw new Error('Signing failed'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'sign_message')?.[2]; + const result = await handler({ message: 'Hello, World!' }); + expect(result).toEqual({ content: [{ type: "text", text: "Error signing message: Signing failed" }], isError: true }); + }); + }); + + describe('sign_typed_data tool', () => { + const originalEnv = process.env; + beforeEach(() => { process.env = { ...originalEnv }; }); + afterEach(() => { process.env = originalEnv; }); + it('should sign typed data', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + services.signTypedData.mockResolvedValue('0xSignature123'); + services.getWalletAddressFromKey.mockReturnValue('0xWallet123'); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'sign_typed_data')?.[2]; + const result = await handler({ domainJson: JSON.stringify({ name: 'TestDApp', version: '1', chainId: 1, verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC' }), typesJson: JSON.stringify({ Person: [{ name: 'name', type: 'string' }] }), primaryType: 'Person', messageJson: JSON.stringify({ name: 'Alice' }) }); + expect(result.content[0].text).toContain('"signature": "0xSignature123"'); + }); + it('should handle invalid JSON inputs', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + services.getWalletAddressFromKey.mockReturnValue('0xWallet123'); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'sign_typed_data')?.[2]; + const result = await handler({ domainJson: 'invalid json', typesJson: JSON.stringify({}), primaryType: 'Person', messageJson: JSON.stringify({}) }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error parsing JSON inputs'); + }); + it('should handle errors gracefully', async () => { + process.env.EVM_PRIVATE_KEY = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + services.signTypedData.mockImplementation(() => { throw new Error('Signing failed'); }); + registerEVMTools(server); + const handler = mockRegisterTool.mock.calls.find(call => call[0] === 'sign_typed_data')?.[2]; + const result = await handler({ domainJson: JSON.stringify({ name: 'TestDApp' }), typesJson: JSON.stringify({}), primaryType: 'Person', messageJson: JSON.stringify({}) }); + expect(result).toEqual({ content: [{ type: "text", text: "Error signing typed data: Signing failed" }], isError: true }); + }); + }); + }); +}); From ebd77d3c44c99eec664b4127f19168597738e999 Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:05 +1000 Subject: [PATCH 17/18] test: add src/server/server.test.ts --- src/server/server.test.ts | 116 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/server/server.test.ts diff --git a/src/server/server.test.ts b/src/server/server.test.ts new file mode 100644 index 0000000..dbe124b --- /dev/null +++ b/src/server/server.test.ts @@ -0,0 +1,116 @@ +import startServer from "./server.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerEVMResources } from "../core/resources.js"; +import { registerEVMTools } from "../core/tools.js"; +import { registerEVMPrompts } from "../core/prompts.js"; +import { getSupportedNetworks } from "../core/chains.js"; + +// Mock the dependencies +jest.mock("../core/resources.js"); +jest.mock("../core/tools.js"); +jest.mock("../core/prompts.js"); +jest.mock("../core/chains.js"); +jest.mock("@modelcontextprotocol/sdk/server/mcp.js"); + +describe("server", () => { + const mockServer = { + registerResource: jest.fn(), + registerTool: jest.fn(), + registerPrompt: jest.fn() + }; + + beforeEach(() => { + jest.clearAllMocks(); + (McpServer as jest.MockedClass).mockImplementation(() => mockServer as unknown as McpServer); + (getSupportedNetworks as jest.Mock).mockReturnValue(["ethereum", "optimism", "arbitrum"]); + }); + + describe("startServer", () => { + it("should create a new McpServer instance with correct configuration", async () => { + await startServer(); + + expect(McpServer).toHaveBeenCalledTimes(1); + expect(McpServer).toHaveBeenCalledWith( + { + name: "evm-mcp-server", + version: "2.0.0" + }, + { + capabilities: { + tools: { + listChanged: true + }, + resources: { + subscribe: false, + listChanged: true + }, + prompts: { + listChanged: true + }, + logging: {} + } + } + ); + }); + + it("should register EVM resources", async () => { + await startServer(); + + expect(registerEVMResources).toHaveBeenCalledTimes(1); + expect(registerEVMResources).toHaveBeenCalledWith(mockServer); + }); + + it("should register EVM tools", async () => { + await startServer(); + + expect(registerEVMTools).toHaveBeenCalledTimes(1); + expect(registerEVMTools).toHaveBeenCalledWith(mockServer); + }); + + it("should register EVM prompts", async () => { + await startServer(); + + expect(registerEVMPrompts).toHaveBeenCalledTimes(1); + expect(registerEVMPrompts).toHaveBeenCalledWith(mockServer); + }); + + it("should log server initialization information", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + + await startServer(); + + expect(consoleSpy).toHaveBeenCalledWith("EVM MCP Server v2.0.0 initialized"); + expect(consoleSpy).toHaveBeenCalledWith("Protocol: MCP 2025-06-18"); + expect(consoleSpy).toHaveBeenCalledWith("Supported networks: 3 networks"); + expect(consoleSpy).toHaveBeenCalledWith("Server is ready to handle requests"); + + consoleSpy.mockRestore(); + }); + + it("should return the server instance", async () => { + const result = await startServer(); + + expect(result).toBe(mockServer); + }); + + it("should handle initialization errors and exit process", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + const processExitSpy = jest.spyOn(process, "exit").mockImplementation(() => undefined as never); + + (registerEVMResources as jest.Mock).mockImplementation(() => { + throw new Error("Initialization failed"); + }); + + await startServer(); + + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to initialize server:", + expect.any(Error) + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + + consoleSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + }); +}); From f67bee300a70811f9cfb320188da17713f06dad3 Mon Sep 17 00:00:00 2001 From: ToTheos-Dev Date: Tue, 19 May 2026 10:17:05 +1000 Subject: [PATCH 18/18] test: add src/server/http-server.test.ts --- src/server/http-server.test.ts | 180 +++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 src/server/http-server.test.ts diff --git a/src/server/http-server.test.ts b/src/server/http-server.test.ts new file mode 100644 index 0000000..c6d36b6 --- /dev/null +++ b/src/server/http-server.test.ts @@ -0,0 +1,180 @@ +import express from "express"; + +describe("http-server", () => { + describe("Express app routes", () => { + let app: express.Application; + + beforeEach(() => { + // Create a minimal express app for testing routes + app = express(); + app.use(express.json({ limit: '10mb' })); + + // Mock endpoints that mimic the http-server behavior + app.get("/", (_req: express.Request, res: express.Response) => { + res.status(200).json({ + name: "EVM MCP Server", + version: "2.0.0", + protocol: "MCP 2025-06-18", + transport: "Streamable HTTP", + }); + }); + + app.get("/health", (_req: express.Request, res: express.Response) => { + res.status(200).json({ + status: "ok", + activeSessions: 0, + sessionIds: [], + }); + }); + + app.post("/mcp", (req: express.Request, res: express.Response) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (sessionId && sessionId !== "valid-session") { + res.status(404).json({ error: "Session not found" }); + return; + } + if (!sessionId) { + res.status(200).json({ result: "ok" }); + return; + } + res.status(200).json({ result: "ok" }); + }); + + app.get("/mcp", (req: express.Request, res: express.Response) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || sessionId !== "valid-session") { + res.status(400).json({ error: "Invalid or missing session ID" }); + return; + } + res.status(200).json({ result: "ok" }); + }); + + app.delete("/mcp", (req: express.Request, res: express.Response) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || sessionId !== "valid-session") { + res.status(404).json({ error: "Session not found" }); + return; + } + res.status(200).json({ result: "ok" }); + }); + }); + + describe("GET /", () => { + it("should return server info", async () => { + const response = await simulateRequest(app, "GET", "/"); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + name: "EVM MCP Server", + version: "2.0.0", + protocol: "MCP 2025-06-18", + transport: "Streamable HTTP", + }); + }); + }); + + describe("GET /health", () => { + it("should return health status", async () => { + const response = await simulateRequest(app, "GET", "/health"); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("status", "ok"); + expect(response.body).toHaveProperty("activeSessions"); + expect(response.body).toHaveProperty("sessionIds"); + }); + }); + + describe("POST /mcp", () => { + it("should return 404 for invalid session ID", async () => { + const response = await simulateRequest(app, "POST", "/mcp", { + headers: { "mcp-session-id": "invalid-session-id" }, + body: { jsonrpc: "2.0", method: "test", id: 1 }, + }); + expect(response.status).toBe(404); + expect(response.body).toHaveProperty("error"); + }); + + it("should handle requests without session ID", async () => { + const response = await simulateRequest(app, "POST", "/mcp", { + body: { jsonrpc: "2.0", method: "test", id: 1 }, + }); + expect(response.status).toBe(200); + }); + }); + + describe("GET /mcp", () => { + it("should return 400 for missing session ID", async () => { + const response = await simulateRequest(app, "GET", "/mcp"); + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("error"); + }); + + it("should return 400 for invalid session ID", async () => { + const response = await simulateRequest(app, "GET", "/mcp", { + headers: { "mcp-session-id": "invalid-session-id" }, + }); + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("error"); + }); + }); + + describe("DELETE /mcp", () => { + it("should return 404 for missing session ID", async () => { + const response = await simulateRequest(app, "DELETE", "/mcp"); + expect(response.status).toBe(404); + expect(response.body).toHaveProperty("error"); + }); + + it("should return 404 for invalid session ID", async () => { + const response = await simulateRequest(app, "DELETE", "/mcp", { + headers: { "mcp-session-id": "invalid-session-id" }, + }); + expect(response.status).toBe(404); + expect(response.body).toHaveProperty("error"); + }); + }); + }); +}); + +/** + * Simulates an HTTP request to an Express app + */ +function simulateRequest( + app: express.Application, + method: string, + path: string, + options: { headers?: Record; body?: any } = {} +): Promise<{ status: number; body: any }> { + return new Promise((resolve) => { + const req = { + method, + url: path, + headers: { + "content-type": "application/json", + ...options.headers, + }, + body: options.body, + ip: "127.0.0.1", + } as any; + + const res = { + statusCode: 200, + headers: {} as Record, + body: null as any, + status(code: number) { + this.statusCode = code; + return this; + }, + json(data: any) { + this.body = data; + resolve({ status: this.statusCode, body: this.body }); + }, + setHeader(key: string, value: string) { + this.headers[key] = value; + }, + getHeader(key: string) { + return this.headers[key]; + }, + } as any; + + app(req, res); + }); +}