From a6ce66e5e85646b7e6fbe76002f3f04fb9812c66 Mon Sep 17 00:00:00 2001 From: mehmetkr-31 Date: Tue, 11 Aug 2026 00:54:10 +0300 Subject: [PATCH 1/3] demo(payments): enforce a rolling spend budget, not just a per-payment cap The policy guard added in #97 caps a single payment. Its own comment and the demo README call out that this is not a spend control: splitting one payment into N below-cap payments defeats it entirely. This closes that gap in the demo. - Add `src/spend-ledger.ts`, a small in-memory ledger that records what a payer has put at risk inside a rolling window. - Add an optional `budget` to `PaymentPolicy` and an `authorizePayment` layer that runs the existing per-transaction checks and then the cumulative window check. `evaluatePaymentPolicy` keeps its current signature and behaviour. - Reserve inside the same synchronous step as the check. The handler awaits token verification before policy runs, so a read-then-write guard would let two concurrent payments both observe the pre-payment total and both pass. - Key reservations by payment request id plus payment option id, so the two calls of the Stripe flow authorize one payment once. Commit the reservation once the receipt is issued; release it if the Receipt Service call fails. - Rename the Payment Service's `serverIdentity` local to `payerIdentity`; it holds the payment service key, not the server key that `getTrustedRecipients` reads under the same name. - Document what is still demo-grade: in-memory storage, single-instance atomicity, deny rather than escalate to human approval. Co-Authored-By: Claude Opus 5 --- demos/payments/README.md | 46 +++++- demos/payments/src/payment-policy.test.ts | 153 ++++++++++++++++++- demos/payments/src/payment-policy.ts | 172 +++++++++++++++++++--- demos/payments/src/payment-service.ts | 110 ++++++++++---- demos/payments/src/spend-ledger.test.ts | 171 +++++++++++++++++++++ demos/payments/src/spend-ledger.ts | 171 +++++++++++++++++++++ 6 files changed, 765 insertions(+), 58 deletions(-) create mode 100644 demos/payments/src/spend-ledger.test.ts create mode 100644 demos/payments/src/spend-ledger.ts diff --git a/demos/payments/README.md b/demos/payments/README.md index 019fe26..7f647c9 100644 --- a/demos/payments/README.md +++ b/demos/payments/README.md @@ -20,7 +20,7 @@ This interactive command-line demo showcases a common use case: the **Server-Ini - Handling currency conversions. - Integrating compliance checks (KYC/AML). - Facilitating complex payment routing. - - Enforcing local payment policy before returning an execution URL or signing a receipt-service payload. + - Enforcing local payment policy, including a cumulative spend budget, before returning an execution URL or signing a receipt-service payload. You can learn more about the full ACK-Pay protocol at [www.agentcommercekit.com](https://www.agentcommercekit.com). @@ -36,9 +36,10 @@ Payment Services should replace it with their own owner, risk, compliance, or human-approval system. The important safety boundary is that policy enforcement happens before execution or signing: -- known low-value recipient: continue automatically +- known low-value recipient, within budget: continue automatically - unknown recipient: return `approval_required` -- amount above the illustrative per-transaction cap: deny before payment +- amount above the per-transaction cap: deny before payment execution +- amount that would exceed the rolling spend budget: deny before payment execution The per-currency cap is expressed in each currency's smallest subunit, so a @@ -46,12 +47,41 @@ 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. +### Rolling spend budget + +A per-transaction cap on its own is not a spend control: it is trivially +defeated by splitting one payment into many smaller ones (`cap × N`). The demo +policy therefore also carries a cumulative budget over a rolling window, +enforced against the small in-memory ledger in `src/spend-ledger.ts`. + +- `maxAutonomousAmount` bounds a single payment. +- `budget.maxWindowAmount` bounds their sum over `budget.windowMs`, in the same + per-currency subunits. + +Two details are what make the budget hold rather than merely look right: + +- **The check and the reservation are one synchronous step.** The request + handler awaits token verification before policy runs, so a guard that read the + running total and then wrote to it would let two concurrent payments both + observe the pre-payment total and both pass. `SpendLedger.reserve` does both + at once. +- **Reservations are keyed by payment attempt.** The Stripe path authorizes + twice for a single payment — once for the payment URL, once on the callback — + so reservations are keyed by payment request id plus payment option id, and + the second authorization re-checks the window without counting the payment + twice. A reservation is committed once the receipt is issued, and released if + the Receipt Service call fails. + > [!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. +> This is still a demo, not production spend control. The ledger lives in +> process memory, so it resets with the demo and is not shared between Payment +> Service instances; a real budget needs durable storage and an atomic +> check-and-reserve (a transactional `UPDATE ... WHERE`, or a distributed lock) +> across every instance that can authorize payments. A real service would also +> route a budget breach to human approval rather than denying outright, and +> would key the budget on its authenticated payer — ACK-Pay carries no payer +> identity on the payment execution request, so the demo tracks the single +> autonomous payer it spends as. 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 a42a5cb..b6facf1 100644 --- a/demos/payments/src/payment-policy.test.ts +++ b/demos/payments/src/payment-policy.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it } from "vitest" +import { beforeEach, describe, expect, it } from "vitest" -import { evaluatePaymentPolicy } from "./payment-policy" +import { + authorizePayment, + evaluatePaymentPolicy, + type PaymentPolicy, +} from "./payment-policy" +import { createSpendLedger, type SpendLedger } from "./spend-ledger" const basePaymentOption = { id: "base-usdc", @@ -160,3 +165,147 @@ describe("evaluatePaymentPolicy", () => { }) }) }) + +describe("authorizePayment", () => { + const WINDOW_MS = 60 * 60 * 1000 + const SUBJECT = "did:example:payment-service" + + const budgetedPolicy: PaymentPolicy = { + allowedRecipients: [basePaymentOption.recipient], + maxAutonomousAmount: { USDC: 1_000n }, + budget: { + windowMs: WINDOW_MS, + maxWindowAmount: { USDC: 2_500n }, + }, + } + + let clock: number + let ledger: SpendLedger + + function authorize( + reference: string, + amount: number, + policy: PaymentPolicy = budgetedPolicy, + ) { + return authorizePayment({ ...basePaymentOption, amount }, policy, { + subject: SUBJECT, + reference, + ledger, + }) + } + + beforeEach(() => { + clock = 1_000_000 + ledger = createSpendLedger({ now: () => clock }) + }) + + it("approves a payment inside both the per-transaction cap and the budget", () => { + expect(authorize("payment-1", 1_000)).toEqual({ status: "approved" }) + expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(1_000n) + }) + + it("denies below-cap payments once they exhaust the window budget", () => { + // Every payment here passes the per-transaction cap on its own. The budget + // is what stops one payment being split into an unbounded number of them. + expect(authorize("payment-1", 1_000)).toEqual({ status: "approved" }) + expect(authorize("payment-2", 1_000)).toEqual({ status: "approved" }) + + expect(authorize("payment-3", 1_000)).toEqual({ + status: "denied", + reason: + "Payment exceeds the autonomous spend budget for the current window", + }) + expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(2_000n) + }) + + it("approves again once the earlier payments age out of the window", () => { + authorize("payment-1", 1_000) + authorize("payment-2", 1_000) + clock += WINDOW_MS + + expect(authorize("payment-3", 1_000)).toEqual({ status: "approved" }) + }) + + it("counts one payment attempt once across repeated authorizations", () => { + // The Stripe flow authorizes twice for a single payment: once for the + // payment URL, once on the callback. + expect(authorize("payment-1", 1_000)).toEqual({ status: "approved" }) + expect(authorize("payment-1", 1_000)).toEqual({ status: "approved" }) + + expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(1_000n) + }) + + it("does not consume budget for a payment the per-transaction cap denies", () => { + expect(authorize("payment-1", 2_000)).toEqual({ + status: "denied", + reason: "Payment amount exceeds the autonomous spend limit", + }) + expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(0n) + }) + + it("does not consume budget for a payment that requires approval", () => { + const decision = authorizePayment( + basePaymentOption, + { ...budgetedPolicy, allowedRecipients: [] }, + { subject: SUBJECT, reference: "payment-1", ledger }, + ) + + expect(decision).toEqual({ + status: "approval_required", + reason: "Recipient is not on the autonomous payment allowlist", + }) + expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(0n) + }) + + it("denies currencies with no configured window budget", () => { + const decision = authorizePayment( + basePaymentOption, + { + ...budgetedPolicy, + maxAutonomousAmount: { USDC: 1_000n, USD: 1_000n }, + budget: { windowMs: WINDOW_MS, maxWindowAmount: { USD: 2_500n } }, + }, + { subject: SUBJECT, reference: "payment-1", ledger }, + ) + + expect(decision).toEqual({ + status: "denied", + reason: "No autonomous spend budget configured for currency USDC", + }) + }) + + it("skips the budget check when the policy configures no budget", () => { + const unbudgetedPolicy: PaymentPolicy = { + allowedRecipients: [basePaymentOption.recipient], + maxAutonomousAmount: { USDC: 1_000n }, + } + + expect(authorize("payment-1", 1_000, unbudgetedPolicy)).toEqual({ + status: "approved", + }) + expect(authorize("payment-2", 1_000, unbudgetedPolicy)).toEqual({ + status: "approved", + }) + expect(authorize("payment-3", 1_000, unbudgetedPolicy)).toEqual({ + status: "approved", + }) + expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(0n) + }) + + it("tracks the budget per payer subject", () => { + authorize("payment-1", 1_000) + authorize("payment-2", 1_000) + + const other = authorizePayment( + { ...basePaymentOption, amount: 1_000 }, + budgetedPolicy, + { + subject: "did:example:other-payer", + reference: "payment-3", + ledger, + }, + ) + + expect(other).toEqual({ status: "approved" }) + }) +}) diff --git a/demos/payments/src/payment-policy.ts b/demos/payments/src/payment-policy.ts index b55b16f..9b3448e 100644 --- a/demos/payments/src/payment-policy.ts +++ b/demos/payments/src/payment-policy.ts @@ -1,5 +1,7 @@ import type { PaymentOption } from "agentcommercekit" +import type { SpendLedger } from "./spend-ledger" + export type PaymentPolicyDecision = | { status: "approved" @@ -9,6 +11,17 @@ export type PaymentPolicyDecision = reason: string } +interface SpendBudget { + /** Length of the rolling window, in milliseconds. */ + windowMs: number + /** + * Cumulative spend allowed inside the window, in each currency's smallest + * subunit and keyed by currency code, following the same per-currency shape + * as `maxAutonomousAmount`. A currency with no configured budget is denied. + */ + maxWindowAmount: Readonly> +} + export interface PaymentPolicy { allowedRecipients: readonly string[] /** @@ -18,13 +31,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. + * This bounds a single payment only. `budget` bounds their sum, which is + * what stops one payment being split into many below-cap ones. */ maxAutonomousAmount: Readonly> + /** + * Optional cumulative budget over a rolling window, enforced by + * `authorizePayment` against a `SpendLedger`. When omitted, only the + * per-transaction cap above applies. + */ + budget?: SpendBudget } +/** Rolling window the demo budget is measured over. */ +const DEMO_SPEND_WINDOW_MS = 60 * 60 * 1000 + export const demoPaymentPolicy: PaymentPolicy = { allowedRecipients: [], maxAutonomousAmount: { @@ -32,19 +53,54 @@ export const demoPaymentPolicy: PaymentPolicy = { USD: 500n, USDC: 5_000_000n, }, + budget: { + windowMs: DEMO_SPEND_WINDOW_MS, + maxWindowAmount: { + // 20.00 in each currency: four payments at the per-transaction cap, + // rather than an unbounded number of them. + USD: 2_000n, + USDC: 20_000_000n, + }, + }, +} + +/** + * 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. + */ +function parseSubunitAmount(amount: PaymentOption["amount"]): bigint | null { + try { + return BigInt(amount) + } catch { + return null + } +} + +/** + * `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 a comparison. + */ +function currencyLimit( + limits: Readonly>, + currency: string, +): bigint | null { + if (!Object.prototype.hasOwnProperty.call(limits, currency)) { + return null + } + + const limit = limits[currency] + return typeof limit === "bigint" ? limit : null } 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 === null) { return { status: "denied", reason: "Payment amount must be a positive integer in subunits", @@ -58,16 +114,12 @@ 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( + const limit = currencyLimit( policy.maxAutonomousAmount, paymentOption.currency, ) - ? policy.maxAutonomousAmount[paymentOption.currency] - : undefined - if (typeof limit !== "bigint") { + + if (limit === null) { return { status: "denied", reason: `No autonomous spend limit configured for currency ${paymentOption.currency}`, @@ -92,3 +144,89 @@ export function evaluatePaymentPolicy( status: "approved", } } + +export interface SpendAuthorization { + /** + * The party the budget is tracked against: the payer this Payment Service + * spends on behalf of. The demo has a single autonomous payer, so this is + * the Payment Service's own DID. A multi-tenant service would key the budget + * on its authenticated payer instead — ACK-Pay does not carry a payer + * identity on the payment execution request today. + */ + subject: string + /** + * Stable identifier for one payment attempt, so the two calls of the Stripe + * flow (payment URL, then callback) reserve once rather than twice. See + * `spendReference` in payment-service.ts. + */ + reference: string + ledger: SpendLedger +} + +/** + * Applies the full policy to a payment before it is executed or signed: the + * per-transaction checks in `evaluatePaymentPolicy`, then the cumulative + * rolling-window budget when the policy configures one. + * + * An approved decision has reserved the amount against the window. The caller + * must `commit` the reservation once the payment settles, or `release` it if + * execution fails. + * + * @param paymentOption - The verified payment option about to be executed + * @param policy - The policy to apply + * @param authorization - Budget subject, attempt reference, and ledger + * @returns The policy decision + */ +export function authorizePayment( + paymentOption: PaymentOption, + policy: PaymentPolicy, + authorization: SpendAuthorization, +): PaymentPolicyDecision { + const decision = evaluatePaymentPolicy(paymentOption, policy) + + if (decision.status !== "approved" || !policy.budget) { + return decision + } + + const amount = parseSubunitAmount(paymentOption.amount) + if (amount === null) { + // Unreachable: `evaluatePaymentPolicy` already denied unparseable amounts. + return { + status: "denied", + reason: "Payment amount must be a positive integer in subunits", + } + } + + const limit = currencyLimit( + policy.budget.maxWindowAmount, + paymentOption.currency, + ) + + if (limit === null) { + return { + status: "denied", + reason: `No autonomous spend budget configured for currency ${paymentOption.currency}`, + } + } + + const result = authorization.ledger.reserve({ + reference: authorization.reference, + subject: authorization.subject, + currency: paymentOption.currency, + amount, + windowMs: policy.budget.windowMs, + limit, + }) + + if (result.status === "exceeded") { + return { + status: "denied", + reason: + "Payment exceeds the autonomous spend budget for the current window", + } + } + + return { + status: "approved", + } +} diff --git a/demos/payments/src/payment-service.ts b/demos/payments/src/payment-service.ts index 713d031..393aa32 100644 --- a/demos/payments/src/payment-service.ts +++ b/demos/payments/src/payment-service.ts @@ -5,6 +5,7 @@ import { createJwt, getDidResolver, verifyPaymentRequestToken, + type DidUri, type JwtString, } from "agentcommercekit" import { jwtStringSchema } from "agentcommercekit/schemas/valibot" @@ -14,12 +15,19 @@ import { HTTPException } from "hono/http-exception" import * as v from "valibot" import { PAYMENT_SERVICE_URL } from "./constants" -import { demoPaymentPolicy, evaluatePaymentPolicy } from "./payment-policy" +import { authorizePayment, demoPaymentPolicy } from "./payment-policy" +import { createSpendLedger } from "./spend-ledger" import { getKeypairInfo } from "./utils/keypair-info" const app = new Hono() app.use(logger()) +/** + * Tracks how much this Payment Service has already authorized inside the + * policy's rolling window. In-memory, so it resets with the demo process. + */ +const spendLedger = createSpendLedger() + const bodySchema = v.object({ paymentOptionId: v.string(), paymentRequestToken: jwtStringSchema, @@ -45,11 +53,15 @@ app.post("/", async (c): Promise> => { // Verify the payment request token and payment option are valid before // returning an execution URL. - const { paymentOption } = await validatePaymentOption( + const { paymentRequest, paymentOption } = await validatePaymentOption( paymentOptionId, paymentRequestToken, ) - enforcePaymentPolicy(paymentOption, await getTrustedRecipients(c)) + const payerIdentity = await getPayerIdentity(c) + await enforcePaymentPolicy(c, paymentOption, { + subject: payerIdentity.did, + reference: spendReference(paymentRequest.id, paymentOptionId), + }) log(colors.dim(`${name} Generating Stripe payment URL ...`)) @@ -72,9 +84,7 @@ const callbackSchema = v.object({ app.post( "/stripe-callback", async (c): Promise> => { - const serverIdentity = await getKeypairInfo( - env(c).PAYMENT_SERVICE_PRIVATE_KEY_HEX, - ) + const payerIdentity = await getPayerIdentity(c) const { paymentOptionId, paymentRequestToken, metadata } = v.parse( callbackSchema, @@ -82,7 +92,7 @@ app.post( ) // Verify the payment request token and payment option are valid - const { paymentOption } = await validatePaymentOption( + const { paymentRequest, paymentOption } = await validatePaymentOption( paymentOptionId, paymentRequestToken, ) @@ -90,7 +100,14 @@ app.post( if (!receiptServiceUrl) { throw new Error(errorMessage("Receipt service URL is required")) } - enforcePaymentPolicy(paymentOption, await getTrustedRecipients(c)) + + // Re-authorizing under the same reference re-checks the window without + // counting this payment a second time. + const reference = spendReference(paymentRequest.id, paymentOptionId) + await enforcePaymentPolicy(c, paymentOption, { + subject: payerIdentity.did, + reference, + }) const payload = { paymentRequestToken, @@ -99,31 +116,36 @@ app.post( network: "stripe", eventId: metadata.eventId, }, - payerDid: serverIdentity.did, + payerDid: payerIdentity.did, } const signedPayload = await createJwt(payload, { - issuer: serverIdentity.did, - signer: serverIdentity.jwtSigner, + issuer: payerIdentity.did, + signer: payerIdentity.jwtSigner, }) log(colors.dim(`${name} Getting receipt from Receipt Service...`)) - const response = await fetch(receiptServiceUrl, { - method: "POST", - body: JSON.stringify({ - payload: signedPayload, - }), - }) - const { receipt, details } = v.parse( - receiptResponseSchema, - await response.json(), - ) + let receiptResponse: v.InferOutput + try { + const response = await fetch(receiptServiceUrl, { + method: "POST", + body: JSON.stringify({ + payload: signedPayload, + }), + }) + + receiptResponse = v.parse(receiptResponseSchema, await response.json()) + } catch (error) { + // The payment never produced a receipt, so it should not keep consuming + // the window budget. + spendLedger.release(reference) + throw error + } - return c.json({ - receipt, - details, - }) + spendLedger.commit(reference) + + return c.json(receiptResponse) }, ) @@ -159,16 +181,33 @@ async function validatePaymentOption( } } -function enforcePaymentPolicy( +/** + * Identifies one payment attempt across both calls of the Stripe flow, so the + * payment URL request and the callback reserve budget once, not twice. + */ +function spendReference(paymentRequestId: string, paymentOptionId: string) { + return `${paymentRequestId}:${paymentOptionId}` +} + +async function enforcePaymentPolicy( + c: Context, paymentOption: Awaited< ReturnType >["paymentOption"], - allowedRecipients: readonly string[], + { subject, reference }: { subject: DidUri; reference: string }, ) { - const decision = evaluatePaymentPolicy(paymentOption, { - ...demoPaymentPolicy, - allowedRecipients, - }) + const decision = authorizePayment( + paymentOption, + { + ...demoPaymentPolicy, + allowedRecipients: await getTrustedRecipients(c), + }, + { + subject, + reference, + ledger: spendLedger, + }, + ) if (decision.status !== "approved") { log(errorMessage(`${name} ${decision.reason}`)) @@ -178,6 +217,15 @@ function enforcePaymentPolicy( } } +/** + * The identity this Payment Service signs and spends as. The demo has a single + * autonomous payer, so it is also the subject the spend budget is tracked + * against. + */ +function getPayerIdentity(c: Context) { + return getKeypairInfo(env(c).PAYMENT_SERVICE_PRIVATE_KEY_HEX) +} + async function getTrustedRecipients(c: Context) { const serverIdentity = await getKeypairInfo(env(c).SERVER_PRIVATE_KEY_HEX) return [serverIdentity.did] diff --git a/demos/payments/src/spend-ledger.test.ts b/demos/payments/src/spend-ledger.test.ts new file mode 100644 index 0000000..9251237 --- /dev/null +++ b/demos/payments/src/spend-ledger.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it } from "vitest" + +import { createSpendLedger, type SpendLedger } from "./spend-ledger" + +const WINDOW_MS = 60 * 60 * 1000 +const SUBJECT = "did:example:payment-service" + +let clock: number +let ledger: SpendLedger + +function reserve(reference: string, amount: bigint, limit = 1_000n) { + return ledger.reserve({ + reference, + subject: SUBJECT, + currency: "USDC", + amount, + windowMs: WINDOW_MS, + limit, + }) +} + +beforeEach(() => { + clock = 1_000_000 + ledger = createSpendLedger({ now: () => clock }) +}) + +describe("createSpendLedger", () => { + it("reserves an amount within the limit", () => { + expect(reserve("payment-1", 400n)).toEqual({ + status: "reserved", + spent: 400n, + }) + expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(400n) + }) + + it("accumulates separate payments inside the window", () => { + reserve("payment-1", 400n) + + expect(reserve("payment-2", 400n)).toEqual({ + status: "reserved", + spent: 800n, + }) + expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(800n) + }) + + it("rejects a reservation that would exceed the limit", () => { + reserve("payment-1", 800n) + + expect(reserve("payment-2", 400n)).toEqual({ + status: "exceeded", + spent: 800n, + limit: 1_000n, + }) + }) + + it("does not record an amount it rejected", () => { + reserve("payment-1", 800n) + reserve("payment-2", 400n) + + expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(800n) + }) + + it("allows a reservation that exactly reaches the limit", () => { + reserve("payment-1", 600n) + + expect(reserve("payment-2", 400n)).toEqual({ + status: "reserved", + spent: 1_000n, + }) + }) + + it("counts a re-reserved reference once", () => { + reserve("payment-1", 600n) + + expect(reserve("payment-1", 600n)).toEqual({ + status: "reserved", + spent: 600n, + }) + expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(600n) + }) + + it("tracks each subject separately", () => { + reserve("payment-1", 800n) + + const other = ledger.reserve({ + reference: "payment-2", + subject: "did:example:other-payer", + currency: "USDC", + amount: 800n, + windowMs: WINDOW_MS, + limit: 1_000n, + }) + + expect(other).toEqual({ status: "reserved", spent: 800n }) + }) + + it("tracks each currency separately", () => { + reserve("payment-1", 800n) + + const other = ledger.reserve({ + reference: "payment-2", + subject: SUBJECT, + currency: "USD", + amount: 800n, + windowMs: WINDOW_MS, + limit: 1_000n, + }) + + expect(other).toEqual({ status: "reserved", spent: 800n }) + }) + + it("drops reservations that have aged out of the window", () => { + reserve("payment-1", 800n) + clock += WINDOW_MS + + expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(0n) + expect(reserve("payment-2", 800n)).toEqual({ + status: "reserved", + spent: 800n, + }) + }) + + it("keeps reservations that are still inside the window", () => { + reserve("payment-1", 800n) + clock += WINDOW_MS - 1 + + expect(reserve("payment-2", 800n)).toEqual({ + status: "exceeded", + spent: 800n, + limit: 1_000n, + }) + }) + + it("ages a re-reserved payment out from its first attempt", () => { + reserve("payment-1", 800n) + clock += WINDOW_MS - 1 + reserve("payment-1", 800n) + clock += 1 + + expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(0n) + }) + + it("releases an unsettled reservation", () => { + reserve("payment-1", 800n) + ledger.release("payment-1") + + expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(0n) + }) + + it("keeps a committed reservation when release is called", () => { + reserve("payment-1", 800n) + ledger.commit("payment-1") + ledger.release("payment-1") + + expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(800n) + }) + + it("ignores commit and release for an unknown reference", () => { + reserve("payment-1", 800n) + ledger.commit("payment-2") + ledger.release("payment-2") + + expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(800n) + }) + + it("returns zero for a subject with no reservations", () => { + expect(ledger.spentWithin("did:example:unknown", "USDC", WINDOW_MS)).toBe( + 0n, + ) + }) +}) diff --git a/demos/payments/src/spend-ledger.ts b/demos/payments/src/spend-ledger.ts new file mode 100644 index 0000000..4a07150 --- /dev/null +++ b/demos/payments/src/spend-ledger.ts @@ -0,0 +1,171 @@ +/** + * A tiny in-memory spend ledger for the payments demo. + * + * The ledger records how much a subject (the party a Payment Service spends on + * behalf of) has already put at risk inside a rolling time window, so policy + * can enforce a cumulative budget instead of only a per-transaction cap. A + * per-transaction cap on its own is trivially defeated by splitting one payment + * into many smaller ones. + * + * This is a demo store, not a spend control. It lives in process memory, so it + * resets on restart and is not shared between Payment Service instances. A + * production budget needs durable storage and an atomic check-and-reserve + * (a transactional `UPDATE ... WHERE` or a distributed lock) whenever more than + * one instance can authorize payments. + */ + +interface SpendLedgerEntry { + subject: string + currency: string + /** Amount in the currency's smallest subunit. */ + amount: bigint + /** Epoch milliseconds at which the amount was reserved. */ + at: number + /** `true` once the payment this entry covers has actually settled. */ + committed: boolean +} + +interface SpendReservation { + /** + * Stable identifier for a single payment attempt. Reserving the same + * reference twice replaces the existing entry rather than adding a second + * one, so the two-phase Stripe flow (payment URL, then callback) and any + * retries never count one payment twice. + */ + reference: string + subject: string + currency: string + /** Amount in the currency's smallest subunit. */ + amount: bigint + /** Length of the rolling window, in milliseconds. */ + windowMs: number + /** Cumulative cap for this subject and currency across the window. */ + limit: bigint +} + +type SpendReservationResult = + | { + status: "reserved" + /** Window total for this subject and currency, including this reservation. */ + spent: bigint + } + | { + status: "exceeded" + /** Window total excluding the rejected reservation. */ + spent: bigint + limit: bigint + } + +export interface SpendLedger { + /** + * Checks the rolling window and records the amount in a single synchronous + * step. Callers must not check the window and reserve separately: the + * enclosing request handler awaits before policy runs, so two concurrent + * payments would both observe the pre-payment total and both pass. + */ + reserve(reservation: SpendReservation): SpendReservationResult + /** Marks a reservation as settled, so it can no longer be released. */ + commit(reference: string): void + /** Drops an unsettled reservation, e.g. when execution failed. */ + release(reference: string): void + /** Reserved and committed total for a subject and currency in the window. */ + spentWithin(subject: string, currency: string, windowMs: number): bigint +} + +export interface SpendLedgerOptions { + /** Clock override, for tests. */ + now?: () => number +} + +/** + * Creates an in-memory spend ledger. + * + * All calls are expected to share one window length (the one configured on the + * policy). `reserve` discards entries that have aged out of the window it is + * given, which is what bounds the ledger's memory. + * + * @param options - Optional clock override + * @returns A `SpendLedger` + */ +export function createSpendLedger({ + now = () => Date.now(), +}: SpendLedgerOptions = {}): SpendLedger { + const entries = new Map() + + function totalWithin( + subject: string, + currency: string, + windowMs: number, + excludeReference?: string, + ): bigint { + const cutoff = now() - windowMs + let total = 0n + + for (const [reference, entry] of entries) { + if (reference === excludeReference) { + continue + } + if (entry.subject !== subject || entry.currency !== currency) { + continue + } + if (entry.at <= cutoff) { + continue + } + total += entry.amount + } + + return total + } + + return { + reserve({ reference, subject, currency, amount, windowMs, limit }) { + const cutoff = now() - windowMs + for (const [key, entry] of entries) { + if (entry.at <= cutoff) { + entries.delete(key) + } + } + + // Exclude any earlier reservation under this reference, otherwise the + // second phase of a single payment would be counted on top of its own + // first phase and denied. + const spent = totalWithin(subject, currency, windowMs, reference) + + if (spent + amount > limit) { + return { status: "exceeded", spent, limit } + } + + // Keep the original timestamp when re-reserving, so a payment ages out of + // the window from its first attempt and cannot be held open indefinitely. + const existing = entries.get(reference) + + entries.set(reference, { + subject, + currency, + amount, + at: existing?.at ?? now(), + committed: existing?.committed ?? false, + }) + + return { status: "reserved", spent: spent + amount } + }, + + commit(reference) { + const entry = entries.get(reference) + if (entry) { + entry.committed = true + } + }, + + release(reference) { + const entry = entries.get(reference) + if (entry && !entry.committed) { + entries.delete(reference) + } + }, + + spentWithin(subject, currency, windowMs) { + return totalWithin(subject, currency, windowMs) + }, + } +} From 54148245d67b8da478397951333bcd76b3c00b6e Mon Sep 17 00:00:00 2001 From: mehmetkr-31 Date: Tue, 11 Aug 2026 01:07:19 +0300 Subject: [PATCH 2/3] fix(demo/payments): make the reservation key collision-free Both parts of the key come from the Payment Request as unconstrained strings, so `${paymentRequestId}:${paymentOptionId}` lets `("a:b", "c")` and `("a", "b:c")` produce the same reference. A collision overwrites the earlier reservation, dropping its amount from the window and under-enforcing the budget. A payment request that clears the recipient allowlist can choose both ids, so this is reachable rather than theoretical. Encode the pair instead, and move `spendReference` next to the ledger it keys so it can be tested without importing the Payment Service, which starts a server on import. Co-Authored-By: Claude Opus 5 --- demos/payments/src/payment-service.ts | 10 +---- demos/payments/src/spend-ledger.test.ts | 52 ++++++++++++++++++++++++- demos/payments/src/spend-ledger.ts | 20 ++++++++++ 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/demos/payments/src/payment-service.ts b/demos/payments/src/payment-service.ts index 393aa32..9c2962f 100644 --- a/demos/payments/src/payment-service.ts +++ b/demos/payments/src/payment-service.ts @@ -16,7 +16,7 @@ import * as v from "valibot" import { PAYMENT_SERVICE_URL } from "./constants" import { authorizePayment, demoPaymentPolicy } from "./payment-policy" -import { createSpendLedger } from "./spend-ledger" +import { createSpendLedger, spendReference } from "./spend-ledger" import { getKeypairInfo } from "./utils/keypair-info" const app = new Hono() @@ -181,14 +181,6 @@ async function validatePaymentOption( } } -/** - * Identifies one payment attempt across both calls of the Stripe flow, so the - * payment URL request and the callback reserve budget once, not twice. - */ -function spendReference(paymentRequestId: string, paymentOptionId: string) { - return `${paymentRequestId}:${paymentOptionId}` -} - async function enforcePaymentPolicy( c: Context, paymentOption: Awaited< diff --git a/demos/payments/src/spend-ledger.test.ts b/demos/payments/src/spend-ledger.test.ts index 9251237..fc9c3f6 100644 --- a/demos/payments/src/spend-ledger.test.ts +++ b/demos/payments/src/spend-ledger.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it } from "vitest" -import { createSpendLedger, type SpendLedger } from "./spend-ledger" +import { + createSpendLedger, + spendReference, + type SpendLedger, +} from "./spend-ledger" const WINDOW_MS = 60 * 60 * 1000 const SUBJECT = "did:example:payment-service" @@ -169,3 +173,49 @@ describe("createSpendLedger", () => { ) }) }) + +describe("spendReference", () => { + it("returns the same key for the same payment attempt", () => { + expect(spendReference("request-1", "stripe-usd")).toBe( + spendReference("request-1", "stripe-usd"), + ) + }) + + it("distinguishes payment options within one payment request", () => { + expect(spendReference("request-1", "stripe-usd")).not.toBe( + spendReference("request-1", "usdc-base-sepolia"), + ) + }) + + it("does not collide when an id contains the separator", () => { + // Both ids come from the Payment Request as unconstrained strings, so a + // plain `${a}:${b}` would map these two attempts onto one reservation and + // drop the second amount from the window. + expect(spendReference("request:1", "stripe-usd")).not.toBe( + spendReference("request", "1:stripe-usd"), + ) + }) + + it("keeps colliding-shaped attempts on separate budget entries", () => { + const limit = 1_000n + const first = ledger.reserve({ + reference: spendReference("request:1", "stripe-usd"), + subject: SUBJECT, + currency: "USDC", + amount: 600n, + windowMs: WINDOW_MS, + limit, + }) + const second = ledger.reserve({ + reference: spendReference("request", "1:stripe-usd"), + subject: SUBJECT, + currency: "USDC", + amount: 600n, + windowMs: WINDOW_MS, + limit, + }) + + expect(first).toEqual({ status: "reserved", spent: 600n }) + expect(second).toEqual({ status: "exceeded", spent: 600n, limit }) + }) +}) diff --git a/demos/payments/src/spend-ledger.ts b/demos/payments/src/spend-ledger.ts index 4a07150..324e95c 100644 --- a/demos/payments/src/spend-ledger.ts +++ b/demos/payments/src/spend-ledger.ts @@ -77,6 +77,26 @@ export interface SpendLedgerOptions { now?: () => number } +/** + * Builds the reservation key identifying one payment attempt, so both calls of + * the Stripe flow reserve against the same entry. + * + * Both parts are unconstrained strings carried in the Payment Request, so they + * are encoded rather than concatenated: a plain `${a}:${b}` lets + * `("a:b", "c")` and `("a", "b:c")` collide, and a collision would silently + * overwrite an earlier reservation and drop its amount from the window. + * + * @param paymentRequestId - `id` of the verified Payment Request + * @param paymentOptionId - `id` of the selected payment option + * @returns A key unique to the pair + */ +export function spendReference( + paymentRequestId: string, + paymentOptionId: string, +): string { + return JSON.stringify([paymentRequestId, paymentOptionId]) +} + /** * Creates an in-memory spend ledger. * From 6100986215224e64611f5f075272b0d6dd7b284e Mon Sep 17 00:00:00 2001 From: mehmetkr-31 Date: Tue, 11 Aug 2026 01:21:11 +0300 Subject: [PATCH 3/3] fix(demo/payments): reserve budget per payment execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three review findings. Keying the reservation on the Payment Request meant a request presented for execution twice consumed the budget once, so an agent could bypass the budget by re-executing one request. The Payment Service now mints an execution id at initiation and carries it through the callback URL: the two calls of one execution still reserve once, while a second execution is charged separately. The demo client needs no change — it forwards whatever `return_to` it is given — and `return_to` is now URL-encoded, since it carries a query of its own. Move `createJwt` inside the try that releases the reservation. Signing failed outside it, leaving a reservation with no receipt to commit or release. Document the post-capture denial path in the README: the callback re-authorizes after the card is captured, so a denial there stops the receipt without stopping the charge. It needs the initiation reservation to have aged out and later payments to have filled the budget, which the one-hour window makes unlikely, but a real service should settle and flag rather than refuse. Co-Authored-By: Claude Opus 5 --- demos/payments/README.md | 19 ++++++--- demos/payments/src/payment-service.ts | 35 +++++++++++----- demos/payments/src/spend-ledger.test.ts | 55 +++++++++++++++++++------ demos/payments/src/spend-ledger.ts | 19 +++++---- 4 files changed, 92 insertions(+), 36 deletions(-) diff --git a/demos/payments/README.md b/demos/payments/README.md index 7f647c9..aa150d6 100644 --- a/demos/payments/README.md +++ b/demos/payments/README.md @@ -65,12 +65,14 @@ Two details are what make the budget hold rather than merely look right: running total and then wrote to it would let two concurrent payments both observe the pre-payment total and both pass. `SpendLedger.reserve` does both at once. -- **Reservations are keyed by payment attempt.** The Stripe path authorizes +- **Reservations are keyed by payment execution.** The Stripe path authorizes twice for a single payment — once for the payment URL, once on the callback — - so reservations are keyed by payment request id plus payment option id, and - the second authorization re-checks the window without counting the payment - twice. A reservation is committed once the receipt is issued, and released if - the Receipt Service call fails. + so the Payment Service mints an execution id at initiation and carries it + through the callback URL. The second authorization then re-checks the window + without counting the payment twice, while a second execution of the same + Payment Request is charged separately, since it moves money separately. A + reservation is committed once the receipt is issued, and released if signing + or the Receipt Service call fails. > [!IMPORTANT] > This is still a demo, not production spend control. The ledger lives in @@ -82,6 +84,13 @@ Two details are what make the budget hold rather than merely look right: > would key the budget on its authenticated payer — ACK-Pay carries no payer > identity on the payment execution request, so the demo tracks the single > autonomous payer it spends as. +> +> The callback re-authorizes after the card has already been captured, so a +> denial there stops the receipt without stopping the charge. In practice this +> needs the initiation reservation to have aged out of the window and later +> payments to have filled the budget, which the one-hour window makes unlikely +> — but a real Payment Service should settle and flag an over-budget capture +> for reconciliation rather than refuse it. 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-service.ts b/demos/payments/src/payment-service.ts index 9c2962f..e6d1fa6 100644 --- a/demos/payments/src/payment-service.ts +++ b/demos/payments/src/payment-service.ts @@ -57,17 +57,24 @@ app.post("/", async (c): Promise> => { paymentOptionId, paymentRequestToken, ) + // Budget is reserved per payment execution, not per Payment Request. A + // Payment Request can be presented for execution more than once, and each + // execution moves money, so they must consume the budget separately. The id + // is minted here and carried through the callback URL so the two calls of + // one execution still reserve once. + const executionId = crypto.randomUUID() const payerIdentity = await getPayerIdentity(c) await enforcePaymentPolicy(c, paymentOption, { subject: payerIdentity.did, - reference: spendReference(paymentRequest.id, paymentOptionId), + reference: spendReference(paymentRequest.id, paymentOptionId, executionId), }) log(colors.dim(`${name} Generating Stripe payment URL ...`)) // This is a placeholder for an actual Strip Payment URL which would // have webhook callbacks already set up - const paymentUrl = `https://payments.stripe.com/payment-url/?return_to=${PAYMENT_SERVICE_URL}/stripe-callback` + const returnTo = `${PAYMENT_SERVICE_URL}/stripe-callback?executionId=${executionId}` + const paymentUrl = `https://payments.stripe.com/payment-url/?return_to=${encodeURIComponent(returnTo)}` return c.json({ paymentUrl, @@ -101,9 +108,17 @@ app.post( throw new Error(errorMessage("Receipt service URL is required")) } - // Re-authorizing under the same reference re-checks the window without - // counting this payment a second time. - const reference = spendReference(paymentRequest.id, paymentOptionId) + // Re-authorizing under the execution's own reference re-checks the window + // without counting this payment a second time. A real Payment Service + // would resolve the execution from the provider's session id server-side + // rather than trusting the caller to echo it back; a caller that supplies + // an unknown id here is charged against the budget again rather than + // escaping it. + const reference = spendReference( + paymentRequest.id, + paymentOptionId, + c.req.query("executionId") ?? crypto.randomUUID(), + ) await enforcePaymentPolicy(c, paymentOption, { subject: payerIdentity.did, reference, @@ -119,15 +134,15 @@ app.post( payerDid: payerIdentity.did, } - const signedPayload = await createJwt(payload, { - issuer: payerIdentity.did, - signer: payerIdentity.jwtSigner, - }) - log(colors.dim(`${name} Getting receipt from Receipt Service...`)) let receiptResponse: v.InferOutput try { + const signedPayload = await createJwt(payload, { + issuer: payerIdentity.did, + signer: payerIdentity.jwtSigner, + }) + const response = await fetch(receiptServiceUrl, { method: "POST", body: JSON.stringify({ diff --git a/demos/payments/src/spend-ledger.test.ts b/demos/payments/src/spend-ledger.test.ts index fc9c3f6..34cadce 100644 --- a/demos/payments/src/spend-ledger.test.ts +++ b/demos/payments/src/spend-ledger.test.ts @@ -175,31 +175,60 @@ describe("createSpendLedger", () => { }) describe("spendReference", () => { - it("returns the same key for the same payment attempt", () => { - expect(spendReference("request-1", "stripe-usd")).toBe( - spendReference("request-1", "stripe-usd"), + it("returns the same key for both calls of one execution", () => { + expect(spendReference("request-1", "stripe-usd", "execution-1")).toBe( + spendReference("request-1", "stripe-usd", "execution-1"), ) }) it("distinguishes payment options within one payment request", () => { - expect(spendReference("request-1", "stripe-usd")).not.toBe( - spendReference("request-1", "usdc-base-sepolia"), + expect(spendReference("request-1", "stripe-usd", "execution-1")).not.toBe( + spendReference("request-1", "usdc-base-sepolia", "execution-1"), + ) + }) + + it("distinguishes executions of the same payment request", () => { + expect(spendReference("request-1", "stripe-usd", "execution-1")).not.toBe( + spendReference("request-1", "stripe-usd", "execution-2"), ) }) it("does not collide when an id contains the separator", () => { - // Both ids come from the Payment Request as unconstrained strings, so a - // plain `${a}:${b}` would map these two attempts onto one reservation and - // drop the second amount from the window. - expect(spendReference("request:1", "stripe-usd")).not.toBe( - spendReference("request", "1:stripe-usd"), + // The ids come from the Payment Request as unconstrained strings, so a + // plain `${a}:${b}` would map these two executions onto one reservation + // and drop the second amount from the window. + expect(spendReference("request:1", "stripe-usd", "execution-1")).not.toBe( + spendReference("request", "1:stripe-usd", "execution-1"), ) }) - it("keeps colliding-shaped attempts on separate budget entries", () => { + it("charges a second execution of one payment request against the budget", () => { + const limit = 1_000n + const first = ledger.reserve({ + reference: spendReference("request-1", "stripe-usd", "execution-1"), + subject: SUBJECT, + currency: "USDC", + amount: 600n, + windowMs: WINDOW_MS, + limit, + }) + const second = ledger.reserve({ + reference: spendReference("request-1", "stripe-usd", "execution-2"), + subject: SUBJECT, + currency: "USDC", + amount: 600n, + windowMs: WINDOW_MS, + limit, + }) + + expect(first).toEqual({ status: "reserved", spent: 600n }) + expect(second).toEqual({ status: "exceeded", spent: 600n, limit }) + }) + + it("keeps colliding-shaped executions on separate budget entries", () => { const limit = 1_000n const first = ledger.reserve({ - reference: spendReference("request:1", "stripe-usd"), + reference: spendReference("request:1", "stripe-usd", "execution-1"), subject: SUBJECT, currency: "USDC", amount: 600n, @@ -207,7 +236,7 @@ describe("spendReference", () => { limit, }) const second = ledger.reserve({ - reference: spendReference("request", "1:stripe-usd"), + reference: spendReference("request", "1:stripe-usd", "execution-1"), subject: SUBJECT, currency: "USDC", amount: 600n, diff --git a/demos/payments/src/spend-ledger.ts b/demos/payments/src/spend-ledger.ts index 324e95c..47b1985 100644 --- a/demos/payments/src/spend-ledger.ts +++ b/demos/payments/src/spend-ledger.ts @@ -78,23 +78,26 @@ export interface SpendLedgerOptions { } /** - * Builds the reservation key identifying one payment attempt, so both calls of - * the Stripe flow reserve against the same entry. + * Builds the reservation key identifying one payment execution, so both calls + * of the Stripe flow reserve against the same entry while two executions of + * the same Payment Request consume the budget separately. * - * Both parts are unconstrained strings carried in the Payment Request, so they - * are encoded rather than concatenated: a plain `${a}:${b}` lets - * `("a:b", "c")` and `("a", "b:c")` collide, and a collision would silently - * overwrite an earlier reservation and drop its amount from the window. + * The parts are unconstrained strings carried in the Payment Request, so they + * are encoded rather than concatenated: a plain `${a}:${b}` lets `("a:b", "c")` + * and `("a", "b:c")` collide, and a collision would silently overwrite an + * earlier reservation and drop its amount from the window. * * @param paymentRequestId - `id` of the verified Payment Request * @param paymentOptionId - `id` of the selected payment option - * @returns A key unique to the pair + * @param executionId - Id minted when this execution was initiated + * @returns A key unique to the triple */ export function spendReference( paymentRequestId: string, paymentOptionId: string, + executionId: string, ): string { - return JSON.stringify([paymentRequestId, paymentOptionId]) + return JSON.stringify([paymentRequestId, paymentOptionId, executionId]) } /**