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))}
/>
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;
+}