From 46c9731c55961c22a082128d1c3a6c83ce36c1f5 Mon Sep 17 00:00:00 2001 From: mehmetkr-31 Date: Tue, 1 Sep 2026 13:23:09 +0300 Subject: [PATCH] demo(payments): add a rolling spend budget to the policy guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A per-transaction cap is trivially defeated by splitting one payment into many smaller ones (`cap × N`), which the README already noted. This adds the cumulative half: one map of amounts keyed by currency, one window check. `PaymentPolicy` gains an optional `budget`, in the same per-currency subunits as `maxAutonomousAmount`. `evaluateSpendBudget` runs on top of `evaluatePaymentPolicy`, so a payment denied by the cap or the allowlist never consumes the window. `evaluatePaymentPolicy` keeps its signature, behaviour and reasons; the amount parsing and per-currency lookup are extracted into two helpers shared by both. The Payment Service reads the window before returning a payment URL and charges it when the callback settles, so one payment is counted once without tracking per-execution state. The read and the write are a single synchronous step: the handler awaits before policy runs, so a guard that read the total and then wrote it would let two concurrent payments both see the pre-payment total. The README replaces the "not a real spend control" warning with what this still leaves out: in-memory storage, a single instance, denying rather than escalating to a human, and a callback that settles after the card is captured. Co-Authored-By: Claude Opus 5 --- demos/payments/README.md | 24 +++- demos/payments/src/payment-policy.test.ts | 92 ++++++++++++- demos/payments/src/payment-policy.ts | 160 +++++++++++++++++++--- demos/payments/src/payment-service.ts | 25 +++- 4 files changed, 270 insertions(+), 31 deletions(-) diff --git a/demos/payments/README.md b/demos/payments/README.md index 019fe26c..d965ab7d 100644 --- a/demos/payments/README.md +++ b/demos/payments/README.md @@ -40,18 +40,32 @@ happens before execution or signing: - unknown recipient: return `approval_required` - amount above the illustrative per-transaction cap: deny before payment execution +- total above the rolling window budget: deny, even when each payment is under + the per-transaction cap The per-currency cap is expressed in each currency's smallest subunit, so a single flat threshold is never compared across currencies with different decimals (e.g. USD at 2dp vs USDC at 6dp). Currencies without a configured limit are denied outright. +The window budget exists because a per-transaction cap on its own is trivially +defeated by splitting one payment into many smaller ones (`cap × N`). The demo +budget is three payments at the cap per hour. The Payment Service reads the +window before returning a payment URL, and charges it when the callback +settles, so one payment is counted once. That check and the write are a single +synchronous step: the handler awaits before policy runs, so a guard that read +the total and then wrote it would let two concurrent payments both see the +pre-payment total and both pass. + > [!IMPORTANT] -> The amount check is an **illustrative per-transaction cap, not a real spend -> control.** A per-transaction limit is trivially defeated by splitting one -> payment into many smaller ones (`cap × N`). A production policy needs a -> cumulative and/or rate-limited budget (e.g. per-payer spend over a rolling -> window), not just a single-transaction threshold. +> The budget is an **illustration of where a spend control belongs, not a spend +> control.** It lives in process memory, so it resets on restart and is not +> shared between instances; a second instance has its own budget. It counts the +> demo's single autonomous payer, since ACK-Pay carries no payer identity on +> the execution request. It denies rather than escalating to a human. And +> because the callback settles after the card is captured, a payment that goes +> over budget between the two calls stops the receipt without stopping the +> charge — a real service would settle it and flag it for reconciliation. The demo allowlist is based on the configured server identity, not the issuer claimed by each incoming Payment Request token. A real Payment Service should diff --git a/demos/payments/src/payment-policy.test.ts b/demos/payments/src/payment-policy.test.ts index a42a5cb0..4518bf77 100644 --- a/demos/payments/src/payment-policy.test.ts +++ b/demos/payments/src/payment-policy.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { evaluatePaymentPolicy } from "./payment-policy" +import { + evaluatePaymentPolicy, + evaluateSpendBudget, + resetSpendBudget, +} from "./payment-policy" const basePaymentOption = { id: "base-usdc", @@ -160,3 +164,87 @@ describe("evaluatePaymentPolicy", () => { }) }) }) + +describe("evaluateSpendBudget", () => { + const policy = { + allowedRecipients: [basePaymentOption.recipient], + maxAutonomousAmount: { USDC: 1_000n }, + budget: { + windowMs: 60_000, + maxWindowAmount: { USD: 300n, USDC: 300n }, + }, + } + + const spend = (amount: number, record = true) => + evaluateSpendBudget({ ...basePaymentOption, amount }, policy, record) + + beforeEach(() => { + resetSpendBudget() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("denies the payment that would cross the window budget", () => { + // The split attack: each payment is under the per-transaction cap, so only + // the cumulative budget stops the fourth one. + expect(spend(100)).toEqual({ status: "approved" }) + expect(spend(100)).toEqual({ status: "approved" }) + expect(spend(100)).toEqual({ status: "approved" }) + + expect(spend(100)).toEqual({ + status: "denied", + reason: + "Payment exceeds the autonomous spend budget for the current window", + }) + }) + + it("does not record the amount it denied", () => { + expect(spend(200)).toEqual({ status: "approved" }) + expect(spend(200)).toMatchObject({ status: "denied" }) + + // The denied 200 must not sit in the window: 200 is still spent, so a + // payment of exactly the remaining 100 is approved. + expect(spend(100)).toEqual({ status: "approved" }) + }) + + it("does not charge the budget when it is only checking", () => { + expect(spend(300, false)).toEqual({ status: "approved" }) + expect(spend(300)).toEqual({ status: "approved" }) + }) + + it("allows the payment again once the window has passed", () => { + vi.useFakeTimers() + + expect(spend(300)).toEqual({ status: "approved" }) + expect(spend(100)).toMatchObject({ status: "denied" }) + + vi.advanceTimersByTime(60_001) + + expect(spend(300)).toEqual({ status: "approved" }) + }) + + it("tracks each currency separately", () => { + expect(spend(300)).toEqual({ status: "approved" }) + + // USDC is exhausted; the USD window is untouched. + expect( + evaluateSpendBudget( + { ...basePaymentOption, currency: "USD", amount: 300 }, + policy, + true, + ), + ).toEqual({ status: "approved" }) + }) + + it("approves when the policy configures no budget", () => { + expect( + evaluateSpendBudget( + { ...basePaymentOption, amount: 1_000_000 }, + { allowedRecipients: [], maxAutonomousAmount: { USDC: 1_000n } }, + true, + ), + ).toEqual({ status: "approved" }) + }) +}) diff --git a/demos/payments/src/payment-policy.ts b/demos/payments/src/payment-policy.ts index b55b16f1..0cb07915 100644 --- a/demos/payments/src/payment-policy.ts +++ b/demos/payments/src/payment-policy.ts @@ -18,11 +18,21 @@ export interface PaymentPolicy { * threshold across currencies with different decimals (e.g. USD at 2dp vs * USDC at 6dp). A currency with no configured limit is denied. * - * NOTE: this is a per-transaction cap only, not a cumulative or rate budget. - * See the demo README — a real spend control needs windowed/cumulative - * limits, since a per-transaction cap is trivially split-gameable. + * NOTE: this is a per-transaction cap only. `budget` below adds the + * cumulative limit, since a per-transaction cap is trivially split-gameable. */ maxAutonomousAmount: Readonly> + /** + * Optional cumulative budget across a rolling window, in the same + * per-currency subunits as `maxAutonomousAmount`. A per-transaction cap on + * its own is trivially defeated by splitting one payment into many smaller + * ones (`cap × N`), so the demo also bounds the total. A currency with no + * configured budget is left to the per-transaction cap alone. + */ + budget?: { + windowMs: number + maxWindowAmount: Readonly> + } } export const demoPaymentPolicy: PaymentPolicy = { @@ -32,19 +42,55 @@ export const demoPaymentPolicy: PaymentPolicy = { USD: 500n, USDC: 5_000_000n, }, + budget: { + windowMs: 60 * 60 * 1000, + maxWindowAmount: { + // Three payments at the per-transaction cap, so the fourth is denied. + USD: 1_500n, + USDC: 15_000_000n, + }, + }, +} + +/** + * Parses an ACK-Pay amount, following the repo-wide BigInt money convention + * (see receipt-service.ts, index.ts). `BigInt()` throws on the + * fractional/malformed amounts the ACK-Pay schema's string branch permits. + */ +function parseSubunitAmount( + amount: PaymentOption["amount"], +): bigint | undefined { + try { + return BigInt(amount) + } catch { + return undefined + } +} + +/** + * Looks a currency up in a per-currency limit map. + * + * `currency` is an unconstrained wire string, so this guards against inherited + * prototype keys (e.g. "constructor", "toString") that would otherwise resolve + * to a non-bigint value and slip past the comparisons below. + */ +function limitFor( + limits: Readonly>, + currency: string, +): bigint | undefined { + const limit = Object.prototype.hasOwnProperty.call(limits, currency) + ? limits[currency] + : undefined + + return typeof limit === "bigint" ? limit : undefined } export function evaluatePaymentPolicy( paymentOption: PaymentOption, policy: PaymentPolicy = demoPaymentPolicy, ): PaymentPolicyDecision { - let amount: bigint - try { - // Follows the repo-wide BigInt money convention (see receipt-service.ts, - // index.ts). `BigInt()` throws on fractional/malformed amounts the - // ACK-Pay schema's string branch otherwise permits. - amount = BigInt(paymentOption.amount) - } catch { + const amount = parseSubunitAmount(paymentOption.amount) + if (amount === undefined) { return { status: "denied", reason: "Payment amount must be a positive integer in subunits", @@ -58,16 +104,8 @@ export function evaluatePaymentPolicy( } } - // `currency` is an unconstrained wire string, so guard against inherited - // prototype keys (e.g. "constructor", "toString") that would otherwise - // resolve to a non-bigint value and slip past the comparison below. - const limit = Object.prototype.hasOwnProperty.call( - policy.maxAutonomousAmount, - paymentOption.currency, - ) - ? policy.maxAutonomousAmount[paymentOption.currency] - : undefined - if (typeof limit !== "bigint") { + const limit = limitFor(policy.maxAutonomousAmount, paymentOption.currency) + if (limit === undefined) { return { status: "denied", reason: `No autonomous spend limit configured for currency ${paymentOption.currency}`, @@ -92,3 +130,85 @@ export function evaluatePaymentPolicy( status: "approved", } } + +/** + * Amounts spent in the current window, keyed by currency. + * + * The demo has a single autonomous payer, and ACK-Pay carries no payer + * identity on the execution request, so one process-wide map is enough here. A + * real Payment Service keys the budget by payer and stores it durably. See the + * demo README for what else this leaves out. + */ +const spentAmounts = new Map() + +/** + * Drops the amounts that have aged out of the window and returns the rest. + * The returned array is the stored one, so appending to it records a spend. + */ +function amountsWithinWindow(currency: string, windowMs: number, now: number) { + const cutoff = now - windowMs + const amounts = (spentAmounts.get(currency) ?? []).filter( + ({ at }) => at > cutoff, + ) + spentAmounts.set(currency, amounts) + + return amounts +} + +/** + * Checks a payment against the policy's rolling budget, on top of the + * per-transaction decision from `evaluatePaymentPolicy`. + * + * @param paymentOption - The payment option being authorized + * @param policy - The policy to enforce + * @param record - Whether to charge the amount against the window. Pass `true` + * at the point the payment actually settles, so a payment is counted once. + * @returns The budget decision, or `approved` when no budget applies + */ +export function evaluateSpendBudget( + paymentOption: PaymentOption, + policy: PaymentPolicy = demoPaymentPolicy, + record = false, +): PaymentPolicyDecision { + const amount = parseSubunitAmount(paymentOption.amount) + const limit = policy.budget + ? limitFor(policy.budget.maxWindowAmount, paymentOption.currency) + : undefined + + // Nothing to add: no budget, no budget for this currency, or an amount + // `evaluatePaymentPolicy` has already denied. + if (!policy.budget || limit === undefined || amount === undefined) { + return { status: "approved" } + } + + // Read the window and record in one synchronous step. The request handler + // awaits before policy runs, so a check that returned to the event loop + // before writing would let two concurrent payments both observe the + // pre-payment total and both pass. + const now = Date.now() + const amounts = amountsWithinWindow( + paymentOption.currency, + policy.budget.windowMs, + now, + ) + const spent = amounts.reduce((total, entry) => total + entry.amount, 0n) + + if (spent + amount > limit) { + return { + status: "denied", + reason: + "Payment exceeds the autonomous spend budget for the current window", + } + } + + if (record) { + amounts.push({ at: now, amount }) + } + + return { status: "approved" } +} + +/** Clears the window. Exported for tests, which share the module. */ +export function resetSpendBudget(): void { + spentAmounts.clear() +} diff --git a/demos/payments/src/payment-service.ts b/demos/payments/src/payment-service.ts index 713d0319..1c5269dc 100644 --- a/demos/payments/src/payment-service.ts +++ b/demos/payments/src/payment-service.ts @@ -14,7 +14,11 @@ import { HTTPException } from "hono/http-exception" import * as v from "valibot" import { PAYMENT_SERVICE_URL } from "./constants" -import { demoPaymentPolicy, evaluatePaymentPolicy } from "./payment-policy" +import { + demoPaymentPolicy, + evaluatePaymentPolicy, + evaluateSpendBudget, +} from "./payment-policy" import { getKeypairInfo } from "./utils/keypair-info" const app = new Hono() @@ -49,6 +53,7 @@ app.post("/", async (c): Promise> => { paymentOptionId, paymentRequestToken, ) + // Reads the rolling budget without charging it: nothing has been paid yet. enforcePaymentPolicy(paymentOption, await getTrustedRecipients(c)) log(colors.dim(`${name} Generating Stripe payment URL ...`)) @@ -90,7 +95,10 @@ app.post( if (!receiptServiceUrl) { throw new Error(errorMessage("Receipt service URL is required")) } - enforcePaymentPolicy(paymentOption, await getTrustedRecipients(c)) + // The callback is where the payment settles, so this is the call that + // charges the rolling budget. The payment-URL handler only reads it, so + // one payment is counted once. + enforcePaymentPolicy(paymentOption, await getTrustedRecipients(c), true) const payload = { paymentRequestToken, @@ -164,11 +172,20 @@ function enforcePaymentPolicy( ReturnType >["paymentOption"], allowedRecipients: readonly string[], + recordSpend = false, ) { - const decision = evaluatePaymentPolicy(paymentOption, { + const policy = { ...demoPaymentPolicy, allowedRecipients, - }) + } + + // Run the per-transaction rules first, so a payment denied by the cap or the + // allowlist never consumes the window budget. + const perPayment = evaluatePaymentPolicy(paymentOption, policy) + const decision = + perPayment.status === "approved" + ? evaluateSpendBudget(paymentOption, policy, recordSpend) + : perPayment if (decision.status !== "approved") { log(errorMessage(`${name} ${decision.reason}`))