Skip to content

Commit a553e60

Browse files
Merge pull request #244 from pinax-network/copilot/add-query-service-for-solana
2 parents 10e70de + 1ac9421 commit a553e60

File tree

3 files changed

+606
-0
lines changed

3 files changed

+606
-0
lines changed

cli.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,61 @@ Examples:
803803
},
804804
);
805805

806+
// ---- query metadata-solana-extras <mint> ----
807+
queryCommand
808+
.command('metadata-solana-extras <mint>')
809+
.description(
810+
'Query LP metadata for a single Solana mint address with verbose debug logging',
811+
)
812+
.option(
813+
'--node-url <url>',
814+
'Solana RPC node URL for querying blockchain data',
815+
process.env.NODE_URL || process.env.SOLANA_NODE_URL,
816+
)
817+
.addHelpText(
818+
'after',
819+
`
820+
This command queries LP token metadata for a single Solana mint address with verbose debug logging.
821+
It's useful for troubleshooting to understand why a particular token may not be identified as an LP token.
822+
823+
The command will:
824+
1. Validate the mint address format
825+
2. Check if the mint is a Pump.fun AMM LP token
826+
3. Check if the mint is a Meteora DLMM LP token
827+
4. Check if the mint is a Raydium LP token (AMM V4 or CPMM)
828+
5. Derive LP metadata if the mint is identified as an LP token
829+
830+
Note: This command uses heavier RPC calls like getProgramAccounts for Raydium LP detection.
831+
832+
Examples:
833+
$ bun run cli.ts query metadata-solana-extras <mint>
834+
$ bun run cli.ts query metadata-solana-extras <mint> --node-url https://api.mainnet-beta.solana.com
835+
`,
836+
)
837+
.action(async (mint: string, options: { nodeUrl?: string }) => {
838+
// Set NODE_URL environment variable if provided via CLI
839+
if (options.nodeUrl) {
840+
process.env.NODE_URL = options.nodeUrl;
841+
}
842+
843+
// Validate NODE_URL is set
844+
if (!process.env.NODE_URL && !process.env.SOLANA_NODE_URL) {
845+
log.error(
846+
'NODE_URL or SOLANA_NODE_URL environment variable is required',
847+
);
848+
log.info(
849+
'Set it via environment variable or use --node-url option',
850+
);
851+
process.exit(1);
852+
}
853+
854+
// Import and run the query service
855+
const { run: runQuery } = await import(
856+
'./services/metadata-solana-extras/query.ts'
857+
);
858+
await runQuery(mint);
859+
});
860+
806861
// ============================================================================
807862
// Parse CLI arguments
808863
// ============================================================================
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* Tests for Solana LP metadata query service
3+
*/
4+
5+
import { beforeEach, describe, expect, mock, test } from 'bun:test';
6+
7+
// Mock dependencies
8+
const mockIsPumpAmmLpToken = mock(() =>
9+
Promise.resolve({ isLpToken: false, poolAddress: null }),
10+
);
11+
const mockDerivePumpAmmLpMetadata = mock(() => Promise.resolve(null));
12+
const mockIsMeteoraDlmmLpToken = mock(() =>
13+
Promise.resolve({ isLpToken: false, poolAddress: null }),
14+
);
15+
const mockDeriveMeteoraDlmmLpMetadata = mock(() => Promise.resolve(null));
16+
const mockIsRaydiumAmmLpToken = mock(() =>
17+
Promise.resolve({ isLpToken: false, poolAddress: null, poolType: null }),
18+
);
19+
const mockDeriveRaydiumLpMetadata = mock(() => Promise.resolve(null));
20+
21+
mock.module('../../lib/solana-rpc', () => ({
22+
isPumpAmmLpToken: mockIsPumpAmmLpToken,
23+
derivePumpAmmLpMetadata: mockDerivePumpAmmLpMetadata,
24+
isMeteoraDlmmLpToken: mockIsMeteoraDlmmLpToken,
25+
deriveMeteoraDlmmLpMetadata: mockDeriveMeteoraDlmmLpMetadata,
26+
isRaydiumAmmLpToken: mockIsRaydiumAmmLpToken,
27+
deriveRaydiumLpMetadata: mockDeriveRaydiumLpMetadata,
28+
PUMP_AMM_PROGRAM_ID: 'pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA',
29+
METEORA_DLMM_PROGRAM_ID: '24Uqj9JCLxUeoC3hGfh5W3s9FM9uCHDS2SG3LYwBpyTi',
30+
RAYDIUM_AMM_PROGRAM_ID: '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8',
31+
RAYDIUM_CPMM_PROGRAM_ID: 'CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C',
32+
}));
33+
34+
// Import the queryLpMetadata function after mocking
35+
const { queryLpMetadata } = await import('./query');
36+
37+
describe('Solana LP metadata query service', () => {
38+
beforeEach(() => {
39+
mockIsPumpAmmLpToken.mockClear();
40+
mockDerivePumpAmmLpMetadata.mockClear();
41+
mockIsMeteoraDlmmLpToken.mockClear();
42+
mockDeriveMeteoraDlmmLpMetadata.mockClear();
43+
mockIsRaydiumAmmLpToken.mockClear();
44+
mockDeriveRaydiumLpMetadata.mockClear();
45+
46+
// Reset to default implementations
47+
mockIsPumpAmmLpToken.mockReturnValue(
48+
Promise.resolve({ isLpToken: false, poolAddress: null }),
49+
);
50+
mockDerivePumpAmmLpMetadata.mockReturnValue(Promise.resolve(null));
51+
mockIsMeteoraDlmmLpToken.mockReturnValue(
52+
Promise.resolve({ isLpToken: false, poolAddress: null }),
53+
);
54+
mockDeriveMeteoraDlmmLpMetadata.mockReturnValue(Promise.resolve(null));
55+
mockIsRaydiumAmmLpToken.mockReturnValue(
56+
Promise.resolve({
57+
isLpToken: false,
58+
poolAddress: null,
59+
poolType: null,
60+
}),
61+
);
62+
mockDeriveRaydiumLpMetadata.mockReturnValue(Promise.resolve(null));
63+
});
64+
65+
test('should validate mint address format', async () => {
66+
// Test with valid mint address (44 chars)
67+
await queryLpMetadata('So11111111111111111111111111111111111111112');
68+
expect(mockIsPumpAmmLpToken).toHaveBeenCalled();
69+
});
70+
71+
test('should reject invalid mint address (too short)', async () => {
72+
await queryLpMetadata('short');
73+
// Should not proceed to LP checks
74+
expect(mockIsPumpAmmLpToken).not.toHaveBeenCalled();
75+
});
76+
77+
test('should detect Pump.fun AMM LP token', async () => {
78+
mockIsPumpAmmLpToken.mockReturnValue(
79+
Promise.resolve({
80+
isLpToken: true,
81+
poolAddress: 'pool123456789012345678901234567890123456',
82+
}),
83+
);
84+
mockDerivePumpAmmLpMetadata.mockReturnValue(
85+
Promise.resolve({
86+
name: 'Pump AMM LP',
87+
symbol: 'PUMP-LP',
88+
}),
89+
);
90+
91+
await queryLpMetadata('So11111111111111111111111111111111111111112');
92+
93+
expect(mockIsPumpAmmLpToken).toHaveBeenCalled();
94+
expect(mockDerivePumpAmmLpMetadata).toHaveBeenCalled();
95+
});
96+
97+
test('should detect Meteora DLMM LP token', async () => {
98+
mockIsMeteoraDlmmLpToken.mockReturnValue(
99+
Promise.resolve({
100+
isLpToken: true,
101+
poolAddress: 'pool123456789012345678901234567890123456',
102+
}),
103+
);
104+
mockDeriveMeteoraDlmmLpMetadata.mockReturnValue(
105+
Promise.resolve({
106+
name: 'Meteora DLMM LP',
107+
symbol: 'DLMM-LP',
108+
}),
109+
);
110+
111+
await queryLpMetadata('So11111111111111111111111111111111111111112');
112+
113+
expect(mockIsMeteoraDlmmLpToken).toHaveBeenCalled();
114+
expect(mockDeriveMeteoraDlmmLpMetadata).toHaveBeenCalled();
115+
});
116+
117+
test('should detect Raydium LP token', async () => {
118+
mockIsRaydiumAmmLpToken.mockReturnValue(
119+
Promise.resolve({
120+
isLpToken: true,
121+
poolAddress: 'pool123456789012345678901234567890123456',
122+
poolType: 'amm-v4',
123+
}),
124+
);
125+
mockDeriveRaydiumLpMetadata.mockReturnValue(
126+
Promise.resolve({
127+
name: 'Raydium LP',
128+
symbol: 'RAY-LP',
129+
}),
130+
);
131+
132+
await queryLpMetadata('So11111111111111111111111111111111111111112');
133+
134+
expect(mockIsRaydiumAmmLpToken).toHaveBeenCalled();
135+
expect(mockDeriveRaydiumLpMetadata).toHaveBeenCalled();
136+
});
137+
138+
test('should handle non-LP token gracefully', async () => {
139+
// All checks return false
140+
mockIsPumpAmmLpToken.mockReturnValue(
141+
Promise.resolve({ isLpToken: false, poolAddress: null }),
142+
);
143+
mockIsMeteoraDlmmLpToken.mockReturnValue(
144+
Promise.resolve({ isLpToken: false, poolAddress: null }),
145+
);
146+
mockIsRaydiumAmmLpToken.mockReturnValue(
147+
Promise.resolve({
148+
isLpToken: false,
149+
poolAddress: null,
150+
poolType: null,
151+
}),
152+
);
153+
154+
// Should complete without throwing
155+
await queryLpMetadata('So11111111111111111111111111111111111111112');
156+
157+
expect(mockIsPumpAmmLpToken).toHaveBeenCalled();
158+
expect(mockIsMeteoraDlmmLpToken).toHaveBeenCalled();
159+
expect(mockIsRaydiumAmmLpToken).toHaveBeenCalled();
160+
});
161+
162+
test('should handle RPC errors gracefully', async () => {
163+
mockIsPumpAmmLpToken.mockImplementation(() => {
164+
throw new Error('RPC connection failed');
165+
});
166+
167+
// Should not throw, just log the error
168+
await queryLpMetadata('So11111111111111111111111111111111111111112');
169+
170+
// Should still attempt other checks
171+
expect(mockIsMeteoraDlmmLpToken).toHaveBeenCalled();
172+
expect(mockIsRaydiumAmmLpToken).toHaveBeenCalled();
173+
});
174+
175+
test('should handle pool address not found for Raydium', async () => {
176+
mockIsRaydiumAmmLpToken.mockReturnValue(
177+
Promise.resolve({
178+
isLpToken: true,
179+
poolAddress: null, // Pool address not found
180+
poolType: 'amm-v4',
181+
}),
182+
);
183+
184+
await queryLpMetadata('So11111111111111111111111111111111111111112');
185+
186+
expect(mockIsRaydiumAmmLpToken).toHaveBeenCalled();
187+
// Should not attempt to derive metadata without pool address
188+
expect(mockDeriveRaydiumLpMetadata).not.toHaveBeenCalled();
189+
});
190+
});

0 commit comments

Comments
 (0)