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"
}
}