import { Router, Request, Response } from 'express'; import { TokenRepository, Token } from '../../database/repositories/token-repo'; import { MarketDataRepository } from '../../database/repositories/market-data-repo'; import { PoolRepository, LiquidityPool } from '../../database/repositories/pool-repo'; import { OHLCVGenerator } from '../../indexer/ohlcv-generator'; import { CoinGeckoAdapter } from '../../adapters/coingecko-adapter'; import { CoinMarketCapAdapter } from '../../adapters/cmc-adapter'; import { DexScreenerAdapter } from '../../adapters/dexscreener-adapter'; import { cacheMiddleware } from '../middleware/cache'; import { resolvePoolTokenDisplays, resolveTokenDisplay } from '../../services/token-display'; import { logger } from '../../utils/logger'; import { filterPoolsForExposure, shouldExposePublicPool } from '../../config/gru-transport'; import { getCanonicalTokenByAddress, getCanonicalTokensByChain, resolveCanonicalQuoteAddress, } from '../../config/canonical-tokens'; import { getLiveDodoPools } from '../../services/live-dodo-fallback'; import { buildExplorerLinks, mergeMarketWithValuation, resolveUsdValuation, } from '../../services/valuation-precedence'; const router: Router = Router(); const tokenRepo = new TokenRepository(); const marketDataRepo = new MarketDataRepository(); const poolRepo = new PoolRepository(); const ohlcvGenerator = new OHLCVGenerator(); const coingeckoAdapter = new CoinGeckoAdapter(); const cmcAdapter = new CoinMarketCapAdapter(); const dexscreenerAdapter = new DexScreenerAdapter(); function buildMarketPricingExplorer( chainId: number, displayAddress: string, lookupAddress: string, marketData: Awaited>, external: { coingecko?: Awaited>; cmc?: Awaited>; dexscreener?: Awaited> } | null ) { const pricing = resolveUsdValuation({ chainId, normalizedAddress: lookupAddress.toLowerCase(), indexer: marketData, coingecko: external?.coingecko ?? undefined, cmc: external?.cmc ?? undefined, dexscreener: external?.dexscreener ?? undefined, }); const market = mergeMarketWithValuation(chainId, displayAddress.toLowerCase(), marketData, pricing); const explorer = buildExplorerLinks(chainId, displayAddress); return { market, pricing, explorer }; } function tokenFromCanonical(chainId: number, address: string): Token | null { const spec = getCanonicalTokenByAddress(chainId, address.toLowerCase()); if (!spec) { return null; } return { chainId, address: address.toLowerCase(), name: spec.name, symbol: spec.symbol, decimals: spec.decimals, verified: true, }; } async function getPoolsByTokenWithFallback(chainId: number, address: string): Promise { const normalized = address.toLowerCase(); const resolution = resolveCanonicalQuoteAddress(chainId, normalized); let dbPools: LiquidityPool[] = []; try { dbPools = filterPoolsForExposure( chainId, await poolRepo.getPoolsByToken(chainId, resolution.lookupAddress) ); } catch (error) { logger.warn('DB pool lookup failed; using live DODO fallback', { chainId, address: resolution.lookupAddress, error }); } if (dbPools.length > 0) { return dbPools; } const livePools = filterPoolsForExposure(chainId, await getLiveDodoPools(chainId)); return livePools.filter( (pool) => pool.token0Address === resolution.lookupAddress || pool.token1Address === resolution.lookupAddress || pool.token0Address === normalized || pool.token1Address === normalized ); } async function getTokenWithFallback(chainId: number, address: string): Promise { const normalized = address.toLowerCase(); const token = await tokenRepo.getToken(chainId, normalized); if (token) { return token; } const canonical = tokenFromCanonical(chainId, normalized); if (canonical) { return canonical; } const resolution = resolveCanonicalQuoteAddress(chainId, normalized); const livePools = await getLiveDodoPools(chainId); const liveAddress = livePools.find((pool) => pool.token0Address === resolution.lookupAddress || pool.token1Address === resolution.lookupAddress) ? resolution.lookupAddress : null; if (!liveAddress) { return null; } const display = await resolveTokenDisplay(tokenRepo, chainId, liveAddress); return { chainId, address: normalized, name: display.name, symbol: display.symbol, decimals: display.decimals, verified: display.source !== 'fallback', }; } async function getTokensWithFallback( chainId: number, limit: number, offset: number ): Promise<{ tokens: Token[]; source: 'db' | 'live-dodo' | 'canonical' }> { const dbTokens = await tokenRepo.getTokens(chainId, limit, offset); if (dbTokens.length > 0) { return { tokens: dbTokens, source: 'db' }; } const livePools = await getLiveDodoPools(chainId); if (livePools.length > 0) { const tokenAddresses = [...new Set(livePools.flatMap((pool) => [pool.token0Address, pool.token1Address]))]; const liveTokens = await Promise.all( tokenAddresses.map(async (address) => { const display = await resolveTokenDisplay(tokenRepo, chainId, address); return { chainId, address: display.address, name: display.name, symbol: display.symbol, decimals: display.decimals, verified: display.source !== 'fallback', } as Token; }) ); const sorted = liveTokens.sort((a, b) => `${a.symbol || ''}${a.address}`.localeCompare(`${b.symbol || ''}${b.address}`) ); return { tokens: sorted.slice(offset, offset + limit), source: 'live-dodo' }; } const canonicalTokens = getCanonicalTokensByChain(chainId) .map((spec) => ({ chainId, address: String(spec.addresses[chainId]).toLowerCase(), name: spec.name, symbol: spec.symbol, decimals: spec.decimals, verified: true, }) as Token) .sort((a, b) => `${a.symbol || ''}${a.address}`.localeCompare(`${b.symbol || ''}${b.address}`)); return { tokens: canonicalTokens.slice(offset, offset + limit), source: 'canonical' }; } router.get('/chains', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => { try { res.json({ chains: [ { chainId: 138, name: 'DeFi Oracle Meta Mainnet', explorerUrl: 'https://explorer.d-bis.org', }, { chainId: 651940, name: 'ALL Mainnet', explorerUrl: 'https://alltra.global', }, ], }); } catch (error) { res.status(500).json({ error: 'Internal server error' }); } }); router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => { try { const chainId = parseInt(req.query.chainId as string, 10); const limit = parseInt(req.query.limit as string, 10) || 50; const offset = parseInt(req.query.offset as string, 10) || 0; const includeDodoPool = (req.query.includeDodoPool as string) === '1' || (req.query.includeDodoPool as string) === 'true'; if (!chainId) { return res.status(400).json({ error: 'chainId is required' }); } const { tokens, source } = await getTokensWithFallback(chainId, limit, offset); const tokensWithMarketData = await Promise.all( tokens.map(async (token) => { const resolution = resolveCanonicalQuoteAddress(chainId, token.address); const marketData = await marketDataRepo.getMarketData(chainId, resolution.lookupAddress); const { market, pricing, explorer } = buildMarketPricingExplorer( chainId, token.address, resolution.lookupAddress, marketData, null ); const out: Record = { ...token, market: market || undefined, pricing, explorer, }; if (includeDodoPool) { const pools = await getPoolsByTokenWithFallback(chainId, token.address); const dodoPool = pools.find((p) => (p.dexType || '').toLowerCase() === 'dodo'); out.hasDodoPool = !!dodoPool; out.pmmPool = dodoPool?.poolAddress || undefined; } return out; }) ); res.json({ tokens: tokensWithMarketData, pagination: { limit, offset, count: tokensWithMarketData.length, }, source, }); } catch (error) { logger.error('Error fetching tokens:', error); res.status(500).json({ error: 'Internal server error' }); } }); router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => { try { const chainId = parseInt(req.query.chainId as string, 10); const address = req.params.address; if (!chainId) { return res.status(400).json({ error: 'chainId is required' }); } const normalizedAddress = address.toLowerCase(); const resolution = resolveCanonicalQuoteAddress(chainId, normalizedAddress); const token = await getTokenWithFallback(chainId, normalizedAddress); if (!token) { return res.status(404).json({ error: 'Token not found' }); } const [ marketDataRaw, pools, coingeckoData, cmcData, dexscreenerData, coingeckoMarket, cmcMarket, dexscreenerMarket, ] = await Promise.all([ marketDataRepo.getMarketData(chainId, resolution.lookupAddress), getPoolsByTokenWithFallback(chainId, normalizedAddress), coingeckoAdapter.getTokenByContract(chainId, resolution.lookupAddress), cmcAdapter.getTokenByContract(chainId, resolution.lookupAddress), dexscreenerAdapter.getTokenByContract(chainId, resolution.lookupAddress), coingeckoAdapter.getMarketData(chainId, resolution.lookupAddress), cmcAdapter.getMarketData(chainId, resolution.lookupAddress), dexscreenerAdapter.getMarketData(chainId, resolution.lookupAddress), ]); const { market: marketData, pricing, explorer } = buildMarketPricingExplorer( chainId, normalizedAddress, resolution.lookupAddress, marketDataRaw, { coingecko: coingeckoMarket, cmc: cmcMarket, dexscreener: dexscreenerMarket } ); res.json({ token: { ...token, onChain: { totalSupply: token.totalSupply, }, market: marketData || undefined, pricing, explorer, external: { coingecko: coingeckoData || undefined, cmc: cmcData || undefined, dexscreener: dexscreenerData || undefined, }, pools: pools.map((pool) => ({ address: pool.poolAddress, dex: pool.dexType, token0: pool.token0Address, token1: pool.token1Address, reserves: { token0: pool.reserve0, token1: pool.reserve1, }, tvl: pool.totalLiquidityUsd, volume24h: pool.volume24h, })), hasDodoPool: pools.some((p) => (p.dexType || '').toLowerCase() === 'dodo'), pmmPool: pools.find((p) => (p.dexType || '').toLowerCase() === 'dodo')?.poolAddress || undefined, canonicalLiquidity: resolution.usedFallback ? { requestedAddress: normalizedAddress, lookupAddress: resolution.lookupAddress, requestedSymbol: resolution.requestedSymbol, lookupSymbol: resolution.lookupSymbol, } : undefined, }, }); } catch (error) { logger.error('Error fetching token:', error); res.status(500).json({ error: 'Internal server error' }); } }); router.get('/tokens/:address/pools', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => { try { const chainId = parseInt(req.query.chainId as string, 10); const address = req.params.address; if (!chainId) { return res.status(400).json({ error: 'chainId is required' }); } let pools: LiquidityPool[]; try { pools = await getPoolsByTokenWithFallback(chainId, address); } catch (error) { logger.error('Error resolving pools list:', error); pools = []; } const settled = await Promise.allSettled( pools.map(async (pool) => { const { token0, token1 } = await resolvePoolTokenDisplays(tokenRepo, chainId, pool.token0Address, pool.token1Address); return { address: pool.poolAddress, dex: String(pool.dexType ?? ''), token0: { address: pool.token0Address, symbol: token0.symbol, name: token0.name, source: token0.source, }, token1: { address: pool.token1Address, symbol: token1.symbol, name: token1.name, source: token1.source, }, reserves: { token0: pool.reserve0, token1: pool.reserve1, }, tvl: pool.totalLiquidityUsd, volume24h: pool.volume24h, feeTier: pool.feeTier, }; }) ); const poolsOut = []; for (const row of settled) { if (row.status === 'fulfilled') { poolsOut.push(row.value); } else { logger.warn('Skipping pool row in /tokens/:address/pools:', row.reason); } } // BigInt (e.g. from live RPC paths) breaks res.json; stringify replacer keeps Mission Control / E2E stable. const payload = JSON.parse( JSON.stringify({ pools: poolsOut }, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)) ) as { pools: typeof poolsOut }; res.json(payload); } catch (error) { logger.error('Error fetching pools:', error); res.status(500).json({ error: 'Internal server error' }); } }); router.get('/tokens/:address/ohlcv', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => { try { const chainId = parseInt(req.query.chainId as string, 10); const address = req.params.address; const interval = (req.query.interval as string) || '1h'; const from = req.query.from ? new Date(req.query.from as string) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const to = req.query.to ? new Date(req.query.to as string) : new Date(); const poolAddress = req.query.poolAddress as string | undefined; if (!chainId) { return res.status(400).json({ error: 'chainId is required' }); } if (!['5m', '15m', '1h', '4h', '24h'].includes(interval)) { return res.status(400).json({ error: 'Invalid interval. Must be one of: 5m, 15m, 1h, 4h, 24h' }); } const ohlcv = await ohlcvGenerator.getOHLCV( chainId, address, interval as '5m' | '15m' | '1h' | '4h' | '24h', from, to, poolAddress ); res.json({ chainId, tokenAddress: address, interval, data: ohlcv, }); } catch (error) { logger.error('Error fetching OHLCV:', error); res.status(500).json({ error: 'Internal server error' }); } }); router.get('/tokens/:address/signals', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => { try { const chainId = parseInt(req.query.chainId as string, 10); const address = req.params.address; if (!chainId) { return res.status(400).json({ error: 'chainId is required' }); } const trending = await coingeckoAdapter.getTrending(); res.json({ chainId, tokenAddress: address, signals: { trendingRank: trending.findIndex((t) => t.symbol.toLowerCase() === address.toLowerCase()), }, }); } catch (error) { logger.error('Error fetching signals:', error); res.status(500).json({ error: 'Internal server error' }); } }); router.get('/search', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => { try { const chainId = parseInt(req.query.chainId as string, 10); const query = req.query.q as string; if (!chainId || !query) { return res.status(400).json({ error: 'chainId and q (query) are required' }); } const tokens = await tokenRepo.searchTokens(chainId, query, 20); res.json({ query, chainId, results: tokens, }); } catch (error) { logger.error('Error searching tokens:', error); res.status(500).json({ error: 'Internal server error' }); } }); router.get('/pools/:poolAddress', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => { try { const chainId = parseInt(req.query.chainId as string, 10); const poolAddress = req.params.poolAddress; if (!chainId) { return res.status(400).json({ error: 'chainId is required' }); } const pool = await poolRepo.getPool(chainId, poolAddress); if ( !pool || !shouldExposePublicPool(chainId, pool.poolAddress, pool.token0Address, pool.token1Address) ) { return res.status(404).json({ error: 'Pool not found' }); } res.json({ pool: { address: pool.poolAddress, dex: pool.dexType, token0: { address: pool.token0Address, }, token1: { address: pool.token1Address, }, reserves: { token0: pool.reserve0, token1: pool.reserve1, }, tvl: pool.totalLiquidityUsd, volume24h: pool.volume24h, feeTier: pool.feeTier, }, }); } catch (error) { logger.error('Error fetching pool:', error); res.status(500).json({ error: 'Internal server error' }); } }); export default router;