Files
smom-dbis-138/services/token-aggregation/src/api/routes/tokens.ts

531 lines
17 KiB
TypeScript

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<ReturnType<MarketDataRepository['getMarketData']>>,
external: { coingecko?: Awaited<ReturnType<CoinGeckoAdapter['getMarketData']>>; cmc?: Awaited<ReturnType<CoinMarketCapAdapter['getMarketData']>>; dexscreener?: Awaited<ReturnType<DexScreenerAdapter['getMarketData']>> } | 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<LiquidityPool[]> {
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<Token | null> {
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<string, unknown> = {
...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;