BigInt micro-credit math in order-selector (audit C1)#135
Conversation
Closes part of audit C1-HIGH (the order-selector hot path; pool.ts
and evm-wallet.ts remain for follow-up PRs). Previously the greedy
fill loop used parseFloat() and JS numbers throughout:
src/services/order-selector.ts:124 parseFloat(order.quantity)
src/services/order-selector.ts:127 Math.min(remaining, available)
src/services/order-selector.ts:131 BigInt(Math.ceil(take * 1_000_000))
The audit flagged three concrete failure modes:
- sell-order quantity strings with >15 significant digits lose precision
when parsed through `parseFloat`
- float subtraction in `remaining -= take` accumulates rounding error
across iterations of the greedy fill
- `take * 1_000_000` produces results like 100000.00000000001, then
Math.ceil rounds the spurious digit UP and over-charges by 1 micro
This rewrites the loop to use bigint micro-credits end-to-end. Adds
three small pure helpers:
- parseQuantityToMicro(decimal: string): bigint
- formatMicroToQuantity(micro: bigint): string
- numberToMicro(quantity: number): bigint
The single float touch is `numberToMicro` at the entry, where a JS
`number` from the caller is rounded to the nearest micro-credit.
That's the unavoidable cost of accepting `quantity: number` at the
public API; everywhere else the math is exact.
Cost rounding now uses ceiling division so any sub-micro fraction
is charged to the buyer rather than absorbed by the seller (matches
how on-chain MsgBuyDirect would settle).
Tests: 14 new vitest cases. Coverage:
- parseQuantityToMicro: integer / fractional / sub-micro truncation
/ malformed input rejection / 0.1 + 0.2 = 0.3 exactness
- formatMicroToQuantity round-trip
- numberToMicro half-rounding behavior
- selectBestOrders: exact fill, multi-order cheapest-first walk,
insufficient supply, malformed-quantity skip-don't-fail
- REGRESSION: 10 × 0.1 sums to exactly 1.0 (no float drift)
- exact cost for fractional take with high-precision price
- cost rounds UP not down
Full suite: 63/63 passing (49 prior + 14 new).
The internal helpers are exported under __orderSelectorInternals
for direct unit testing — they're pure, simple, and worth covering.
Refs: AUDIT.md C1-HIGH (order-selector portion)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request replaces floating-point arithmetic with BigInt micro-credits in the order selection logic to prevent rounding errors and ensure exact calculations. Key changes include the introduction of helper functions for parsing, formatting, and converting quantities to micro-credits, as well as updating the greedy fill loop to use these helpers. Feedback focuses on refining the insufficientSupply check to be more precise now that BigInt is used, improving the robustness of the decimal parsing regex to handle common input formats like leading or trailing dots, and addressing a behavioral inconsistency between string truncation and numeric rounding when handling sub-micro quantities.
| if (remainingMicro > 1n) { | ||
| insufficientSupply = true; | ||
| } |
There was a problem hiding this comment.
The condition remainingMicro > 1n allows the selection to be up to 1 micro-credit short without flagging insufficientSupply. While this might have been a workaround for floating-point inaccuracies in the previous implementation, it is no longer necessary with BigInt. If the requested quantity cannot be fully met (even by 1 micro), insufficientSupply should be true to ensure the caller is aware of the shortfall.
| if (remainingMicro > 1n) { | |
| insufficientSupply = true; | |
| } | |
| if (remainingMicro > 0n) { | |
| insufficientSupply = true; | |
| } |
| */ | ||
| function parseQuantityToMicro(decimal: string): bigint { | ||
| const trimmed = decimal.trim(); | ||
| if (!/^-?\d+(\.\d+)?$/.test(trimmed)) { |
There was a problem hiding this comment.
The regex /^-?\d+(\.\d+)?$/ is more restrictive than standard decimal parsing as it rejects strings with leading dots (e.g., .5) or trailing dots (e.g., 1.). Consider using a more inclusive regex to match common user input formats. Note that if you update this, you should also handle empty whole parts in the BigInt conversion on line 35.
| if (!/^-?\d+(\.\d+)?$/.test(trimmed)) { | |
| if (!/^-?(\d+(\.\d*)?|\.\d+)$/.test(trimmed)) { |
| const negative = trimmed.startsWith("-"); | ||
| const body = negative ? trimmed.slice(1) : trimmed; | ||
| const [whole, frac = ""] = body.split("."); | ||
| const fracPadded = (frac + "0".repeat(QUANTITY_EXPONENT)).slice(0, QUANTITY_EXPONENT); |
There was a problem hiding this comment.
There is a behavioral inconsistency between parseQuantityToMicro and numberToMicro. parseQuantityToMicro truncates digits beyond the 6th decimal place, while numberToMicro rounds to the nearest micro-credit using Math.round. For example, the string "0.0000006" parses to 0n, but the number 0.0000006 converts to 1n. It is recommended to use a consistent strategy for both to avoid unexpected discrepancies depending on the input type.
| const body = negative ? trimmed.slice(1) : trimmed; | ||
| const [whole, frac = ""] = body.split("."); | ||
| const fracPadded = (frac + "0".repeat(QUANTITY_EXPONENT)).slice(0, QUANTITY_EXPONENT); | ||
| const micro = BigInt(whole) * QUANTITY_SCALE + BigInt(fracPadded || "0"); |
There was a problem hiding this comment.
If the regex on line 28 is updated to allow leading dots (like .5), the whole variable will be an empty string. BigInt("") throws a SyntaxError, so it is safer to provide a fallback to "0".
| const micro = BigInt(whole) * QUANTITY_SCALE + BigInt(fracPadded || "0"); | |
| const micro = BigInt(whole || "0") * QUANTITY_SCALE + BigInt(fracPadded || "0"); |
Closes part of audit C1-HIGH (the order-selector hot path).
pool.tsandevm-wallet.tshave similar issues but wider blast radius — happy to take those as separate PRs.Problem
Audit (
AUDIT.md) flagged three concrete failure modes in the greedy fill loop:order-selector.ts:124parseFloat(order.quantity)loses precision on strings with >15 significant digitsorder-selector.ts:127Math.min(remaining, available)thenremaining -= take— float subtraction accumulates rounding error across iterationsorder-selector.ts:131BigInt(Math.ceil(take * 1_000_000))—0.1 * 1_000_000 = 100000.00000000001, thenMath.ceilrounds the spurious digit UP and over-charges by 1 microWhat this PR does
Rewrites the loop to use bigint micro-credits (1 credit = 1_000_000 micro) end-to-end. The single float touch is
numberToMicroat the public entry point, where a JSnumberfrom the caller is rounded to the nearest micro. That's the unavoidable cost of acceptingquantity: numberfrom MCP/REST callers; everywhere else is exact.Three small pure helpers (exported under
__orderSelectorInternalsfor direct testing):parseQuantityToMicro(decimal: string): bigint— string "10.5" → bigint10_500_000nformatMicroToQuantity(micro: bigint): string— bigint → fixed-precision stringnumberToMicro(quantity: number): bigint—Math.round(quantity * 1_000_000), error-boundedCost rounding now uses ceiling division (
(a + scale - 1) / scale) so any sub-micro fraction is charged to the buyer rather than absorbed by the seller — matches how on-chainMsgBuyDirectsettles.Tests
14 new cases. Highlight: a regression test that sums 10 × 0.1 credits and asserts it equals exactly 1.0 (with
parseFloat/Math.ceil, the previous code drifted by 1 micro).Full suite: 63/63 passing (49 prior + 14 new).
Test plan
npm run typecheck— cleannpm test— 63/63 passingnpm run build— cleanselectBestOrderswas relying on the oldparseFloat-style total — all callers I traced (retirement.ts,retire-subscriber.ts) consumetotalCostMicroas bigint andtotalQuantityas a string, both of which keep working.Out of scope (audit C1 follow-ups)
pool.ts:324parseFloat(finalSelection.totalQuantity)for displaypool.ts:388sub.amount_cents / totalRevenueCentsfor attribution fractionsretirement.ts:169parseFloat(selection.totalQuantity)for displayevm-wallet.ts:110Math.round(amountUsdc * 10 ** decimals)Each of those can be a focused PR. The order-selector site was the one the audit flagged as highest-risk because of the multi-iteration accumulation.
Refs:
AUDIT.mdC1-HIGH🤖 Generated with Claude Code