Audience: Contributors, reviewers, operators
Applies to: src/api/amount.ts, src/api/client.ts (amountFields / ApiAmountError)
Related: docs/API_CLIENT_POLICIES.md, openapi.yaml (Bond.amount)
Every USDC amount this app sends to the backend crosses apiFetch in
src/api/client.ts. Before this change, the body went through
JSON.stringify with no amount guarantees, and JavaScript silently
corrupts money-like numbers on that path:
| Input (caller) | What used to go on the wire | Problem |
|---|---|---|
0.1 + 0.2 |
0.30000000000000004 |
IEEE-754 drift sent verbatim |
NaN, Infinity |
null |
JSON.stringify quirk; shape changes |
1e21 |
1e+21 |
Exponent notation; strict decimals reject |
9007199254740993 |
9007199254740992 |
Silent rounding past MAX_SAFE_INTEGER |
-5, 1000.005 |
-5, 1000.005 |
No sign or scale validation, ever |
The OpenAPI contract is explicit that amounts are decimal strings
(Bond.amount: "decimal string (avoids float precision issues)"), and the
UI helper normalizeUSDC (src/lib/format.ts) rounds to 2 decimals via
Number.toFixed(2) — so nothing before this change could guarantee that the
amount a user saw, the amount a review showed, and the amount the backend
received were the same number.
Two pieces, mirroring the existing rateLimit.ts + client.ts pattern:
-
src/api/amount.ts— exact decimal engine. Parsing, validation, and canonical serialization using only string-digit manipulation andBigIntarithmetic. No floating-point operation ever touches an amount value. Numbers are interpreted as their shortest round-trip decimal string (String(n)); exponent forms (1e-7,1e+21) are expanded exactly by digit shifting. -
src/api/client.ts— theamountFieldsgate. An opt-in request option that declares which body fields are decimal amounts. The gate runs first inapiFetch— before the rate limiter and beforefetch— validates each declared field, and replaces it with its canonical decimal string on the wire.
import { apiFetch } from '@/api'
await apiFetch('/bonds', {
method: 'POST',
body: { borrower: address, amount: bondAmount }, // '1000.5' | 1000.5 | 1000n
amountFields: { amount: { min: '1.00' } },
})
// Wire body: {"borrower":"G…","amount":"1000.50"} ← exact, canonical string- Exactness — a declared amount leaves the client as a plain decimal
string with exactly
rules.scalefractional digits (1000.5→'1000.50'), no exponent, no leading zeros. - No rounding, ever — excess precision is rejected
(
INVALID_SCALE), never rounded or truncated the waytoFixed(2)does.0.1 + 0.2is rejected, not sent as0.30000000000000004or coerced to0.30. - Unsigned by design — negative values are rejected (
NEGATIVE); debits/credits are separate operations, not signed values. - Bounded magnitude — the scaled integer (
value × 10^scale) must fit in a signed 64-bit integer (9223372036854775807), the Soroban/Stellar on-chain amount representation, and within optionalmin/maxrules. At scale 2 the ceiling is92233720368547758.07(exactly2^63 − 1cents); at scale 7 it is922337203685.4775807. - Reject before state change — an invalid amount throws
ApiAmountErrorbefore the rate limiter is consulted and beforefetchis called: no budget consumed, no network traffic, no partial or unauthorized server-side state. - No caller mutation — the caller's body object is never modified; the gate builds a shallow copy for the wire. Sibling fields and their JSON types pass through untouched.
- Determinism — identical inputs produce byte-identical wire bodies
across repeated and concurrent calls; the check order is fixed
(
INVALID_RULES→INVALID_TYPE→EMPTY→INVALID_FORMAT/NOT_FINITE/NEGATIVE→INVALID_SCALE→BELOW_MIN→OVERFLOW). - Strict grammar — plain unsigned decimals only:
/^\d+(\.\d+)?$/after trimming. Signs (+/-), exponents, grouping separators, non-ASCII digits, and empty strings are all rejected with typed codes. - Opt-in compatibility — calls that do not declare
amountFieldsare byte-for-byte identical to the previous behavior (including sending raw numbers), so no existing caller can break.
Rejections throw ApiAmountError extends ApiError with a synthetic
status: 400 (mirroring how ApiRateLimitError surfaces a client-side
rejection as 429), so existing instanceof ApiError handlers keep working:
err instanceof ApiError // true — existing handlers unchanged
err instanceof ApiAmountError
err.status // 400 (synthetic; the request never left the client)
err.field // 'amount' (null for body-level rejections)
err.code // see below
err.payload // { field, code }| Code | Meaning |
|---|---|
INVALID_BODY |
amountFields declared but the body is not a JSON object |
MISSING |
A declared field is absent or undefined on the body |
INVALID_TYPE |
Field holds null, a boolean, an object, … |
EMPTY |
Empty / whitespace-only string |
INVALID_FORMAT |
Not a plain unsigned decimal ('abc', '1e3', '1,000', '+1') |
NOT_FINITE |
NaN / ±Infinity (would otherwise serialize as null) |
NEGATIVE |
Negative sign |
INVALID_SCALE |
More fractional digits than rules.scale allows (rejected, not rounded) |
BELOW_MIN |
Below the configured min |
OVERFLOW |
Above the configured max or the int64 scaled-integer bound |
INVALID_RULES |
The rules object itself is misconfigured (bad scale, unparsable bound, min > max) |
Declared fields are required: if a field is absent the call rejects with
MISSING rather than silently sending nothing. Send optional amounts by
declaring the field only on calls that include it.
- No behavior change for existing callers. The gate is opt-in; every
current call site in the repo (settings PATCH, queries) omits
amountFieldsand produces identical requests. The full pre-existingclient.test.tssuite passes unmodified. - For amount-carrying calls (current and future): declared values are
serialized as strings, matching the
Bond.amountOpenAPI contract. Callers that previously sent a raw number amount should expect the string form on the backend side once they opt in — this is the documented contract, not a regression. Number(-0)vs'-0': the number-0canonicalizes to'0.00'(IEEE-754-0 === 0,String(-0) === '0'), while the string'-0'is rejected asNEGATIVE. This asymmetry is intentional and tested.- Rollback: revert the commit. No persisted state, no wire-format migration, and no backend coordination is involved; the feature is purely client-side and opt-in.
- Top-level object bodies only.
amountFieldsresolves keys on the body object itself (no nested paths, no array bodies). Nested amount objects should be validated withparseAmountbefore being placed in the body. - Request-side only. Response amounts (e.g.
Transaction.amountUsdc, typednumberin the spec) are still parsed byJSON.parseand subject to double rounding at extreme magnitudes. UseparseAmount/tryParseAmount(which accept string, number, and bigint) when consuming response amounts that must be exact; a spec-level change to decimal strings for response fields is the long-term fix. - Scale, not denomination, is validated. The engine guarantees decimal shape and bounds; it does not know exchange rates or issuer metadata.
- The int64 ceiling is the on-chain representability bound, not a
business limit. Set
max(e.g.{ max: '1000000.00' }) for product-level caps.
- The client-side gate is a correctness and defence-in-depth control. The backend remains the authority on authorization: it must independently re-validate amounts (scale, sign, bounds, balance sufficiency) before any state change. Never rely on client validation for security.
- Rejection happens before
fetch, so invalid amounts cannot consume rate-limit budget or leak request contents to the network. - Error messages embed a truncated (≤ 40 char) preview of the offending value; they never echo full user input into logs.
- Amounts are unsigned, which removes an entire class of sign-flip/abs() mistakes from the request path.
src/api/amount.test.ts— unit + property coverage, including zero, minimum, maximum, near-overflow, fractional, and conversion-boundary values (Number.MAX_SAFE_INTEGER,2^53,2^63,1e21,1e-7,0.1 + 0.2) checked against an independent oracle (string-digit and char-walk implementations that deliberately differ from the production algorithm). Property tests use a fixed seed (mulberry32) so they are deterministic and cannot flake.src/api/client.test.ts— integration coverage at the realapiFetch→fetchboundary: byte-exact wire bodies, rejection beforefetch, no rate-limit budget consumption on rejection, no caller-body mutation, repeated/concurrent determinism, and network-failure isolation.
Run them with:
npx vitest run src/api