diff --git a/src/adapters/EvmChainAdapter.ts b/src/adapters/EvmChainAdapter.ts index 71c6d71..919b019 100644 --- a/src/adapters/EvmChainAdapter.ts +++ b/src/adapters/EvmChainAdapter.ts @@ -28,6 +28,7 @@ import { import { ChainConfig } from '../types/ChainConfig.js'; import { Balance, Transaction, AssetType, Chain as DataModelChain } from '@cygnus-wealth/data-models'; import { mapChainIdToChain, mapEvmBalanceToBalance, mapTokenToAsset } from '../utils/mappers.js'; +import type { TokenDiscoveryResult, TokenDiscoveryError, DiscoveredToken } from '../types/TokenDiscovery.js'; // ERC20 ABI for balance queries const ERC20_ABI = parseAbi([ @@ -319,6 +320,97 @@ export class EvmChainAdapter implements IChainAdapter { }; } + async discoverTokens(address: Address): Promise { + const client = await this.ensureConnected(); + const result: TokenDiscoveryResult = { + address, + chainId: this.config.id, + tokens: [], + errors: [], + }; + + let alchemyResponse: { + address: string; + tokenBalances: Array<{ + contractAddress: Address; + tokenBalance: string | null; + error: string | null; + }>; + }; + + try { + alchemyResponse = await (client as any).request({ + method: 'alchemy_getTokenBalances', + params: [address, 'DEFAULT_TOKENS'], + }); + } catch (err: any) { + result.errors.push({ + chainId: this.config.id, + message: err.message || 'Token discovery API unavailable', + code: 'DISCOVERY_API_UNAVAILABLE', + }); + return result; + } + + for (const entry of alchemyResponse.tokenBalances) { + // Report Alchemy-level errors + if (entry.error) { + result.errors.push({ + contractAddress: entry.contractAddress, + chainId: this.config.id, + message: entry.error, + code: 'TOKEN_BALANCE_ERROR', + }); + continue; + } + + // Skip zero-balance tokens + const rawBalance = BigInt(entry.tokenBalance || '0'); + if (rawBalance === 0n) continue; + + // Fetch token metadata + try { + const decimals = await client.readContract({ + address: entry.contractAddress, + abi: ERC20_ABI, + functionName: 'decimals', + }) as number; + + const symbol = await client.readContract({ + address: entry.contractAddress, + abi: ERC20_ABI, + functionName: 'symbol', + }) as string; + + const name = await client.readContract({ + address: entry.contractAddress, + abi: ERC20_ABI, + functionName: 'name', + }) as string; + + // Filter spam tokens + if (isSpamToken(symbol || '', name || '')) continue; + + result.tokens.push({ + contractAddress: entry.contractAddress, + balance: rawBalance.toString(), + symbol, + name, + decimals, + }); + } catch (err: any) { + result.errors.push({ + contractAddress: entry.contractAddress, + chainId: this.config.id, + message: err.message || 'Failed to fetch token metadata', + code: 'METADATA_FETCH_FAILED', + }); + } + } + + return result; + } + async isHealthy(): Promise { try { const client = await this.ensureConnected(); diff --git a/src/adapters/TokenDiscovery.test.ts b/src/adapters/TokenDiscovery.test.ts new file mode 100644 index 0000000..6670aec --- /dev/null +++ b/src/adapters/TokenDiscovery.test.ts @@ -0,0 +1,321 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Address, PublicClient } from 'viem'; +import { EvmChainAdapter, isSpamToken } from './EvmChainAdapter.js'; +import { ChainConfig } from '../types/ChainConfig.js'; + +const TEST_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' as Address; +const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Address; +const USDT_ADDRESS = '0xdAC17F958D2ee523a2206206994597C13D831ec7' as Address; +const DAI_ADDRESS = '0x6B175474E89094C44Da98b954EedeAC495271d0F' as Address; +const BROKEN_TOKEN = '0xDEAD000000000000000000000000000000000000' as Address; +const SPAM_TOKEN = '0x5PAA000000000000000000000000000000000000' as Address; + +function makeConfig(chainId: number = 1): ChainConfig { + return { + id: chainId, + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + endpoints: { http: ['https://eth-mainnet.g.alchemy.com/v2/test-key'] }, + explorer: 'https://etherscan.io', + }; +} + +// Helper to create a mock client +function createMockClient() { + return { + request: vi.fn(), + readContract: vi.fn(), + getBalance: vi.fn(), + getBlockNumber: vi.fn(), + watchBlockNumber: vi.fn(), + watchPendingTransactions: vi.fn(), + getTransaction: vi.fn(), + chain: { id: 1 }, + }; +} + +describe('EvmChainAdapter.discoverTokens', () => { + let adapter: EvmChainAdapter; + let mockClient: ReturnType; + + beforeEach(() => { + adapter = new EvmChainAdapter(makeConfig()); + mockClient = createMockClient(); + + // Inject mock client via connect override + (adapter as any).client = mockClient; + }); + + it('should call alchemy_getTokenBalances with DEFAULT_TOKENS', async () => { + mockClient.request.mockResolvedValue({ + address: TEST_ADDRESS, + tokenBalances: [], + }); + + await adapter.discoverTokens(TEST_ADDRESS); + + expect(mockClient.request).toHaveBeenCalledWith({ + method: 'alchemy_getTokenBalances', + params: [TEST_ADDRESS, 'DEFAULT_TOKENS'], + }); + }); + + it('should return discovered tokens with balances and metadata', async () => { + // Mock Alchemy response + mockClient.request.mockResolvedValue({ + address: TEST_ADDRESS, + tokenBalances: [ + { + contractAddress: USDC_ADDRESS, + tokenBalance: '0x0000000000000000000000000000000000000000000000000000000000989680', // 10000000 + error: null, + }, + { + contractAddress: DAI_ADDRESS, + tokenBalance: '0x0000000000000000000000000000000000000000000000001bc16d674ec80000', // 2e18 + error: null, + }, + ], + }); + + // Mock metadata calls + mockClient.readContract + // USDC decimals + .mockResolvedValueOnce(6) + // USDC symbol + .mockResolvedValueOnce('USDC') + // USDC name + .mockResolvedValueOnce('USD Coin') + // DAI decimals + .mockResolvedValueOnce(18) + // DAI symbol + .mockResolvedValueOnce('DAI') + // DAI name + .mockResolvedValueOnce('Dai Stablecoin'); + + const result = await adapter.discoverTokens(TEST_ADDRESS); + + expect(result.address).toBe(TEST_ADDRESS); + expect(result.chainId).toBe(1); + expect(result.tokens).toHaveLength(2); + + expect(result.tokens[0].contractAddress).toBe(USDC_ADDRESS); + expect(result.tokens[0].symbol).toBe('USDC'); + expect(result.tokens[0].name).toBe('USD Coin'); + expect(result.tokens[0].decimals).toBe(6); + expect(result.tokens[0].balance).toBeDefined(); + + expect(result.tokens[1].contractAddress).toBe(DAI_ADDRESS); + expect(result.tokens[1].symbol).toBe('DAI'); + expect(result.errors).toHaveLength(0); + }); + + it('should skip zero-balance tokens', async () => { + mockClient.request.mockResolvedValue({ + address: TEST_ADDRESS, + tokenBalances: [ + { + contractAddress: USDC_ADDRESS, + tokenBalance: '0x0000000000000000000000000000000000000000000000000000000000000000', + error: null, + }, + { + contractAddress: DAI_ADDRESS, + tokenBalance: '0x0000000000000000000000000000000000000000000000001bc16d674ec80000', + error: null, + }, + ], + }); + + // Only DAI metadata should be fetched + mockClient.readContract + .mockResolvedValueOnce(18) + .mockResolvedValueOnce('DAI') + .mockResolvedValueOnce('Dai Stablecoin'); + + const result = await adapter.discoverTokens(TEST_ADDRESS); + + expect(result.tokens).toHaveLength(1); + expect(result.tokens[0].symbol).toBe('DAI'); + }); + + it('should report errors for tokens that fail in Alchemy response', async () => { + mockClient.request.mockResolvedValue({ + address: TEST_ADDRESS, + tokenBalances: [ + { + contractAddress: USDC_ADDRESS, + tokenBalance: '0x0000000000000000000000000000000000000000000000000000000000989680', + error: null, + }, + { + contractAddress: BROKEN_TOKEN, + tokenBalance: null, + error: 'Contract execution reverted', + }, + ], + }); + + // USDC metadata + mockClient.readContract + .mockResolvedValueOnce(6) + .mockResolvedValueOnce('USDC') + .mockResolvedValueOnce('USD Coin'); + + const result = await adapter.discoverTokens(TEST_ADDRESS); + + expect(result.tokens).toHaveLength(1); + expect(result.tokens[0].symbol).toBe('USDC'); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].contractAddress).toBe(BROKEN_TOKEN); + expect(result.errors[0].message).toContain('Contract execution reverted'); + }); + + it('should report errors for tokens where metadata fetch fails', async () => { + mockClient.request.mockResolvedValue({ + address: TEST_ADDRESS, + tokenBalances: [ + { + contractAddress: USDC_ADDRESS, + tokenBalance: '0x0000000000000000000000000000000000000000000000000000000000989680', + error: null, + }, + { + contractAddress: BROKEN_TOKEN, + tokenBalance: '0x0000000000000000000000000000000000000000000000000000000000000001', + error: null, + }, + ], + }); + + // USDC metadata succeeds + mockClient.readContract + .mockResolvedValueOnce(6) + .mockResolvedValueOnce('USDC') + .mockResolvedValueOnce('USD Coin') + // BROKEN_TOKEN metadata fails + .mockRejectedValueOnce(new Error('execution reverted')); + + const result = await adapter.discoverTokens(TEST_ADDRESS); + + expect(result.tokens).toHaveLength(1); + expect(result.tokens[0].symbol).toBe('USDC'); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].contractAddress).toBe(BROKEN_TOKEN); + expect(result.errors[0].code).toBe('METADATA_FETCH_FAILED'); + }); + + it('should filter out spam tokens', async () => { + mockClient.request.mockResolvedValue({ + address: TEST_ADDRESS, + tokenBalances: [ + { + contractAddress: USDC_ADDRESS, + tokenBalance: '0x0000000000000000000000000000000000000000000000000000000000989680', + error: null, + }, + { + contractAddress: SPAM_TOKEN, + tokenBalance: '0x0000000000000000000000000000000000000000000000000000000000000001', + error: null, + }, + ], + }); + + mockClient.readContract + // USDC metadata + .mockResolvedValueOnce(6) + .mockResolvedValueOnce('USDC') + .mockResolvedValueOnce('USD Coin') + // Spam token metadata + .mockResolvedValueOnce(18) + .mockResolvedValueOnce('REWARD') + .mockResolvedValueOnce('Claim Reward at https://scam.live'); + + const result = await adapter.discoverTokens(TEST_ADDRESS); + + expect(result.tokens).toHaveLength(1); + expect(result.tokens[0].symbol).toBe('USDC'); + // Spam tokens are filtered, not reported as errors + }); + + it('should handle empty token list from Alchemy', async () => { + mockClient.request.mockResolvedValue({ + address: TEST_ADDRESS, + tokenBalances: [], + }); + + const result = await adapter.discoverTokens(TEST_ADDRESS); + + expect(result.tokens).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); + + it('should handle Alchemy API not available (fallback error)', async () => { + mockClient.request.mockRejectedValue( + new Error('Method alchemy_getTokenBalances not found') + ); + + const result = await adapter.discoverTokens(TEST_ADDRESS); + + expect(result.tokens).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].code).toBe('DISCOVERY_API_UNAVAILABLE'); + expect(result.errors[0].message).toContain('not found'); + }); + + it('should work for Polygon chain', async () => { + const polyAdapter = new EvmChainAdapter(makeConfig(137)); + const polyMock = createMockClient(); + polyMock.chain = { id: 137 } as any; + (polyAdapter as any).client = polyMock; + + polyMock.request.mockResolvedValue({ + address: TEST_ADDRESS, + tokenBalances: [ + { + contractAddress: USDC_ADDRESS, + tokenBalance: '0x0000000000000000000000000000000000000000000000000000000000989680', + error: null, + }, + ], + }); + + polyMock.readContract + .mockResolvedValueOnce(6) + .mockResolvedValueOnce('USDC') + .mockResolvedValueOnce('USD Coin (PoS)'); + + const result = await polyAdapter.discoverTokens(TEST_ADDRESS); + + expect(result.chainId).toBe(137); + expect(result.tokens).toHaveLength(1); + expect(result.tokens[0].name).toBe('USD Coin (PoS)'); + }); + + it('should convert token balances as Balance objects', async () => { + mockClient.request.mockResolvedValue({ + address: TEST_ADDRESS, + tokenBalances: [ + { + contractAddress: USDC_ADDRESS, + tokenBalance: '0x0000000000000000000000000000000000000000000000000000000000989680', + error: null, + }, + ], + }); + + mockClient.readContract + .mockResolvedValueOnce(6) + .mockResolvedValueOnce('USDC') + .mockResolvedValueOnce('USD Coin'); + + const result = await adapter.discoverTokens(TEST_ADDRESS); + + // The balance should be the raw bigint string representation + expect(result.tokens[0].balance).toBeDefined(); + expect(typeof result.tokens[0].balance).toBe('string'); + expect(result.tokens[0].decimals).toBe(6); + }); +}); diff --git a/src/index.ts b/src/index.ts index 7c4b5f9..7e3ac32 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,6 +70,15 @@ export type { BalanceServiceStats, } from './services/BalanceService.js'; +// Token Discovery Service - Dynamic ERC20 token discovery via Alchemy API +export { TokenDiscoveryService } from './services/TokenDiscoveryService.js'; +export type { + DiscoveredToken, + TokenDiscoveryError, + TokenDiscoveryResult, + MultiChainTokenDiscoveryResult, +} from './types/TokenDiscovery.js'; + // DeFi Service - Read DeFi protocol positions (Beefy, Aave, etc.) export { DeFiService } from './defi/DeFiService.js'; export type { diff --git a/src/services/TokenDiscoveryService.test.ts b/src/services/TokenDiscoveryService.test.ts new file mode 100644 index 0000000..6f0c42d --- /dev/null +++ b/src/services/TokenDiscoveryService.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Address } from 'viem'; +import { TokenDiscoveryService } from './TokenDiscoveryService.js'; +import { EvmChainAdapter } from '../adapters/EvmChainAdapter.js'; +import { ChainConfig } from '../types/ChainConfig.js'; + +// Mock EvmChainAdapter +vi.mock('../adapters/EvmChainAdapter.js'); + +const TEST_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' as Address; + +const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Address; +const USDT_ADDRESS = '0xdAC17F958D2ee523a2206206994597C13D831ec7' as Address; +const DAI_ADDRESS = '0x6B175474E89094C44Da98b954EedeAC495271d0F' as Address; +const BROKEN_TOKEN = '0xDEAD000000000000000000000000000000000000' as Address; + +function makeConfig(chainId: number, name: string): ChainConfig { + return { + id: chainId, + name, + symbol: 'ETH', + decimals: 18, + endpoints: { http: ['https://rpc.example.com'] }, + explorer: 'https://explorer.example.com', + }; +} + +function makeMockAdapter(chainId: number): { + adapter: EvmChainAdapter; + mockDiscoverTokens: ReturnType; +} { + const mockDiscoverTokens = vi.fn(); + const adapter = { + getChainInfo: () => ({ id: chainId, name: `Chain ${chainId}`, symbol: 'ETH', decimals: 18, explorer: '' }), + discoverTokens: mockDiscoverTokens, + connect: vi.fn(), + disconnect: vi.fn(), + getBalance: vi.fn(), + getTokenBalances: vi.fn(), + getTransactions: vi.fn(), + subscribeToBalance: vi.fn(), + subscribeToTransactions: vi.fn(), + isHealthy: vi.fn(), + } as unknown as EvmChainAdapter; + return { adapter, mockDiscoverTokens }; +} + +describe('TokenDiscoveryService', () => { + describe('discoverTokens (single chain via adapter)', () => { + it('should return discovered tokens from Alchemy API response', async () => { + const { adapter, mockDiscoverTokens } = makeMockAdapter(1); + mockDiscoverTokens.mockResolvedValue({ + address: TEST_ADDRESS, + chainId: 1, + tokens: [ + { + contractAddress: USDC_ADDRESS, + balance: '1000000', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + { + contractAddress: DAI_ADDRESS, + balance: '2000000000000000000', + symbol: 'DAI', + name: 'Dai Stablecoin', + decimals: 18, + }, + ], + errors: [], + }); + + const adapters = new Map([[1, adapter]]); + const service = new TokenDiscoveryService(adapters); + const result = await service.discoverTokens(TEST_ADDRESS, 1); + + expect(result.tokens).toHaveLength(2); + expect(result.tokens[0].symbol).toBe('USDC'); + expect(result.tokens[0].balance).toBe('1000000'); + expect(result.tokens[1].symbol).toBe('DAI'); + expect(result.errors).toHaveLength(0); + }); + + it('should include error details for tokens that fail metadata fetch', async () => { + const { adapter, mockDiscoverTokens } = makeMockAdapter(1); + mockDiscoverTokens.mockResolvedValue({ + address: TEST_ADDRESS, + chainId: 1, + tokens: [ + { + contractAddress: USDC_ADDRESS, + balance: '1000000', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + ], + errors: [ + { + contractAddress: BROKEN_TOKEN, + chainId: 1, + message: 'Failed to fetch token metadata', + code: 'METADATA_FETCH_FAILED', + }, + ], + }); + + const adapters = new Map([[1, adapter]]); + const service = new TokenDiscoveryService(adapters); + const result = await service.discoverTokens(TEST_ADDRESS, 1); + + expect(result.tokens).toHaveLength(1); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].contractAddress).toBe(BROKEN_TOKEN); + expect(result.errors[0].message).toBe('Failed to fetch token metadata'); + expect(result.errors[0].code).toBe('METADATA_FETCH_FAILED'); + }); + + it('should throw if chain adapter not found', async () => { + const adapters = new Map(); + const service = new TokenDiscoveryService(adapters); + + await expect(service.discoverTokens(TEST_ADDRESS, 999)) + .rejects.toThrow('No adapter registered for chain 999'); + }); + }); + + describe('discoverTokensMultiChain', () => { + it('should aggregate results across all requested chains', async () => { + const { adapter: ethAdapter, mockDiscoverTokens: mockEth } = makeMockAdapter(1); + const { adapter: polyAdapter, mockDiscoverTokens: mockPoly } = makeMockAdapter(137); + + mockEth.mockResolvedValue({ + address: TEST_ADDRESS, + chainId: 1, + tokens: [ + { contractAddress: USDC_ADDRESS, balance: '1000000', symbol: 'USDC', name: 'USD Coin', decimals: 6 }, + ], + errors: [], + }); + + mockPoly.mockResolvedValue({ + address: TEST_ADDRESS, + chainId: 137, + tokens: [ + { contractAddress: USDT_ADDRESS, balance: '5000000', symbol: 'USDT', name: 'Tether USD', decimals: 6 }, + ], + errors: [], + }); + + const adapters = new Map([ + [1, ethAdapter], + [137, polyAdapter], + ]); + const service = new TokenDiscoveryService(adapters); + const result = await service.discoverTokensMultiChain(TEST_ADDRESS, [1, 137]); + + expect(result.results).toHaveLength(2); + + const ethResult = result.results.find(r => r.chainId === 1); + expect(ethResult).toBeDefined(); + expect(ethResult!.tokens).toHaveLength(1); + expect(ethResult!.tokens[0].symbol).toBe('USDC'); + + const polyResult = result.results.find(r => r.chainId === 137); + expect(polyResult).toBeDefined(); + expect(polyResult!.tokens).toHaveLength(1); + expect(polyResult!.tokens[0].symbol).toBe('USDT'); + }); + + it('should discover across all 5 supported chains', async () => { + const chainIds = [1, 137, 42161, 10, 8453]; // ETH, Polygon, Arbitrum, Optimism, Base + const adapterMap = new Map(); + + for (const chainId of chainIds) { + const { adapter, mockDiscoverTokens } = makeMockAdapter(chainId); + mockDiscoverTokens.mockResolvedValue({ + address: TEST_ADDRESS, + chainId, + tokens: [ + { contractAddress: USDC_ADDRESS, balance: '1000000', symbol: 'USDC', name: 'USD Coin', decimals: 6 }, + ], + errors: [], + }); + adapterMap.set(chainId, adapter); + } + + const service = new TokenDiscoveryService(adapterMap); + const result = await service.discoverTokensMultiChain(TEST_ADDRESS, chainIds); + + expect(result.results).toHaveLength(5); + for (const chainId of chainIds) { + const chainResult = result.results.find(r => r.chainId === chainId); + expect(chainResult).toBeDefined(); + expect(chainResult!.tokens.length).toBeGreaterThanOrEqual(1); + } + }); + + it('should collect chain-level errors without failing other chains', async () => { + const { adapter: ethAdapter, mockDiscoverTokens: mockEth } = makeMockAdapter(1); + const { adapter: polyAdapter, mockDiscoverTokens: mockPoly } = makeMockAdapter(137); + + mockEth.mockResolvedValue({ + address: TEST_ADDRESS, + chainId: 1, + tokens: [ + { contractAddress: USDC_ADDRESS, balance: '1000000', symbol: 'USDC', name: 'USD Coin', decimals: 6 }, + ], + errors: [], + }); + + mockPoly.mockRejectedValue(new Error('RPC connection failed')); + + const adapters = new Map([ + [1, ethAdapter], + [137, polyAdapter], + ]); + const service = new TokenDiscoveryService(adapters); + const result = await service.discoverTokensMultiChain(TEST_ADDRESS, [1, 137]); + + // Successful chain should still have results + expect(result.results).toHaveLength(1); + expect(result.results[0].chainId).toBe(1); + + // Failed chain should be in chainErrors + expect(result.chainErrors).toHaveLength(1); + expect(result.chainErrors[0].chainId).toBe(137); + expect(result.chainErrors[0].message).toBe('RPC connection failed'); + }); + + it('should return empty results for chains with no tokens', async () => { + const { adapter, mockDiscoverTokens } = makeMockAdapter(1); + mockDiscoverTokens.mockResolvedValue({ + address: TEST_ADDRESS, + chainId: 1, + tokens: [], + errors: [], + }); + + const adapters = new Map([[1, adapter]]); + const service = new TokenDiscoveryService(adapters); + const result = await service.discoverTokensMultiChain(TEST_ADDRESS, [1]); + + expect(result.results).toHaveLength(1); + expect(result.results[0].tokens).toHaveLength(0); + expect(result.results[0].errors).toHaveLength(0); + }); + + it('should skip chains without registered adapters and report errors', async () => { + const { adapter, mockDiscoverTokens } = makeMockAdapter(1); + mockDiscoverTokens.mockResolvedValue({ + address: TEST_ADDRESS, + chainId: 1, + tokens: [], + errors: [], + }); + + const adapters = new Map([[1, adapter]]); + const service = new TokenDiscoveryService(adapters); + const result = await service.discoverTokensMultiChain(TEST_ADDRESS, [1, 999]); + + expect(result.results).toHaveLength(1); + expect(result.chainErrors).toHaveLength(1); + expect(result.chainErrors[0].chainId).toBe(999); + }); + }); +}); diff --git a/src/services/TokenDiscoveryService.ts b/src/services/TokenDiscoveryService.ts new file mode 100644 index 0000000..2f629fd --- /dev/null +++ b/src/services/TokenDiscoveryService.ts @@ -0,0 +1,56 @@ +import { Address } from 'viem'; +import { EvmChainAdapter } from '../adapters/EvmChainAdapter.js'; +import type { + TokenDiscoveryResult, + TokenDiscoveryError, + MultiChainTokenDiscoveryResult, +} from '../types/TokenDiscovery.js'; + +export class TokenDiscoveryService { + private adapters: Map; + + constructor(adapters: Map) { + this.adapters = adapters; + } + + async discoverTokens(address: Address, chainId: number): Promise { + const adapter = this.adapters.get(chainId); + if (!adapter) { + throw new Error(`No adapter registered for chain ${chainId}`); + } + return adapter.discoverTokens(address); + } + + async discoverTokensMultiChain( + address: Address, + chainIds: number[], + ): Promise { + const results: TokenDiscoveryResult[] = []; + const chainErrors: TokenDiscoveryError[] = []; + + const settled = await Promise.allSettled( + chainIds.map(async (chainId) => { + const adapter = this.adapters.get(chainId); + if (!adapter) { + throw new Error(`No adapter registered for chain ${chainId}`); + } + return adapter.discoverTokens(address); + }), + ); + + for (let i = 0; i < settled.length; i++) { + const outcome = settled[i]; + if (outcome.status === 'fulfilled') { + results.push(outcome.value); + } else { + chainErrors.push({ + chainId: chainIds[i], + message: outcome.reason?.message || 'Unknown error', + code: 'CHAIN_DISCOVERY_FAILED', + }); + } + } + + return { results, chainErrors }; + } +} diff --git a/src/types/TokenDiscovery.ts b/src/types/TokenDiscovery.ts new file mode 100644 index 0000000..4e2a6b5 --- /dev/null +++ b/src/types/TokenDiscovery.ts @@ -0,0 +1,28 @@ +import { Address } from 'viem'; + +export interface DiscoveredToken { + contractAddress: Address; + balance: string; + symbol?: string; + name?: string; + decimals?: number; +} + +export interface TokenDiscoveryError { + contractAddress?: Address; + chainId: number; + message: string; + code?: string; +} + +export interface TokenDiscoveryResult { + address: Address; + chainId: number; + tokens: DiscoveredToken[]; + errors: TokenDiscoveryError[]; +} + +export interface MultiChainTokenDiscoveryResult { + results: TokenDiscoveryResult[]; + chainErrors: TokenDiscoveryError[]; +}