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

<p align="center">
Expand Down
147 changes: 147 additions & 0 deletions demos/payments/src/payment-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Comment on lines +14 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Rename test cases to the required assertive pattern.

Current it(...) titles don’t follow the enforced creates/throws/requires/returns naming convention for *.test.ts files. Please rename these test names accordingly (for example, it("returns approved decision..."), it("requires approval..."), it("returns denied decision...")).

As per coding guidelines: Use assertive test names with patterns: "it("creates...")" , "it("throws...")" , "it("requires...")" , "it("returns...")".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@demos/payments/src/payment-policy.test.ts` around lines 14 - 133, Rename each
it(...) test title to an assertive form using the required verbs
(returns/creates/requires/throws) while keeping the rest of the description and
the same test bodies; update titles referencing
evaluatePaymentPolicy/basePaymentOption accordingly (e.g., change "approves
below-threshold payments to allowed recipients" to "returns approved decision
for below-threshold payments to allowed recipients", "approves string subunit
amounts within the limit" to "returns approved decision for string subunit
amounts within the limit", "does not approve self-asserted recipients without an
allowlist" to "requires approval for self-asserted recipients without an
allowlist", "requires approval before execution for unknown recipients" to
"requires approval for unknown recipients", "denies payments above the
autonomous spend limit" to "returns denied decision for payments above the
autonomous spend limit", "applies the per-currency limit in the currency's own
subunits" to "returns approved decision when per-currency limits are respected",
"denies currencies with no configured limit" to "returns denied decision for
currencies with no configured limit", "denies non-positive amounts" to "returns
denied decision for non-positive amounts", etc.; ensure all remaining it(...)
titles follow the same assertive verb pattern.

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",
})
})
})
86 changes: 86 additions & 0 deletions demos/payments/src/payment-policy.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, bigint>>
}

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) {
Comment on lines +61 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent inherited-key crashes when resolving per-currency limits.

policy.maxAutonomousAmount[paymentOption.currency] can resolve inherited keys (for example "constructor"), which makes amount > limit throw and turns an invalid input into a 500 path instead of a deterministic policy denial.

Suggested fix
-  const limit = policy.maxAutonomousAmount[paymentOption.currency]
-  if (limit === undefined) {
+  if (
+    !Object.prototype.hasOwnProperty.call(
+      policy.maxAutonomousAmount,
+      paymentOption.currency,
+    )
+  ) {
     return {
       status: "denied",
       reason: `No autonomous spend limit configured for currency ${paymentOption.currency}`,
     }
   }
+  const limit = policy.maxAutonomousAmount[paymentOption.currency]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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) {
if (
!Object.prototype.hasOwnProperty.call(
policy.maxAutonomousAmount,
paymentOption.currency,
)
) {
return {
status: "denied",
reason: `No autonomous spend limit configured for currency ${paymentOption.currency}`,
}
}
const limit = policy.maxAutonomousAmount[paymentOption.currency]
if (amount > limit) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@demos/payments/src/payment-policy.ts` around lines 61 - 69, The code
currently reads limit = policy.maxAutonomousAmount[paymentOption.currency] and
then compares amount > limit, but that can pick up inherited prototype keys
(e.g., "constructor") and cause a crash; change the lookup to only accept own
properties and numeric values — e.g., use
Object.prototype.hasOwnProperty.call(policy.maxAutonomousAmount,
paymentOption.currency) (or ensure maxAutonomousAmount is Object.create(null))
before assigning/using limit, and also verify Number.isFinite(limit); if the
property is not an own property or not a finite number, treat it as undefined
and return the deterministic denial branch used for missing limits. Ensure
references: policy.maxAutonomousAmount, paymentOption.currency, amount, and
limit are updated accordingly.

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",
}
}
37 changes: 34 additions & 3 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 { demoPaymentPolicy, 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 @@ -149,6 +156,30 @@ async function validatePaymentOption(
}
}

function enforcePaymentPolicy(
paymentOption: Awaited<
ReturnType<typeof validatePaymentOption>
>["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<Env>) {
const serverIdentity = await getKeypairInfo(env(c).SERVER_PRIVATE_KEY_HEX)
return [serverIdentity.did]
}

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