Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1835825
feat: add retry key and redelivery contract
Revinand Nov 6, 2026
d2b1f3e
feat(storage): scope idempotency recovery by endpoint
Revinand Nov 6, 2026
3ded733
feat(core): send an idempotency key for paid operations
Revinand Nov 6, 2026
5e4b234
feat(core): redeliver a stored response on a client retry
Revinand Nov 7, 2026
01d5dfe
feat(payments): support the x402 payment-identifier extension
Revinand Nov 7, 2026
5fa1f2b
feat(payments): accept Idempotency-Key for MPP
Revinand Nov 7, 2026
3cab627
feat(protocols): map payment retries on MCP and A2A
Revinand Nov 7, 2026
a0a3111
feat(gateway): recover payment keys at startup
Revinand Nov 7, 2026
322abf1
test: retry a paid request after a lost response
Revinand Nov 7, 2026
5b760bd
test(payments): cover payment identifiers on Solana and NEAR offers
Revinand Nov 7, 2026
fb2b0f5
fix(core): tie retry answers to the purchase's proof and mandate
Revinand Nov 7, 2026
79c06af
fix: bound the lifetime of payment retry keys
Revinand Nov 7, 2026
ce0d4d1
fix: correct IDs, events and retry hints on retry answers
Revinand Nov 7, 2026
8c9ab22
refactor: hash the paid input once and share the Idempotency-Key rule
Revinand Nov 7, 2026
ed57060
fix(mpp): parse quoted Idempotency-Key, redact it, use 422 for conflicts
Revinand Nov 7, 2026
fc7744b
docs: document retries after a lost response
Revinand Nov 7, 2026
2c745f2
docs: document safe retries
Revinand Nov 7, 2026
8983e51
Merge branch 'master' into feature/safe-retries
Revinand Nov 7, 2026
1395266
feat(x402): sign CDP facilitator JWTs without @coinbase/x402
Revinand Nov 7, 2026
7de7f3b
build(deps): move x402 to 2.30.0 and @solana/kit to 8.6.0
Revinand Nov 7, 2026
ad4bd8b
build(deps): move to Zod 4.6.7
Revinand Nov 7, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ jobs:
- name: Install package with optional peers
run: |
cd "$RUNNER_TEMP/bare"
npm install @modelcontextprotocol/server@2.3.0 @x402/core@2.25.0 @x402/evm@2.25.0 viem@2.57.1
npm install @modelcontextprotocol/server@2.3.0 @x402/core@2.30.0 @x402/evm@2.30.0 viem@2.57.1
node --input-type=module -e "
import { createGateway, loadConfig, receipts, CommerceError } from '@devlab.group/agent-commerce';
import { mcp } from '@devlab.group/agent-commerce/mcp';
Expand Down
8 changes: 3 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,9 @@ The repository has one `package.json` and publishes
binary and five library paths - `.`, `./ap2`, `./mcp`, `./mpp` and `./x402` -
built from `src/` into `dist/`.

