Skip to content

Commit 6abe2ca

Browse files
brawlaphantclaude
andcommitted
fix: bigint micro-credit math in order-selector (audit C1)
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>
1 parent 66e0677 commit 6abe2ca

2 files changed

Lines changed: 249 additions & 14 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
3+
vi.mock("../services/ledger.js", () => ({
4+
listSellOrders: vi.fn(),
5+
listCreditClasses: vi.fn(),
6+
listBatches: vi.fn(),
7+
getAllowedDenoms: vi.fn(),
8+
}));
9+
10+
import { selectBestOrders, __orderSelectorInternals } from "../services/order-selector.js";
11+
import * as ledger from "../services/ledger.js";
12+
13+
const { parseQuantityToMicro, formatMicroToQuantity, numberToMicro, QUANTITY_SCALE } =
14+
__orderSelectorInternals;
15+
16+
describe("quantity micro conversion", () => {
17+
it("parses integer quantities", () => {
18+
expect(parseQuantityToMicro("0")).toBe(0n);
19+
expect(parseQuantityToMicro("1")).toBe(1_000_000n);
20+
expect(parseQuantityToMicro("1234")).toBe(1_234_000_000n);
21+
});
22+
23+
it("parses fractional quantities exactly", () => {
24+
expect(parseQuantityToMicro("0.5")).toBe(500_000n);
25+
expect(parseQuantityToMicro("0.000001")).toBe(1n);
26+
expect(parseQuantityToMicro("10.500000")).toBe(10_500_000n);
27+
// 0.1 + 0.2 in float = 0.30000000000000004; in our parser it's exact:
28+
expect(parseQuantityToMicro("0.1") + parseQuantityToMicro("0.2")).toBe(parseQuantityToMicro("0.3"));
29+
});
30+
31+
it("truncates beyond 6 decimal places (sub-micro is not representable)", () => {
32+
expect(parseQuantityToMicro("0.1234567")).toBe(123_456n);
33+
expect(parseQuantityToMicro("1.99999999")).toBe(1_999_999n);
34+
});
35+
36+
it("rejects malformed input", () => {
37+
expect(() => parseQuantityToMicro("abc")).toThrow();
38+
expect(() => parseQuantityToMicro("1.2.3")).toThrow();
39+
expect(() => parseQuantityToMicro("")).toThrow();
40+
});
41+
42+
it("formatMicroToQuantity round-trips", () => {
43+
for (const s of ["0.000000", "1.000000", "10.500000", "0.000001", "999.999999"]) {
44+
expect(formatMicroToQuantity(parseQuantityToMicro(s))).toBe(s);
45+
}
46+
});
47+
48+
it("numberToMicro rounds half to nearest", () => {
49+
expect(numberToMicro(1)).toBe(1_000_000n);
50+
expect(numberToMicro(0.5)).toBe(500_000n);
51+
expect(numberToMicro(0.0000005)).toBe(1n); // 0.5 micro rounds up
52+
expect(numberToMicro(0.0000004)).toBe(0n);
53+
});
54+
55+
it("QUANTITY_SCALE is 1_000_000n", () => {
56+
expect(QUANTITY_SCALE).toBe(1_000_000n);
57+
});
58+
});
59+
60+
const allowedDenoms = [
61+
{ bank_denom: "uregen", display_denom: "REGEN", exponent: 6 },
62+
];
63+
const carbonClass = { id: "C01", credit_type_abbrev: "C" };
64+
65+
function setup(orders: ledger.SellOrder[]) {
66+
vi.mocked(ledger.listSellOrders).mockResolvedValue(orders);
67+
vi.mocked(ledger.listCreditClasses).mockResolvedValue([carbonClass as ledger.CreditClass]);
68+
vi.mocked(ledger.getAllowedDenoms).mockResolvedValue(allowedDenoms as ledger.AllowedDenom[]);
69+
}
70+
71+
function order(over: Partial<ledger.SellOrder> = {}): ledger.SellOrder {
72+
return {
73+
id: "1",
74+
seller: "regen1...",
75+
batch_denom: "C01-001-20240101-20241231-001",
76+
quantity: "100.000000",
77+
ask_denom: "uregen",
78+
ask_amount: "1000000", // 1 REGEN per credit
79+
disable_auto_retire: false,
80+
expiration: null,
81+
...over,
82+
};
83+
}
84+
85+
describe("selectBestOrders greedy fill (audit C1)", () => {
86+
it("fills exactly when supply matches request", async () => {
87+
setup([order({ quantity: "5.000000" })]);
88+
const sel = await selectBestOrders("carbon", 5);
89+
expect(sel.orders).toHaveLength(1);
90+
expect(sel.orders[0].quantity).toBe("5.000000");
91+
expect(sel.totalQuantity).toBe("5.000000");
92+
expect(sel.totalCostMicro).toBe(5_000_000n); // 5 credits × 1 REGEN
93+
expect(sel.insufficientSupply).toBe(false);
94+
});
95+
96+
it("walks across multiple orders cheapest-first", async () => {
97+
setup([
98+
order({ id: "a", ask_amount: "2000000", quantity: "10.000000" }),
99+
order({ id: "b", ask_amount: "1000000", quantity: "3.000000" }),
100+
order({ id: "c", ask_amount: "1500000", quantity: "5.000000" }),
101+
]);
102+
// Want 7 credits. Cheapest-first: 3 from b (@1), 5→remaining 4 from c (@1.5),
103+
// total_cost = 3*1 + 4*1.5 = 9 REGEN = 9_000_000 uregen.
104+
const sel = await selectBestOrders("carbon", 7);
105+
expect(sel.orders.map((o) => o.sellOrderId)).toEqual(["b", "c"]);
106+
expect(sel.orders[0].quantity).toBe("3.000000");
107+
expect(sel.orders[1].quantity).toBe("4.000000");
108+
expect(sel.totalQuantity).toBe("7.000000");
109+
expect(sel.totalCostMicro).toBe(9_000_000n);
110+
expect(sel.insufficientSupply).toBe(false);
111+
});
112+
113+
it("flags insufficient supply when total available < request", async () => {
114+
setup([order({ quantity: "2.000000" })]);
115+
const sel = await selectBestOrders("carbon", 5);
116+
expect(sel.insufficientSupply).toBe(true);
117+
expect(sel.totalQuantity).toBe("2.000000");
118+
});
119+
120+
it("skips orders with malformed quantity rather than blowing up", async () => {
121+
setup([
122+
order({ id: "good", quantity: "5.000000" }),
123+
order({ id: "bad", quantity: "not-a-number" }),
124+
]);
125+
const sel = await selectBestOrders("carbon", 5);
126+
expect(sel.orders.map((o) => o.sellOrderId)).toEqual(["good"]);
127+
expect(sel.insufficientSupply).toBe(false);
128+
});
129+
130+
it("does not accumulate float error across the greedy fill (audit C1)", async () => {
131+
// 10 orders of 0.1 each, request 1.0. With float arithmetic this used to
132+
// leave 1.0 - 10*0.1 = 0.0000000000000001 and could trigger spurious
133+
// insufficientSupply or off-by-one micro-cost. With bigint micro it
134+
// sums exactly.
135+
setup(
136+
Array.from({ length: 10 }, (_, i) =>
137+
order({ id: String(i), quantity: "0.100000", ask_amount: "1000000" })
138+
)
139+
);
140+
const sel = await selectBestOrders("carbon", 1.0);
141+
expect(sel.totalQuantity).toBe("1.000000");
142+
expect(sel.totalCostMicro).toBe(1_000_000n);
143+
expect(sel.insufficientSupply).toBe(false);
144+
});
145+
146+
it("computes exact cost with fractional take and high-precision price", async () => {
147+
setup([order({ quantity: "10.000000", ask_amount: "1234567" })]);
148+
// Take 0.5 credits at 1.234567 REGEN/credit.
149+
// Exact micro = 1234567 * 500000 / 1000000 = 617283.5 → ceil → 617284
150+
const sel = await selectBestOrders("carbon", 0.5);
151+
expect(sel.orders[0].quantity).toBe("0.500000");
152+
expect(sel.orders[0].costMicro).toBe(617284n);
153+
expect(sel.totalCostMicro).toBe(617284n);
154+
});
155+
156+
it("rounds cost UP so the buyer covers the sub-micro remainder", async () => {
157+
// Pick numbers where the exact micro division is non-integer.
158+
setup([order({ quantity: "10.000000", ask_amount: "3" })]);
159+
const sel = await selectBestOrders("carbon", 0.000001);
160+
// Exact = 3 * 1 / 1_000_000 = 3e-6 → ceil to 1
161+
expect(sel.orders[0].costMicro).toBe(1n);
162+
});
163+
});

