Skip to content

fix(send): keep amount math in integer minor units, sanitize the amount field (closes #457) - #545

Open
Vyacheslav-Tomashevskiy wants to merge 1 commit into
Northgate-Systems:mainfrom
Vyacheslav-Tomashevskiy:fix/457-currency-amount-precision
Open

fix(send): keep amount math in integer minor units, sanitize the amount field (closes #457)#545
Vyacheslav-Tomashevskiy wants to merge 1 commit into
Northgate-Systems:mainfrom
Vyacheslav-Tomashevskiy:fix/457-currency-amount-precision

Conversation

@Vyacheslav-Tomashevskiy

Copy link
Copy Markdown
Contributor

Closes #457.

The bug

send/page.tsx did all of its amount math in floating point:

const numericAmount = parseFloat(amount) || 0;
const converted = rate ? numericAmount * parseFloat(rate) : 0;
// ... rendered as converted.toFixed(2)

That is three separate places for binary rounding error to appear on a value the user is about to send irreversibly over Stellar: the parseFloat of the typed amount, the multiplication, and toFixed(2) ((1.005).toFixed(2) is "1.00" in every JS engine, not "1.01"). On top of that the field was type="number", whose value the browser also considers valid for scientific notation (1e5) and unbounded decimal places, so the string posted to /api/stellar/send could disagree with what the user believed they typed.

The fix

New src/lib/currency.ts keeps the amount as an exact integer count of minor units:

  • toMinorUnits("100.50") === 10050 — done by splitting the decimal string and concatenating digits, never by parsing a float. Returns null for anything that isn't a plain non-negative decimal, or that has more fractional digits than the currency supports (rejecting beats silently truncating a user's money).
  • fromMinorUnits(10050) === "100.50" — the inverse, integer arithmetic only.
  • convertMinorUnits(amountMinor, rate) — the rate is inherently a float from the API, so the multiply stays a multiply, but it rounds to the nearest minor unit exactly once and the amount side of it was never anything but an exact integer.
  • sanitizeAmountInput constrains the field as the user types (digits and one decimal point, capped at the currency's precision), so what is displayed is always exactly what toMinorUnits will accept.

send/page.tsx now routes the amount, the "Recipient Gets" display, the summary card and the canContinue gate through those helpers, and the amount field became type="text" + inputMode="decimal" (still a numeric keypad on mobile, but the value is exactly the characters typed) with an aria-label.

Scope note (deviation from the issue text, as requested in the issue's contributor notes)

The issue suggests "consider a shared component". I did the shared logic rather than a shared visual component: a new input component would mean reworking send/page.tsx's existing Tailwind layout well beyond what this precision bug needs, and the precision problem lives entirely in the math, not the markup. rates/page.tsx is listed in the issue too, but it has no user-entered amount at all — every parseFloat there is read-only formatting of server-provided rate/liquidity strings for display, so there is nothing to fix there. Happy to widen the scope to an actual <AmountInput> component if you'd prefer it.

Verification

  • 32 new unit tests in src/lib/__tests__/currency.test.ts, all green (npx vitest run src/lib/__tests__/currency.test.ts) — including the 1.005/toFixed case, over-precision rejection, Number.isSafeInteger guard, custom-precision and zero-decimal currencies.
  • npx eslint on all three touched files: clean.
  • npm run build: ✓ Compiled successfully, and the type-check step passes for everything in this PR. (The build's TypeScript stage still stops on the pre-existing missing isValidStellarPublicKey import in src/lib/validations.ts, unrelated to this change — that's what fix(validations): restore broken isValidStellarPublicKey import; feat: add /api/stellar/fee-estimate #529 fixes. I temporarily added that import locally to get the build past it and confirm a full clean pass, then reverted it; it is not part of this diff.)
  • Checked live at 1440×900 and 375×812 in Chrome: typing 12ab.3456e7 into the amount field yields 12.34 — letters dropped, no scientific notation, precision capped. Layout at 375px is byte-identical to main (same element geometry; the card's pre-existing horizontal overflow at that width is unchanged by this PR).
  • tsc --noEmit reports the same pre-existing errors as clean main and no new ones.

parseFloat() on the amount string turned every typed amount into an
IEEE754 double before it was multiplied by the rate and rendered with
toFixed(2) - three places for binary rounding error on a value the user
is about to send irreversibly over Stellar.

- add src/lib/currency.ts: toMinorUnits/fromMinorUnits do the decimal
  <-> integer-minor-unit conversion by string manipulation, never via
  parseFloat; convertMinorUnits rounds through the rate exactly once
- wire send/page.tsx's amount, conversion display and canContinue gate
  through it
- sanitize the amount field as it is typed, and make it a text field
  with inputMode="decimal" so its value is exactly the characters the
  user typed (type="number" also accepts scientific notation)
- 32 unit tests for the new helpers
@vercel

vercel Bot commented Sep 10, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the codex723's projects Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a currency/amount input component with proper decimal handling

1 participant