The main entry and the CLI must import no optional peer. Peers load only
through the subpaths that use them, and `@coinbase/x402` only for
`auth.type: cdp`;
[published entry points](docs/contracts.md#published-entry-points) lists which
entry imports which peer.
The main entry and CLI must not statically import optional peers. Load peers
through the subpaths that use them; see
[published entry points](docs/contracts.md#published-entry-points) for the import map.
Architectural boundaries are directories under `src/`, not package manifests.
The packaging tests enforce both, and reject a `pnpm-workspace.yaml` file or a
`packages/` directory.
Expand Down
25 changes: 15 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ free HTTP resources does not install MCP, EVM, JOSE or SD-JWT packages.
| accept x402 on NEAR | x402 peers `+ @x402/near` | (no import - loaded for a NEAR offer) |
| accept MPP payments | `+ mppx viem @x402/core @x402/evm` | `from '@devlab.group/agent-commerce/mpp'` |
| verify AP2 mandates, sign checkout JWTs | `+ jose @sd-jwt/core canonicalize` | `from '@devlab.group/agent-commerce/ap2'` |
| authenticate to a CDP facilitator | `+ @coinbase/x402` | (no import - loaded on demand) |

The x402 entry needs `@x402/core`, `@x402/evm` and `viem` even when every
offer is on Solana or NEAR. MPP settles through an x402 facilitator, so its
Expand All @@ -144,10 +143,6 @@ boundary, so a version skew is a correctness problem rather than a convenience
one. Importing a subpath without its peer fails at load time and names the
missing package.

`@coinbase/x402` has no subpath import. It is loaded dynamically only for
`facilitator.auth.type: cdp` and brings the larger CDP dependency tree. A
facilitator with a static token can use `auth.type: bearer` without it.

### Testing a custom store

`createGateway` accepts a custom `ReceiptStore`. The experimental `./testing`
Expand Down Expand Up @@ -341,7 +336,8 @@ prints the `unsupported` lists for A2A, ACP and AP2 when they are enabled.
Minor releases may break experimental configurations, integrations or types:
MPP, A2A, ACP, AP2, ERC-8409 quotes, `agent-commerce import openapi`,
`PaymentProvider.reconcile`, the `ReceiptStore` methods and types added in
2.6.0, and the `./testing` entry.
2.6.0, the `idempotency` resource settings with the store and provider methods
added in 2.7.0, and the `./testing` entry.

The journal's `Transaction` type, `ReceiptStore` journal methods and
`/api/transactions` response shape are supported. The `transactionId` fields,
Expand All @@ -365,6 +361,14 @@ differ from 2.0; see [Upgrading from 2.0 to 2.x](docs/migration-2.1.md).
- **Replay-safe twice over.** The EIP-3009 or Permit2 nonce stops a double
spend on-chain; the gateway additionally reserves a `replayKey` derived from
the authorization before it settles anything.
- **Retry after a lost response (experimental, disabled by default).** With
`idempotency.redelivery`, buyers can retrieve a stored response without
another charge using the same wallet, input and x402 `payment-identifier`
`id` or MPP `Idempotency-Key`. Matching exact resends skip verification
until retention ends, an exception to fail closed. Responses are stored
unencrypted in the receipt database. `idempotency.merchantKey` sends a
derived `Idempotency-Key` to the merchant for paid operations. See
[Retry after a lost response](docs/payment-flow.md#retry-after-a-lost-response).
- **On-chain settlement in CI.** The end-to-end test asserts the buyer's balance
falls and the merchant's rises by exactly the price, with a real transaction
hash in the receipt. A log line saying "payment successful" would not count.
Expand Down Expand Up @@ -426,9 +430,9 @@ EIP-3009 authorization without ETH, and the facilitator pays gas. Because the
authorization fixes the recipient, amount and chain, the facilitator cannot
redirect the transfer. It can see authorizations and refuse service.

Remote facilitator auth supports `none`, `bearer` and `cdp`. `cdp` loads the
optional peer `@coinbase/x402`, which signs a fresh JWT per request; bearer auth
needs no extra peer. Other types are rejected at config load.
Remote facilitator auth supports `none`, `bearer` and `cdp`. CDP auth signs a
fresh JWT per request with the CDP API key. No auth mode needs an extra peer
package. Config load rejects other types.

### Base Sepolia

Expand Down Expand Up @@ -671,7 +675,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
mainnet, Solana devnet and Solana mainnet, receipts, doctor, deterministic
demo, experimental A2A v1.0.1 and ACP `2026-04-17` checkout adapters,
experimental AP2 v0.2.0 mandate verification, experimental MPP `charge`
payments, and experimental OpenAPI import.
payments, experimental OpenAPI import, and experimental retries after a lost
response.

**Next** - a `doctor` GitHub Action · UCP ·
autonomous-mode AP2 (open mandates, agent key binding, constraint evaluation) ·
Expand Down
9 changes: 9 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ resources:
# What the backend returns, declared in each x402 challenge before payment;
# an HTTP delivery keeps the backend's own Content-Type when it is text
# mimeType: application/json
# Experimental paid retries, off by default. merchantKey sends the backend
# an Idempotency-Key derived from the purchase. redelivery stores the
# response, so a retry with the buyer's key gets it without another charge.
# See docs/configuration.md#resources.
# idempotency:
# merchantKey: true
# redelivery:
# retentionHours: 24
# required: false

payments:
x402:
Expand Down
4 changes: 2 additions & 2 deletions docker/node.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ USER node
# Manifest first, so the dependency install caches independently of source
COPY --chown=node:node package.json package-lock.json ./

# --ignore-scripts skips every dependency install script, such as the
# postinstall steps of @coinbase/x402 and esbuild. None is needed here:
# --ignore-scripts skips every dependency install script, such as esbuild's
# postinstall step. None is needed here:
# - better-sqlite3@13 ships its binaries in the tarball (`prebuilds/`) and sets
# `"gypfile": false`, so npm does not add its implicit `node-gyp rebuild`,
# which would need a toolchain this slim image lacks.
Expand Down
72 changes: 67 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,64 @@ fits the body (see [security.md](security.md#backend-response-relay)); this
setting does not change it. Config load accepts `mimeType` only on a fixed-price
resource that lists `x402` in `payments`.

Enable `idempotency.merchantKey: true` to send an `Idempotency-Key` header with
paid backend calls. This setting is experimental and disabled by default.
The merchant decides how to handle repeated keys.

The generated key hashes `merchant.publicBaseUrl`, the resource ID, a payment
identity and the canonical input digest with SHA-256. The identity defaults to
the payment's replay identity. With `redelivery`, requests that have both a
client retry key and a verified payer use the key's hash and payer instead.
The retry claim prevents concurrent purchases under that key, and re-signed
retries keep the same merchant key. With `merchantKey` alone, the client key
is ignored; a different replay identity produces a different merchant key.
Changing `merchant.publicBaseUrl` also changes generated keys.

An adapter's operation key takes precedence over the generated key. Either
overrides an `Idempotency-Key` in `backend.headers`. Without a client retry
key, input with no canonical JSON digest, such as a string containing a lone
surrogate, receives no generated key and can still be delivered. Config load
accepts `idempotency` only on a fixed-price resource.

For paid requests with a client retry key, `idempotency.redelivery` stores the
response for reuse with matching input, without another charge. It is
experimental and disabled by default. The x402 provider extracts the buyer's
`id` from the echoed `payment-identifier` extension (see
[x402.md](protocols/x402.md#payment-identifier)), and the HTTP invoke route
reads MPP's `Idempotency-Key` header (see
[mpp.md](protocols/mpp.md#idempotency-key)). Direct pipeline requests can set
`CanonicalRequest.retryKey`, which takes precedence over provider extraction.
Custom providers can implement `PaymentProvider.retryKey`. With redelivery
enabled, input without a canonical JSON digest fails with `INPUT_INVALID`
when a retry key is used.

`retentionHours` is an integer from 1 to 8760 (one year), default 24. Config
load rejects other values. The window starts at the initial retry check, before
proof verification. Until it ends, a matching exact resend gets the stored
response without verification, even after the proof or challenge expires. A
verified fresh proof from the same payer can also retrieve it. After expiry,
retries follow the normal purchase flow. Stored responses are unencrypted in
the receipt database and its backups; expiry does not erase them immediately.
See [security.md](security.md#buyer-protection). `required` defaults to false.
Setting it to true requires `x402` in `payments` and makes x402 retry-key
extraction reject a missing `id` with `INPUT_INVALID` (HTTP 400).
[Retry after a lost response](payment-flow.md#retry-after-a-lost-response)
describes the rules.

At startup, the gateway marks in-flight payment retry keys `unresolved`, even
if no resource still enables `redelivery`, and logs a nonzero count at WARN.
Recovery includes requests that crashed before settlement. An interrupted
request may have charged, so matching retries with `redelivery` enabled get
409 `payment_identifier_unresolved` until the key's retention deadline. After
expiry, retries follow the normal purchase flow, as with completed keys.
Reconciling or resolving the payment attempt does not change the key. `doctor`
reports unexpired unresolved keys as INFO. Run one gateway process per receipt
database.

Removing `redelivery`, renaming a resource or changing `merchant.publicBaseUrl`
makes its stored responses and unresolved keys unavailable to new requests.
Reusing a key can then start a new purchase.

`url` supports `{param}` templating from validated input; values are
URL-encoded. Remaining input becomes query string for `GET`/`DELETE` and a JSON
body otherwise - unless `backend.inputBindings` says otherwise, below.
Expand Down Expand Up @@ -687,9 +745,10 @@ facilitator:
Omitting `auth` is equivalent to `type: none`: the gateway sends no credential.
Use it only with a facilitator that accepts anonymous requests. Supported types
are `none`, `bearer` (a static token) and `cdp` (a fresh JWT per request).
The `cdp` type requires the optional `@coinbase/x402` peer. It signs each
request for `https://api.cdp.coinbase.com/platform/v2/x402`, so config load
refuses `cdp` with any other `url`.
For `cdp`, `apiKeySecret` must be CDP's PEM EC P-256 private key or base64
Ed25519 key. JWTs bind requests to CDP's host and path, so config load rejects
any `url` other than `https://api.cdp.coinbase.com/platform/v2/x402`. An
unreadable secret fails the health check with a detail naming `apiKeySecret`.

Deployment mode - `local`, `testnet` or `mainnet` - comes from the network:
`eip155:31337` is local, and every other network is the testnet or mainnet it
Expand Down Expand Up @@ -773,8 +832,11 @@ resource for two operations, or maps a paid one · an ACP idempotency retention
below 24 hours · an invalid or zero `payTo`/`asset`/`recipient` · an
`x402.accepts` list that is empty or on a resource that cannot use it · a
`mimeType` that is not a media type, or on a resource that is not fixed-price
with `x402` in `payments` · an MPP `challengeSecret` with length below 32 or a
multi-line `realm`.
with `x402` in `payments` · an `idempotency` block on a resource that is not
fixed-price, or with neither `merchantKey` nor `redelivery` · a
`redelivery.retentionHours` that is not an integer from 1 to 8760 ·
`redelivery.required: true` on a resource without `x402` in `payments` · an MPP
`challengeSecret` with length below 32 or a multi-line `realm`.

It exits non-zero on any of them.

Expand Down
11 changes: 9 additions & 2 deletions docs/contract-surface.txt
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ interface CanonicalRequest {
readonly receivedAt: string;
readonly requestId: string;
readonly resourceId: string;
readonly retryKey?: string;
}

interface Clock {
Expand All @@ -164,6 +165,7 @@ interface CommerceErrorOptions {
readonly httpStatus?: number;
readonly requestId?: string;
readonly resourceId?: string;
readonly retryable?: boolean;
}

interface CommerceEvent {
Expand Down Expand Up @@ -200,6 +202,7 @@ interface CommerceResource {
readonly exposedVia: ReadonlyArray<ProtocolName>;
readonly handler: BackendHandler;
readonly id: string;
readonly idempotency?: { readonly merchantKey?: boolean; readonly redelivery?: { readonly retentionHours: number; readonly required: boolean; }; };
readonly inputSchema?: JsonSchema;
readonly name: string;
readonly outputSchema?: JsonSchema;
Expand All @@ -216,6 +219,7 @@ interface DeliveredOutcome {
readonly payment?: PaymentResult;
readonly persistenceFailures?: ReadonlyArray<PersistenceFailure>;
readonly receipt: CommerceReceipt;
readonly redelivered?: boolean;
readonly requestId: string;
readonly resourceId: string;
readonly transactionId?: string;
Expand All @@ -226,6 +230,7 @@ interface DeliverySummary {
readonly payment?: { readonly status: "verified" | "settled" | "rejected"; readonly amount: DecimalAmount; readonly currency: string; readonly externalReference?: string; readonly network?: string; };
readonly receiptCommitted?: false;
readonly receiptId: string;
readonly redelivered?: boolean;
readonly requestId: string;
readonly resourceId: string;
}
Expand Down Expand Up @@ -379,6 +384,7 @@ interface PaymentProvider {
readonly descriptor: AdapterDescriptor;
readonly name: PaymentMethodName;
reconcile?: (context: PaymentReconciliationContext) => Promise<PaymentReconciliationResult>;
retryKey?: (context: { readonly resource: CommerceResource; readonly submission: PaymentSubmission; }) => string | undefined;
selectRequirement?: (context: PaymentVerificationContext) => Promise<PaymentRequirement>;
settle: (context: PaymentSettlementContext) => Promise<PaymentResult>;
verify: (context: PaymentVerificationContext) => Promise<PaymentResult>;
Expand Down Expand Up @@ -503,13 +509,14 @@ interface ReceiptListOptions {

interface ReceiptStore {
appendEvent: (event: CommerceEvent) => Promise<void>;
claimIdempotencyKey?: (scope: IdempotencyScope, fingerprint: string, expiresAt: IsoTimestamp) => Promise<IdempotencyClaim>;
claimIdempotencyKey?: (scope: IdempotencyScope, fingerprint: string, expiresAt: IsoTimestamp, owner?: string) => Promise<IdempotencyClaim>;
close: () => Promise<void>;
countReceipts: () => Promise<number>;
countUncertainPaymentAttempts: () => Promise<number>;
countUndeliveredReceipts: () => Promise<number>;
findPaymentAttemptByReplayKey?: (replayKey: string) => Promise<PaymentAttempt | undefined>;
getAuthorizationHoldState?: (reference: string) => Promise<AuthorizationHoldState | undefined>;
getIdempotencyKey?: (scope: IdempotencyScope, fingerprint: string) => Promise<IdempotencyClaim | undefined>;
getPaymentAttempt?: (id: string) => Promise<PaymentAttempt | undefined>;
getPendingTask?: (id: string) => Promise<PendingTask | undefined>;
getReceipt: (id: string) => Promise<CommerceReceipt | undefined>;
Expand All @@ -524,7 +531,7 @@ interface ReceiptStore {
putPendingTask?: (task: PendingTask, options: { readonly maxTasks: number; }) => Promise<boolean>;
readonly descriptor: AdapterDescriptor;
recordTransactionStep?: (step: TransactionStep) => Promise<void>;
recoverIdempotencyKeys?: (options: { readonly release: boolean; }) => Promise<number>;
recoverIdempotencyKeys?: (options: { readonly release: boolean; readonly endpointPrefix?: string; }) => Promise<number>;
reserveAuthorizationHold?: (reservation: AuthorizationHoldReservation) => Promise<AuthorizationHoldResult>;
reservePaymentAttempt: (reservation: PaymentAttemptReservation) => Promise<PaymentAttempt>;
resolvePaymentAttempt?: (request: PaymentAttemptResolveRequest) => Promise<boolean>;
Expand Down
Loading
Loading