src/services/order-selector.ts

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,71 @@
33
*
44
* Finds the cheapest sell orders that match criteria and fills
55
* across multiple orders if needed.
6+
*
7+
* All quantity arithmetic is done in bigint micro-credits (1 credit =
8+
* 1_000_000 micro-credits) — the same scale used by the chain. parseFloat()
9+
* was removed from the greedy fill loop (audit C1): float subtraction
10+
* accumulated rounding errors across iterations, and float-multiply-before-
11+
* BigInt produced spurious sub-micro digits like 100000.00000000001.
612
*/
713

814
import { listSellOrders, listCreditClasses, listBatches, getAllowedDenoms } from "./ledger.js";
915
import type { SellOrder, CreditClass, AllowedDenom } from "./ledger.js";
1016

17+
/** Internal scale for credit quantities. The chain reports balances at this exponent. */
18+
const QUANTITY_EXPONENT = 6;
19+
const QUANTITY_SCALE = 10n ** BigInt(QUANTITY_EXPONENT); // 1_000_000n
20+
21+
/**
22+
* Parse a decimal credit quantity string ("10.5", "0.000001") into bigint
23+
* micro-credits. Throws on malformed input. Truncates at the 6th decimal
24+
* place — anything beyond that is sub-micro and cannot be represented.
25+
*/
26+
function parseQuantityToMicro(decimal: string): bigint {
27+
const trimmed = decimal.trim();
28+
if (!/^-?\d+(\.\d+)?$/.test(trimmed)) {
29+
throw new Error(`Invalid decimal quantity: ${JSON.stringify(decimal)}`);
30+
}
31+
const negative = trimmed.startsWith("-");
32+
const body = negative ? trimmed.slice(1) : trimmed;
33+
const [whole, frac = ""] = body.split(".");
34+
const fracPadded = (frac + "0".repeat(QUANTITY_EXPONENT)).slice(0, QUANTITY_EXPONENT);
35+
const micro = BigInt(whole) * QUANTITY_SCALE + BigInt(fracPadded || "0");
36+
return negative ? -micro : micro;
37+
}
38+
39+
/**
40+
* Render a bigint micro-credit count back to a fixed-precision decimal string
41+
* (e.g. 10500000n → "10.500000"). Always shows QUANTITY_EXPONENT decimals so
42+
* the output round-trips through parseQuantityToMicro.
43+
*/
44+
function formatMicroToQuantity(micro: bigint): string {
45+
const negative = micro < 0n;
46+
const abs = negative ? -micro : micro;
47+
const whole = abs / QUANTITY_SCALE;
48+
const frac = abs % QUANTITY_SCALE;
49+
const fracStr = frac.toString().padStart(QUANTITY_EXPONENT, "0");
50+
return `${negative ? "-" : ""}${whole}.${fracStr}`;
51+
}
52+
53+
/** Convert a JS number quantity (from MCP/REST callers) to bigint micro-credits. */
54+
function numberToMicro(quantity: number): bigint {
55+
if (!Number.isFinite(quantity) || quantity < 0) {
56+
throw new Error(`Invalid numeric quantity: ${quantity}`);
57+
}
58+
// Single float touch: round to the nearest micro-credit. Bounded error of 0.5
59+
// micro is the unavoidable cost of accepting a JS `number` from the caller.
60+
return BigInt(Math.round(quantity * Number(QUANTITY_SCALE)));
61+
}
62+
63+
/** Test-only exports — these helpers are pure and worth covering directly. */
64+
export const __orderSelectorInternals = {
65+
parseQuantityToMicro,
66+
formatMicroToQuantity,
67+
numberToMicro,
68+
QUANTITY_SCALE,
69+
};
70+
1171
export interface OrderSelection {
1272
orders: SelectedOrder[];
1373
totalQuantity: string;
@@ -112,47 +172,59 @@ export async function selectBestOrders(
112172
return 0;
113173
});
114174

