Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions __tests__/lib/math.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
6 changes: 3 additions & 3 deletions app/swap/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down
10 changes: 9 additions & 1 deletion components/SwapPreview.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -61,7 +69,7 @@ function SwapPreviewInner({
<div className="flex justify-between text-gray-400">
<span>Rate</span>
<span className="text-white">
1 {tokenIn} = {formatAmount(amountOut, tokenOut)} {tokenOut}
1 {tokenIn} = {rate} {tokenOut}
</span>
</div>

Expand Down
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** @type {import('jest').Config} */
const config = {
testEnvironment: "node",
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
transform: {
"^.+\\.tsx?$": ["ts-jest", { tsconfig: { jsx: "react-jsx" } }],
},
Expand Down
10 changes: 10 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
@@ -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"
92 changes: 64 additions & 28 deletions lib/format.ts
Original file line number Diff line number Diff line change
@@ -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" */
Expand Down Expand Up @@ -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`
}
}
Loading