Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions demos/payments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 90 additions & 2 deletions demos/payments/src/payment-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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" })
})
})
160 changes: 140 additions & 20 deletions demos/payments/src/payment-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, bigint>>
/**
* 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<Record<string, bigint>>
}
}

export const demoPaymentPolicy: PaymentPolicy = {
Expand All @@ -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<Record<string, bigint>>,
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",
Expand All @@ -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}`,
Expand All @@ -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<string, { at: number; amount: bigint }[]>()

/**
* 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()
}
25 changes: 21 additions & 4 deletions demos/payments/src/payment-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Env>()
Expand Down Expand Up @@ -49,6 +53,7 @@ app.post("/", async (c): Promise<TypedResponse<{ paymentUrl: string }>> => {
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 ...`))
Expand Down Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const payload = {
paymentRequestToken,
Expand Down Expand Up @@ -164,11 +172,20 @@ function enforcePaymentPolicy(
ReturnType<typeof validatePaymentOption>
>["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}`))
Expand Down