From 9673e55f233fcb1a46c999317445f7043574916c Mon Sep 17 00:00:00 2001 From: dcccrypto Date: Thu, 3 Sep 2026 09:56:01 +0100 Subject: [PATCH] fix(#2324,#2246): guard bigint->float conversions and route price->E6 through one helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues, one underlying problem: arithmetic done inline at the render boundary instead of behind a helper that can be given a rule. #2324 — Number(someBigint) loses precision above MAX_SAFE_INTEGER and says nothing. It returns a plausible, wrong number. For 6-decimal USDC the ceiling is ~9 billion tokens; for a 9-decimal mint it is ~9 million, which is not a comfortable margin. `bigintToFloat` returns NULL rather than a wrong figure, so callers render the placeholder they already render for missing data. Returning 0 would be worse than the bug: a wrong LARGE number at least looks suspicious, whereas a confident 0 reads as a real balance. Three details the naive guard would miss, each pinned by a test: * It checks the RAW base-unit magnitude, BEFORE dividing by the decimal scale. Dividing first hides the loss — (MAX+1)/1e6 is small and finite, but the precision is already gone. * It guards the NEGATIVE side. A large loss is as easy to hit as a large gain, and `raw > MAX` alone lets it through. * `bigintRatio` scales inside bigint arithmetic, so a huge numerator over a huge denominator still yields an exact small quotient — converting each side to a float first would lose it before the division. Migrated: PositionsDock PnL, LpPositionDashboard redeemable value, useLpPositions balance/redeemable/share. #2246 — the price->E6 conversion was inlined at 8 sites, so the one place that could grow a guard was eight places that could not. All now call the existing `toE6()`: priceStore x2, useCreateMarket x2, prices/[slab] x2, trader stats, leaderboard. Worth noting `toE6` THROWS on a non-finite input, which the inline form did too — but from eight different places with eight different stack traces. Consolidating does not change that behaviour; it makes it one traceable thing. Negative control: removing the two MAX_SAFE_INTEGER checks fails 3 of the 11 new tests. The other 8 pin that ordinary values still convert, so this cannot be satisfied by returning null for everything. Launch suite: 3149 passed / 16 skipped / 0 failed. Refs: dcccrypto/percolator-launch#2324, dcccrypto/percolator-launch#2246 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NgoNgagkvw7i5SSRC3FJ8D --- app/__tests__/lib/bigint-precision.test.ts | 85 +++++++++++++++++++++ app/app/api/leaderboard/route.ts | 3 +- app/app/api/prices/[slab]/route.ts | 5 +- app/app/api/trader/[wallet]/stats/route.ts | 3 +- app/components/earn/LpPositionDashboard.tsx | 5 +- app/components/trade/PositionsDock.tsx | 6 +- app/hooks/useCreateMarket.ts | 5 +- app/hooks/useLpPositions.ts | 19 +++-- app/lib/formatters.ts | 36 +++++++++ app/lib/priceStore/priceStore.ts | 5 +- 10 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 app/__tests__/lib/bigint-precision.test.ts diff --git a/app/__tests__/lib/bigint-precision.test.ts b/app/__tests__/lib/bigint-precision.test.ts new file mode 100644 index 000000000..47252ff90 --- /dev/null +++ b/app/__tests__/lib/bigint-precision.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { bigintToFloat, bigintRatio } from "@/lib/formatters"; +import { toE6 } from "@/lib/format"; + +/** + * GH#2324 — `Number(someBigint)` loses precision above `Number.MAX_SAFE_INTEGER` + * and says nothing about it: it returns a plausible, wrong number. + * + * GH#2246 — the `price -> E6` conversion was inlined rather than shared, so the + * one place that could grow a guard was five places that could not. + * + * The two are the same underlying problem — arithmetic done inline at the render + * boundary instead of behind a helper that can be given a rule. + */ + +describe("bigintToFloat refuses silently-wrong conversions (GH#2324)", () => { + const MAX = BigInt(Number.MAX_SAFE_INTEGER); + + it("converts ordinary amounts", () => { + expect(bigintToFloat(1_500_000n, 6)).toBe(1.5); + expect(bigintToFloat(0n, 6)).toBe(0); + expect(bigintToFloat(-2_000_000n, 6)).toBe(-2); + }); + + it("returns null above MAX_SAFE_INTEGER instead of a wrong number", () => { + // The failure this exists for: Number() on this returns a real-looking float. + const tooBig = MAX + 1n; + expect(Number.isFinite(Number(tooBig))).toBe(true); // the trap + expect(bigintToFloat(tooBig, 6)).toBeNull(); // the guard + }); + + it("guards the NEGATIVE side too", () => { + // A large loss is as easy to hit as a large gain, and `raw < 0` would slip + // past a naive `raw > MAX` check. + expect(bigintToFloat(-(MAX + 1n), 6)).toBeNull(); + }); + + it("checks the RAW magnitude, not the scaled one", () => { + // Dividing first would hide the loss: (MAX+1)/1e6 is small and finite, but + // the precision is already gone by then. + const tooBig = MAX + 1n; + expect(bigintToFloat(tooBig, 18)).toBeNull(); + }); + + it("accepts exactly MAX_SAFE_INTEGER — the boundary is inclusive", () => { + expect(bigintToFloat(MAX, 0)).toBe(Number.MAX_SAFE_INTEGER); + }); +}); + +describe("bigintRatio keeps large-over-large exact (GH#2324)", () => { + it("computes a small quotient from two huge operands", () => { + // Both sides far exceed MAX_SAFE_INTEGER; the quotient is 0.5. Converting + // each side to a float first would lose the precision before dividing. + const huge = BigInt("100000000000000000000000"); + expect(bigintRatio(huge, huge * 2n)).toBeCloseTo(0.5, 9); + }); + + it("returns null on a zero denominator rather than Infinity or NaN", () => { + expect(bigintRatio(1n, 0n)).toBeNull(); + }); + + it("handles an ordinary ratio", () => { + expect(bigintRatio(1n, 4n)).toBeCloseTo(0.25, 9); + }); +}); + +describe("toE6 is the one place price->E6 happens (GH#2246)", () => { + it("converts a price to E6 base units", () => { + expect(toE6(1)).toBe(1_000_000n); + expect(toE6(0.5)).toBe(500_000n); + expect(toE6(123.456789)).toBe(123_456_789n); + }); + + it("rounds rather than truncating", () => { + // 1.0000005 * 1e6 = 1000000.5 → rounds to 1000001, not 1000000. + expect(toE6(1.0000005)).toBe(1_000_001n); + }); + + it("throws on a non-finite input rather than producing garbage", () => { + // BigInt(Math.round(NaN)) throws RangeError — the inline form did this too, + // but from five different places with five different stack traces. + expect(() => toE6(NaN)).toThrow(); + expect(() => toE6(Infinity)).toThrow(); + }); +}); diff --git a/app/app/api/leaderboard/route.ts b/app/app/api/leaderboard/route.ts index a6e3e1e0f..7e1697efc 100644 --- a/app/app/api/leaderboard/route.ts +++ b/app/app/api/leaderboard/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { toE6 } from "@/lib/format"; import { hasIndexerDb, queryLeaderboard, @@ -123,7 +124,7 @@ export async function GET(request: Request) { try { const rawSize = BigInt(String(row.size).split(".")[0]); const absSize = rawSize < 0n ? -rawSize : rawSize; - const priceE6 = BigInt(Math.round((Number(row.price) || 0) * 1_000_000)); + const priceE6 = toE6(Number(row.price) || 0); entry.totalVolumeMicroUsd += (absSize * priceE6) / 1_000_000n; } catch { const size = Math.abs(parseFloat(String(row.size)) || 0); diff --git a/app/app/api/prices/[slab]/route.ts b/app/app/api/prices/[slab]/route.ts index 542c22a14..408daf42c 100644 --- a/app/app/api/prices/[slab]/route.ts +++ b/app/app/api/prices/[slab]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { validateSlabParam } from "@/lib/route-validators"; +import { toE6 } from "@/lib/format"; import { PLAYGROUND_SLAB_META } from "@/lib/playground-slab-meta"; import { boundedSet } from "@/lib/bounded-map"; import * as Sentry from "@sentry/nextjs"; @@ -81,7 +82,7 @@ async function pythStatsFallback(slab: string): Promise { return null; } - const toE6Str = (v: number) => BigInt(Math.round(v * 1_000_000)).toString(); + const toE6Str = (v: number) => toE6(v).toString(); const result: Stats24h = { change24h: ((last - first) / first) * 100, high24h: toE6Str(high), @@ -154,7 +155,7 @@ async function geckoTerminalStatsFallback(slab: string, origin: string): Promise return setCache(null); } - const toE6Str = (v: number) => BigInt(Math.round(v * 1_000_000)).toString(); + const toE6Str = (v: number) => toE6(v).toString(); return setCache({ change24h: ((last - first) / first) * 100, high24h: toE6Str(high), diff --git a/app/app/api/trader/[wallet]/stats/route.ts b/app/app/api/trader/[wallet]/stats/route.ts index bd66e9dba..4384871e7 100644 --- a/app/app/api/trader/[wallet]/stats/route.ts +++ b/app/app/api/trader/[wallet]/stats/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { PublicKey } from "@solana/web3.js"; import { getClientIp } from "@/lib/get-client-ip"; +import { toE6 } from "@/lib/format"; import { createUpstashRateLimiter } from "@/lib/upstash-rate-limit"; import { hasIndexerDb, queryTraderStatsAggregate } from "@/lib/indexer-db"; @@ -61,7 +62,7 @@ function aggregateRows(rows: { side: string; size: string; price: string; fee: s try { const rawSize = BigInt(String(row.size).split(".")[0]); const absSize = rawSize < 0n ? -rawSize : rawSize; - const priceE6 = BigInt(Math.round(Number(row.price) * 1_000_000)); + const priceE6 = toE6(Number(row.price)); totalVolume += (absSize * priceE6) / 1_000_000n; } catch { /* skip malformed */ } diff --git a/app/components/earn/LpPositionDashboard.tsx b/app/components/earn/LpPositionDashboard.tsx index afba13d1d..072772678 100644 --- a/app/components/earn/LpPositionDashboard.tsx +++ b/app/components/earn/LpPositionDashboard.tsx @@ -1,6 +1,7 @@ 'use client'; import { AnimatedNumber } from '@/components/ui/AnimatedNumber'; +import { bigintRatio } from "@/lib/formatters"; import { ShimmerSkeleton } from '@/components/ui/ShimmerSkeleton'; @@ -45,7 +46,9 @@ export function LpPositionDashboard({ const userRedeemableValue = lpSupply > 0n ? (userLpBalance * vaultBalance) / lpSupply : 0n; - const userRedeemableFloat = Number(userRedeemableValue) / Number(divisor); + // #2324: both sides can be large while the quotient is small, so scale inside + // bigint arithmetic rather than converting each side to a float first. + const userRedeemableFloat = bigintRatio(userRedeemableValue, divisor) ?? 0; if (loading) { return ( diff --git a/app/components/trade/PositionsDock.tsx b/app/components/trade/PositionsDock.tsx index 13b3eb7fc..d42916d9e 100644 --- a/app/components/trade/PositionsDock.tsx +++ b/app/components/trade/PositionsDock.tsx @@ -62,6 +62,7 @@ import { adlReductionTooltip, } from "@/lib/v17-adl"; import { isMockMode } from "@/lib/mock-mode"; +import { bigintToFloat } from "@/lib/formatters"; import { isMockSlab, getMockUserAccount } from "@/lib/mock-trade-data"; import { computeLiquidationDistancePct } from "@/lib/liquidation-distance"; import { ClosePositionModal } from "./ClosePositionModal"; @@ -259,7 +260,10 @@ const PositionRow: FC<{ slabAddress: string }> = memo(function PositionRow({ sla const pnlTokens = hasValidMark ? computeMarkPnlCollateral(pnlNative, currentPriceE6) : 0n; // sim-USDC is $1-pegged collateral (see PLAYGROUND.md) — the collateral // amount above already IS the dollar figure, just formatted differently. - const pnlUsdRaw = hasValidMark ? Number(pnlTokens) / 10 ** decimals : null; + // #2324: null rather than a silently-wrong figure above MAX_SAFE_INTEGER. + // The `: null` branch already exists for an invalid mark, so the render path + // downstream already handles it. + const pnlUsdRaw = hasValidMark ? bigintToFloat(pnlTokens, decimals) : null; const pnlUsd = pnlUsdRaw !== null && Number.isFinite(pnlUsdRaw) ? pnlUsdRaw : null; // The position's own locked initial margin (its entry notional at the // market's initial-margin requirement) — NOT total account capital — is diff --git a/app/hooks/useCreateMarket.ts b/app/hooks/useCreateMarket.ts index dd634e901..d57925aee 100644 --- a/app/hooks/useCreateMarket.ts +++ b/app/hooks/useCreateMarket.ts @@ -72,6 +72,7 @@ import { parseMarketGroupV17OI, } from "@percolatorct/sdk"; import { PERCOLATOR_NFT_PROGRAM_ID } from "@/lib/nft-program"; +import { toE6 } from "@/lib/format"; import { buildKeeperRegisterProofMessage } from "@/lib/keeper-register-proof"; import { deriveMarketParams, MIN_LEVERAGE_X, backingSeedPerDomain, leverageFromMarginBps } from "@/lib/market-params"; // v17: SetOracleAuthority (tag 17), PushOraclePrice (tag 16), SetOraclePriceCap (tag 16), @@ -175,7 +176,7 @@ async function fetchJupiterPriceE6(ca: string): Promise { const json = await resp.json() as { data?: Record }; const price = json.data?.[ca]?.price; if (price && isFinite(price) && price > 0) { - return BigInt(Math.round(price * 1_000_000)); + return toE6(price); } } } catch { /* fall through */ } @@ -191,7 +192,7 @@ async function fetchJupiterPriceE6(ca: string): Promise { const priceStr = json.pairs?.[0]?.priceUsd; const price = priceStr ? parseFloat(priceStr) : 0; if (price > 0 && isFinite(price)) { - return BigInt(Math.round(price * 1_000_000)); + return toE6(price); } } } catch { /* fall through */ } diff --git a/app/hooks/useLpPositions.ts b/app/hooks/useLpPositions.ts index 8ee8c0308..7679010e1 100644 --- a/app/hooks/useLpPositions.ts +++ b/app/hooks/useLpPositions.ts @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; +import { bigintToFloat } from "@/lib/formatters"; import { PublicKey, SystemProgram } from '@solana/web3.js'; import { useWalletCompat, useConnectionCompat } from '@/hooks/useWalletCompat'; import { getAssociatedTokenAddressSync, unpackAccount, unpackMint } from '@solana/spl-token'; @@ -257,7 +258,10 @@ export function useLpPositions(): LpPositionsState & { refresh: () => void } { // Compute redeemable value: (lpBalance / totalLpSupply) * tvl // Use per-mint decimals — do NOT hardcode 6 (PERC-8197). const lpMintDecimals = lpDecimalsByMint[pool.lpMint] ?? 6; - const lpBalance = Number(lpBalanceRaw) / Math.pow(10, lpMintDecimals); + // #2324: null above MAX_SAFE_INTEGER rather than a wrong balance. 0 is a + // deliberate fallback here — this feeds a list row, and a missing position + // reads better than a confident wrong one. + const lpBalance = bigintToFloat(lpBalanceRaw, lpMintDecimals) ?? 0; const totalLpSupply = pool.totalLpSupply; const tvlRaw = BigInt(pool.tvlRaw); @@ -266,10 +270,15 @@ export function useLpPositions(): LpPositionsState & { refresh: () => void } { : 0n; // Redeemable value is in the pool's collateral token — look up actual decimals. const collateralDecimals = collateralDecimalsByMint[pool.collateralMint] ?? 6; - const redeemable = Number(redeemableRaw) / Math.pow(10, collateralDecimals); - const userSharePct = totalLpSupply > 0 - ? (Number(lpBalanceRaw) / totalLpSupply) * 100 - : 0; + // #2324: same guard as the balance above. + const redeemable = bigintToFloat(redeemableRaw, collateralDecimals) ?? 0; + // `totalLpSupply` is a number here, so this is a bigint/number mix. Guard + // the bigint side — the divisor cannot overflow, only the dividend can. + const lpBalanceForPct = bigintToFloat(lpBalanceRaw, 0); + const userSharePct = + totalLpSupply > 0 && lpBalanceForPct !== null + ? (lpBalanceForPct / totalLpSupply) * 100 + : 0; // Check cooldown status from the batched deposit PDA info. let cooldownElapsed = true; diff --git a/app/lib/formatters.ts b/app/lib/formatters.ts index ecf94b2b1..b9bde9c11 100644 --- a/app/lib/formatters.ts +++ b/app/lib/formatters.ts @@ -30,6 +30,42 @@ * * Shared and guarded here so the two call sites cannot drift apart again. */ +/** + * Convert a `bigint` of base units to a float, refusing silently-wrong results. + * + * #2324: `Number(someBigint)` loses precision above `Number.MAX_SAFE_INTEGER` + * (~9e15) and says nothing — it returns a plausible, wrong number. For 6-decimal + * USDC that ceiling is ~9 billion tokens; for a 9-decimal mint it is ~9 million, + * which is not a comfortable margin. + * + * Returns `null` rather than a wrong figure, so the caller renders a placeholder + * the way it already does for missing data. Returning 0 would be worse than the + * bug: a silently wrong LARGE number at least looks suspicious, whereas a + * confident 0 reads as a real balance. + * + * Note this checks the RAW base-unit magnitude, before dividing by the decimal + * scale — that is where the precision is actually lost. Dividing first would + * hide it. + */ +export function bigintToFloat(raw: bigint, decimals: number): number | null { + const abs = raw < 0n ? -raw : raw; + if (abs > BigInt(Number.MAX_SAFE_INTEGER)) return null; + return Number(raw) / 10 ** decimals; +} + +/** + * `bigintToFloat` for a ratio of two bigints, where neither side alone need be + * small — only their quotient. Scales inside bigint arithmetic first, so a large + * numerator and denominator still produce an exact result. + */ +export function bigintRatio(numerator: bigint, denominator: bigint, precision = 1_000_000n): number | null { + if (denominator === 0n) return null; + const scaled = (numerator * precision) / denominator; + const abs = scaled < 0n ? -scaled : scaled; + if (abs > BigInt(Number.MAX_SAFE_INTEGER)) return null; + return Number(scaled) / Number(precision); +} + export function formatCompactUsd(n: number | null | undefined): string { if (n == null || !Number.isFinite(n)) return '—'; if (n >= 1e9) return `$${(n / 1e9).toFixed(1)}B`; diff --git a/app/lib/priceStore/priceStore.ts b/app/lib/priceStore/priceStore.ts index 2bb9be042..2b7c62e17 100644 --- a/app/lib/priceStore/priceStore.ts +++ b/app/lib/priceStore/priceStore.ts @@ -36,6 +36,7 @@ */ import { applyInvert, sanitizePriceE6 } from "@/lib/oraclePrice"; +import { toE6 } from "@/lib/format"; import { getBackendUrl } from "@/lib/config"; import { startPerfSpan } from "@/lib/perf/perfTiming"; import { getWsManager } from "./wsManager"; @@ -260,7 +261,7 @@ function handleRawMessage(slab: string, entry: SlabEntry, data: unknown): void { let rawE6: bigint | null = null; if (isCurrentServer) { - rawE6 = BigInt(Math.round(msg.price! * 1_000_000)); + rawE6 = toE6(msg.price!); } else if (isLegacy) { const priceStr = msg.data!.priceE6!; if (typeof priceStr === "string" && /^-?\d+$/.test(priceStr)) { @@ -374,7 +375,7 @@ export function setInvertFlag(slab: string, invert: number | undefined): void { export function seedFromDbIfEmpty(slab: string, dbPrice: number, invert: number | undefined): void { if (dbPrice <= 0) return; const entry = getOrCreateEntry(slab); - const rawE6 = BigInt(Math.round(dbPrice * 1_000_000)); + const rawE6 = toE6(dbPrice); entry.lastDbRawE6 = rawE6; if (entry.snapshot.price !== null) return; // real data already present — never clobber it const e6 = applyInvert(rawE6, invert);