diff --git a/__tests__/providers/ca-lookup.test.ts b/__tests__/providers/ca-lookup.test.ts new file mode 100644 index 0000000..dcaf90f --- /dev/null +++ b/__tests__/providers/ca-lookup.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach, mock } from 'bun:test'; +import type { IAgentRuntime, Memory, State } from '@elizaos/core'; +import { caLookupProvider } from '../../src/providers/ca-lookup'; +import { SOLANA_SERVICE_NAME } from '../../src/constants'; + +describe('CA Lookup Provider', () => { + let mockRuntime: IAgentRuntime; + let mockMessage: Memory; + let mockState: State; + + beforeEach(() => { + // Mock runtime + mockRuntime = { + logger: { + debug: mock(() => { }), + warn: mock(() => { }), + error: mock(() => { }), + log: mock(() => { }), + }, + getService: mock(() => null), + getSetting: mock(() => undefined), + } as any; + + // Mock state + mockState = {} as State; + + // Mock message + mockMessage = { + content: { + text: '', + }, + } as Memory; + }); + + it('should return false when no addresses are found in message', async () => { + mockMessage.content.text = 'Hello world, no addresses here!'; + + const detectPubkeys = mock(() => []); + const mockSolanaService = { + detectPubkeysFromString: detectPubkeys, + }; + + mockRuntime.getService = mock(() => mockSolanaService); + + const result = await caLookupProvider.get(mockRuntime, mockMessage, mockState); + + expect(result).toBe(false); + expect(detectPubkeys).toHaveBeenCalledWith('Hello world, no addresses here!', false); + }); + + it('should extract Solana addresses from message text', async () => { + const testAddress = 'So11111111111111111111111111111111111111112'; + mockMessage.content.text = `Check out this token: ${testAddress}`; + + const mockSolanaService = { + detectPubkeysFromString: mock(() => [testAddress]), + getTokensSymbols: mock(async () => ({ [testAddress]: 'SOL' })), + getAddressesTypes: mock(async () => ({ [testAddress]: 'Token' })), + getDecimals: mock(async () => [9]), + getSupply: mock(async () => ({})), + }; + + mockRuntime.getService = mock(() => mockSolanaService); + + const result = await caLookupProvider.get(mockRuntime, mockMessage, mockState); + + expect(result).not.toBe(false); + if (result !== false) { + expect(result.data.tokens.length).toBe(1); + expect(result.data.tokens[0].symbol).toBe('SOL'); + expect(result.data.tokens[0].address).toBe(testAddress); + expect(result.data.resolvedCount).toBe(1); + expect(result.text).toContain('SOL'); + expect(result.text).toContain(testAddress); + } + }); + + it('should handle multiple addresses in message', async () => { + const addr1 = 'So11111111111111111111111111111111111111112'; + const addr2 = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + mockMessage.content.text = `Compare ${addr1} and ${addr2}`; + + const mockSolanaService = { + detectPubkeysFromString: mock(() => [addr1, addr2]), + getTokensSymbols: mock(async () => ({ + [addr1]: 'SOL', + [addr2]: 'USDC', + })), + getAddressesTypes: mock(async () => ({ + [addr1]: 'Token', + [addr2]: 'Token', + })), + getDecimals: mock(async () => [9, 6]), + getSupply: mock(async () => ({})), + }; + + mockRuntime.getService = mock(() => mockSolanaService); + + const result = await caLookupProvider.get(mockRuntime, mockMessage, mockState); + + expect(result).not.toBe(false); + if (result !== false) { + expect(result.data.tokens.length).toBe(2); + expect(result.data.resolvedCount).toBe(2); + const symbols = result.data.tokens.map((t: any) => t.symbol); + expect(symbols).toContain('SOL'); + expect(symbols).toContain('USDC'); + } + }); + + it('should return false when solana service is not available', async () => { + const testAddress = 'So11111111111111111111111111111111111111112'; + mockMessage.content.text = testAddress; + + mockRuntime.getService = mock(() => null); + + const result = await caLookupProvider.get(mockRuntime, mockMessage, mockState); + + expect(result).toBe(false); + expect(mockRuntime.logger.warn).toHaveBeenCalled(); + expect(mockRuntime.getService).toHaveBeenCalledWith(SOLANA_SERVICE_NAME); + }); + + it('should filter out non-token addresses', async () => { + const addr1 = 'So11111111111111111111111111111111111111112'; + const addr2 = 'WalletAddr11111111111111111111111111111111'; + mockMessage.content.text = `${addr1} ${addr2}`; + + const mockSolanaService = { + detectPubkeysFromString: mock(() => [addr1, addr2]), + getTokensSymbols: mock(async () => ({ + [addr1]: 'SOL', + [addr2]: null, + })), + getAddressesTypes: mock(async () => ({ + [addr1]: 'Token', + [addr2]: 'Wallet', // Not a token + })), + getDecimals: mock(async () => [9, -1]), + getSupply: mock(async () => ({})), + }; + + mockRuntime.getService = mock(() => mockSolanaService); + + const result = await caLookupProvider.get(mockRuntime, mockMessage, mockState); + + expect(result).not.toBe(false); + if (result !== false) { + // Only SOL token should be in results, wallet should be filtered + expect(result.data.tokens.length).toBe(1); + expect(result.data.tokens[0].symbol).toBe('SOL'); + expect(result.data.resolvedCount).toBe(1); + expect(result.data.lookupCount).toBe(2); + } + }); + + it('should handle errors gracefully', async () => { + const testAddress = 'So11111111111111111111111111111111111111112'; + mockMessage.content.text = testAddress; + + const mockSolanaService = { + detectPubkeysFromString: mock(() => [testAddress]), + getTokensSymbols: mock(async () => { + throw new Error('RPC error'); + }), + getAddressesTypes: mock(async () => ({})), + getDecimals: mock(async () => []), + getSupply: mock(async () => ({})), + }; + + mockRuntime.getService = mock(() => mockSolanaService); + + const result = await caLookupProvider.get(mockRuntime, mockMessage, mockState); + + expect(result).toBe(false); + expect(mockRuntime.logger.error).toHaveBeenCalled(); + }); + + it('should have correct provider metadata', () => { + expect(caLookupProvider.name).toBe('SOLANA_CA_SYMBOL_LOOKUP'); + expect(caLookupProvider.description).toBeTruthy(); + expect(caLookupProvider.dynamic).toBe(true); + }); +}); diff --git a/src/index.ts b/src/index.ts index 20970b9..6ed0afa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { solanaRoutes } from './routes/index'; import { SolanaService, SolanaWalletService } from './service'; import { SOLANA_SERVICE_NAME } from './constants'; +import { caLookupProvider } from './providers/ca-lookup'; export const solanaPlugin: Plugin = { name: SOLANA_SERVICE_NAME, @@ -39,8 +40,17 @@ export const solanaPlugin: Plugin = { runtime.registerProvider(walletProvider) + // Only register CA lookup provider if spartan-intel plugin is not loaded + // (spartan-intel has its own CA lookup functionality) + const hasIntel = runtime.character.plugins?.includes('spartan-intel') ?? false; + if (!hasIntel) { + runtime.registerProvider(caLookupProvider) // CA symbol lookup for Solana + } else { + runtime.logger.log('Skipping CA lookup provider - spartan-intel plugin is loaded') + } + // extensions - runtime.getServiceLoadPromise('INTEL_CHAIN' as ServiceTypeName).then( () => { + runtime.getServiceLoadPromise('INTEL_CHAIN' as ServiceTypeName).then(() => { //runtime.logger.log('solana INTEL_CHAIN LOADED') const traderChainService = runtime.getService('INTEL_CHAIN') as any; const me = { @@ -50,7 +60,7 @@ export const solanaPlugin: Plugin = { }; traderChainService.registerChain(me); }).catch(error => { - runtime.logger.error({ error },'Failed to register with INTEL_CHAIN'); + runtime.logger.error({ error }, 'Failed to register with INTEL_CHAIN'); }); }, diff --git a/src/providers/ca-lookup.ts b/src/providers/ca-lookup.ts new file mode 100644 index 0000000..658c147 --- /dev/null +++ b/src/providers/ca-lookup.ts @@ -0,0 +1,127 @@ +import type { IAgentRuntime, Memory, Provider, State } from '@elizaos/core'; +import { SOLANA_SERVICE_NAME } from '../constants'; + +/** + * Provider for looking up comprehensive Solana token information + * + * This provider extracts contract addresses from messages and retrieves: + * - Symbol (from on-chain Metaplex/Token-2022 metadata) + * - Token type (Token, Token Account, Wallet, etc.) + * - Decimals + * - Total supply (when available) + * + * No LLM calls are made - this is pure data lookup from on-chain data. + */ +export const caLookupProvider: Provider = { + name: 'SOLANA_CA_SYMBOL_LOOKUP', + description: 'Looks up comprehensive Solana token information including symbol, type, decimals, and supply from on-chain data', + dynamic: true, + get: async (runtime: IAgentRuntime, message: Memory, state: State): Promise => { + // Get Solana service + const solanaService = runtime.getService(SOLANA_SERVICE_NAME) as any; + + if (!solanaService) { + runtime.logger.warn('CA lookup provider: solana service not available'); + return false; + } + + // Extract potential addresses from the message content using Solana service's method + const messageText = message.content?.text || ''; + const addresses = solanaService.detectPubkeysFromString(messageText, false); + + if (!addresses || addresses.length === 0) { + // No addresses found, return empty + return false; + } + + runtime.logger.debug(`CA lookup provider found ${addresses.length} potential addresses`); + + // Gather all token information in parallel + let symbolMap: Record = {}; + let addressTypes: Record = {}; + let decimals: number[] = []; + let supplyInfo: Record = {}; + + try { + // Fetch all information in parallel for efficiency + [symbolMap, addressTypes, decimals, supplyInfo] = await Promise.all([ + solanaService.getTokensSymbols(addresses), + solanaService.getAddressesTypes(addresses), + solanaService.getDecimals(addresses), + solanaService.getSupply(addresses).catch((err: Error) => { + runtime.logger.warn(`Failed to fetch supply info: ${err.message}`); + return {}; + }) + ]); + } catch (error) { + runtime.logger.error( + `CA lookup error: ${error instanceof Error ? error.message : String(error)}` + ); + return false; + } + + // Build comprehensive token information + const tokenInfo: Array<{ + address: string; + symbol: string | null; + type: string; + decimals: number; + supply?: string; + }> = []; + + for (let i = 0; i < addresses.length; i++) { + const ca = addresses[i]; + const type = addressTypes[ca] || 'Unknown'; + + // Only include tokens (not wallets or other account types) + if (type === 'Token' || type.includes('Token')) { + const info: any = { + address: ca, + symbol: symbolMap[ca] || null, + type, + decimals: decimals[i] ?? -1, + }; + + // Add supply if available + if (supplyInfo[ca]?.human) { + info.supply = supplyInfo[ca].human.toString(); + } + + tokenInfo.push(info); + } + } + + if (tokenInfo.length === 0) { + runtime.logger.debug('CA lookup: no valid tokens found'); + return false; + } + + // Format output + let text = '\nSolana Token Information:\n'; + text += 'CA, Symbol, Type, Decimals, Supply\n'; + + for (const info of tokenInfo) { + const parts = [ + info.address, + info.symbol || 'N/A', + info.type, + info.decimals >= 0 ? info.decimals.toString() : 'N/A', + info.supply || 'N/A' + ]; + text += parts.join(', ') + '\n'; + } + + const data = { + tokens: tokenInfo, + lookupCount: addresses.length, + resolvedCount: tokenInfo.length, + }; + + return { + data, + values: {}, + text, + }; + }, +}; +