531 lines
17 KiB
TypeScript
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;
|