-
Notifications
You must be signed in to change notification settings - Fork 109
demo(payments): enforce policy before signing #97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
47e59db
02014d3
790ad42
734f40a
4cfc2e7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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", () => { | ||
| 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", | ||
| }) | ||
| }) | ||
| }) | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent inherited-key crashes when resolving per-currency limits.
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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
| 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", | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rename test cases to the required assertive pattern.
Current
it(...)titles don’t follow the enforcedcreates/throws/requires/returnsnaming convention for*.test.tsfiles. 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