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
48 changes: 40 additions & 8 deletions demos/payments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -36,22 +36,54 @@ 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
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. The
second authorization then re-checks the window without counting the payment
twice. A reservation is committed once the receipt is issued, and released if
signing or the Receipt Service call fails. The Stripe callback only sets
`allowOverBudget` after consuming a pending settlement issued with the
payment URL (demo stand-in for verifying a signed Stripe event), and the
Receipt Service `fetch` uses a timeout so a hung call releases the reservation.

> [!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 today.

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
214 changes: 213 additions & 1 deletion demos/payments/src/payment-policy.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"

import { evaluatePaymentPolicy } from "./payment-policy"
import { authorizePayment, evaluatePaymentPolicy } from "./payment-policy"
import { createSpendLedger } from "./spend-ledger"

const basePaymentOption = {
id: "base-usdc",
Expand Down Expand Up @@ -160,3 +161,214 @@ describe("evaluatePaymentPolicy", () => {
})
})
})

describe("authorizePayment", () => {
const budgetPolicy = {
allowedRecipients: [basePaymentOption.recipient],
maxAutonomousAmount: { USDC: 1_000n },
budget: {
windowMs: 60_000,
maxWindowAmount: { USDC: 3_000n },
},
}

it("denies the split attack once the window budget is exhausted", () => {
const ledger = createSpendLedger()
const payment = { ...basePaymentOption, amount: 1_000 }

for (let i = 0; i < 3; i++) {
expect(
authorizePayment(payment, budgetPolicy, {
subject: "did:example:payer",
reference: `attempt-${i}`,
ledger,
}),
).toEqual({ status: "approved" })
}

expect(
authorizePayment(payment, budgetPolicy, {
subject: "did:example:payer",
reference: "attempt-3",
ledger,
}),
).toEqual({
status: "denied",
reason:
"Payment exceeds the autonomous spend budget for the current window",
})
})

it("does not consume budget for denied or approval-required payments", () => {
const ledger = createSpendLedger()

expect(
authorizePayment(
{ ...basePaymentOption, amount: 10_000 },
budgetPolicy,
{
subject: "did:example:payer",
reference: "too-large",
ledger,
},
).status,
).toBe("denied")

expect(
authorizePayment(
basePaymentOption,
{ ...budgetPolicy, allowedRecipients: [] },
{
subject: "did:example:payer",
reference: "unknown-recipient",
ledger,
},
).status,
).toBe("approval_required")

expect(ledger.spentWithin("did:example:payer", "USDC", 60_000)).toBe(0n)
})

it("re-authorizes the same reference without double-counting", () => {
const ledger = createSpendLedger()
const payment = { ...basePaymentOption, amount: 1_000 }
const auth = {
subject: "did:example:payer",
reference: "stripe-attempt",
ledger,
}

expect(authorizePayment(payment, budgetPolicy, auth)).toEqual({
status: "approved",
})
expect(authorizePayment(payment, budgetPolicy, auth)).toEqual({
status: "approved",
})
expect(ledger.spentWithin("did:example:payer", "USDC", 60_000)).toBe(
1_000n,
)
})

it("isolates subjects and currencies", () => {
const ledger = createSpendLedger()
const payment = { ...basePaymentOption, amount: 1_000 }

for (let i = 0; i < 3; i++) {
authorizePayment(payment, budgetPolicy, {
subject: "did:example:payer-a",
reference: `a-${i}`,
ledger,
})
}

expect(
authorizePayment(payment, budgetPolicy, {
subject: "did:example:payer-b",
reference: "b-0",
ledger,
}),
).toEqual({ status: "approved" })

expect(
authorizePayment(
{ ...payment, currency: "USD", decimals: 2, amount: 500 },
{
...budgetPolicy,
maxAutonomousAmount: { USDC: 1_000n, USD: 500n },
budget: {
windowMs: 60_000,
maxWindowAmount: { USDC: 3_000n, USD: 2_000n },
},
},
{
subject: "did:example:payer-a",
reference: "usd-0",
ledger,
},
),
).toEqual({ status: "approved" })
})

it("expires spend out of the rolling window", () => {
let now = 1_000_000
const ledger = createSpendLedger({ now: () => now })
const payment = { ...basePaymentOption, amount: 1_000 }

for (let i = 0; i < 3; i++) {
authorizePayment(payment, budgetPolicy, {
subject: "did:example:payer",
reference: `old-${i}`,
ledger,
})
}

now += 60_001

expect(
authorizePayment(payment, budgetPolicy, {
subject: "did:example:payer",
reference: "after-expiry",
ledger,
}),
).toEqual({ status: "approved" })
})

it("skips the window check when no budget is configured", () => {
const ledger = createSpendLedger()
const decision = authorizePayment(
basePaymentOption,
{
allowedRecipients: [basePaymentOption.recipient],
maxAutonomousAmount: { USDC: 1_000n },
},
{
subject: "did:example:payer",
reference: "no-budget",
ledger,
},
)

expect(decision).toEqual({ status: "approved" })
expect(ledger.spentWithin("did:example:payer", "USDC", 60_000)).toBe(0n)
})

it("records and approves an over-budget payment when allowOverBudget is set", () => {
const ledger = createSpendLedger()
const payment = { ...basePaymentOption, amount: 1_000 }

for (let i = 0; i < 3; i++) {
expect(
authorizePayment(payment, budgetPolicy, {
subject: "did:example:payer",
reference: `fill-${i}`,
ledger,
}),
).toEqual({ status: "approved" })
}

expect(
authorizePayment(payment, budgetPolicy, {
subject: "did:example:payer",
reference: "settled-callback",
ledger,
}),
).toEqual({
status: "denied",
reason:
"Payment exceeds the autonomous spend budget for the current window",
})

expect(
authorizePayment(payment, budgetPolicy, {
subject: "did:example:payer",
reference: "settled-callback",
ledger,
allowOverBudget: true,
}),
).toEqual({ status: "approved" })

expect(ledger.spentWithin("did:example:payer", "USDC", 60_000)).toBe(
4_000n,
)
})
})
Loading