fix(token-aggregation): normalize inflated liquidityUsd; token-price report
- Apply decimal-aware liquidity normalization on /tokens and reports - Add GET /report/token-price/:symbol compact evidence snapshot - Extend route tests for normalization and token-price endpoint Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,7 +15,20 @@ jest.mock('../../database/repositories/token-repo', () => ({
|
||||
}));
|
||||
jest.mock('../../database/repositories/market-data-repo', () => ({
|
||||
MarketDataRepository: jest.fn().mockImplementation(() => ({
|
||||
getMarketData: jest.fn().mockResolvedValue(null),
|
||||
getMarketData: jest.fn().mockImplementation(async (chainId: number, address: string) => {
|
||||
if (chainId === 138 && String(address).toLowerCase() === '0xf22258f57794cc8e06237084b353ab30fffa640b') {
|
||||
return {
|
||||
priceUsd: 1,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
marketCapUsd: undefined,
|
||||
liquidityUsd: 5180095723066127,
|
||||
lastUpdated: new Date('2026-05-10T01:43:11.733Z'),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
jest.mock('../../database/repositories/pool-repo', () => ({
|
||||
@@ -180,6 +193,71 @@ describe('Report API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/token-price/:symbol', () => {
|
||||
it('returns a compact cWUSDC price evidence packet for reviewers', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/token-price/cWUSDC?chainId=1`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
|
||||
expect(body).toMatchObject({
|
||||
schema: 'dbis-token-price-evidence/v1',
|
||||
chainId: 1,
|
||||
token: {
|
||||
address: '0x2de5f116bfce3d0f922d9c8351e0c5fc24b9284a',
|
||||
symbol: 'cWUSDC',
|
||||
decimals: 6,
|
||||
},
|
||||
price: {
|
||||
usd: 1,
|
||||
source: 'token-aggregation',
|
||||
},
|
||||
supply: {
|
||||
totalSupply: 10451316981.309788,
|
||||
totalSupplyRaw: '10451316981309788',
|
||||
circulatingSupply: 10451316981.309788,
|
||||
proof: expect.objectContaining({
|
||||
schema: 'mainnet-cwusdc-supply-proof/v1',
|
||||
}),
|
||||
},
|
||||
valuation: {
|
||||
marketCapUsd: 10451316981.309788,
|
||||
},
|
||||
submissionLinks: {
|
||||
coingeckoReport: expect.stringContaining('/api/v1/report/coingecko?chainId=1'),
|
||||
cmcReport: expect.stringContaining('/api/v1/report/cmc?chainId=1'),
|
||||
tokenList: expect.stringContaining('/api/v1/report/token-list?chainId=1'),
|
||||
etherscan: 'https://etherscan.io/token/0x2de5f116bfce3d0f922d9c8351e0c5fc24b9284a',
|
||||
},
|
||||
});
|
||||
expect(body.price.caveat).toContain('Etherscan USD Value');
|
||||
expect(Array.isArray(body.pools)).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes raw 6-decimal Chain 138 liquidity in compact price evidence', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/token-price/cUSDC?chainId=138`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
|
||||
expect(body).toMatchObject({
|
||||
chainId: 138,
|
||||
token: {
|
||||
symbol: 'cUSDC',
|
||||
decimals: 6,
|
||||
},
|
||||
price: {
|
||||
usd: 1,
|
||||
},
|
||||
});
|
||||
expect(body.valuation.liquidityUsd).toBeLessThan(38601011267);
|
||||
expect(body.valuation.liquidityUsd).toBeCloseTo(5180095723.066127, 6);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown token symbol', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/token-price/NOPE?chainId=1`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/all', () => {
|
||||
it('includes GRU transport summary for operator visibility', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/all?chainId=138`);
|
||||
|
||||
@@ -363,6 +363,39 @@ function buildSupplyProofEnrichment(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePossiblyRawLiquidityUsd(
|
||||
liquidityUsd: number | undefined,
|
||||
decimals: number,
|
||||
priceUsd: number | undefined,
|
||||
totalSupplyUnits: string | undefined
|
||||
): number | undefined {
|
||||
if (liquidityUsd === undefined || !Number.isFinite(liquidityUsd) || liquidityUsd <= 0) return liquidityUsd;
|
||||
if (priceUsd === undefined || !Number.isFinite(priceUsd) || priceUsd <= 0) return liquidityUsd;
|
||||
if (!totalSupplyUnits) {
|
||||
if (decimals === 6 && liquidityUsd >= 1_000_000_000_000 && priceUsd <= 10) {
|
||||
return liquidityUsd / 10 ** decimals;
|
||||
}
|
||||
return liquidityUsd;
|
||||
}
|
||||
|
||||
const supplyUnits = Number(totalSupplyUnits);
|
||||
if (!Number.isFinite(supplyUnits) || supplyUnits <= 0) return liquidityUsd;
|
||||
|
||||
const normalizedSupplyValue = supplyUnits * priceUsd;
|
||||
const divisor = 10 ** decimals;
|
||||
const decimalAdjustedLiquidity = liquidityUsd / divisor;
|
||||
|
||||
if (
|
||||
liquidityUsd > normalizedSupplyValue &&
|
||||
decimalAdjustedLiquidity > 0 &&
|
||||
decimalAdjustedLiquidity <= normalizedSupplyValue * 1.25
|
||||
) {
|
||||
return decimalAdjustedLiquidity;
|
||||
}
|
||||
|
||||
return liquidityUsd;
|
||||
}
|
||||
|
||||
function resolveGruV2ReserveRpcUrl(chainId: number): string {
|
||||
const configured = resolvePmmQuoteRpcUrl();
|
||||
if (chainId === 138 && process.env.NODE_ENV !== 'test') return configured || 'http://192.168.11.211:8545';
|
||||
@@ -650,6 +683,11 @@ async function buildTokenReport(chainId: number) {
|
||||
if (supplyProof?.marketCapUsd !== undefined && market) {
|
||||
market.marketCapUsd = supplyProof.marketCapUsd;
|
||||
}
|
||||
if (market) {
|
||||
market.liquidityUsd =
|
||||
normalizePossiblyRawLiquidityUsd(market.liquidityUsd, spec.decimals, market.priceUsd, supplyProof?.totalSupply) ??
|
||||
market.liquidityUsd;
|
||||
}
|
||||
|
||||
const dbPoolEntries: ReportPoolEntry[] = resolvedPools.map((p) => ({
|
||||
poolAddress: p.poolAddress,
|
||||
@@ -1249,6 +1287,107 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
/** GET /report/token-price/:symbol — compact reviewer-facing price and evidence snapshot. */
|
||||
router.get(
|
||||
'/token-price/:symbol',
|
||||
cacheMiddleware(60 * 1000),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const symbol = String(req.params.symbol || '').trim();
|
||||
const chainId = parseInt(req.query.chainId as string, 10) || 1;
|
||||
const tokens = await buildTokenReport(chainId);
|
||||
const token = tokens.find((entry) => entry.symbol.toLowerCase() === symbol.toLowerCase());
|
||||
|
||||
if (!token) {
|
||||
res.status(404).json({
|
||||
error: 'Token not found',
|
||||
symbol,
|
||||
chainId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const poolLiquidityUsd = token.pools.reduce((sum, pool) => sum + (pool.tvl || 0), 0);
|
||||
const marketLiquidityUsd = token.market?.liquidityUsd ?? 0;
|
||||
const liquidityUsd = marketLiquidityUsd > 0 ? marketLiquidityUsd : poolLiquidityUsd;
|
||||
const priceUsd = token.market?.priceUsd;
|
||||
const circulatingSupply = token.circulatingSupply ? Number(token.circulatingSupply) : undefined;
|
||||
const totalSupply = token.totalSupply ? Number(token.totalSupply) : undefined;
|
||||
const marketCapUsd =
|
||||
token.market?.marketCapUsd ??
|
||||
(priceUsd !== undefined && circulatingSupply !== undefined ? priceUsd * circulatingSupply : undefined);
|
||||
|
||||
res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
schema: 'dbis-token-price-evidence/v1',
|
||||
chainId,
|
||||
token: {
|
||||
address: token.address,
|
||||
symbol: token.symbol,
|
||||
name: token.name,
|
||||
decimals: token.decimals,
|
||||
type: token.type,
|
||||
registryFamily: token.registryFamily,
|
||||
logoURI: token.logoURI,
|
||||
},
|
||||
price: {
|
||||
usd: priceUsd,
|
||||
source: token.market ? 'token-aggregation' : priceUsd !== undefined ? 'canonical-fallback' : 'unavailable',
|
||||
lastUpdated: token.market?.lastUpdated,
|
||||
caveat:
|
||||
chainId === 1 && token.symbol === 'cWUSDC'
|
||||
? 'This is DBIS tracker-submission evidence. Etherscan USD Value appears only after Etherscan/CoinGecko/Dex indexers accept a public price source.'
|
||||
: undefined,
|
||||
},
|
||||
supply: {
|
||||
totalSupply,
|
||||
totalSupplyRaw: token.totalSupplyRaw,
|
||||
circulatingSupply,
|
||||
circulatingSupplyFormula: token.circulatingSupplyFormula,
|
||||
proof: token.supplyProofProvenance,
|
||||
caveats: token.trackerCaveats ?? [],
|
||||
},
|
||||
valuation: {
|
||||
marketCapUsd,
|
||||
liquidityUsd,
|
||||
volume24hUsd: token.market?.volume24h ?? 0,
|
||||
},
|
||||
pools: token.pools.map((pool) => ({
|
||||
poolAddress: pool.poolAddress,
|
||||
dexId: pool.dex,
|
||||
tvlUsd: pool.tvl,
|
||||
volume24hUsd: pool.volume24h,
|
||||
source: pool.source,
|
||||
status: pool.status,
|
||||
statusReason: pool.statusReason,
|
||||
role: pool.role,
|
||||
publicRoutingEnabled: pool.publicRoutingEnabled,
|
||||
token0: {
|
||||
address: pool.token0,
|
||||
symbol: pool.token0Symbol,
|
||||
},
|
||||
token1: {
|
||||
address: pool.token1,
|
||||
symbol: pool.token1Symbol,
|
||||
},
|
||||
})),
|
||||
submissionLinks: {
|
||||
coingeckoReport: `${resolvePublicBaseUrl(req)}/api/v1/report/coingecko?chainId=${chainId}`,
|
||||
cmcReport: `${resolvePublicBaseUrl(req)}/api/v1/report/cmc?chainId=${chainId}`,
|
||||
tokenList: `${resolvePublicBaseUrl(req)}/api/v1/report/token-list?chainId=${chainId}`,
|
||||
etherscan:
|
||||
chainId === 1
|
||||
? `https://etherscan.io/token/${token.address}`
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error building report/token-price:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/** GET /report/token-list — flat list of all canonical tokens (Uniswap token list format with logoURI).
|
||||
* If TOKEN_LIST_JSON_URL is set (e.g. GitHub raw URL), fetches and returns that JSON; optional ?chainId= filters tokens.
|
||||
*/
|
||||
|
||||
@@ -222,6 +222,47 @@ describe('Tokens API', () => {
|
||||
expect(body.token.canonicalLiquidity).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes raw 6-decimal liquidity rows on token detail responses', async () => {
|
||||
const cusdc = getCanonicalTokenBySymbol(138, 'cUSDC');
|
||||
expect(cusdc?.addresses[138]).toBeTruthy();
|
||||
const cusdcAddress = String(cusdc?.addresses[138]).toLowerCase();
|
||||
|
||||
mockGetToken.mockResolvedValue({
|
||||
chainId: 138,
|
||||
address: cusdcAddress,
|
||||
name: 'USD Coin (Compliant)',
|
||||
symbol: 'cUSDC',
|
||||
decimals: 6,
|
||||
totalSupply: '38601011267000000',
|
||||
verified: true,
|
||||
});
|
||||
mockGetMarketData.mockResolvedValue({
|
||||
chainId: 138,
|
||||
tokenAddress: cusdcAddress,
|
||||
priceUsd: 1,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 26890.7,
|
||||
liquidityUsd: 5180095723066127,
|
||||
holdersCount: 0,
|
||||
transfers24h: 0,
|
||||
lastUpdated: new Date('2026-05-10T01:43:11.733Z'),
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/tokens/${cusdcAddress}?chainId=138`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.token).toMatchObject({
|
||||
symbol: 'cUSDC',
|
||||
decimals: 6,
|
||||
market: expect.objectContaining({
|
||||
priceUsd: 1,
|
||||
liquidityUsd: 5180095723.066127,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns historical price snapshots for a token at a requested timestamp', async () => {
|
||||
const weth = getCanonicalTokenBySymbol(138, 'WETH');
|
||||
expect(weth?.addresses[138]).toBeTruthy();
|
||||
|
||||
@@ -106,6 +106,52 @@ function buildMarketPricingExplorer(
|
||||
return { market, pricing, explorer };
|
||||
}
|
||||
|
||||
function decimalStringToNumber(value?: string, decimals?: number): number | null {
|
||||
if (!value || decimals === undefined || decimals < 0) return null;
|
||||
try {
|
||||
const raw = BigInt(value);
|
||||
if (raw <= 0n) return 0;
|
||||
const scale = 10n ** BigInt(decimals);
|
||||
const whole = raw / scale;
|
||||
const fraction = raw % scale;
|
||||
const normalized = Number(whole) + Number(fraction) / Number(scale);
|
||||
return Number.isFinite(normalized) ? normalized : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePossiblyRawLiquidityUsd<T extends { liquidityUsd: number; priceUsd?: number }>(
|
||||
market: T | null,
|
||||
token: Token
|
||||
): T | null {
|
||||
if (!market || !(market.liquidityUsd > 0)) return market;
|
||||
const decimals = Number(token.decimals);
|
||||
if (!Number.isInteger(decimals) || decimals <= 0) return market;
|
||||
|
||||
const supplyUnits = decimalStringToNumber(token.totalSupply, decimals);
|
||||
const priceUsd = market.priceUsd && market.priceUsd > 0 ? market.priceUsd : 1;
|
||||
if (supplyUnits === null || supplyUnits <= 0) return market;
|
||||
|
||||
const plausibleSupplyValue = supplyUnits * priceUsd;
|
||||
const scale = 10 ** decimals;
|
||||
const normalizedLiquidity = market.liquidityUsd / scale;
|
||||
|
||||
if (
|
||||
Number.isFinite(plausibleSupplyValue) &&
|
||||
Number.isFinite(normalizedLiquidity) &&
|
||||
market.liquidityUsd > plausibleSupplyValue &&
|
||||
normalizedLiquidity <= plausibleSupplyValue
|
||||
) {
|
||||
return {
|
||||
...market,
|
||||
liquidityUsd: normalizedLiquidity,
|
||||
};
|
||||
}
|
||||
|
||||
return market;
|
||||
}
|
||||
|
||||
function tokenFromCanonical(chainId: number, address: string): Token | null {
|
||||
const spec = getCanonicalTokenByAddress(chainId, address.toLowerCase());
|
||||
if (!spec) {
|
||||
@@ -456,7 +502,7 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp
|
||||
);
|
||||
const out: Record<string, unknown> = {
|
||||
...token,
|
||||
market: market || undefined,
|
||||
market: normalizePossiblyRawLiquidityUsd(market, token) || undefined,
|
||||
pricing,
|
||||
explorer,
|
||||
};
|
||||
@@ -534,7 +580,7 @@ router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request,
|
||||
onChain: {
|
||||
totalSupply: token.totalSupply,
|
||||
},
|
||||
market: marketData || undefined,
|
||||
market: normalizePossiblyRawLiquidityUsd(marketData, token) || undefined,
|
||||
pricing,
|
||||
explorer,
|
||||
external: {
|
||||
|
||||
Reference in New Issue
Block a user