From 21578e1a131ece98563998857edb5b67ed291c6d Mon Sep 17 00:00:00 2001 From: defiQUG Date: Mon, 11 May 2026 12:55:07 -0700 Subject: [PATCH] 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 --- .../src/api/routes/report.test.ts | 80 +++++++++- .../src/api/routes/report.ts | 139 ++++++++++++++++++ .../src/api/routes/tokens.test.ts | 41 ++++++ .../src/api/routes/tokens.ts | 50 ++++++- 4 files changed, 307 insertions(+), 3 deletions(-) diff --git a/services/token-aggregation/src/api/routes/report.test.ts b/services/token-aggregation/src/api/routes/report.test.ts index a74fd21..fcd883e 100644 --- a/services/token-aggregation/src/api/routes/report.test.ts +++ b/services/token-aggregation/src/api/routes/report.test.ts @@ -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; + + 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; + + 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`); diff --git a/services/token-aggregation/src/api/routes/report.ts b/services/token-aggregation/src/api/routes/report.ts index 1e3b9f7..9ae1c94 100644 --- a/services/token-aggregation/src/api/routes/report.ts +++ b/services/token-aggregation/src/api/routes/report.ts @@ -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. */ diff --git a/services/token-aggregation/src/api/routes/tokens.test.ts b/services/token-aggregation/src/api/routes/tokens.test.ts index 973efac..ca09d02 100644 --- a/services/token-aggregation/src/api/routes/tokens.test.ts +++ b/services/token-aggregation/src/api/routes/tokens.test.ts @@ -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; + 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(); diff --git a/services/token-aggregation/src/api/routes/tokens.ts b/services/token-aggregation/src/api/routes/tokens.ts index 11454b4..941dce1 100644 --- a/services/token-aggregation/src/api/routes/tokens.ts +++ b/services/token-aggregation/src/api/routes/tokens.ts @@ -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( + 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 = { ...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: {