From f1922ae0732471e052b83ef2244d30b925a31c9a Mon Sep 17 00:00:00 2001 From: pq198363-ops <246611021+pq198363-ops@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:05:54 +0800 Subject: [PATCH] feat: use bigint for stroops billing --- README.md | 4 +- docs/billing-units.md | 22 ++++--- src/billing-stroops.test.ts | 78 +++++++++++++++++++++++ src/billing-total-breakdown.test.ts | 16 ++--- src/health.test.ts | 4 +- src/routes/meta.ts | 96 ++++++++++++++++++++++++++++- src/routes/operational.test.ts | 8 +++ src/routes/usage.test.ts | 6 +- src/routes/usage.ts | 22 ++++--- src/util/stroops.test.ts | 23 +++++++ src/util/stroops.ts | 21 +++++++ 11 files changed, 265 insertions(+), 35 deletions(-) create mode 100644 src/billing-stroops.test.ts create mode 100644 src/util/stroops.test.ts create mode 100644 src/util/stroops.ts diff --git a/README.md b/README.md index 337ee4f..2a3490a 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ BASE_URL=http://localhost:3001 "serviceId": "embedding-v1", "requests": 10, "priceStroops": 25, - "billedStroops": 250 + "billedStroops": "250" } ``` @@ -210,7 +210,7 @@ BASE_URL=http://localhost:3001 "serviceId": "embedding-v1", "requests": 10, "priceStroops": 25, - "billedStroops": 250 + "billedStroops": "250" } ``` diff --git a/docs/billing-units.md b/docs/billing-units.md index 90b47eb..52db987 100644 --- a/docs/billing-units.md +++ b/docs/billing-units.md @@ -23,14 +23,13 @@ bill is `2,000,000` stroops, or `0.2 XLM`. Stellar amounts are precise to seven decimal places. Keeping backend prices and bills in integer stroops avoids floating-point rounding when usage counters are multiplied by prices. API consumers should treat `priceStroops`, -`billedStroops`, and `totalStroops` as integer ledger units and convert to XLM -only for display. +`billedStroops`, `totalStroops`, and `disabledStroops` as integer ledger units +and convert to XLM only for display. -The current implementation stores counters and billing values as JavaScript -numbers. `POST /api/v1/usage` clamps request counters at -`Number.MAX_SAFE_INTEGER`. Future bigint-backed precision work should preserve -the public stroops convention while avoiding number precision limits for very -large counters and bills. +`POST /api/v1/usage` keeps request counters as JSON numbers and clamps them at +`Number.MAX_SAFE_INTEGER`. Billing amounts are computed with `BigInt` and +serialized as decimal strings so values above JavaScript's safe integer range +remain exact in JSON responses. ## Endpoint Semantics @@ -40,14 +39,16 @@ Returns the current quote for one agent and service pair: - `requests`: outstanding recorded requests. - `priceStroops`: service price per request. -- `billedStroops`: `requests * priceStroops`. +- `billedStroops`: `requests * priceStroops`, serialized as a decimal string. This endpoint is read-only and does not change counters or transfer funds. ### `GET /api/v1/billing/total` Returns `totalStroops`, the sum of all outstanding usage counters multiplied by -their service prices. This is also read-only and does not transfer funds. +their service prices, serialized as a decimal string. Disabled priced usage is +also returned as decimal-string `disabledStroops`. This is read-only and does +not transfer funds. ### `POST /api/v1/settle` @@ -59,6 +60,9 @@ The backend settle endpoint is an off-chain accounting operation. It: 4. Returns `{ agent, serviceId, requests, priceStroops, billedStroops }`. 5. Records a `usage.settled` audit event. +`billedStroops` is a decimal string in the response. This is intentionally a +breaking change from numeric JSON amounts to avoid silent precision loss. + It does **not** move XLM, tokens, or any other on-chain value. A successful response means the backend drained its in-memory accumulator and quoted the amount that should be settled elsewhere. diff --git a/src/billing-stroops.test.ts b/src/billing-stroops.test.ts new file mode 100644 index 0000000..46b9a8e --- /dev/null +++ b/src/billing-stroops.test.ts @@ -0,0 +1,78 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert"; +import express from "express"; +import request from "supertest"; +import { createUsageRouter } from "./routes/usage.js"; +import { servicesDisabled, servicesStore, usageStore } from "./store/state.js"; + +function createIsolatedBillingApp() { + const app = express(); + app.use(express.json()); + app.use(createUsageRouter()); + return app; +} + +beforeEach(() => { + servicesDisabled.clear(); + servicesStore.clear(); + usageStore.clear(); +}); + +void describe("BigInt stroops billing responses", () => { + void it("returns exact decimal-string billedStroops for pair billing", async () => { + const app = createIsolatedBillingApp(); + servicesStore.set("svc-expensive", { priceStroops: 10_000_000 }); + usageStore.set("agent-big::svc-expensive", Number.MAX_SAFE_INTEGER); + + const res = await request(app).get("/api/v1/billing/agent-big/svc-expensive"); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.requests, Number.MAX_SAFE_INTEGER); + assert.strictEqual(res.body.priceStroops, 10_000_000); + assert.strictEqual(res.body.billedStroops, "90071992547409910000000"); + }); + + void it("returns exact decimal-string billedStroops from settle and drains usage", async () => { + const app = createIsolatedBillingApp(); + servicesStore.set("svc-settle", { priceStroops: 9_999_999 }); + usageStore.set("agent-settle::svc-settle", Number.MAX_SAFE_INTEGER); + + const res = await request(app) + .post("/api/v1/settle") + .send({ agent: "agent-settle", serviceId: "svc-settle" }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.billedStroops, "90071983540210655259009"); + assert.strictEqual(usageStore.get("agent-settle::svc-settle"), 0); + }); + + void it("returns exact decimal-string totals across priced and disabled usage", async () => { + const app = createIsolatedBillingApp(); + servicesStore.set("svc-enabled", { priceStroops: 10_000_000 }); + servicesStore.set("svc-disabled", { priceStroops: 2 }); + servicesDisabled.add("svc-disabled"); + usageStore.set("agent-a::svc-enabled", Number.MAX_SAFE_INTEGER); + usageStore.set("agent-b::svc-disabled", Number.MAX_SAFE_INTEGER); + usageStore.set("agent-c::svc-deleted", 7); + + const res = await request(app).get("/api/v1/billing/total"); + + assert.strictEqual(res.status, 200); + assert.deepStrictEqual(res.body, { + totalStroops: "90072010561808419481982", + disabledStroops: "18014398509481982", + unpricedRequests: 7, + }); + }); + + void it("keeps zero-price service billing as a string zero", async () => { + const app = createIsolatedBillingApp(); + servicesStore.set("svc-free", { priceStroops: 0 }); + usageStore.set("agent-free::svc-free", Number.MAX_SAFE_INTEGER); + + const res = await request(app).get("/api/v1/billing/agent-free/svc-free"); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.billedStroops, "0"); + }); +}); diff --git a/src/billing-total-breakdown.test.ts b/src/billing-total-breakdown.test.ts index 366dec5..93b1b50 100644 --- a/src/billing-total-breakdown.test.ts +++ b/src/billing-total-breakdown.test.ts @@ -26,8 +26,8 @@ void describe("billing total breakdown", () => { assert.strictEqual(res.status, 200); assert.deepStrictEqual(res.body, { - totalStroops: 0, - disabledStroops: 0, + totalStroops: "0", + disabledStroops: "0", unpricedRequests: 0, }); }); @@ -43,8 +43,8 @@ void describe("billing total breakdown", () => { assert.strictEqual(res.status, 200); assert.deepStrictEqual(res.body, { - totalStroops: 130, - disabledStroops: 0, + totalStroops: "130", + disabledStroops: "0", unpricedRequests: 0, }); }); @@ -61,8 +61,8 @@ void describe("billing total breakdown", () => { assert.strictEqual(res.status, 200); assert.deepStrictEqual(res.body, { - totalStroops: 101, - disabledStroops: 66, + totalStroops: "101", + disabledStroops: "66", unpricedRequests: 0, }); }); @@ -80,8 +80,8 @@ void describe("billing total breakdown", () => { assert.strictEqual(res.status, 200); assert.deepStrictEqual(res.body, { - totalStroops: 62, - disabledStroops: 10, + totalStroops: "62", + disabledStroops: "10", unpricedRequests: 8, }); }); diff --git a/src/health.test.ts b/src/health.test.ts index ab26f2b..6cdfe4a 100644 --- a/src/health.test.ts +++ b/src/health.test.ts @@ -82,12 +82,12 @@ void describe("AgentPay Backend", () => { assert.strictEqual(quote.status, 200); assert.strictEqual(quote.body.requests, 10); assert.strictEqual(quote.body.priceStroops, 50); - assert.strictEqual(quote.body.billedStroops, 500); + assert.strictEqual(quote.body.billedStroops, "500"); const settle = await request(app) .post("/api/v1/settle") .send({ agent: "agent-bill", serviceId: "svc-bill" }); - assert.strictEqual(settle.body.billedStroops, 500); + assert.strictEqual(settle.body.billedStroops, "500"); const after = await request(app).get("/api/v1/usage/agent-bill/svc-bill"); assert.strictEqual(after.body.total, 0); diff --git a/src/routes/meta.ts b/src/routes/meta.ts index dc0af6a..da5ced4 100644 --- a/src/routes/meta.ts +++ b/src/routes/meta.ts @@ -40,6 +40,7 @@ export function createMetaRouter(): Router { "Admin pause/unpause, API keys, webhooks, event log.", "Bulk usage + bulk services, CSV/JSON exports.", "Metadata + disabled flag per service.", + "Billing stroop amounts are returned as exact decimal strings.", ], }, ], @@ -86,8 +87,54 @@ export function createMetaRouter(): Router { "/api/v1/usage": { post: { summary: "Record usage" } }, "/api/v1/usage/bulk": { post: { summary: "Batched record" } }, "/api/v1/usage/{agent}/{serviceId}": { get: { summary: "Read accumulator" } }, - "/api/v1/billing/{agent}/{serviceId}": { get: { summary: "Quote bill" } }, - "/api/v1/settle": { post: { summary: "Drain & quote bill" } }, + "/api/v1/billing/total": { + get: { + summary: "Quote protocol-wide outstanding bill", + responses: { + "200": { + description: + "Billing totals with decimal-string stroop amounts for exact JSON precision.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/BillingTotal" }, + }, + }, + }, + }, + }, + }, + "/api/v1/billing/{agent}/{serviceId}": { + get: { + summary: "Quote bill", + responses: { + "200": { + description: + "Pair billing quote with billedStroops serialized as a decimal string.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/BillingQuote" }, + }, + }, + }, + }, + }, + }, + "/api/v1/settle": { + post: { + summary: "Drain & quote bill", + responses: { + "200": { + description: + "Settlement quote with billedStroops serialized as a decimal string.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/BillingQuote" }, + }, + }, + }, + }, + }, + }, "/api/v1/api-keys": { get: { summary: "List api keys" }, post: { summary: "Create api key" }, @@ -102,6 +149,51 @@ export function createMetaRouter(): Router { "/api/v1/admin/unpause": { post: { summary: "Resume" } }, "/api/v1/admin/status": { get: { summary: "Read pause flag" } }, }, + components: { + schemas: { + BillingQuote: { + type: "object", + properties: { + agent: { type: "string" }, + serviceId: { type: "string" }, + requests: { type: "integer", minimum: 0 }, + priceStroops: { type: "integer", minimum: 0 }, + billedStroops: { + type: "string", + pattern: "^[0-9]+$", + description: + "Exact decimal stroop amount. String-typed to avoid JSON number precision loss.", + }, + }, + required: [ + "agent", + "serviceId", + "requests", + "priceStroops", + "billedStroops", + ], + }, + BillingTotal: { + type: "object", + properties: { + totalStroops: { + type: "string", + pattern: "^[0-9]+$", + description: + "Exact decimal stroop total. String-typed to avoid JSON number precision loss.", + }, + disabledStroops: { + type: "string", + pattern: "^[0-9]+$", + description: + "Exact decimal stroop total for disabled services included in billing totals.", + }, + unpricedRequests: { type: "integer", minimum: 0 }, + }, + required: ["totalStroops", "disabledStroops", "unpricedRequests"], + }, + }, + }, }); }); diff --git a/src/routes/operational.test.ts b/src/routes/operational.test.ts index 16b7ab2..a890450 100644 --- a/src/routes/operational.test.ts +++ b/src/routes/operational.test.ts @@ -85,6 +85,14 @@ void describe("operational routes", () => { const openapi = await request(app).get("/api/v1/openapi.json"); assert.strictEqual(openapi.status, 200); assert.ok(openapi.body.paths["/api/v1/usage"]); + assert.strictEqual( + openapi.body.components.schemas.BillingQuote.properties.billedStroops.type, + "string" + ); + assert.strictEqual( + openapi.body.components.schemas.BillingTotal.properties.totalStroops.type, + "string" + ); }); void it("creates, lists, and revokes API keys without exposing full keys on list", async () => { diff --git a/src/routes/usage.test.ts b/src/routes/usage.test.ts index 432eb0b..41f35e1 100644 --- a/src/routes/usage.test.ts +++ b/src/routes/usage.test.ts @@ -54,7 +54,7 @@ void describe("usage router", () => { assert.strictEqual(quote.status, 200); assert.strictEqual(quote.body.requests, 4); assert.strictEqual(quote.body.priceStroops, 25); - assert.strictEqual(quote.body.billedStroops, 100); + assert.strictEqual(quote.body.billedStroops, "100"); }); void it("covers bulk usage, exports, agent rollups, settlement, and disabled services", async () => { @@ -87,7 +87,7 @@ void describe("usage router", () => { const billingTotal = await request(app).get("/api/v1/billing/total"); assert.strictEqual(billingTotal.status, 200); - assert.strictEqual(billingTotal.body.totalStroops, 50); + assert.strictEqual(billingTotal.body.totalStroops, "50"); const agents = await request(app).get("/api/v1/agents"); assert.strictEqual(agents.status, 200); @@ -108,7 +108,7 @@ void describe("usage router", () => { .post("/api/v1/settle") .send({ agent: "agent-bulk", serviceId: "svc-bulk" }); assert.strictEqual(settled.status, 200); - assert.strictEqual(settled.body.billedStroops, 50); + assert.strictEqual(settled.body.billedStroops, "50"); servicesDisabled.add("svc-disabled"); const disabled = await request(app) diff --git a/src/routes/usage.ts b/src/routes/usage.ts index cd28a50..35ca3c1 100644 --- a/src/routes/usage.ts +++ b/src/routes/usage.ts @@ -7,6 +7,7 @@ import { usageStore, } from "../store/state.js"; import { getRequestId } from "../types.js"; +import { addStroops, multiplyStroops } from "../util/stroops.js"; type BulkUsageResult = { index: number; @@ -16,8 +17,8 @@ type BulkUsageResult = { }; type BillingTotalBreakdown = { - totalStroops: number; - disabledStroops: number; + totalStroops: string; + disabledStroops: string; unpricedRequests: number; }; @@ -156,8 +157,8 @@ export function createUsageRouter(): Router { */ router.get("/api/v1/billing/total", (_req, res: Response) => { const breakdown: BillingTotalBreakdown = { - totalStroops: 0, - disabledStroops: 0, + totalStroops: "0", + disabledStroops: "0", unpricedRequests: 0, }; @@ -169,10 +170,13 @@ export function createUsageRouter(): Router { continue; } - const billedStroops = requests * service.priceStroops; - breakdown.totalStroops += billedStroops; + const billedStroops = multiplyStroops(requests, service.priceStroops); + breakdown.totalStroops = addStroops(breakdown.totalStroops, billedStroops); if (servicesDisabled.has(serviceId)) { - breakdown.disabledStroops += billedStroops; + breakdown.disabledStroops = addStroops( + breakdown.disabledStroops, + billedStroops + ); } } res.json(breakdown); @@ -187,7 +191,7 @@ export function createUsageRouter(): Router { serviceId, requests, priceStroops: price, - billedStroops: requests * price, + billedStroops: multiplyStroops(requests, price), }); }); @@ -205,7 +209,7 @@ export function createUsageRouter(): Router { const key = usageKey(agent, serviceId); const requests = usageStore.get(key) ?? 0; const price = servicesStore.get(serviceId)?.priceStroops ?? 0; - const billedStroops = requests * price; + const billedStroops = multiplyStroops(requests, price); usageStore.set(key, 0); recordEvent("usage.settled", { agent, serviceId, requests, billedStroops }); res.json({ agent, serviceId, requests, priceStroops: price, billedStroops }); diff --git a/src/util/stroops.test.ts b/src/util/stroops.test.ts new file mode 100644 index 0000000..b730c82 --- /dev/null +++ b/src/util/stroops.test.ts @@ -0,0 +1,23 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { addStroops, multiplyStroops } from "./stroops.js"; + +void describe("stroops arithmetic", () => { + void it("multiplies request counts and prices exactly above Number.MAX_SAFE_INTEGER", () => { + assert.strictEqual( + multiplyStroops(Number.MAX_SAFE_INTEGER, 10_000_000), + "90071992547409910000000" + ); + }); + + void it("keeps zero-price services at an exact decimal-string zero", () => { + assert.strictEqual(multiplyStroops(Number.MAX_SAFE_INTEGER, 0), "0"); + }); + + void it("sums billed stroops without converting back to Number", () => { + const first = multiplyStroops(Number.MAX_SAFE_INTEGER, 10_000_000); + const second = multiplyStroops(Number.MAX_SAFE_INTEGER, 2); + + assert.strictEqual(addStroops(first, second), "90072010561808419481982"); + }); +}); diff --git a/src/util/stroops.ts b/src/util/stroops.ts new file mode 100644 index 0000000..b7de698 --- /dev/null +++ b/src/util/stroops.ts @@ -0,0 +1,21 @@ +function assertSafeNonNegativeInteger(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`); + } +} + +/** + * Multiplies usage counts by per-request stroop prices without converting the + * billed amount back to Number, so JSON responses can preserve exact ledger + * units above 2^53. + */ +export function multiplyStroops(requests: number, priceStroops: number): string { + assertSafeNonNegativeInteger(requests, "requests"); + assertSafeNonNegativeInteger(priceStroops, "priceStroops"); + return (BigInt(requests) * BigInt(priceStroops)).toString(); +} + +/** Adds decimal-string stroop totals while preserving exact integer precision. */ +export function addStroops(left: string, right: string): string { + return (BigInt(left) + BigInt(right)).toString(); +}