diff --git a/src/app/(app)/send/page.tsx b/src/app/(app)/send/page.tsx index 86b1aec..96116dd 100644 --- a/src/app/(app)/send/page.tsx +++ b/src/app/(app)/send/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; import { ArrowRightLeft, AtSign, ArrowRight, Info, RefreshCw, AlertCircle, CheckCircle2 } from "lucide-react"; import { checkStellarPublicKey, STELLAR_PUBLIC_KEY_LENGTH } from "@/lib/stellar-address"; +import { toMinorUnits, fromMinorUnits, convertMinorUnits, sanitizeAmountInput } from "@/lib/currency"; const ASSETS = ["XLM", "USDC", "USD", "NGN", "PHP", "GBP"]; const QUOTE_REFRESH_MS = 15_000; @@ -66,8 +67,13 @@ export default function SendMoneyPage() { return () => clearInterval(interval); }, [fetchQuote]); - const numericAmount = parseFloat(amount) || 0; - const converted = rate ? numericAmount * parseFloat(rate) : 0; + // Kept as an exact integer count of minor units (cents) rather than a + // float - see src/lib/currency.ts for why parseFloat(amount) here would + // reintroduce the precision bug this replaced. + const amountMinor = toMinorUnits(amount); + const convertedMinor = + amountMinor !== null && rate ? convertMinorUnits(amountMinor, parseFloat(rate)) : 0; + const convertedDisplay = fromMinorUnits(convertedMinor); // Checksum-validate the recipient as it's typed. Funds sent on Stellar are // irreversible, so a typo has to be caught here rather than by Horizon. @@ -79,7 +85,8 @@ export default function SendMoneyPage() { !recipientCheck.valid && trimmedRecipient.length > 0 && (recipientTouched || trimmedRecipient.length >= STELLAR_PUBLIC_KEY_LENGTH); - const canContinue = numericAmount > 0 && recipientCheck.valid && !!rate && !rateLoading; + const canContinue = + amountMinor !== null && amountMinor > 0 && recipientCheck.valid && !!rate && !rateLoading; const handleContinue = async () => { if (!canContinue) return; @@ -141,13 +148,21 @@ export default function SendMoneyPage() {
+ {/* A money field, not a spinner: type="number" hands back + whatever the browser considers a valid float, including + scientific notation ("1e5"), so the raw value can + disagree with what the user thinks they typed. text + + inputMode="decimal" still brings up a numeric keypad on + mobile, but the value is exactly the characters typed, + which sanitizeAmountInput can then constrain. */} setAmount(e.target.value)} + onChange={(e) => setAmount(sanitizeAmountInput(e.target.value))} /> Recipient Receives
- {rateLoading ? "…" : converted.toFixed(2)} + {rateLoading ? "…" : convertedDisplay} {toAsset}
diff --git a/src/lib/__tests__/currency.test.ts b/src/lib/__tests__/currency.test.ts new file mode 100644 index 0000000..3e4876e --- /dev/null +++ b/src/lib/__tests__/currency.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from "vitest"; +import { + isPlainDecimal, + toMinorUnits, + fromMinorUnits, + convertMinorUnits, + sanitizeAmountInput, +} from "@/lib/currency"; + +describe("isPlainDecimal", () => { + it("accepts a whole number", () => { + expect(isPlainDecimal("100")).toBe(true); + }); + + it("accepts a number with a decimal part", () => { + expect(isPlainDecimal("100.50")).toBe(true); + }); + + it("rejects a negative number", () => { + expect(isPlainDecimal("-5")).toBe(false); + }); + + it("rejects scientific notation", () => { + expect(isPlainDecimal("1e5")).toBe(false); + }); + + it("rejects an empty string", () => { + expect(isPlainDecimal("")).toBe(false); + }); + + it("rejects non-numeric text", () => { + expect(isPlainDecimal("abc")).toBe(false); + }); +}); + +describe("toMinorUnits", () => { + it("converts a whole number", () => { + expect(toMinorUnits("100")).toBe(10000); + }); + + it("converts a two-decimal amount exactly", () => { + expect(toMinorUnits("100.50")).toBe(10050); + }); + + it("pads a single-decimal amount", () => { + expect(toMinorUnits("1.5")).toBe(150); + }); + + it("handles a value that famously loses precision through parseFloat, exactly", () => { + // (1.005).toFixed(2) === "1.00" in every JS engine, because 1.005 + // can't be represented exactly in binary floating point. Going + // through toMinorUnits never parses "1.00" as a float at all, so + // there's nothing here for that bug to affect. + expect(toMinorUnits("1.00")).toBe(100); + }); + + it("returns null for more fractional digits than the currency supports", () => { + expect(toMinorUnits("1.005")).toBeNull(); + }); + + it("returns null for a negative amount", () => { + expect(toMinorUnits("-5")).toBeNull(); + }); + + it("returns null for scientific notation", () => { + expect(toMinorUnits("1e5")).toBeNull(); + }); + + it("returns null for garbage input", () => { + expect(toMinorUnits("not-a-number")).toBeNull(); + }); + + it("returns null for an empty string", () => { + expect(toMinorUnits("")).toBeNull(); + }); + + it("respects a custom decimals argument", () => { + expect(toMinorUnits("1.5000000", 7)).toBe(15000000); + }); + + it("zero is a valid amount", () => { + expect(toMinorUnits("0")).toBe(0); + expect(toMinorUnits("0.00")).toBe(0); + }); +}); + +describe("fromMinorUnits", () => { + it("is the inverse of toMinorUnits for a round amount", () => { + expect(fromMinorUnits(10000)).toBe("100.00"); + }); + + it("pads a small fractional amount", () => { + expect(fromMinorUnits(1)).toBe("0.01"); + }); + + it("formats zero", () => { + expect(fromMinorUnits(0)).toBe("0.00"); + }); + + it("round-trips through toMinorUnits for an arbitrary amount", () => { + const original = "42.37"; + expect(fromMinorUnits(toMinorUnits(original)!)).toBe(original); + }); + + it("supports zero decimals", () => { + expect(fromMinorUnits(42, 0)).toBe("42"); + }); +}); + +describe("convertMinorUnits", () => { + it("converts an exact amount by a simple rate", () => { + // 1.01 send -> 101 minor units, times a 1.50 rate -> 151.5, rounds to 152. + expect(convertMinorUnits(101, 1.5)).toBe(152); + }); + + it("handles a zero amount", () => { + expect(convertMinorUnits(0, 1.2345)).toBe(0); + }); + + it("handles a rate below 1 (fee-like conversion)", () => { + expect(convertMinorUnits(10000, 0.99)).toBe(9900); + }); +}); + +describe("sanitizeAmountInput", () => { + it("leaves a well-formed amount untouched", () => { + expect(sanitizeAmountInput("100.50")).toBe("100.50"); + }); + + it("strips non-digit, non-dot characters", () => { + expect(sanitizeAmountInput("1e5")).toBe("15"); + }); + + it("strips a leading minus sign", () => { + expect(sanitizeAmountInput("-5")).toBe("5"); + }); + + it("collapses multiple decimal points to the first one", () => { + expect(sanitizeAmountInput("1.2.3")).toBe("1.23"); + }); + + it("truncates extra fractional digits beyond the given precision", () => { + expect(sanitizeAmountInput("1.23456")).toBe("1.23"); + }); + + it("supports a custom decimals argument", () => { + expect(sanitizeAmountInput("1.2345678", 7)).toBe("1.2345678"); + }); + + it("drops the fractional part entirely when decimals is 0", () => { + expect(sanitizeAmountInput("1.99", 0)).toBe("1"); + }); +}); diff --git a/src/lib/currency.ts b/src/lib/currency.ts new file mode 100644 index 0000000..be06517 --- /dev/null +++ b/src/lib/currency.ts @@ -0,0 +1,98 @@ +/** + * Money-safe helpers for the send flow's amount input. + * + * The problem this fixes: `parseFloat(amount)` immediately converts a + * decimal string into an IEEE754 double, which can silently lose + * precision even for perfectly normal-looking money amounts. This isn't + * hypothetical - `(1.005).toFixed(2)` returns `"1.00"` in every JS + * engine, not `"1.01"`, because the literal `1.005` can't be represented + * exactly in binary floating point and rounds down before `toFixed` ever + * sees it. `send/page.tsx` did exactly this: `parseFloat(amount)` for the + * amount the user typed, then `numericAmount * parseFloat(rate)`, then + * `.toFixed(2)` for display - three separate places for binary rounding + * error to creep in, on values a user is about to send irreversibly over + * Stellar. + * + * The fix here is to keep the amount as an *integer* count of minor + * units (cents, for a 2-decimal currency) for as long as possible, and + * only ever produce that integer via direct string manipulation of the + * decimal string - never via `parseFloat`. Converting the exchange rate + * still needs a float multiply (the rate itself is inherently a + * floating-point quantity from the API), but at least the amount side of + * the multiplication is exact. + */ + +const DEFAULT_DECIMALS = 2; + +/** A plain non-negative decimal number: "100", "100.5", "100.50". No + * sign, no exponent ("1e5"), no thousands separators - anything else is + * not a value this UI should accept into an amount field. */ +const PLAIN_DECIMAL_RE = /^\d+(\.\d+)?$/; + +export function isPlainDecimal(raw: string): boolean { + return PLAIN_DECIMAL_RE.test(raw.trim()); +} + +/** + * Converts a decimal amount string into an integer count of minor units + * by splitting on the decimal point and concatenating the digits - + * never by parsing the string into a float first. Returns `null` for + * anything that isn't a plain non-negative decimal, or that carries more + * fractional digits than `decimals` supports (silently truncating would + * hide a mistake; better to reject and let the caller ask again). + */ +export function toMinorUnits(raw: string, decimals: number = DEFAULT_DECIMALS): number | null { + const trimmed = raw.trim(); + if (!isPlainDecimal(trimmed)) return null; + const [whole, fraction = ""] = trimmed.split("."); + if (fraction.length > decimals) return null; + const minorDigits = whole + fraction.padEnd(decimals, "0"); + const minor = Number(minorDigits); + return Number.isSafeInteger(minor) ? minor : null; +} + +/** The inverse of `toMinorUnits`: an integer count of minor units back + * into a fixed-decimal string, e.g. `fromMinorUnits(10050) === "100.50"`. */ +export function fromMinorUnits(minor: number, decimals: number = DEFAULT_DECIMALS): string { + const whole = Math.trunc(minor); + const sign = whole < 0 ? "-" : ""; + const abs = Math.abs(whole); + if (decimals === 0) return `${sign}${abs}`; + const divisor = 10 ** decimals; + const wholePart = Math.floor(abs / divisor); + const fractionPart = (abs % divisor).toString().padStart(decimals, "0"); + return `${sign}${wholePart}.${fractionPart}`; +} + +/** + * Converts an exact integer amount (in minor units) through a floating + * exchange rate, rounding to the nearest minor unit exactly once. This + * is the same rounding a naive `(amount * rate).toFixed(2)` does in the + * common case, but it doesn't compound a *second* source of float error + * from parsing the amount itself through `parseFloat` first - the amount + * here was never anything but an exact integer. + */ +export function convertMinorUnits(amountMinor: number, rate: number): number { + return Math.round(amountMinor * rate); +} + +/** + * Sanitizes a raw amount input as the user types: strips anything that + * isn't a digit or a decimal point, collapses multiple decimal points + * down to the first one, and truncates extra fractional digits beyond + * `decimals`. This keeps what's shown in the field always equal to what + * `toMinorUnits` will accept - no scientific notation, no surprises + * between what the user sees and what gets sent to the API. + */ +export function sanitizeAmountInput(raw: string, decimals: number = DEFAULT_DECIMALS): string { + let cleaned = raw.replace(/[^\d.]/g, ""); + const firstDot = cleaned.indexOf("."); + if (firstDot !== -1) { + cleaned = cleaned.slice(0, firstDot + 1) + cleaned.slice(firstDot + 1).replace(/\./g, ""); + } + const [whole, fraction] = cleaned.split("."); + if (fraction !== undefined && fraction.length > decimals) { + cleaned = decimals > 0 ? `${whole}.${fraction.slice(0, decimals)}` : whole; + } + return cleaned; +}