diff --git a/demos/payments/README.md b/demos/payments/README.md index 56b3cfb..019fe26 100644 --- a/demos/payments/README.md +++ b/demos/payments/README.md @@ -20,9 +20,43 @@ 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. You can learn more about the full ACK-Pay protocol at [www.agentcommercekit.com](https://www.agentcommercekit.com). +## Policy-before-signing example + +The Stripe Payment Service path includes a tiny local policy guard in +`src/payment-policy.ts`. The guard runs after the Payment Request token and +payment option are verified, but before the demo returns a payment URL or signs +the payload that asks the Receipt Service to issue a receipt. + +This is an example pattern, not a normative ACK-Pay policy engine. Production +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 +- unknown recipient: return `approval_required` +- amount above the illustrative per-transaction cap: 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. + +> [!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 demo allowlist is based on the configured server identity, not the issuer +claimed by each incoming Payment Request token. A real Payment Service should +load this allowlist from operator-controlled configuration. + ## Demo Video

diff --git a/demos/payments/src/payment-policy.test.ts b/demos/payments/src/payment-policy.test.ts new file mode 100644 index 0000000..635b212 --- /dev/null +++ b/demos/payments/src/payment-policy.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest" + +import { evaluatePaymentPolicy } from "./payment-policy" + +const basePaymentOption = { + id: "base-usdc", + amount: 100, + decimals: 6, + currency: "USDC", + recipient: "did:example:merchant", +} + +describe("evaluatePaymentPolicy", () => { + it("approves below-threshold payments to allowed recipients", () => { + const decision = evaluatePaymentPolicy(basePaymentOption, { + allowedRecipients: [basePaymentOption.recipient], + maxAutonomousAmount: { USDC: 1_000n }, + }) + + expect(decision).toEqual({ + status: "approved", + }) + }) + + it("approves string subunit amounts within the limit", () => { + const decision = evaluatePaymentPolicy( + { ...basePaymentOption, amount: "50000" }, + { + allowedRecipients: [basePaymentOption.recipient], + maxAutonomousAmount: { USDC: 5_000_000n }, + }, + ) + + expect(decision).toEqual({ + status: "approved", + }) + }) + + it("does not approve self-asserted recipients without an allowlist", () => { + const decision = evaluatePaymentPolicy(basePaymentOption, { + allowedRecipients: [], + maxAutonomousAmount: { USDC: 1_000n }, + }) + + expect(decision).toEqual({ + status: "approval_required", + reason: "Recipient is not on the autonomous payment allowlist", + }) + }) + + it("requires approval before execution for unknown recipients", () => { + const decision = evaluatePaymentPolicy(basePaymentOption, { + allowedRecipients: ["did:example:trusted-merchant"], + maxAutonomousAmount: { USDC: 1_000n }, + }) + + expect(decision).toEqual({ + status: "approval_required", + reason: "Recipient is not on the autonomous payment allowlist", + }) + }) + + it("denies payments above the autonomous spend limit", () => { + const decision = evaluatePaymentPolicy( + { + ...basePaymentOption, + amount: 10_000, + }, + { + allowedRecipients: [basePaymentOption.recipient], + maxAutonomousAmount: { USDC: 1_000n }, + }, + ) + + expect(decision).toEqual({ + status: "denied", + reason: "Payment amount exceeds the autonomous spend limit", + }) + }) + + it("applies the per-currency limit in the currency's own subunits", () => { + const policy = { + allowedRecipients: [basePaymentOption.recipient], + // 5.00 USD (2dp) and 5.000000 USDC (6dp) — same value, different subunits + maxAutonomousAmount: { USD: 500n, USDC: 5_000_000n }, + } + + // 4.00 USD is below the USD limit + expect( + evaluatePaymentPolicy( + { ...basePaymentOption, amount: 400, decimals: 2, currency: "USD" }, + policy, + ), + ).toEqual({ status: "approved" }) + + // The same 400 subunits in USDC (0.0004) is also below the USDC limit, + // confirming each currency is bounded by its own threshold + expect( + evaluatePaymentPolicy({ ...basePaymentOption, amount: 400 }, policy), + ).toEqual({ status: "approved" }) + }) + + it("denies currencies with no configured limit", () => { + const decision = evaluatePaymentPolicy( + { ...basePaymentOption, currency: "SOL", decimals: 9 }, + { + allowedRecipients: [basePaymentOption.recipient], + maxAutonomousAmount: { USDC: 1_000n }, + }, + ) + + expect(decision).toEqual({ + status: "denied", + reason: "No autonomous spend limit configured for currency SOL", + }) + }) + + it("denies non-positive amounts", () => { + const decision = evaluatePaymentPolicy( + { ...basePaymentOption, amount: 0 }, + { + allowedRecipients: [basePaymentOption.recipient], + maxAutonomousAmount: { USDC: 1_000n }, + }, + ) + + expect(decision).toEqual({ + status: "denied", + reason: "Payment amount must be greater than zero", + }) + }) + + it("denies fractional or malformed amounts the schema permits as strings", () => { + const decision = evaluatePaymentPolicy( + { ...basePaymentOption, amount: "1.5" }, + { + allowedRecipients: [basePaymentOption.recipient], + maxAutonomousAmount: { USDC: 1_000n }, + }, + ) + + expect(decision).toEqual({ + status: "denied", + reason: "Payment amount must be a positive integer in subunits", + }) + }) +}) diff --git a/demos/payments/src/payment-policy.ts b/demos/payments/src/payment-policy.ts new file mode 100644 index 0000000..61a6976 --- /dev/null +++ b/demos/payments/src/payment-policy.ts @@ -0,0 +1,86 @@ +import type { PaymentOption } from "agentcommercekit" + +export type PaymentPolicyDecision = + | { + status: "approved" + } + | { + status: "approval_required" | "denied" + reason: string + } + +export interface PaymentPolicy { + allowedRecipients: readonly string[] + /** + * Per-transaction autonomous spend limits, expressed in each currency's + * smallest subunit (matching `PaymentOption.amount`) and keyed by currency + * code. Keeping the limit per-currency avoids comparing a single flat + * 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. + */ + maxAutonomousAmount: Readonly> +} + +export const demoPaymentPolicy: PaymentPolicy = { + allowedRecipients: [], + maxAutonomousAmount: { + // 5.00 USD (2 decimals) and 5.000000 USDC (6 decimals) + USD: 500n, + USDC: 5_000_000n, + }, +} + +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 { + return { + status: "denied", + reason: "Payment amount must be a positive integer in subunits", + } + } + + if (amount <= 0n) { + return { + status: "denied", + reason: "Payment amount must be greater than zero", + } + } + + const limit = policy.maxAutonomousAmount[paymentOption.currency] + if (limit === undefined) { + return { + status: "denied", + reason: `No autonomous spend limit configured for currency ${paymentOption.currency}`, + } + } + + if (amount > limit) { + return { + status: "denied", + reason: "Payment amount exceeds the autonomous spend limit", + } + } + + if (!policy.allowedRecipients.includes(paymentOption.recipient)) { + return { + status: "approval_required", + reason: "Recipient is not on the autonomous payment allowlist", + } + } + + return { + status: "approved", + } +} diff --git a/demos/payments/src/payment-service.ts b/demos/payments/src/payment-service.ts index 356ba5f..10eb4a4 100644 --- a/demos/payments/src/payment-service.ts +++ b/demos/payments/src/payment-service.ts @@ -10,12 +10,13 @@ import { type Verifiable, } from "agentcommercekit" import { jwtStringSchema } from "agentcommercekit/schemas/valibot" -import { Hono, type Env, type TypedResponse } from "hono" +import { Hono, type Context, type Env, type TypedResponse } from "hono" import { env } from "hono/adapter" import { HTTPException } from "hono/http-exception" import * as v from "valibot" import { PAYMENT_SERVICE_URL } from "./constants" +import { demoPaymentPolicy, evaluatePaymentPolicy } from "./payment-policy" import { getKeypairInfo } from "./utils/keypair-info" const app = new Hono() @@ -39,8 +40,13 @@ app.post("/", async (c): Promise> => { await c.req.json(), ) - // Verify the payment request token and payment option are valid - await validatePaymentOption(paymentOptionId, paymentRequestToken) + // Verify the payment request token and payment option are valid before + // returning an execution URL. + const { paymentOption } = await validatePaymentOption( + paymentOptionId, + paymentRequestToken, + ) + enforcePaymentPolicy(paymentOption, await getTrustedRecipients(c)) log(colors.dim(`${name} Generating Stripe payment URL ...`)) @@ -81,6 +87,7 @@ app.post( if (!receiptServiceUrl) { throw new Error(errorMessage("Receipt service URL is required")) } + enforcePaymentPolicy(paymentOption, await getTrustedRecipients(c)) const payload = { paymentRequestToken, @@ -149,6 +156,30 @@ async function validatePaymentOption( } } +function enforcePaymentPolicy( + paymentOption: Awaited< + ReturnType + >["paymentOption"], + allowedRecipients: readonly string[], +) { + const decision = evaluatePaymentPolicy(paymentOption, { + ...demoPaymentPolicy, + allowedRecipients, + }) + + if (decision.status !== "approved") { + log(errorMessage(`${name} ${decision.reason}`)) + throw new HTTPException(decision.status === "denied" ? 403 : 409, { + message: decision.reason, + }) + } +} + +async function getTrustedRecipients(c: Context) { + const serverIdentity = await getKeypairInfo(env(c).SERVER_PRIVATE_KEY_HEX) + return [serverIdentity.did] +} + serve({ port: 4569, fetch: app.fetch,