From a3ee16930d1bee62db3fa79d57b71cfaed3150fd Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 00:46:14 -0700 Subject: [PATCH 1/3] feat(pools): organize pool templates by network instead of connector - Restructure pool templates from `pools/{connector}.json` to `pools/{chain}/{network}.json` - Change pool list/get API to use chain+network as primary keys, connector becomes optional filter - Add chain field to pool add/remove requests - Make baseSymbol/quoteSymbol optional in add request (auto-fetched from pool info) - Update pool-service to handle new directory structure - Remove deprecated per-connector template files This enables better organization of pools by network, allowing users to see all pools on a network regardless of which DEX they belong to. Co-Authored-By: Claude Opus 4.5 --- package.json | 2 +- src/connectors/pancakeswap/pancakeswap.ts | 3 +- src/connectors/uniswap/uniswap.ts | 3 +- src/pools/pool-lookup-helper.ts | 50 ++-- src/pools/routes/addPool.ts | 29 ++- src/pools/routes/findPools.ts | 9 +- src/pools/routes/getPool.ts | 19 +- src/pools/routes/listPools.ts | 6 +- src/pools/routes/removePool.ts | 19 +- src/pools/routes/save.ts | 101 +++++++- src/pools/schemas.ts | 99 +++---- src/pools/types.ts | 23 +- src/services/coingecko-service.ts | 4 - src/services/gecko-types.ts | 96 ------- src/services/pool-service.ts | 211 ++++++++------- src/templates/pools/ethereum/arbitrum.json | 24 ++ src/templates/pools/ethereum/base.json | 24 ++ .../{pancakeswap.json => ethereum/bsc.json} | 2 + src/templates/pools/ethereum/mainnet.json | 13 + src/templates/pools/ethereum/optimism.json | 13 + src/templates/pools/ethereum/polygon.json | 13 + src/templates/pools/meteora.json | 22 -- src/templates/pools/orca.json | 22 -- src/templates/pools/pancakeswap-sol.json | 22 -- src/templates/pools/raydium.json | 42 --- src/templates/pools/solana/mainnet-beta.json | 112 ++++++++ src/templates/pools/uniswap.json | 72 ------ src/tokens/schemas.ts | 44 +--- src/tokens/token-lookup-helper.ts | 22 +- src/tokens/types.ts | 11 - test/pools/pool-service.test.ts | 59 ++++- test/pools/pools.routes.test.ts | 242 +++--------------- 32 files changed, 634 insertions(+), 799 deletions(-) delete mode 100644 src/services/gecko-types.ts create mode 100644 src/templates/pools/ethereum/arbitrum.json create mode 100644 src/templates/pools/ethereum/base.json rename src/templates/pools/{pancakeswap.json => ethereum/bsc.json} (91%) create mode 100644 src/templates/pools/ethereum/mainnet.json create mode 100644 src/templates/pools/ethereum/optimism.json create mode 100644 src/templates/pools/ethereum/polygon.json delete mode 100644 src/templates/pools/meteora.json delete mode 100644 src/templates/pools/orca.json delete mode 100644 src/templates/pools/pancakeswap-sol.json delete mode 100644 src/templates/pools/raydium.json create mode 100644 src/templates/pools/solana/mainnet-beta.json delete mode 100644 src/templates/pools/uniswap.json diff --git a/package.json b/package.json index 7f0bf49ef8..09a5906404 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "setup": "bash ./gateway-setup.sh", "setup:with-defaults": "bash ./gateway-setup.sh --with-defaults", "start": "START_SERVER=true node dist/index.js", - "copy-files": "copyfiles 'src/templates/namespace/*.json' 'src/templates/*.yml' 'src/templates/chains/**/*.yml' 'src/templates/connectors/*.yml' 'src/templates/tokens/**/*.json' 'src/templates/pools/*.json' dist && copyfiles -u 1 'src/connectors/pancakeswap-sol/idl/*.json' dist", + "copy-files": "copyfiles 'src/templates/namespace/*.json' 'src/templates/*.yml' 'src/templates/chains/**/*.yml' 'src/templates/connectors/*.yml' 'src/templates/tokens/**/*.json' 'src/templates/pools/**/*.json' dist && copyfiles -u 1 'src/connectors/pancakeswap-sol/idl/*.json' dist", "test": "GATEWAY_TEST_MODE=dev jest --verbose", "test:clear-cache": "jest --clearCache", "test:debug": "GATEWAY_TEST_MODE=dev jest --watch --runInBand", diff --git a/src/connectors/pancakeswap/pancakeswap.ts b/src/connectors/pancakeswap/pancakeswap.ts index 7ead39828d..daa3ad2678 100644 --- a/src/connectors/pancakeswap/pancakeswap.ts +++ b/src/connectors/pancakeswap/pancakeswap.ts @@ -412,11 +412,12 @@ export class Pancakeswap { const poolService = PoolService.getInstance(); const pool = await poolService.getPool( - 'pancakeswap', + 'ethereum', this.networkName, poolType, baseTokenInfo.symbol, quoteTokenInfo.symbol, + 'pancakeswap', ); if (!pool) { diff --git a/src/connectors/uniswap/uniswap.ts b/src/connectors/uniswap/uniswap.ts index 1eb8d2e02c..df36e15b5f 100644 --- a/src/connectors/uniswap/uniswap.ts +++ b/src/connectors/uniswap/uniswap.ts @@ -472,11 +472,12 @@ export class Uniswap { const poolService = PoolService.getInstance(); const pool = await poolService.getPool( - 'uniswap', + 'ethereum', this.networkName, poolType, baseTokenInfo.symbol, quoteTokenInfo.symbol, + 'uniswap', ); if (!pool) { diff --git a/src/pools/pool-lookup-helper.ts b/src/pools/pool-lookup-helper.ts index 4c60e3f440..43fca4c0e7 100644 --- a/src/pools/pool-lookup-helper.ts +++ b/src/pools/pool-lookup-helper.ts @@ -3,7 +3,6 @@ */ import { CoinGeckoService, TopPoolInfo } from '../services/coingecko-service'; -import { extractRawPoolData, toPoolGeckoData } from '../services/gecko-types'; import { logger } from '../services/logger'; import { fetchPoolInfo, resolveTokenSymbols } from './pool-info-helpers'; @@ -51,32 +50,44 @@ export async function fetchDetailedPoolInfo(chainNetwork: string, address: strin poolInfo.quoteTokenAddress, ); - const baseSymbol = symbols.baseSymbol || poolData.baseTokenSymbol; - const quoteSymbol = symbols.quoteSymbol || poolData.quoteTokenSymbol; + // If symbols not found locally (or are DUMMY_ placeholders), fetch from GeckoTerminal token info endpoint + let baseSymbol = symbols.baseSymbol; + let quoteSymbol = symbols.quoteSymbol; - if (!baseSymbol || !quoteSymbol) { - throw new Error( - `Could not resolve symbols for pool ${address} (base: ${baseSymbol || 'unknown'}, quote: ${quoteSymbol || 'unknown'})`, - ); + // Check if symbol is missing or is a DUMMY_ placeholder + const needsBaseSymbol = !baseSymbol || baseSymbol.startsWith('DUMMY_'); + const needsQuoteSymbol = !quoteSymbol || quoteSymbol.startsWith('DUMMY_'); + + if (needsBaseSymbol) { + try { + const tokenInfo = await coinGeckoService.getTokenInfo(chainNetwork, poolInfo.baseTokenAddress); + baseSymbol = tokenInfo.symbol; + logger.info(`Resolved base token symbol from GeckoTerminal: ${baseSymbol}`); + } catch (error) { + logger.warn(`Failed to fetch base token info, using pool name: ${error.message}`); + baseSymbol = poolData.baseTokenSymbol; + } } - // Calculate APR if we have volume and liquidity data - let apr: number | undefined; - if (poolData.volumeUsd24h && poolData.liquidityUsd) { - const volume = parseFloat(poolData.volumeUsd24h); - const liquidity = parseFloat(poolData.liquidityUsd); - if (!isNaN(volume) && !isNaN(liquidity) && liquidity > 0) { - // APR = (daily volume * fee% / liquidity) * 365 * 100 - apr = ((volume * (poolInfo.feePct / 100)) / liquidity) * 365 * 100; + if (needsQuoteSymbol) { + try { + const tokenInfo = await coinGeckoService.getTokenInfo(chainNetwork, poolInfo.quoteTokenAddress); + quoteSymbol = tokenInfo.symbol; + logger.info(`Resolved quote token symbol from GeckoTerminal: ${quoteSymbol}`); + } catch (error) { + logger.warn(`Failed to fetch quote token info, using pool name: ${error.message}`); + quoteSymbol = poolData.quoteTokenSymbol; } } - // Create pool object with CoinGecko data separated - // Use typed transformation helper to ensure consistent geckoData format - const rawPoolData = extractRawPoolData(poolData); - const geckoData = toPoolGeckoData(rawPoolData, apr); + if (!baseSymbol || !quoteSymbol) { + throw new Error( + `Could not resolve symbols for pool ${address} (base: ${baseSymbol || 'unknown'}, quote: ${quoteSymbol || 'unknown'})`, + ); + } const pool: Pool = { + connector: poolData.connector, type: poolData.type as 'amm' | 'clmm', network, baseSymbol, @@ -85,7 +96,6 @@ export async function fetchDetailedPoolInfo(chainNetwork: string, address: strin quoteTokenAddress: poolInfo.quoteTokenAddress, feePct: poolInfo.feePct, address, - geckoData, }; return { diff --git a/src/pools/routes/addPool.ts b/src/pools/routes/addPool.ts index a8b15876b7..4b8a7b5bb1 100644 --- a/src/pools/routes/addPool.ts +++ b/src/pools/routes/addPool.ts @@ -26,6 +26,7 @@ export const addPoolRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { const { + chain, connector, type, network, @@ -70,8 +71,9 @@ export const addPoolRoute: FastifyPluginAsync = async (fastify) => { finalQuoteSymbol = finalQuoteSymbol || resolvedQuote; } - // Step 3: Create enhanced pool object + // Step 3: Create enhanced pool object with connector field const pool: Pool = { + connector, type, network, baseSymbol: finalBaseSymbol, @@ -84,46 +86,47 @@ export const addPoolRoute: FastifyPluginAsync = async (fastify) => { // Step 4: Check if a pool with same token pair already exists (ignoring feePct) const existingPoolByMetadata = await poolService.getPoolByMetadata( - connector, - type, + chain, network, + type, baseTokenAddress, quoteTokenAddress, + connector, ); if (existingPoolByMetadata) { if (existingPoolByMetadata.address.toLowerCase() === address.toLowerCase()) { // Same pool (same address and token pair), just update it (fee tier may have changed) - await poolService.updatePool(connector, pool); + await poolService.updatePool(chain, network, pool); return { - message: `Pool ${finalBaseSymbol}-${finalQuoteSymbol} updated to ${finalFeePct}% fee in ${connector} ${type} on ${network}`, + message: `Pool ${finalBaseSymbol}-${finalQuoteSymbol} updated to ${finalFeePct}% fee in ${connector} ${type} on ${chain}/${network}`, }; } else { // Different address but same token pair - replace the old pool - await poolService.removePool(connector, network, type, existingPoolByMetadata.address); - await poolService.addPool(connector, pool); + await poolService.removePool(chain, network, existingPoolByMetadata.address); + await poolService.addPool(chain, network, pool); return { - message: `Pool ${finalBaseSymbol}-${finalQuoteSymbol} (${finalFeePct}% fee) replaced (old: ${existingPoolByMetadata.address} ${existingPoolByMetadata.feePct}% fee, new: ${address}) in ${connector} ${type} on ${network}`, + message: `Pool ${finalBaseSymbol}-${finalQuoteSymbol} (${finalFeePct}% fee) replaced (old: ${existingPoolByMetadata.address} ${existingPoolByMetadata.feePct}% fee, new: ${address}) in ${connector} ${type} on ${chain}/${network}`, }; } } // Step 5: No pool with matching metadata exists - check if address is used - const existingPoolByAddress = await poolService.getPoolByAddress(connector, address); + const existingPoolByAddress = await poolService.getPoolByAddress(chain, network, address); if (existingPoolByAddress) { // Address exists but with different metadata - this shouldn't happen normally // but we'll update it anyway - await poolService.updatePool(connector, pool); + await poolService.updatePool(chain, network, pool); return { - message: `Pool ${finalBaseSymbol}-${finalQuoteSymbol} (${finalFeePct}% fee) updated successfully in ${connector} ${type} on ${network}`, + message: `Pool ${finalBaseSymbol}-${finalQuoteSymbol} (${finalFeePct}% fee) updated successfully in ${connector} ${type} on ${chain}/${network}`, }; } // Step 6: Completely new pool - add it - await poolService.addPool(connector, pool); + await poolService.addPool(chain, network, pool); return { - message: `Pool ${finalBaseSymbol}-${finalQuoteSymbol} (${finalFeePct}% fee) added successfully to ${connector} ${type} on ${network}`, + message: `Pool ${finalBaseSymbol}-${finalQuoteSymbol} (${finalFeePct}% fee) added successfully to ${connector} ${type} on ${chain}/${network}`, }; } catch (error) { if (error.statusCode) { diff --git a/src/pools/routes/findPools.ts b/src/pools/routes/findPools.ts index 113fd74041..515c5a900b 100644 --- a/src/pools/routes/findPools.ts +++ b/src/pools/routes/findPools.ts @@ -2,7 +2,6 @@ import { Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; import { TopPoolInfo } from '../../services/coingecko-service'; -import { extractRawPoolData, toPoolGeckoData } from '../../services/gecko-types'; import { handlePoolError } from '../pool-error-handler'; import { findPools } from '../pool-finder'; import { fetchDetailedPoolInfo } from '../pool-lookup-helper'; @@ -17,14 +16,10 @@ import { Pool } from '../types'; /** * Transform TopPoolInfo from CoinGecko to PoolInfo format - * Uses typed transformation helper to ensure consistent geckoData format */ function transformToPoolInfo(topPoolInfo: TopPoolInfo): Pool { - // Extract and transform geckoData using typed helpers - const rawPoolData = extractRawPoolData(topPoolInfo); - const geckoData = toPoolGeckoData(rawPoolData); - return { + connector: topPoolInfo.connector || '', // Will be filled from connector mapping type: topPoolInfo.type as 'amm' | 'clmm', network: '', // Will be filled from chainNetwork baseSymbol: topPoolInfo.baseTokenSymbol, @@ -33,7 +28,6 @@ function transformToPoolInfo(topPoolInfo: TopPoolInfo): Pool { quoteTokenAddress: topPoolInfo.quoteTokenAddress, feePct: topPoolInfo.feePct ?? 0, // Use fee from pool name if available address: topPoolInfo.poolAddress, - geckoData, }; } @@ -76,7 +70,6 @@ export const findPoolsRoute: FastifyPluginAsync = async (fastify) => { // Fetch detailed pool information using shared helper const { pool } = await fetchDetailedPoolInfo(chainNetwork, address); - // Return pool data in PoolInfo format with geckoData return pool; } catch (error: any) { handlePoolError(fastify, error, `Failed to get pool info for ${address}`); diff --git a/src/pools/routes/getPool.ts b/src/pools/routes/getPool.ts index c1fd4db639..703cdb51cc 100644 --- a/src/pools/routes/getPool.ts +++ b/src/pools/routes/getPool.ts @@ -7,9 +7,10 @@ export const getPoolRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Params: { tradingPair: string }; Querystring: { - connector: string; + chain: string; network: string; type: string; + connector?: string; }; }>( '/:tradingPair', @@ -51,7 +52,7 @@ export const getPoolRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { const { tradingPair } = request.params; - const { connector, network, type } = request.query; + const { chain, network, type, connector } = request.query; const poolService = PoolService.getInstance(); try { @@ -62,10 +63,20 @@ export const getPoolRoute: FastifyPluginAsync = async (fastify) => { throw new Error('Invalid trading pair format. Expected: BASE-QUOTE (e.g., ETH-USDC)'); } - const pool = await poolService.getPool(connector, network, type as 'amm' | 'clmm', baseToken, quoteToken); + const pool = await poolService.getPool( + chain, + network, + type as 'amm' | 'clmm', + baseToken, + quoteToken, + connector, + ); if (!pool) { - throw fastify.httpErrors.notFound(`Pool for ${tradingPair} not found in ${connector} ${type} on ${network}`); + const connectorInfo = connector ? ` (connector: ${connector})` : ''; + throw fastify.httpErrors.notFound( + `Pool for ${tradingPair} not found on ${chain}/${network} ${type}${connectorInfo}`, + ); } return pool; diff --git a/src/pools/routes/listPools.ts b/src/pools/routes/listPools.ts index a03105ba16..c7185cf19f 100644 --- a/src/pools/routes/listPools.ts +++ b/src/pools/routes/listPools.ts @@ -9,7 +9,7 @@ export const listPoolsRoute: FastifyPluginAsync = async (fastify) => { '/', { schema: { - description: 'List all pools for a connector, optionally filtered by network, type, or search term', + description: 'List all pools for a chain/network, optionally filtered by connector, type, or search term', tags: ['/pools'], querystring: PoolListRequestSchema, response: { @@ -18,11 +18,11 @@ export const listPoolsRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request) => { - const { connector, network, type, search } = request.query; + const { chain, network, connector, type, search } = request.query; const poolService = PoolService.getInstance(); try { - const pools = await poolService.listPools(connector, network, type, search); + const pools = await poolService.listPools(chain, network, connector, type, search); return pools; } catch (error) { throw fastify.httpErrors.badRequest(error.message); diff --git a/src/pools/routes/removePool.ts b/src/pools/routes/removePool.ts index 9d4fc12cc7..bb94e9c91f 100644 --- a/src/pools/routes/removePool.ts +++ b/src/pools/routes/removePool.ts @@ -8,9 +8,8 @@ export const removePoolRoute: FastifyPluginAsync = async (fastify) => { fastify.delete<{ Params: { address: string }; Querystring: { - connector: string; + chain: string; network: string; - type: string; }; }>( '/:address', @@ -29,18 +28,14 @@ export const removePoolRoute: FastifyPluginAsync = async (fastify) => { required: ['address'], }, querystring: Type.Object({ - connector: Type.String({ - description: 'Connector (raydium, meteora, uniswap, orca)', - examples: ['raydium', 'meteora', 'uniswap', 'orca'], + chain: Type.String({ + description: 'Blockchain chain (solana, ethereum)', + examples: ['solana', 'ethereum'], }), network: Type.String({ description: 'Network name (mainnet, mainnet-beta, etc)', examples: ['mainnet', 'mainnet-beta'], }), - type: Type.String({ - description: 'Pool type', - examples: ['amm', 'clmm'], - }), }), response: { 200: PoolSuccessResponseSchema, @@ -55,14 +50,14 @@ export const removePoolRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { const { address } = request.params; - const { connector, network, type } = request.query; + const { chain, network } = request.query; const poolService = PoolService.getInstance(); try { - await poolService.removePool(connector, network, type as 'amm' | 'clmm', address); + await poolService.removePool(chain, network, address); return { - message: `Pool with address ${address} removed successfully from ${connector} ${type} on ${network}`, + message: `Pool with address ${address} removed successfully from ${chain}/${network}`, }; } catch (error) { if (error.message.includes('not found')) { diff --git a/src/pools/routes/save.ts b/src/pools/routes/save.ts index 0c29236971..2134fa4d2d 100644 --- a/src/pools/routes/save.ts +++ b/src/pools/routes/save.ts @@ -1,23 +1,69 @@ import { Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; +import { CoinGeckoService } from '../../services/coingecko-service'; import { logger } from '../../services/logger'; import { PoolService } from '../../services/pool-service'; +import { TokenService } from '../../services/token-service'; +import { Token } from '../../tokens/types'; import { handlePoolError } from '../pool-error-handler'; import { fetchDetailedPoolInfo } from '../pool-lookup-helper'; import { FindPoolsQuerySchema, PoolInfoSchema } from '../schemas'; import { Pool } from '../types'; +/** + * Auto-save a token if it doesn't exist in the token list + * Fetches token info from GeckoTerminal and adds it + */ +async function autoSaveTokenIfMissing( + chain: string, + network: string, + chainNetwork: string, + tokenAddress: string, + tokenSymbol: string, +): Promise<{ added: boolean; symbol: string }> { + const tokenService = TokenService.getInstance(); + const coinGeckoService = CoinGeckoService.getInstance(); + + // Check if token already exists by address + const existingToken = await tokenService.getToken(chain, network, tokenAddress); + if (existingToken) { + return { added: false, symbol: existingToken.symbol }; + } + + // Token doesn't exist, fetch info from GeckoTerminal + try { + logger.info(`Token ${tokenSymbol} (${tokenAddress}) not found, fetching from GeckoTerminal...`); + const tokenInfo = await coinGeckoService.getTokenInfo(chainNetwork, tokenAddress); + + const newToken: Token = { + name: tokenInfo.name, + symbol: tokenInfo.symbol, + address: tokenInfo.address, + decimals: tokenInfo.decimals, + }; + + await tokenService.addToken(chain, network, newToken); + logger.info(`Auto-added token ${newToken.symbol} (${newToken.address}) to ${chain}/${network}`); + + return { added: true, symbol: newToken.symbol }; + } catch (error: any) { + logger.warn(`Failed to auto-add token ${tokenSymbol} (${tokenAddress}): ${error.message}`); + // Don't fail the pool save if token auto-add fails + return { added: false, symbol: tokenSymbol }; + } +} + export const savePoolRoute: FastifyPluginAsync = async (fastify) => { fastify.post<{ Params: { address: string }; Querystring: { chainNetwork: string }; - Reply: { message: string; pool: Pool }; + Reply: { message: string; pool: Pool; tokensAdded?: string[] }; }>( '/save/:address', { schema: { - description: 'Find pool from GeckoTerminal and save it to the pool list', + description: 'Find pool from GeckoTerminal and save it to the pool list. Auto-adds missing tokens.', tags: ['/pools'], params: { type: 'object', @@ -37,6 +83,7 @@ export const savePoolRoute: FastifyPluginAsync = async (fastify) => { 200: Type.Object({ message: Type.String(), pool: PoolInfoSchema, + tokensAdded: Type.Optional(Type.Array(Type.String())), }), }, }, @@ -46,34 +93,72 @@ export const savePoolRoute: FastifyPluginAsync = async (fastify) => { const { chainNetwork } = request.query; try { + // Parse chainNetwork to get chain and network + const coinGeckoService = CoinGeckoService.getInstance(); + const { chain, network } = coinGeckoService.parseChainNetwork(chainNetwork); + // Fetch detailed pool information using shared helper const { poolData, pool } = await fetchDetailedPoolInfo(chainNetwork, address); + // Auto-save tokens if they don't exist and get the correct symbols + const tokensAdded: string[] = []; + + const baseResult = await autoSaveTokenIfMissing( + chain, + network, + chainNetwork, + pool.baseTokenAddress, + pool.baseSymbol, + ); + if (baseResult.added) { + tokensAdded.push(baseResult.symbol); + } + // Update pool with correct base symbol (in case it was resolved from GeckoTerminal) + pool.baseSymbol = baseResult.symbol; + + const quoteResult = await autoSaveTokenIfMissing( + chain, + network, + chainNetwork, + pool.quoteTokenAddress, + pool.quoteSymbol, + ); + if (quoteResult.added) { + tokensAdded.push(quoteResult.symbol); + } + // Update pool with correct quote symbol (in case it was resolved from GeckoTerminal) + pool.quoteSymbol = quoteResult.symbol; + // Check if pool already exists const poolService = PoolService.getInstance(); - const existingPool = await poolService.getPoolByAddress(poolData.connector, address); + const existingPool = await poolService.getPoolByAddress(chain, network, address); if (existingPool) { // Update existing pool with latest market data logger.info( `Pool ${pool.baseSymbol}-${pool.quoteSymbol} (${address}) already exists, updating with latest data`, ); - await poolService.updatePoolByAddress(poolData.connector, pool); + await poolService.updatePoolByAddress(chain, network, pool); + + const tokenMsg = tokensAdded.length > 0 ? ` (auto-added tokens: ${tokensAdded.join(', ')})` : ''; return { - message: `Pool ${pool.baseSymbol}-${pool.quoteSymbol} already exists in the pool list for ${poolData.connector}, updated with latest data`, + message: `Pool ${pool.baseSymbol}-${pool.quoteSymbol} already exists in the pool list for ${chain}/${network}, updated with latest data${tokenMsg}`, pool, + tokensAdded: tokensAdded.length > 0 ? tokensAdded : undefined, }; } // Add pool to the list - await poolService.addPool(poolData.connector, pool); + await poolService.addPool(chain, network, pool); logger.info( - `Saved pool ${pool.baseSymbol}-${pool.quoteSymbol} (${address}) to ${poolData.connector} ${poolData.type}`, + `Saved pool ${pool.baseSymbol}-${pool.quoteSymbol} (${address}) to ${chain}/${network} ${poolData.type}`, ); + const tokenMsg = tokensAdded.length > 0 ? ` (auto-added tokens: ${tokensAdded.join(', ')})` : ''; return { - message: `Pool ${pool.baseSymbol}-${pool.quoteSymbol} has been added to the pool list for ${poolData.connector}`, + message: `Pool ${pool.baseSymbol}-${pool.quoteSymbol} has been added to the pool list for ${chain}/${network}${tokenMsg}`, pool, + tokensAdded: tokensAdded.length > 0 ? tokensAdded : undefined, }; } catch (error: any) { handlePoolError(fastify, error, 'Failed to find and save pool'); diff --git a/src/pools/schemas.ts b/src/pools/schemas.ts index 5e96d29ad5..37895c7ed5 100644 --- a/src/pools/schemas.ts +++ b/src/pools/schemas.ts @@ -4,14 +4,18 @@ import { ConfigManagerV2 } from '../services/config-manager-v2'; // Pool list request export const PoolListRequestSchema = Type.Object({ - connector: Type.String({ - description: 'Connector (raydium, meteora, uniswap, orca)', - examples: ['raydium', 'meteora', 'uniswap', 'orca'], + chain: Type.String({ + description: 'Blockchain chain (solana, ethereum)', + examples: ['solana', 'ethereum'], + }), + network: Type.String({ + description: 'Network name (mainnet-beta, mainnet, base, etc)', + examples: ['mainnet-beta', 'mainnet', 'base', 'arbitrum'], }), - network: Type.Optional( + connector: Type.Optional( Type.String({ - description: 'Optional: filter by network (mainnet, mainnet-beta, etc)', - examples: ['mainnet', 'mainnet-beta', 'base'], + description: 'Optional: filter by connector (raydium, meteora, uniswap, orca)', + examples: ['raydium', 'meteora', 'uniswap', 'orca'], }), ), type: Type.Optional( @@ -30,6 +34,10 @@ export const PoolListRequestSchema = Type.Object({ // Pool template (core data stored in templates) export const PoolTemplateSchema = Type.Object({ + connector: Type.String({ + description: 'Connector name (raydium, uniswap, orca, etc)', + examples: ['raydium', 'uniswap', 'orca', 'meteora', 'pancakeswap'], + }), type: Type.String({ description: 'Pool type', examples: ['clmm', 'amm'], @@ -46,11 +54,15 @@ export const PoolTemplateSchema = Type.Object({ export type PoolTemplate = typeof PoolTemplateSchema.static; -// Pool list response (includes template data, no geckoData) +// Pool list response export const PoolListResponseSchema = Type.Array(PoolTemplateSchema); // Add pool request export const PoolAddRequestSchema = Type.Object({ + chain: Type.String({ + description: 'Blockchain chain (solana, ethereum)', + examples: ['solana', 'ethereum'], + }), connector: Type.String({ description: 'Connector (raydium, meteora, uniswap, orca)', examples: ['raydium', 'meteora', 'uniswap', 'orca'], @@ -68,14 +80,18 @@ export const PoolAddRequestSchema = Type.Object({ address: Type.String({ description: 'Pool contract address', }), - baseSymbol: Type.String({ - description: 'Base token symbol', - examples: ['SOL', 'ETH'], - }), - quoteSymbol: Type.String({ - description: 'Quote token symbol', - examples: ['USDC', 'USDT'], - }), + baseSymbol: Type.Optional( + Type.String({ + description: 'Base token symbol (optional - fetched automatically if not provided)', + examples: ['SOL', 'ETH'], + }), + ), + quoteSymbol: Type.Optional( + Type.String({ + description: 'Quote token symbol (optional - fetched automatically if not provided)', + examples: ['USDC', 'USDT'], + }), + ), baseTokenAddress: Type.String({ description: 'Base token contract address', examples: ['So11111111111111111111111111111111111111112'], @@ -96,9 +112,9 @@ export const PoolAddRequestSchema = Type.Object({ // Get pool request export const GetPoolRequestSchema = Type.Object({ - connector: Type.String({ - description: 'Connector (raydium, meteora, uniswap, orca)', - examples: ['raydium', 'meteora', 'uniswap', 'orca'], + chain: Type.String({ + description: 'Blockchain chain (solana, ethereum)', + examples: ['solana', 'ethereum'], }), network: Type.String({ description: 'Network name (mainnet, mainnet-beta, etc)', @@ -110,6 +126,12 @@ export const GetPoolRequestSchema = Type.Object({ examples: ['amm', 'clmm'], enum: ['amm', 'clmm'], }), + connector: Type.Optional( + Type.String({ + description: 'Optional: filter by connector (raydium, meteora, uniswap, orca)', + examples: ['raydium', 'meteora', 'uniswap', 'orca'], + }), + ), }); // Success response @@ -117,45 +139,8 @@ export const PoolSuccessResponseSchema = Type.Object({ message: Type.String(), }); -// Optional CoinGecko data for pools -export const PoolGeckoDataSchema = Type.Object({ - volumeUsd24h: Type.String({ - description: '24-hour trading volume in USD', - }), - liquidityUsd: Type.String({ - description: 'Total liquidity in USD', - }), - priceNative: Type.String({ - description: 'Base token price in quote token', - }), - priceUsd: Type.String({ - description: 'Base token price in USD', - }), - buys24h: Type.Number({ - description: 'Number of buy transactions in 24h', - }), - sells24h: Type.Number({ - description: 'Number of sell transactions in 24h', - }), - apr: Type.Optional( - Type.Number({ - description: 'Annual percentage rate', - }), - ), - timestamp: Type.Number({ - description: 'Unix timestamp (ms) when data was fetched', - }), -}); - -export type PoolGeckoData = typeof PoolGeckoDataSchema.static; - -// Pool info with optional CoinGecko data (returned by /pools/find) -export const PoolInfoSchema = Type.Composite([ - PoolTemplateSchema, - Type.Object({ - geckoData: Type.Optional(PoolGeckoDataSchema), - }), -]); +// Pool info (returned by /pools/find and /pools/save) +export const PoolInfoSchema = PoolTemplateSchema; export type PoolInfo = typeof PoolInfoSchema.static; diff --git a/src/pools/types.ts b/src/pools/types.ts index 6bbf0a77b5..423ea7d385 100644 --- a/src/pools/types.ts +++ b/src/pools/types.ts @@ -4,18 +4,8 @@ import { connectorsConfig } from '../config/routes/getConnectors'; -export interface PoolGeckoData { - volumeUsd24h: string; - liquidityUsd: string; - priceNative: string; - priceUsd: string; - buys24h: number; - sells24h: number; - apr?: number; // Annualized percentage rate: (volume * feePct / liquidity) * 365 - timestamp: number; -} - export interface PoolTemplate { + connector: string; // 'raydium', 'uniswap', 'orca', etc. type: 'amm' | 'clmm'; network: string; baseSymbol: string; // Required - resolved from token service or CoinGecko @@ -26,10 +16,7 @@ export interface PoolTemplate { address: string; } -export interface Pool extends PoolTemplate { - // Optional CoinGecko market data (stored when saving pools) - geckoData?: PoolGeckoData; -} +export type Pool = PoolTemplate; export type PoolFileFormat = Pool[]; @@ -49,13 +36,15 @@ export function isSupportedConnector(connector: string): boolean { } export interface PoolListRequest { - connector: string; - network?: string; + chain: string; + network: string; + connector?: string; // Optional filter by connector type?: 'amm' | 'clmm'; search?: string; } export interface PoolAddRequest { + chain: string; connector: string; type: 'amm' | 'clmm'; network: string; diff --git a/src/services/coingecko-service.ts b/src/services/coingecko-service.ts index 24d0de917e..28ec6a6f0f 100644 --- a/src/services/coingecko-service.ts +++ b/src/services/coingecko-service.ts @@ -579,9 +579,6 @@ export class CoinGeckoService { /** * Get token info with market data including price, volume, market cap, and top pools * This uses the extended endpoint that includes top_pools relationship - * - * The market data fields in the return value can be transformed to TokenGeckoData - * using toTokenGeckoData() helper from gecko-types.ts */ public async getTokenInfoWithMarketData( chainNetwork: string, @@ -591,7 +588,6 @@ export class CoinGeckoService { name: string; symbol: string; decimals: number; - // Fields below can be transformed to TokenGeckoData using toTokenGeckoData() coingeckoCoinId: string | null; imageUrl: string; priceUsd: string; diff --git a/src/services/gecko-types.ts b/src/services/gecko-types.ts deleted file mode 100644 index 944e87ce87..0000000000 --- a/src/services/gecko-types.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Centralized GeckoTerminal data type definitions and transformation helpers - * Ensures consistent data structure across all gecko-related operations - */ - -import { PoolGeckoData } from '../pools/schemas'; -import { TokenGeckoData } from '../tokens/schemas'; - -/** - * Raw pool data from GeckoTerminal API (TopPoolInfo) - * This is what we receive from the API - */ -export interface GeckoRawPoolData { - volumeUsd24h: string; - liquidityUsd: string; - priceNative: string; - priceUsd: string; - txns24h: { - buys: number; - sells: number; - }; -} - -/** - * Raw token market data from GeckoTerminal API - * This is what we receive from the API - */ -export interface GeckoRawTokenData { - coingeckoCoinId: string | null; - imageUrl: string; - priceUsd: string; - volumeUsd24h: string; - marketCapUsd: string; - fdvUsd: string; - totalSupply: string; - topPools: string[]; -} - -/** - * Transform raw pool data from GeckoTerminal API to standardized PoolGeckoData format - * @param rawData - Raw pool data from GeckoTerminal API - * @param apr - Optional APR value (calculated separately) - * @returns Standardized PoolGeckoData with timestamp - */ -export function toPoolGeckoData(rawData: GeckoRawPoolData, apr?: number): PoolGeckoData { - return { - volumeUsd24h: rawData.volumeUsd24h, - liquidityUsd: rawData.liquidityUsd, - priceNative: rawData.priceNative, - priceUsd: rawData.priceUsd, - buys24h: rawData.txns24h?.buys || 0, - sells24h: rawData.txns24h?.sells || 0, - ...(apr !== undefined && { apr }), - timestamp: Date.now(), - }; -} - -/** - * Transform raw token data from GeckoTerminal API to standardized TokenGeckoData format - * @param rawData - Raw token data from GeckoTerminal API - * @returns Standardized TokenGeckoData with timestamp - */ -export function toTokenGeckoData(rawData: GeckoRawTokenData): TokenGeckoData { - return { - coingeckoCoinId: rawData.coingeckoCoinId, - imageUrl: rawData.imageUrl, - priceUsd: rawData.priceUsd, - volumeUsd24h: rawData.volumeUsd24h, - marketCapUsd: rawData.marketCapUsd, - fdvUsd: rawData.fdvUsd, - totalSupply: rawData.totalSupply, - topPools: rawData.topPools, - timestamp: Date.now(), - }; -} - -/** - * Extract gecko pool data from TopPoolInfo - * @param topPoolInfo - Pool info from GeckoTerminal - * @returns Raw pool data ready for transformation - */ -export function extractRawPoolData(topPoolInfo: { - volumeUsd24h: string; - liquidityUsd: string; - priceNative: string; - priceUsd: string; - txns24h: { buys: number; sells: number }; -}): GeckoRawPoolData { - return { - volumeUsd24h: topPoolInfo.volumeUsd24h, - liquidityUsd: topPoolInfo.liquidityUsd, - priceNative: topPoolInfo.priceNative, - priceUsd: topPoolInfo.priceUsd, - txns24h: topPoolInfo.txns24h, - }; -} diff --git a/src/services/pool-service.ts b/src/services/pool-service.ts index 610cd5be28..d0ca156121 100644 --- a/src/services/pool-service.ts +++ b/src/services/pool-service.ts @@ -30,24 +30,29 @@ export class PoolService { /** * Get the path to a pool list file with security validation + * Now uses chain/network structure instead of connector */ - private getPoolListPath(connector: string): string { + private getPoolListPath(chain: string, network: string): string { // Validate inputs to prevent path traversal - if (!connector) { - throw new Error('Connector parameter is required'); + if (!chain || !network) { + throw new Error('Chain and network parameters are required'); } // Remove any path traversal attempts - const sanitizedConnector = path.basename(connector); + const sanitizedChain = path.basename(chain); + const sanitizedNetwork = path.basename(network); // Additional validation - only allow alphanumeric, dash, and underscore const validPathRegex = /^[a-zA-Z0-9_-]+$/; - if (!validPathRegex.test(sanitizedConnector)) { - throw new Error(`Invalid connector name: ${connector}`); + if (!validPathRegex.test(sanitizedChain)) { + throw new Error(`Invalid chain name: ${chain}`); + } + if (!validPathRegex.test(sanitizedNetwork)) { + throw new Error(`Invalid network name: ${network}`); } - // Construct the path - now using flat structure - const poolListPath = path.join(rootPath(), 'conf', 'pools', `${sanitizedConnector}.json`); + // Construct the path - now using chain/network structure + const poolListPath = path.join(rootPath(), 'conf', 'pools', sanitizedChain, `${sanitizedNetwork}.json`); // Ensure the resolved path is within the expected directory const expectedRoot = path.join(rootPath(), 'conf', 'pools'); @@ -61,53 +66,45 @@ export class PoolService { /** * Get the template path for initial pool data + * Now uses chain/network structure */ - private getTemplatePath(connector: string): string { - const sanitizedConnector = path.basename(connector); + private getTemplatePath(chain: string, network: string): string { + const sanitizedChain = path.basename(chain); + const sanitizedNetwork = path.basename(network); - return path.join(rootPath(), 'dist', 'src', 'templates', 'pools', `${sanitizedConnector}.json`); + return path.join(rootPath(), 'dist', 'src', 'templates', 'pools', sanitizedChain, `${sanitizedNetwork}.json`); } /** - * Validate connector + * Validate chain */ - private async validateConnector(connector: string): Promise { - if (!isSupportedConnector(connector)) { - throw new Error( - `Unsupported connector: ${connector}. Supported connectors: ${getSupportedConnectors().join(', ')}`, - ); + private validateChain(chain: string): SupportedChain { + switch (chain.toLowerCase()) { + case 'ethereum': + return SupportedChain.ETHEREUM; + case 'solana': + return SupportedChain.SOLANA; + default: + throw new Error(`Unsupported chain: ${chain}. Supported chains: ethereum, solana`); } } /** - * Get chain for a connector by looking it up in the connectors configuration + * Validate connector (optional, for filtering) */ - private getChainForConnector(connector: string): SupportedChain { - // Find the connector configuration - const connectorInfo = connectorsConfig.find((c) => c.name === connector); - - if (!connectorInfo) { + private validateConnector(connector: string): void { + if (!isSupportedConnector(connector)) { throw new Error( - `Unknown connector: ${connector}. Available connectors: ${connectorsConfig.map((c) => c.name).join(', ')}`, + `Unsupported connector: ${connector}. Supported connectors: ${getSupportedConnectors().join(', ')}`, ); } - - // Map chain string to SupportedChain enum - switch (connectorInfo.chain.toLowerCase()) { - case 'ethereum': - return SupportedChain.ETHEREUM; - case 'solana': - return SupportedChain.SOLANA; - default: - throw new Error(`Unsupported chain '${connectorInfo.chain}' for connector: ${connector}`); - } } /** * Initialize pool list from template if it doesn't exist */ - private async initializePoolList(connector: string): Promise { - const templatePath = this.getTemplatePath(connector); + private async initializePoolList(chain: string, network: string): Promise { + const templatePath = this.getTemplatePath(chain, network); // If template exists, use it if (fs.existsSync(templatePath)) { @@ -115,7 +112,7 @@ export class PoolService { const data = await readFile(templatePath, 'utf8'); return JSON.parse(data); } catch (error) { - logger.warn(`Failed to read template for ${connector}: ${error.message}`); + logger.warn(`Failed to read template for ${chain}/${network}: ${error.message}`); } } @@ -126,16 +123,16 @@ export class PoolService { /** * Load pool list from file */ - public async loadPoolList(connector: string): Promise { - await this.validateConnector(connector); + public async loadPoolList(chain: string, network: string): Promise { + this.validateChain(chain); - const poolListPath = this.getPoolListPath(connector); + const poolListPath = this.getPoolListPath(chain, network); if (!fs.existsSync(poolListPath)) { // Initialize from template if available - const initialPools = await this.initializePoolList(connector); + const initialPools = await this.initializePoolList(chain, network); if (initialPools.length > 0) { - await this.savePoolList(connector, initialPools); + await this.savePoolList(chain, network, initialPools); return initialPools; } return []; @@ -160,20 +157,18 @@ export class PoolService { /** * Save pool list to file with atomic write - * Strips geckoData before saving (only saves core pool template data) */ - public async savePoolList(connector: string, pools: Pool[]): Promise { - await this.validateConnector(connector); + public async savePoolList(chain: string, network: string, pools: Pool[]): Promise { + this.validateChain(chain); - const poolListPath = this.getPoolListPath(connector); + const poolListPath = this.getPoolListPath(chain, network); const dirPath = path.dirname(poolListPath); - // Ensure directory exists + // Ensure directory exists (now includes chain subdirectory) if (!fs.existsSync(dirPath)) { await fse.ensureDir(dirPath); } - // Save pools with geckoData included (similar to token storage) // Use atomic write (write to temp file then rename) const tempPath = `${poolListPath}.tmp`; @@ -190,16 +185,22 @@ export class PoolService { } /** - * List all pools for a connector with optional filtering + * List all pools for a chain/network with optional filtering */ - public async listPools(connector: string, network?: string, type?: 'amm' | 'clmm', search?: string): Promise { - const pools = await this.loadPoolList(connector); + public async listPools( + chain: string, + network: string, + connector?: string, + type?: 'amm' | 'clmm', + search?: string, + ): Promise { + const pools = await this.loadPoolList(chain, network); let filteredPools = pools; - // Filter by network if specified - if (network) { - filteredPools = filteredPools.filter((pool) => pool.network === network); + // Filter by connector if specified + if (connector) { + filteredPools = filteredPools.filter((pool) => pool.connector === connector); } // Filter by type if specified @@ -225,13 +226,14 @@ export class PoolService { * Get a specific pool by token pair */ public async getPool( - connector: string, + chain: string, network: string, type: 'amm' | 'clmm', baseSymbol: string, quoteSymbol: string, + connector?: string, ): Promise { - const pools = await this.listPools(connector, network, type); + const pools = await this.listPools(chain, network, connector, type); // Find by exact match or reversed match const pool = pools.find( @@ -246,7 +248,15 @@ export class PoolService { /** * Validate pool data */ - public async validatePool(connector: string, pool: Pool): Promise { + public async validatePool(chain: string, pool: Pool): Promise { + // Validate connector field + if (!pool.connector || pool.connector.trim() === '') { + throw new Error('Connector is required'); + } + + // Validate connector is supported + this.validateConnector(pool.connector); + // Validate optional symbol fields (warn if empty but don't fail) if (pool.baseSymbol && pool.baseSymbol.trim() === '') { logger.warn('Base token symbol is empty string'); @@ -287,9 +297,9 @@ export class PoolService { } // Validate address format based on chain - const chain = this.getChainForConnector(connector); + const chainEnum = this.validateChain(chain); - if (chain === SupportedChain.SOLANA) { + if (chainEnum === SupportedChain.SOLANA) { // Validate Solana addresses try { new PublicKey(pool.address); @@ -298,7 +308,7 @@ export class PoolService { } catch { throw new Error('Invalid Solana address'); } - } else if (chain === SupportedChain.ETHEREUM) { + } else if (chainEnum === SupportedChain.ETHEREUM) { // Validate Ethereum addresses if (!ethers.utils.isAddress(pool.address)) { throw new Error('Invalid Ethereum pool address'); @@ -320,10 +330,10 @@ export class PoolService { /** * Add a new pool */ - public async addPool(connector: string, pool: Pool): Promise { - await this.validatePool(connector, pool); + public async addPool(chain: string, network: string, pool: Pool): Promise { + await this.validatePool(chain, pool); - const pools = await this.loadPoolList(connector); + const pools = await this.loadPoolList(chain, network); // Check for duplicate address only if (pools.some((p) => p.address.toLowerCase() === pool.address.toLowerCase())) { @@ -331,54 +341,53 @@ export class PoolService { } pools.push(pool); - await this.savePoolList(connector, pools); + await this.savePoolList(chain, network, pools); } /** * Remove a pool by address */ - public async removePool(connector: string, network: string, type: 'amm' | 'clmm', address: string): Promise { - const pools = await this.loadPoolList(connector); + public async removePool(chain: string, network: string, address: string): Promise { + const pools = await this.loadPoolList(chain, network); const initialLength = pools.length; - const filteredPools = pools.filter( - (p) => !(p.address.toLowerCase() === address.toLowerCase() && p.network === network && p.type === type), - ); + const filteredPools = pools.filter((p) => p.address.toLowerCase() !== address.toLowerCase()); if (filteredPools.length === initialLength) { - throw new Error(`Pool with address ${address} not found on ${network} ${type}`); + throw new Error(`Pool with address ${address} not found on ${chain}/${network}`); } - await this.savePoolList(connector, filteredPools); + await this.savePoolList(chain, network, filteredPools); } /** * Get a pool by address */ - public async getPoolByAddress(connector: string, address: string): Promise { - const pools = await this.loadPoolList(connector); + public async getPoolByAddress(chain: string, network: string, address: string): Promise { + const pools = await this.loadPoolList(chain, network); return pools.find((p) => p.address.toLowerCase() === address.toLowerCase()) || null; } /** - * Get a pool by metadata (type, network, token addresses) + * Get a pool by metadata (type, token addresses, optional connector) * This finds pools with identical token pair but potentially different fee tiers or addresses */ public async getPoolByMetadata( - connector: string, - type: 'amm' | 'clmm', + chain: string, network: string, + type: 'amm' | 'clmm', baseTokenAddress: string, quoteTokenAddress: string, + connector?: string, ): Promise { - const pools = await this.loadPoolList(connector); + const pools = await this.loadPoolList(chain, network); return ( pools.find( (p) => p.type === type && - p.network === network && p.baseTokenAddress.toLowerCase() === baseTokenAddress.toLowerCase() && - p.quoteTokenAddress.toLowerCase() === quoteTokenAddress.toLowerCase(), + p.quoteTokenAddress.toLowerCase() === quoteTokenAddress.toLowerCase() && + (!connector || p.connector === connector), ) || null ); } @@ -386,10 +395,10 @@ export class PoolService { /** * Update an existing pool by address */ - public async updatePoolByAddress(connector: string, pool: Pool): Promise { - await this.validatePool(connector, pool); + public async updatePoolByAddress(chain: string, network: string, pool: Pool): Promise { + await this.validatePool(chain, pool); - const pools = await this.loadPoolList(connector); + const pools = await this.loadPoolList(chain, network); // Find the pool to update by address const existingIndex = pools.findIndex((p) => p.address.toLowerCase() === pool.address.toLowerCase()); @@ -400,28 +409,28 @@ export class PoolService { // Update the pool pools[existingIndex] = pool; - await this.savePoolList(connector, pools); + await this.savePoolList(chain, network, pools); } /** * Update an existing pool */ - public async updatePool(connector: string, pool: Pool): Promise { - await this.validatePool(connector, pool); + public async updatePool(chain: string, network: string, pool: Pool): Promise { + await this.validatePool(chain, pool); - const pools = await this.loadPoolList(connector); + const pools = await this.loadPoolList(chain, network); - // Find the pool to update by matching token pair, network, and type + // Find the pool to update by matching token pair, connector, and type const existingIndex = pools.findIndex( (p) => - p.network === pool.network && + p.connector === pool.connector && p.type === pool.type && ((p.baseSymbol === pool.baseSymbol && p.quoteSymbol === pool.quoteSymbol) || (p.baseSymbol === pool.quoteSymbol && p.quoteSymbol === pool.baseSymbol)), ); if (existingIndex === -1) { - throw new Error(`Pool for ${pool.baseSymbol}-${pool.quoteSymbol} not found on ${pool.network} ${pool.type}`); + throw new Error(`Pool for ${pool.baseSymbol}-${pool.quoteSymbol} not found on ${network} ${pool.type}`); } // Check if the new address is already used by another pool @@ -435,19 +444,20 @@ export class PoolService { // Update the pool pools[existingIndex] = pool; - await this.savePoolList(connector, pools); + await this.savePoolList(chain, network, pools); } /** - * Get default pools for a connector in the format expected by connectors + * Get default pools for a chain/network/connector in the format expected by connectors */ public async getDefaultPools( - connector: string, + chain: string, network: string, type: 'amm' | 'clmm', + connector?: string, ): Promise> { try { - const pools = await this.listPools(connector, network, type); + const pools = await this.listPools(chain, network, connector, type); const poolMap: Record = {}; for (const pool of pools) { @@ -461,4 +471,19 @@ export class PoolService { return {}; } } + + /** + * Get chain for a connector by looking it up in the connectors configuration + */ + public getChainForConnector(connector: string): string { + const connectorInfo = connectorsConfig.find((c) => c.name === connector); + + if (!connectorInfo) { + throw new Error( + `Unknown connector: ${connector}. Available connectors: ${connectorsConfig.map((c) => c.name).join(', ')}`, + ); + } + + return connectorInfo.chain; + } } diff --git a/src/templates/pools/ethereum/arbitrum.json b/src/templates/pools/ethereum/arbitrum.json new file mode 100644 index 0000000000..1039d30d09 --- /dev/null +++ b/src/templates/pools/ethereum/arbitrum.json @@ -0,0 +1,24 @@ +[ + { + "connector": "uniswap", + "type": "amm", + "network": "arbitrum", + "baseSymbol": "ARB", + "quoteSymbol": "USDC", + "address": "0xcda53b1f66614552f834ceef361a8d12a0b8dad8", + "baseTokenAddress": "0x912CE59144191C1204E64559FE8253a0e49E6548", + "quoteTokenAddress": "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8", + "feePct": 0.3 + }, + { + "connector": "uniswap", + "type": "clmm", + "network": "arbitrum", + "baseSymbol": "WETH", + "quoteSymbol": "USDC", + "address": "0xc473e2aee3441bf9240be85eb122abb059a3b57c", + "baseTokenAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + "quoteTokenAddress": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "feePct": 0.3 + } +] diff --git a/src/templates/pools/ethereum/base.json b/src/templates/pools/ethereum/base.json new file mode 100644 index 0000000000..b58f3ea75e --- /dev/null +++ b/src/templates/pools/ethereum/base.json @@ -0,0 +1,24 @@ +[ + { + "connector": "uniswap", + "type": "amm", + "network": "base", + "baseSymbol": "AERO", + "quoteSymbol": "USDC", + "address": "0x6cdcb1c4a4d1c3c6d054b27ac5b77e89eafb971d", + "baseTokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "quoteTokenAddress": "0x940181a94A35A4569E4529A3CDfB74e38FD98631", + "feePct": 0.3 + }, + { + "connector": "uniswap", + "type": "clmm", + "network": "base", + "baseSymbol": "WETH", + "quoteSymbol": "USDC", + "address": "0xd0b53d9277642d899df5c87a3966a349a798f224", + "baseTokenAddress": "0x4200000000000000000000000000000000000006", + "quoteTokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "feePct": 0.05 + } +] diff --git a/src/templates/pools/pancakeswap.json b/src/templates/pools/ethereum/bsc.json similarity index 91% rename from src/templates/pools/pancakeswap.json rename to src/templates/pools/ethereum/bsc.json index 9fed0b2aaf..d34905a48b 100644 --- a/src/templates/pools/pancakeswap.json +++ b/src/templates/pools/ethereum/bsc.json @@ -1,5 +1,6 @@ [ { + "connector": "pancakeswap", "type": "clmm", "network": "bsc", "baseSymbol": "USDT", @@ -10,6 +11,7 @@ "address": "0x172fcd41e0913e95784454622d1c3724f546f849" }, { + "connector": "pancakeswap", "type": "clmm", "network": "bsc", "baseSymbol": "CAKE", diff --git a/src/templates/pools/ethereum/mainnet.json b/src/templates/pools/ethereum/mainnet.json new file mode 100644 index 0000000000..1111c44e4f --- /dev/null +++ b/src/templates/pools/ethereum/mainnet.json @@ -0,0 +1,13 @@ +[ + { + "connector": "uniswap", + "type": "clmm", + "network": "mainnet", + "baseSymbol": "WETH", + "quoteSymbol": "USDC", + "address": "0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8", + "baseTokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "quoteTokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "feePct": 0.3 + } +] diff --git a/src/templates/pools/ethereum/optimism.json b/src/templates/pools/ethereum/optimism.json new file mode 100644 index 0000000000..569ecd4cc6 --- /dev/null +++ b/src/templates/pools/ethereum/optimism.json @@ -0,0 +1,13 @@ +[ + { + "connector": "uniswap", + "type": "amm", + "network": "optimism", + "baseSymbol": "OP", + "quoteSymbol": "USDC", + "address": "0x1c3140ab59d6caf9fa7459c6f83d4b52ba881d36", + "baseTokenAddress": "0x4200000000000000000000000000000000000042", + "quoteTokenAddress": "0x7F5c764cBc14f9669B88837ca1490cCa17c31607", + "feePct": 0.3 + } +] diff --git a/src/templates/pools/ethereum/polygon.json b/src/templates/pools/ethereum/polygon.json new file mode 100644 index 0000000000..d3bf54a137 --- /dev/null +++ b/src/templates/pools/ethereum/polygon.json @@ -0,0 +1,13 @@ +[ + { + "connector": "uniswap", + "type": "clmm", + "network": "polygon", + "baseSymbol": "WETH", + "quoteSymbol": "USDC", + "address": "0x45dda9cb7c25131df268515131f647d726f50608", + "baseTokenAddress": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "quoteTokenAddress": "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", + "feePct": 0.05 + } +] diff --git a/src/templates/pools/meteora.json b/src/templates/pools/meteora.json deleted file mode 100644 index 391bdbb596..0000000000 --- a/src/templates/pools/meteora.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "SOL", - "quoteSymbol": "USDC", - "address": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", - "baseTokenAddress": "So11111111111111111111111111111111111111112", - "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "feePct": 0.04 - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "JUP", - "quoteSymbol": "USDC", - "address": "7HR1ouGwPsCyPScUCx4WJCPyktXMoLitrGkLczd7Vabx", - "baseTokenAddress": "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN", - "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "feePct": 0.01 - } -] diff --git a/src/templates/pools/orca.json b/src/templates/pools/orca.json deleted file mode 100644 index 6de4a1ee2b..0000000000 --- a/src/templates/pools/orca.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "SOL", - "quoteSymbol": "USDC", - "address": "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE", - "baseTokenAddress": "So11111111111111111111111111111111111111112", - "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "feePct": 0.04 - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "JUP", - "quoteSymbol": "USDC", - "address": "HrLmpzp8Nu5wkn9SGZYSS9Ms6deTvMgGc6BETp4161ZX", - "baseTokenAddress": "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN", - "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "feePct": 0.3 - } -] diff --git a/src/templates/pools/pancakeswap-sol.json b/src/templates/pools/pancakeswap-sol.json deleted file mode 100644 index c996905354..0000000000 --- a/src/templates/pools/pancakeswap-sol.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "SOL", - "quoteSymbol": "USDC", - "address": "4QU2NpRaqmKMvPSwVKQDeW4V6JFEKJdkzbzdauumD9qN", - "baseTokenAddress": "So11111111111111111111111111111111111111112", - "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "feePct": 0.03 - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "PENGU", - "quoteSymbol": "USDC", - "address": "CbvdQYoaykHHQ9k27W7WCEMJL8jY99NZ7FoNPfa2vEVx", - "baseTokenAddress": "2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv", - "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "feePct": 0.25 - } -] diff --git a/src/templates/pools/raydium.json b/src/templates/pools/raydium.json deleted file mode 100644 index 8ed28d8028..0000000000 --- a/src/templates/pools/raydium.json +++ /dev/null @@ -1,42 +0,0 @@ -[ - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "RAY", - "quoteSymbol": "SOL", - "address": "AVs9TA4nWDzfPJE9gGVNJMVhcQy3V9PGazuz33BfG2RA", - "baseTokenAddress": "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", - "quoteTokenAddress": "So11111111111111111111111111111111111111112", - "feePct": 0.0025 - }, - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "SOL", - "quoteSymbol": "USDC", - "address": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2", - "baseTokenAddress": "So11111111111111111111111111111111111111112", - "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "feePct": 0.0025 - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "SOL", - "quoteSymbol": "RAY", - "address": "2AXXcN6oN9bBT5owwmTH53C7QHUXvhLeu718Kqt8rvY2", - "baseTokenAddress": "So11111111111111111111111111111111111111112", - "quoteTokenAddress": "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", - "feePct": 0.05 - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "SOL", - "quoteSymbol": "USDC", - "address": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv", - "baseTokenAddress": "So11111111111111111111111111111111111111112", - "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "feePct": 0.04 - } -] diff --git a/src/templates/pools/solana/mainnet-beta.json b/src/templates/pools/solana/mainnet-beta.json new file mode 100644 index 0000000000..9d82436ee5 --- /dev/null +++ b/src/templates/pools/solana/mainnet-beta.json @@ -0,0 +1,112 @@ +[ + { + "connector": "raydium", + "type": "amm", + "network": "mainnet-beta", + "baseSymbol": "RAY", + "quoteSymbol": "SOL", + "address": "AVs9TA4nWDzfPJE9gGVNJMVhcQy3V9PGazuz33BfG2RA", + "baseTokenAddress": "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", + "quoteTokenAddress": "So11111111111111111111111111111111111111112", + "feePct": 0.0025 + }, + { + "connector": "raydium", + "type": "amm", + "network": "mainnet-beta", + "baseSymbol": "SOL", + "quoteSymbol": "USDC", + "address": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2", + "baseTokenAddress": "So11111111111111111111111111111111111111112", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.0025 + }, + { + "connector": "raydium", + "type": "clmm", + "network": "mainnet-beta", + "baseSymbol": "SOL", + "quoteSymbol": "RAY", + "address": "2AXXcN6oN9bBT5owwmTH53C7QHUXvhLeu718Kqt8rvY2", + "baseTokenAddress": "So11111111111111111111111111111111111111112", + "quoteTokenAddress": "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", + "feePct": 0.05 + }, + { + "connector": "raydium", + "type": "clmm", + "network": "mainnet-beta", + "baseSymbol": "SOL", + "quoteSymbol": "USDC", + "address": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv", + "baseTokenAddress": "So11111111111111111111111111111111111111112", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.04 + }, + { + "connector": "orca", + "type": "clmm", + "network": "mainnet-beta", + "baseSymbol": "SOL", + "quoteSymbol": "USDC", + "address": "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE", + "baseTokenAddress": "So11111111111111111111111111111111111111112", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.04 + }, + { + "connector": "orca", + "type": "clmm", + "network": "mainnet-beta", + "baseSymbol": "JUP", + "quoteSymbol": "USDC", + "address": "HrLmpzp8Nu5wkn9SGZYSS9Ms6deTvMgGc6BETp4161ZX", + "baseTokenAddress": "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.3 + }, + { + "connector": "meteora", + "type": "clmm", + "network": "mainnet-beta", + "baseSymbol": "SOL", + "quoteSymbol": "USDC", + "address": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", + "baseTokenAddress": "So11111111111111111111111111111111111111112", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.04 + }, + { + "connector": "meteora", + "type": "clmm", + "network": "mainnet-beta", + "baseSymbol": "JUP", + "quoteSymbol": "USDC", + "address": "7HR1ouGwPsCyPScUCx4WJCPyktXMoLitrGkLczd7Vabx", + "baseTokenAddress": "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.01 + }, + { + "connector": "pancakeswap-sol", + "type": "clmm", + "network": "mainnet-beta", + "baseSymbol": "SOL", + "quoteSymbol": "USDC", + "address": "4QU2NpRaqmKMvPSwVKQDeW4V6JFEKJdkzbzdauumD9qN", + "baseTokenAddress": "So11111111111111111111111111111111111111112", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.03 + }, + { + "connector": "pancakeswap-sol", + "type": "clmm", + "network": "mainnet-beta", + "baseSymbol": "PENGU", + "quoteSymbol": "USDC", + "address": "CbvdQYoaykHHQ9k27W7WCEMJL8jY99NZ7FoNPfa2vEVx", + "baseTokenAddress": "2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.25 + } +] diff --git a/src/templates/pools/uniswap.json b/src/templates/pools/uniswap.json deleted file mode 100644 index 6b887caa7b..0000000000 --- a/src/templates/pools/uniswap.json +++ /dev/null @@ -1,72 +0,0 @@ -[ - { - "type": "amm", - "network": "base", - "baseSymbol": "AERO", - "quoteSymbol": "USDC", - "address": "0x6cdcb1c4a4d1c3c6d054b27ac5b77e89eafb971d", - "baseTokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "quoteTokenAddress": "0x940181a94A35A4569E4529A3CDfB74e38FD98631", - "feePct": 0.3 - }, - { - "type": "amm", - "network": "arbitrum", - "baseSymbol": "ARB", - "quoteSymbol": "USDC", - "address": "0xcda53b1f66614552f834ceef361a8d12a0b8dad8", - "baseTokenAddress": "0x912CE59144191C1204E64559FE8253a0e49E6548", - "quoteTokenAddress": "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8", - "feePct": 0.3 - }, - { - "type": "amm", - "network": "optimism", - "baseSymbol": "OP", - "quoteSymbol": "USDC", - "address": "0x1c3140ab59d6caf9fa7459c6f83d4b52ba881d36", - "baseTokenAddress": "0x4200000000000000000000000000000000000042", - "quoteTokenAddress": "0x7F5c764cBc14f9669B88837ca1490cCa17c31607", - "feePct": 0.3 - }, - { - "type": "clmm", - "network": "mainnet", - "baseSymbol": "WETH", - "quoteSymbol": "USDC", - "address": "0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8", - "baseTokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "quoteTokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - "feePct": 0.3 - }, - { - "type": "clmm", - "network": "arbitrum", - "baseSymbol": "WETH", - "quoteSymbol": "USDC", - "address": "0xc473e2aee3441bf9240be85eb122abb059a3b57c", - "baseTokenAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", - "quoteTokenAddress": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", - "feePct": 0.3 - }, - { - "type": "clmm", - "network": "polygon", - "baseSymbol": "WETH", - "quoteSymbol": "USDC", - "address": "0x45dda9cb7c25131df268515131f647d726f50608", - "baseTokenAddress": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", - "quoteTokenAddress": "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", - "feePct": 0.05 - }, - { - "type": "clmm", - "network": "base", - "baseSymbol": "WETH", - "quoteSymbol": "USDC", - "address": "0xd0b53d9277642d899df5c87a3966a349a798f224", - "baseTokenAddress": "0x4200000000000000000000000000000000000006", - "quoteTokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "feePct": 0.05 - } -] diff --git a/src/tokens/schemas.ts b/src/tokens/schemas.ts index b089786803..38a33368d1 100644 --- a/src/tokens/schemas.ts +++ b/src/tokens/schemas.ts @@ -2,39 +2,6 @@ import { Type } from '@sinclair/typebox'; import { ConfigManagerV2 } from '../services/config-manager-v2'; -// Optional CoinGecko data for tokens -export const TokenGeckoDataSchema = Type.Object({ - coingeckoCoinId: Type.Union([Type.String(), Type.Null()], { - description: 'CoinGecko coin ID if available', - }), - imageUrl: Type.String({ - description: 'Token image URL', - }), - priceUsd: Type.String({ - description: 'Current price in USD', - }), - volumeUsd24h: Type.String({ - description: '24h trading volume in USD', - }), - marketCapUsd: Type.String({ - description: 'Market capitalization in USD', - }), - fdvUsd: Type.String({ - description: 'Fully diluted valuation in USD', - }), - totalSupply: Type.String({ - description: 'Normalized total supply (human-readable)', - }), - topPools: Type.Array(Type.String(), { - description: 'Array of top pool addresses', - }), - timestamp: Type.Number({ - description: 'Unix timestamp (ms) when data was fetched', - }), -}); - -export type TokenGeckoData = typeof TokenGeckoDataSchema.static; - // Individual token structure export const TokenSchema = Type.Object({ chainId: Type.Optional( @@ -61,7 +28,6 @@ export const TokenSchema = Type.Object({ maximum: 255, examples: [6, 18], }), - geckoData: Type.Optional(TokenGeckoDataSchema), }); export type Token = { @@ -70,7 +36,6 @@ export type Token = { symbol: string; address: string; decimals: number; - geckoData?: TokenGeckoData; }; // Query parameters for listing tokens @@ -165,13 +130,8 @@ export const TokenOperationResponseSchema = Type.Object({ export type TokenOperationResponse = typeof TokenOperationResponseSchema.static; -// Token info with optional CoinGecko data (returned by /tokens/find) -export const TokenInfoSchema = Type.Composite([ - TokenSchema, - Type.Object({ - geckoData: Type.Optional(TokenGeckoDataSchema), - }), -]); +// Token info (returned by /tokens/find) +export const TokenInfoSchema = TokenSchema; export type TokenInfo = typeof TokenInfoSchema.static; diff --git a/src/tokens/token-lookup-helper.ts b/src/tokens/token-lookup-helper.ts index 033cbb0842..7ba778410a 100644 --- a/src/tokens/token-lookup-helper.ts +++ b/src/tokens/token-lookup-helper.ts @@ -5,42 +5,28 @@ import { CoinGeckoService } from '../services/coingecko-service'; import { ConfigManagerV2 } from '../services/config-manager-v2'; -import { toTokenGeckoData } from '../services/gecko-types'; import { TokenInfo } from './schemas'; /** - * Fetch token information with market data from GeckoTerminal + * Fetch token information from GeckoTerminal * This is shared logic used by both /tokens/find/:address and /tokens/save/:address */ export async function fetchTokenInfo(chainNetwork: string, address: string): Promise { - // Fetch token info with market data from GeckoTerminal + // Fetch token info from GeckoTerminal const coinGeckoService = CoinGeckoService.getInstance(); - const tokenData = await coinGeckoService.getTokenInfoWithMarketData(chainNetwork, address); + const tokenData = await coinGeckoService.getTokenInfo(chainNetwork, address); // Get chainId from chainNetwork const configManager = ConfigManagerV2.getInstance(); const chainId = configManager.getChainId(chainNetwork); - // Transform to typed geckoData using helper - const geckoData = toTokenGeckoData({ - coingeckoCoinId: tokenData.coingeckoCoinId, - imageUrl: tokenData.imageUrl, - priceUsd: tokenData.priceUsd, - volumeUsd24h: tokenData.volumeUsd24h, - marketCapUsd: tokenData.marketCapUsd, - fdvUsd: tokenData.fdvUsd, - totalSupply: tokenData.totalSupply, - topPools: tokenData.topPools, - }); - - // Return TokenInfo with geckoData populated + // Return TokenInfo return { chainId, name: tokenData.name, symbol: tokenData.symbol, address: tokenData.address, decimals: tokenData.decimals, - geckoData, }; } diff --git a/src/tokens/types.ts b/src/tokens/types.ts index 1e4699b4ac..841da953de 100644 --- a/src/tokens/types.ts +++ b/src/tokens/types.ts @@ -5,17 +5,6 @@ export interface Token { symbol: string; address: string; decimals: number; - geckoData?: { - coingeckoCoinId: string | null; - imageUrl: string; - priceUsd: string; - volumeUsd24h: string; - marketCapUsd: string; - fdvUsd: string; - totalSupply: string; - topPools: string[]; - timestamp: number; - }; } // Chain-specific token interfaces diff --git a/test/pools/pool-service.test.ts b/test/pools/pool-service.test.ts index 466b6b285e..db6201a91b 100644 --- a/test/pools/pool-service.test.ts +++ b/test/pools/pool-service.test.ts @@ -44,6 +44,7 @@ describe('PoolService', () => { describe('validatePool', () => { it('should validate Solana pool with new fields', async () => { const pool: Pool = { + connector: 'raydium', type: 'amm', baseSymbol: 'SOL', quoteSymbol: 'USDC', @@ -54,11 +55,12 @@ describe('PoolService', () => { address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', }; - await expect(poolService.validatePool('raydium', pool)).resolves.not.toThrow(); + await expect(poolService.validatePool('solana', pool)).resolves.not.toThrow(); }); it('should reject invalid address', async () => { const pool: Pool = { + connector: 'raydium', type: 'amm', baseSymbol: 'SOL', quoteSymbol: 'USDC', @@ -69,11 +71,12 @@ describe('PoolService', () => { address: 'invalid-address', }; - await expect(poolService.validatePool('raydium', pool)).rejects.toThrow('Invalid Solana address'); + await expect(poolService.validatePool('solana', pool)).rejects.toThrow('Invalid Solana address'); }); it('should reject pool without token addresses', async () => { const pool: any = { + connector: 'raydium', type: 'amm', baseSymbol: 'SOL', quoteSymbol: 'USDC', @@ -81,11 +84,12 @@ describe('PoolService', () => { address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', }; - await expect(poolService.validatePool('raydium', pool)).rejects.toThrow('Base token address is required'); + await expect(poolService.validatePool('solana', pool)).rejects.toThrow('Base token address is required'); }); it('should reject pool without fee percentage', async () => { const pool: any = { + connector: 'raydium', type: 'amm', baseSymbol: 'SOL', quoteSymbol: 'USDC', @@ -95,7 +99,54 @@ describe('PoolService', () => { address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', }; - await expect(poolService.validatePool('raydium', pool)).rejects.toThrow('Fee percentage is required'); + await expect(poolService.validatePool('solana', pool)).rejects.toThrow('Fee percentage is required'); + }); + + it('should reject pool without connector', async () => { + const pool: any = { + type: 'amm', + baseSymbol: 'SOL', + quoteSymbol: 'USDC', + baseTokenAddress: 'So11111111111111111111111111111111111111112', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + feePct: 0.25, + network: 'mainnet-beta', + address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', + }; + + await expect(poolService.validatePool('solana', pool)).rejects.toThrow('Connector is required'); + }); + + it('should reject unsupported chain', async () => { + const pool: Pool = { + connector: 'raydium', + type: 'amm', + baseSymbol: 'SOL', + quoteSymbol: 'USDC', + baseTokenAddress: 'So11111111111111111111111111111111111111112', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + feePct: 0.25, + network: 'mainnet-beta', + address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', + }; + + await expect(poolService.validatePool('invalid-chain', pool)).rejects.toThrow('Unsupported chain'); + }); + }); + + describe('getChainForConnector', () => { + it('should return correct chain for solana connector', () => { + expect(poolService.getChainForConnector('raydium')).toBe('solana'); + expect(poolService.getChainForConnector('meteora')).toBe('solana'); + }); + + it('should return correct chain for ethereum connector', () => { + expect(poolService.getChainForConnector('uniswap')).toBe('ethereum'); + expect(poolService.getChainForConnector('pancakeswap')).toBe('ethereum'); + }); + + it('should throw for unknown connector', () => { + expect(() => poolService.getChainForConnector('unknown')).toThrow('Unknown connector'); }); }); }); diff --git a/test/pools/pools.routes.test.ts b/test/pools/pools.routes.test.ts index 38ae3b570a..6ef21a1008 100644 --- a/test/pools/pools.routes.test.ts +++ b/test/pools/pools.routes.test.ts @@ -76,6 +76,7 @@ describe('Pool Routes Tests', () => { getPoolByAddress: jest.fn(), getPoolByMetadata: jest.fn(), getDefaultPools: jest.fn(), + getChainForConnector: jest.fn(), } as any; (PoolService.getInstance as jest.Mock).mockReturnValue(mockPoolService); @@ -152,9 +153,10 @@ describe('Pool Routes Tests', () => { }); describe('GET /pools', () => { - it('should list all pools for a connector', async () => { + it('should list all pools for a chain/network', async () => { const mockPools: Pool[] = [ { + connector: 'raydium', type: 'amm', network: 'mainnet-beta', baseSymbol: 'SOL', @@ -165,6 +167,7 @@ describe('Pool Routes Tests', () => { address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', }, { + connector: 'raydium', type: 'amm', network: 'mainnet-beta', baseSymbol: 'RAY', @@ -180,17 +183,18 @@ describe('Pool Routes Tests', () => { const response = await fastify.inject({ method: 'GET', - url: '/?connector=raydium&network=mainnet-beta', + url: '/?chain=solana&network=mainnet-beta', }); expect(response.statusCode).toBe(200); expect(JSON.parse(response.payload)).toEqual(mockPools); - expect(mockPoolService.listPools).toHaveBeenCalledWith('raydium', 'mainnet-beta', undefined, undefined); + expect(mockPoolService.listPools).toHaveBeenCalledWith('solana', 'mainnet-beta', undefined, undefined, undefined); }); - it('should filter pools by type', async () => { + it('should filter pools by connector and type', async () => { const mockPools: Pool[] = [ { + connector: 'raydium', type: 'clmm', network: 'mainnet-beta', baseSymbol: 'SOL', @@ -206,17 +210,18 @@ describe('Pool Routes Tests', () => { const response = await fastify.inject({ method: 'GET', - url: '/?connector=raydium&network=mainnet-beta&type=clmm', + url: '/?chain=solana&network=mainnet-beta&connector=raydium&type=clmm', }); expect(response.statusCode).toBe(200); expect(JSON.parse(response.payload)).toEqual(mockPools); - expect(mockPoolService.listPools).toHaveBeenCalledWith('raydium', 'mainnet-beta', 'clmm', undefined); + expect(mockPoolService.listPools).toHaveBeenCalledWith('solana', 'mainnet-beta', 'raydium', 'clmm', undefined); }); it('should search pools by token symbol', async () => { const mockPools: Pool[] = [ { + connector: 'raydium', type: 'amm', network: 'mainnet-beta', baseSymbol: 'SOL', @@ -232,20 +237,20 @@ describe('Pool Routes Tests', () => { const response = await fastify.inject({ method: 'GET', - url: '/?connector=raydium&search=SOL', + url: '/?chain=solana&network=mainnet-beta&search=SOL', }); expect(response.statusCode).toBe(200); expect(JSON.parse(response.payload)).toEqual(mockPools); - expect(mockPoolService.listPools).toHaveBeenCalledWith('raydium', undefined, undefined, 'SOL'); + expect(mockPoolService.listPools).toHaveBeenCalledWith('solana', 'mainnet-beta', undefined, undefined, 'SOL'); }); it('should return 400 for invalid parameters', async () => { - mockPoolService.listPools.mockRejectedValue(new Error('Invalid connector name')); + mockPoolService.listPools.mockRejectedValue(new Error('Unsupported chain')); const response = await fastify.inject({ method: 'GET', - url: '/?connector=invalid&network=mainnet', + url: '/?chain=invalid&network=mainnet', }); expect(response.statusCode).toBe(400); @@ -256,6 +261,7 @@ describe('Pool Routes Tests', () => { describe('GET /pools/:tradingPair', () => { it('should find pool by trading pair', async () => { const mockPool: Pool = { + connector: 'raydium', type: 'amm', network: 'mainnet-beta', baseSymbol: 'SOL', @@ -270,12 +276,12 @@ describe('Pool Routes Tests', () => { const response = await fastify.inject({ method: 'GET', - url: '/SOL-USDC?connector=raydium&network=mainnet-beta&type=amm', + url: '/SOL-USDC?chain=solana&network=mainnet-beta&type=amm&connector=raydium', }); expect(response.statusCode).toBe(200); expect(JSON.parse(response.payload)).toEqual(mockPool); - expect(mockPoolService.getPool).toHaveBeenCalledWith('raydium', 'mainnet-beta', 'amm', 'SOL', 'USDC'); + expect(mockPoolService.getPool).toHaveBeenCalledWith('solana', 'mainnet-beta', 'amm', 'SOL', 'USDC', 'raydium'); }); it('should return 404 if pool not found', async () => { @@ -283,7 +289,7 @@ describe('Pool Routes Tests', () => { const response = await fastify.inject({ method: 'GET', - url: '/UNKNOWN-TOKEN?connector=raydium&network=mainnet-beta&type=amm', + url: '/UNKNOWN-TOKEN?chain=solana&network=mainnet-beta&type=amm', }); expect(response.statusCode).toBe(404); @@ -293,7 +299,7 @@ describe('Pool Routes Tests', () => { it('should return 400 for invalid trading pair format', async () => { const response = await fastify.inject({ method: 'GET', - url: '/INVALIDFORMAT?connector=raydium&network=mainnet-beta&type=amm', + url: '/INVALIDFORMAT?chain=solana&network=mainnet-beta&type=amm', }); expect(response.statusCode).toBe(400); @@ -312,6 +318,7 @@ describe('Pool Routes Tests', () => { method: 'POST', url: '/', payload: { + chain: 'solana', connector: 'raydium', type: 'amm', network: 'mainnet-beta', @@ -328,10 +335,12 @@ describe('Pool Routes Tests', () => { expect(JSON.parse(response.payload)).toHaveProperty('message'); expect(JSON.parse(response.payload).message).toContain('Pool WIF-SOL'); - // Verify addPool was called with pool data + // Verify addPool was called with chain, network, and pool data expect(mockPoolService.addPool).toHaveBeenCalledWith( - 'raydium', + 'solana', + 'mainnet-beta', expect.objectContaining({ + connector: 'raydium', type: 'amm', network: 'mainnet-beta', baseSymbol: 'WIF', @@ -347,6 +356,7 @@ describe('Pool Routes Tests', () => { it('should update existing pool with same address', async () => { mockPoolService.getPoolByMetadata.mockResolvedValue(null); mockPoolService.getPoolByAddress.mockResolvedValue({ + connector: 'raydium', type: 'amm', network: 'mainnet-beta', baseSymbol: 'SOL', @@ -362,6 +372,7 @@ describe('Pool Routes Tests', () => { method: 'POST', url: '/', payload: { + chain: 'solana', connector: 'raydium', type: 'amm', network: 'mainnet-beta', @@ -384,6 +395,7 @@ describe('Pool Routes Tests', () => { method: 'POST', url: '/', payload: { + chain: 'solana', connector: 'raydium', network: 'mainnet-beta', // Missing other required fields @@ -400,7 +412,7 @@ describe('Pool Routes Tests', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2?connector=raydium&network=mainnet-beta&type=amm', + url: '/58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2?chain=solana&network=mainnet-beta', }); expect(response.statusCode).toBe(200); @@ -408,9 +420,8 @@ describe('Pool Routes Tests', () => { expect(JSON.parse(response.payload).message).toContain('Pool with address'); expect(mockPoolService.removePool).toHaveBeenCalledWith( - 'raydium', + 'solana', 'mainnet-beta', - 'amm', '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', ); }); @@ -420,7 +431,7 @@ describe('Pool Routes Tests', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/NonExistent?connector=raydium&network=mainnet-beta&type=amm', + url: '/NonExistent?chain=solana&network=mainnet-beta', }); expect(response.statusCode).toBe(404); @@ -430,8 +441,8 @@ describe('Pool Routes Tests', () => { it('should return 400 for missing required parameters', async () => { const response = await fastify.inject({ method: 'DELETE', - url: '/58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2?connector=raydium', - // Missing network and type + url: '/58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2?chain=solana', + // Missing network }); expect(response.statusCode).toBe(400); @@ -509,9 +520,10 @@ describe('Pool Routes Tests', () => { expect(response.statusCode).toBe(200); const result = JSON.parse(response.payload); - // Verify response is in PoolInfo format with geckoData + // Verify response is in PoolInfo format expect(result).toHaveLength(2); expect(result[0]).toMatchObject({ + connector: 'raydium', type: 'amm', network: 'mainnet-beta', baseSymbol: 'SOL', @@ -519,25 +531,13 @@ describe('Pool Routes Tests', () => { baseTokenAddress: 'So11111111111111111111111111111111111111112', quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', - geckoData: expect.objectContaining({ - volumeUsd24h: '1000000', - liquidityUsd: '5000000', - priceUsd: '100.50', - priceNative: '1', - buys24h: 150, - sells24h: 120, - timestamp: expect.any(Number), - }), }); expect(result[1]).toMatchObject({ + connector: 'raydium', type: 'clmm', network: 'mainnet-beta', baseSymbol: 'SOL', quoteSymbol: 'USDC', - geckoData: expect.objectContaining({ - volumeUsd24h: '2000000', - liquidityUsd: '8000000', - }), }); expect(mockTokenService.getToken).toHaveBeenCalledWith('solana', 'mainnet-beta', 'SOL'); @@ -551,113 +551,6 @@ describe('Pool Routes Tests', () => { ); }); - it('should find pools by addresses', async () => { - const mockPools = [ - { - poolAddress: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', - dex: 'raydium', - connector: 'raydium', - type: 'amm' as const, - baseTokenAddress: 'So11111111111111111111111111111111111111112', - quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - baseTokenSymbol: 'SOL', - quoteTokenSymbol: 'USDC', - feePct: 0.25, - priceUsd: '100.50', - priceNative: '1', - volumeUsd24h: '1000000', - priceChange24h: '5.2', - liquidityUsd: '5000000', - txns24h: { - buys: 150, - sells: 120, - }, - }, - ]; - - mockCoinGeckoService.getTopPoolsForToken.mockResolvedValue(mockPools); - - // Mock getToken to return null for addresses (not found in token list) - mockTokenService.getToken.mockResolvedValue(null); - - const response = await fastify.inject({ - method: 'GET', - url: '/find?tokenA=So11111111111111111111111111111111111111112&tokenB=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&chainNetwork=solana-mainnet-beta', - }); - - expect(response.statusCode).toBe(200); - const result = JSON.parse(response.payload); - - // Verify response is in PoolInfo format with geckoData - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - type: 'amm', - network: 'mainnet-beta', - baseSymbol: 'SOL', - quoteSymbol: 'USDC', - baseTokenAddress: 'So11111111111111111111111111111111111111112', - quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', - geckoData: expect.objectContaining({ - volumeUsd24h: '1000000', - liquidityUsd: '5000000', - }), - }); - - // Should call getToken to check token list, but both return null (addresses not in list) - expect(mockTokenService.getToken).toHaveBeenCalledWith( - 'solana', - 'mainnet-beta', - 'So11111111111111111111111111111111111111112', - ); - expect(mockTokenService.getToken).toHaveBeenCalledWith( - 'solana', - 'mainnet-beta', - 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - ); - }); - - it('should filter by connector', async () => { - const mockPools = [ - { - poolAddress: '3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv', - dex: 'raydium-clmm', - connector: 'raydium', - type: 'clmm' as const, - baseTokenAddress: 'So11111111111111111111111111111111111111112', - quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - baseTokenSymbol: 'SOL', - quoteTokenSymbol: 'USDC', - feePct: 0.01, - priceUsd: '100.52', - priceNative: '1', - volumeUsd24h: '2000000', - priceChange24h: '5.3', - liquidityUsd: '8000000', - txns24h: { - buys: 200, - sells: 180, - }, - }, - ]; - - mockCoinGeckoService.getTopPoolsForToken.mockResolvedValue(mockPools); - - const response = await fastify.inject({ - method: 'GET', - url: '/find?tokenA=So11111111111111111111111111111111111111112&tokenB=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&chainNetwork=solana-mainnet-beta&connector=raydium', - }); - - expect(response.statusCode).toBe(200); - expect(mockCoinGeckoService.getTopPoolsForToken).toHaveBeenCalledWith( - 'solana-mainnet-beta', - 'So11111111111111111111111111111111111111112', - 10, // default maxPages - 'raydium', - 'clmm', // default type - ); - }); - it('should return empty array when no pools found', async () => { mockCoinGeckoService.getTopPoolsForToken.mockResolvedValue([]); @@ -671,69 +564,6 @@ describe('Pool Routes Tests', () => { expect(result).toEqual([]); }); - it('should return top pools by network when neither tokenA nor tokenB is provided', async () => { - const mockPools = [ - { - poolAddress: '3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv', - dex: 'raydium-clmm', - connector: 'raydium', - type: 'clmm' as const, - baseTokenAddress: 'So11111111111111111111111111111111111111112', - quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - baseTokenSymbol: 'SOL', - quoteTokenSymbol: 'USDC', - feePct: 0.01, - priceUsd: '100.52', - priceNative: '1', - volumeUsd24h: '2000000', - priceChange24h: '5.3', - liquidityUsd: '8000000', - txns24h: { - buys: 200, - sells: 180, - }, - }, - ]; - - mockCoinGeckoService.getTopPoolsByNetwork.mockResolvedValue(mockPools); - - const response = await fastify.inject({ - method: 'GET', - url: '/find?chainNetwork=solana-mainnet-beta', - }); - - expect(response.statusCode).toBe(200); - const result = JSON.parse(response.payload); - - // Verify response is in PoolInfo format with geckoData - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - type: 'clmm', - network: 'mainnet-beta', - baseSymbol: 'SOL', - quoteSymbol: 'USDC', - baseTokenAddress: 'So11111111111111111111111111111111111111112', - quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - address: '3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv', - geckoData: expect.objectContaining({ - volumeUsd24h: '2000000', - liquidityUsd: '8000000', - priceUsd: '100.52', - priceNative: '1', - buys24h: 200, - sells24h: 180, - timestamp: expect.any(Number), - }), - }); - - expect(mockCoinGeckoService.getTopPoolsByNetwork).toHaveBeenCalledWith( - 'solana-mainnet-beta', - 10, // default maxPages - undefined, // no connector filter - 'clmm', // default type - ); - }); - it('should return 400 for invalid chainNetwork format', async () => { const response = await fastify.inject({ method: 'GET', From 3ecbc7f550b6777d46ee07c7fec236a8a1583c53 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 17:28:32 -0700 Subject: [PATCH 2/3] feat(fetch-pools): standardize Meteora and Orca pool listing APIs - Update Meteora to use new API (dlmm.datapi.meteora.ag/pools) - Add Orca fetch-pools endpoint using api.orca.so/v2/solana/pools/search - Standardize response format across both connectors - Add FetchPoolsRequest/Response schemas in clmm-schema.ts - Remove isVerified field (not reliably available from APIs) - Add tests for both endpoints Co-Authored-By: Claude Opus 4.5 --- .../meteora/clmm-routes/fetchPools.ts | 91 +++-- src/connectors/meteora/meteora.ts | 107 +++++- src/connectors/meteora/schemas.ts | 34 +- src/connectors/orca/clmm-routes/fetchPools.ts | 80 ++-- src/connectors/orca/orca.ts | 96 ++++- src/connectors/orca/schemas.ts | 31 +- src/schemas/clmm-schema.ts | 56 ++- .../meteora/clmm-routes/fetchPools.test.ts | 255 +++++++++++++ .../orca/clmm-routes/fetchPools.test.ts | 343 ++++++++++-------- 9 files changed, 816 insertions(+), 277 deletions(-) create mode 100644 test/connectors/meteora/clmm-routes/fetchPools.test.ts diff --git a/src/connectors/meteora/clmm-routes/fetchPools.ts b/src/connectors/meteora/clmm-routes/fetchPools.ts index 7f641a44f8..fc8027fb61 100644 --- a/src/connectors/meteora/clmm-routes/fetchPools.ts +++ b/src/connectors/meteora/clmm-routes/fetchPools.ts @@ -1,72 +1,67 @@ -import { Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { Solana } from '../../../chains/solana/solana'; -import { PoolInfo, PoolInfoSchema, FetchPoolsRequestType } from '../../../schemas/clmm-schema'; +import { FetchPoolsResponse } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; -import { Meteora } from '../meteora'; +import { Meteora, MeteoraApiPool } from '../meteora'; import { MeteoraClmmFetchPoolsRequest } from '../schemas'; -// Using Fastify's native error handling export const fetchPoolsRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ - Querystring: FetchPoolsRequestType; - Reply: PoolInfo[]; + Querystring: { + network?: string; + page?: number; + limit?: number; + query?: string; + sortBy?: string; + includeUnverified?: boolean; + }; }>('/fetch-pools', { schema: { - description: 'Fetch info about Meteora pools', + description: 'Fetch Meteora pools from API with search and sorting', tags: ['/connector/meteora'], querystring: MeteoraClmmFetchPoolsRequest, response: { - 200: Type.Array(PoolInfoSchema), + 200: FetchPoolsResponse, }, }, handler: async (request, _reply) => { try { - const { limit, tokenA, tokenB } = request.query; - const network = request.query.network; + const { network, page, limit, query, sortBy, includeUnverified } = request.query; const meteora = await Meteora.getInstance(network); - const solana = await Solana.getInstance(network); - let tokenMintA, tokenMintB; + const result = await meteora.fetchPoolsFromApi({ + page, + limit, + query, + sortBy, + includeUnverified, + }); - if (tokenA) { - const tokenInfoA = await solana.getToken(tokenA); - if (!tokenInfoA) { - throw fastify.httpErrors.notFound(`Token ${tokenA} not found`); - } - tokenMintA = tokenInfoA.address; - } + // Map API response to simplified format + const pools = result.pools.map((pool: MeteoraApiPool) => ({ + address: pool.address, + name: pool.name, + baseTokenAddress: pool.token_x.address, + baseTokenSymbol: pool.token_x.symbol, + quoteTokenAddress: pool.token_y.address, + quoteTokenSymbol: pool.token_y.symbol, + binStep: pool.pool_config.bin_step, + baseFee: pool.pool_config.base_fee_pct, + price: pool.current_price, + tvl: pool.tvl, + apr: pool.apr, + apy: pool.apy, + volume24h: pool.volume?.['24h'], + fees24h: pool.fees?.['24h'], + })); - if (tokenB) { - const tokenInfoB = await solana.getToken(tokenB); - if (!tokenInfoB) { - throw fastify.httpErrors.notFound(`Token ${tokenB} not found`); - } - tokenMintB = tokenInfoB.address; - } - - const pairs = await meteora.getPools(limit, tokenMintA, tokenMintB); - if (!Array.isArray(pairs)) { - logger.error('No matching Meteora pools found'); - return []; - } - - const poolInfos = await Promise.all( - pairs - .filter((pair) => pair?.publicKey?.toString) - .map(async (pair) => { - try { - return await meteora.getPoolInfo(pair.publicKey.toString()); - } catch (error) { - logger.error(`Failed to get pool info for ${pair.publicKey.toString()}: ${error.message}`); - throw fastify.httpErrors.notFound(`Pool not found: ${pair.publicKey.toString()}`); - } - }), - ); - - return poolInfos.filter(Boolean); + return { + pools, + total: result.total, + page: result.page, + pageSize: result.pageSize, + }; } catch (e) { logger.error('Error in fetch-pools:', e); if (e.statusCode) throw e; diff --git a/src/connectors/meteora/meteora.ts b/src/connectors/meteora/meteora.ts index 8d9577d11d..a346c12c4f 100644 --- a/src/connectors/meteora/meteora.ts +++ b/src/connectors/meteora/meteora.ts @@ -10,6 +10,50 @@ import { logger } from '../../services/logger'; import { MeteoraConfig } from './meteora.config'; +/** Pool data from Meteora API */ +export interface MeteoraApiPool { + address: string; + name: string; + token_x: { + address: string; + symbol: string; + name: string; + decimals: number; + is_verified: boolean; + price?: number; + }; + token_y: { + address: string; + symbol: string; + name: string; + decimals: number; + is_verified: boolean; + price?: number; + }; + reserve_x: string; + reserve_y: string; + token_x_amount: number; + token_y_amount: number; + pool_config: { + bin_step: number; + base_fee_pct: number; + max_fee_pct: number; + protocol_fee_pct: number; + }; + dynamic_fee_pct: number; + tvl: number; + current_price: number; + apr: number; + apy: number; + has_farm: boolean; + farm_apr: number; + farm_apy: number; + volume: { '30m'?: number; '1h'?: number; '24h'?: number }; + fees: { '30m'?: number; '1h'?: number; '24h'?: number }; + is_blacklisted: boolean; + tags?: string[]; +} + export class Meteora { private static _instances: { [name: string]: Meteora }; // Recommended maximum bins per position (aligns with SDK's DEFAULT_BIN_PER_POSITION) @@ -91,7 +135,7 @@ export class Meteora { tokenMintA?: string, tokenMintB?: string, ): Promise<{ publicKey: PublicKey; account: LbPair }[]> { - const timeoutMs = 10000; + const timeoutMs = 60000; // Increased to 60s - fetching all pools from blockchain is slow try { logger.info('Fetching Meteora pools...'); const lbPairsPromise = DLMM.getLbPairs(this.solana.connection, { @@ -136,6 +180,67 @@ export class Meteora { } } + /** Fetches pools from Meteora API (fast, paginated) */ + async fetchPoolsFromApi( + options: { + page?: number; + limit?: number; + query?: string; + sortBy?: string; + includeUnverified?: boolean; + } = {}, + ): Promise<{ + pools: MeteoraApiPool[]; + total: number; + page: number; + pageSize: number; + }> { + const { page = 0, limit = 50, query, sortBy = 'volume_24h:desc', includeUnverified = true } = options; + + try { + const url = new URL('https://dlmm.datapi.meteora.ag/pools'); + url.searchParams.set('page', String(page + 1)); // API uses 1-based pagination + url.searchParams.set('page_size', String(Math.min(limit, 1000))); + + if (query) { + url.searchParams.set('query', query); + } + if (sortBy) { + url.searchParams.set('sort_by', sortBy); + } + + // Filter out blacklisted pools + const filters = ['is_blacklisted=false']; + if (!includeUnverified) { + filters.push('token_x.is_verified=true'); + filters.push('token_y.is_verified=true'); + } + url.searchParams.set('filter_by', filters.join(' && ')); + + logger.info(`Fetching Meteora pools from API: ${url.toString()}`); + + const response = await fetch(url.toString(), { + headers: { accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`Meteora API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + return { + pools: data.data || [], + total: data.total || 0, + page: data.current_page || 1, + pageSize: data.page_size || limit, + }; + } catch (error) { + logger.error('Failed to fetch pools from Meteora API:', error); + throw error; + } + } + /** Gets comprehensive pool information */ async getPoolInfo(poolAddress: string): Promise { try { diff --git a/src/connectors/meteora/schemas.ts b/src/connectors/meteora/schemas.ts index 4cc7638476..26ca651eae 100644 --- a/src/connectors/meteora/schemas.ts +++ b/src/connectors/meteora/schemas.ts @@ -365,24 +365,40 @@ export const MeteoraClmmFetchPoolsRequest = Type.Object({ enum: [...MeteoraConfig.networks], }), ), + page: Type.Optional( + Type.Number({ + minimum: 0, + default: 0, + description: 'Page number (0-based)', + examples: [0], + }), + ), limit: Type.Optional( Type.Number({ minimum: 1, - default: 10, - description: 'Maximum number of pools to return', - examples: [10], + maximum: 1000, + default: 50, + description: 'Maximum number of pools to return (max 1000)', + examples: [50], }), ), - tokenA: Type.Optional( + query: Type.Optional( Type.String({ - description: 'First token symbol or address', - examples: [BASE_TOKEN], + description: 'Search query to match pools by name, tokens, or address', + examples: ['SOL', 'USDC', 'SOL-USDC'], }), ), - tokenB: Type.Optional( + sortBy: Type.Optional( Type.String({ - description: 'Second token symbol or address', - examples: [QUOTE_TOKEN], + description: 'Sort by field (volume, fees, tvl, apr) with optional time window', + default: 'volume_24h:desc', + examples: ['volume_24h:desc', 'tvl:desc', 'apr:desc'], + }), + ), + includeUnverified: Type.Optional( + Type.Boolean({ + description: 'Include pools with unverified tokens', + default: true, }), ), }); diff --git a/src/connectors/orca/clmm-routes/fetchPools.ts b/src/connectors/orca/clmm-routes/fetchPools.ts index 8ab7c11289..ba56259bd9 100644 --- a/src/connectors/orca/clmm-routes/fetchPools.ts +++ b/src/connectors/orca/clmm-routes/fetchPools.ts @@ -1,63 +1,67 @@ -import { Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { Solana } from '../../../chains/solana/solana'; -import { PoolInfo, PoolInfoSchema, FetchPoolsRequestType } from '../../../schemas/clmm-schema'; +import { FetchPoolsResponse } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; import { OrcaClmmFetchPoolsRequest } from '../schemas'; -// Using Fastify's native error handling export const fetchPoolsRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ - Querystring: FetchPoolsRequestType; - Reply: PoolInfo[]; + Querystring: { + network?: string; + limit?: number; + query?: string; + sortBy?: string; + sortDirection?: string; + verifiedOnly?: boolean; + }; }>('/fetch-pools', { schema: { - description: 'Fetch info about Orca pools', + description: 'Fetch Orca pools from API with search and sorting', tags: ['/connector/orca'], querystring: OrcaClmmFetchPoolsRequest, response: { - 200: Type.Array(PoolInfoSchema), + 200: FetchPoolsResponse, }, }, handler: async (request, _reply) => { try { - const { limit, tokenA, tokenB } = request.query; - const network = request.query.network; + const { network, limit = 50, query, sortBy, sortDirection, verifiedOnly } = request.query; const orca = await Orca.getInstance(network); - const solana = await Solana.getInstance(network); - // Get token symbols for API search - let tokenSymbolA: string | undefined; - let tokenSymbolB: string | undefined; + const rawPools = await orca.fetchPoolsFromApi({ + limit, + query, + sortBy, + sortDirection, + verifiedOnly, + }); - if (tokenA) { - const tokenInfoA = await solana.getToken(tokenA); - if (!tokenInfoA) { - throw fastify.httpErrors.notFound(`Token ${tokenA} not found`); - } - tokenSymbolA = tokenInfoA.symbol; - } + // Map to standardized format (same as Meteora) + const pools = rawPools.map((pool: any) => ({ + address: pool.address, + name: `${pool.tokenA?.symbol || '?'}-${pool.tokenB?.symbol || '?'}`, + baseTokenAddress: pool.tokenMintA, + baseTokenSymbol: pool.tokenA?.symbol || '', + quoteTokenAddress: pool.tokenMintB, + quoteTokenSymbol: pool.tokenB?.symbol || '', + binStep: pool.tickSpacing, + baseFee: Number(pool.feeRate) / 10000, // Convert to percentage + price: Number(pool.price), + tvl: Number(pool.tvlUsdc) || 0, + apr: pool.feeApr?.day ? Number(pool.feeApr.day) * 100 : undefined, // Convert to percentage + apy: pool.totalApr?.day ? Number(pool.totalApr.day) * 100 : undefined, + volume24h: pool.volume?.day ? Number(pool.volume.day) : undefined, + fees24h: pool.fees?.day ? Number(pool.fees.day) : undefined, + })); - if (tokenB) { - const tokenInfoB = await solana.getToken(tokenB); - if (!tokenInfoB) { - throw fastify.httpErrors.notFound(`Token ${tokenB} not found`); - } - tokenSymbolB = tokenInfoB.symbol; - } - - // getPools now returns mapped OrcaPoolInfo objects directly - const pools = await orca.getPools(limit, tokenSymbolA, tokenSymbolB); - - if (!Array.isArray(pools) || pools.length === 0) { - logger.info('No matching Orca pools found'); - return []; - } - - return pools; + return { + pools, + total: pools.length, // Orca API doesn't return total count + page: 1, + pageSize: limit, + }; } catch (e) { logger.error('Error in fetch-pools:', e); if (e.statusCode) throw e; diff --git a/src/connectors/orca/orca.ts b/src/connectors/orca/orca.ts index a57e88cc0c..32ee2102a6 100644 --- a/src/connectors/orca/orca.ts +++ b/src/connectors/orca/orca.ts @@ -87,12 +87,20 @@ export class Orca { /** * Fetches pools from Orca API and maps them to OrcaPoolInfo format - * @param limit Maximum number of pools to return (maps to 'size' parameter) - * @param tokenSymbolA Optional first token symbol (e.g., 'SOL') - * @param tokenSymbolB Optional second token symbol (e.g., 'USDC') + * @param options Fetch options * @returns Array of OrcaPoolInfo objects */ - async getPools(limit?: number, tokenSymbolA?: string, tokenSymbolB?: string): Promise { + async getPools( + options: { + limit?: number; + query?: string; + sortBy?: string; + sortDirection?: string; + verifiedOnly?: boolean; + } = {}, + ): Promise { + const { limit = 50, query, sortBy = 'volume', sortDirection = 'desc', verifiedOnly = false } = options; + try { let baseUrl: string; if (this.solana.network === 'mainnet-beta') { @@ -103,21 +111,25 @@ export class Orca { const params = new URLSearchParams(); - // Build search query from token symbols - if (tokenSymbolA && tokenSymbolB) { - params.append('q', `${tokenSymbolA} ${tokenSymbolB}`); - } else if (tokenSymbolA) { - params.append('q', tokenSymbolA); - } else if (tokenSymbolB) { - params.append('q', tokenSymbolB); + if (query) { + params.append('q', query); } - - // Add size parameter (limit) if (limit) { params.append('size', limit.toString()); } + if (sortBy) { + params.append('sortBy', sortBy); + } + if (sortDirection) { + params.append('sortDirection', sortDirection); + } + if (verifiedOnly) { + params.append('verifiedOnly', 'true'); + } const url = `${baseUrl}?${params.toString()}`; + logger.info(`Fetching Orca pools from API: ${url}`); + const response = await fetch(url); if (!response.ok) { @@ -135,6 +147,64 @@ export class Orca { } } + /** + * Fetches raw pool data from Orca API (for fetch-pools endpoint) + * Returns raw API response without mapping to OrcaPoolInfo + */ + async fetchPoolsFromApi( + options: { + limit?: number; + query?: string; + sortBy?: string; + sortDirection?: string; + verifiedOnly?: boolean; + } = {}, + ): Promise { + const { limit = 50, query, sortBy = 'volume', sortDirection = 'desc', verifiedOnly = false } = options; + + try { + let baseUrl: string; + if (this.solana.network === 'mainnet-beta') { + baseUrl = 'https://api.orca.so/v2/solana/pools/search'; + } else { + baseUrl = 'https://api.devnet.orca.so/v2/solana/pools/search'; + } + + const params = new URLSearchParams(); + + if (query) { + params.append('q', query); + } + if (limit) { + params.append('size', limit.toString()); + } + if (sortBy) { + params.append('sortBy', sortBy); + } + if (sortDirection) { + params.append('sortDirection', sortDirection); + } + if (verifiedOnly) { + params.append('verifiedOnly', 'true'); + } + + const url = `${baseUrl}?${params.toString()}`; + logger.info(`Fetching Orca pools from API: ${url}`); + + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Orca API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + return data.data || []; + } catch (error) { + logger.error('Error fetching pools from Orca API:', error); + throw error; + } + } + /** * Maps Orca API v2 pool data to OrcaPoolInfo format * @param apiPool Pool data from Orca API v2 diff --git a/src/connectors/orca/schemas.ts b/src/connectors/orca/schemas.ts index 236e1d6b09..3aebf5f2a2 100644 --- a/src/connectors/orca/schemas.ts +++ b/src/connectors/orca/schemas.ts @@ -378,21 +378,36 @@ export const OrcaClmmFetchPoolsRequest = Type.Object({ limit: Type.Optional( Type.Number({ minimum: 1, - default: 10, + maximum: 100, + default: 50, description: 'Maximum number of pools to return', - examples: [10], + examples: [50], }), ), - tokenA: Type.Optional( + query: Type.Optional( Type.String({ - description: 'First token symbol or address', - examples: [BASE_TOKEN], + description: 'Search query to match pools by name, tokens, or address', + examples: ['SOL', 'USDC', 'SOL-USDC'], }), ), - tokenB: Type.Optional( + sortBy: Type.Optional( Type.String({ - description: 'Second token symbol or address', - examples: [QUOTE_TOKEN], + description: 'Sort by field', + enum: ['volume', 'tvl', 'fees', 'rewards', 'yieldovertvl'], + default: 'volume', + }), + ), + sortDirection: Type.Optional( + Type.String({ + description: 'Sort direction', + enum: ['asc', 'desc'], + default: 'desc', + }), + ), + verifiedOnly: Type.Optional( + Type.Boolean({ + description: 'Only return pools with verified tokens', + default: false, }), ), }); diff --git a/src/schemas/clmm-schema.ts b/src/schemas/clmm-schema.ts index 60b6d88d54..ddf2e5a819 100644 --- a/src/schemas/clmm-schema.ts +++ b/src/schemas/clmm-schema.ts @@ -4,16 +4,64 @@ import { TransactionStatus } from './chain-schema'; export const FetchPoolsRequest = Type.Object( { - network: Type.Optional(Type.String()), // Network - limit: Type.Optional(Type.Number({ minimum: 1 })), // Maximum number of pools to return - tokenA: Type.Optional(Type.String()), // First token symbol or address - tokenB: Type.Optional(Type.String()), // Second token symbol or address + network: Type.Optional(Type.String({ description: 'Network to use' })), + limit: Type.Optional( + Type.Number({ + minimum: 1, + maximum: 100, + default: 50, + description: 'Maximum number of pools to return', + }), + ), + query: Type.Optional( + Type.String({ + description: 'Search query to match pools by name, tokens, or address', + }), + ), + sortBy: Type.Optional( + Type.String({ + description: 'Sort by field (connector-specific)', + }), + ), }, { $id: 'FetchPoolsRequest' }, ); export type FetchPoolsRequestType = Static; +// Standardized pool list item schema for fetch-pools response +export const PoolListItemSchema = Type.Object( + { + address: Type.String({ description: 'Pool address' }), + name: Type.String({ description: 'Pool name (e.g., SOL-USDC)' }), + baseTokenAddress: Type.String({ description: 'Base token address' }), + baseTokenSymbol: Type.String({ description: 'Base token symbol' }), + quoteTokenAddress: Type.String({ description: 'Quote token address' }), + quoteTokenSymbol: Type.String({ description: 'Quote token symbol' }), + binStep: Type.Number({ description: 'Bin step / tick spacing' }), + baseFee: Type.Number({ description: 'Base fee percentage' }), + price: Type.Number({ description: 'Current price' }), + tvl: Type.Number({ description: 'Total value locked in USD' }), + apr: Type.Optional(Type.Number({ description: 'Annual percentage rate' })), + apy: Type.Optional(Type.Number({ description: 'Annual percentage yield' })), + volume24h: Type.Optional(Type.Number({ description: '24-hour trading volume' })), + fees24h: Type.Optional(Type.Number({ description: '24-hour fees collected' })), + }, + { $id: 'PoolListItem' }, +); +export type PoolListItem = Static; + +export const FetchPoolsResponse = Type.Object( + { + pools: Type.Array(PoolListItemSchema), + total: Type.Number({ description: 'Total number of matching pools' }), + page: Type.Number({ description: 'Current page number' }), + pageSize: Type.Number({ description: 'Number of pools per page' }), + }, + { $id: 'FetchPoolsResponse' }, +); +export type FetchPoolsResponseType = Static; + export const GetPositionsOwnedRequest = Type.Object( { network: Type.Optional(Type.String()), diff --git a/test/connectors/meteora/clmm-routes/fetchPools.test.ts b/test/connectors/meteora/clmm-routes/fetchPools.test.ts new file mode 100644 index 0000000000..a4db076aac --- /dev/null +++ b/test/connectors/meteora/clmm-routes/fetchPools.test.ts @@ -0,0 +1,255 @@ +import { Meteora } from '../../../../src/connectors/meteora/meteora'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/connectors/meteora/meteora'); +jest.mock('../../../../src/chains/solana/solana.config', () => ({ + getSolanaChainConfig: jest.fn().mockReturnValue({ + defaultNetwork: 'mainnet-beta', + defaultWallet: '11111111111111111111111111111111', + }), +})); + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { fetchPoolsRoute } = await import('../../../../src/connectors/meteora/clmm-routes/fetchPools'); + await server.register(fetchPoolsRoute); + return server; +}; + +const mockMeteoraApiResponse = { + pools: [ + { + address: '5rCf1DM8LjKTw4YqhnoLcngyZYeNnQqztScTogYHAS6', + name: 'SOL-USDC', + token_x: { + address: 'So11111111111111111111111111111111111111112', + symbol: 'SOL', + name: 'Wrapped SOL', + decimals: 9, + is_verified: true, + }, + token_y: { + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + is_verified: true, + }, + pool_config: { + bin_step: 4, + base_fee_pct: 0.04, + max_fee_pct: 0, + protocol_fee_pct: 5, + }, + tvl: 3708880.74, + current_price: 83.82, + apr: 0.143, + apy: 68.52, + volume: { '24h': 13738342.03 }, + fees: { '24h': 5307.12 }, + is_blacklisted: false, + }, + { + address: 'AsSyvUnbfaZJPRrNh3kUuvZTeHKoMVWEoHz86f4Q5D9x', + name: 'MET-SOL', + token_x: { + address: 'METvsvVRapdj9cFLzq4Tr43xK4tAjQfwX76z3n6mWQL', + symbol: 'MET', + name: 'Meteora', + decimals: 9, + is_verified: true, + }, + token_y: { + address: 'So11111111111111111111111111111111111111112', + symbol: 'SOL', + name: 'Wrapped SOL', + decimals: 9, + is_verified: true, + }, + pool_config: { + bin_step: 20, + base_fee_pct: 0.2, + max_fee_pct: 0, + protocol_fee_pct: 5, + }, + tvl: 627207.71, + current_price: 0.00183, + apr: 0.14, + apy: 67.0, + volume: { '24h': 462925.85 }, + fees: { '24h': 881.74 }, + is_blacklisted: false, + }, + ], + total: 81391, + page: 1, + pageSize: 50, +}; + +describe('GET /fetch-pools (Meteora)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + if (server) { + await server.close(); + } + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch pools with default parameters', async () => { + const mockMeteoraInstance = { + fetchPoolsFromApi: jest.fn().mockResolvedValue(mockMeteoraApiResponse), + }; + (Meteora.getInstance as jest.Mock).mockResolvedValue(mockMeteoraInstance); + + const response = await server.inject({ + method: 'GET', + url: '/fetch-pools?network=mainnet-beta', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + expect(body).toHaveProperty('pools'); + expect(body).toHaveProperty('total', 81391); + expect(body).toHaveProperty('page', 1); + expect(body).toHaveProperty('pageSize', 50); + + expect(body.pools).toHaveLength(2); + expect(body.pools[0]).toEqual({ + address: '5rCf1DM8LjKTw4YqhnoLcngyZYeNnQqztScTogYHAS6', + name: 'SOL-USDC', + baseTokenAddress: 'So11111111111111111111111111111111111111112', + baseTokenSymbol: 'SOL', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + quoteTokenSymbol: 'USDC', + binStep: 4, + baseFee: 0.04, + price: 83.82, + tvl: 3708880.74, + apr: 0.143, + apy: 68.52, + volume24h: 13738342.03, + fees24h: 5307.12, + }); + }); + + it('should fetch pools with search query', async () => { + const mockMeteoraInstance = { + fetchPoolsFromApi: jest.fn().mockResolvedValue({ + pools: [mockMeteoraApiResponse.pools[0]], + total: 1, + page: 1, + pageSize: 50, + }), + }; + (Meteora.getInstance as jest.Mock).mockResolvedValue(mockMeteoraInstance); + + const response = await server.inject({ + method: 'GET', + url: '/fetch-pools?network=mainnet-beta&query=SOL-USDC&limit=10', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + expect(body.pools).toHaveLength(1); + expect(body.pools[0].name).toBe('SOL-USDC'); + + // Verify fetchPoolsFromApi was called with correct params + expect(mockMeteoraInstance.fetchPoolsFromApi).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 10, + query: 'SOL-USDC', + }), + ); + }); + + it('should fetch pools with sorting', async () => { + const mockMeteoraInstance = { + fetchPoolsFromApi: jest.fn().mockResolvedValue(mockMeteoraApiResponse), + }; + (Meteora.getInstance as jest.Mock).mockResolvedValue(mockMeteoraInstance); + + const response = await server.inject({ + method: 'GET', + url: '/fetch-pools?network=mainnet-beta&sortBy=tvl:desc', + }); + + expect(response.statusCode).toBe(200); + expect(mockMeteoraInstance.fetchPoolsFromApi).toHaveBeenCalledWith( + expect.objectContaining({ + sortBy: 'tvl:desc', + }), + ); + }); + + it('should handle API errors gracefully', async () => { + const mockMeteoraInstance = { + fetchPoolsFromApi: jest.fn().mockRejectedValue(new Error('Meteora API error')), + }; + (Meteora.getInstance as jest.Mock).mockResolvedValue(mockMeteoraInstance); + + const response = await server.inject({ + method: 'GET', + url: '/fetch-pools?network=mainnet-beta', + }); + + expect(response.statusCode).toBe(500); + expect(JSON.parse(response.body)).toHaveProperty('error'); + }); + + it('should handle empty pool results', async () => { + const mockMeteoraInstance = { + fetchPoolsFromApi: jest.fn().mockResolvedValue({ + pools: [], + total: 0, + page: 1, + pageSize: 50, + }), + }; + (Meteora.getInstance as jest.Mock).mockResolvedValue(mockMeteoraInstance); + + const response = await server.inject({ + method: 'GET', + url: '/fetch-pools?network=mainnet-beta&query=NONEXISTENT', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.pools).toHaveLength(0); + expect(body.total).toBe(0); + }); + + it('should filter unverified pools when includeUnverified is false', async () => { + const mockMeteoraInstance = { + fetchPoolsFromApi: jest.fn().mockResolvedValue({ + pools: [mockMeteoraApiResponse.pools[0]], + total: 1, + page: 1, + pageSize: 50, + }), + }; + (Meteora.getInstance as jest.Mock).mockResolvedValue(mockMeteoraInstance); + + const response = await server.inject({ + method: 'GET', + url: '/fetch-pools?network=mainnet-beta&includeUnverified=false', + }); + + expect(response.statusCode).toBe(200); + expect(mockMeteoraInstance.fetchPoolsFromApi).toHaveBeenCalledWith( + expect.objectContaining({ + includeUnverified: false, + }), + ); + }); +}); diff --git a/test/connectors/orca/clmm-routes/fetchPools.test.ts b/test/connectors/orca/clmm-routes/fetchPools.test.ts index 73ec6f678c..75ea87773c 100644 --- a/test/connectors/orca/clmm-routes/fetchPools.test.ts +++ b/test/connectors/orca/clmm-routes/fetchPools.test.ts @@ -1,23 +1,11 @@ -import { Solana } from '../../../../src/chains/solana/solana'; import { Orca } from '../../../../src/connectors/orca/orca'; import { fastifyWithTypeProvider } from '../../../utils/testUtils'; -jest.mock('../../../../src/chains/solana/solana', () => ({ - Solana: { - getInstance: jest.fn(), - }, -})); - -jest.mock('../../../../src/connectors/orca/orca', () => ({ - Orca: { - getInstance: jest.fn(), - }, -})); - +jest.mock('../../../../src/connectors/orca/orca'); jest.mock('../../../../src/chains/solana/solana.config', () => ({ getSolanaChainConfig: jest.fn().mockReturnValue({ defaultNetwork: 'mainnet-beta', - defaultWallet: 'BPgNwGDBiRuaAKuRQLpXC9rCiw5FfJDDdTunDEmtN6VF', + defaultWallet: '11111111111111111111111111111111', }), })); @@ -29,168 +17,211 @@ const buildApp = async () => { return server; }; -describe('GET /fetch-pools', () => { - let app: ReturnType; +// Mock Orca API response format +const mockOrcaApiResponse = [ + { + address: 'Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE', + tokenMintA: 'So11111111111111111111111111111111111111112', + tokenMintB: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + tokenA: { + symbol: 'SOL', + name: 'Wrapped SOL', + decimals: 9, + imageUrl: + 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/So11111111111111111111111111111111111111112/logo.png', + }, + tokenB: { + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + imageUrl: + 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png', + }, + tickSpacing: 64, + feeRate: 2500, // 0.25% in hundredths of basis points + price: '150.5', + tvlUsdc: 5000000, + feeApr: { day: 0.15 }, + totalApr: { day: 0.25 }, + volume: { day: 1000000 }, + fees: { day: 2500 }, + }, + { + address: '2AEWSvUds1wsufnsDPCXjFsJCMJH5SNNm7fSF4kxys9a', + tokenMintA: 'So11111111111111111111111111111111111111112', + tokenMintB: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + tokenA: { + symbol: 'SOL', + name: 'Wrapped SOL', + decimals: 9, + imageUrl: 'https://example.com/sol.png', + }, + tokenB: { + symbol: 'USDT', + name: 'USDT', + decimals: 6, + imageUrl: 'https://example.com/usdt.png', + }, + tickSpacing: 64, + feeRate: 2500, + price: '148.2', + tvlUsdc: 3000000, + feeApr: { day: 0.12 }, + totalApr: { day: 0.2 }, + volume: { day: 800000 }, + fees: { day: 2000 }, + }, +]; + +describe('GET /fetch-pools (Orca)', () => { + let server: any; beforeAll(async () => { - app = await buildApp(); - await app.ready(); + server = await buildApp(); }); afterAll(async () => { - await app.close(); + if (server) { + await server.close(); + } + }); + + beforeEach(() => { + jest.clearAllMocks(); }); - describe('successful pool fetching', () => { - it('should fetch pools successfully', async () => { - const mockPools = [ - { - address: 'Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE', - baseTokenAddress: 'So11111111111111111111111111111111111111112', - quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - binStep: 64, - feePct: 0.25, - price: 150.5, - baseTokenAmount: 1000, - quoteTokenAmount: 150500, - activeBinId: 12345, - }, - { - address: '2AEWSvUds1wsufnsDPCXjFsJCMJH5SNNm7fSF4kxys9a', - baseTokenAddress: 'So11111111111111111111111111111111111111112', - quoteTokenAddress: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', - binStep: 64, - feePct: 0.25, - price: 148.2, - baseTokenAmount: 2000, - quoteTokenAmount: 296400, - activeBinId: 12340, - }, - ]; - - const mockOrca = { - getPools: jest.fn().mockResolvedValue(mockPools), - }; - const mockSolana = { - getToken: jest.fn().mockResolvedValue({ symbol: 'SOL' }), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); - (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana); - - const response = await app.inject({ - method: 'GET', - url: '/fetch-pools', - query: { - network: 'mainnet-beta', - }, - }); - - expect(response.statusCode).toBe(200); - if (response.statusCode === 200) { - const body = JSON.parse(response.body); - expect(Array.isArray(body)).toBe(true); - expect(mockOrca.getPools).toHaveBeenCalled(); - } + it('should fetch pools with default parameters', async () => { + const mockOrcaInstance = { + fetchPoolsFromApi: jest.fn().mockResolvedValue(mockOrcaApiResponse), + }; + (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrcaInstance); + + const response = await server.inject({ + method: 'GET', + url: '/fetch-pools?network=mainnet-beta', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + expect(body).toHaveProperty('pools'); + expect(body).toHaveProperty('total', 2); + expect(body).toHaveProperty('page', 1); + expect(body).toHaveProperty('pageSize', 50); + + expect(body.pools).toHaveLength(2); + expect(body.pools[0]).toEqual({ + address: 'Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE', + name: 'SOL-USDC', + baseTokenAddress: 'So11111111111111111111111111111111111111112', + baseTokenSymbol: 'SOL', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + quoteTokenSymbol: 'USDC', + binStep: 64, + baseFee: 0.25, + price: 150.5, + tvl: 5000000, + apr: 15, // 0.15 * 100 + apy: 25, // 0.25 * 100 + volume24h: 1000000, + fees24h: 2500, }); + }); + + it('should fetch pools with search query', async () => { + const mockOrcaInstance = { + fetchPoolsFromApi: jest.fn().mockResolvedValue([mockOrcaApiResponse[0]]), + }; + (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrcaInstance); - it('should use default network if not provided', async () => { - const mockOrca = { - getPools: jest.fn().mockResolvedValue([]), - }; - const mockSolana = { - getToken: jest.fn().mockResolvedValue({ symbol: 'SOL' }), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); - (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana); - - const response = await app.inject({ - method: 'GET', - url: '/fetch-pools', - query: {}, - }); - - expect(response.statusCode).toBe(200); + const response = await server.inject({ + method: 'GET', + url: '/fetch-pools?network=mainnet-beta&query=SOL-USDC&limit=10', }); - it('should return empty array when no pools found', async () => { - const mockOrca = { - getPools: jest.fn().mockResolvedValue([]), - }; - const mockSolana = { - getToken: jest.fn().mockResolvedValue({ symbol: 'SOL' }), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); - (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana); - - const response = await app.inject({ - method: 'GET', - url: '/fetch-pools', - query: { - network: 'mainnet-beta', - }, - }); - - expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); - expect(body).toEqual([]); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + expect(body.pools).toHaveLength(1); + expect(body.pools[0].name).toBe('SOL-USDC'); + + // Verify fetchPoolsFromApi was called with correct params + expect(mockOrcaInstance.fetchPoolsFromApi).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 10, + query: 'SOL-USDC', + }), + ); + }); + + it('should fetch pools with sorting', async () => { + const mockOrcaInstance = { + fetchPoolsFromApi: jest.fn().mockResolvedValue(mockOrcaApiResponse), + }; + (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrcaInstance); + + const response = await server.inject({ + method: 'GET', + url: '/fetch-pools?network=mainnet-beta&sortBy=tvl&sortDirection=desc', }); + + expect(response.statusCode).toBe(200); + expect(mockOrcaInstance.fetchPoolsFromApi).toHaveBeenCalledWith( + expect.objectContaining({ + sortBy: 'tvl', + sortDirection: 'desc', + }), + ); }); - describe('error handling', () => { - it('should handle Orca errors gracefully', async () => { - const mockOrca = { - getPools: jest.fn().mockRejectedValue(new Error('Failed to fetch pools')), - }; - const mockSolana = { - getToken: jest.fn().mockResolvedValue({ symbol: 'SOL' }), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); - (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana); - - const response = await app.inject({ - method: 'GET', - url: '/fetch-pools', - query: { - network: 'mainnet-beta', - }, - }); - - expect(response.statusCode).toBeGreaterThanOrEqual(400); + it('should handle API errors gracefully', async () => { + const mockOrcaInstance = { + fetchPoolsFromApi: jest.fn().mockRejectedValue(new Error('Orca API error')), + }; + (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrcaInstance); + + const response = await server.inject({ + method: 'GET', + url: '/fetch-pools?network=mainnet-beta', }); - it('should handle service unavailable', async () => { - (Orca.getInstance as jest.Mock).mockResolvedValue(null); + expect(response.statusCode).toBe(500); + expect(JSON.parse(response.body)).toHaveProperty('error'); + }); - const response = await app.inject({ - method: 'GET', - url: '/fetch-pools', - query: { - network: 'mainnet-beta', - }, - }); + it('should handle empty pool results', async () => { + const mockOrcaInstance = { + fetchPoolsFromApi: jest.fn().mockResolvedValue([]), + }; + (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrcaInstance); - expect(response.statusCode).toBeGreaterThanOrEqual(400); + const response = await server.inject({ + method: 'GET', + url: '/fetch-pools?network=mainnet-beta&query=NONEXISTENT', }); - it('should handle invalid network', async () => { - const mockOrca = { - getPools: jest.fn().mockRejectedValue(new Error('Invalid network')), - }; - const mockSolana = { - getToken: jest.fn().mockResolvedValue({ symbol: 'SOL' }), - }; - (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrca); - (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana); - - const response = await app.inject({ - method: 'GET', - url: '/fetch-pools', - query: { - network: 'invalid-network', - }, - }); - - expect(response.statusCode).toBeGreaterThanOrEqual(400); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.pools).toHaveLength(0); + expect(body.total).toBe(0); + }); + + it('should pass verifiedOnly parameter to API', async () => { + const mockOrcaInstance = { + fetchPoolsFromApi: jest.fn().mockResolvedValue([mockOrcaApiResponse[0]]), + }; + (Orca.getInstance as jest.Mock).mockResolvedValue(mockOrcaInstance); + + const response = await server.inject({ + method: 'GET', + url: '/fetch-pools?network=mainnet-beta&verifiedOnly=true', }); + + expect(response.statusCode).toBe(200); + expect(mockOrcaInstance.fetchPoolsFromApi).toHaveBeenCalledWith( + expect.objectContaining({ + verifiedOnly: true, + }), + ); }); }); From 2d56d282b203e20d912408f406fea662e4403c84 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 28 Apr 2026 20:15:58 -0700 Subject: [PATCH 3/3] fix: update Orca getPools test to match new method signature Co-Authored-By: Claude Opus 4.5 --- test/connectors/orca/orca.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/connectors/orca/orca.test.ts b/test/connectors/orca/orca.test.ts index f3bffbae23..bb1428daa8 100644 --- a/test/connectors/orca/orca.test.ts +++ b/test/connectors/orca/orca.test.ts @@ -230,7 +230,7 @@ describe('Orca', () => { json: async () => mockApiResponse, } as any); - const pools = await orcaInstance.getPools(10, 'SOL', 'USDC'); + const pools = await orcaInstance.getPools({ limit: 10, query: 'SOL USDC' }); expect(pools).toHaveLength(1); expect(pools[0]).toEqual({ @@ -267,7 +267,7 @@ describe('Orca', () => { json: async () => ({ data: [] }), } as any); - await orcaInstance.getPools(5, 'SOL', 'USDC'); + await orcaInstance.getPools({ limit: 5, query: 'SOL USDC' }); expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('q=SOL+USDC')); expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('size=5'));