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
31 changes: 23 additions & 8 deletions src/app/(app)/send/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -141,13 +148,21 @@ export default function SendMoneyPage() {
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 ml-1">You Send</label>
<div className="flex items-stretch border border-gray-200 rounded-xl overflow-hidden focus-within:ring-2 focus-within:ring-primary/20 transition-all shadow-sm">
{/* 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. */}
<input
className="flex-1 px-4 py-3.5 border-none text-lg font-bold focus:ring-0 outline-none"
placeholder="0.00"
type="number"
min="0"
type="text"
inputMode="decimal"
aria-label="Amount to send"
value={amount}
onChange={(e) => setAmount(e.target.value)}
onChange={(e) => setAmount(sanitizeAmountInput(e.target.value))}
/>
<select
value={fromAsset}
Expand All @@ -165,7 +180,7 @@ export default function SendMoneyPage() {
className="flex-1 px-4 py-3.5 border-none text-lg font-bold bg-gray-50 focus:ring-0 text-gray-500 outline-none"
readOnly
type="text"
value={rateLoading ? "…" : converted.toFixed(2)}
value={rateLoading ? "…" : convertedDisplay}
/>
<select
value={toAsset}
Expand Down Expand Up @@ -246,7 +261,7 @@ export default function SendMoneyPage() {
<div className="flex flex-col items-center py-1">
<span className="text-[10px] opacity-70 uppercase tracking-widest mb-1">Recipient Receives</span>
<div className="flex items-baseline gap-1.5">
<span className="text-2xl lg:text-3xl font-bold">{rateLoading ? "…" : converted.toFixed(2)}</span>
<span className="text-2xl lg:text-3xl font-bold">{rateLoading ? "…" : convertedDisplay}</span>
<span className="text-sm">{toAsset}</span>
</div>
</div>
Expand Down
153 changes: 153 additions & 0 deletions src/lib/__tests__/currency.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
98 changes: 98 additions & 0 deletions src/lib/currency.ts
Original file line number Diff line number Diff line change
@@ -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;
}