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
35 changes: 35 additions & 0 deletions .github/workflows/check-rfc.yaml
Original file line number Diff line number Diff line change
@@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Private keys are being shown

VERIFIER_PRIVATE_KEY: "0xeeca8f89b2f5196126f7d9199e739153bd43a13f9cdd1099e7191a33143a2059"
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- run: pnpm run build
- run: pnpm run check
4 changes: 4 additions & 0 deletions docs/ack-id/rfc/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions docs/ack-pay/rfc/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions docs/ack-pay/rfc/vectors/README.md
Original file line number Diff line number Diff line change
@@ -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.
145 changes: 145 additions & 0 deletions docs/ack-pay/rfc/vectors/payment-grant-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>
}

function createMerchant() {
const records = new Map<string, "started" | "completed" | "unknown">()
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<void>((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)
})
})
3 changes: 2 additions & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions docs/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config"

export default defineConfig({
test: {
include: ["ack-pay/rfc/vectors/**/*.test.ts"],
watch: false,
},
})