diff --git a/.github/workflows/check-rfc.yaml b/.github/workflows/check-rfc.yaml new file mode 100644 index 00000000..e4b44124 --- /dev/null +++ b/.github/workflows/check-rfc.yaml @@ -0,0 +1,35 @@ +name: Check RFC + +on: + push: + paths: + - "docs/ack-id/rfc/**" + - "docs/ack-pay/rfc/**" + - "docs/package.json" + - "docs/vitest.config.ts" + - ".github/workflows/check-rfc.yaml" + pull_request: + branches: + - ack-id-core-rfc + paths: + - "docs/ack-id/rfc/**" + - "docs/ack-pay/rfc/**" + - "docs/package.json" + - "docs/vitest.config.ts" + - ".github/workflows/check-rfc.yaml" + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + env: + ANTHROPIC_API_KEY: secret + ISSUER_PRIVATE_KEY: "0xa45f5c566918ef954e8c200a96b14092cabcd69cb8a1a132804a2b8cbb8489a1" + VERIFIER_PRIVATE_KEY: "0xeeca8f89b2f5196126f7d9199e739153bd43a13f9cdd1099e7191a33143a2059" + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: pnpm run build + - run: pnpm run check diff --git a/docs/ack-id/rfc/core.md b/docs/ack-id/rfc/core.md index 5ce4089a..00f305ff 100644 --- a/docs/ack-id/rfc/core.md +++ b/docs/ack-id/rfc/core.md @@ -367,6 +367,10 @@ ext-revocation. exposure with no revocation check at all. - **Single-use grants.** For one-shot authority, the RP records the `jti` at first acceptance and rejects reuse; the grant is spent when used. + For a single-use payment grant, checking the grant before issuing a + payment challenge is not acceptance of the paid operation and MUST NOT + redeem that grant. ACK-Pay core Section 4.1 defines the ordering between + grant checks, challenges, redemption, and paid execution. Which actions need single-use authority is the RP's call (a payment authorization, a one-shot registration); core supplies the mechanism. Redemption MUST be atomic: a check-and-set keyed by (`iss`, `jti`). A diff --git a/docs/ack-pay/rfc/core.md b/docs/ack-pay/rfc/core.md index d73d31b7..b1fafa55 100644 --- a/docs/ack-pay/rfc/core.md +++ b/docs/ack-pay/rfc/core.md @@ -101,6 +101,39 @@ proofs). This is the full trail from a payment event to the legal entity behind the paying agent, walkable by a third party with no callback to any participant. +### 4.1 Single-use payment grants and payment challenges + +This section applies when a seller verifies a single-use payment grant +before returning a payment challenge. It distinguishes validation of +permission from acceptance of the paid operation that consumes it. + +- A request that only produces a `402 Payment Required` challenge, without + performing the operation authorized by the payment grant, MUST NOT + redeem that grant. A separately authorized paid quote-generation + operation may consume its own grant; it is not a challenge-only request. +- The paid retry MUST carry a new request signature and pass the current + ACK-ID request and grant checks. A successful earlier check is neither + a reservation nor a promise of later acceptance. The seller MUST + validate the payment payload before redeeming the grant; validation + here does not itself submit settlement or perform the paid operation. +- Before the first authorized effect, the seller MUST atomically and + durably redeem the selected grant by (`iss`, `jti`), using ACK-ID core + Section 8's single-use mechanism. Authorized effects include submitting + settlement, accepting a deferred payment obligation, executing the paid + operation, and delivering the protected resource. Only the request that + wins redemption may start those effects. Coordination MUST cover every + server accepting that grant for the same RP. +- Once an authorized effect has started or been submitted, a timeout or + uncertain outcome MUST NOT make the grant available for another + operation. Recovery must reconcile the existing operation. A redemption + record is not a lease that expires on a request timeout. + +This ordering adds no wire fields. Request-signature replay checks still +apply to both attempts. A production implementation needs durable state +and rail-specific recovery; these rules do not promise exactly-once +settlement or successful delivery after a crash. The executable examples +in `vectors/payment-grant-lifecycle.test.ts` model the ordering only. + ## 5. Third-party verification A third party verifies a receipt (with its offer, when presented together) diff --git a/docs/ack-pay/rfc/vectors/README.md b/docs/ack-pay/rfc/vectors/README.md new file mode 100644 index 00000000..4cb5ab44 --- /dev/null +++ b/docs/ack-pay/rfc/vectors/README.md @@ -0,0 +1,17 @@ +# Single-use payment grant lifecycle examples + +Run from the repository root after installing its pinned dependencies: + +```sh +pnpm --filter @docs/agentcommercekit test +``` + +These executable examples specify the ordering in ACK-Pay core Section 4.1. +Request/grant and payment validation are explicit inputs; the examples do +not implement cryptography or verify x402 payloads. They cover the unpaid +challenge, paid retry, concurrent requests, and uncertain execution outcome. + +The in-memory claim is atomic within one process only. A production adapter +needs durable atomic claims shared across the RP's acceptance domain and +rail-specific recovery. The examples do not test distributed storage, +settlement, record-retention deadlines, or exactly-once execution. diff --git a/docs/ack-pay/rfc/vectors/payment-grant-lifecycle.test.ts b/docs/ack-pay/rfc/vectors/payment-grant-lifecycle.test.ts new file mode 100644 index 00000000..42846f91 --- /dev/null +++ b/docs/ack-pay/rfc/vectors/payment-grant-lifecycle.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest" + +// Ordering examples, not an ACK verifier or a settlement implementation. +// Inputs stand for the results of fresh request/grant and payment validation. +type Grant = { iss: string; jti: string } +type Attempt = { + grant: Grant + authorized: boolean + payment: "absent" | "invalid" | "valid" + execute?: () => Promise +} + +function createMerchant() { + const records = new Map() + let started = 0 + + async function handle(attempt: Attempt) { + if (!attempt.authorized) { + return "unauthorized" + } + if (attempt.payment === "absent") { + return "challenge" + } + if (attempt.payment === "invalid") { + return "invalid-payment" + } + + // The identity comes from the validated grant, not the payment payload. + const key = JSON.stringify([attempt.grant.iss, attempt.grant.jti]) + // Synchronous claim-before-await is atomic only within this model. + // Real adapters need a durable atomic store shared by all RP replicas. + if (records.has(key)) { + return "already-claimed" + } + records.set(key, "started") + started++ + try { + await attempt.execute?.() + records.set(key, "completed") + return "completed" + } catch { + records.set(key, "unknown") + return "unknown" + } + } + + return { handle, started: () => started } +} + +const grant = { iss: "did:web:owner.example", jti: "purchase-1" } +const unpaid: Attempt = { grant, authorized: true, payment: "absent" } +const paid: Attempt = { grant, authorized: true, payment: "valid" } + +describe("single-use payment grant lifecycle", () => { + it("preserves a checked grant through the challenge and paid retry", async () => { + const merchant = createMerchant() + expect(await merchant.handle(unpaid)).toBe("challenge") + expect(merchant.started()).toBe(0) + expect(await merchant.handle(paid)).toBe("completed") + expect(merchant.started()).toBe(1) + }) + + it("preserves the grant through repeated challenge-only requests", async () => { + const merchant = createMerchant() + expect(await merchant.handle(unpaid)).toBe("challenge") + expect(await merchant.handle(unpaid)).toBe("challenge") + expect(await merchant.handle(paid)).toBe("completed") + expect(merchant.started()).toBe(1) + }) + + it("rejects an unauthorized attempt before redeeming the grant", async () => { + const merchant = createMerchant() + expect(await merchant.handle({ ...paid, authorized: false })).toBe( + "unauthorized", + ) + expect(merchant.started()).toBe(0) + expect(await merchant.handle(paid)).toBe("completed") + }) + + it("rejects invalid payment evidence before redeeming the grant", async () => { + const merchant = createMerchant() + expect(await merchant.handle({ ...paid, payment: "invalid" })).toBe( + "invalid-payment", + ) + expect(merchant.started()).toBe(0) + expect(await merchant.handle(paid)).toBe("completed") + }) + + it("uses the paid retry's current authorization result", async () => { + const merchant = createMerchant() + expect(await merchant.handle(unpaid)).toBe("challenge") + // For example, the grant expired or was revoked after the challenge. + expect(await merchant.handle({ ...paid, authorized: false })).toBe( + "unauthorized", + ) + expect(merchant.started()).toBe(0) + }) + + it("allows only one concurrent paid request to begin execution", async () => { + const merchant = createMerchant() + let release: (() => void) | undefined + const pending = new Promise((resolve) => { + release = resolve + }) + const first = merchant.handle({ ...paid, execute: () => pending }) + try { + expect(await merchant.handle(paid)).toBe("already-claimed") + expect(merchant.started()).toBe(1) + } finally { + release?.() + } + expect(await first).toBe("completed") + }) + + it("keeps the claim when a submitted operation has an uncertain outcome", async () => { + const merchant = createMerchant() + expect( + await merchant.handle({ + ...paid, + execute: () => Promise.reject(new Error("settlement timed out")), + }), + ).toBe("unknown") + expect(await merchant.handle(paid)).toBe("already-claimed") + expect(merchant.started()).toBe(1) + }) + + it("rejects another paid operation after completion", async () => { + const merchant = createMerchant() + expect(await merchant.handle(paid)).toBe("completed") + expect(await merchant.handle(paid)).toBe("already-claimed") + expect(merchant.started()).toBe(1) + }) + + it("keeps grants with the same jti from different issuers independent", async () => { + const merchant = createMerchant() + expect(await merchant.handle(paid)).toBe("completed") + expect( + await merchant.handle({ + ...paid, + grant: { ...grant, iss: "did:web:another-owner.example" }, + }), + ).toBe("completed") + expect(merchant.started()).toBe(2) + }) +}) diff --git a/docs/package.json b/docs/package.json index dfaa38ab..c43faf63 100644 --- a/docs/package.json +++ b/docs/package.json @@ -16,7 +16,8 @@ }, "scripts": { "clean": "git clean -fdX .turbo dist", - "docs": "mintlify dev" + "docs": "mintlify dev", + "test": "vitest run" }, "devDependencies": { "mintlify": "4.2.637" diff --git a/docs/vitest.config.ts b/docs/vitest.config.ts new file mode 100644 index 00000000..ce9a44f7 --- /dev/null +++ b/docs/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + include: ["ack-pay/rfc/vectors/**/*.test.ts"], + watch: false, + }, +})