diff --git a/app/__tests__/lib/formatters-nan-guards.test.ts b/app/__tests__/lib/formatters-nan-guards.test.ts new file mode 100644 index 000000000..4b50cf294 --- /dev/null +++ b/app/__tests__/lib/formatters-nan-guards.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { formatCompact, formatCompactUsd, formatPercent } from '@/lib/formatters'; + +/** + * GH#2313 — non-finite values reached the display layer and rendered literally. + * + * The mechanism is quiet: `(NaN).toFixed(2)` is the STRING "NaN", and every + * magnitude comparison against NaN is false — so NaN falls through each + * `n >= 1e6` branch and lands on the final `toFixed`, producing "$NaN" with no + * error anywhere. + * + * Scope note, because the issue overstates it: `lib/formatters.ts`'s + * `formatCompact` was ALREADY guarded, and the Earn percentages (`utilPct`, + * `userSharePct`) guard their divisors at source. The real gaps were two + * divergent private copies of formatCompact and one inline 0/0. + */ +describe('display formatters reject non-finite input (GH#2313)', () => { + const bad: Array<[string, number]> = [ + ['NaN', NaN], + ['Infinity', Infinity], + ['-Infinity', -Infinity], + ]; + + for (const [label, v] of bad) { + it(`formatCompactUsd(${label}) renders a placeholder, not "$${label}"`, () => { + const out = formatCompactUsd(v); + expect(out).toBe('—'); + expect(out).not.toContain('NaN'); + expect(out).not.toContain('Infinity'); + }); + + it(`formatPercent(${label}) renders a placeholder`, () => { + const out = formatPercent(v); + expect(out).toBe('—'); + expect(out).not.toContain('NaN'); + }); + + it(`formatCompact(${label}) stays guarded`, () => { + expect(formatCompact(v)).toBe('—'); + }); + } + + it('formatCompactUsd handles null/undefined like the copy it replaced', () => { + // MarketInfoBar's private version guarded null; Watchlist's did not. The + // shared one keeps the stricter behaviour of the two. + expect(formatCompactUsd(null)).toBe('—'); + expect(formatCompactUsd(undefined)).toBe('—'); + }); + + it('still formats real values across every magnitude branch', () => { + // Guard against "fix" by making everything return a placeholder. + expect(formatCompactUsd(1_500_000_000)).toBe('$1.5B'); + expect(formatCompactUsd(2_500_000)).toBe('$2.5M'); + expect(formatCompactUsd(3_400)).toBe('$3.4K'); + expect(formatCompactUsd(12.345)).toBe('$12.35'); + expect(formatCompactUsd(0)).toBe('$0.00'); + expect(formatPercent(12.345)).toBe('12.35%'); + expect(formatPercent(12.345, 0)).toBe('12%'); + }); + + it('the 0/0 share preview that motivated this renders a placeholder', () => { + // DepositWithdrawPanel: (Number(0n) / Number(0n + 0n)) * 100 === NaN + const previewShares = 0n; + const lpSupply = 0n; + const pct = (Number(previewShares) / Number(lpSupply + previewShares)) * 100; + expect(Number.isNaN(pct)).toBe(true); + expect(formatPercent(pct)).toBe('—'); + }); +}); diff --git a/app/components/dashboard/Watchlist.tsx b/app/components/dashboard/Watchlist.tsx index 941c5cd0f..ad7e4d0de 100644 --- a/app/components/dashboard/Watchlist.tsx +++ b/app/components/dashboard/Watchlist.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import { formatCompactUsd } from "@/lib/formatters"; import Link from "next/link"; interface MarketEntry { @@ -13,12 +14,7 @@ interface MarketEntry { total_open_interest_usd?: number | null; } -function formatCompact(val: number): string { - if (val >= 1_000_000_000) return `$${(val / 1_000_000_000).toFixed(1)}B`; - if (val >= 1_000_000) return `$${(val / 1_000_000).toFixed(1)}M`; - if (val >= 1_000) return `$${(val / 1_000).toFixed(1)}K`; - return `$${val.toFixed(2)}`; -} + export function Watchlist() { const [markets, setMarkets] = useState([]); @@ -68,10 +64,10 @@ export function Watchlist() {

- Vol: {m.volume_24h_usd != null && m.volume_24h_usd > 0 ? formatCompact(m.volume_24h_usd) : "--"} + Vol: {m.volume_24h_usd != null && m.volume_24h_usd > 0 ? formatCompactUsd(m.volume_24h_usd) : "--"}

- OI: {m.total_open_interest_usd != null && m.total_open_interest_usd > 0 ? formatCompact(m.total_open_interest_usd) : "--"} + OI: {m.total_open_interest_usd != null && m.total_open_interest_usd > 0 ? formatCompactUsd(m.total_open_interest_usd) : "--"}

diff --git a/app/components/earn/DepositWithdrawPanel.tsx b/app/components/earn/DepositWithdrawPanel.tsx index 5ba9978ab..4ed5b30c6 100644 --- a/app/components/earn/DepositWithdrawPanel.tsx +++ b/app/components/earn/DepositWithdrawPanel.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useCallback, useMemo } from 'react'; +import { formatPercent } from "@/lib/formatters"; import { GlowButton } from '@/components/ui/GlowButton'; import { useWalletCompat } from '@/hooks/useWalletCompat'; import dynamic from 'next/dynamic'; @@ -364,7 +365,7 @@ export function DepositWithdrawPanel({ {lpSupply > 0n && ( - Share: {((Number(previewShares) / Number(lpSupply + previewShares)) * 100).toFixed(2)}% + Share: {formatPercent((Number(previewShares) / Number(lpSupply + previewShares)) * 100)} )} diff --git a/app/components/trade/MarketInfoBar.tsx b/app/components/trade/MarketInfoBar.tsx index a1f839e7b..657e93067 100644 --- a/app/components/trade/MarketInfoBar.tsx +++ b/app/components/trade/MarketInfoBar.tsx @@ -9,6 +9,7 @@ import { useSlabState } from "@/components/providers/SlabProvider"; import { usePriceFlash } from "@/hooks/usePriceFlash"; import { MarketSwitcher } from "@/components/trade/MarketSwitcher"; import { formatUsdFromNumber, formatMarkPrice } from "@/lib/format"; +import { formatCompactUsd } from "@/lib/formatters"; import { computeMarketSpread } from "@/lib/oraclePrice"; interface MarketInfoBarProps { @@ -20,12 +21,7 @@ interface MarketInfoBarProps { mainnetCa?: string | null; } -function formatCompact(n: number | null | undefined): string { - if (n == null) return "—"; - if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `$${(n / 1_000).toFixed(1)}K`; - return `$${n.toFixed(0)}`; -} + /** * Phase 2: funding rate display — designer note says show funding / 8h. @@ -209,7 +205,7 @@ export const MarketInfoBar: FC = ({ slabAddress, symbol, log className={`text-xs font-medium ${volume == null ? "text-[var(--text-dim)]" : "text-[var(--text)]"}`} style={{ fontFamily: "var(--font-mono)" }} > - {volume == null ? "—" : formatCompact(volume as number)} + {volume == null ? "—" : formatCompactUsd(volume as number)} @@ -217,7 +213,7 @@ export const MarketInfoBar: FC = ({ slabAddress, symbol, log
Open Interest - {formatCompact(oi as number)} + {formatCompactUsd(oi as number)}
diff --git a/app/lib/formatters.ts b/app/lib/formatters.ts index 80cd2091b..ecf94b2b1 100644 --- a/app/lib/formatters.ts +++ b/app/lib/formatters.ts @@ -19,6 +19,37 @@ * formatCompact(45.678) // → "45.68" * formatCompact(NaN) // → "—" (invalid input, e.g. from an upstream 0/0 division) */ +/** + * `formatCompact` with a leading `$`, one decimal place, for USD figures. + * + * #2313: this existed as two DIVERGENT private copies — `Watchlist.tsx` (no + * guard at all) and `MarketInfoBar.tsx` (guarded `null` but not NaN) — so a + * non-finite value rendered as the literal `$NaN`. `(NaN).toFixed(2)` is the + * string `"NaN"`, and `NaN >= 1_000` is false, so it falls through every + * magnitude branch to the final `toFixed`. + * + * Shared and guarded here so the two call sites cannot drift apart again. + */ +export function formatCompactUsd(n: number | null | undefined): string { + if (n == null || !Number.isFinite(n)) return '—'; + if (n >= 1e9) return `$${(n / 1e9).toFixed(1)}B`; + if (n >= 1e6) return `$${(n / 1e6).toFixed(1)}M`; + if (n >= 1e3) return `$${(n / 1e3).toFixed(1)}K`; + return `$${n.toFixed(2)}`; +} + +/** + * Render a percentage, or `—` when the value is not finite. + * + * #2313: percentages here are division-derived, and a `0 / 0` from an empty + * pool is NaN. Most sites already guard the divisor at source; this is for the + * ones where the division happens inline at the render boundary. + */ +export function formatPercent(n: number, digits = 2): string { + if (!Number.isFinite(n)) return '—'; + return `${n.toFixed(digits)}%`; +} + export function formatCompact(n: number): string { // Defense-in-depth: NaN/±Infinity can leak in from upstream division-by-zero // or malformed on-chain data. Render "—" instead of "NaN"/"Infinity".