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
21 changes: 21 additions & 0 deletions demos/payments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,30 @@ 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 autonomous spend limit: deny before payment execution

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

https://github.com/user-attachments/assets/193b8f66-443f-457f-9370-32835f3b1c85
Expand Down
66 changes: 66 additions & 0 deletions demos/payments/src/payment-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest"

import { evaluatePaymentPolicy } from "./payment-policy"

const basePaymentOption = {
id: "base-usdc",
amount: 100,
decimals: 2,
currency: "USDC",
recipient: "did:example:merchant",
}

describe("evaluatePaymentPolicy", () => {
it("approves below-threshold payments to allowed recipients", () => {
const decision = evaluatePaymentPolicy(basePaymentOption, {
allowedRecipients: [basePaymentOption.recipient],
maxAutonomousAmount: 1_000,
})

expect(decision).toEqual({
status: "approved",
})
})

it("does not approve self-asserted recipients without an allowlist", () => {
const decision = evaluatePaymentPolicy(basePaymentOption, {
allowedRecipients: [],
maxAutonomousAmount: 1_000,
})

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: 1_000,
})

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: 1_000,
},
)

expect(decision).toEqual({
status: "denied",
reason: "Payment amount exceeds the autonomous spend limit",
})
})
})
63 changes: 63 additions & 0 deletions demos/payments/src/payment-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { PaymentOption } from "agentcommercekit"

export type PaymentPolicyDecision =
| {
status: "approved"
}
| {
status: "approval_required" | "denied"
reason: string
}

export interface PaymentPolicy {
allowedRecipients: readonly string[]
maxAutonomousAmount: number
}

export const demoPaymentPolicy: PaymentPolicy = {
allowedRecipients: [],
maxAutonomousAmount: 1_000_000,
}

export function evaluatePaymentPolicy(
paymentOption: PaymentOption,
policy: PaymentPolicy = demoPaymentPolicy,
): PaymentPolicyDecision {
const amount = Number(paymentOption.amount)

if (!Number.isFinite(amount)) {
return {
status: "denied",
reason: "Payment amount must be a finite number",
}
}

if (amount > policy.maxAutonomousAmount) {
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",
}
}

export function assertPaymentPolicyApproved(
paymentOption: PaymentOption,
policy: PaymentPolicy = demoPaymentPolicy,
) {
const decision = evaluatePaymentPolicy(paymentOption, policy)

if (decision.status !== "approved") {
throw new Error(decision.reason)
}
}
40 changes: 36 additions & 4 deletions demos/payments/src/payment-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { evaluatePaymentPolicy } from "./payment-policy"
import { getKeypairInfo } from "./utils/keypair-info"

const app = new Hono<Env>()
Expand All @@ -39,8 +40,13 @@ app.post("/", async (c): Promise<TypedResponse<{ paymentUrl: string }>> => {
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 ...`))

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -124,7 +131,7 @@ async function validatePaymentOption(
const didResolver = getDidResolver()

log(colors.dim(`${name} Verifying payment request token...`))
const { paymentRequest } = await verifyPaymentRequestToken(
const { paymentRequest, parsed } = await verifyPaymentRequestToken(
paymentRequestToken,
{
resolver: didResolver,
Expand All @@ -146,9 +153,34 @@ async function validatePaymentOption(
return {
paymentRequest,
paymentOption,
parsed,
}
}

function enforcePaymentPolicy(
paymentOption: Awaited<
ReturnType<typeof validatePaymentOption>
>["paymentOption"],
allowedRecipients: readonly string[],
) {
const decision = evaluatePaymentPolicy(paymentOption, {
allowedRecipients,
maxAutonomousAmount: 1_000_000,
})

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<Env>) {
const serverIdentity = await getKeypairInfo(env(c).SERVER_PRIVATE_KEY_HEX)
return [serverIdentity.did]
}

serve({
port: 4569,
fetch: app.fetch,
Expand Down