Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions __tests__/providers/ca-lookup.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 12 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {
Expand All @@ -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');
});

},
Expand Down
127 changes: 127 additions & 0 deletions src/providers/ca-lookup.ts
Original file line number Diff line number Diff line change
@@ -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<any> => {
// 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<string, string | null> = {};
let addressTypes: Record<string, string> = {};
let decimals: number[] = [];
let supplyInfo: Record<string, { supply: bigint; decimals: number; human: any }> = {};

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,
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Bug

The caLookupProvider accesses token decimals by array index, while other token details use the contract address as a key. This assumes the getDecimals service method preserves the input address order, which could lead to incorrect decimal assignments if not guaranteed.

Fix in Cursor Fix in Web


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