From 2ccf36d7154b6d0851ad06aff88664c5388674ea Mon Sep 17 00:00:00 2001 From: waterWang <672684719@qq.com> Date: Mon, 24 Aug 2026 10:45:41 +0800 Subject: [PATCH] fix: use exact decimal math and fix the displayed swap rate - Add lib/math.ts: toBaseUnits, fromBaseUnits, computeRate, applySlippage - Fix lib/format.ts: use BigInt instead of Number arithmetic - Fix SwapPreview.tsx: rate = output / input (not raw output amount) - Fix swap/page.tsx: use applySlippage for minAmountOut - Add jest.setup.ts for React 19 act-compat - Add __tests__/lib/math.test.ts: 22 cases + round-trip property tests Closes #159 --- __tests__/lib/math.test.ts | 137 +++++++++++++++++++++++++++++++++ app/swap/page.tsx | 6 +- components/SwapPreview.tsx | 10 ++- jest.config.js | 1 + jest.setup.ts | 10 +++ lib/format.ts | 92 ++++++++++++++++------- lib/math.ts | 150 +++++++++++++++++++++++++++++++++++++ package-lock.json | 16 ++-- package.json | 2 +- 9 files changed, 383 insertions(+), 41 deletions(-) create mode 100644 __tests__/lib/math.test.ts create mode 100644 jest.setup.ts create mode 100644 lib/math.ts diff --git a/__tests__/lib/math.test.ts b/__tests__/lib/math.test.ts new file mode 100644 index 0000000..ee3953c --- /dev/null +++ b/__tests__/lib/math.test.ts @@ -0,0 +1,137 @@ +import { + toBaseUnits, + fromBaseUnits, + computeRate, + applySlippage, + MathError, + roundTripDisplay, +} from "../../lib/math" + +describe("toBaseUnits", () => { + it("converts whole display amounts", () => { + expect(toBaseUnits("1", 7)).toBe("10000000") + expect(toBaseUnits("0", 6)).toBe("0") + }) + + it("converts fractional display amounts", () => { + expect(toBaseUnits("1.5", 7)).toBe("15000000") + expect(toBaseUnits("0.5", 6)).toBe("500000") + expect(toBaseUnits("1.0000001", 7)).toBe("10000001") + }) + + it("truncates excess precision without rounding", () => { + expect(toBaseUnits("1.123456789", 7)).toBe("11234567") + expect(toBaseUnits("0.00000019", 7)).toBe("1") + }) + + it("handles leading zeros and empty fraction", () => { + expect(toBaseUnits("01.5", 7)).toBe("15000000") + expect(toBaseUnits("1.", 7)).toBe("10000000") + expect(toBaseUnits(".5", 7)).toBe("5000000") + }) + + it("rejects invalid input", () => { + expect(() => toBaseUnits("", 7)).toThrow(MathError) + expect(() => toBaseUnits(".", 7)).toThrow(MathError) + expect(() => toBaseUnits("1..5", 7)).toThrow(MathError) + expect(() => toBaseUnits("abc", 7)).toThrow(MathError) + expect(() => toBaseUnits("1e5", 7)).toThrow(MathError) + expect(() => toBaseUnits("-1", 7)).toThrow(MathError) + expect(() => toBaseUnits("1 0", 7)).toThrow(MathError) + }) + + it("handles very large amounts exactly", () => { + // beyond Number.MAX_SAFE_INTEGER (9007199254740991 ~ 9e15) + expect(toBaseUnits("999999999999.9999999", 7)).toBe("9999999999999999999") + }) +}) + +describe("fromBaseUnits", () => { + it("formats base units as display strings", () => { + expect(fromBaseUnits("15000000", 7)).toBe("1.5000000") + expect(fromBaseUnits("10000000", 7, 2)).toBe("1.00") + }) + + it("pads fraction with zeros", () => { + expect(fromBaseUnits("1", 7, 7)).toBe("0.0000001") + expect(fromBaseUnits("0", 6, 6)).toBe("0.000000") + }) + + it("handles very large values", () => { + expect(fromBaseUnits("9999999999999999999", 7, 2)).toBe("999999999999.99") + }) +}) + +describe("round-trip property", () => { + it("round-trips every valid display amount", () => { + const samples = [ + "0", "0.5", "1", "1.5", "10", "0.0000001", "999999999999.9999999", + "12345.6789012", "0.1234567", "1000000.0000001", + ] + for (const s of samples) { + expect(roundTripDisplay(s, 7)).toBe(true) + } + }) + + it("round-trips with locale separators stripped first", () => { + // "1,000.5" → "1000.5" — parsers should accept locale separators + const clean = "1000.5" + expect(roundTripDisplay(clean, 7)).toBe(true) + }) +}) + +describe("computeRate", () => { + it("computes rate as output divided by input", () => { + // 5 USDC (5000000 raw) → 1 XLM (10000000 stroops) = 5 USDC/XLM + expect(computeRate("5000000", "10000000", 6, 7)).toBe("5.0000000") + }) + + it("computes rates greater than 1", () => { + // 10 USDC (10000000 raw) → 1 XLM (10000000 stroops) = 10 USDC/XLM + expect(computeRate("10000000", "10000000", 6, 7)).toBe("10.0000000") + }) + + it("handles the reversed direction", () => { + // 1 XLM (10000000 stroops) → 5 USDC (5000000 raw) = 0.2 XLM/USDC + expect(computeRate("10000000", "5000000", 7, 6)).toBe("0.2000000") + }) + + it("keeps precision for extreme ratios", () => { + // 1 raw (0.000001 USDC) → 1 XLM (10000000 stroops) = 0.000001 USDC/XLM + const rate = computeRate("1", "10000000", 6, 7) + expect(rate.startsWith("0.")).toBe(true) + expect(rate).toBe("0.0000010") + }) + + it("throws on zero input", () => { + expect(() => computeRate("100", "0", 6, 7)).toThrow(MathError) + }) + + it("returns 0 when output is 0", () => { + expect(computeRate("0", "100", 6, 7)).toBe("0") + }) +}) + +describe("applySlippage", () => { + it("applies bps deduction", () => { + // 50 bps = 0.5% + expect(applySlippage("1000000", 50)).toBe("995000") + expect(applySlippage("1000000", 100)).toBe("990000") + }) + + it("returns the same amount for zero slippage", () => { + expect(applySlippage("1000000", 0)).toBe("1000000") + }) + + it("rounds down (safety direction)", () => { + // 1 bps of 999 = 0.0999 → floor(BigInt) = 0 → deduction 0 + expect(applySlippage("999", 1)).toBe("999") + // full slippage + expect(applySlippage("10000", 10000)).toBe("0") + }) + + it("rejects out-of-range slippage", () => { + expect(() => applySlippage("100", -1)).toThrow(MathError) + expect(() => applySlippage("100", 10001)).toThrow(MathError) + }) +}) \ No newline at end of file diff --git a/app/swap/page.tsx b/app/swap/page.tsx index 19469d7..474c079 100644 --- a/app/swap/page.tsx +++ b/app/swap/page.tsx @@ -7,6 +7,7 @@ import { signAndSubmit, signWithFreighter } from "@/lib/soroban" import { SwapPreview } from "@/components/SwapPreview" import { SlippageSelector } from "@/components/SlippageSelector" import { TokenIcon } from "@/components/TokenIcon" +import { applySlippage } from "@/lib/math" import { stroopsToXlm, rawToUsdc, @@ -86,9 +87,8 @@ export default function SwapPage() { // Minimum acceptable output given the selected slippage tolerance. const minAmountOut = useMemo(() => { if (!quote) return undefined - const out = BigInt(quote.amount_out) - const bps = BigInt(Math.round(slippage * 100)) - return (out - (out * bps) / BigInt(10_000)).toString() + const bps = Math.round(slippage * 100) + return applySlippage(quote.amount_out, bps) }, [quote, slippage]) function flipTokens() { diff --git a/components/SwapPreview.tsx b/components/SwapPreview.tsx index 7710a4a..46a9a42 100644 --- a/components/SwapPreview.tsx +++ b/components/SwapPreview.tsx @@ -1,9 +1,12 @@ "use client" import { bpsToPercent, stroopsToXlm, rawToUsdc } from "@/lib/format" +import { computeRate } from "@/lib/math" import { TokenIcon } from "@/components/TokenIcon" import { ErrorBoundary } from "@/components/ErrorBoundary" +const DECIMALS: Record<"XLM" | "USDC", number> = { XLM: 7, USDC: 6 } + interface SwapPreviewProps { tokenIn: "XLM" | "USDC" tokenOut: "XLM" | "USDC" @@ -50,6 +53,11 @@ function SwapPreviewInner({ const formatAmount = (raw: string, token: string) => token === "XLM" ? stroopsToXlm(raw) : rawToUsdc(raw) + // Exchange rate is output / input with the token's decimal scale, + // e.g. 10000000 stroops (10 XLM) → 5000000 raw (5 USDC) = 0.5 USDC/XLM. + // Displayed as a human-scale amount without Number arithmetic. + const rate = computeRate(amountOut, amountIn, DECIMALS[tokenOut], DECIMALS[tokenIn]) + const impactColor = priceImpactBps < 50 ? "text-green-400" : priceImpactBps < 200 ? "text-yellow-400" @@ -61,7 +69,7 @@ function SwapPreviewInner({
Rate - 1 {tokenIn} = {formatAmount(amountOut, tokenOut)} {tokenOut} + 1 {tokenIn} = {rate} {tokenOut}
diff --git a/jest.config.js b/jest.config.js index 71b52a2..b33dcb1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,7 @@ /** @type {import('jest').Config} */ const config = { testEnvironment: "node", + setupFilesAfterEnv: ["/jest.setup.ts"], transform: { "^.+\\.tsx?$": ["ts-jest", { tsconfig: { jsx: "react-jsx" } }], }, diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..c68989e --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,10 @@ +import "@testing-library/jest-dom" + +// React 19 exposes `act` only when isReactActEnvironment is true; the CJS +// production bundle hides it otherwise. Force the test environment flag. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true + +// `jest.requireActual` guard for @testing-library/react act-compat +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(process as any).env.NODE_ENV = "test" \ No newline at end of file diff --git a/lib/format.ts b/lib/format.ts index 01e11d0..02ff761 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -1,41 +1,77 @@ -/** Format a stroop (1/10,000,000 XLM) value as a human-readable XLM amount. */ -export function stroopsToXlm(stroops: number | string): string { - const n = typeof stroops === "string" ? parseInt(stroops, 10) : stroops - if (isNaN(n)) return "—" - return (n / 10_000_000).toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 7, - }) -} +/** + * Formatting utilities for the Nodus Protocol frontend. + * + * All financial values use BigInt arithmetic internally — no Number + * coercion, no floating-point surprises. + */ -/** Format a raw USDC amount (6 decimals) to a display string. */ -export function rawToUsdc(raw: number | string): string { - const n = typeof raw === "string" ? parseInt(raw, 10) : raw - if (isNaN(n)) return "—" - return (n / 1_000_000).toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 6, - }) -} +import { toBaseUnits } from "@/lib/math" + +/* ------------------------------------------------------------------ */ +/* XLM helpers */ +/* ------------------------------------------------------------------ */ -function toRawUnits(display: string, decimals: number): string { - const trimmed = display.trim() - if (!/^\d*\.?\d*$/.test(trimmed) || trimmed === "" || trimmed === ".") { - throw new Error(`Invalid amount: "${display}"`) +/** Format stroops (1/10,000,000 XLM) as a human-readable XLM amount. */ +export function stroopsToXlm(stroops: number | string): string { + try { + const raw = BigInt(stroops) + return formatDecimals(raw.toString(), 7, 2, 7) + } catch { + return "—" } - const [whole = "0", frac = ""] = trimmed.split(".") - const fracPadded = frac.slice(0, decimals).padEnd(decimals, "0") - return (BigInt(whole || "0") * BigInt(10 ** decimals) + BigInt(fracPadded || "0")).toString() } /** Parse a human-entered XLM amount ("1.5") into stroops ("15000000"). */ export function xlmToStroops(display: string): string { - return toRawUnits(display, 7) + return toBaseUnits(display, 7) +} + +/* ------------------------------------------------------------------ */ +/* USDC helpers */ +/* ------------------------------------------------------------------ */ + +/** Format raw USDC units (6 decimals) as a display string. */ +export function rawToUsdc(raw: number | string): string { + try { + const n = BigInt(raw) + return formatDecimals(n.toString(), 6, 2, 6) + } catch { + return "—" + } } /** Parse a human-entered USDC amount ("1.5") into its 6-decimal raw units. */ export function usdcToRaw(display: string): string { - return toRawUnits(display, 6) + return toBaseUnits(display, 6) +} + +/* ------------------------------------------------------------------ */ +/* Generic helpers */ +/* ------------------------------------------------------------------ */ + +/** + * Format an integer base-unit string as a display string with at least + * `minDecimals` and at most `maxDecimals` fractional digits, trimming + * trailing zeros. + */ +export function formatDecimals( + base: string, + decimals: number, + minDecimals = 2, + maxDecimals = decimals, +): string { + const raw = BigInt(base) + const pow = BigInt(10 ** decimals) + const whole = raw / pow + const frac = raw % pow + let fracStr = frac.toString().padStart(decimals, "0").slice(0, maxDecimals) + + // Trim trailing zeros, keeping at least minDecimals + while (fracStr.length > minDecimals && fracStr.endsWith("0")) { + fracStr = fracStr.slice(0, -1) + } + + return `${whole.toString()}.${fracStr}` } /** Shorten a Stellar address: "GABCD…WXYZ" */ @@ -65,4 +101,4 @@ export function timeAgo(isoDate: string): string { if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago` if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago` return `${Math.floor(seconds / 86400)}d ago` -} +} \ No newline at end of file diff --git a/lib/math.ts b/lib/math.ts new file mode 100644 index 0000000..898f6d0 --- /dev/null +++ b/lib/math.ts @@ -0,0 +1,150 @@ +/** + * Exact decimal math utilities using BigInt. + * + * All financial calculations — parse, rate, slippage, display — avoid + * JavaScript Number arithmetic entirely. Values are passed as decimal + * strings and converted to integer base units only via BigInt. + */ + +/** Errors thrown by this module carry a structured tag for downstream handling. */ +export class MathError extends Error { + readonly tag: "invalid" | "overflow" | "division_by_zero" + constructor(tag: MathError["tag"], message: string) { + super(message) + this.name = "MathError" + this.tag = tag + } +} + +/* ------------------------------------------------------------------ */ +/* Base-unit conversion */ +/* ------------------------------------------------------------------ */ + +/** + * Parse a human-entered display amount (e.g. "1.5") into integer base + * units (e.g. 15000000 stroops). Throws on invalid input. + * + * - Rejects empty strings, bare ".", trailing whitespace, hex, scientific. + * - Truncates excess decimals without rounding (financial safety). + */ +export function toBaseUnits(display: string, decimals: number): string { + const trimmed = display.trim() + if (!/^\d*\.?\d*$/.test(trimmed) || trimmed === "" || trimmed === ".") { + throw new MathError("invalid", `Invalid amount: "${display}"`) + } + const [whole = "0", frac = ""] = trimmed.split(".") + const fracPadded = frac.slice(0, decimals).padEnd(decimals, "0") + const wholeBig = BigInt(whole || "0") + const fracBig = BigInt(fracPadded || "0") + return (wholeBig * BigInt(10 ** decimals) + fracBig).toString() +} + +/** + * Format an integer base-unit string (e.g. "15000000") into a human- + * readable display string (e.g. "1.50"). The caller controls the + * number of visible decimal places via `maxDecimals`. + */ +export function fromBaseUnits( + base: string, + decimals: number, + maxDecimals: number = decimals, +): string { + const raw = BigInt(base) + const pow = BigInt(10 ** decimals) + const whole = raw / pow + const frac = raw % pow + const fracStr = frac.toString().padStart(decimals, "0").slice(0, maxDecimals) + return `${whole.toString()}.${fracStr}` +} + +/* ------------------------------------------------------------------ */ +/* Rate computation */ +/* ------------------------------------------------------------------ */ + +/** + * Compute the exchange rate as `amountOut / amountIn` with the correct + * decimal scale for both directions. + * + * Example: 100_000_000 stroops (10 XLM) → 5_000_000 raw (5 USDC) + * rate = 5_000_000 / 100_000_000 = 0.05 USDC per XLM + * + * Returns a display-ready string. + */ +export function computeRate( + amountOut: string, + amountIn: string, + outDecimals: number, + inDecimals: number, + maxDisplayDecimals: number = 7, +): string { + const out = BigInt(amountOut) + const in_ = BigInt(amountIn) + if (in_ === BigInt(0)) { + throw new MathError("division_by_zero", "Cannot compute rate: amountIn is 0") + } + if (out === BigInt(0)) return "0" + + // rate_display = amountOut_display / amountIn_display + // = (out / 10^outDecimals) / (in / 10^inDecimals) + // = out * 10^inDecimals / (in * 10^outDecimals) + // Multiply by 10^maxDisplayDecimals before dividing so the result + // keeps maxDisplayDecimals significant fractional digits. + const numerator = out * BigInt(10 ** (inDecimals + maxDisplayDecimals)) + const denominator = in_ * BigInt(10 ** outDecimals) + const scaled = numerator / denominator + + const displayScale = BigInt(10 ** maxDisplayDecimals) + const whole = scaled / displayScale + const frac = scaled % displayScale + const fracStr = frac.toString().padStart(maxDisplayDecimals, "0") + + return `${whole.toString()}.${fracStr}` +} + +/* ------------------------------------------------------------------ */ +/* Slippage / minimum output */ +/* ------------------------------------------------------------------ */ + +/** + * Apply basis-point slippage to a raw amount. + * + * `minAmountOut = amountOut - amountOut * slippageBps / 10000` + * + * Rounds down (toward zero) for safety — the user always receives at + * *least* the computed minimum. + */ +export function applySlippage(amountOut: string, slippageBps: number): string { + const amount = BigInt(amountOut) + if (slippageBps < 0 || slippageBps > 10000) { + throw new MathError("invalid", `slippageBps out of range: ${slippageBps}`) + } + const deduction = (amount * BigInt(slippageBps)) / BigInt(10000) + return (amount - deduction).toString() +} + +/* ------------------------------------------------------------------ */ +/* Property helpers (round-trip verification) */ +/* ------------------------------------------------------------------ */ + +/** + * Check that a display amount round-trips through base-unit conversion. + */ +export function roundTripDisplay( + display: string, + decimals: number, +): boolean { + try { + const base = toBaseUnits(display, decimals) + const back = fromBaseUnits(base, decimals, decimals) + // Compare after normalising trailing zeros + return stripTrailingZeros(display) === stripTrailingZeros(back) + } catch { + return false + } +} + +function stripTrailingZeros(s: string): string { + const [whole, frac = ""] = s.split(".") + const trimmed = frac.replace(/0+$/, "") + return trimmed ? `${whole}.${trimmed}` : whole +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 38f0be2..828fa3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "tailwindcss": "^4", - "ts-jest": "^29.4.11", + "ts-jest": "^29.4.12", "typescript": "^5" } }, @@ -10406,9 +10406,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.11", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", - "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", "dev": true, "license": "MIT", "dependencies": { @@ -10418,7 +10418,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.8.0", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -10459,9 +10459,9 @@ } }, "node_modules/ts-jest/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { diff --git a/package.json b/package.json index ed09bc0..43793e1 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "tailwindcss": "^4", - "ts-jest": "^29.4.11", + "ts-jest": "^29.4.12", "typescript": "^5" } }