115-
// Fill from cheapest available orders
116-
let remaining = quantity;
175+
// Fill from cheapest available orders. All arithmetic is bigint micro-credits.
176+
// Cost formula: cost_micro = ask_amount * quantity_micro / QUANTITY_SCALE
177+
// (ask_amount is per-credit in micro-payment-units; dividing by QUANTITY_SCALE
178+
// converts micro-credits → credits in the multiplication.) We round UP on the
179+
// division so partial sub-micro costs are charged to the buyer, not absorbed.
180+
const requestedMicro = numberToMicro(quantity);
181+
let remainingMicro = requestedMicro;
117182
const selected: SelectedOrder[] = [];
118183
let totalCostMicro = 0n;
119184
let insufficientSupply = false;
120185

121186
for (const order of eligible) {
122-
if (remaining <= 0) break;
187+
if (remainingMicro <= 0n) break;
123188

124-
const available = parseFloat(order.quantity);
125-
if (available <= 0) continue;
189+
let availableMicro: bigint;
190+
try {
191+
availableMicro = parseQuantityToMicro(order.quantity);
192+
} catch {
193+
continue; // Skip malformed quantities rather than fail the whole selection.
194+
}
195+
if (availableMicro <= 0n) continue;
126196

127-
const take = Math.min(remaining, available);
197+
const takeMicro = remainingMicro < availableMicro ? remainingMicro : availableMicro;
128198
const pricePerCredit = BigInt(order.ask_amount);
129-
// Cost = quantity * price_per_credit (ask_amount is in micro-units)
130-
// Since quantity can be fractional, compute cost carefully
131-
const costMicro = (pricePerCredit * BigInt(Math.ceil(take * 1_000_000))) / 1_000_000n;
199+
200+
// Ceiling division: (a + b - 1) / b
201+
const numer = pricePerCredit * takeMicro;
202+
const costMicro = (numer + QUANTITY_SCALE - 1n) / QUANTITY_SCALE;
132203

133204
selected.push({
134205
sellOrderId: order.id,
135206
batchDenom: order.batch_denom,
136-
quantity: take.toFixed(6),
207+
quantity: formatMicroToQuantity(takeMicro),
137208
askAmount: order.ask_amount,
138209
askDenom: order.ask_denom,
139210
costMicro,
140211
disableAutoRetire: order.disable_auto_retire,
141212
});
142213

143214
totalCostMicro += costMicro;
144-
remaining -= take;
215+
remainingMicro -= takeMicro;
145216
}
146217

147-
if (remaining > 0.000001) {
218+
// Insufficient if more than one micro-credit short.
219+
if (remainingMicro > 1n) {
148220
insufficientSupply = true;
149221
}
150222

151-
const actualQuantity = quantity - Math.max(remaining, 0);
223+
const actualMicro = requestedMicro - (remainingMicro > 0n ? remainingMicro : 0n);
152224

153225
return {
154226
orders: selected,
155-
totalQuantity: actualQuantity.toFixed(6),
227+
totalQuantity: formatMicroToQuantity(actualMicro),
156228
totalCostMicro,
157229
paymentDenom: denomInfo.bankDenom,
158230
displayDenom: denomInfo.displayDenom,

0 commit comments

Comments
 (0)