From 183582597eb59bf6259ff9b9704de539fae25792 Mon Sep 17 00:00:00 2001 From: Revinand Date: Fri, 6 Nov 2026 15:03:40 +0100 Subject: [PATCH 01/20] feat: add retry key and redelivery contract --- docs/contract-surface.txt | 8 ++- docs/contracts.md | 19 +++++++ docs/migration-3.0.md | 2 + src/core/domain/payment.ts | 10 ++++ src/core/domain/request.ts | 10 ++++ src/core/domain/resource.ts | 15 ++++++ src/core/domain/wire.ts | 5 ++ src/core/execution/pipeline/context.ts | 5 ++ src/core/interfaces/store.ts | 15 +++++- src/core/store-method-groups.ts | 2 + src/storage/receipts/idempotency.ts | 40 +++++++++----- src/testing/conformance/record-cases.ts | 54 +++++++++++++++++++ .../unit/storage-receipts/conformance.test.ts | 15 ++++++ .../storage-receipts/record-inputs.test.ts | 28 +++++++++- 14 files changed, 212 insertions(+), 16 deletions(-) diff --git a/docs/contract-surface.txt b/docs/contract-surface.txt index d760299..6827e35 100644 --- a/docs/contract-surface.txt +++ b/docs/contract-surface.txt @@ -140,6 +140,7 @@ interface CanonicalRequest { readonly receivedAt: string; readonly requestId: string; readonly resourceId: string; + readonly retryKey?: string; } interface Clock { @@ -200,6 +201,7 @@ interface CommerceResource { readonly exposedVia: ReadonlyArray; 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; @@ -216,6 +218,7 @@ interface DeliveredOutcome { readonly payment?: PaymentResult; readonly persistenceFailures?: ReadonlyArray; readonly receipt: CommerceReceipt; + readonly redelivered?: boolean; readonly requestId: string; readonly resourceId: string; readonly transactionId?: string; @@ -226,6 +229,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; } @@ -379,6 +383,7 @@ interface PaymentProvider { readonly descriptor: AdapterDescriptor; readonly name: PaymentMethodName; reconcile?: (context: PaymentReconciliationContext) => Promise; + retryKey?: (context: { readonly resource: CommerceResource; readonly submission: PaymentSubmission; }) => string | undefined; selectRequirement?: (context: PaymentVerificationContext) => Promise; settle: (context: PaymentSettlementContext) => Promise; verify: (context: PaymentVerificationContext) => Promise; @@ -510,6 +515,7 @@ interface ReceiptStore { countUndeliveredReceipts: () => Promise; findPaymentAttemptByReplayKey?: (replayKey: string) => Promise; getAuthorizationHoldState?: (reference: string) => Promise; + getIdempotencyKey?: (scope: IdempotencyScope, fingerprint: string) => Promise; getPaymentAttempt?: (id: string) => Promise; getPendingTask?: (id: string) => Promise; getReceipt: (id: string) => Promise; @@ -524,7 +530,7 @@ interface ReceiptStore { putPendingTask?: (task: PendingTask, options: { readonly maxTasks: number; }) => Promise; readonly descriptor: AdapterDescriptor; recordTransactionStep?: (step: TransactionStep) => Promise; - recoverIdempotencyKeys?: (options: { readonly release: boolean; }) => Promise; + recoverIdempotencyKeys?: (options: { readonly release: boolean; readonly endpointPrefix?: string; }) => Promise; reserveAuthorizationHold?: (reservation: AuthorizationHoldReservation) => Promise; reservePaymentAttempt: (reservation: PaymentAttemptReservation) => Promise; resolvePaymentAttempt?: (request: PaymentAttemptResolveRequest) => Promise; diff --git a/docs/contracts.md b/docs/contracts.md index ef7eac7..bc30b88 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -231,6 +231,25 @@ persistence must not fail the commerce flow. challenge. A lookup failure returns `STORAGE_ERROR` without a challenge; a store without the method keeps the 402 response. - The method is experimental. The 3.0.0 migration plan makes it required. +- The planned 2.7.0 API adds optional contracts for paid retries. Existing 2.6 + resources, stores and providers need no changes. SQLite implements the claim + lookup; the retry flow, recovery filter and summary mapping remain planned. + - `CommerceResource.idempotency` defines retry settings for a paid resource. + `merchantKey` requests a backend `Idempotency-Key` identifying the paid + operation. `redelivery` sets response retention in hours (`retentionHours`); + when `required` is true, an x402 payment missing an `id` must receive HTTP 400. + - `CanonicalRequest.retryKey` holds the client's key for retrying a paid + request. `PaymentProvider.retryKey` extracts it from a proof without I/O. + Treat the key as a credential: never log it or send it to a merchant or + facilitator. + - `ReceiptStore.getIdempotencyKey` reads a claim without writing. It returns + `undefined` if the claim is absent or is `completed` and expired. + - `recoverIdempotencyKeys` takes an optional `endpointPrefix` to restrict + recovery to endpoints with that prefix; omit it to include all endpoints. + - `redelivered` on `DeliveredOutcome` and `DeliverySummary` indicates that + the request reused a stored response without another charge. + - These fields and methods are experimental and may change in a minor + release. The 3.0.0 migration plan makes `getIdempotencyKey` required. ### Protocols and gateway diff --git a/docs/migration-3.0.md b/docs/migration-3.0.md index e005a8d..2b8fe2d 100644 --- a/docs/migration-3.0.md +++ b/docs/migration-3.0.md @@ -34,6 +34,8 @@ required in 3.0.0. Custom stores will need to implement: - Pending tasks: `putPendingTask`, `getPendingTask` and `takePendingTask`. - Payment attempts: `findPaymentAttemptByReplayKey`. +The 3.0.0 plan also requires `getIdempotencyKey` from the proposed 2.7.0 API. + The 3.0.0 plan removes these store settings deprecated in 2.6.0: - `authorization.ap2.replay.path` and `protocols.acp.idempotency.path`. diff --git a/src/core/domain/payment.ts b/src/core/domain/payment.ts index e7c6e42..e94097e 100644 --- a/src/core/domain/payment.ts +++ b/src/core/domain/payment.ts @@ -179,6 +179,16 @@ export interface PaymentProvider { */ createRequirement(context: PaymentContext): Promise; + /** + * Extract a client retry key from a proof without I/O. Return `undefined` + * if the proof has no key; throw `INPUT_INVALID` if the key is malformed. + * Experimental: may change in a minor release. + */ + retryKey?(context: { + readonly resource: CommerceResource; + readonly submission: PaymentSubmission; + }): string | undefined; + /** * Selects a proof's offer before verification, returning its destination, * network, asset and metadata while preserving `id` and the full `challenge`. diff --git a/src/core/domain/request.ts b/src/core/domain/request.ts index e61314a..8672ef6 100644 --- a/src/core/domain/request.ts +++ b/src/core/domain/request.ts @@ -32,6 +32,11 @@ export interface CanonicalRequest { * Passed on as `BackendRequest.idempotencyKey`. */ readonly idempotencyKey?: string; + /** + * Client's key for retrying this paid request. Treat it as a credential; + * never log it. Experimental: may change in a minor release. + */ + readonly retryKey?: string; /** Adapter headers passed to the backend; configured headers take precedence */ readonly backendHeaders?: Readonly>; /** Non-secret transport metadata, such as client id or user agent */ @@ -55,6 +60,11 @@ export interface DeliveredOutcome { * marks `receipt.id` as uncommitted. */ readonly persistenceFailures?: readonly PersistenceFailure[]; + /** + * True when this request reuses a stored response without another charge. + * Experimental: may change in a minor release. + */ + readonly redelivered?: boolean; } /** The resource costs money and the request carried no payment proof */ diff --git a/src/core/domain/resource.ts b/src/core/domain/resource.ts index 3884188..64e7197 100644 --- a/src/core/domain/resource.ts +++ b/src/core/domain/resource.ts @@ -84,6 +84,21 @@ export interface CommerceResource { readonly authorization?: { readonly required: readonly AuthorizationMethodName[]; }; + /** + * Settings for planned paid retries, disabled when omitted. + * Experimental: may change in a minor release. + */ + readonly idempotency?: { + /** Send the backend an `Idempotency-Key` identifying this paid operation */ + readonly merchantKey?: boolean; + /** Store delivered responses for retries with the same client key */ + readonly redelivery?: { + /** How long to retain a response, in hours */ + readonly retentionHours: number; + /** If true, reject an x402 payment missing an `id` with HTTP 400 */ + readonly required: boolean; + }; + }; } /** Read-only view of every configured resource */ diff --git a/src/core/domain/wire.ts b/src/core/domain/wire.ts index dd51a06..afc1584 100644 --- a/src/core/domain/wire.ts +++ b/src/core/domain/wire.ts @@ -171,6 +171,11 @@ export interface DeliverySummary { readonly externalReference?: string; readonly network?: string; }; + /** + * True when this request reuses a stored response without another charge. + * Experimental: may change in a minor release. + */ + readonly redelivered?: boolean; } /** Build the payer-facing summary of a delivered outcome */ diff --git a/src/core/execution/pipeline/context.ts b/src/core/execution/pipeline/context.ts index c84e922..da2919a 100644 --- a/src/core/execution/pipeline/context.ts +++ b/src/core/execution/pipeline/context.ts @@ -28,6 +28,11 @@ export interface CreateExecutionPipelineOptions { readonly logger: Logger; readonly clock: Clock; readonly ids: IdGenerator; + /** + * Gateway public base URL (`merchant.publicBaseUrl`) for planned payment retry + * and paid operation keys + */ + readonly deployment?: string; } export type HoldAction = 'consume' | 'release' | 'markUncertain'; diff --git a/src/core/interfaces/store.ts b/src/core/interfaces/store.ts index b301099..187f0c1 100644 --- a/src/core/interfaces/store.ts +++ b/src/core/interfaces/store.ts @@ -240,7 +240,20 @@ export interface ReceiptStore { * changed. Assumes one gateway per store. Experimental: may change in a * minor release. */ - recoverIdempotencyKeys?(options: { readonly release: boolean }): Promise; + recoverIdempotencyKeys?(options: { + readonly release: boolean; + /** Limit recovery to endpoints with this prefix; omit to include all endpoints */ + readonly endpointPrefix?: string; + }): Promise; + /** + * Read a claim without writing. Return `undefined` if the claim is absent + * or is `completed` and expired. Otherwise, return the same result as + * `claimIdempotencyKey`. Experimental: may change in a minor release. + */ + getIdempotencyKey?( + scope: IdempotencyScope, + fingerprint: string, + ): Promise; /** * Authorization holds for authorization providers. A store must implement diff --git a/src/core/store-method-groups.ts b/src/core/store-method-groups.ts index e11ece0..ccc1435 100644 --- a/src/core/store-method-groups.ts +++ b/src/core/store-method-groups.ts @@ -12,6 +12,8 @@ export const STORE_METHOD_GROUPS = { 'listOrphanAuthorizationHolds', ], pending_tasks: ['putPendingTask', 'getPendingTask', 'takePendingTask'], + // Separate from `idempotency_claims` so stores can support claims without redelivery + response_redelivery: ['getIdempotencyKey'], } as const satisfies Record; export type StoreMethodGroup = keyof typeof STORE_METHOD_GROUPS; diff --git a/src/storage/receipts/idempotency.ts b/src/storage/receipts/idempotency.ts index b67cf26..39cb9f0 100644 --- a/src/storage/receipts/idempotency.ts +++ b/src/storage/receipts/idempotency.ts @@ -21,9 +21,11 @@ interface ClaimRow { fingerprint: string; status: number | null; body_json: string | null; + expires_at: string; } -type IdempotencyMethods = StoreGroupMethods<'idempotency_claims'>; +type IdempotencyMethods = StoreGroupMethods<'idempotency_claims'> & + StoreGroupMethods<'response_redelivery'>; const scopeParams = (scope: IdempotencyScope) => ({ deployment: scope.deployment, @@ -31,11 +33,23 @@ const scopeParams = (scope: IdempotencyScope) => ({ idempotency_key: scope.key, }); +// A different fingerprint conflicts in every claim state +function claimOf(row: ClaimRow, fingerprint: string): IdempotencyClaim { + if (row.fingerprint !== fingerprint) return { kind: 'conflict' }; + if (row.state === 'unresolved') return { kind: 'unresolved' }; + if (row.state !== 'completed') return { kind: 'in-flight' }; + return { + kind: 'replay', + status: row.status ?? 500, + body: row.body_json === null ? null : JSON.parse(row.body_json), + }; +} + export function createIdempotencyMethods(db: Database.Database, clock: Clock): IdempotencyMethods { const SCOPE = 'deployment = @deployment AND endpoint = @endpoint AND idempotency_key = @idempotency_key'; const selectClaim = db.prepare, ClaimRow>( - `SELECT state, fingerprint, status, body_json FROM idempotency_keys WHERE ${SCOPE}`, + `SELECT state, fingerprint, status, body_json, expires_at FROM idempotency_keys WHERE ${SCOPE}`, ); const insertClaim = db.prepare( `INSERT INTO idempotency_keys @@ -73,16 +87,7 @@ export function createIdempotencyMethods(db: Database.Database, clock: Clock): I }); return { kind: 'reserved' }; } - // Fingerprint first: a key reused for another body conflicts whether or - // not the first request has finished - if (existing.fingerprint !== fingerprint) return { kind: 'conflict' }; - if (existing.state === 'unresolved') return { kind: 'unresolved' }; - if (existing.state !== 'completed') return { kind: 'in-flight' }; - return { - kind: 'replay', - status: existing.status ?? 500, - body: existing.body_json === null ? null : JSON.parse(existing.body_json), - }; + return claimOf(existing, fingerprint); }, ); @@ -95,6 +100,17 @@ export function createIdempotencyMethods(db: Database.Database, clock: Clock): I } }, + async getIdempotencyKey(scope, fingerprint) { + try { + const existing = selectClaim.get(scopeParams(scope)); + if (existing === undefined) return undefined; + const expired = existing.state === 'completed' && existing.expires_at <= clock.nowIso(); + return expired ? undefined : claimOf(existing, fingerprint); + } catch (err) { + throw toCommerceError(err, 'STORAGE_ERROR', 'Failed to read idempotency key'); + } + }, + async updateIdempotencyKey(scope, update) { try { switch (update.kind) { diff --git a/src/testing/conformance/record-cases.ts b/src/testing/conformance/record-cases.ts index 64a710f..72a93ac 100644 --- a/src/testing/conformance/record-cases.ts +++ b/src/testing/conformance/record-cases.ts @@ -11,6 +11,7 @@ import { STORE_METHOD_GROUPS, type StoreGroupMethods } from '../../core/store-me import { type CaseSpec, isoAt } from './support'; const CLAIMS = STORE_METHOD_GROUPS.idempotency_claims; +const CLAIM_READS = [...CLAIMS, ...STORE_METHOD_GROUPS.response_redelivery]; const HOLDS = STORE_METHOD_GROUPS.authorization_holds; const TASKS = STORE_METHOD_GROUPS.pending_tasks; @@ -19,9 +20,11 @@ const DAY = 24 * 60 * MINUTE; // Cases list these methods in `uses`; runners must skip cases missing them type Claims = StoreGroupMethods<'idempotency_claims'>; +type ClaimReads = Claims & StoreGroupMethods<'response_redelivery'>; type Holds = StoreGroupMethods<'authorization_holds'>; type Tasks = StoreGroupMethods<'pending_tasks'>; const claims = (store: ReceiptStore) => store as ReceiptStore & Claims; +const claimReads = (store: ReceiptStore) => store as ReceiptStore & ClaimReads; const holds = (store: ReceiptStore) => store as ReceiptStore & Holds; const tasks = (store: ReceiptStore) => store as ReceiptStore & Tasks; @@ -164,6 +167,57 @@ const CLAIM_CASES: readonly CaseSpec[] = [ }); }, }, + { + name: 'reading an idempotency scope answers as a claim would and creates nothing', + uses: CLAIM_READS, + async run({ store }) { + const s = claimReads(store); + assert.equal(await s.getIdempotencyKey(scope('k1'), 'fp'), undefined); + assert.deepEqual(await s.claimIdempotencyKey(scope('k1'), 'fp', isoAt(DAY)), { + kind: 'reserved', + }); + + assert.deepEqual(await s.getIdempotencyKey(scope('k1'), 'fp'), { kind: 'in-flight' }); + assert.deepEqual(await s.getIdempotencyKey(scope('k1'), 'other'), { kind: 'conflict' }); + + await s.updateIdempotencyKey(scope('k1'), { kind: 'unresolved' }); + assert.deepEqual(await s.getIdempotencyKey(scope('k1'), 'fp'), { kind: 'unresolved' }); + assert.deepEqual(await s.getIdempotencyKey(scope('k1'), 'other'), { kind: 'conflict' }); + + await s.claimIdempotencyKey(scope('k2'), 'fp', isoAt(DAY)); + await s.updateIdempotencyKey(scope('k2'), { + kind: 'completed', + status: 201, + body: { id: 'cs_1' }, + }); + assert.deepEqual(await s.getIdempotencyKey(scope('k2'), 'fp'), { + kind: 'replay', + status: 201, + body: { id: 'cs_1' }, + }); + }, + }, + { + name: 'an expired completed claim reads as absent, and other states never expire', + uses: CLAIM_READS, + async run({ store, clock }) { + const s = claimReads(store); + await s.claimIdempotencyKey(scope('done'), 'fp', isoAt(MINUTE)); + await s.updateIdempotencyKey(scope('done'), { kind: 'completed', status: 200, body: {} }); + await s.claimIdempotencyKey(scope('running'), 'fp', isoAt(MINUTE)); + await s.claimIdempotencyKey(scope('unknown'), 'fp', isoAt(MINUTE)); + await s.updateIdempotencyKey(scope('unknown'), { kind: 'unresolved' }); + + clock.advance(MINUTE - 1); + assert.equal((await s.getIdempotencyKey(scope('done'), 'fp'))?.kind, 'replay'); + + clock.advance(1); + assert.equal(await s.getIdempotencyKey(scope('done'), 'fp'), undefined); + assert.equal(await s.getIdempotencyKey(scope('done'), 'other'), undefined); + assert.deepEqual(await s.getIdempotencyKey(scope('running'), 'fp'), { kind: 'in-flight' }); + assert.deepEqual(await s.getIdempotencyKey(scope('unknown'), 'fp'), { kind: 'unresolved' }); + }, + }, { name: 'recovery marks in-flight claims unresolved and counts them', uses: CLAIMS, diff --git a/tests/unit/storage-receipts/conformance.test.ts b/tests/unit/storage-receipts/conformance.test.ts index 0e24a61..7b70356 100644 --- a/tests/unit/storage-receipts/conformance.test.ts +++ b/tests/unit/storage-receipts/conformance.test.ts @@ -97,4 +97,19 @@ describe('the conformance runner', () => { expect(replayCases).toHaveLength(2); for (const c of replayCases) await expect(c.run()).rejects.toBeInstanceOf(AssertionError); }); + + it('skips the claim reads for a store without getIdempotencyKey, whose claim group stays whole', async () => { + const cases = await receiptStoreConformanceCases(({ clock }) => ({ + store: Object.assign(createSqliteReceiptStore({ path: ':memory:', clock }), { + getIdempotencyKey: undefined, + }), + })); + + expect(cases.filter((c) => c.skip?.includes('getIdempotencyKey')).map((c) => c.name)).toEqual([ + 'reading an idempotency scope answers as a claim would and creates nothing', + 'an expired completed claim reads as absent, and other states never expire', + ]); + const groups = cases.find((c) => c.name.startsWith('implements each optional method group')); + await expect(groups?.run()).resolves.toBeUndefined(); + }); }); diff --git a/tests/unit/storage-receipts/record-inputs.test.ts b/tests/unit/storage-receipts/record-inputs.test.ts index 6ef9c49..7f1fbfc 100644 --- a/tests/unit/storage-receipts/record-inputs.test.ts +++ b/tests/unit/storage-receipts/record-inputs.test.ts @@ -1,8 +1,8 @@ -// SQLite-specific input checks on the 2.6.0 record methods, which the -// conformance suite leaves to each store +// SQLite-specific input checks left to each store by the conformance suite import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createSqliteReceiptStore, type SqliteReceiptStore } from '../../../src/storage/receipts'; import { @@ -43,6 +43,30 @@ describe('SQLite record method inputs', () => { expect(dumpTables(path).pending_tasks).toEqual([]); }); + it('writes nothing when reading a claim, even one past its expiry', async () => { + await store.claimIdempotencyKey(SCOPE, 'fp', isoAt(MINUTE)); + await store.updateIdempotencyKey(SCOPE, { kind: 'completed', status: 200, body: {} }); + clock.advance(MINUTE); + const before = dumpTables(path); + + expect(await store.getIdempotencyKey(SCOPE, 'fp')).toBeUndefined(); + + expect(before.idempotency_keys).toHaveLength(1); + expect(dumpTables(path)).toEqual(before); + }); + + it('answers STORAGE_ERROR when a stored claim body cannot be parsed', async () => { + await store.claimIdempotencyKey(SCOPE, 'fp', isoAt(MINUTE)); + await store.updateIdempotencyKey(SCOPE, { kind: 'completed', status: 200, body: {} }); + const db = new Database(path); + db.prepare("UPDATE idempotency_keys SET body_json = '{'").run(); + db.close(); + + await expect(store.getIdempotencyKey(SCOPE, 'fp')).rejects.toMatchObject({ + code: 'STORAGE_ERROR', + }); + }); + it('refuses an unparseable timestamp on each method that stores or compares one', async () => { await expect(store.claimIdempotencyKey(SCOPE, 'fp', 'not-a-date')).rejects.toMatchObject({ code: 'STORAGE_ERROR', From d2b1f3ea099fa2e4873b13b27f76e00f26b9128a Mon Sep 17 00:00:00 2001 From: Revinand Date: Fri, 6 Nov 2026 15:43:18 +0100 Subject: [PATCH 02/20] feat(storage): scope idempotency recovery by endpoint --- docs/contracts.md | 8 ++- src/core/domain/store-records.ts | 6 ++ src/core/interfaces/store.ts | 2 +- src/protocols/acp/adapter.ts | 4 +- src/storage/receipts/idempotency.ts | 16 +++-- src/storage/receipts/store.ts | 20 +++++-- src/testing/conformance/record-cases.ts | 60 +++++++++++++++++++ tests/unit/cli/doctor-storage.test.ts | 1 + tests/unit/cli/doctor.test.ts | 1 + .../idempotency-receipt-store.test.ts | 39 +++++++++++- .../unit/storage-receipts/conformance.test.ts | 2 + tests/unit/storage-receipts/summary.test.ts | 24 ++++++++ 12 files changed, 167 insertions(+), 16 deletions(-) diff --git a/docs/contracts.md b/docs/contracts.md index bc30b88..a89fb37 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -233,7 +233,8 @@ persistence must not fail the commerce flow. - The method is experimental. The 3.0.0 migration plan makes it required. - The planned 2.7.0 API adds optional contracts for paid retries. Existing 2.6 resources, stores and providers need no changes. SQLite implements the claim - lookup; the retry flow, recovery filter and summary mapping remain planned. + lookup and recovery filtering; paid retry execution and delivery summary + mapping remain planned. - `CommerceResource.idempotency` defines retry settings for a paid resource. `merchantKey` requests a backend `Idempotency-Key` identifying the paid operation. `redelivery` sets response retention in hours (`retentionHours`); @@ -244,8 +245,9 @@ persistence must not fail the commerce flow. facilitator. - `ReceiptStore.getIdempotencyKey` reads a claim without writing. It returns `undefined` if the claim is absent or is `completed` and expired. - - `recoverIdempotencyKeys` takes an optional `endpointPrefix` to restrict - recovery to endpoints with that prefix; omit it to include all endpoints. + - `recoverIdempotencyKeys` takes an optional `endpointPrefix`. Recovery + matches the prefix literally, including case; omit it to include all + endpoints. The ACP adapter passes `/` when recovering receipt-store claims. - `redelivered` on `DeliveredOutcome` and `DeliverySummary` indicates that the request reused a stored response without another charge. - These fields and methods are experimental and may change in a minor diff --git a/src/core/domain/store-records.ts b/src/core/domain/store-records.ts index f30a2c9..12e97d3 100644 --- a/src/core/domain/store-records.ts +++ b/src/core/domain/store-records.ts @@ -15,6 +15,12 @@ export interface IdempotencyScope { readonly key: string; } +/** + * Prefix reserved for paid retry claim endpoints, followed by the resource ID. + * ACP endpoints begin with `/`, keeping the claim namespaces distinct. + */ +export const PAYMENT_KEY_ENDPOINT_PREFIX = 'payment:'; + /** Result of claiming an idempotency key. Experimental: may change in a minor release */ export type IdempotencyClaim = /** The caller owns the key and must perform the operation */ diff --git a/src/core/interfaces/store.ts b/src/core/interfaces/store.ts index 187f0c1..8abbb5a 100644 --- a/src/core/interfaces/store.ts +++ b/src/core/interfaces/store.ts @@ -242,7 +242,7 @@ export interface ReceiptStore { */ recoverIdempotencyKeys?(options: { readonly release: boolean; - /** Limit recovery to endpoints with this prefix; omit to include all endpoints */ + /** Match endpoints by a literal, case-sensitive prefix; omit to include all endpoints */ readonly endpointPrefix?: string; }): Promise; /** diff --git a/src/protocols/acp/adapter.ts b/src/protocols/acp/adapter.ts index 6de5579..c922416 100644 --- a/src/protocols/acp/adapter.ts +++ b/src/protocols/acp/adapter.ts @@ -290,7 +290,9 @@ export class AcpProtocolAdapter implements HttpProtocolAdapter { const store = this.store; // Select the receipt store only when all idempotency methods are present if (hasMethodGroup(store, 'idempotency_claims')) { - const recovered = await store.recoverIdempotencyKeys({ release }); + // Recover ACP HTTP paths (`/`) separately from paid retry claims (`payment:`). + // Releasing a paid retry claim could allow duplicate settlement. + const recovered = await store.recoverIdempotencyKeys({ release, endpointPrefix: '/' }); warnOrphanedClaims(context.logger, recovered, release); return backendOverReceiptStore(store, context.clock, retentionHours); } diff --git a/src/storage/receipts/idempotency.ts b/src/storage/receipts/idempotency.ts index 39cb9f0..ad34beb 100644 --- a/src/storage/receipts/idempotency.ts +++ b/src/storage/receipts/idempotency.ts @@ -27,6 +27,12 @@ interface ClaimRow { type IdempotencyMethods = StoreGroupMethods<'idempotency_claims'> & StoreGroupMethods<'response_redelivery'>; +/** + * Match a literal, case-sensitive endpoint prefix, including `%` and `_`. + * An empty `@prefix` matches every endpoint. + */ +export const ENDPOINT_HAS_PREFIX = 'substr(endpoint, 1, length(@prefix)) = @prefix'; + const scopeParams = (scope: IdempotencyScope) => ({ deployment: scope.deployment, endpoint: scope.endpoint, @@ -67,9 +73,10 @@ export function createIdempotencyMethods(db: Database.Database, clock: Clock): I `UPDATE idempotency_keys SET state = 'unresolved' WHERE ${SCOPE}`, ); const deleteClaim = db.prepare(`DELETE FROM idempotency_keys WHERE ${SCOPE}`); - const releaseInFlight = db.prepare("DELETE FROM idempotency_keys WHERE state = 'in_flight'"); + const IN_FLIGHT = `state = 'in_flight' AND ${ENDPOINT_HAS_PREFIX}`; + const releaseInFlight = db.prepare(`DELETE FROM idempotency_keys WHERE ${IN_FLIGHT}`); const unresolveInFlight = db.prepare( - "UPDATE idempotency_keys SET state = 'unresolved' WHERE state = 'in_flight'", + `UPDATE idempotency_keys SET state = 'unresolved' WHERE ${IN_FLIGHT}`, ); // Sweep expired completed rows during the claim; no background worker is needed @@ -133,9 +140,10 @@ export function createIdempotencyMethods(db: Database.Database, clock: Clock): I } }, - async recoverIdempotencyKeys(options) { + async recoverIdempotencyKeys({ release, endpointPrefix = '' }) { try { - return (options.release ? releaseInFlight : unresolveInFlight).run().changes; + const recover = release ? releaseInFlight : unresolveInFlight; + return recover.run({ prefix: endpointPrefix }).changes; } catch (err) { throw toCommerceError(err, 'STORAGE_ERROR', 'Failed to recover idempotency keys'); } diff --git a/src/storage/receipts/store.ts b/src/storage/receipts/store.ts index 7dfd4b1..6fc72f0 100644 --- a/src/storage/receipts/store.ts +++ b/src/storage/receipts/store.ts @@ -32,10 +32,11 @@ import { toCommerceError, transactionState, } from '../../core'; +import { PAYMENT_KEY_ENDPOINT_PREFIX } from '../../core/domain/store-records'; import { PACKAGE_VERSION } from '../../version'; import { openSqliteDatabase } from '../sqlite'; import { createHoldMethods, ORPHAN_HOLD_WHERE } from './holds'; -import { createIdempotencyMethods } from './idempotency'; +import { createIdempotencyMethods, ENDPOINT_HAS_PREFIX } from './idempotency'; import { importLegacyStores, type LegacySource, @@ -123,8 +124,10 @@ export interface SqliteReceiptStoreSummary { * current or archived payment attempt */ readonly orphanHolds: number; - /** Claims recorded as `unresolved` */ + /** Unresolved claims whose endpoints do not start with `payment:` */ readonly unresolvedIdempotencyKeys: number; + /** Unresolved claims whose endpoints start with `payment:` */ + readonly unresolvedPaymentKeys: number; readonly unexpiredPendingTasks: number; readonly legacyImports: readonly SqliteLegacyImport[]; /** @@ -506,8 +509,13 @@ function storeOn(db: Database.Database, options: SqliteReceiptStoreOptions): Sql const countOrphanHoldsStmt = db.prepare<[string], { count: number }>( `SELECT COUNT(*) AS count FROM authorization_holds h WHERE ${ORPHAN_HOLD_WHERE}`, ); - const countUnresolvedKeysStmt = db.prepare<[], { count: number }>( - "SELECT COUNT(*) AS count FROM idempotency_keys WHERE state = 'unresolved'", + const countUnresolvedKeysStmt = db.prepare< + { prefix: string }, + { payment: number; other: number } + >( + `SELECT COUNT(*) FILTER (WHERE ${ENDPOINT_HAS_PREFIX}) AS payment, + COUNT(*) FILTER (WHERE NOT ${ENDPOINT_HAS_PREFIX}) AS other + FROM idempotency_keys WHERE state = 'unresolved'`, ); const countLiveTasksStmt = db.prepare<[string], { count: number }>( 'SELECT COUNT(*) AS count FROM pending_tasks WHERE expires_at > ?', @@ -813,10 +821,12 @@ function storeOn(db: Database.Database, options: SqliteReceiptStoreOptions): Sql unresolvedTransactions[state] = (unresolvedTransactions[state] ?? 0) + 1; } const orphansBefore = new Date(Date.parse(now) - orphanHoldMinAgeMs).toISOString(); + const unresolvedKeys = countUnresolvedKeysStmt.get({ prefix: PAYMENT_KEY_ENDPOINT_PREFIX }); return { unresolvedTransactions, orphanHolds: countOrphanHoldsStmt.get(orphansBefore)?.count ?? 0, - unresolvedIdempotencyKeys: countUnresolvedKeysStmt.get()?.count ?? 0, + unresolvedIdempotencyKeys: unresolvedKeys?.other ?? 0, + unresolvedPaymentKeys: unresolvedKeys?.payment ?? 0, unexpiredPendingTasks: countLiveTasksStmt.get(now)?.count ?? 0, legacyImports: legacyImportsStmt.all().map((row) => ({ source: row.source, diff --git a/src/testing/conformance/record-cases.ts b/src/testing/conformance/record-cases.ts index 72a93ac..e62fa95 100644 --- a/src/testing/conformance/record-cases.ts +++ b/src/testing/conformance/record-cases.ts @@ -254,6 +254,66 @@ const CLAIM_CASES: readonly CaseSpec[] = [ }); }, }, + { + name: 'recovery without an endpoint prefix reaches every endpoint', + uses: CLAIMS, + async run({ store }) { + const s = claims(store); + const paid = scope('b', 'payment:report'); + await s.claimIdempotencyKey(scope('a'), 'fp', isoAt(DAY)); + await s.claimIdempotencyKey(paid, 'fp', isoAt(DAY)); + + assert.equal(await s.recoverIdempotencyKeys({ release: false }), 2); + assert.deepEqual(await s.claimIdempotencyKey(scope('a'), 'fp', isoAt(DAY)), { + kind: 'unresolved', + }); + assert.deepEqual(await s.claimIdempotencyKey(paid, 'fp', isoAt(DAY)), { + kind: 'unresolved', + }); + }, + }, + { + name: 'recovery with an endpoint prefix leaves other endpoints alone', + uses: CLAIM_READS, + async run({ store }) { + const s = claimReads(store); + const checkout = scope('a'); + const paid = scope('b', 'payment:report'); + const running = scope('c', 'payment:report'); + await s.claimIdempotencyKey(checkout, 'fp', isoAt(DAY)); + await s.claimIdempotencyKey(paid, 'fp', isoAt(DAY)); + + const marked = { release: false, endpointPrefix: 'payment:' }; + assert.equal(await s.recoverIdempotencyKeys(marked), 1); + assert.deepEqual(await s.getIdempotencyKey(paid, 'fp'), { kind: 'unresolved' }); + assert.deepEqual(await s.getIdempotencyKey(checkout, 'fp'), { kind: 'in-flight' }); + + await s.claimIdempotencyKey(running, 'fp', isoAt(DAY)); + assert.equal(await s.recoverIdempotencyKeys({ release: true, endpointPrefix: '/' }), 1); + assert.equal(await s.getIdempotencyKey(checkout, 'fp'), undefined); + assert.deepEqual(await s.getIdempotencyKey(running, 'fp'), { kind: 'in-flight' }); + assert.deepEqual(await s.getIdempotencyKey(paid, 'fp'), { kind: 'unresolved' }); + }, + }, + { + name: 'an endpoint prefix is literal text', + uses: CLAIM_READS, + async run({ store }) { + const s = claimReads(store); + const paid = scope('a', 'payment:report_1'); + await s.claimIdempotencyKey(paid, 'fp', isoAt(DAY)); + + const misses = ['%', 'payment%', 'pay_ent:', 'PAYMENT:', 'report', 'payment:report_12']; + for (const endpointPrefix of misses) { + const changed = await s.recoverIdempotencyKeys({ release: true, endpointPrefix }); + assert.equal(changed, 0, endpointPrefix); + } + assert.deepEqual(await s.getIdempotencyKey(paid, 'fp'), { kind: 'in-flight' }); + + const whole = { release: true, endpointPrefix: 'payment:report_1' }; + assert.equal(await s.recoverIdempotencyKeys(whole), 1); + }, + }, { name: 'concurrent claims of one idempotency scope reserve it once', uses: CLAIMS, diff --git a/tests/unit/cli/doctor-storage.test.ts b/tests/unit/cli/doctor-storage.test.ts index 9a44c02..d29d3b9 100644 --- a/tests/unit/cli/doctor-storage.test.ts +++ b/tests/unit/cli/doctor-storage.test.ts @@ -36,6 +36,7 @@ const EMPTY_SUMMARY: SqliteReceiptStoreSummary = { unresolvedTransactions: {}, orphanHolds: 0, unresolvedIdempotencyKeys: 0, + unresolvedPaymentKeys: 0, unexpiredPendingTasks: 0, legacyImports: [], missingLegacyImports: [], diff --git a/tests/unit/cli/doctor.test.ts b/tests/unit/cli/doctor.test.ts index 0ab9287..e525b52 100644 --- a/tests/unit/cli/doctor.test.ts +++ b/tests/unit/cli/doctor.test.ts @@ -1968,6 +1968,7 @@ describe('runDoctor: AP2', () => { unresolvedTransactions: {}, orphanHolds: 0, unresolvedIdempotencyKeys: 0, + unresolvedPaymentKeys: 0, unexpiredPendingTasks: 0, missingLegacyImports: [], legacyImports: [ diff --git a/tests/unit/protocols-acp/idempotency-receipt-store.test.ts b/tests/unit/protocols-acp/idempotency-receipt-store.test.ts index d6a59d9..9f6f64c 100644 --- a/tests/unit/protocols-acp/idempotency-receipt-store.test.ts +++ b/tests/unit/protocols-acp/idempotency-receipt-store.test.ts @@ -422,9 +422,10 @@ describe('ACP idempotency recovery on the receipt store', () => { const expiresAt = new Date(START + 24 * HOUR).toISOString(); // A claim an earlier process left in flight, as it sits on disk after a crash - async function leaveOrphan(): Promise { + async function leaveOrphan(endpoint = ENDPOINT): Promise { const earlier = openStore(); - await earlier.claimIdempotencyKey(scope, requestFingerprint(CREATE), expiresAt); + const orphan = { ...scope, endpoint }; + await earlier.claimIdempotencyKey(orphan, requestFingerprint(CREATE), expiresAt); await earlier.close(); stores = []; } @@ -475,6 +476,40 @@ describe('ACP idempotency recovery on the receipt store', () => { await adapter.stop(); }); + it('frees only ACP claims, so a restart cannot reopen a paid retry key', async () => { + await leaveOrphan(); + const store = openStore(); + const payment = { ...scope, endpoint: 'payment:market_report', key: 'client-key-digest' }; + await store.claimIdempotencyKey(payment, 'input-digest', expiresAt); + const { context } = setup(deliveredFor('createCheckoutSession')); + const adapter = createAcpAdapter( + adapterOptions({ store, idempotency: { retentionHours: 24, merchantIdempotent: true } }), + ); + + await adapter.start(context); + await adapter.stop(); + + expect(await store.getIdempotencyKey(scope, requestFingerprint(CREATE))).toBeUndefined(); + // Releasing this claim would allow the retry key to be reserved again + expect(await store.claimIdempotencyKey(payment, 'input-digest', expiresAt)).toEqual({ + kind: 'in-flight', + }); + }); + + it('recovers a claim left under an earlier mount path', async () => { + await leaveOrphan('/old-mount/checkout_sessions'); + const store = openStore(); + const { context } = setup(deliveredFor('createCheckoutSession')); + const adapter = createAcpAdapter( + adapterOptions({ store, idempotency: { retentionHours: 24 } }), + ); + + await adapter.start(context); + await adapter.stop(); + + expect(rowStates()).toEqual(['unresolved']); + }); + it('stays silent when nothing was left in flight', async () => { const store = openStore(); const warn = vi.fn(); diff --git a/tests/unit/storage-receipts/conformance.test.ts b/tests/unit/storage-receipts/conformance.test.ts index 7b70356..1f09db8 100644 --- a/tests/unit/storage-receipts/conformance.test.ts +++ b/tests/unit/storage-receipts/conformance.test.ts @@ -108,6 +108,8 @@ describe('the conformance runner', () => { expect(cases.filter((c) => c.skip?.includes('getIdempotencyKey')).map((c) => c.name)).toEqual([ 'reading an idempotency scope answers as a claim would and creates nothing', 'an expired completed claim reads as absent, and other states never expire', + 'recovery with an endpoint prefix leaves other endpoints alone', + 'an endpoint prefix is literal text', ]); const groups = cases.find((c) => c.name.startsWith('implements each optional method group')); await expect(groups?.run()).resolves.toBeUndefined(); diff --git a/tests/unit/storage-receipts/summary.test.ts b/tests/unit/storage-receipts/summary.test.ts index f69bb6f..bd6e215 100644 --- a/tests/unit/storage-receipts/summary.test.ts +++ b/tests/unit/storage-receipts/summary.test.ts @@ -48,6 +48,7 @@ describe('SQLite receipt store summary', () => { unresolvedTransactions: {}, orphanHolds: 0, unresolvedIdempotencyKeys: 0, + unresolvedPaymentKeys: 0, unexpiredPendingTasks: 0, legacyImports: [], missingLegacyImports: [], @@ -134,6 +135,29 @@ describe('SQLite receipt store summary', () => { }); }); + it('counts unresolved payment keys apart from other claims', async () => { + const scope = (endpoint: string, key: string) => ({ + deployment: 'https://gw.example', + endpoint, + key, + }); + const leaveUnresolved = async (endpoint: string, key: string) => { + await store.claimIdempotencyKey(scope(endpoint, key), 'fp', isoAt(60 * MINUTE)); + await store.updateIdempotencyKey(scope(endpoint, key), { kind: 'unresolved' }); + }; + await leaveUnresolved('/acp/checkout_sessions', 'a'); + await leaveUnresolved('/payment:mount/checkout_sessions', 'b'); + await leaveUnresolved('payment:report', 'c'); + await leaveUnresolved('payment:report', 'd'); + await leaveUnresolved('payment:forecast', 'e'); + await store.claimIdempotencyKey(scope('payment:report', 'running'), 'fp', isoAt(60 * MINUTE)); + + expect(await store.summary(AGE)).toMatchObject({ + unresolvedIdempotencyKeys: 2, + unresolvedPaymentKeys: 3, + }); + }); + it('lists the recorded legacy imports, not a start with nothing to import', async () => { const db = new Database(path); const insert = db.prepare( From 3ded733bab059ed776db62851ba5e445f601e460 Mon Sep 17 00:00:00 2001 From: Revinand Date: Fri, 6 Nov 2026 17:06:57 +0100 Subject: [PATCH 03/20] feat(core): send an idempotency key for paid operations --- docs/configuration.md | 18 +- docs/contracts.md | 17 +- src/config/schema/raw.ts | 1 + src/config/schema/resources.ts | 20 +- src/core/domain/resource.ts | 2 +- src/core/execution/merchant-key.ts | 36 ++++ src/core/execution/pipeline/admission.ts | 30 +++ src/core/execution/pipeline/context.ts | 6 +- src/core/execution/pipeline/index.ts | 3 +- src/core/interfaces/backend.ts | 9 +- src/gateway/server.ts | 1 + .../integration/merchant-idempotency.test.ts | 189 ++++++++++++++++++ tests/unit/config/schema.test.ts | 55 +++++ .../unit/core/execution/merchant-key.test.ts | 70 +++++++ .../execution/pipeline-merchant-key.test.ts | 159 +++++++++++++++ 15 files changed, 599 insertions(+), 17 deletions(-) create mode 100644 src/core/execution/merchant-key.ts create mode 100644 tests/integration/merchant-idempotency.test.ts create mode 100644 tests/unit/core/execution/merchant-key.test.ts create mode 100644 tests/unit/core/execution/pipeline-merchant-key.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index 913426e..895dee6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,6 +83,21 @@ 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 is a SHA-256 hex digest of `merchant.publicBaseUrl`, the +resource ID, the payment's replay identity and a canonical input digest. +Retrying a rejected x402 authorization with the same input keeps the same key. +Changing `merchant.publicBaseUrl` changes the generated keys. + +An adapter's operation key takes precedence over the generated key. Either +overrides an `Idempotency-Key` in `backend.headers`. 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. + `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. @@ -773,7 +788,8 @@ 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 +with `x402` in `payments` · an `idempotency` block on a resource that is not +fixed-price · an MPP `challengeSecret` with length below 32 or a multi-line `realm`. It exits non-zero on any of them. diff --git a/docs/contracts.md b/docs/contracts.md index a89fb37..fb6a474 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -232,13 +232,16 @@ persistence must not fail the commerce flow. a store without the method keeps the 402 response. - The method is experimental. The 3.0.0 migration plan makes it required. - The planned 2.7.0 API adds optional contracts for paid retries. Existing 2.6 - resources, stores and providers need no changes. SQLite implements the claim - lookup and recovery filtering; paid retry execution and delivery summary - mapping remain planned. - - `CommerceResource.idempotency` defines retry settings for a paid resource. - `merchantKey` requests a backend `Idempotency-Key` identifying the paid - operation. `redelivery` sets response retention in hours (`retentionHours`); - when `required` is true, an x402 payment missing an `id` must receive HTTP 400. + resources, stores and providers need no changes. The pipeline supports + merchant keys; SQLite implements claim lookup and recovery filtering. + Client retry handling and delivery summary mapping remain planned. + - `CommerceResource.idempotency` configures paid retries. With `merchantKey` + enabled, the pipeline uses a derived backend `Idempotency-Key` for the paid + operation; an adapter's key takes precedence. The `deployment` option is + required; if omitted, payment admission throws `CONFIG_INVALID` before any + provider call. + Planned `redelivery` settings retain responses for `retentionHours` hours; + when `required` is true, an x402 payment without an `id` must receive HTTP 400. - `CanonicalRequest.retryKey` holds the client's key for retrying a paid request. `PaymentProvider.retryKey` extracts it from a proof without I/O. Treat the key as a credential: never log it or send it to a merchant or diff --git a/src/config/schema/raw.ts b/src/config/schema/raw.ts index 9062961..021d2ca 100644 --- a/src/config/schema/raw.ts +++ b/src/config/schema/raw.ts @@ -267,6 +267,7 @@ const ResourceEntrySchema = z .object({ required: z.array(z.string().min(1)).min(1) }) .strict() .optional(), + idempotency: z.object({ merchantKey: BooleanOrString }).strict().optional(), }) .strict(); diff --git a/src/config/schema/resources.ts b/src/config/schema/resources.ts index cb0a7b8..116ec20 100644 --- a/src/config/schema/resources.ts +++ b/src/config/schema/resources.ts @@ -25,7 +25,7 @@ import { validateResourceSchemaKeywords, } from './resource-schema'; import type { NormalizedProtocols } from './types'; -import { toNumber } from './values'; +import { toBoolean, toNumber } from './values'; import type { NormalizedX402 } from './x402'; const SUPPORTED_PROTOCOLS: ReadonlySet = new Set(PROTOCOL_NAMES); @@ -235,6 +235,14 @@ export function normalizeResource( ); } + if (entry.idempotency !== undefined && entry.pricing.type !== 'fixed') { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" sets idempotency, which requires fixed pricing`, + { details: { path: `resources.${id}.idempotency`, resourceId: id } }, + ); + } + const pricing: Pricing = entry.pricing.type === 'free' ? { type: 'free' } @@ -271,6 +279,16 @@ export function normalizeResource( exposedVia: entry.expose as CommerceResource['exposedVia'], paymentMethods: paymentMethods as CommerceResource['paymentMethods'], ...(authorization !== undefined ? { authorization } : {}), + ...(entry.idempotency !== undefined + ? { + idempotency: { + merchantKey: toBoolean( + entry.idempotency.merchantKey, + `resources.${id}.idempotency.merchantKey`, + ), + }, + } + : {}), }; } diff --git a/src/core/domain/resource.ts b/src/core/domain/resource.ts index 64e7197..42b40c3 100644 --- a/src/core/domain/resource.ts +++ b/src/core/domain/resource.ts @@ -85,7 +85,7 @@ export interface CommerceResource { readonly required: readonly AuthorizationMethodName[]; }; /** - * Settings for planned paid retries, disabled when omitted. + * Settings for paid retries, disabled when omitted. `redelivery` is planned. * Experimental: may change in a minor release. */ readonly idempotency?: { diff --git a/src/core/execution/merchant-key.ts b/src/core/execution/merchant-key.ts new file mode 100644 index 0000000..ab91b6d --- /dev/null +++ b/src/core/execution/merchant-key.ts @@ -0,0 +1,36 @@ +// The `Idempotency-Key` that names a paid operation to the merchant +import { createHash } from 'node:crypto'; +import { inputDigest } from './journal'; + +export interface PaidOperation { + /** `merchant.publicBaseUrl`, which outlives credential rotation */ + readonly deployment: string; + readonly resourceId: string; + readonly replayKey: string; + readonly input: unknown; + /** Client retry credential; only its hash enters the payment identity */ + readonly clientKey?: string; + readonly payer?: string; +} + +function sha256Hex(text: string): string { + return createHash('sha256').update(text).digest('hex'); +} + +/** + * Derive a merchant operation key, or return `undefined` when `inputDigest` does. + * + * Use the hash of `clientKey` with `payer` when both are nonempty; otherwise + * use `replayKey`. The client key keeps the same identity across proof changes; + * the payer distinguishes buyers sharing that key. The input digest separates + * bodies when a rejected proof is retried. Serialize fields as a JSON array so + * delimiters in URLs or resource IDs stay within their fields. Format changes + * alter derived keys and can break merchant deduplication. + */ +export function paidOperationKey(operation: PaidOperation): string | undefined { + const digest = inputDigest(operation.input); + if (digest === undefined) return undefined; + const { deployment, resourceId, replayKey, clientKey, payer } = operation; + const identity = clientKey && payer ? `retry:${sha256Hex(clientKey)}:${payer}` : replayKey; + return sha256Hex(JSON.stringify(['paid-operation', deployment, resourceId, identity, digest])); +} diff --git a/src/core/execution/pipeline/admission.ts b/src/core/execution/pipeline/admission.ts index 3f82c43..4f42967 100644 --- a/src/core/execution/pipeline/admission.ts +++ b/src/core/execution/pipeline/admission.ts @@ -7,6 +7,7 @@ import type { CanonicalRequest, PaymentRequiredOutcome } from '../../domain/requ import type { CommerceResource, Pricing } from '../../domain/resource'; import { CommerceError, isCommerceError, toCommerceError } from '../../errors'; import { type Flow, newTransaction } from '../journal'; +import { paidOperationKey } from '../merchant-key'; import { REUSE_REJECTED_AUTHORIZATION_METADATA_KEY } from '../settlement-order'; import { type AuthorizeAndReserve, resolveAuthorizationProviders } from './authorization'; import { isFinishedAttempt, replayError, retryChallenge } from './challenge'; @@ -38,6 +39,7 @@ export function createPaymentAdmission( const { options, safeEmit, buildEvent } = ctx; return async function admitPayment(flow, request, resource, pricing, validInput) { + const deployment = merchantKeyDeployment(options.deployment, request, resource); const provider = pickProvider(options.paymentProviders, resource.paymentMethods); if (!provider) { throw new CommerceError( @@ -254,6 +256,16 @@ export function createPaymentAdmission( }), ); + const merchantKey = + deployment !== undefined + ? paidOperationKey({ + deployment, + resourceId: resource.id, + replayKey, + input: validInput, + }) + : undefined; + return { kind: 'admitted', settleArgs: { @@ -266,6 +278,7 @@ export function createPaymentAdmission( verification, replayKey, hold, + ...(merchantKey !== undefined ? { merchantKey } : {}), }, }; }; @@ -312,6 +325,23 @@ export function createPaymentAdmission( } } +// Check deployment before provider calls so missing configuration is caught before upfront settlement +function merchantKeyDeployment( + deployment: string | undefined, + request: CanonicalRequest, + resource: CommerceResource, +): string | undefined { + if (resource.idempotency?.merchantKey !== true) return undefined; + if (deployment === undefined) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${resource.id}" sets idempotency.merchantKey, which requires the pipeline's deployment option`, + { requestId: request.requestId, resourceId: resource.id }, + ); + } + return deployment; +} + function pickProvider( providers: readonly PaymentProvider[], methods: readonly PaymentMethodName[], diff --git a/src/core/execution/pipeline/context.ts b/src/core/execution/pipeline/context.ts index da2919a..fcb1f98 100644 --- a/src/core/execution/pipeline/context.ts +++ b/src/core/execution/pipeline/context.ts @@ -29,8 +29,8 @@ export interface CreateExecutionPipelineOptions { readonly clock: Clock; readonly ids: IdGenerator; /** - * Gateway public base URL (`merchant.publicBaseUrl`) for planned payment retry - * and paid operation keys + * Gateway public base URL (`merchant.publicBaseUrl`). Required when + * `idempotency.merchantKey` is true; also intended to scope planned payment retries. */ readonly deployment?: string; } @@ -72,6 +72,8 @@ export interface SettleArgs { readonly verification: PaymentResult; readonly replayKey: string; readonly hold: AuthorizationHold | undefined; + /** Derived backend key for a resource with `idempotency.merchantKey` enabled */ + readonly merchantKey?: string; } export interface PipelineContext { diff --git a/src/core/execution/pipeline/index.ts b/src/core/execution/pipeline/index.ts index 1c07e56..e913158 100644 --- a/src/core/execution/pipeline/index.ts +++ b/src/core/execution/pipeline/index.ts @@ -182,13 +182,14 @@ export function createExecutionPipeline( deadlineAt: backendDeadline(options.clock.now(), resource), }); const backendStart = options.clock.monotonicMs(); + const idempotencyKey = request.idempotencyKey ?? settleArgs?.merchantKey; let backendResponse: BackendResponse; try { backendResponse = await options.backend.call(resource.handler, { requestId: request.requestId, resourceId: resource.id, input: validInput, - ...(request.idempotencyKey !== undefined ? { idempotencyKey: request.idempotencyKey } : {}), + ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), ...(request.backendHeaders !== undefined ? { backendHeaders: request.backendHeaders } : {}), }); } catch (error) { diff --git a/src/core/interfaces/backend.ts b/src/core/interfaces/backend.ts index 177c25c..5cd3e91 100644 --- a/src/core/interfaces/backend.ts +++ b/src/core/interfaces/backend.ts @@ -15,10 +15,11 @@ export interface BackendRequest { * `requestId`, which is fresh per call and would make every retry look like * new work. * - * An adapter sets it only when the value survives a client retry, a - * reconnect and a gateway restart. The HTTP executor sends it as the - * `Idempotency-Key` header, replacing one set in `BackendHandler.headers`; - * when absent, the executor adds none. + * An adapter may supply this key, or the pipeline may derive it when + * `idempotency.merchantKey` is true. The value must survive client retries, + * reconnects and gateway restarts. An adapter's key takes precedence. + * The HTTP executor sends it as the `Idempotency-Key` header, replacing one + * set in `BackendHandler.headers`; when absent, the executor adds none. */ readonly idempotencyKey?: string; /** diff --git a/src/gateway/server.ts b/src/gateway/server.ts index add0a87..8cb1d92 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -116,6 +116,7 @@ export async function createGateway(options: GatewayOptions): Promise"] +const KEY = '88f4c564adf4855753414603c78d094f2aa220eb58a6af76363cc0669f60be4e'; + +const PAYMENT_ROUTE = '/api/resources/report/invoke'; + +interface MerchantRequest { + readonly method: string | undefined; + readonly url: string | undefined; + readonly headers: IncomingHttpHeaders; + readonly body: string; +} + +let merchant: Server; +let merchantUrl: string; +let merchantStatus = 200; +let received: MerchantRequest[] = []; +let gateway: GatewayInstance | undefined; + +beforeAll(async () => { + merchant = createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += String(chunk); + }); + req.on('end', () => { + received.push({ method: req.method, url: req.url, headers: req.headers, body }); + res.writeHead(merchantStatus, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); + }); + }); + await new Promise((resolve) => merchant.listen(0, '127.0.0.1', resolve)); + merchantUrl = `http://127.0.0.1:${(merchant.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => merchant.close(() => resolve())); +}); + +afterEach(async () => { + await gateway?.close(); + gateway = undefined; + received = []; + merchantStatus = 200; +}); + +function rawConfig(idempotency?: unknown): Record { + return { + version: 1, + merchant: { id: 'shop', name: 'Shop', publicBaseUrl: 'https://gw.example' }, + server: { port: 0, host: '127.0.0.1' }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } }, + resources: { + report: { + name: 'Report', + input: { + type: 'object', + properties: { city: { type: 'string' } }, + additionalProperties: false, + }, + backend: { type: 'http', method: 'POST', url: `${merchantUrl}/api/report` }, + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + expose: ['http'], + payments: ['x402'], + ...(idempotency !== undefined ? { idempotency } : {}), + }, + }, + payments: { + x402: { + enabled: true, + network: 'eip155:31337', + rpcUrl: 'http://127.0.0.1:8545', + asset: '0x1111111111111111111111111111111111111111', + assetName: 'MockUSDC', + assetVersion: '2', + assetDecimals: 6, + payTo: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + maxTimeoutSeconds: 120, + facilitator: { mode: 'local', signerPrivateKey: '0xTHIS_IS_A_TEST_ONLY_SECRET_KEY' }, + }, + }, + }; +} + +async function startGateway(idempotency?: unknown): Promise { + gateway = await createGateway({ + config: parseConfig(rawConfig(idempotency), {}), + store: createSqliteReceiptStore({ path: ':memory:' }), + paymentProviders: [ + createFakePaymentProvider({ + // Model x402 authorization flow, which allows reuse of a rejected payment's authorization + requirementMetadata: { settleAfterBackend: true, reuseRejectedAuthorization: true }, + verify: async (ctx) => ({ + status: 'verified', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + payer: '0xBUYER', + replayKey: `replay-${String(ctx.submission.payload)}`, + }), + }), + ], + protocolAdapters: [], + }); + return gateway; +} + +function buy(instance: GatewayInstance, payload: Record = {}) { + return instance.server.inject({ + method: 'POST', + url: PAYMENT_ROUTE, + headers: { [PAYMENT_HEADER]: 'valid-proof' }, + payload, + }); +} + +describe('merchant idempotency over the gateway', () => { + it('sends the merchant the derived key, unquoted', async () => { + const paid = await buy(await startGateway({ merchantKey: true })); + + expect(paid.statusCode).toBe(200); + expect(received.map((request) => request.headers['idempotency-key'])).toEqual([KEY]); + }); + + it('sends the same key when a proof is retried after a backend failure', async () => { + const instance = await startGateway({ merchantKey: true }); + merchantStatus = 500; + const failed = await buy(instance); + merchantStatus = 200; + const retried = await buy(instance); + + expect(failed.statusCode).not.toBe(200); + expect(retried.statusCode).toBe(200); + expect(received.map((request) => request.headers['idempotency-key'])).toEqual([KEY, KEY]); + }); + + it('sends another key when a proof is retried with another body', async () => { + const instance = await startGateway({ merchantKey: true }); + merchantStatus = 500; + await buy(instance, { city: 'Oslo' }); + merchantStatus = 200; + const retried = await buy(instance, { city: 'Bergen' }); + + expect(retried.statusCode).toBe(200); + const [first, second] = received.map((request) => request.headers['idempotency-key']); + expect(first).toMatch(/^[0-9a-f]{64}$/); + expect(second).toMatch(/^[0-9a-f]{64}$/); + expect(second).not.toBe(first); + }); + + it('sends the merchant no part of the proof', async () => { + await buy(await startGateway({ merchantKey: true })); + + expect(received).toHaveLength(1); + expect(JSON.stringify(received)).not.toContain('valid-proof'); + }); + + it('changes nothing else the merchant receives', async () => { + await buy(await startGateway({ merchantKey: true })); + await gateway?.close(); + const [optedIn] = received; + received = []; + + await buy(await startGateway()); + const [plain] = received; + + expect(plain?.headers).not.toHaveProperty('idempotency-key'); + const { 'idempotency-key': _key, ...others } = optedIn?.headers ?? {}; + expect(plain).toEqual({ ...optedIn, headers: others }); + }); +}); diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index 7150c19..a00b592 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { parseConfig } from '../../../src/config/schema'; import { type CommerceError, + type CommerceResource, isCommerceError, PAYMENT_METHOD_NAMES, RESERVED_INPUT_FIELDS, @@ -2516,3 +2517,57 @@ describe('resource mimeType', () => { ); }); }); + +describe('resource idempotency', () => { + function withIdempotency(id: string, idempotency: unknown): Record { + const raw = validRawConfig(); + const resources = raw['resources'] as Record>; + resources[id] = { ...resources[id], idempotency }; + return raw; + } + + function resource(raw: Record, id: string): CommerceResource | undefined { + return parseConfig(raw, {}).resources.find((candidate) => candidate.id === id); + } + + it.each([ + [true, true], + [false, false], + ['true', true], + ['FALSE', false], + ])('reads merchantKey %s on a fixed-price resource', (merchantKey, expected) => { + const raw = withIdempotency('market_report', { merchantKey }); + + expect(resource(raw, 'market_report')?.idempotency).toEqual({ merchantKey: expected }); + }); + + it('leaves a resource without the block unchanged', () => { + expect(resource(validRawConfig(), 'market_report')).not.toHaveProperty('idempotency'); + }); + + it('refuses it on a free resource', () => { + expectConfigInvalid( + () => parseConfig(withIdempotency('weather_basic', { merchantKey: true }), {}), + 'resources.weather_basic.idempotency', + 'requires fixed pricing', + ); + }); + + it('refuses a merchantKey that is not a boolean', () => { + expectConfigInvalid( + () => parseConfig(withIdempotency('market_report', { merchantKey: 'maybe' }), {}), + 'resources.market_report.idempotency.merchantKey', + ); + }); + + it.each([ + ['an empty block', {}, 'resources.market_report.idempotency.merchantKey'], + [ + 'redelivery, which has no effect', + { merchantKey: true, redelivery: {} }, + 'resources.market_report.idempotency', + ], + ])('refuses %s', (_name, idempotency, path) => { + expectConfigInvalid(() => parseConfig(withIdempotency('market_report', idempotency), {}), path); + }); +}); diff --git a/tests/unit/core/execution/merchant-key.test.ts b/tests/unit/core/execution/merchant-key.test.ts new file mode 100644 index 0000000..c230b80 --- /dev/null +++ b/tests/unit/core/execution/merchant-key.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { paidOperationKey } from '../../../../src/core/execution/merchant-key'; + +// Independent sha256sum fixtures detect format changes that could break merchant deduplication. +// Preimages use the digest of {"city":"Oslo"}: +// ["paid-operation","https://gw.example","report","","sha256:"] +const KEY_FOR_REPLAY_KEY = '3e51c13adef2740ac0e40798081fbfad6a01d86d64416f0b23bf344719313f75'; +const KEY_FOR_CLIENT_KEY = '09eb5f9d837926ff77f3dcc000b09453d07ae3fa6325f2626ed43746acddcb7a'; + +const operation = { + deployment: 'https://gw.example', + resourceId: 'report', + replayKey: '0xabc', + input: { city: 'Oslo' }, +}; +const retried = { ...operation, clientKey: 'client-key-1', payer: '0xPAYER' }; + +describe('paidOperationKey', () => { + it('derives the key from the replay key without a client key', () => { + expect(paidOperationKey(operation)).toBe(KEY_FOR_REPLAY_KEY); + }); + + it('derives the key from the client key and payer when both are known', () => { + expect(paidOperationKey(retried)).toBe(KEY_FOR_CLIENT_KEY); + }); + + it('gives two proofs from one payer one key', () => { + expect(paidOperationKey({ ...retried, replayKey: '0xdef' })).toBe(KEY_FOR_CLIENT_KEY); + }); + + it.each([ + ['payer', { payer: '0xOTHER' }], + ['client key', { clientKey: 'client-key-2' }], + ['input', { input: { city: 'Bergen' } }], + ['resource', { resourceId: 'other' }], + ['deployment', { deployment: 'https://other.example' }], + ])('gives another %s a different key', (_name, change) => { + const key = paidOperationKey({ ...retried, ...change }); + + expect(key).toMatch(/^[0-9a-f]{64}$/); + expect(key).not.toBe(KEY_FOR_CLIENT_KEY); + }); + + it('keeps a delimiter in one field from reaching the next', () => { + const first = { ...operation, deployment: 'http://a', resourceId: '8080:report' }; + const second = { ...operation, deployment: 'http://a:8080', resourceId: 'report' }; + + expect(paidOperationKey(first)).not.toBe(paidOperationKey(second)); + }); + + it('gives another replay key a different key without a client key', () => { + const key = paidOperationKey({ ...operation, replayKey: '0xdef' }); + + expect(key).toMatch(/^[0-9a-f]{64}$/); + expect(key).not.toBe(KEY_FOR_REPLAY_KEY); + }); + + it.each([ + ['absent', {}], + ['empty', { payer: '' }], + ])('falls back to the replay key when the payer is %s', (_name, payer) => { + const key = paidOperationKey({ ...operation, clientKey: 'client-key-1', ...payer }); + + expect(key).toBe(KEY_FOR_REPLAY_KEY); + }); + + it('gives no key for input that is not I-JSON', () => { + expect(paidOperationKey({ ...operation, input: { text: '\uD800' } })).toBeUndefined(); + }); +}); diff --git a/tests/unit/core/execution/pipeline-merchant-key.test.ts b/tests/unit/core/execution/pipeline-merchant-key.test.ts new file mode 100644 index 0000000..3224bd5 --- /dev/null +++ b/tests/unit/core/execution/pipeline-merchant-key.test.ts @@ -0,0 +1,159 @@ +/** + * Check when `idempotency.merchantKey` adds a key to backend requests. + * Key derivation is tested in merchant-key.test.ts. + */ +import { describe, expect, it } from 'vitest'; +import type { CanonicalRequest } from '../../../../src/core/domain/request'; +import type { CommerceResource } from '../../../../src/core/domain/resource'; +import { isCommerceError } from '../../../../src/core/errors'; +import { createExecutionPipeline } from '../../../../src/core/execution/pipeline'; +import { createResourceRegistry } from '../../../../src/core/execution/registry'; +import type { BackendRequest } from '../../../../src/core/interfaces/backend'; +import { + createCapturingLogger, + createFakeBackendExecutor, + createFakeClock, + createFakeIdGenerator, + createFakePaymentProvider, + createFakeStore, + makeResource, +} from './helpers'; + +// sha256sum of ["paid-operation","https://gw.example","res-1","replay-key-1","sha256:"] +const KEY = '00109a2fb4bde23d050022a9e07ad3e8279087352c638f2f1649d847482bdf05'; + +const PAID = { + id: 'res-1', + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + paymentMethods: ['x402'], +} as const; +const OPTED_IN = { ...PAID, idempotency: { merchantKey: true } }; + +const UNPAID_REQUEST: CanonicalRequest = { + requestId: 'req-1', + resourceId: 'res-1', + input: {}, + protocol: 'http', + receivedAt: '2026-01-01T00:00:00.000Z', +}; + +function paidRequest(overrides: Partial = {}): CanonicalRequest { + return { ...UNPAID_REQUEST, payment: { method: 'x402', payload: 'proof' }, ...overrides }; +} + +function setup( + resource: Partial, + options: { readonly deployment?: string; readonly settleAfterBackend?: boolean } = { + deployment: 'https://gw.example', + }, +) { + const providerCalls: string[] = []; + const sent: BackendRequest[] = []; + const store = createFakeStore(); + const provider = createFakePaymentProvider({ + ...(options.settleAfterBackend ? { requirementMetadata: { settleAfterBackend: true } } : {}), + }); + const pipeline = createExecutionPipeline({ + resources: createResourceRegistry([makeResource(resource)]), + paymentProviders: [ + { + ...provider, + createRequirement: (ctx) => { + providerCalls.push('createRequirement'); + return provider.createRequirement(ctx); + }, + verify: (ctx) => { + providerCalls.push('verify'); + return provider.verify(ctx); + }, + }, + ], + store, + backend: createFakeBackendExecutor(async (_handler, request) => { + sent.push(request); + return { status: 200, headers: {}, body: { ok: true }, durationMs: 1 }; + }), + events: store, + logger: createCapturingLogger(), + clock: createFakeClock(), + ids: createFakeIdGenerator(), + ...(options.deployment !== undefined ? { deployment: options.deployment } : {}), + }); + return { pipeline, sent, store, providerCalls }; +} + +describe('merchant idempotency key', () => { + it.each([ + ['upfront', false], + ['authorization', true], + ])('sends the derived key under the %s flow', async (_flow, settleAfterBackend) => { + const { pipeline, sent } = setup(OPTED_IN, { + deployment: 'https://gw.example', + settleAfterBackend, + }); + + const outcome = await pipeline.execute(paidRequest()); + + expect(outcome.kind).toBe('delivered'); + expect(sent.map((request) => request.idempotencyKey)).toEqual([KEY]); + }); + + it("keeps the protocol's own key, which ACP sends", async () => { + const { pipeline, sent } = setup(OPTED_IN); + + await pipeline.execute(paidRequest({ idempotencyKey: 'acp-key' })); + + expect(sent.map((request) => request.idempotencyKey)).toEqual(['acp-key']); + }); + + it.each([ + ['omitted', PAID], + ['false', { ...PAID, idempotency: { merchantKey: false } }], + ])('sends no key when merchantKey is %s', async (_name, resource) => { + const { pipeline, sent } = setup(resource); + + const outcome = await pipeline.execute(paidRequest()); + + expect(outcome.kind).toBe('delivered'); + expect(sent).toHaveLength(1); + expect(sent[0]).not.toHaveProperty('idempotencyKey'); + }); + + it('sends no key on a free resource and still delivers', async () => { + const { pipeline, sent } = setup({ id: 'res-1', idempotency: { merchantKey: true } }); + + const outcome = await pipeline.execute(UNPAID_REQUEST); + + expect(outcome.kind).toBe('delivered'); + expect(sent).toHaveLength(1); + expect(sent[0]).not.toHaveProperty('idempotencyKey'); + }); + + it('sends no key for input that is not I-JSON and still delivers', async () => { + const { pipeline, sent, store } = setup(OPTED_IN); + + const outcome = await pipeline.execute(paidRequest({ input: { text: '\uD800' } })); + + expect(outcome.kind).toBe('delivered'); + expect(sent).toHaveLength(1); + expect(sent[0]).not.toHaveProperty('idempotencyKey'); + expect(store.receipts[0]?.payment?.status).toBe('settled'); + }); + + it.each([ + ['with a proof', paidRequest()], + ['without a proof', UNPAID_REQUEST], + ])( + 'refuses a request %s before any provider call when deployment is missing', + async (_name, request) => { + const { pipeline, sent, store, providerCalls } = setup(OPTED_IN, {}); + + const error = await pipeline.execute(request).catch((err: unknown) => err); + + expect(isCommerceError(error) && error.code).toBe('CONFIG_INVALID'); + expect(providerCalls).toEqual([]); + expect(sent).toEqual([]); + expect(store.attempts.size).toBe(0); + }, + ); +}); From 5e4b2348501feefb3efadd71dba7ad026fff4891 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sat, 7 Nov 2026 13:14:51 +0100 Subject: [PATCH 04/20] feat(core): redeliver a stored response on a client retry --- docs/configuration.md | 36 +- docs/contract-surface.txt | 1 + docs/contracts.md | 26 +- src/config/schema/raw.ts | 14 +- src/config/schema/resources.ts | 63 +- src/core/domain/resource.ts | 7 +- src/core/errors/codes.ts | 2 +- src/core/errors/commerce-error.ts | 4 +- src/core/execution/journal.ts | 7 +- src/core/execution/merchant-key.ts | 2 +- src/core/execution/pipeline/admission.ts | 238 +-- src/core/execution/pipeline/context.ts | 7 +- src/core/execution/pipeline/index.ts | 37 +- src/core/execution/pipeline/retry.ts | 390 +++++ src/core/execution/pipeline/settlement.ts | 3 + tests/unit/config/schema.test.ts | 112 +- tests/unit/core/errors/codes.test.ts | 26 +- .../core/execution/pipeline-retry.test.ts | 1302 +++++++++++++++++ 18 files changed, 2112 insertions(+), 165 deletions(-) create mode 100644 src/core/execution/pipeline/retry.ts create mode 100644 tests/unit/core/execution/pipeline-retry.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index 895dee6..2888f83 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,16 +87,30 @@ 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 is a SHA-256 hex digest of `merchant.publicBaseUrl`, the -resource ID, the payment's replay identity and a canonical input digest. -Retrying a rejected x402 authorization with the same input keeps the same key. -Changing `merchant.publicBaseUrl` changes the generated keys. +The generated key hashes `merchant.publicBaseUrl`, the resource ID, a payment +identity and the canonical input digest with SHA-256. When both the client +retry key and verified payer are nonempty, the identity uses the key's hash +and payer; otherwise it uses the payment's replay identity. The same identity +and input keep the same key across retries. Changing `merchant.publicBaseUrl` +changes generated keys. An adapter's operation key takes precedence over the generated key. Either -overrides an `Idempotency-Key` in `backend.headers`. 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. +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. Supply keys through +`CanonicalRequest.retryKey` or a custom `PaymentProvider.retryKey` hook; +built-in rail extraction is planned. With redelivery enabled, input without a +canonical JSON digest fails with `INPUT_INVALID` when a retry key is used. + +`retentionHours` is a positive integer (default 24). The retention window +starts at the initial retry check, before proof verification. `required` +defaults to false; setting it to true requires `x402` in `payments`. Rejecting +an x402 payment that lacks an `id` remains planned. `url` supports `{param}` templating from validated input; values are URL-encoded. Remaining input becomes query string for `GET`/`DELETE` and a JSON @@ -789,8 +803,10 @@ 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 `idempotency` block on a resource that is not -fixed-price · an MPP `challengeSecret` with length below 32 or a -multi-line `realm`. +fixed-price, or with neither `merchantKey` nor `redelivery` · a +`redelivery.retentionHours` that is not a positive integer · +`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. diff --git a/docs/contract-surface.txt b/docs/contract-surface.txt index 6827e35..f7fa196 100644 --- a/docs/contract-surface.txt +++ b/docs/contract-surface.txt @@ -165,6 +165,7 @@ interface CommerceErrorOptions { readonly httpStatus?: number; readonly requestId?: string; readonly resourceId?: string; + readonly retryable?: boolean; } interface CommerceEvent { diff --git a/docs/contracts.md b/docs/contracts.md index fb6a474..bf17051 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -233,15 +233,19 @@ persistence must not fail the commerce flow. - The method is experimental. The 3.0.0 migration plan makes it required. - The planned 2.7.0 API adds optional contracts for paid retries. Existing 2.6 resources, stores and providers need no changes. The pipeline supports - merchant keys; SQLite implements claim lookup and recovery filtering. - Client retry handling and delivery summary mapping remain planned. + merchant keys and client retry keys; SQLite implements claim lookup and + recovery filtering. Built-in rail key extraction and delivery summary mapping + remain planned; direct pipeline requests and custom provider hooks can supply keys. - `CommerceResource.idempotency` configures paid retries. With `merchantKey` enabled, the pipeline uses a derived backend `Idempotency-Key` for the paid - operation; an adapter's key takes precedence. The `deployment` option is - required; if omitted, payment admission throws `CONFIG_INVALID` before any - provider call. - Planned `redelivery` settings retain responses for `retentionHours` hours; - when `required` is true, an x402 payment without an `id` must receive HTTP 400. + operation; an adapter's key takes precedence. + - `redelivery` sets a retention deadline `retentionHours` hours after the + initial retry check, before proof verification. It requires a store with + the `idempotency_claims` and `response_redelivery` groups. Both `merchantKey: true` + and `redelivery` require the `deployment` option. Missing dependencies make + payment admission throw `CONFIG_INVALID` before any provider call. + The planned `required` check must return HTTP 400 for an x402 payment + without an `id`. - `CanonicalRequest.retryKey` holds the client's key for retrying a paid request. `PaymentProvider.retryKey` extracts it from a proof without I/O. Treat the key as a credential: never log it or send it to a merchant or @@ -253,6 +257,14 @@ persistence must not fail the commerce flow. endpoints. The ACP adapter passes `/` when recovering receipt-store claims. - `redelivered` on `DeliveredOutcome` and `DeliverySummary` indicates that the request reused a stored response without another charge. + - `persistenceFailures` gains `response` when a delivered response cannot be + stored for its client key. The pipeline attempts to mark the claim + `unresolved`; in-flight and unresolved claims refuse retries with 409. + - `CommerceErrorOptions.retryable` overrides the code's default from + `RETRYABLE_ERROR_CODES` for one error instance, in either direction. An + in-flight client-key 409 sets it to true so callers can wait and resend + without a new payment. Other refusals from the retry gate are not retryable. + Omitting the override keeps the code's default. - These fields and methods are experimental and may change in a minor release. The 3.0.0 migration plan makes `getIdempotencyKey` required. diff --git a/src/config/schema/raw.ts b/src/config/schema/raw.ts index 021d2ca..a22eb17 100644 --- a/src/config/schema/raw.ts +++ b/src/config/schema/raw.ts @@ -267,7 +267,19 @@ const ResourceEntrySchema = z .object({ required: z.array(z.string().min(1)).min(1) }) .strict() .optional(), - idempotency: z.object({ merchantKey: BooleanOrString }).strict().optional(), + idempotency: z + .object({ + merchantKey: BooleanOrString.optional(), + redelivery: z + .object({ + retentionHours: NumberOrString.optional(), + required: BooleanOrString.optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), }) .strict(); diff --git a/src/config/schema/resources.ts b/src/config/schema/resources.ts index 116ec20..c330a14 100644 --- a/src/config/schema/resources.ts +++ b/src/config/schema/resources.ts @@ -31,6 +31,7 @@ import type { NormalizedX402 } from './x402'; const SUPPORTED_PROTOCOLS: ReadonlySet = new Set(PROTOCOL_NAMES); const SUPPORTED_PAYMENT_METHODS: ReadonlySet = new Set(PAYMENT_METHOD_NAMES); +const DEFAULT_REDELIVERY_RETENTION_HOURS = 24; export function normalizeResource( id: string, @@ -235,13 +236,7 @@ export function normalizeResource( ); } - if (entry.idempotency !== undefined && entry.pricing.type !== 'fixed') { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" sets idempotency, which requires fixed pricing`, - { details: { path: `resources.${id}.idempotency`, resourceId: id } }, - ); - } + const idempotency = normalizeResourceIdempotency(id, entry, paymentMethods); const pricing: Pricing = entry.pricing.type === 'free' @@ -279,16 +274,52 @@ export function normalizeResource( exposedVia: entry.expose as CommerceResource['exposedVia'], paymentMethods: paymentMethods as CommerceResource['paymentMethods'], ...(authorization !== undefined ? { authorization } : {}), - ...(entry.idempotency !== undefined - ? { - idempotency: { - merchantKey: toBoolean( - entry.idempotency.merchantKey, - `resources.${id}.idempotency.merchantKey`, - ), - }, - } + ...(idempotency !== undefined ? { idempotency } : {}), + }; +} + +function normalizeResourceIdempotency( + id: string, + entry: RawResourceEntry, + paymentMethods: readonly string[], +): CommerceResource['idempotency'] { + const raw = entry.idempotency; + if (raw === undefined) return undefined; + const path = `resources.${id}.idempotency`; + const refuse = (problem: string, at: string) => + new CommerceError('CONFIG_INVALID', `Resource "${id}" sets ${problem}`, { + details: { path: at, resourceId: id }, + }); + if (entry.pricing.type !== 'fixed') { + throw refuse('idempotency, which requires fixed pricing', path); + } + if (raw.merchantKey === undefined && raw.redelivery === undefined) { + throw refuse('idempotency without merchantKey or redelivery', path); + } + + let redelivery: { readonly retentionHours: number; readonly required: boolean } | undefined; + if (raw.redelivery !== undefined) { + const required = toBoolean(raw.redelivery.required ?? false, `${path}.redelivery.required`); + if (required && !paymentMethods.includes('x402')) { + throw refuse( + 'idempotency.redelivery.required, which requires "x402" in payments', + `${path}.redelivery.required`, + ); + } + redelivery = { + retentionHours: toNumber( + raw.redelivery.retentionHours ?? DEFAULT_REDELIVERY_RETENTION_HOURS, + `${path}.redelivery.retentionHours`, + { min: 1 }, + ), + required, + }; + } + return { + ...(raw.merchantKey !== undefined + ? { merchantKey: toBoolean(raw.merchantKey, `${path}.merchantKey`) } : {}), + ...(redelivery !== undefined ? { redelivery } : {}), }; } diff --git a/src/core/domain/resource.ts b/src/core/domain/resource.ts index 42b40c3..7139c05 100644 --- a/src/core/domain/resource.ts +++ b/src/core/domain/resource.ts @@ -85,7 +85,8 @@ export interface CommerceResource { readonly required: readonly AuthorizationMethodName[]; }; /** - * Settings for paid retries, disabled when omitted. `redelivery` is planned. + * Paid retry settings, disabled when omitted. Built-in rail key extraction + * is planned. * Experimental: may change in a minor release. */ readonly idempotency?: { @@ -93,9 +94,9 @@ export interface CommerceResource { readonly merchantKey?: boolean; /** Store delivered responses for retries with the same client key */ readonly redelivery?: { - /** How long to retain a response, in hours */ + /** Hours from the initial retry check to the stored response's expiry */ readonly retentionHours: number; - /** If true, reject an x402 payment missing an `id` with HTTP 400 */ + /** Planned: if true, reject an x402 payment missing an `id` with HTTP 400 */ readonly required: boolean; }; }; diff --git a/src/core/errors/codes.ts b/src/core/errors/codes.ts index 5920c0b..bc8ccbf 100644 --- a/src/core/errors/codes.ts +++ b/src/core/errors/codes.ts @@ -54,7 +54,7 @@ export const COMMERCE_ERROR_HTTP_STATUS: Readonly = new Set([ 'PAYMENT_PROVIDER_UNAVAILABLE', // The verifier or replay store was unreachable, so the mandate got no diff --git a/src/core/errors/commerce-error.ts b/src/core/errors/commerce-error.ts index 3e42986..98a544f 100644 --- a/src/core/errors/commerce-error.ts +++ b/src/core/errors/commerce-error.ts @@ -9,6 +9,8 @@ export interface CommerceErrorOptions { readonly resourceId?: string; /** Override the default HTTP status for this code */ readonly httpStatus?: number; + /** Override the code's default retryability for this error instance */ + readonly retryable?: boolean; } /** Serializable wire form of a CommerceError */ @@ -35,7 +37,7 @@ export class CommerceError extends Error { this.name = 'CommerceError'; this.code = code; this.httpStatus = options.httpStatus ?? COMMERCE_ERROR_HTTP_STATUS[code]; - this.retryable = RETRYABLE_ERROR_CODES.has(code); + this.retryable = options.retryable ?? RETRYABLE_ERROR_CODES.has(code); if (options.details !== undefined) this.details = options.details; if (options.requestId !== undefined) this.requestId = options.requestId; if (options.resourceId !== undefined) this.resourceId = options.resourceId; diff --git a/src/core/execution/journal.ts b/src/core/execution/journal.ts index ac3169c..ae65708 100644 --- a/src/core/execution/journal.ts +++ b/src/core/execution/journal.ts @@ -18,10 +18,15 @@ export interface Flow { readonly persistenceFailures: PersistenceFailure[]; /** True when the reserved attempt matches this ID and the store provides recordTransactionStep */ journaled: boolean; + /** + * Set before calling provider.settle. Cleared on confirmed non-submission + * or a non-settled result; unknown outcomes keep it true. + */ + chargeRisk: boolean; } export function createFlow(request: CanonicalRequest, transactionId: string): Flow { - return { request, transactionId, persistenceFailures: [], journaled: false }; + return { request, transactionId, persistenceFailures: [], journaled: false, chargeRisk: false }; } /** Snapshot failure labels for events and outcomes, omitting the field when empty */ diff --git a/src/core/execution/merchant-key.ts b/src/core/execution/merchant-key.ts index ab91b6d..a6054aa 100644 --- a/src/core/execution/merchant-key.ts +++ b/src/core/execution/merchant-key.ts @@ -13,7 +13,7 @@ export interface PaidOperation { readonly payer?: string; } -function sha256Hex(text: string): string { +export function sha256Hex(text: string): string { return createHash('sha256').update(text).digest('hex'); } diff --git a/src/core/execution/pipeline/admission.ts b/src/core/execution/pipeline/admission.ts index 4f42967..16b1d31 100644 --- a/src/core/execution/pipeline/admission.ts +++ b/src/core/execution/pipeline/admission.ts @@ -3,7 +3,11 @@ import type { PaymentMethodName } from '../../domain/common'; import type { PaymentProvider, PaymentRequirement, PaymentResult } from '../../domain/payment'; import type { PaymentAttempt } from '../../domain/receipt'; -import type { CanonicalRequest, PaymentRequiredOutcome } from '../../domain/request'; +import type { + CanonicalRequest, + DeliveredOutcome, + PaymentRequiredOutcome, +} from '../../domain/request'; import type { CommerceResource, Pricing } from '../../domain/resource'; import { CommerceError, isCommerceError, toCommerceError } from '../../errors'; import { type Flow, newTransaction } from '../journal'; @@ -12,6 +16,7 @@ import { REUSE_REJECTED_AUTHORIZATION_METADATA_KEY } from '../settlement-order'; import { type AuthorizeAndReserve, resolveAuthorizationProviders } from './authorization'; import { isFinishedAttempt, replayError, retryChallenge } from './challenge'; import type { PipelineContext, SettleArgs } from './context'; +import { assertRetrySupport, type RetryGate } from './retry'; /** A verified payment whose authorization and replay identity are reserved */ export interface AdmittedPayment { @@ -25,21 +30,28 @@ export type AdmitPayment = ( resource: CommerceResource, pricing: Extract, validInput: unknown, -) => Promise; + pipelineStart: number, +) => Promise; /** - * Builds the payment phase. It returns the challenge when the request carries - * no proof, and otherwise verifies the proof and reserves the authorization - * and replay identity, or throws. + * Build payment admission. Return a challenge, a stored retry response or + * verified claims for delivery; failures throw. */ export function createPaymentAdmission( ctx: PipelineContext, authorizeAndReserve: AuthorizeAndReserve, + retryGate: RetryGate, ): AdmitPayment { const { options, safeEmit, buildEvent } = ctx; - return async function admitPayment(flow, request, resource, pricing, validInput) { - const deployment = merchantKeyDeployment(options.deployment, request, resource); + return async function admitPayment(flow, request, resource, pricing, validInput, pipelineStart) { + // Check retry dependencies before provider calls and before upfront settlement can charge + const support = assertRetrySupport( + resource, + options.store, + options.deployment, + request.requestId, + ); const provider = pickProvider(options.paymentProviders, resource.paymentMethods); if (!provider) { throw new CommerceError( @@ -61,6 +73,16 @@ export function createPaymentAdmission( request.requestId, ); + const retry = await retryGate.open( + flow, + resource, + provider, + validInput, + support, + pipelineStart, + ); + if (retry?.kind === 'delivered') return retry; + let requirement: PaymentRequirement; try { requirement = await provider.createRequirement({ @@ -187,100 +209,121 @@ export function createPaymentAdmission( } const replayKey = verification.replayKey; - const hold = await authorizeAndReserve( - flow, - request, - resource, - authProviders, - requirement, - validInput, - ); + // Claim before authorization holds and replay reservations so a losing request has + // no new reservation to release + if (retry !== undefined) { + const stored = await retryGate.claim( + flow, + resource, + provider, + retry, + verification, + pipelineStart, + ); + if (stored !== undefined) return stored; + } try { - const attempt = await options.store.reservePaymentAttempt({ - requestId: request.requestId, - resourceId: resource.id, - provider: provider.name, - replayKey, - amount: verification.amount, - currency: verification.currency, - ...(verification.payer !== undefined ? { payer: verification.payer } : {}), - ...(verification.payee !== undefined ? { payee: verification.payee } : {}), - ...(requirement.metadata?.[REUSE_REJECTED_AUTHORIZATION_METADATA_KEY] === true - ? { reclaimRejected: true } - : {}), - transaction: newTransaction( - flow, - resource, - requirement, - verification, - validInput, - hold !== undefined ? { ...hold.record, reservationId: hold.reservationId } : undefined, - ), - }); - // Require a matching transaction ID and recordTransactionStep before enabling - // journaling, so legacy stores can ignore the transaction data - flow.journaled = - attempt.transactionId === flow.transactionId && - options.store.recordTransactionStep !== undefined; - } catch (error) { - // A duplicate replayKey arrives as PAYMENT_REPLAYED and is kept. Any - // other failure (a full disk, a dropped connection) is STORAGE_ERROR, - // never a replay. - const mapped = toCommerceError( - error, - 'STORAGE_ERROR', - 'Payment attempt could not be recorded', + const hold = await authorizeAndReserve( + flow, + request, + resource, + authProviders, + requirement, + validInput, ); - await hold?.finalize('release'); + + try { + const attempt = await options.store.reservePaymentAttempt({ + requestId: request.requestId, + resourceId: resource.id, + provider: provider.name, + replayKey, + amount: verification.amount, + currency: verification.currency, + ...(verification.payer !== undefined ? { payer: verification.payer } : {}), + ...(verification.payee !== undefined ? { payee: verification.payee } : {}), + ...(requirement.metadata?.[REUSE_REJECTED_AUTHORIZATION_METADATA_KEY] === true + ? { reclaimRejected: true } + : {}), + transaction: newTransaction( + flow, + resource, + requirement, + verification, + validInput, + hold !== undefined ? { ...hold.record, reservationId: hold.reservationId } : undefined, + ), + }); + // Require a matching transaction ID and recordTransactionStep before enabling + // journaling, so legacy stores can ignore the transaction data + flow.journaled = + attempt.transactionId === flow.transactionId && + options.store.recordTransactionStep !== undefined; + } catch (error) { + // Preserve CommerceError codes, including PAYMENT_REPLAYED; map untyped + // storage failures to STORAGE_ERROR + const mapped = toCommerceError( + error, + 'STORAGE_ERROR', + 'Payment attempt could not be recorded', + ); + await hold?.finalize('release'); + await safeEmit( + buildEvent(flow, { + type: 'payment.rejected', + resourceId: resource.id, + adapter: request.protocol, + paymentProvider: provider.name, + status: 'error', + data: { reason: mapped.code }, + }), + ); + throw replayError(mapped, requirement); + } + await safeEmit( buildEvent(flow, { - type: 'payment.rejected', + type: 'payment.verified', resourceId: resource.id, adapter: request.protocol, paymentProvider: provider.name, - status: 'error', - data: { reason: mapped.code }, + status: 'ok', }), ); - throw replayError(mapped, requirement); - } - await safeEmit( - buildEvent(flow, { - type: 'payment.verified', - resourceId: resource.id, - adapter: request.protocol, - paymentProvider: provider.name, - status: 'ok', - }), - ); - - const merchantKey = - deployment !== undefined - ? paidOperationKey({ - deployment, - resourceId: resource.id, - replayKey, - input: validInput, - }) - : undefined; + const merchantKey = + support !== undefined && resource.idempotency?.merchantKey === true + ? paidOperationKey({ + deployment: support.deployment, + resourceId: resource.id, + replayKey, + input: validInput, + ...(retry !== undefined ? { clientKey: retry.clientKey } : {}), + ...(verification.payer !== undefined ? { payer: verification.payer } : {}), + }) + : undefined; - return { - kind: 'admitted', - settleArgs: { - flow, - request, - resource, - provider, - requirement, - payment, - verification, - replayKey, - hold, - ...(merchantKey !== undefined ? { merchantKey } : {}), - }, - }; + return { + kind: 'admitted', + settleArgs: { + flow, + request, + resource, + provider, + requirement, + payment, + verification, + replayKey, + hold, + ...(merchantKey !== undefined ? { merchantKey } : {}), + ...(retry !== undefined ? { retry } : {}), + }, + }; + } catch (error) { + if (retry !== undefined) await retryGate.finishFailed(flow, retry); + throw error; + } }; // A spent authorization can fail verification before reaching the replay @@ -325,23 +368,6 @@ export function createPaymentAdmission( } } -// Check deployment before provider calls so missing configuration is caught before upfront settlement -function merchantKeyDeployment( - deployment: string | undefined, - request: CanonicalRequest, - resource: CommerceResource, -): string | undefined { - if (resource.idempotency?.merchantKey !== true) return undefined; - if (deployment === undefined) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${resource.id}" sets idempotency.merchantKey, which requires the pipeline's deployment option`, - { requestId: request.requestId, resourceId: resource.id }, - ); - } - return deployment; -} - function pickProvider( providers: readonly PaymentProvider[], methods: readonly PaymentMethodName[], diff --git a/src/core/execution/pipeline/context.ts b/src/core/execution/pipeline/context.ts index fcb1f98..15e76be 100644 --- a/src/core/execution/pipeline/context.ts +++ b/src/core/execution/pipeline/context.ts @@ -16,6 +16,7 @@ import type { Logger } from '../../interfaces/logger'; import type { Clock, IdGenerator } from '../../interfaces/runtime'; import type { ReceiptStore, TransactionStep } from '../../interfaces/store'; import type { Flow } from '../journal'; +import type { RetryTicket } from './retry'; export interface CreateExecutionPipelineOptions { readonly resources: ResourceRegistry; @@ -29,8 +30,8 @@ export interface CreateExecutionPipelineOptions { readonly clock: Clock; readonly ids: IdGenerator; /** - * Gateway public base URL (`merchant.publicBaseUrl`). Required when - * `idempotency.merchantKey` is true; also intended to scope planned payment retries. + * Gateway public base URL (`merchant.publicBaseUrl`) used in merchant keys + * and retry scopes. Required when `merchantKey` is true or `redelivery` is configured. */ readonly deployment?: string; } @@ -74,6 +75,8 @@ export interface SettleArgs { readonly hold: AuthorizationHold | undefined; /** Derived backend key for a resource with `idempotency.merchantKey` enabled */ readonly merchantKey?: string; + /** Retry claim to finalize after delivery or failure */ + readonly retry?: RetryTicket; } export interface PipelineContext { diff --git a/src/core/execution/pipeline/index.ts b/src/core/execution/pipeline/index.ts index e913158..ca54098 100644 --- a/src/core/execution/pipeline/index.ts +++ b/src/core/execution/pipeline/index.ts @@ -7,13 +7,15 @@ * (INPUT_INVALID, or BACKEND_ERROR for an illegal configured header) * 3. resolve price * 4. free: straight to the backend - * 5. paid: pick provider and createRequirement, then return a challenge, or - * select the offer the proof pays, verify it, and reserve authorization and - * replay identities + * 5. paid: validate retry support, pick provider and resolve authorization + * providers, then check retry state for an exact proof resend. Otherwise + * createRequirement and return a challenge for a missing proof, or select + * the offer and verify the proof. Claim the retry key, which may also return + * a stored response, before reserving authorization and replay identities * 6. settle before calling the backend, or call the backend first and settle * only if it succeeds. Finalize authorization according to the outcome * (BACKEND_TIMEOUT / BACKEND_ERROR) - * 7. persist receipt, emit events, return outcome + * 7. persist receipt, finish the retry claim, emit events, return outcome */ import type { AuthorizationRecord } from '../../domain/authorization'; @@ -36,6 +38,7 @@ import { createPipelineContext, type SettleArgs, } from './context'; +import { createRetryGate } from './retry'; import { createSettlement } from './settlement'; export type { CreateExecutionPipelineOptions } from './context'; @@ -56,7 +59,8 @@ export function createExecutionPipeline( const ctx = createPipelineContext(options); const { safeEmit, safePersist, recordStep, buildEvent } = ctx; const { settle, abandonSettlement } = createSettlement(ctx); - const admitPayment = createPaymentAdmission(ctx, createAuthorizeAndReserve(ctx)); + const retryGate = createRetryGate(ctx); + const admitPayment = createPaymentAdmission(ctx, createAuthorizeAndReserve(ctx), retryGate); async function execute(request: CanonicalRequest): Promise { const pipelineStart = options.clock.monotonicMs(); @@ -142,13 +146,23 @@ export function createExecutionPipeline( let settleArgs: SettleArgs | undefined; if (resource.pricing.type === 'fixed') { - const admission = await admitPayment(flow, request, resource, resource.pricing, validInput); - if (admission.kind === 'payment-required') return admission; + const admission = await admitPayment( + flow, + request, + resource, + resource.pricing, + validInput, + pipelineStart, + ); + if (admission.kind !== 'admitted') return admission; settleArgs = admission.settleArgs; } try { return await settleAndDeliver(flow, resource, validInput, settleArgs, pipelineStart); + } catch (error) { + if (settleArgs?.retry !== undefined) await retryGate.finishFailed(flow, settleArgs.retry); + throw error; } finally { // Attempt the final journal step after settleAndDeliver returns or throws. // A failure is logged after the outcome and event snapshots are built, so @@ -304,6 +318,15 @@ export function createExecutionPipeline( }; await saveReceipt(flow, receipt, 'saveReceipt'); + if (settleArgs?.retry !== undefined) { + await retryGate.finishDelivered( + flow, + settleArgs.retry, + settleArgs.verification, + backendResponse, + receipt, + ); + } const totalDurationMs = Math.round(options.clock.monotonicMs() - pipelineStart); const failures = failuresOf(flow); diff --git a/src/core/execution/pipeline/retry.ts b/src/core/execution/pipeline/retry.ts new file mode 100644 index 0000000..a0df37e --- /dev/null +++ b/src/core/execution/pipeline/retry.ts @@ -0,0 +1,390 @@ +// The retry gate: a client key either unlocks a stored response or claims the purchase +import type { PaymentProvider, PaymentResult } from '../../domain/payment'; +import type { CommerceReceipt } from '../../domain/receipt'; +import type { DeliveredOutcome } from '../../domain/request'; +import type { CommerceResource } from '../../domain/resource'; +import { + type IdempotencyClaim, + type IdempotencyScope, + type IdempotencyUpdate, + PAYMENT_KEY_ENDPOINT_PREFIX, +} from '../../domain/store-records'; +import { CommerceError } from '../../errors'; +import type { BackendResponse } from '../../interfaces/backend'; +import type { ReceiptStore } from '../../interfaces/store'; +import { isRecord } from '../../is-record'; +import { hasMethodGroup, type StoreGroupMethods } from '../../store-method-groups'; +import { type Flow, inputDigest } from '../journal'; +import { sha256Hex } from '../merchant-key'; +import type { PipelineContext } from './context'; + +type RetryStore = ReceiptStore & + StoreGroupMethods<'idempotency_claims'> & + StoreGroupMethods<'response_redelivery'>; + +/** Pipeline dependencies for merchant keys or response redelivery */ +export interface RetrySupport { + readonly deployment: string; + /** Set on a resource that stores responses */ + readonly store?: RetryStore; +} + +/** A presented client key and its claim data; finalize it after a successful claim */ +export interface RetryTicket { + readonly kind: 'retry'; + readonly store: RetryStore; + readonly scope: IdempotencyScope; + readonly fingerprint: string; + readonly expiresAt: string; + /** A credential: never log it, and never send it to a merchant or facilitator */ + readonly clientKey: string; + readonly proofDigest: string; +} + +export interface RetryGate { + /** + * Inspect retry state without changing the claim. Return a stored response + * for an exact proof resend, or a ticket for verification. Return `undefined` + * if redelivery is disabled or a proof or key is missing; unusable claims throw. + */ + readonly open: ( + flow: Flow, + resource: CommerceResource, + provider: PaymentProvider, + validInput: unknown, + support: RetrySupport | undefined, + pipelineStart: number, + ) => Promise; + /** + * Claim the key after proof verification. Return `undefined` when this request + * owns the purchase. Reuse a stored response only if the verified payer is + * defined and matches its recorded payer. + */ + readonly claim: ( + flow: Flow, + resource: CommerceResource, + provider: PaymentProvider, + ticket: RetryTicket, + verification: PaymentResult, + pipelineStart: number, + ) => Promise; + /** Store the response or attempt to mark its claim unresolved */ + readonly finishDelivered: ( + flow: Flow, + ticket: RetryTicket, + verification: PaymentResult, + response: BackendResponse, + receipt: CommerceReceipt, + ) => Promise; + /** Attempt to release an uncharged claim or mark a possible charge unresolved */ + readonly finishFailed: (flow: Flow, ticket: RetryTicket) => Promise; +} + +// The stored row's body. Of the backend's headers, it keeps only the content type +interface StoredDelivery { + readonly proofDigest: string; + readonly payer?: string; + readonly contentType?: string; + readonly body: unknown; + readonly receiptId: string; +} + +// The delay ACP gives its own in-flight answer +const IN_FLIGHT_RETRY_AFTER_SECONDS = 1; + +const REFUSALS = { + conflict: { + reason: 'payment_identifier_conflict', + message: 'This payment identifier belongs to another request', + }, + 'in-flight': { + reason: 'payment_identifier_in_flight', + message: 'A request with this payment identifier is in progress', + }, + unresolved: { + reason: 'payment_identifier_unresolved', + message: 'The outcome of the request with this payment identifier is unknown', + }, +} as const; + +type RefusedClaim = keyof typeof REFUSALS; + +/** + * Validate deployment and store support for enabled retry features. + * Return `undefined` when both features are disabled; throw `CONFIG_INVALID` + * for missing dependencies. + */ +export function assertRetrySupport( + resource: CommerceResource, + store: ReceiptStore, + deployment: string | undefined, + requestId?: string, +): RetrySupport | undefined { + const { merchantKey, redelivery } = resource.idempotency ?? {}; + if (merchantKey !== true && redelivery === undefined) return undefined; + const unsupported = (needs: string) => + new CommerceError( + 'CONFIG_INVALID', + `Resource "${resource.id}" sets idempotency, which requires ${needs}`, + { ...(requestId !== undefined ? { requestId } : {}), resourceId: resource.id }, + ); + if (deployment === undefined) throw unsupported("the pipeline's deployment option"); + if (redelivery === undefined) return { deployment }; + if ( + !hasMethodGroup(store, 'idempotency_claims') || + !hasMethodGroup(store, 'response_redelivery') + ) { + throw unsupported('a store with idempotency claims and getIdempotencyKey'); + } + return { deployment, store }; +} + +export function createRetryGate(ctx: PipelineContext): RetryGate { + const { options, safeEmit, safePersist, buildEvent } = ctx; + + async function open( + flow: Flow, + resource: CommerceResource, + provider: PaymentProvider, + validInput: unknown, + support: RetrySupport | undefined, + pipelineStart: number, + ): Promise { + const { request } = flow; + const retentionHours = resource.idempotency?.redelivery?.retentionHours; + const { payment } = request; + if (support?.store === undefined || retentionHours === undefined || payment === undefined) { + return undefined; + } + const clientKey = request.retryKey ?? provider.retryKey?.({ resource, submission: payment }); + if (clientKey === undefined) return undefined; + + const fingerprint = inputDigest(validInput); + if (fingerprint === undefined) { + throw new CommerceError( + 'INPUT_INVALID', + `Input for resource "${resource.id}" is not I-JSON, so a retry key cannot identify it`, + { requestId: request.requestId, resourceId: resource.id }, + ); + } + + const { store } = support; + const ticket: RetryTicket = { + kind: 'retry', + store, + scope: { + deployment: support.deployment, + endpoint: `${PAYMENT_KEY_ENDPOINT_PREFIX}${resource.id}`, + key: sha256Hex(clientKey), + }, + fingerprint, + expiresAt: new Date( + options.clock.now().getTime() + retentionHours * 60 * 60 * 1000, + ).toISOString(), + clientKey, + proofDigest: sha256Hex(`${payment.method}:${payment.payload}`), + }; + + const existing = await reading(flow, resource, () => + store.getIdempotencyKey(ticket.scope, fingerprint), + ); + const replay = await acceptOrRefuse(flow, resource, provider, existing); + if (replay === undefined) return ticket; + const stored = await storedDelivery(flow, resource, provider, replay); + return stored.proofDigest === ticket.proofDigest + ? redeliver(flow, resource, provider, replay.status, stored, pipelineStart) + : ticket; + } + + async function claim( + flow: Flow, + resource: CommerceResource, + provider: PaymentProvider, + ticket: RetryTicket, + verification: PaymentResult, + pipelineStart: number, + ): Promise { + const { scope, fingerprint, expiresAt } = ticket; + const claimed = await reading(flow, resource, () => + ticket.store.claimIdempotencyKey(scope, fingerprint, expiresAt), + ); + const replay = await acceptOrRefuse(flow, resource, provider, claimed); + if (replay === undefined) return undefined; + const stored = await storedDelivery(flow, resource, provider, replay); + if (verification.payer === undefined || verification.payer !== stored.payer) { + throw await refusal(flow, resource, provider, 'conflict'); + } + return redeliver(flow, resource, provider, replay.status, stored, pipelineStart); + } + + async function finishDelivered( + flow: Flow, + ticket: RetryTicket, + verification: PaymentResult, + response: BackendResponse, + receipt: CommerceReceipt, + ): Promise { + const contentType = response.headers['content-type']; + const stored: StoredDelivery = { + proofDigest: ticket.proofDigest, + // Record the verified buyer; settlement may name a fee payer instead + ...(verification.payer !== undefined ? { payer: verification.payer } : {}), + // Keep only Content-Type; never persist a backend Set-Cookie header + ...(contentType !== undefined ? { contentType } : {}), + body: response.body ?? null, + receiptId: receipt.id, + }; + // Redelivery needs the original receipt, so a failed receipt write must block completion + const receiptSaved = !flow.persistenceFailures.includes('receipt'); + const update = { kind: 'completed', status: response.status, body: stored } as const; + if (receiptSaved && (await write(flow, ticket, update))) return; + if (!flow.persistenceFailures.includes('response')) flow.persistenceFailures.push('response'); + await write(flow, ticket, { kind: 'unresolved' }); + } + + async function finishFailed(flow: Flow, ticket: RetryTicket): Promise { + await write(flow, ticket, { kind: flow.chargeRisk ? 'unresolved' : 'released' }); + } + + // Record write failures without replacing a delivery or the original error + function write(flow: Flow, ticket: RetryTicket, update: IdempotencyUpdate): Promise { + return safePersist( + flow, + () => ticket.store.updateIdempotencyKey(ticket.scope, update), + `updateIdempotencyKey(${update.kind})`, + 'response', + ); + } + + async function reading( + flow: Flow, + resource: CommerceResource, + read: () => Promise, + ): Promise { + try { + return await read(); + } catch (error) { + throw new CommerceError('STORAGE_ERROR', 'Payment retry state could not be read', { + requestId: flow.request.requestId, + resourceId: resource.id, + cause: error, + }); + } + } + + // Returns a stored response to consider, or `undefined` for an open key + async function acceptOrRefuse( + flow: Flow, + resource: CommerceResource, + provider: PaymentProvider, + claimed: IdempotencyClaim | undefined, + ): Promise | undefined> { + if (claimed === undefined || claimed.kind === 'reserved') return undefined; + if (claimed.kind === 'replay') return claimed; + throw await refusal(flow, resource, provider, claimed.kind); + } + + // A malformed stored response may represent a charge; refuse it as unresolved + async function storedDelivery( + flow: Flow, + resource: CommerceResource, + provider: PaymentProvider, + replay: Extract, + ): Promise { + const stored = readStoredDelivery(replay.body); + if (stored === undefined) throw await refusal(flow, resource, provider, 'unresolved'); + return stored; + } + + // These 409s omit a payment challenge to avoid prompting another charge. + // Only in-flight claims are retryable; the active request may finish on its own. + async function refusal( + flow: Flow, + resource: CommerceResource, + provider: PaymentProvider, + kind: RefusedClaim, + ): Promise { + const { reason, message } = REFUSALS[kind]; + await safeEmit( + buildEvent(flow, { + type: 'payment.rejected', + resourceId: resource.id, + adapter: flow.request.protocol, + paymentProvider: provider.name, + status: 'error', + data: { reason }, + }), + ); + const inFlight = kind === 'in-flight'; + return new CommerceError('PAYMENT_REPLAYED', message, { + requestId: flow.request.requestId, + resourceId: resource.id, + details: { + reason, + ...(inFlight ? { retryAfterSeconds: IN_FLIGHT_RETRY_AFTER_SECONDS } : {}), + }, + ...(inFlight ? { retryable: true } : {}), + }); + } + + // Writes no receipt, attempt or journal row: the original purchase holds them + async function redeliver( + flow: Flow, + resource: CommerceResource, + provider: PaymentProvider, + status: number, + stored: StoredDelivery, + pipelineStart: number, + ): Promise { + const { request } = flow; + const receipt = await reading(flow, resource, () => options.store.getReceipt(stored.receiptId)); + if (receipt?.payment === undefined) { + throw await refusal(flow, resource, provider, 'unresolved'); + } + const durationMs = Math.round(options.clock.monotonicMs() - pipelineStart); + const { transactionId } = receipt; + await safeEmit( + buildEvent(flow, { + type: 'resource.delivered', + resourceId: resource.id, + adapter: request.protocol, + status: 'ok', + durationMs, + data: { + redelivered: true, + ...(transactionId !== undefined ? { originalTransactionId: transactionId } : {}), + }, + }), + ); + return { + kind: 'delivered', + requestId: request.requestId, + ...(transactionId !== undefined ? { transactionId } : {}), + resourceId: resource.id, + backendStatus: status, + body: stored.body, + headers: stored.contentType !== undefined ? { 'content-type': stored.contentType } : {}, + payment: receipt.payment, + receipt, + durationMs, + redelivered: true, + }; + } + + return { open, claim, finishDelivered, finishFailed }; +} + +function readStoredDelivery(value: unknown): StoredDelivery | undefined { + if (!isRecord(value) || !Object.hasOwn(value, 'body')) return undefined; + const { proofDigest, payer, contentType, body, receiptId } = value; + if (typeof proofDigest !== 'string' || typeof receiptId !== 'string') return undefined; + if (payer !== undefined && typeof payer !== 'string') return undefined; + if (contentType !== undefined && typeof contentType !== 'string') return undefined; + return { + proofDigest, + ...(payer !== undefined ? { payer } : {}), + ...(contentType !== undefined ? { contentType } : {}), + body, + receiptId, + }; +} diff --git a/src/core/execution/pipeline/settlement.ts b/src/core/execution/pipeline/settlement.ts index ca33c7b..d3d41b4 100644 --- a/src/core/execution/pipeline/settlement.ts +++ b/src/core/execution/pipeline/settlement.ts @@ -30,6 +30,7 @@ export function createSettlement(ctx: PipelineContext): Settlement { hold, } = args; await recordStep(flow, { kind: 'settling' }); + flow.chargeRisk = true; let settlement: PaymentResult; try { settlement = await provider.settle({ @@ -44,6 +45,7 @@ export function createSettlement(ctx: PipelineContext): Settlement { // it for retry and report the provider failure. const notSubmitted = notSubmittedReason(error); if (notSubmitted !== undefined) { + flow.chargeRisk = false; await finalizeHold(flow, hold, 'release'); await safePersist( flow, @@ -122,6 +124,7 @@ export function createSettlement(ctx: PipelineContext): Settlement { } if (settlement.status !== 'settled') { + flow.chargeRisk = false; await finalizeHold(flow, hold, 'release'); await safePersist( flow, diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index a00b592..bae37e8 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -2560,14 +2560,110 @@ describe('resource idempotency', () => { ); }); - it.each([ - ['an empty block', {}, 'resources.market_report.idempotency.merchantKey'], - [ - 'redelivery, which has no effect', - { merchantKey: true, redelivery: {} }, + it('refuses an empty block, which does nothing', () => { + expectConfigInvalid( + () => parseConfig(withIdempotency('market_report', {}), {}), 'resources.market_report.idempotency', - ], - ])('refuses %s', (_name, idempotency, path) => { - expectConfigInvalid(() => parseConfig(withIdempotency('market_report', idempotency), {}), path); + 'merchantKey or redelivery', + ); + }); + + it('refuses a key it does not know', () => { + expectConfigInvalid( + () => parseConfig(withIdempotency('market_report', { merchantKeys: true }), {}), + 'resources.market_report.idempotency', + ); + }); + + describe('redelivery', () => { + const REDELIVERY_PATH = 'resources.market_report.idempotency.redelivery'; + + it('defaults to 24 hours and not required', () => { + const raw = withIdempotency('market_report', { redelivery: {} }); + + expect(resource(raw, 'market_report')?.idempotency).toEqual({ + redelivery: { retentionHours: 24, required: false }, + }); + }); + + it('reads its settings, from strings too', () => { + const raw = withIdempotency('market_report', { + merchantKey: true, + redelivery: { retentionHours: '48', required: 'true' }, + }); + + expect(resource(raw, 'market_report')?.idempotency).toEqual({ + merchantKey: true, + redelivery: { retentionHours: 48, required: true }, + }); + }); + + it('accepts one hour', () => { + const raw = withIdempotency('market_report', { redelivery: { retentionHours: 1 } }); + + expect(resource(raw, 'market_report')?.idempotency?.redelivery?.retentionHours).toBe(1); + }); + + it.each([[0], [-1], [1.5], ['soon']])('refuses a retention of %s', (retentionHours) => { + expectConfigInvalid( + () => parseConfig(withIdempotency('market_report', { redelivery: { retentionHours } }), {}), + `${REDELIVERY_PATH}.retentionHours`, + ); + }); + + it('refuses a required that is not a boolean', () => { + expectConfigInvalid( + () => + parseConfig(withIdempotency('market_report', { redelivery: { required: 'often' } }), {}), + `${REDELIVERY_PATH}.required`, + ); + }); + + it('refuses a key it does not know', () => { + expectConfigInvalid( + () => parseConfig(withIdempotency('market_report', { redelivery: { hours: 1 } }), {}), + REDELIVERY_PATH, + ); + }); + + it('refuses required on a resource without x402', () => { + const raw = mppResource({ required: true }); + + expectConfigInvalid( + () => parseConfig(raw, {}), + `${REDELIVERY_PATH}.required`, + 'requires "x402" in payments', + ); + }); + + it('accepts redelivery on an MPP resource', () => { + const idempotency = resource(mppResource({}), 'market_report')?.idempotency; + + expect(idempotency?.redelivery).toEqual({ retentionHours: 24, required: false }); + }); + + function mppResource(redelivery: unknown): Record { + const raw = validRawConfig(); + raw['payments'] = { + mpp: { + enabled: true, + rpcUrl: 'https://sepolia.example/v2/key', + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + assetName: 'USDC', + assetVersion: '2', + recipient: '0x1111111111111111111111111111111111111111', + realm: 'api.example.com', + challengeSecret: 'c'.repeat(32), + facilitator: { mode: 'remote', url: 'https://facilitator.example' }, + }, + }; + const resources = raw['resources'] as Record>; + resources['market_report'] = { + ...resources['market_report'], + payments: ['mpp'], + idempotency: { redelivery }, + }; + return raw; + } }); }); diff --git a/tests/unit/core/errors/codes.test.ts b/tests/unit/core/errors/codes.test.ts index 8283403..5174065 100644 --- a/tests/unit/core/errors/codes.test.ts +++ b/tests/unit/core/errors/codes.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { COMMERCE_ERROR_CODES, COMMERCE_ERROR_HTTP_STATUS } from '../../../../src/core/errors'; +import { toErrorEnvelope } from '../../../../src/core/domain/wire'; +import { + COMMERCE_ERROR_CODES, + COMMERCE_ERROR_HTTP_STATUS, + CommerceError, + RETRYABLE_ERROR_CODES, +} from '../../../../src/core/errors'; describe('COMMERCE_ERROR_HTTP_STATUS', () => { it('maps every code to an error status, never to a delivery', () => { @@ -19,3 +25,21 @@ describe('COMMERCE_ERROR_HTTP_STATUS', () => { } }); }); + +describe('CommerceError retryable', () => { + it('follows the code when the option is absent', () => { + for (const code of COMMERCE_ERROR_CODES) { + expect(new CommerceError(code, 'x').retryable, code).toBe(RETRYABLE_ERROR_CODES.has(code)); + } + }); + + it.each([ + ['PAYMENT_REPLAYED', true], + ['GATEWAY_BUSY', false], + ] as const)('lets one %s error override its default with %s', (code, retryable) => { + const error = new CommerceError(code, 'x', { retryable }); + + expect(error.retryable).toBe(retryable); + expect(toErrorEnvelope(error).retryable).toBe(retryable); + }); +}); diff --git a/tests/unit/core/execution/pipeline-retry.test.ts b/tests/unit/core/execution/pipeline-retry.test.ts new file mode 100644 index 0000000..af653ae --- /dev/null +++ b/tests/unit/core/execution/pipeline-retry.test.ts @@ -0,0 +1,1302 @@ +/** + * Exercise retry claims, expiry and reclaim with SQLite. A counting fake + * provider uses `payer/nonce` proofs: `forged` is rejected, and `anon` verifies + * without a payer. + */ +import { createHash } from 'node:crypto'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { CommerceEvent } from '../../../../src/core/domain/event'; +import type { PaymentProvider } from '../../../../src/core/domain/payment'; +import type { CanonicalRequest, DeliveredOutcome } from '../../../../src/core/domain/request'; +import type { CommerceResource } from '../../../../src/core/domain/resource'; +import { toErrorEnvelope } from '../../../../src/core/domain/wire'; +import { CommerceError, isCommerceError } from '../../../../src/core/errors'; +import { createExecutionPipeline } from '../../../../src/core/execution/pipeline'; +import { assertRetrySupport } from '../../../../src/core/execution/pipeline/retry'; +import { createResourceRegistry } from '../../../../src/core/execution/registry'; +import type { BackendRequest, BackendResponse } from '../../../../src/core/interfaces/backend'; +import type { Logger } from '../../../../src/core/interfaces/logger'; +import type { Clock } from '../../../../src/core/interfaces/runtime'; +import { + createSqliteReceiptStore, + type SqliteReceiptStore, +} from '../../../../src/storage/receipts'; +import { + createFakeAuthorizationProvider, + createFakeBackendExecutor, + createFakeIdGenerator, + createFakePaymentProvider, + createFakeStore, + makeResource, +} from './helpers'; + +const T0 = Date.parse('2026-01-01T00:00:00.000Z'); +const HOUR_MS = 3_600_000; +const CLIENT_KEY = 'client-key-secret-1'; +const DEPLOYMENT = 'https://gw.example'; + +// sha256sum of {}, the digest the claim is fingerprinted with +const EMPTY_INPUT_DIGEST = + 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'; +const SCOPE = { + deployment: DEPLOYMENT, + endpoint: 'payment:res-1', + key: createHash('sha256').update(CLIENT_KEY).digest('hex'), +}; +// sha256sum of ["paid-operation","https://gw.example","res-1","","sha256:"] +const MERCHANT_KEY_FOR_CLIENT_KEY = + '2b380ff0fa31f524434675ac86f733173bd62bfa65b0659a194e2f3c7f2f4bde'; +const MERCHANT_KEY_FOR_PROOF = '387ea77166eb841a04f939e2239e0142ead5cc2aa30d27e9e63999c01ff4b5fa'; +const MERCHANT_KEY_FOR_ANON_PROOF = + 'c60d2bf5d522d82f49c16d5e6771ae799127a6ed083dd3c3c2dd0bc130024b6f'; +// sha256sum of x402:alice/1 +const PROOF_DIGEST = '847918077efb9a729f6444e770b7b22a980d287fff7fa938dc5814f119fff09b'; + +const REDELIVERY = { redelivery: { retentionHours: 24, required: false } }; +const OPTED_IN: Partial = { + id: 'res-1', + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + paymentMethods: ['x402'], + idempotency: REDELIVERY, +}; + +const stores: SqliteReceiptStore[] = []; + +afterEach(async () => { + await Promise.all(stores.splice(0).map((store) => store.close())); +}); + +function movableClock(): Clock & { advance(ms: number): void } { + let at = T0; + let tick = 0; + return { + now: () => new Date(at), + nowIso: () => new Date(at).toISOString(), + monotonicMs: () => { + tick += 1; + return tick; + }, + advance(ms) { + at += ms; + }, + }; +} + +function recordingLogger(): Logger & { lines: unknown[] } { + const lines: unknown[] = []; + const log = (obj: unknown, msg?: string) => { + lines.push([obj, msg]); + }; + const logger: Logger & { lines: unknown[] } = { + lines, + debug: log, + info: log, + warn: log, + error: log, + child: () => logger, + }; + return logger; +} + +interface Setup { + readonly resource?: Partial; + readonly settleAfterBackend?: boolean; + readonly provider?: Partial; + readonly backend?: (request: BackendRequest) => Promise; + readonly path?: string; + readonly authorization?: boolean; + readonly verifyBarrier?: Promise; +} + +function setup(options: Setup = {}) { + const clock = movableClock(); + const store = createSqliteReceiptStore({ path: options.path ?? ':memory:', clock }); + stores.push(store); + const calls = { createRequirement: 0, verify: 0, settle: 0, backend: 0, ap2: 0 }; + const sent: BackendRequest[] = []; + const events: CommerceEvent[] = []; + const logger = recordingLogger(); + const auth = createFakeAuthorizationProvider(); + const verifyAndReserve = auth.verifyAndReserve.bind(auth); + auth.verifyAndReserve = (ctx) => { + calls.ap2 += 1; + return verifyAndReserve(ctx); + }; + const base = createFakePaymentProvider({ + ...(options.settleAfterBackend ? { requirementMetadata: { settleAfterBackend: true } } : {}), + }); + const provider: PaymentProvider = { + ...base, + createRequirement: (ctx) => { + calls.createRequirement += 1; + return base.createRequirement(ctx); + }, + verify: async ({ submission }) => { + calls.verify += 1; + await options.verifyBarrier; + const [payer] = submission.payload.split('/'); + const amounts = { provider: 'x402', amount: '0.01', currency: 'USDC' } as const; + if (payer === 'forged') { + return { status: 'rejected', ...amounts, rejectionReason: 'invalid_signature' }; + } + return { + status: 'verified', + ...amounts, + ...(payer !== 'anon' ? { payer } : {}), + replayKey: `replay-${submission.payload}`, + }; + }, + settle: async ({ verification }) => { + calls.settle += 1; + return { + status: 'settled', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + // A fee payer, unlike the buyer that verification names + payer: 'fee-payer', + externalReference: `tx-${calls.settle}`, + metadata: { receipt: 'mpp-receipt', amountBaseUnits: '10000', from: verification.payer }, + }; + }, + ...options.provider, + }; + const pipeline = createExecutionPipeline({ + resources: createResourceRegistry([makeResource({ ...OPTED_IN, ...options.resource })]), + paymentProviders: [provider], + authorizationProviders: options.authorization ? [auth] : [], + store, + backend: createFakeBackendExecutor(async (_handler, request) => { + calls.backend += 1; + sent.push(request); + if (options.backend) return options.backend(request); + return { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { report: 'paid data' }, + durationMs: 5, + }; + }), + events: { + async emit(event) { + events.push(event); + }, + }, + logger, + clock, + ids: createFakeIdGenerator(), + deployment: DEPLOYMENT, + }); + return { pipeline, store, calls, sent, events, logger, clock }; +} + +let requestCounter = 0; + +function paid(payload = 'alice/1', overrides: Partial = {}): CanonicalRequest { + requestCounter += 1; + return { + requestId: `req-${requestCounter}`, + resourceId: 'res-1', + input: {}, + protocol: 'http', + receivedAt: '2026-01-01T00:00:00.000Z', + payment: { method: 'x402', payload }, + retryKey: CLIENT_KEY, + ...overrides, + }; +} + +async function delivered(run: Promise): Promise { + const outcome = (await run) as DeliveredOutcome; + expect(outcome.kind).toBe('delivered'); + return outcome; +} + +async function refusal(run: Promise): Promise { + const error = await run.then( + () => undefined, + (err: unknown) => err, + ); + if (isCommerceError(error)) return error; + return expect.unreachable(`expected a CommerceError, got ${String(error)}`); +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = () => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +const row = (store: SqliteReceiptStore) => store.getIdempotencyKey(SCOPE, EMPTY_INPUT_DIGEST); + +describe('retry gate: requests it leaves alone', () => { + it('claims nothing for a request with no client key', async () => { + const { pipeline, store, calls } = setup(); + + await delivered(pipeline.execute(paid('alice/1', { retryKey: undefined as never }))); + + expect(calls.settle).toBe(1); + expect(await row(store)).toBeUndefined(); + }); + + it('claims nothing on a resource that did not opt in', async () => { + const { pipeline, store, calls } = setup({ resource: { idempotency: undefined as never } }); + + await delivered(pipeline.execute(paid())); + await delivered(pipeline.execute(paid('alice/2'))); + + expect(calls.settle).toBe(2); + expect(await row(store)).toBeUndefined(); + }); + + it('answers a request with no proof with a challenge, and claims nothing', async () => { + const { pipeline, store } = setup(); + + const outcome = await pipeline.execute(paid('x', { payment: undefined as never })); + + expect(outcome.kind).toBe('payment-required'); + expect(await row(store)).toBeUndefined(); + }); + + it("reads the provider's key from the proof when the request has none", async () => { + const retryKey: NonNullable = () => CLIENT_KEY; + const { pipeline, store } = setup({ provider: { retryKey } }); + + await delivered(pipeline.execute(paid('alice/1', { retryKey: undefined as never }))); + + expect((await row(store))?.kind).toBe('replay'); + }); + + it('prefers the request key over the provider key', async () => { + const retryKey: NonNullable = () => 'another-key'; + const { pipeline, store } = setup({ provider: { retryKey } }); + + await delivered(pipeline.execute(paid())); + + expect((await row(store))?.kind).toBe('replay'); + }); + + it('does not read a key on a resource that did not opt in', async () => { + let read = 0; + const retryKey: NonNullable = () => { + read += 1; + return CLIENT_KEY; + }; + const { pipeline } = setup({ + resource: { idempotency: undefined as never }, + provider: { retryKey }, + }); + + await delivered(pipeline.execute(paid())); + + expect(read).toBe(0); + }); + + it("passes a provider's refusal of a bad key through before any payment work", async () => { + const retryKey: NonNullable = () => { + throw new CommerceError('INPUT_INVALID', 'bad key', { + details: { reason: 'payment_identifier_invalid' }, + }); + }; + const { pipeline, calls } = setup({ provider: { retryKey } }); + + const error = await refusal( + pipeline.execute(paid('alice/1', { retryKey: undefined as never })), + ); + + expect(error.code).toBe('INPUT_INVALID'); + expect(error.details?.['reason']).toBe('payment_identifier_invalid'); + expect(calls.createRequirement).toBe(0); + }); + + it('refuses input that is not I-JSON before any payment work', async () => { + const { pipeline, calls, store } = setup(); + + const error = await refusal(pipeline.execute(paid('alice/1', { input: { text: '\uD800' } }))); + + expect(error.code).toBe('INPUT_INVALID'); + expect(calls).toMatchObject({ createRequirement: 0, verify: 0, settle: 0 }); + expect(await store.countReceipts()).toBe(0); + }); +}); + +describe.each([ + ['upfront', false], + ['authorization', true], +])('retry gate: a retry after a lost response, %s flow', (_name, settleAfterBackend) => { + it('serves an exact resend with no provider, AP2 or backend call', async () => { + const { pipeline, calls } = setup({ + settleAfterBackend, + authorization: true, + resource: { authorization: { required: ['ap2'] } }, + }); + const authorization = { method: 'ap2', payload: 'mandate' } as const; + const first = await delivered(pipeline.execute(paid('alice/1', { authorization }))); + const before = { ...calls }; + + const again = await delivered(pipeline.execute(paid('alice/1', { authorization }))); + + expect(calls).toEqual(before); + expect(calls.settle).toBe(1); + expect(again.redelivered).toBe(true); + expect(again.body).toEqual(first.body); + }); + + it('serves a fresh proof from the same payer, verifying it with no AP2 or settle', async () => { + const { pipeline, calls } = setup({ + settleAfterBackend, + authorization: true, + resource: { authorization: { required: ['ap2'] } }, + }); + const authorization = { method: 'ap2', payload: 'mandate' } as const; + const first = await delivered(pipeline.execute(paid('alice/1', { authorization }))); + const before = { ...calls }; + + const again = await delivered(pipeline.execute(paid('alice/2', { authorization }))); + + expect(calls).toEqual({ + ...before, + createRequirement: before.createRequirement + 1, + verify: before.verify + 1, + }); + expect(again.redelivered).toBe(true); + expect(again.body).toEqual(first.body); + }); + + it('rebuilds the original outcome, apart from the request id and duration', async () => { + const { pipeline } = setup({ + settleAfterBackend, + backend: async () => ({ + status: 201, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + body: 'created', + durationMs: 5, + }), + }); + const first = await delivered(pipeline.execute(paid())); + const request = paid('alice/2'); + + const again = await delivered(pipeline.execute(request)); + + expect(again).toEqual({ + ...first, + requestId: request.requestId, + durationMs: again.durationMs, + redelivered: true, + }); + expect(again.payment?.metadata).toMatchObject({ + receipt: 'mpp-receipt', + amountBaseUnits: '10000', + }); + expect(first).not.toHaveProperty('redelivered'); + }); + + it('writes no receipt, attempt or journal row', async () => { + const { pipeline, store } = setup({ settleAfterBackend }); + await delivered(pipeline.execute(paid())); + + await delivered(pipeline.execute(paid())); + await delivered(pipeline.execute(paid('alice/2'))); + + expect(await store.countReceipts()).toBe(1); + expect(await store.listPaymentAttempts()).toHaveLength(1); + expect((await store.listTransactions({})).transactions).toHaveLength(1); + }); + + it('reports the redelivery on a delivered event, with no settlement event', async () => { + const { pipeline, events } = setup({ settleAfterBackend }); + const first = await delivered(pipeline.execute(paid())); + events.length = 0; + + await delivered(pipeline.execute(paid())); + + expect(events.map((event) => event.type)).toEqual(['resource.requested', 'resource.delivered']); + expect(events[1]?.data).toEqual({ + redelivered: true, + originalTransactionId: first.transactionId, + }); + }); + + it('refuses another payer, with no stored body in the error', async () => { + const { pipeline, store, calls } = setup({ settleAfterBackend }); + await delivered(pipeline.execute(paid())); + + const error = await refusal(pipeline.execute(paid('mallory/1'))); + + expect(error).toMatchObject({ code: 'PAYMENT_REPLAYED', httpStatus: 409 }); + expect(error.details).toEqual({ reason: 'payment_identifier_conflict' }); + expect(JSON.stringify(toErrorEnvelope(error))).not.toContain('paid data'); + expect(calls.settle).toBe(1); + expect((await row(store))?.kind).toBe('replay'); + }); + + it('refuses a verified proof with an unknown payer', async () => { + const { pipeline, store, calls } = setup({ settleAfterBackend }); + await delivered(pipeline.execute(paid())); + + const error = await refusal(pipeline.execute(paid('anon/1'))); + + expect(error.details).toEqual({ reason: 'payment_identifier_conflict' }); + expect(calls.settle).toBe(1); + expect((await row(store))?.kind).toBe('replay'); + }); + + it('gives a forged proof with a known key the normal refusal', async () => { + const { pipeline, store, calls } = setup({ settleAfterBackend }); + await delivered(pipeline.execute(paid())); + + const error = await refusal(pipeline.execute(paid('forged/1'))); + + expect(error.code).toBe('PAYMENT_INVALID'); + expect(JSON.stringify(toErrorEnvelope(error))).not.toContain('paid data'); + expect(calls.settle).toBe(1); + expect((await row(store))?.kind).toBe('replay'); + }); + + it('refuses the same key with other input before any provider call', async () => { + const { pipeline, calls } = setup({ settleAfterBackend }); + await delivered(pipeline.execute(paid())); + const before = { ...calls }; + + const error = await refusal(pipeline.execute(paid('alice/1', { input: { city: 'Oslo' } }))); + + expect(error).toMatchObject({ code: 'PAYMENT_REPLAYED', httpStatus: 409 }); + expect(error.details).toEqual({ reason: 'payment_identifier_conflict' }); + expect(calls).toEqual(before); + }); +}); + +describe('retry gate: a purchase whose payer is unknown', () => { + it('still serves an exact resend', async () => { + const { pipeline, calls } = setup(); + await delivered(pipeline.execute(paid('anon/1'))); + + const again = await delivered(pipeline.execute(paid('anon/1'))); + + expect(again.redelivered).toBe(true); + expect(calls.settle).toBe(1); + }); + + it('refuses a fresh proof, which no stored payer can match', async () => { + const { pipeline, calls } = setup(); + await delivered(pipeline.execute(paid('anon/1'))); + + const error = await refusal(pipeline.execute(paid('anon/2'))); + + expect(error.details).toEqual({ reason: 'payment_identifier_conflict' }); + expect(calls.settle).toBe(1); + }); + + it('refuses a named payer too', async () => { + const { pipeline, calls } = setup(); + await delivered(pipeline.execute(paid('anon/1'))); + + const error = await refusal(pipeline.execute(paid('alice/1'))); + + expect(error.details).toEqual({ reason: 'payment_identifier_conflict' }); + expect(calls.settle).toBe(1); + }); +}); + +describe('retry gate: an unresolved or concurrent key', () => { + it('lets one of two concurrent requests settle after the other is in flight', async () => { + const settling = deferred(); + const entered = deferred(); + let settles = 0; + const { pipeline, calls } = setup({ + provider: { + settle: async () => { + settles += 1; + entered.resolve(); + await settling.promise; + return { status: 'settled', provider: 'x402', amount: '0.01', currency: 'USDC' }; + }, + }, + }); + + const first = pipeline.execute(paid()); + await entered.promise; + const before = { ...calls }; + const error = await refusal(pipeline.execute(paid('alice/2'))); + settling.resolve(); + await delivered(first); + + expect(error).toMatchObject({ code: 'PAYMENT_REPLAYED', httpStatus: 409, retryable: true }); + expect(error.details).toEqual({ + reason: 'payment_identifier_in_flight', + retryAfterSeconds: 1, + }); + expect(calls.verify).toBe(before.verify); + expect(settles).toBe(1); + }); + + it('lets one settle when both pass the read before either claims', async () => { + const barrier = deferred(); + const { pipeline, calls } = setup({ verifyBarrier: barrier.promise }); + + const runs = [pipeline.execute(paid()), pipeline.execute(paid('alice/2'))].map((run) => + run.then( + () => 'delivered', + (error: CommerceError) => error.details?.['reason'], + ), + ); + await expect.poll(() => calls.verify).toBe(2); + barrier.resolve(); + + expect((await Promise.all(runs)).sort()).toEqual(['delivered', 'payment_identifier_in_flight']); + expect(calls.settle).toBe(1); + }); + + it('leaves the claim with the request that won it', async () => { + const barrier = deferred(); + const settling = deferred(); + const { pipeline, store, calls } = setup({ + verifyBarrier: barrier.promise, + provider: { + settle: async () => { + await settling.promise; + return { status: 'settled', provider: 'x402', amount: '0.01', currency: 'USDC' }; + }, + }, + }); + const runs = [pipeline.execute(paid()), pipeline.execute(paid('alice/2'))].map((run) => + run.catch((error: unknown) => error), + ); + await expect.poll(() => calls.verify).toBe(2); + barrier.resolve(); + + const lost = await Promise.race(runs); + const during = await row(store); + settling.resolve(); + await Promise.all(runs); + + expect(lost).toMatchObject({ details: { reason: 'payment_identifier_in_flight' } }); + expect(during).toEqual({ kind: 'in-flight' }); + expect((await row(store))?.kind).toBe('replay'); + }); + + it('answers an unresolved key with 409 and no challenge, calling no provider', async () => { + const { pipeline, calls } = setup({ + settleAfterBackend: true, + provider: { + settle: async () => { + throw new Error('facilitator lost the response'); + }, + }, + }); + await refusal(pipeline.execute(paid())); + const before = { ...calls }; + + const resend = await refusal(pipeline.execute(paid())); + const fresh = await refusal(pipeline.execute(paid('alice/2'))); + + for (const error of [resend, fresh]) { + expect(error).toMatchObject({ code: 'PAYMENT_REPLAYED', httpStatus: 409, retryable: false }); + expect(error.details).toEqual({ reason: 'payment_identifier_unresolved' }); + } + expect(calls).toEqual(before); + }); + + it('marks every refusal as a rejected payment, by reason', async () => { + const { pipeline, events } = setup(); + await delivered(pipeline.execute(paid())); + events.length = 0; + + await refusal(pipeline.execute(paid('mallory/1'))); + + const rejected = events.filter((event) => event.type === 'payment.rejected'); + expect(rejected.map((event) => event.data)).toEqual([ + { reason: 'payment_identifier_conflict' }, + ]); + }); + + it('keeps a spent proof under a new key refused, and frees the new key', async () => { + const { pipeline, store, calls } = setup(); + await delivered(pipeline.execute(paid('alice/1', { retryKey: 'another-key-1' }))); + + const error = await refusal(pipeline.execute(paid())); + + expect(error.code).toBe('PAYMENT_REPLAYED'); + expect(calls.settle).toBe(1); + expect(await row(store)).toBeUndefined(); + }); + + it('writes nothing for a junk proof', async () => { + const { pipeline, store } = setup(); + + const error = await refusal(pipeline.execute(paid('forged/1'))); + + expect(error.code).toBe('PAYMENT_INVALID'); + expect(await row(store)).toBeUndefined(); + }); + + it('writes nothing for a proof whose verification throws', async () => { + const { pipeline, store } = setup({ + provider: { + verify: async () => { + throw new Error('facilitator down'); + }, + }, + }); + + await refusal(pipeline.execute(paid())); + + expect(await row(store)).toBeUndefined(); + }); + + it('writes nothing for a verified proof with no replay key', async () => { + const { pipeline, store } = setup({ + provider: { + verify: async () => ({ + status: 'verified', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + }), + }, + }); + + await refusal(pipeline.execute(paid())); + + expect(await row(store)).toBeUndefined(); + }); +}); + +describe('retry gate: secrets and stored data', () => { + it('puts no client key or proof in a log line, an event or an error', async () => { + const failing = setup({ + settleAfterBackend: true, + provider: { + settle: async () => { + throw new Error('lost'); + }, + }, + }); + failing.store.saveReceipt = async () => { + throw new Error('disk full'); + }; + const errors = [ + await refusal(failing.pipeline.execute(paid())), + await refusal(failing.pipeline.execute(paid())), + ]; + const { pipeline, events, logger } = setup(); + await delivered(pipeline.execute(paid())); + await delivered(pipeline.execute(paid())); + await delivered(pipeline.execute(paid('alice/2'))); + errors.push(await refusal(pipeline.execute(paid('mallory/1')))); + + const seen = JSON.stringify([ + events, + logger.lines, + failing.events, + failing.logger.lines, + errors.map(toErrorEnvelope), + ]); + + expect(seen).not.toContain(CLIENT_KEY); + expect(seen).not.toContain('alice/1'); + expect(seen).not.toContain('mallory/1'); + expect(seen).not.toContain(PROOF_DIGEST); + }); + + it('never sends the client key to the merchant', async () => { + const { pipeline, sent } = setup({ + resource: { idempotency: { merchantKey: true, ...REDELIVERY } }, + }); + + await delivered(pipeline.execute(paid())); + + expect(JSON.stringify(sent)).not.toContain(CLIENT_KEY); + }); + + it('stores only the content type of the backend headers', async () => { + const { pipeline, store } = setup({ + backend: async () => ({ + status: 200, + headers: { 'content-type': 'text/html', 'set-cookie': 'session=secret' }, + body: '

paid

', + durationMs: 5, + }), + }); + + const first = await delivered(pipeline.execute(paid())); + const again = await delivered(pipeline.execute(paid())); + + expect(await row(store)).toEqual({ + kind: 'replay', + status: 200, + body: { + proofDigest: PROOF_DIGEST, + // The buyer verification named, not the fee payer settlement reported + payer: 'alice', + contentType: 'text/html', + body: '

paid

', + receiptId: first.receipt.id, + }, + }); + expect(again.headers).toEqual({ 'content-type': 'text/html' }); + }); + + it('keeps a response with no content type and an empty body', async () => { + const { pipeline } = setup({ + backend: async () => ({ status: 204, headers: {}, body: undefined, durationMs: 5 }), + }); + + await delivered(pipeline.execute(paid())); + const again = await delivered(pipeline.execute(paid())); + + expect(again).toMatchObject({ backendStatus: 204, body: null, headers: {} }); + }); +}); + +describe('retry gate: retention', () => { + it('still serves a retry just before retention ends', async () => { + const { pipeline, clock } = setup(); + await delivered(pipeline.execute(paid())); + + clock.advance(24 * HOUR_MS - 1); + + expect((await delivered(pipeline.execute(paid('alice/2')))).redelivered).toBe(true); + }); + + it('treats a retry after retention as a new purchase', async () => { + const { pipeline, calls, clock } = setup(); + await delivered(pipeline.execute(paid())); + + clock.advance(24 * HOUR_MS); + const again = await delivered(pipeline.execute(paid('alice/2'))); + + expect(again).not.toHaveProperty('redelivered'); + expect(calls.settle).toBe(2); + }); + + it("uses the resource's own retention", async () => { + const { pipeline, calls, clock } = setup({ + resource: { idempotency: { redelivery: { retentionHours: 2, required: false } } }, + }); + await delivered(pipeline.execute(paid())); + + clock.advance(2 * HOUR_MS); + await delivered(pipeline.execute(paid('alice/2'))); + + expect(calls.settle).toBe(2); + }); +}); + +describe('retry gate: how a claim ends', () => { + const refused = async () => ({ + status: 'rejected' as const, + provider: 'x402' as const, + amount: '0.01', + currency: 'USDC', + rejectionReason: 'insufficient_funds', + }); + + it.each([ + [ + 'settlement refused, upfront', + { provider: { settle: refused } }, + 'PAYMENT_SETTLEMENT_FAILED', + undefined, + ], + [ + 'settlement refused, authorization', + { settleAfterBackend: true, provider: { settle: refused } }, + 'PAYMENT_SETTLEMENT_FAILED', + undefined, + ], + [ + 'settlement not submitted', + { + provider: { + settle: async () => { + throw new CommerceError('PAYMENT_PROVIDER_UNAVAILABLE', 'not sent', { + details: { settlementNotSubmitted: true }, + }); + }, + }, + }, + 'PAYMENT_PROVIDER_UNAVAILABLE', + undefined, + ], + [ + 'backend failed, authorization', + { + settleAfterBackend: true, + backend: async () => { + throw new CommerceError('BACKEND_ERROR', 'merchant returned 500'); + }, + }, + 'BACKEND_ERROR', + undefined, + ], + [ + 'settlement uncertain', + { + provider: { + settle: async () => { + throw new Error('facilitator lost the response'); + }, + }, + }, + 'PAYMENT_SETTLEMENT_FAILED', + { kind: 'unresolved' }, + ], + [ + 'settlement uncertain after the backend', + { + settleAfterBackend: true, + provider: { + settle: async () => { + throw new Error('facilitator lost the response'); + }, + }, + }, + 'PAYMENT_SETTLEMENT_FAILED', + { kind: 'unresolved' }, + ], + [ + 'backend failed after an upfront settlement', + { + backend: async () => { + throw new CommerceError('BACKEND_ERROR', 'merchant returned 500'); + }, + }, + 'BACKEND_ERROR', + { kind: 'unresolved' }, + ], + ] as const)('leaves the expected claim after: %s', async (_name, options, code, expected) => { + const { pipeline, store } = setup(options as Setup); + + const error = await refusal(pipeline.execute(paid())); + + expect(error.code).toBe(code); + expect(await row(store)).toEqual(expected); + }); + + it('releases the claim when the mandate is missing', async () => { + const { pipeline, store, calls } = setup({ + authorization: true, + resource: { authorization: { required: ['ap2'] } }, + }); + + const error = await refusal(pipeline.execute(paid())); + + expect(error.code).toBe('AUTHORIZATION_REQUIRED'); + expect(calls.settle).toBe(0); + expect(await row(store)).toBeUndefined(); + }); + + it('releases the claim when the replay reservation fails', async () => { + const { pipeline, store } = setup(); + store.reservePaymentAttempt = async () => { + throw new Error('disk full'); + }; + + const error = await refusal(pipeline.execute(paid())); + + expect(error.code).toBe('STORAGE_ERROR'); + expect(await row(store)).toBeUndefined(); + }); + + it('lets a released key buy again', async () => { + let failing = true; + const { pipeline, calls } = setup({ + settleAfterBackend: true, + backend: async () => { + if (failing) throw new CommerceError('BACKEND_ERROR', 'merchant returned 500'); + return { status: 200, headers: {}, body: { ok: true }, durationMs: 5 }; + }, + }); + await refusal(pipeline.execute(paid())); + failing = false; + + const retried = await delivered(pipeline.execute(paid('alice/2'))); + + expect(retried).not.toHaveProperty('redelivered'); + expect(calls.settle).toBe(1); + }); + + it('keeps the merchant key across a re-signed retry', async () => { + let failing = true; + const { pipeline, sent } = setup({ + settleAfterBackend: true, + resource: { idempotency: { merchantKey: true, ...REDELIVERY } }, + backend: async () => { + if (failing) throw new CommerceError('BACKEND_ERROR', 'merchant returned 500'); + return { status: 200, headers: {}, body: { ok: true }, durationMs: 5 }; + }, + }); + await refusal(pipeline.execute(paid())); + failing = false; + + await delivered(pipeline.execute(paid('alice/2'))); + + expect(sent.map((request) => request.idempotencyKey)).toEqual([ + MERCHANT_KEY_FOR_CLIENT_KEY, + MERCHANT_KEY_FOR_CLIENT_KEY, + ]); + }); + + it('reads no client key for the merchant key without redelivery', async () => { + const { pipeline, sent } = setup({ resource: { idempotency: { merchantKey: true } } }); + + await delivered(pipeline.execute(paid())); + + expect(sent.map((request) => request.idempotencyKey)).toEqual([MERCHANT_KEY_FOR_PROOF]); + }); + + it('falls back to the proof for the merchant key when the payer is unknown', async () => { + const { pipeline, sent } = setup({ + resource: { idempotency: { merchantKey: true, ...REDELIVERY } }, + }); + + await delivered(pipeline.execute(paid('anon/1'))); + + expect(sent.map((request) => request.idempotencyKey)).toEqual([MERCHANT_KEY_FOR_ANON_PROOF]); + }); + + it('sends no merchant key when only redelivery is set', async () => { + const { pipeline, sent } = setup(); + + await delivered(pipeline.execute(paid())); + + expect(sent[0]).not.toHaveProperty('idempotencyKey'); + }); +}); + +describe('retry gate: store writes that fail after a charge', () => { + type Update = Parameters[1]['kind']; + + function failUpdates(store: SqliteReceiptStore, failing: readonly Update[]): Update[] { + const attempted: Update[] = []; + const update = store.updateIdempotencyKey.bind(store); + store.updateIdempotencyKey = async (scope, change) => { + attempted.push(change.kind); + if (failing.includes(change.kind)) throw new Error('disk full'); + await update(scope, change); + }; + return attempted; + } + + it('delivers, and closes the key, when the receipt is not saved', async () => { + const { pipeline, store } = setup(); + store.saveReceipt = async () => { + throw new Error('disk full'); + }; + const attempted = failUpdates(store, []); + + const outcome = await delivered(pipeline.execute(paid())); + + expect(outcome.body).toEqual({ report: 'paid data' }); + expect(outcome.persistenceFailures).toEqual(['receipt', 'response']); + expect(attempted).toEqual(['unresolved']); + expect(await row(store)).toEqual({ kind: 'unresolved' }); + }); + + it('delivers, and closes the key, when the completed write fails', async () => { + const { pipeline, store, calls } = setup(); + const attempted = failUpdates(store, ['completed']); + + const outcome = await delivered(pipeline.execute(paid())); + const retry = await refusal(pipeline.execute(paid('alice/2'))); + + expect(outcome.persistenceFailures).toEqual(['response']); + expect(attempted).toEqual(['completed', 'unresolved']); + expect(retry.details).toEqual({ reason: 'payment_identifier_unresolved' }); + expect(calls.settle).toBe(1); + }); + + it('delivers, and keeps the key closed, when both writes fail', async () => { + const { pipeline, store, calls } = setup(); + failUpdates(store, ['completed', 'unresolved']); + + const outcome = await delivered(pipeline.execute(paid())); + const retry = await refusal(pipeline.execute(paid('alice/2'))); + + expect(outcome.persistenceFailures).toEqual(['response']); + expect(retry.details?.['reason']).toBe('payment_identifier_in_flight'); + expect(calls.settle).toBe(1); + }); + + it('reports the failed write on the delivered event', async () => { + const { pipeline, store, events } = setup(); + failUpdates(store, ['completed']); + + await delivered(pipeline.execute(paid())); + + const event = events.find((candidate) => candidate.type === 'resource.delivered'); + expect(event?.data).toEqual({ persistenceFailures: ['response'] }); + }); + + it('keeps the original error when the release fails', async () => { + const { pipeline, store } = setup({ + settleAfterBackend: true, + backend: async () => { + throw new CommerceError('BACKEND_ERROR', 'merchant returned 500'); + }, + }); + failUpdates(store, ['released']); + + const error = await refusal(pipeline.execute(paid())); + + expect(error.code).toBe('BACKEND_ERROR'); + expect(await row(store)).toEqual({ kind: 'in-flight' }); + const { transactions } = await store.listTransactions({}); + expect(transactions[0]?.persistenceFailures).toEqual(['response']); + }); + + it('keeps the original error when a release inside admission fails', async () => { + const { pipeline, store } = setup(); + store.reservePaymentAttempt = async () => { + throw new Error('disk full'); + }; + failUpdates(store, ['released']); + + const error = await refusal(pipeline.execute(paid())); + + expect(error.code).toBe('STORAGE_ERROR'); + expect(await row(store)).toEqual({ kind: 'in-flight' }); + }); + + it('keeps the original error when the unresolved write fails', async () => { + const { pipeline, store } = setup({ + provider: { + settle: async () => { + throw new Error('facilitator lost the response'); + }, + }, + }); + failUpdates(store, ['unresolved']); + + const error = await refusal(pipeline.execute(paid())); + + expect(error.code).toBe('PAYMENT_SETTLEMENT_FAILED'); + expect(await row(store)).toEqual({ kind: 'in-flight' }); + const { transactions } = await store.listTransactions({}); + expect(transactions[0]?.persistenceFailures).toEqual(['response']); + }); +}); + +describe('retry gate: a stored row it cannot serve', () => { + const STORED = { proofDigest: PROOF_DIGEST, payer: 'alice', body: 1, receiptId: 'receipt-1' }; + const RECEIPT = { + id: 'receipt-1', + requestId: 'req-0', + resourceId: 'res-1', + deliveredAt: '2026-01-01T00:00:00.000Z', + backendStatus: 200, + }; + const PAYMENT = { + status: 'settled', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + } as const; + + async function plantRow(store: SqliteReceiptStore, body: unknown): Promise { + await store.claimIdempotencyKey(SCOPE, EMPTY_INPUT_DIGEST, '2026-06-01T00:00:00.000Z'); + await store.updateIdempotencyKey(SCOPE, { kind: 'completed', status: 200, body }); + } + + it.each([ + ['null', null], + ['a string', 'report'], + ['an empty object', {}], + ['no proof digest', { ...STORED, proofDigest: undefined }], + ['a proof digest that is not text', { ...STORED, proofDigest: 7 }], + ['no receipt id', { ...STORED, receiptId: undefined }], + ['a receipt id that is not text', { ...STORED, receiptId: 7 }], + ['no body', { ...STORED, body: undefined }], + ['a payer that is not text', { ...STORED, payer: 7 }], + ['a content type that is not text', { ...STORED, contentType: 7 }], + ])('answers unresolved for a row with %s, calling no provider', async (_name, body) => { + const { pipeline, store, calls } = setup(); + await plantRow(store, body); + // With the receipt present, only the row's own fields can refuse + await store.saveReceipt({ ...RECEIPT, payment: PAYMENT }); + + const error = await refusal(pipeline.execute(paid())); + + expect(error.code).toBe('PAYMENT_REPLAYED'); + expect(error.details).toEqual({ reason: 'payment_identifier_unresolved' }); + expect(calls).toMatchObject({ createRequirement: 0, verify: 0, settle: 0 }); + }); + + it.each([ + ['an exact resend', 'alice/1'], + ['a fresh proof', 'alice/2'], + ])('answers unresolved for %s when the receipt is gone', async (_name, payload) => { + const { pipeline, store, calls } = setup(); + await plantRow(store, STORED); + + const error = await refusal(pipeline.execute(paid(payload))); + + expect(error.details).toEqual({ reason: 'payment_identifier_unresolved' }); + expect(calls.settle).toBe(0); + }); + + it('answers unresolved for a receipt with no payment', async () => { + const { pipeline, store, calls } = setup(); + await plantRow(store, STORED); + await store.saveReceipt(RECEIPT); + + const error = await refusal(pipeline.execute(paid())); + + expect(error.details).toEqual({ reason: 'payment_identifier_unresolved' }); + expect(calls.settle).toBe(0); + }); + + it('serves a receipt that has no transaction id', async () => { + const { pipeline, store, events } = setup(); + await plantRow(store, STORED); + await store.saveReceipt({ ...RECEIPT, payment: PAYMENT }); + + const again = await delivered(pipeline.execute(paid())); + + expect(again).not.toHaveProperty('transactionId'); + expect(events.at(-1)?.data).toEqual({ redelivered: true }); + }); + + it('answers STORAGE_ERROR for a corrupt row, calling no provider', async () => { + const path = join(mkdtempSync(join(tmpdir(), 'retry-gate-')), 'receipts.sqlite'); + const { pipeline, store, calls } = setup({ path }); + await plantRow(store, STORED); + const db = new Database(path); + db.prepare('UPDATE idempotency_keys SET body_json = ?').run('{not json'); + db.close(); + + const error = await refusal(pipeline.execute(paid())); + + expect(error).toMatchObject({ code: 'STORAGE_ERROR', requestId: expect.any(String) }); + expect(calls).toMatchObject({ createRequirement: 0, verify: 0, settle: 0 }); + }); + + it('answers STORAGE_ERROR when the claim read fails, calling no provider', async () => { + const { pipeline, store, calls } = setup(); + store.getIdempotencyKey = async () => { + throw new Error('disk gone'); + }; + + const error = await refusal(pipeline.execute(paid())); + + expect(error.code).toBe('STORAGE_ERROR'); + expect(calls.createRequirement).toBe(0); + }); + + it('answers STORAGE_ERROR when the claim write fails, settling nothing', async () => { + const { pipeline, store, calls } = setup(); + store.claimIdempotencyKey = async () => { + throw new Error('disk gone'); + }; + + const error = await refusal(pipeline.execute(paid())); + + expect(error.code).toBe('STORAGE_ERROR'); + expect(calls.settle).toBe(0); + }); + + it('answers STORAGE_ERROR when the receipt read fails, settling nothing', async () => { + const { pipeline, store, calls } = setup(); + await delivered(pipeline.execute(paid())); + store.getReceipt = async () => { + throw new Error('disk gone'); + }; + + const error = await refusal(pipeline.execute(paid())); + + expect(error.code).toBe('STORAGE_ERROR'); + expect(calls.settle).toBe(1); + }); +}); + +describe('retry gate: a pipeline that cannot serve the resource', () => { + function pipelineWith( + resource: Partial, + store: Parameters[0]['store'], + deployment: string | undefined, + ) { + const providerCalls: string[] = []; + const base = createFakePaymentProvider(); + const pipeline = createExecutionPipeline({ + resources: createResourceRegistry([makeResource({ ...OPTED_IN, ...resource })]), + paymentProviders: [ + { + ...base, + createRequirement: (ctx) => { + providerCalls.push('createRequirement'); + return base.createRequirement(ctx); + }, + }, + ], + store, + backend: createFakeBackendExecutor(), + events: { emit: async () => {} }, + logger: recordingLogger(), + clock: movableClock(), + ids: createFakeIdGenerator(), + ...(deployment !== undefined ? { deployment } : {}), + }); + return { pipeline, providerCalls }; + } + + function sqliteWithout(method?: keyof SqliteReceiptStore): SqliteReceiptStore { + const store = createSqliteReceiptStore({ path: ':memory:', clock: movableClock() }); + stores.push(store); + if (method !== undefined) Reflect.deleteProperty(store, method); + return store; + } + + const WITHOUT_PROOF = paid('x', { payment: undefined as never }); + + it.each([ + ['a store with no claims', () => createFakeStore(), DEPLOYMENT], + ['a store with no claim read', () => sqliteWithout('getIdempotencyKey'), DEPLOYMENT], + ['a store with no claim write', () => sqliteWithout('claimIdempotencyKey'), DEPLOYMENT], + ['no deployment', () => sqliteWithout(), undefined], + ])('refuses before any provider call with %s', async (_name, store, deployment) => { + const { pipeline, providerCalls } = pipelineWith({}, store(), deployment); + + for (const request of [paid(), WITHOUT_PROOF]) { + const error = await refusal(pipeline.execute(request)); + + expect(error).toMatchObject({ code: 'CONFIG_INVALID', requestId: request.requestId }); + } + expect(providerCalls).toEqual([]); + }); + + it('names no request when a caller outside a request checks support', () => { + const resource = makeResource(OPTED_IN); + + const check = () => assertRetrySupport(resource, createFakeStore(), DEPLOYMENT); + + expect(check).toThrow(expect.objectContaining({ code: 'CONFIG_INVALID', resourceId: 'res-1' })); + expect(() => check()).toThrow(expect.not.objectContaining({ requestId: expect.anything() })); + }); + + it('asks nothing of a resource with merchantKey off', async () => { + const { pipeline } = pipelineWith( + { idempotency: { merchantKey: false } }, + createFakeStore(), + undefined, + ); + + await delivered(pipeline.execute(paid())); + }); + + it('asks a store only for what a merchant key needs', async () => { + const { pipeline } = pipelineWith( + { idempotency: { merchantKey: true } }, + createFakeStore(), + DEPLOYMENT, + ); + + await delivered(pipeline.execute(paid())); + }); +}); From 01d5dfec404589b16d8e7644e2ecd4aa07e070f2 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sat, 7 Nov 2026 15:01:29 +0100 Subject: [PATCH 05/20] feat(payments): support the x402 payment-identifier extension --- docs/configuration.md | 16 +- docs/contracts.md | 15 +- docs/protocols/x402.md | 25 +- src/core/domain/resource.ts | 7 +- src/payments/x402/client.ts | 6 + src/payments/x402/provider/index.ts | 2 + src/payments/x402/provider/payment-id.ts | 89 ++++ src/payments/x402/provider/requirement.ts | 6 +- src/payments/x402/provider/verify.ts | 5 + src/payments/x402/transport.ts | 7 +- tests/unit/payments-x402/payment-id.test.ts | 548 ++++++++++++++++++++ 11 files changed, 709 insertions(+), 17 deletions(-) create mode 100644 src/payments/x402/provider/payment-id.ts create mode 100644 tests/unit/payments-x402/payment-id.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index 2888f83..dec7690 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -102,15 +102,19 @@ 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. Supply keys through -`CanonicalRequest.retryKey` or a custom `PaymentProvider.retryKey` hook; -built-in rail extraction is planned. With redelivery enabled, input without a -canonical JSON digest fails with `INPUT_INVALID` when a retry key is used. +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)). Direct pipeline requests can +set `CanonicalRequest.retryKey`, which takes precedence over provider +extraction. Custom providers can implement `PaymentProvider.retryKey`; +extracting MPP's `Idempotency-Key` is planned. With redelivery enabled, input +without a canonical JSON digest fails with `INPUT_INVALID` when a retry key +is used. `retentionHours` is a positive integer (default 24). The retention window starts at the initial retry check, before proof verification. `required` -defaults to false; setting it to true requires `x402` in `payments`. Rejecting -an x402 payment that lacks an `id` remains planned. +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). `url` supports `{param}` templating from validated input; values are URL-encoded. Remaining input becomes query string for `GET`/`DELETE` and a JSON diff --git a/docs/contracts.md b/docs/contracts.md index bf17051..c2dc034 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -234,8 +234,10 @@ persistence must not fail the commerce flow. - The planned 2.7.0 API adds optional contracts for paid retries. Existing 2.6 resources, stores and providers need no changes. The pipeline supports merchant keys and client retry keys; SQLite implements claim lookup and - recovery filtering. Built-in rail key extraction and delivery summary mapping - remain planned; direct pipeline requests and custom provider hooks can supply keys. + recovery filtering. The x402 provider extracts keys from `payment-identifier`. + Extracting MPP's `Idempotency-Key` and mapping `redelivered` to `DeliverySummary` + remain planned. Direct pipeline requests and custom provider hooks can also + supply keys. - `CommerceResource.idempotency` configures paid retries. With `merchantKey` enabled, the pipeline uses a derived backend `Idempotency-Key` for the paid operation; an adapter's key takes precedence. @@ -244,8 +246,8 @@ persistence must not fail the commerce flow. the `idempotency_claims` and `response_redelivery` groups. Both `merchantKey: true` and `redelivery` require the `deployment` option. Missing dependencies make payment admission throw `CONFIG_INVALID` before any provider call. - The planned `required` check must return HTTP 400 for an x402 payment - without an `id`. + With `required` true, x402 retry-key extraction rejects a missing `id` + with `INPUT_INVALID` (HTTP 400). - `CanonicalRequest.retryKey` holds the client's key for retrying a paid request. `PaymentProvider.retryKey` extracts it from a proof without I/O. Treat the key as a credential: never log it or send it to a merchant or @@ -1101,6 +1103,11 @@ export interface CreatePaymentProofOptions { readonly buyerPrivateKey: `0x${string}`; /** One entry from PaymentRequiredEnvelope.payment.accepts, verbatim */ readonly accepts: Readonly>; + /** + * `PaymentPayload.extensions`, sent outside the signature. Echo the challenge's + * declarations; for `payment-identifier`, add the buyer's `id` to `info`. + */ + readonly extensions?: Readonly>; /** For negative tests only: a wrong amount, recipient, nonce or validity window */ readonly overrides?: { readonly value?: string; diff --git a/docs/protocols/x402.md b/docs/protocols/x402.md index 2c21d95..36ab053 100644 --- a/docs/protocols/x402.md +++ b/docs/protocols/x402.md @@ -119,6 +119,29 @@ other types. Local mode requires Anvil on `eip155:31337`; all public networks require remote mode. Mainnet also requires the guardrails in [configuration.md](../configuration.md#mainnet-guardrails). +## Payment identifier + +Experimental and disabled by default. A resource with +[`idempotency.redelivery`](../configuration.md#resources) declares x402's +`payment-identifier` extension in its challenge, setting `info.required` from +`redelivery.required`. To retrieve a stored response without another charge, +add an `id` to the echoed `info` and reuse it with the same input. Resend the +same proof or a new verified proof from the same payer. + +Keep one `id` per purchase. The SDK's `appendPaymentIdentifierToExtensions` +generates a new ID on each call without an explicit `id`. Supply the original +ID on retries so they identify the same purchase. + +During retry-key extraction, a supplied `id` must contain 16 to 128 characters from +`A-Z`, `a-z`, `0-9`, `_` and `-`. Invalid IDs fail with `INPUT_INVALID` and +reason `payment_identifier_invalid`; a missing ID with `required: true` fails +with reason `payment_identifier_required`. Verification checks the echoed +`info.required`, rejecting a changed or missing value as +`extension_echo_mismatch`. Additional `info` fields are allowed. With +`required: false`, omitting the extension disables redelivery unless the caller +supplies `CanonicalRequest.retryKey` directly. The facilitator receives no +extensions. + ## ERC-8409 signed quotes Experimental and disabled by default. With @@ -138,7 +161,7 @@ describes `info`, as x402 requires. Each entry contains `chainId` and | Quote field | Value | | ------------------------------ | --------------------------------------------------------------------------------------------- | | `issuer` | `payments.x402.quote.issuer` in `/.well-known/agent-commerce` | -| `payer` | the zero address: a bearer quote issued before the payer is known | +| `payer` | the zero address: a bearer quote issued before the payer is known | | `recipient`, `asset`, `amount` | the offer's `payTo`, `asset` and `amount` in base units | | `requestScheme` | `agent-commerce/request/v1` | | `requestHash` | Keccak-256 of the RFC 8785 serialization of `{ "input": , "resource": }` | diff --git a/src/core/domain/resource.ts b/src/core/domain/resource.ts index 7139c05..57bfa89 100644 --- a/src/core/domain/resource.ts +++ b/src/core/domain/resource.ts @@ -85,8 +85,9 @@ export interface CommerceResource { readonly required: readonly AuthorizationMethodName[]; }; /** - * Paid retry settings, disabled when omitted. Built-in rail key extraction - * is planned. + * Paid retry settings, disabled when omitted. With redelivery configured, + * x402 reads client keys from `payment-identifier`; MPP's `Idempotency-Key` + * support is planned. * Experimental: may change in a minor release. */ readonly idempotency?: { @@ -96,7 +97,7 @@ export interface CommerceResource { readonly redelivery?: { /** Hours from the initial retry check to the stored response's expiry */ readonly retentionHours: number; - /** Planned: if true, reject an x402 payment missing an `id` with HTTP 400 */ + /** If true, reject a missing `id` during x402 retry-key extraction with HTTP 400 */ readonly required: boolean; }; }; diff --git a/src/payments/x402/client.ts b/src/payments/x402/client.ts index b2a088c..65cc745 100644 --- a/src/payments/x402/client.ts +++ b/src/payments/x402/client.ts @@ -22,6 +22,11 @@ export interface CreatePaymentProofOptions { readonly buyerPrivateKey: `0x${string}`; /** One entry from PaymentRequiredEnvelope.payment.accepts, verbatim */ readonly accepts: Readonly>; + /** + * `PaymentPayload.extensions`, sent outside the signature. Echo the challenge's + * declarations; for `payment-identifier`, add the buyer's `id` to `info`. + */ + readonly extensions?: Readonly>; /** For negative tests only: a wrong amount, recipient, nonce or validity window */ readonly overrides?: { readonly value?: string; @@ -121,6 +126,7 @@ export async function createPaymentProof(options: CreatePaymentProofOptions): Pr nonce, }, }, + ...(options.extensions !== undefined ? { extensions: { ...options.extensions } } : {}), }; return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64'); diff --git a/src/payments/x402/provider/index.ts b/src/payments/x402/provider/index.ts index cf41ab5..2aacc1a 100644 --- a/src/payments/x402/provider/index.ts +++ b/src/payments/x402/provider/index.ts @@ -21,6 +21,7 @@ import { } from './fee-payer'; import { createHealthCheck } from './health'; import { resolveOffers } from './offers'; +import { paymentRetryKey } from './payment-id'; import { createQuoteIssuer, QUOTE_EXTENSION, QUOTE_REQUEST_SCHEME } from './quote'; import { createReconcile } from './reconcile'; import { createRequirementBuilders } from './requirement'; @@ -168,6 +169,7 @@ export function createX402PaymentProvider( createRequirement, // A single offer needs no selection ...(severalOffers ? { selectRequirement } : {}), + retryKey: paymentRetryKey, verify, settle, reconcile: createReconcile(ctx), diff --git a/src/payments/x402/provider/payment-id.ts b/src/payments/x402/provider/payment-id.ts new file mode 100644 index 0000000..c1436d0 --- /dev/null +++ b/src/payments/x402/provider/payment-id.ts @@ -0,0 +1,89 @@ +/** + * Declare x402's `payment-identifier` extension, extract the buyer's retry `id` + * and validate the echoed `info`. The wire format follows + * `specs/extensions/payment_identifier.md` at `x402-foundation/x402@6a773dba`. + * Defining it here avoids a runtime dependency on `@x402/extensions`. + */ +import type { PaymentPayload } from '@x402/core/types'; +import type { CommerceResource, PaymentSubmission } from '../../../core'; +import { CommerceError } from '../../../core'; +import { isRecord } from '../../../core/is-record'; +import { decodePaymentSubmission } from '../payload'; + +/** The extension's key under `PaymentRequired.extensions` and `PaymentPayload.extensions` */ +export const PAYMENT_IDENTIFIER = 'payment-identifier'; + +const ID_PATTERN = /^[A-Za-z0-9_-]{16,128}$/; + +const field = (value: unknown, key: string): unknown => (isRecord(value) ? value[key] : undefined); + +/** Declare the extension for a resource with response redelivery configured */ +export function paymentIdentifierExtensions( + resource: CommerceResource, +): Record | undefined { + const redelivery = resource.idempotency?.redelivery; + if (redelivery === undefined) return undefined; + // Build a fresh declaration so a buyer's in-place ID update cannot affect another challenge + return { + [PAYMENT_IDENTIFIER]: { + info: { required: redelivery.required }, + schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + required: { type: 'boolean' }, + id: { type: 'string', minLength: 16, maxLength: 128, pattern: '^[a-zA-Z0-9_-]+$' }, + }, + required: ['required'], + }, + }, + }; +} + +/** + * Read and validate the buyer's retry `id` when redelivery is configured. + * Return no key for undecodable proofs; `verify()` rejects them. + */ +export function paymentRetryKey(context: { + readonly resource: CommerceResource; + readonly submission: PaymentSubmission; +}): string | undefined { + const { resource, submission } = context; + const redelivery = resource.idempotency?.redelivery; + if (redelivery === undefined) return undefined; + const payload = decodePaymentSubmission(submission.payload); + if (payload === undefined) return undefined; + const extension = payload.extensions?.[PAYMENT_IDENTIFIER]; + const info = field(extension, 'info'); + if (extension !== undefined && !isRecord(info)) throw invalid(resource); + const id = field(info, 'id'); + if (id === undefined) { + if (!redelivery.required) return undefined; + throw new CommerceError('INPUT_INVALID', 'This resource requires a payment identifier', { + resourceId: resource.id, + details: { reason: 'payment_identifier_required' }, + }); + } + if (typeof id !== 'string' || !ID_PATTERN.test(id)) throw invalid(resource); + return id; +} + +/** + * Check that an echoed identifier preserves the challenge's `required` value. + * Missing echoes pass this check; `paymentRetryKey` enforces required IDs. + * Additional `info` fields are allowed by x402 v2 §5.1.2. + */ +export function paymentIdentifierEchoed(envelope: unknown, payload: PaymentPayload): boolean { + const declared = field(field(field(envelope, 'extensions'), PAYMENT_IDENTIFIER), 'info'); + const echoed = payload.extensions?.[PAYMENT_IDENTIFIER]; + if (!isRecord(declared) || echoed === undefined) return true; + return field(field(echoed, 'info'), 'required') === declared['required']; +} + +function invalid(resource: CommerceResource): CommerceError { + return new CommerceError( + 'INPUT_INVALID', + 'A payment identifier must be 16 to 128 characters of A-Z, a-z, 0-9, "_" and "-"', + { resourceId: resource.id, details: { reason: 'payment_identifier_invalid' } }, + ); +} diff --git a/src/payments/x402/provider/requirement.ts b/src/payments/x402/provider/requirement.ts index 1f38382..a08f545 100644 --- a/src/payments/x402/provider/requirement.ts +++ b/src/payments/x402/provider/requirement.ts @@ -11,6 +11,7 @@ import type { X402Network } from '../networks'; import { decodePaymentSubmission } from '../payload'; import { X402_DEFAULT_PAYMENT_FLOW } from '../payment-flow'; import { DEFAULT_MIME_TYPE, X402_PROTOCOL_VERSION, X402_VERSION } from './constants'; +import { paymentIdentifierExtensions } from './payment-id'; import type { ProviderContext, SvmOffer } from './types'; // Requirement metadata naming the offer `selectRequirement` chose, by index @@ -99,7 +100,7 @@ export function createRequirementBuilders( })), ); const single = offers.length === 1 ? offers[0] : undefined; - const extensions = await ctx.quotes?.extensionsFor( + const quoteExtensions = await ctx.quotes?.extensionsFor( context.resource.id, context.input, offers.flatMap((offer, index) => { @@ -107,6 +108,9 @@ export function createRequirementBuilders( return offer.family === 'evm' && accepted ? [{ offer, amount: accepted.amount }] : []; }), ); + const identifier = paymentIdentifierExtensions(context.resource); + const extensions = + identifier === undefined ? quoteExtensions : { ...quoteExtensions, ...identifier }; // The v2 challenge document as sent on the wire. The HTTP adapter // base64-encodes it into `PAYMENT-REQUIRED` and MCP passes it through, so diff --git a/src/payments/x402/provider/verify.ts b/src/payments/x402/provider/verify.ts index c2713cb..174c779 100644 --- a/src/payments/x402/provider/verify.ts +++ b/src/payments/x402/provider/verify.ts @@ -8,6 +8,7 @@ import { normalizeAddress, sameAddress } from '../addresses'; import { declaredX402Version, decodePaymentSubmission, forFacilitator } from '../payload'; import { X402_VERSION } from './constants'; import { offerCurrency } from './offers'; +import { paymentIdentifierEchoed } from './payment-id'; import { isProviderUnavailableError, NO_VERDICT_REASONS, sanitizeReason } from './reasons'; import { acceptedMatchesOffer, offerIndexOf } from './requirement'; import { readTransfer, type Transfer } from './transfer'; @@ -139,6 +140,10 @@ export function createVerify( ) { return refused('invalid_payment_requirements'); } + // Preserve the challenge's declared required value in the buyer's echo + if (!paymentIdentifierEchoed(requirement.challenge.envelope, payload)) { + return refused('extension_echo_mismatch'); + } let required: bigint; try { required = BigInt(requirements.amount); diff --git a/src/payments/x402/transport.ts b/src/payments/x402/transport.ts index 2545109..2d0a604 100644 --- a/src/payments/x402/transport.ts +++ b/src/payments/x402/transport.ts @@ -35,10 +35,13 @@ const REPLAY_CODES: Readonly> = { }; /** - * Returns the provider's refusal reason, or the paid network family's replay - * code. Unknown networks use EVM's replay code. + * Preserve `payment_identifier_` refusal reasons: they do not indicate a spent + * authorization. Other `PAYMENT_REPLAYED` errors use the network family's + * replay code, defaulting to EVM for unknown networks. Otherwise use the + * provider's reason, or `unexpected_verify_error` if it is not a string. */ export function x402ErrorCode(code: string, reason: unknown, network?: unknown): string { + if (typeof reason === 'string' && reason.startsWith('payment_identifier_')) return reason; if (code === 'PAYMENT_REPLAYED') { const namespace = typeof network === 'string' ? network.split(':')[0] : undefined; return ( diff --git a/tests/unit/payments-x402/payment-id.test.ts b/tests/unit/payments-x402/payment-id.test.ts new file mode 100644 index 0000000..2f3623d --- /dev/null +++ b/tests/unit/payments-x402/payment-id.test.ts @@ -0,0 +1,548 @@ +/** + * Exercise the real provider's payment-identifier challenge, retry-key + * extraction, echo validation and facilitator payloads. A stub HTTP facilitator + * on 127.0.0.1 lets verify() and settle() use the remote binding without a chain. + */ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { x402Client } from '@x402/core/client'; +import type { PaymentRequired } from '@x402/core/types'; +import { registerExactEvmScheme } from '@x402/evm/exact/client'; +import { privateKeyToAccount } from 'viem/accounts'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { Clock, CommerceResource, PaymentRequirement } from '../../../src/core'; +import { createPaymentProof } from '../../../src/payments/x402/client'; +import { decodePaymentSubmission } from '../../../src/payments/x402/payload'; +import { + createX402PaymentProvider, + type X402ProviderOptions, +} from '../../../src/payments/x402/provider'; +import { x402ErrorCode } from '../../../src/payments/x402/transport'; + +const ASSET = '0x5FbDB2315678afecb367f032d93F642f64180aa3' as const; +const PAY_TO = '0x1111111111111111111111111111111111111111' as const; +// LOCAL DEVELOPMENT ONLY - DO NOT FUND (Anvil account 1) +const BUYER_PRIVATE_KEY = + '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a' as const; +const BUYER = privateKeyToAccount(BUYER_PRIVATE_KEY).address; +const QUOTE_KEY = `0x${'33'.repeat(32)}` as const; +const NONCE = `0x${'01'.repeat(32)}` as const; +const PINNED_ID = 'pay_0123456789abcdef'; +const KEY = 'payment-identifier'; + +// declarePaymentIdentifierExtension(required) from @x402/extensions 2.25.0 +function declared(required: boolean): Record { + return { + info: { required }, + schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + required: { type: 'boolean' }, + id: { type: 'string', minLength: 16, maxLength: 128, pattern: '^[a-zA-Z0-9_-]+$' }, + }, + required: ['required'], + }, + }; +} + +const PLAIN: CommerceResource = { + id: 'demo.report', + name: 'Demo report', + handler: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: '0.01', currency: 'USD' }, + exposedVia: ['http'], + paymentMethods: ['x402'], +}; +const OPTIONAL: CommerceResource = { + ...PLAIN, + idempotency: { redelivery: { retentionHours: 24, required: false } }, +}; +const REQUIRED: CommerceResource = { + ...PLAIN, + idempotency: { redelivery: { retentionHours: 24, required: true } }, +}; + +const clock: Clock = { + now: () => new Date('2026-11-07T12:00:00.000Z'), + nowIso: () => '2026-11-07T12:00:00.000Z', + monotonicMs: () => 0, +}; + +interface Facilitator { + url: string; + requests: string[]; + bodies: string[]; +} + +const facilitator: Facilitator = { url: '', requests: [], bodies: [] }; +const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + facilitator.requests.push(`${req.method} ${req.url}`); + facilitator.bodies.push(Buffer.concat(chunks).toString('utf8')); + const answer = req.url?.endsWith('/verify') + ? { isValid: true, payer: BUYER } + : { success: true, transaction: `0x${'ab'.repeat(32)}`, network: 'eip155:31337', payer: BUYER }; + res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(answer)); +}); + +beforeAll(async () => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + facilitator.url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); +beforeEach(() => { + facilitator.requests = []; + facilitator.bodies = []; +}); + +function makeProvider(overrides: Partial = {}) { + return createX402PaymentProvider({ + network: 'eip155:31337', + // Never contacted: the facilitator answers for the chain + rpcUrl: 'http://127.0.0.1:9', + asset: ASSET, + assetName: 'MockUSDC', + assetVersion: '2', + assetDecimals: 6, + payTo: PAY_TO, + facilitator: { mode: 'remote', url: facilitator.url, auth: { type: 'none' } }, + clock, + ...overrides, + }); +} + +function challengeFor(provider: ReturnType, resource: CommerceResource) { + return provider.createRequirement({ + requestId: 'req-1', + resource, + amount: '0.01', + currency: 'USD', + requestedAt: clock.nowIso(), + input: {}, + }); +} + +const envelopeOf = (requirement: PaymentRequirement) => + requirement.challenge.envelope as unknown as PaymentRequired; + +// The challenge's declaration with `info` changed as a buyer would echo it +function echo(requirement: PaymentRequirement, info: Record) { + const ours = envelopeOf(requirement).extensions?.[KEY] as { info: object }; + return { [KEY]: { ...ours, info: { ...ours.info, ...info } } }; +} + +function proof( + requirement: PaymentRequirement, + extensions?: Record, + value?: string, +): Promise { + return createPaymentProof({ + buyerPrivateKey: BUYER_PRIVATE_KEY, + accepts: requirement.challenge.accepts[0] as Record, + ...(extensions !== undefined ? { extensions } : {}), + overrides: { nonce: NONCE, validBefore: 1_900_000_000, ...(value ? { value } : {}) }, + }); +} + +async function retryKeyOf(resource: CommerceResource, extensions?: Record) { + const provider = makeProvider(); + const requirement = await challengeFor(provider, OPTIONAL); + const payload = await proof(requirement, extensions); + return () => provider.retryKey?.({ resource, submission: { method: 'x402', payload } }); +} + +async function verified( + provider: ReturnType, + resource: CommerceResource, + requirement: PaymentRequirement, + payload: string, +) { + return provider.verify({ + requestId: 'req-1', + resource, + requirement, + submission: { method: 'x402', payload }, + }); +} + +describe('payment-identifier: the challenge', () => { + it.each([false, true])( + 'declares the extension as the spec writes it, required %s', + async (required) => { + const requirement = await challengeFor(makeProvider(), required ? REQUIRED : OPTIONAL); + + expect(envelopeOf(requirement).extensions).toEqual({ [KEY]: declared(required) }); + }, + ); + + it('declares it beside the ERC-8409 quote extension', async () => { + const provider = makeProvider({ quote: { signerPrivateKey: QUOTE_KEY } }); + const quoteKey = provider.quote?.extension ?? ''; + + const extensions = envelopeOf(await challengeFor(provider, OPTIONAL)).extensions ?? {}; + + expect(Object.keys(extensions).sort()).toEqual([quoteKey, KEY].sort()); + expect(extensions[KEY]).toEqual(declared(false)); + expect(extensions[quoteKey]).toMatchObject({ info: { quotes: [expect.anything()] } }); + }); + + it('leaves the challenge unchanged on a resource that stores no responses', async () => { + const provider = makeProvider(); + const merchantKeyOnly = { ...PLAIN, idempotency: { merchantKey: true } }; + + const plain = await challengeFor(provider, PLAIN); + const other = await challengeFor(provider, merchantKeyOnly); + + expect(JSON.stringify(other.challenge)).toBe(JSON.stringify(plain.challenge)); + expect(plain.challenge.envelope).not.toHaveProperty('extensions'); + }); + + it('leaves the quote extension alone on a resource that stores no responses', async () => { + const provider = makeProvider({ quote: { signerPrivateKey: QUOTE_KEY } }); + + const extensions = envelopeOf(await challengeFor(provider, PLAIN)).extensions ?? {}; + + expect(Object.keys(extensions)).toEqual([provider.quote?.extension]); + }); + + it('gives each challenge its own declaration', async () => { + const provider = makeProvider(); + const first = await challengeFor(provider, OPTIONAL); + const ours = envelopeOf(first).extensions as Record }>; + (ours[KEY] as { info: Record }).info['id'] = 'x'; + + const second = await challengeFor(provider, OPTIONAL); + + expect(envelopeOf(second).extensions?.[KEY]).toEqual(declared(false)); + }); +}); + +describe('payment-identifier: the retry key', () => { + it("reads the buyer's id", async () => { + const read = await retryKeyOf( + OPTIONAL, + echo(await challengeFor(makeProvider(), OPTIONAL), { + id: PINNED_ID, + }), + ); + + expect(read()).toBe(PINNED_ID); + }); + + it('reads no key on a resource that stores no responses', async () => { + const read = await retryKeyOf(PLAIN, { [KEY]: { info: { required: false, id: PINNED_ID } } }); + + expect(read()).toBeUndefined(); + }); + + it.each([ + ['a stored response is optional', OPTIONAL], + ['the id is required', REQUIRED], + ])('reads no key from a proof that does not decode, when %s', (_name, resource) => { + const provider = makeProvider(); + + const key = provider.retryKey?.({ resource, submission: { method: 'x402', payload: '%%%' } }); + + expect(key).toBeUndefined(); + }); + + it.each([ + ['no extension', undefined], + ['an echo with no id', { [KEY]: { info: { required: false } } }], + ])('reads no key from %s when the id is optional', async (_name, extensions) => { + const read = await retryKeyOf(OPTIONAL, extensions); + + expect(read()).toBeUndefined(); + }); + + it.each([ + ['no extension', undefined], + ['an echo with no id', { [KEY]: { info: { required: true } } }], + ])('refuses %s with 400 when the id is required', async (_name, extensions) => { + const read = await retryKeyOf(REQUIRED, extensions); + + expect(read).toThrow( + expect.objectContaining({ + code: 'INPUT_INVALID', + httpStatus: 400, + resourceId: 'demo.report', + details: { reason: 'payment_identifier_required' }, + }), + ); + }); + + it.each([ + [16, 'a'.repeat(16)], + [128, 'Z_-9'.repeat(32)], + ])('accepts an id of %i characters', async (_length, id) => { + const read = await retryKeyOf(OPTIONAL, { [KEY]: { info: { required: false, id } } }); + + expect(read()).toBe(id); + }); + + it.each([ + ['15 characters', 'a'.repeat(15)], + ['129 characters', 'a'.repeat(129)], + ['a dot', `${'a'.repeat(15)}.`], + ['a space', `${'a'.repeat(15)} `], + ['a letter outside ASCII', `${'a'.repeat(15)}é`], + ['a number', 1234567890123456], + ['null', null], + ])('refuses an id with %s', async (_name, id) => { + const read = await retryKeyOf(OPTIONAL, { [KEY]: { info: { required: false, id } } }); + + expect(read).toThrow( + expect.objectContaining({ + code: 'INPUT_INVALID', + details: { reason: 'payment_identifier_invalid' }, + }), + ); + }); + + it.each([ + ['an extension that is not an object', { [KEY]: true }], + ['info that is not an object', { [KEY]: { info: PINNED_ID } }], + ['no info', { [KEY]: { id: PINNED_ID } }], + ])('refuses %s', async (_name, extensions) => { + const read = await retryKeyOf(OPTIONAL, extensions); + + expect(read).toThrow( + expect.objectContaining({ details: { reason: 'payment_identifier_invalid' } }), + ); + }); + + it('puts no part of the id in its error', async () => { + const id = `${PINNED_ID}!`; + const read = await retryKeyOf(OPTIONAL, { [KEY]: { info: { required: false, id } } }); + + expect(read).toThrow(expect.not.objectContaining({ message: expect.stringContaining(id) })); + }); +}); + +describe('payment-identifier: the echo check', () => { + it.each([ + ['adds the id', { id: PINNED_ID }], + ['adds another info field', { id: PINNED_ID, note: 'retry 1' }], + ])('verifies an echo that %s', async (_name, info) => { + const provider = makeProvider(); + const requirement = await challengeFor(provider, OPTIONAL); + + const result = await verified( + provider, + OPTIONAL, + requirement, + await proof(requirement, echo(requirement, info)), + ); + + expect(result).toMatchObject({ status: 'verified', payer: BUYER }); + expect(facilitator.requests).toEqual(['POST /verify']); + }); + + it.each([ + ['changes required', { required: true, id: PINNED_ID }], + ['drops required', { required: undefined, id: PINNED_ID }], + ])('refuses an echo that %s, before the facilitator', async (_name, info) => { + const provider = makeProvider(); + const requirement = await challengeFor(provider, OPTIONAL); + + const result = await verified( + provider, + OPTIONAL, + requirement, + await proof(requirement, echo(requirement, info)), + ); + + expect(result).toMatchObject({ + status: 'rejected', + rejectionReason: 'extension_echo_mismatch', + replayKey: expect.any(String), + }); + expect(facilitator.requests).toEqual([]); + }); + + it('refuses an echo with no info', async () => { + const provider = makeProvider(); + const requirement = await challengeFor(provider, OPTIONAL); + + const result = await verified( + provider, + OPTIONAL, + requirement, + await proof(requirement, { [KEY]: { id: PINNED_ID } }), + ); + + expect(result.rejectionReason).toBe('extension_echo_mismatch'); + }); + + it('verifies a proof that echoes no extension', async () => { + const provider = makeProvider(); + const requirement = await challengeFor(provider, OPTIONAL); + + const result = await verified(provider, OPTIONAL, requirement, await proof(requirement)); + + expect(result.status).toBe('verified'); + }); + + it('verifies a proof that echoes the identifier but not the quotes', async () => { + const provider = makeProvider({ quote: { signerPrivateKey: QUOTE_KEY } }); + const requirement = await challengeFor(provider, OPTIONAL); + + const result = await verified( + provider, + OPTIONAL, + requirement, + await proof(requirement, echo(requirement, { id: PINNED_ID })), + ); + + expect(result.status).toBe('verified'); + }); + + it('ignores an identifier the challenge did not declare', async () => { + const provider = makeProvider(); + const requirement = await challengeFor(provider, PLAIN); + + const result = await verified( + provider, + PLAIN, + requirement, + await proof(requirement, { [KEY]: { info: { required: true, id: PINNED_ID } } }), + ); + + expect(result.status).toBe('verified'); + }); +}); + +describe('payment-identifier: proofs from other sources', () => { + it('reads and verifies a proof from the SDK client with a pinned id', async () => { + const provider = makeProvider(); + const requirement = await challengeFor(provider, REQUIRED); + const client = new x402Client(); + registerExactEvmScheme(client, { + signer: privateKeyToAccount(BUYER_PRIVATE_KEY), + networks: ['eip155:31337'], + }); + // MockUSDC is not in the SDK's asset list, so its spend controls are off + client.setSpendControls(false); + // Add the purchase's ID before the SDK echoes the declaration into the proof + client.onBeforePaymentCreation(async ({ paymentRequired }) => { + const extensions = paymentRequired.extensions as Record; + Object.assign((extensions[KEY] as { info: object }).info, { id: PINNED_ID }); + }); + const signed = await client.createPaymentPayload(structuredClone(envelopeOf(requirement))); + const payload = Buffer.from(JSON.stringify(signed), 'utf8').toString('base64'); + + const key = provider.retryKey?.({ + resource: REQUIRED, + submission: { method: 'x402', payload }, + }); + const result = await verified(provider, REQUIRED, requirement, payload); + + expect(key).toBe(PINNED_ID); + expect(result.status).toBe('verified'); + }); + + it('refuses a proof signed for another amount, whatever id it carries', async () => { + const provider = makeProvider(); + const requirement = await challengeFor(provider, OPTIONAL); + const payload = await proof(requirement, echo(requirement, { id: PINNED_ID }), '1'); + + const key = provider.retryKey?.({ + resource: OPTIONAL, + submission: { method: 'x402', payload }, + }); + const result = await verified(provider, OPTIONAL, requirement, payload); + + expect(key).toBe(PINNED_ID); + expect(result.status).toBe('rejected'); + expect(facilitator.requests).toEqual([]); + }); + + it('sends the facilitator no part of the extension', async () => { + const provider = makeProvider(); + const requirement = await challengeFor(provider, OPTIONAL); + const payload = await proof(requirement, echo(requirement, { id: PINNED_ID })); + const context = { + requestId: 'req-1', + resource: OPTIONAL, + requirement, + submission: { method: 'x402' as const, payload }, + }; + + const verification = await provider.verify(context); + const settlement = await provider.settle({ ...context, verification }); + + expect(settlement.status).toBe('settled'); + expect(facilitator.requests).toEqual(['POST /verify', 'POST /settle']); + for (const body of facilitator.bodies) { + expect(body).not.toContain(PINNED_ID); + expect(body).not.toContain(KEY); + } + }); +}); + +describe('x402ErrorCode', () => { + it.each([ + ['PAYMENT_REPLAYED', 'payment_identifier_conflict'], + ['PAYMENT_REPLAYED', 'payment_identifier_in_flight'], + ['PAYMENT_REPLAYED', 'payment_identifier_unresolved'], + ['INPUT_INVALID', 'payment_identifier_required'], + ['INPUT_INVALID', 'payment_identifier_invalid'], + ])('answers %s with its reason %s', (code, reason) => { + expect(x402ErrorCode(code, reason, 'solana:devnet')).toBe(reason); + }); + + it.each([ + ['eip155:8453', 'invalid_exact_evm_nonce_already_used'], + ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', 'duplicate_settlement'], + ['near:mainnet', 'invalid_exact_near_payload_delegate_action_nonce_already_used'], + [undefined, 'invalid_exact_evm_nonce_already_used'], + ])("keeps a spent authorization's replay code on %s", (network, expected) => { + expect(x402ErrorCode('PAYMENT_REPLAYED', 'PAYMENT_REPLAYED', network)).toBe(expected); + }); + + it('keeps other refusal reasons', () => { + expect(x402ErrorCode('PAYMENT_INVALID', 'extension_echo_mismatch')).toBe( + 'extension_echo_mismatch', + ); + expect(x402ErrorCode('PAYMENT_INVALID', undefined)).toBe('unexpected_verify_error'); + }); +}); + +describe('createPaymentProof: extensions', () => { + async function proofs() { + const requirement = await challengeFor(makeProvider(), OPTIONAL); + const extensions = echo(requirement, { id: PINNED_ID }); + const decode = (payload: string) => + JSON.parse(Buffer.from(payload, 'base64').toString('utf8')) as Record; + return { + extensions, + without: await proof(requirement), + with: await proof(requirement, extensions), + decode, + }; + } + + it('writes the same proof as before without them', async () => { + const { without, decode } = await proofs(); + + expect(Object.keys(decode(without))).toEqual(['x402Version', 'accepted', 'payload']); + }); + + it('adds them outside the signature', async () => { + const { without, with: carrying, extensions, decode } = await proofs(); + + expect(decode(carrying)).toEqual({ ...decode(without), extensions }); + }); + + it('keeps the id through decodePaymentSubmission', async () => { + const { with: carrying } = await proofs(); + + expect(decodePaymentSubmission(carrying)?.extensions?.[KEY]).toMatchObject({ + info: { required: false, id: PINNED_ID }, + }); + }); +}); From 5fa1f2b7b26b9deff794c532fed71cc0166e550b Mon Sep 17 00:00:00 2001 From: Revinand Date: Sat, 7 Nov 2026 16:45:15 +0100 Subject: [PATCH 06/20] feat(payments): accept Idempotency-Key for MPP --- docs/configuration.md | 13 +- docs/contracts.md | 8 +- docs/protocols/http.md | 32 +- docs/protocols/mpp.md | 37 +- src/core/domain/resource.ts | 6 +- src/gateway/routes.ts | 46 +- src/payments/mpp/problems.ts | 31 +- tests/integration/payment-retries.test.ts | 627 ++++++++++++++++++++++ tests/unit/payments-mpp/problems.test.ts | 36 ++ 9 files changed, 799 insertions(+), 37 deletions(-) create mode 100644 tests/integration/payment-retries.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index dec7690..cfd55f1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -104,12 +104,13 @@ 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)). Direct pipeline requests can -set `CanonicalRequest.retryKey`, which takes precedence over provider -extraction. Custom providers can implement `PaymentProvider.retryKey`; -extracting MPP's `Idempotency-Key` is planned. With redelivery enabled, input -without a canonical JSON digest fails with `INPUT_INVALID` when a retry key -is used. +[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 a positive integer (default 24). The retention window starts at the initial retry check, before proof verification. `required` diff --git a/docs/contracts.md b/docs/contracts.md index c2dc034..b77017f 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -234,10 +234,10 @@ persistence must not fail the commerce flow. - The planned 2.7.0 API adds optional contracts for paid retries. Existing 2.6 resources, stores and providers need no changes. The pipeline supports merchant keys and client retry keys; SQLite implements claim lookup and - recovery filtering. The x402 provider extracts keys from `payment-identifier`. - Extracting MPP's `Idempotency-Key` and mapping `redelivered` to `DeliverySummary` - remain planned. Direct pipeline requests and custom provider hooks can also - supply keys. + recovery filtering. The x402 provider extracts `payment-identifier` IDs; + the HTTP invoke route reads MPP's `Idempotency-Key` into `retryKey`. + Mapping `redelivered` to `DeliverySummary` remains planned. Direct pipeline + requests and custom provider hooks can also supply keys. - `CommerceResource.idempotency` configures paid retries. With `merchantKey` enabled, the pipeline uses a derived backend `Idempotency-Key` for the paid operation; an adapter's key takes precedence. diff --git a/docs/protocols/http.md b/docs/protocols/http.md index c0a698c..0f7ce6c 100644 --- a/docs/protocols/http.md +++ b/docs/protocols/http.md @@ -1,17 +1,17 @@ # HTTP surface -| Route | Purpose | -| -------------------------------------------------- | ---------------------------------------------------------- | -| `GET /health` | liveness | -| `GET /ready` | readiness for the store, adapters and configured providers | -| `GET /.well-known/agent-commerce` | merchant, protocol and provider discovery | -| `GET /api/resources` | canonical resource list | -| `POST /api/resources/:id/invoke` | invoke; returns 402 when proof is absent | -| `GET /api/receipts`, `GET /api/events` | operator audit | +| Route | Purpose | +| -------------------------------------------------- | ------------------------------------------------------------- | +| `GET /health` | liveness | +| `GET /ready` | readiness for the store, adapters and configured providers | +| `GET /.well-known/agent-commerce` | merchant, protocol and provider discovery | +| `GET /api/resources` | canonical resource list | +| `POST /api/resources/:id/invoke` | invoke; returns 402 when proof is absent | +| `GET /api/receipts`, `GET /api/events` | operator audit | | `GET /api/payment-attempts` | operator payment ledger; `?uncertain=true` for reconciliation | -| `/mcp` | MCP Streamable HTTP when enabled | -| `/.well-known/agent-card.json`, `/a2a` | A2A discovery and JSON-RPC when enabled | -| `/.well-known/acp.json`, `/acp/checkout_sessions…` | ACP discovery and checkout when enabled | +| `/mcp` | MCP Streamable HTTP when enabled | +| `/.well-known/agent-card.json`, `/a2a` | A2A discovery and JSON-RPC when enabled | +| `/.well-known/acp.json`, `/acp/checkout_sessions…` | ACP discovery and checkout when enabled | The invoke route uses the selected rail's headers: @@ -43,3 +43,13 @@ retry challenge. Other outcomes: - A settled `PAYMENT-RESPONSE` includes `amount` in base units when the provider supplies it. Paid deliveries use `Cache-Control: private`; fresh 402 challenges use `no-store`. +- On a resource with [`idempotency.redelivery`](../configuration.md#resources) + (experimental), clients supply a retry key through the x402 + [`payment-identifier`](x402.md#payment-identifier) `id` or the MPP + [`Idempotency-Key`](mpp.md#idempotency-key) header. Redelivery returns the + stored status and body, relays the stored `Content-Type` under the + [backend response relay rules](../security.md#backend-response-relay), and + includes `PAYMENT-RESPONSE`, `Cache-Control: private` + and `Idempotent-Replayed: true`. MPP also includes `Payment-Receipt` on 2xx + redeliveries. Retry-key 409s omit the challenge; only an in-flight refusal + includes `Retry-After`. diff --git a/docs/protocols/mpp.md b/docs/protocols/mpp.md index 5981627..0c00e9d 100644 --- a/docs/protocols/mpp.md +++ b/docs/protocols/mpp.md @@ -23,10 +23,10 @@ then checked by the x402 settlement provider. Both checks run in `verify`, before replay reservation; settlement broadcasts later. For an HTTP request with a body, the challenge includes an RFC 9530 -`sha-256` digest of the original body bytes. The paid retry must send the same -bytes: even equivalent JSON with different spacing returns -`body_digest_mismatch`. MCP and A2A challenges omit the digest because their -payment proof travels in the request body. +`sha-256` digest of the original body bytes. During credential verification, +the paid request must send the same bytes: equivalent JSON with different +spacing returns `body_digest_mismatch`. MCP and A2A challenges omit the digest +because their payment proof travels in the request body. MPP and x402 derive the same replay identity for the same EIP-3009 authorization, so reuse across rails collides in one receipt store. @@ -64,3 +64,32 @@ Base mainnet deployments must satisfy the Unsupported variants are listed in `src/payments/mpp/descriptor.ts`: non-EVM methods, Permit2/transaction/hash credentials, splits, subscriptions, EVM sessions and the discovery extension. + +## Idempotency-Key + +Experimental and disabled by default. On a resource with +[`idempotency.redelivery`](../configuration.md#resources), the HTTP invoke +route reads the buyer's `Idempotency-Key` header. It accepts 1 to 255 printable +ASCII characters; invalid values return HTTP 400 `bad-request` with reason +`payment_identifier_invalid`. + +After delivery, reuse the key and input to retrieve the stored response without +another charge. An exact resend of `Authorization` skips verification, so an +expired challenge still works while the response is retained. A new credential +must answer a fresh challenge and verify as the same payer; it is not settled +again. + +Make the key random and keep it secret: with the original credential, it +unlocks the stored response. The MCP and A2A adapters do not extract this +header, and `redelivery.required` does not apply to MPP. + +Retry-key 409s use Problem Details with `type: about:blank` and title `Conflict`. +Using `invalid-challenge` could prompt a new challenge and another payment. +`details.reason` identifies the state: + +- `payment_identifier_conflict`: the key belongs to different input or another payer. +- `payment_identifier_in_flight`: a request with the key is still running. +- `payment_identifier_unresolved`: the earlier request may have been charged but has no + usable stored response. + +[http.md](http.md) lists the redelivery headers. diff --git a/src/core/domain/resource.ts b/src/core/domain/resource.ts index 57bfa89..970efd5 100644 --- a/src/core/domain/resource.ts +++ b/src/core/domain/resource.ts @@ -85,9 +85,9 @@ export interface CommerceResource { readonly required: readonly AuthorizationMethodName[]; }; /** - * Paid retry settings, disabled when omitted. With redelivery configured, - * x402 reads client keys from `payment-identifier`; MPP's `Idempotency-Key` - * support is planned. + * Paid retry settings, disabled when omitted. For redelivery, the x402 provider + * extracts `payment-identifier` IDs; the HTTP invoke route reads MPP's + * `Idempotency-Key` header. * Experimental: may change in a minor release. */ readonly idempotency?: { diff --git a/src/gateway/routes.ts b/src/gateway/routes.ts index 24c2524..1400eaf 100644 --- a/src/gateway/routes.ts +++ b/src/gateway/routes.ts @@ -346,6 +346,11 @@ async function handleInvoke( paymentValue !== undefined && paymentMethod !== undefined ? { method: paymentMethod, payload: paymentValue } : undefined; + // MPP carries its retry key in Idempotency-Key, separately from the credential + const retryKey = + paymentMethod === 'mpp' && resource.idempotency?.redelivery !== undefined + ? mppRetryKey(request, resourceId) + : undefined; // Its own header rather than a reserved body field: HTTP already carries // the payment proof out of band, and an authorization inside the body @@ -363,6 +368,7 @@ async function handleInvoke( protocol: 'http', receivedAt: options.clock.nowIso(), ...(payment !== undefined ? { payment } : {}), + ...(retryKey !== undefined ? { retryKey } : {}), ...(authorization !== undefined ? { authorization } : {}), ...(digest !== undefined ? { metadata: { [CONTENT_DIGEST_METADATA_KEY]: digest } } : {}), }; @@ -416,13 +422,22 @@ async function handleInvoke( encodeHeaderDocument(settlementResponse(outcome.payment)), ); const receipt = outcome.payment.metadata?.['receipt']; - if (outcome.payment.provider === 'mpp' && typeof receipt === 'string') { + // On redelivery, send Payment-Receipt only for 2xx responses. Preserve the + // original-delivery behavior for compatibility. + const success = outcome.backendStatus >= 200 && outcome.backendStatus < 300; + if ( + outcome.payment.provider === 'mpp' && + typeof receipt === 'string' && + (success || outcome.redelivered !== true) + ) { reply.header('payment-receipt', receipt); } // Payment headers identify this buyer's payment; MPP also requires // private caching for responses carrying `Payment-Receipt` reply.header('cache-control', 'private'); } + // Mark redelivery with the same replay header as ACP + if (outcome.redelivered === true) reply.header('idempotent-replayed', 'true'); const contentType = relayedContentType(outcome.headers?.['content-type'], outcome.body); if (contentType !== undefined) reply.type(contentType); if (typeof outcome.body === 'string') { @@ -473,7 +488,7 @@ const PROVIDER_UNAVAILABLE_CODES: ReadonlySet = new Set([ ]); const PROVIDER_RETRY_AFTER_SECONDS = 5; -// Codes whose MPP response body is a Problem Details document +// These codes and payment_identifier_ reasons produce MPP Problem Details const MPP_PROBLEM_CODES: ReadonlySet = new Set([ 'PAYMENT_INVALID', 'PAYMENT_REPLAYED', @@ -487,9 +502,15 @@ function sendInvokeError( paymentMethod: PaymentMethodName | undefined, ): void { const details = error.details; + const reason = typeof details?.['reason'] === 'string' ? details['reason'] : undefined; if (PROVIDER_UNAVAILABLE_CODES.has(error.code)) { reply.header('retry-after', String(PROVIDER_RETRY_AFTER_SECONDS)); } + // In-flight retry claims supply this delay so the client can wait for completion + const retryAfter = details?.['retryAfterSeconds']; + if (typeof retryAfter === 'number' && Number.isSafeInteger(retryAfter) && retryAfter >= 0) { + reply.header('retry-after', String(retryAfter)); + } // Report a settled payment even if backend delivery fails. MPP forbids a // `Payment-Receipt` header on error responses, so send only this summary const settled = settlementResponseFromDetails(details); @@ -519,11 +540,13 @@ function sendInvokeError( } const envelope = toErrorEnvelope(error); - if (paymentMethod === 'mpp' && MPP_PROBLEM_CODES.has(error.code)) { + if ( + paymentMethod === 'mpp' && + (MPP_PROBLEM_CODES.has(error.code) || reason?.startsWith('payment_identifier_') === true) + ) { // RFC 9457 `status` is the HTTP status, so it replaces the envelope's // `status: 'error'`; the other envelope fields stay as extension members const { status: _envelopeStatus, ...members } = envelope; - const reason = typeof details?.['reason'] === 'string' ? details['reason'] : undefined; reply .header('content-type', 'application/problem+json') .status(error.httpStatus) @@ -544,6 +567,21 @@ function sendInvokeError( reply.status(status).send(envelope); } +// Use ACP's printable-ASCII length limit. The key unlocks a stored response, +// so validation errors must not quote it. +const IDEMPOTENCY_KEY_PATTERN = /^[\x20-\x7e]{1,255}$/; + +function mppRetryKey(request: FastifyRequest, resourceId: string): string | undefined { + const value = request.headers['idempotency-key']; + if (value === undefined) return undefined; + if (typeof value === 'string' && IDEMPOTENCY_KEY_PATTERN.test(value)) return value; + throw new CommerceError( + 'INPUT_INVALID', + 'The Idempotency-Key header must be 1 to 255 printable ASCII characters', + { requestId: request.id, resourceId, details: { reason: 'payment_identifier_invalid' } }, + ); +} + // Each rail has its own proof header. MPP uses HTTP authentication, and an // `Authorization` value in another scheme is not a payment proof. function paymentProof( diff --git a/src/payments/mpp/problems.ts b/src/payments/mpp/problems.ts index cfb6b2d..a0242e3 100644 --- a/src/payments/mpp/problems.ts +++ b/src/payments/mpp/problems.ts @@ -1,7 +1,7 @@ /** - * Map MPP payment errors to the Problem Details types in - * `draft-httpauth-payment-01`. This module has no optional peer imports, so - * the main HTTP route can use it. + * Map MPP payment errors to Problem Details using `draft-httpauth-payment-01` + * types and generic retry-key conflicts. No optional peers are imported, so + * the main HTTP route can use this module. */ export const MPP_PROBLEM_BASE_URI = 'https://paymentauth.org/problems/'; @@ -12,7 +12,8 @@ export type MppProblemType = | 'invalid-challenge' | 'payment-expired' | 'verification-failed' - | 'internal-payment-error'; + | 'internal-payment-error' + | 'bad-request'; const TITLES: Readonly> = { 'payment-required': 'Payment Required', @@ -21,6 +22,7 @@ const TITLES: Readonly> = { 'payment-expired': 'Payment Expired', 'verification-failed': 'Verification Failed', 'internal-payment-error': 'Internal Payment Error', + 'bad-request': 'Bad Request', }; // Map known MPP refusal reasons to problem types; other reasons use @@ -35,19 +37,25 @@ const REASON_TYPES: ReadonlyMap = new Map([ ['challenge_already_used', 'invalid-challenge'], ['challenge_expired', 'payment-expired'], ['authorization_expired', 'payment-expired'], + ['payment_identifier_invalid', 'bad-request'], ]); +// Use a generic conflict so clients do not treat a retry key's refusal as an +// invalid challenge and risk paying again with a new key +const RETRY_KEY_CONFLICT = { type: 'about:blank', title: 'Conflict' } as const; + /** The Problem Details `type` URI and `title` for an MPP payment error */ export function mppProblem( code: string, httpStatus: number, reason?: string, ): { readonly type: string; readonly title: string } { + if (httpStatus === 409 && reason?.startsWith('payment_identifier_')) return RETRY_KEY_CONFLICT; const problem = problemType(code, httpStatus, reason); return { type: `${MPP_PROBLEM_BASE_URI}${problem}`, title: TITLES[problem] }; } -// Descriptions for MPP reasons and two x402 facilitator refusals +// Descriptions for MPP, retry-key and x402 facilitator refusal reasons const REASON_DETAILS: ReadonlyMap = new Map([ ['wrong_provider', 'The challenge was issued for another payment method.'], ['missing_challenge', 'No challenge is available for this request.'], @@ -83,6 +91,19 @@ const REASON_DETAILS: ReadonlyMap = new Map([ 'invalid_exact_evm_transaction_simulation_failed', 'The transfer failed in simulation, so it was not submitted.', ], + [ + 'payment_identifier_invalid', + 'The Idempotency-Key header must be 1 to 255 printable ASCII characters.', + ], + [ + 'payment_identifier_conflict', + 'The Idempotency-Key belongs to a request with other input or another payer.', + ], + ['payment_identifier_in_flight', 'A request with this Idempotency-Key is still in progress.'], + [ + 'payment_identifier_unresolved', + 'The earlier request with this Idempotency-Key may have been charged and has no stored response.', + ], ]); /** Return a sentence for a known reason, or quote an unknown reason */ diff --git a/tests/integration/payment-retries.test.ts b/tests/integration/payment-retries.test.ts new file mode 100644 index 0000000..f624560 --- /dev/null +++ b/tests/integration/payment-retries.test.ts @@ -0,0 +1,627 @@ +/** + * Exercise HTTP retries through the gateway, x402 and MPP providers, and SQLite. + * An injected backend controls delivery; a mocked x402 facilitator client + * counts verification and settlement calls without contacting a chain. + */ +import { Challenge } from 'mppx'; +import { charge as clientCharge } from 'mppx/evm/client'; +import { generatePrivateKey, type PrivateKeyAccount, privateKeyToAccount } from 'viem/accounts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { GatewayConfig } from '../../src/config'; +import { + type BackendExecutor, + type BackendRequest, + type BackendResponse, + type Clock, + type CommerceResource, + PAYMENT_HEADER, + PAYMENT_REQUIRED_HEADER, + PAYMENT_RESPONSE_HEADER, +} from '../../src/core'; +import { createGateway, type GatewayInstance } from '../../src/gateway'; +import { createMppPaymentProvider } from '../../src/payments/mpp/provider'; +import { createPaymentProof } from '../../src/payments/x402/client'; +import { createX402PaymentProvider } from '../../src/payments/x402/provider'; +import { createSqliteReceiptStore } from '../../src/storage/receipts'; + +process.env['NODE_ENV'] = 'test'; + +interface SignedPayload { + readonly payload: { readonly authorization: { readonly from: string } }; +} + +const facilitator = vi.hoisted(() => ({ verify: vi.fn(), settle: vi.fn() })); +vi.mock('@x402/core/http', async (importOriginal) => ({ + ...(await importOriginal()), + HTTPFacilitatorClient: class { + verify(...args: unknown[]) { + return facilitator.verify(...args); + } + settle(...args: unknown[]) { + return facilitator.settle(...args); + } + }, +})); + +const TX = `0x${'cd'.repeat(32)}`; +const ASSET = '0x1111111111111111111111111111111111111111' as const; +const AUTHORIZATION = { name: 'MockUSDC', version: '2' }; +const PAY_TO = privateKeyToAccount(generatePrivateKey()).address; +const BUYER_KEY = generatePrivateKey(); +const buyer = privateKeyToAccount(BUYER_KEY); +const otherBuyer = privateKeyToAccount(generatePrivateKey()); +const REMOTE = { + mode: 'remote', + url: 'https://facilitator.example.com', + auth: { type: 'none' }, +} as const; +const RPC_URL = 'http://127.0.0.1:19321'; // never contacted: the facilitator client is mocked + +const ID = 'purchase-0123456789abcdef'; +const KEY = 'order-42-retry'; +const REPORT = { report: 'paid' }; +const OK: BackendResponse = { status: 200, body: REPORT, headers: {}, durationMs: 1 }; + +type EvmChallenge = Parameters['createCredential']>[0]['challenge']; + +function resource( + id: string, + paymentMethods: CommerceResource['paymentMethods'], + idempotency?: CommerceResource['idempotency'], +): CommerceResource { + return { + id, + name: id, + description: 'A paid report.', + inputSchema: { + type: 'object', + properties: { city: { type: 'string' } }, + additionalProperties: false, + }, + handler: { type: 'http', method: 'POST', url: 'http://backend.local/report' }, + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + exposedVia: ['http'], + paymentMethods, + ...(idempotency !== undefined ? { idempotency } : {}), + }; +} + +const OPTIONAL = { redelivery: { retentionHours: 24, required: false } }; +const RESOURCES = [ + resource('x402_report', ['x402'], OPTIONAL), + resource('x402_required', ['x402'], { redelivery: { retentionHours: 24, required: true } }), + resource('mpp_report', ['mpp'], OPTIONAL), + resource('mpp_plain', ['mpp']), +]; + +// Tests initialize MPP's expiry clock at setup and advance it explicitly +let mppNowMs = 0; +const mppClock: Clock = { + now: () => new Date(mppNowMs), + nowIso: () => new Date(mppNowMs).toISOString(), + monotonicMs: () => mppNowMs, +}; + +let backendResponse: BackendResponse = OK; +let backendFails = false; +let hold: Promise | undefined; +let backendCalls: BackendRequest[] = []; +const backend: BackendExecutor = { + async call(_handler, request) { + backendCalls.push(request); + await hold; + if (backendFails) throw new Error('merchant down'); + return backendResponse; + }, +}; + +let gateways: GatewayInstance[] = []; +let gateway: GatewayInstance; + +beforeEach(async () => { + mppNowMs = Date.now(); + backendResponse = OK; + backendFails = false; + hold = undefined; + backendCalls = []; + const payerOf = (payload: SignedPayload) => payload.payload.authorization.from; + facilitator.verify.mockReset().mockImplementation(async (payload: SignedPayload) => ({ + isValid: true, + payer: payerOf(payload), + })); + facilitator.settle.mockReset().mockImplementation(async (payload: SignedPayload) => ({ + success: true, + transaction: TX, + network: 'eip155:31337', + payer: payerOf(payload), + })); + gateway = await startGateway(RESOURCES); +}); + +afterEach(async () => { + await Promise.all(gateways.map((instance) => instance.close().catch(() => {}))); + gateways = []; +}); + +function config(resources: readonly CommerceResource[]): GatewayConfig { + return { + version: 1, + merchant: { id: 'shop', name: 'Shop', publicBaseUrl: 'https://gw.example' }, + server: { port: 0, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { + http: { enabled: true }, + mcp: { enabled: false, mountPath: '/mcp' }, + a2a: { enabled: false, mountPath: '/a2a' }, + acp: { enabled: false, mountPath: '/acp' }, + }, + resources: [...resources], + payments: {}, + }; +} + +async function startGateway(resources: readonly CommerceResource[]): Promise { + const instance = await createGateway({ + config: config(resources), + store: createSqliteReceiptStore({ path: ':memory:' }), + paymentProviders: [ + createX402PaymentProvider({ + network: 'eip155:31337', + rpcUrl: RPC_URL, + asset: ASSET, + assetName: AUTHORIZATION.name, + assetVersion: AUTHORIZATION.version, + assetDecimals: 6, + payTo: PAY_TO, + facilitator: REMOTE, + }), + createMppPaymentProvider({ + network: 'eip155:31337', + recipient: PAY_TO, + asset: ASSET, + assetName: AUTHORIZATION.name, + assetVersion: AUTHORIZATION.version, + realm: 'gateway.test', + challengeSecret: 's'.repeat(32), + rpcUrl: RPC_URL, + facilitator: REMOTE, + clock: mppClock, + }), + ], + protocolAdapters: [], + backend, + }); + gateways.push(instance); + return instance; +} + +function invoke( + resourceId: string, + headers: Record = {}, + body = '{}', + instance: GatewayInstance = gateway, +) { + return instance.server.inject({ + method: 'POST', + url: `/api/resources/${resourceId}/invoke`, + headers: { 'content-type': 'application/json', ...headers }, + payload: body, + }); +} + +function decodeHeader(value: unknown): Record { + return JSON.parse(Buffer.from(String(value), 'base64').toString('utf8')); +} + +// Sign a fresh x402 challenge, echoing its declaration with `id` when supplied +async function x402Proof(resourceId: string, id?: string, body = '{}'): Promise { + const required = decodeHeader( + (await invoke(resourceId, {}, body)).headers[PAYMENT_REQUIRED_HEADER], + ); + const accepts = (required['accepts'] as Record[])[0] as Record; + const declared = (required['extensions'] as Record)[ + 'payment-identifier' + ] as { info: object }; + return createPaymentProof({ + buyerPrivateKey: BUYER_KEY, + accepts, + ...(id !== undefined + ? { extensions: { 'payment-identifier': { ...declared, info: { ...declared.info, id } } } } + : {}), + }); +} + +// Answer a fresh MPP challenge +async function mppCredential( + resourceId: string, + account: PrivateKeyAccount = buyer, + instance: GatewayInstance = gateway, +): Promise { + const challenged = await invoke(resourceId, {}, '{}', instance); + const challenge = Challenge.deserialize( + String(challenged.headers['www-authenticate']), + ) as unknown as EvmChallenge; + const client = clientCharge({ account, authorization: AUTHORIZATION }); + return String(await client.createCredential({ challenge, context: {} })); +} + +// Hold the backend until the returned function runs +function holdBackend(): () => void { + let release = () => {}; + hold = new Promise((resolve) => { + release = resolve; + }); + return release; +} + +describe('x402 retries over HTTP', () => { + it('redelivers an exact resend, marked as a replay, without a second charge', async () => { + const proof = await x402Proof('x402_report', ID); + + const first = await invoke('x402_report', { [PAYMENT_HEADER]: proof }); + const resend = await invoke('x402_report', { [PAYMENT_HEADER]: proof }); + + expect(first.statusCode).toBe(200); + expect(first.headers['idempotent-replayed']).toBeUndefined(); + expect(resend.statusCode).toBe(200); + expect(resend.json()).toEqual(REPORT); + expect(resend.headers['idempotent-replayed']).toBe('true'); + expect(resend.headers['cache-control']).toBe('private'); + expect(resend.headers['content-type']).toBe('application/json; charset=utf-8'); + expect(decodeHeader(resend.headers[PAYMENT_RESPONSE_HEADER])).toEqual( + decodeHeader(first.headers[PAYMENT_RESPONSE_HEADER]), + ); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + expect(backendCalls).toHaveLength(1); + }); + + it('redelivers to a fresh proof from the same payer after verifying it', async () => { + const first = await invoke('x402_report', { + [PAYMENT_HEADER]: await x402Proof('x402_report', ID), + }); + + const retry = await invoke('x402_report', { + [PAYMENT_HEADER]: await x402Proof('x402_report', ID), + }); + + expect(first.statusCode).toBe(200); + expect(retry.statusCode).toBe(200); + expect(retry.json()).toEqual(REPORT); + expect(retry.headers['idempotent-replayed']).toBe('true'); + expect(facilitator.verify).toHaveBeenCalledTimes(2); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + }); + + it('redelivers a text body with its type and sandbox policy, and no Set-Cookie', async () => { + backendResponse = { + status: 200, + body: '

report

', + headers: { 'content-type': 'text/html', 'set-cookie': 'session=secret' }, + durationMs: 1, + }; + const proof = await x402Proof('x402_report', ID); + await invoke('x402_report', { [PAYMENT_HEADER]: proof }); + + const resend = await invoke('x402_report', { [PAYMENT_HEADER]: proof }); + + expect(resend.statusCode).toBe(200); + expect(resend.body).toBe('

report

'); + expect(resend.headers['idempotent-replayed']).toBe('true'); + expect(resend.headers['content-type']).toBe('text/html; charset=utf-8'); + expect(resend.headers['content-security-policy']).toBe('sandbox'); + expect(resend.headers['set-cookie']).toBeUndefined(); + }); + + it('refuses a payment without an id when the resource requires one', async () => { + const proof = await x402Proof('x402_required'); + + const refused = await invoke('x402_required', { [PAYMENT_HEADER]: proof }); + + expect(refused.statusCode).toBe(400); + expect(refused.json()).toMatchObject({ + status: 'error', + code: 'INPUT_INVALID', + retryable: false, + details: { reason: 'payment_identifier_required' }, + }); + expect(refused.headers[PAYMENT_REQUIRED_HEADER]).toBeUndefined(); + expect(facilitator.verify).not.toHaveBeenCalled(); + expect(backendCalls).toHaveLength(0); + }); + + it('refuses a malformed id before verification', async () => { + const proof = await x402Proof('x402_report', 'short-id'); + + const refused = await invoke('x402_report', { [PAYMENT_HEADER]: proof }); + + expect(refused.statusCode).toBe(400); + expect(refused.json()).toMatchObject({ + code: 'INPUT_INVALID', + details: { reason: 'payment_identifier_invalid' }, + }); + expect(refused.body).not.toContain('short-id'); + expect(facilitator.verify).not.toHaveBeenCalled(); + }); + + it('answers the same id with other input with a 409 and no challenge', async () => { + const oslo = JSON.stringify({ city: 'Oslo' }); + const rome = JSON.stringify({ city: 'Rome' }); + await invoke( + 'x402_report', + { [PAYMENT_HEADER]: await x402Proof('x402_report', ID, oslo) }, + oslo, + ); + + const conflict = await invoke( + 'x402_report', + { [PAYMENT_HEADER]: await x402Proof('x402_report', ID, rome) }, + rome, + ); + + expect(conflict.statusCode).toBe(409); + expect(conflict.json()).toMatchObject({ + status: 'error', + code: 'PAYMENT_REPLAYED', + retryable: false, + details: { reason: 'payment_identifier_conflict' }, + }); + expect(conflict.headers['retry-after']).toBeUndefined(); + expect(conflict.headers[PAYMENT_REQUIRED_HEADER]).toBeUndefined(); + expect(conflict.headers['www-authenticate']).toBeUndefined(); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + }); + + it('answers a request in flight with Retry-After and retryable', async () => { + const release = holdBackend(); + const proof = await x402Proof('x402_report', ID); + const first = invoke('x402_report', { [PAYMENT_HEADER]: proof }); + await vi.waitFor(() => expect(backendCalls).toHaveLength(1)); + + const busy = await invoke('x402_report', { [PAYMENT_HEADER]: proof }); + release(); + + expect(busy.statusCode).toBe(409); + expect(busy.headers['retry-after']).toBe('1'); + expect(busy.json()).toMatchObject({ + code: 'PAYMENT_REPLAYED', + retryable: true, + details: { reason: 'payment_identifier_in_flight', retryAfterSeconds: 1 }, + }); + expect(busy.headers[PAYMENT_REQUIRED_HEADER]).toBeUndefined(); + expect((await first).statusCode).toBe(200); + }); + + it('answers a key with an unknown outcome with a 409, no Retry-After and no challenge', async () => { + facilitator.settle.mockRejectedValueOnce(new Error('connection reset')); + const proof = await x402Proof('x402_report', ID); + await invoke('x402_report', { [PAYMENT_HEADER]: proof }); + + const unresolved = await invoke('x402_report', { + [PAYMENT_HEADER]: await x402Proof('x402_report', ID), + }); + + expect(unresolved.statusCode).toBe(409); + expect(unresolved.json()).toMatchObject({ + code: 'PAYMENT_REPLAYED', + retryable: false, + details: { reason: 'payment_identifier_unresolved' }, + }); + expect(unresolved.headers['retry-after']).toBeUndefined(); + expect(unresolved.headers[PAYMENT_REQUIRED_HEADER]).toBeUndefined(); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + }); + + it('ignores an Idempotency-Key header on the x402 rail', async () => { + const proof = await x402Proof('x402_report'); + + const paid = await invoke('x402_report', { [PAYMENT_HEADER]: proof, 'idempotency-key': '' }); + const resend = await invoke('x402_report', { [PAYMENT_HEADER]: proof, 'idempotency-key': KEY }); + + expect(paid.statusCode).toBe(200); + // Idempotency-Key does not enable redelivery for x402 + expect(resend.statusCode).toBe(402); + expect(resend.json()).toMatchObject({ code: 'PAYMENT_REPLAYED' }); + expect(resend.headers['idempotent-replayed']).toBeUndefined(); + }); +}); + +describe('MPP retries over HTTP', () => { + it('redelivers an exact resend after its challenge expired, with the receipt', async () => { + const credential = await mppCredential('mpp_report'); + const first = await invoke('mpp_report', { authorization: credential, 'idempotency-key': KEY }); + mppNowMs += 301_000; + + const withoutKey = await invoke('mpp_report', { authorization: credential }); + const resend = await invoke('mpp_report', { + authorization: credential, + 'idempotency-key': KEY, + }); + + expect(first.statusCode).toBe(200); + expect(first.headers['payment-receipt']).toEqual(expect.any(String)); + expect(withoutKey.json()).toMatchObject({ + type: 'https://paymentauth.org/problems/payment-expired', + }); + expect(resend.statusCode).toBe(200); + expect(resend.json()).toEqual(REPORT); + expect(resend.headers['payment-receipt']).toBe(first.headers['payment-receipt']); + expect(resend.headers['cache-control']).toBe('private'); + expect(resend.headers['idempotent-replayed']).toBe('true'); + expect(resend.headers[PAYMENT_RESPONSE_HEADER]).toEqual(expect.any(String)); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + // The client key never reaches the merchant + expect(JSON.stringify(backendCalls)).not.toContain(KEY); + }); + + it('redelivers to a fresh credential for a new challenge', async () => { + await invoke('mpp_report', { + authorization: await mppCredential('mpp_report'), + 'idempotency-key': KEY, + }); + mppNowMs += 301_000; + + const retry = await invoke('mpp_report', { + authorization: await mppCredential('mpp_report'), + 'idempotency-key': KEY, + }); + + expect(retry.statusCode).toBe(200); + expect(retry.json()).toEqual(REPORT); + expect(retry.headers['payment-receipt']).toEqual(expect.any(String)); + expect(retry.headers['idempotent-replayed']).toBe('true'); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + }); + + it('sends no Payment-Receipt when the redelivered status is not a success', async () => { + backendResponse = { status: 404, body: { error: 'gone' }, headers: {}, durationMs: 1 }; + const credential = await mppCredential('mpp_report'); + const first = await invoke('mpp_report', { authorization: credential, 'idempotency-key': KEY }); + + const resend = await invoke('mpp_report', { + authorization: credential, + 'idempotency-key': KEY, + }); + + // Preserve the original delivery's Payment-Receipt even for a non-2xx status + expect(first.headers['payment-receipt']).toEqual(expect.any(String)); + expect(resend.statusCode).toBe(404); + expect(resend.json()).toEqual({ error: 'gone' }); + expect(resend.headers['idempotent-replayed']).toBe('true'); + expect(resend.headers['cache-control']).toBe('private'); + expect(resend.headers['payment-receipt']).toBeUndefined(); + }); + + it.each([ + ['an empty key', ''], + ['256 characters', 'k'.repeat(256)], + ['a control character', 'order\t42'], + ])('refuses %s as bad-request Problem Details', async (_case, key) => { + const refused = await invoke('mpp_report', { 'idempotency-key': key }); + + expect(refused.statusCode).toBe(400); + expect(refused.headers['content-type']).toMatch(/^application\/problem\+json/); + expect(refused.json()).toMatchObject({ + type: 'https://paymentauth.org/problems/bad-request', + title: 'Bad Request', + status: 400, + code: 'INPUT_INVALID', + details: { reason: 'payment_identifier_invalid' }, + }); + expect(refused.headers['www-authenticate']).toBeUndefined(); + }); + + it.each([ + ['1', 'k'], + ['255', 'k'.repeat(255)], + ])('accepts a %s-character key', async (_length, key) => { + const challenged = await invoke('mpp_report', { 'idempotency-key': key }); + + expect(challenged.statusCode).toBe(402); + }); + + it('answers a key used by another payer with about:blank, never invalid-challenge', async () => { + await invoke('mpp_report', { + authorization: await mppCredential('mpp_report'), + 'idempotency-key': KEY, + }); + + const conflict = await invoke('mpp_report', { + authorization: await mppCredential('mpp_report', otherBuyer), + 'idempotency-key': KEY, + }); + + expect(conflict.statusCode).toBe(409); + expect(conflict.headers['content-type']).toMatch(/^application\/problem\+json/); + expect(conflict.json()).toMatchObject({ + type: 'about:blank', + title: 'Conflict', + status: 409, + code: 'PAYMENT_REPLAYED', + retryable: false, + details: { reason: 'payment_identifier_conflict' }, + }); + expect(conflict.body).not.toContain('invalid-challenge'); + expect(conflict.headers['www-authenticate']).toBeUndefined(); + expect(conflict.headers['retry-after']).toBeUndefined(); + expect(conflict.headers['payment-receipt']).toBeUndefined(); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + }); + + it('answers a request in flight as Problem Details with Retry-After', async () => { + const release = holdBackend(); + const credential = await mppCredential('mpp_report'); + const first = invoke('mpp_report', { authorization: credential, 'idempotency-key': KEY }); + await vi.waitFor(() => expect(backendCalls).toHaveLength(1)); + + const busy = await invoke('mpp_report', { authorization: credential, 'idempotency-key': KEY }); + release(); + + expect(busy.statusCode).toBe(409); + expect(busy.headers['retry-after']).toBe('1'); + expect(busy.json()).toMatchObject({ + type: 'about:blank', + title: 'Conflict', + retryable: true, + details: { reason: 'payment_identifier_in_flight' }, + }); + expect(busy.headers['www-authenticate']).toBeUndefined(); + expect((await first).statusCode).toBe(200); + }); + + it('answers a key with an unknown outcome as Problem Details without Retry-After', async () => { + // MPP settles before the backend, so this failure leaves a charge behind + backendFails = true; + const credential = await mppCredential('mpp_report'); + await invoke('mpp_report', { authorization: credential, 'idempotency-key': KEY }); + + const unresolved = await invoke('mpp_report', { + authorization: credential, + 'idempotency-key': KEY, + }); + + expect(unresolved.statusCode).toBe(409); + expect(unresolved.json()).toMatchObject({ + type: 'about:blank', + title: 'Conflict', + retryable: false, + details: { reason: 'payment_identifier_unresolved' }, + }); + expect(unresolved.headers['retry-after']).toBeUndefined(); + expect(unresolved.headers['www-authenticate']).toBeUndefined(); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + }); + + it('ignores the header on a resource that did not opt in', async () => { + const malformed = await invoke('mpp_plain', { 'idempotency-key': '' }); + const credential = await mppCredential('mpp_plain'); + await invoke('mpp_plain', { authorization: credential, 'idempotency-key': KEY }); + + const resend = await invoke('mpp_plain', { authorization: credential, 'idempotency-key': KEY }); + + expect(malformed.statusCode).toBe(402); + expect(resend.statusCode).toBe(402); + expect(resend.json()).toMatchObject({ code: 'PAYMENT_REPLAYED' }); + expect(resend.headers['idempotent-replayed']).toBeUndefined(); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + }); + + it('leaves the buyer challenge unchanged and keeps the extension from the facilitator', async () => { + const plain = await startGateway([resource('mpp_report', ['mpp'])]); + + const optedIn = await invoke('mpp_report'); + const notOptedIn = await invoke('mpp_report', {}, '{}', plain); + const paid = await invoke('mpp_report', { + authorization: await mppCredential('mpp_report'), + 'idempotency-key': KEY, + }); + + expect(optedIn.headers['www-authenticate']).toMatch(/^Payment /); + expect(optedIn.headers['www-authenticate']).toBe(notOptedIn.headers['www-authenticate']); + expect(optedIn.body).not.toContain('payment-identifier'); + // The internal x402 requirement declares the extension; its proof echoes none + expect(paid.statusCode).toBe(200); + expect( + JSON.stringify([facilitator.verify.mock.calls, facilitator.settle.mock.calls]), + ).not.toContain('payment-identifier'); + }); +}); diff --git a/tests/unit/payments-mpp/problems.test.ts b/tests/unit/payments-mpp/problems.test.ts index bb4e975..9c6c187 100644 --- a/tests/unit/payments-mpp/problems.test.ts +++ b/tests/unit/payments-mpp/problems.test.ts @@ -20,6 +20,7 @@ describe('mppProblem', () => { ['PAYMENT_SETTLEMENT_FAILED', 402, 'insufficient_funds', 'verification-failed'], ['PAYMENT_SETTLEMENT_FAILED', 502, undefined, 'internal-payment-error'], ['PAYMENT_PROVIDER_UNAVAILABLE', 503, undefined, 'internal-payment-error'], + ['INPUT_INVALID', 400, 'payment_identifier_invalid', 'bad-request'], ] as const)('%s %i %s -> %s', (code, status, reason, expected) => { expect(mppProblem(code, status, reason).type).toBe(type(expected)); }); @@ -32,6 +33,23 @@ describe('mppProblem', () => { }); }); + it.each([ + 'payment_identifier_conflict', + 'payment_identifier_in_flight', + 'payment_identifier_unresolved', + ])('answers the retry-key 409 %s with about:blank, not invalid-challenge', (reason) => { + expect(mppProblem('PAYMENT_REPLAYED', 409, reason)).toEqual({ + type: 'about:blank', + title: 'Conflict', + }); + }); + + it('titles a bad retry key Bad Request', () => { + expect(mppProblem('INPUT_INVALID', 400, 'payment_identifier_invalid').title).toBe( + 'Bad Request', + ); + }); + it('titles the unpaid challenge Payment Required', () => { expect(mppProblem('PAYMENT_REQUIRED', 402).title).toBe('Payment Required'); }); @@ -44,6 +62,24 @@ describe('mppProblemDetail', () => { ); }); + it.each([ + [ + 'payment_identifier_invalid', + 'The Idempotency-Key header must be 1 to 255 printable ASCII characters.', + ], + [ + 'payment_identifier_conflict', + 'The Idempotency-Key belongs to a request with other input or another payer.', + ], + ['payment_identifier_in_flight', 'A request with this Idempotency-Key is still in progress.'], + [ + 'payment_identifier_unresolved', + 'The earlier request with this Idempotency-Key may have been charged and has no stored response.', + ], + ])('describes the retry-key reason %s', (reason, sentence) => { + expect(mppProblemDetail(reason, 'fallback')).toBe(sentence); + }); + it('quotes a reason it has no sentence for, without reading Object.prototype', () => { expect(mppProblemDetail('insufficient_funds', 'fallback')).toBe( 'The payment was refused: insufficient_funds.', From 3cab627d7adfe759aa65b6bbeda82c87157d850f Mon Sep 17 00:00:00 2001 From: Revinand Date: Sat, 7 Nov 2026 17:56:39 +0100 Subject: [PATCH 07/20] feat(protocols): map payment retries on MCP and A2A --- docs/contracts.md | 7 +- docs/protocols/a2a.md | 25 +- docs/protocols/mcp.md | 10 + docs/protocols/x402.md | 6 +- src/core/domain/wire.ts | 2 + src/core/execution/pipeline/retry.ts | 3 +- src/protocols/a2a/payment-resume.ts | 4 + src/protocols/a2a/x402-extension.ts | 12 +- tests/integration/a2a-x402-extension.test.ts | 1 + tests/integration/payment-retries.test.ts | 450 +++++++++++++++++- .../unit/core/domain/delivery-summary.test.ts | 12 +- .../unit/protocols-a2a/x402-extension.test.ts | 24 + 12 files changed, 522 insertions(+), 34 deletions(-) diff --git a/docs/contracts.md b/docs/contracts.md index b77017f..159d670 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -235,9 +235,8 @@ persistence must not fail the commerce flow. resources, stores and providers need no changes. The pipeline supports merchant keys and client retry keys; SQLite implements claim lookup and recovery filtering. The x402 provider extracts `payment-identifier` IDs; - the HTTP invoke route reads MPP's `Idempotency-Key` into `retryKey`. - Mapping `redelivered` to `DeliverySummary` remains planned. Direct pipeline - requests and custom provider hooks can also supply keys. + the HTTP invoke route reads MPP's `Idempotency-Key` into `retryKey`. Direct + pipeline requests and custom provider hooks can also supply keys. - `CommerceResource.idempotency` configures paid retries. With `merchantKey` enabled, the pipeline uses a derived backend `Idempotency-Key` for the paid operation; an adapter's key takes precedence. @@ -259,6 +258,8 @@ persistence must not fail the commerce flow. endpoints. The ACP adapter passes `/` when recovering receipt-store claims. - `redelivered` on `DeliveredOutcome` and `DeliverySummary` indicates that the request reused a stored response without another charge. + `toDeliverySummary` includes the field only when true; original delivery + summaries omit it. - `persistenceFailures` gains `response` when a delivered response cannot be stored for its client key. The pipeline attempts to mark the claim `unresolved`; in-flight and unresolved claims refuse retries with 409. diff --git a/docs/protocols/a2a.md b/docs/protocols/a2a.md index 23248fb..e08ff95 100644 --- a/docs/protocols/a2a.md +++ b/docs/protocols/a2a.md @@ -126,6 +126,16 @@ in a data part as `{"data": {"input": {"_authorization": {...}}}}`. It is verified against the stored purchase, and the rest of that part is ignored. A mandate from the first message takes precedence. +On a resource with [`idempotency.redelivery`](../configuration.md#resources) +(experimental), `x402.payment.required` declares the +[payment identifier](x402.md#payment-identifier). Each pending task accepts one +payment submission; a resend returns `TaskNotFoundError`. To retry, start a new +task with the same input and submit a proof with the same `id`. Redelivery +completes the new task with `payment-completed` and the original settlement +entry, without another settlement. A refusal fails the task and puts its +`payment_identifier_*` reason in `x402.payment.error`. An in-flight refusal +also consumes the task, so retry on another new task. + The extension applies only to x402. An MPP resource still returns a terminal task. @@ -139,18 +149,19 @@ These outcomes return a terminal task with one artifact: | Payment required on terminal path | `TASK_STATE_FAILED` | Shared payment-required envelope | | Commerce failure | `TASK_STATE_FAILED` | Shared error envelope | -Every `TASK_STATE_FAILED` task has a `ROLE_AGENT` status message whose text is -the envelope's `message`; after an extension payment, the text summarizes the -payment outcome instead. Only extension status messages carry `metadata` and -`extensions`. +Every `TASK_STATE_FAILED` task has a `ROLE_AGENT` status message. Ordinary +failures and `payment_identifier_*` refusals use the envelope's `message` as +their text. Other extension payment failures summarize the payment outcome. +Only extension status messages carry `metadata` and `extensions`. With the x402 extension, an unpaid task is `TASK_STATE_INPUT_REQUIRED`; its payment terms are in the status message metadata. If the client declines, the task ends with no artifact. -A non-object merchant body is wrapped as `{ "value": ... }`. Without the x402 -extension, payment required is terminal; the buyer sends a new message with -the proof. +Redelivery sets `redelivered: true` in the artifact's `agent-commerce/delivery` +summary. A non-object merchant body is wrapped as `{ "value": ... }`. Without +the x402 extension, payment required is terminal; the buyer sends a new message +with the proof. The adapter returns JSON-RPC errors for invalid requests and A2A features it cannot serve. Alongside `-32700`, `-32600`, `-32601`, `-32602` and `-32603`, it diff --git a/docs/protocols/mcp.md b/docs/protocols/mcp.md index 2b6eb4d..e4c2b87 100644 --- a/docs/protocols/mcp.md +++ b/docs/protocols/mcp.md @@ -97,6 +97,16 @@ settlement failure or a backend failure after settlement also includes `_meta["x402/payment-response"]`. Every failure retains the gateway's error envelope in `structuredContent`. +On a resource with [`idempotency.redelivery`](../configuration.md#resources) +(experimental), x402 can redeliver a stored result for the same +[payment identifier](x402.md#payment-identifier) and input. The result includes +the original `_meta["x402/payment-response"]` and sets `redelivered: true` in +its `agent-commerce/delivery` summary. Refusals with a `payment_identifier_*` +reason return the error envelope without a challenge or `_meta["x402/error"]`. +Only an in-flight refusal sets `retryable: true` and `details.retryAfterSeconds`. +MPP calls carry no retry key +([mpp.md](mpp.md#idempotency-key)). + For payment-required and error results, `content[0].text` is a JSON copy of `structuredContent`; `content[1].text` is a sentence for readers. Unexpected exception messages and stack traces are omitted. diff --git a/docs/protocols/x402.md b/docs/protocols/x402.md index 36ab053..628d6d5 100644 --- a/docs/protocols/x402.md +++ b/docs/protocols/x402.md @@ -125,8 +125,10 @@ Experimental and disabled by default. A resource with [`idempotency.redelivery`](../configuration.md#resources) declares x402's `payment-identifier` extension in its challenge, setting `info.required` from `redelivery.required`. To retrieve a stored response without another charge, -add an `id` to the echoed `info` and reuse it with the same input. Resend the -same proof or a new verified proof from the same payer. +add an `id` to the echoed `info` and reuse it with the same input. An exact +resend of the proof skips verification while the response is retained, even +if the wallet can no longer cover the price. A new proof must verify as the +same payer and may be rejected without another charge. Keep one `id` per purchase. The SDK's `appendPaymentIdentifierToExtensions` generates a new ID on each call without an explicit `id`. Supply the original diff --git a/src/core/domain/wire.ts b/src/core/domain/wire.ts index afc1584..a53fe9f 100644 --- a/src/core/domain/wire.ts +++ b/src/core/domain/wire.ts @@ -200,6 +200,8 @@ export function toDeliverySummary(outcome: DeliveredOutcome): DeliverySummary { }, } : {}), + // Omit the flag from original deliveries to preserve the existing summary shape + ...(outcome.redelivered === true ? { redelivered: true } : {}), }; } diff --git a/src/core/execution/pipeline/retry.ts b/src/core/execution/pipeline/retry.ts index a0df37e..a0dd9b7 100644 --- a/src/core/execution/pipeline/retry.ts +++ b/src/core/execution/pipeline/retry.ts @@ -103,7 +103,8 @@ const REFUSALS = { }, unresolved: { reason: 'payment_identifier_unresolved', - message: 'The outcome of the request with this payment identifier is unknown', + message: + 'The earlier request with this payment identifier may have been charged and has no stored response', }, } as const; diff --git a/src/protocols/a2a/payment-resume.ts b/src/protocols/a2a/payment-resume.ts index 37b214e..09a3f03 100644 --- a/src/protocols/a2a/payment-resume.ts +++ b/src/protocols/a2a/payment-resume.ts @@ -159,9 +159,13 @@ async function payPending( ); } const metadata = paymentFailureMetadata(error, pending.network); + const reason = error.details?.['reason']; let text = 'Payment failed.'; if (metadata[X402_PAYMENT_STATUS_KEY] === 'payment-completed') { text = 'Payment settled, but the resource could not be delivered.'; + } else if (typeof reason === 'string' && reason.startsWith('payment_identifier_')) { + // Retry-key refusals make no new charge; show the specific error message + text = error.message; } else if (!error.code.startsWith('PAYMENT_')) { text = 'The resource could not be delivered.'; } diff --git a/src/protocols/a2a/x402-extension.ts b/src/protocols/a2a/x402-extension.ts index 31052b4..d8720d7 100644 --- a/src/protocols/a2a/x402-extension.ts +++ b/src/protocols/a2a/x402-extension.ts @@ -297,10 +297,14 @@ export function paymentFailureMetadata( [X402_PAYMENT_RECEIPTS_KEY]: [settled], }; } - // A backend failure before settlement keeps its own commerce error code. - const code = error.code.startsWith('PAYMENT_') - ? x402ErrorCode(error.code, error.details?.['reason'], error.details?.['network']) - : error.code.toLowerCase(); + // Retry-key refusals retain their reason, including INPUT_INVALID refusals. + // Backend failures before settlement keep their lowercase commerce code. + const reason = error.details?.['reason']; + const code = + error.code.startsWith('PAYMENT_') || + (typeof reason === 'string' && reason.startsWith('payment_identifier_')) + ? x402ErrorCode(error.code, reason, error.details?.['network']) + : error.code.toLowerCase(); const failure = settlementFailure(error.details); const receipt = failure['network'] === '' && network !== undefined ? { ...failure, network } : failure; diff --git a/tests/integration/a2a-x402-extension.test.ts b/tests/integration/a2a-x402-extension.test.ts index 6916c69..c81cff2 100644 --- a/tests/integration/a2a-x402-extension.test.ts +++ b/tests/integration/a2a-x402-extension.test.ts @@ -302,6 +302,7 @@ describe('x402 A2A extension', () => { const failed = body.result?.task; expect(failed?.status.state).toBe('TASK_STATE_FAILED'); + expect(failed?.status.message?.parts).toEqual([{ text: 'Payment failed.' }]); expect(failed?.status.message?.metadata).toMatchObject({ 'x402.payment.status': 'payment-failed', 'x402.payment.error': 'insufficient_funds', diff --git a/tests/integration/payment-retries.test.ts b/tests/integration/payment-retries.test.ts index f624560..a6e403c 100644 --- a/tests/integration/payment-retries.test.ts +++ b/tests/integration/payment-retries.test.ts @@ -1,7 +1,8 @@ /** - * Exercise HTTP retries through the gateway, x402 and MPP providers, and SQLite. - * An injected backend controls delivery; a mocked x402 facilitator client - * counts verification and settlement calls without contacting a chain. + * Exercise retries over HTTP, MCP and A2A through the gateway, the x402 and MPP + * providers, and SQLite. An injected backend controls delivery; a mocked x402 + * facilitator client counts verification and settlement calls without + * contacting a chain. */ import { Challenge } from 'mppx'; import { charge as clientCharge } from 'mppx/evm/client'; @@ -14,7 +15,9 @@ import { type BackendResponse, type Clock, type CommerceResource, + DELIVERY_SUMMARY_META_KEY, PAYMENT_HEADER, + PAYMENT_INPUT_FIELD, PAYMENT_REQUIRED_HEADER, PAYMENT_RESPONSE_HEADER, } from '../../src/core'; @@ -22,6 +25,10 @@ import { createGateway, type GatewayInstance } from '../../src/gateway'; import { createMppPaymentProvider } from '../../src/payments/mpp/provider'; import { createPaymentProof } from '../../src/payments/x402/client'; import { createX402PaymentProvider } from '../../src/payments/x402/provider'; +import { createA2aAdapter } from '../../src/protocols/a2a'; +import type { A2aTask } from '../../src/protocols/a2a/types'; +import { A2A_X402_EXTENSION_URI } from '../../src/protocols/a2a/x402-extension'; +import { createMcpAdapter } from '../../src/protocols/mcp'; import { createSqliteReceiptStore } from '../../src/storage/receipts'; process.env['NODE_ENV'] = 'test'; @@ -80,7 +87,7 @@ function resource( }, handler: { type: 'http', method: 'POST', url: 'http://backend.local/report' }, pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, - exposedVia: ['http'], + exposedVia: ['http', 'mcp', 'a2a'], paymentMethods, ...(idempotency !== undefined ? { idempotency } : {}), }; @@ -151,8 +158,8 @@ function config(resources: readonly CommerceResource[]): GatewayConfig { storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, protocols: { http: { enabled: true }, - mcp: { enabled: false, mountPath: '/mcp' }, - a2a: { enabled: false, mountPath: '/a2a' }, + mcp: { enabled: true, mountPath: '/mcp' }, + a2a: { enabled: true, mountPath: '/a2a' }, acp: { enabled: false, mountPath: '/acp' }, }, resources: [...resources], @@ -188,7 +195,7 @@ async function startGateway(resources: readonly CommerceResource[]): Promise { return JSON.parse(Buffer.from(String(value), 'base64').toString('utf8')); } -// Sign a fresh x402 challenge, echoing its declaration with `id` when supplied +// Sign a fresh x402 challenge from the HTTP route async function x402Proof(resourceId: string, id?: string, body = '{}'): Promise { - const required = decodeHeader( - (await invoke(resourceId, {}, body)).headers[PAYMENT_REQUIRED_HEADER], + return signX402( + decodeHeader((await invoke(resourceId, {}, body)).headers[PAYMENT_REQUIRED_HEADER]), + id, ); +} + +// Sign an x402 challenge, echoing its declaration with `id` when supplied +function signX402(required: Record, id?: string): Promise { const accepts = (required['accepts'] as Record[])[0] as Record; const declared = (required['extensions'] as Record)[ 'payment-identifier' @@ -231,16 +243,21 @@ async function x402Proof(resourceId: string, id?: string, body = '{}'): Promise< }); } -// Answer a fresh MPP challenge +// Answer a fresh MPP challenge from the HTTP route async function mppCredential( resourceId: string, account: PrivateKeyAccount = buyer, instance: GatewayInstance = gateway, ): Promise { const challenged = await invoke(resourceId, {}, '{}', instance); - const challenge = Challenge.deserialize( - String(challenged.headers['www-authenticate']), - ) as unknown as EvmChallenge; + return signMpp(challenged.headers['www-authenticate'], account); +} + +async function signMpp( + wwwAuthenticate: unknown, + account: PrivateKeyAccount = buyer, +): Promise { + const challenge = Challenge.deserialize(String(wwwAuthenticate)) as unknown as EvmChallenge; const client = clientCharge({ account, authorization: AUTHORIZATION }); return String(await client.createCredential({ challenge, context: {} })); } @@ -254,6 +271,121 @@ function holdBackend(): () => void { return release; } +interface ToolResult { + readonly isError?: boolean; + readonly structuredContent?: Record; + readonly _meta?: Record; +} + +// One MCP tools/call; Streamable HTTP may frame the reply as an SSE event +async function callMcp( + resourceId: string, + args: Record, + headers: Record = {}, +): Promise { + const res = await gateway.server.inject({ + method: 'POST', + url: '/mcp', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...headers, + }, + payload: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: resourceId, arguments: args }, + }, + }); + const raw = res.body.startsWith('event:') + ? (res.body.split('\n').find((line) => line.startsWith('data:')) ?? '').slice(5) + : res.body; + return (JSON.parse(raw) as { result: ToolResult }).result; +} + +interface A2aReply { + readonly result?: { readonly task: A2aTask }; + readonly error?: { readonly code: number; readonly message: string }; +} + +async function sendA2a( + message: Record, + headers: Record = {}, +): Promise { + const res = await gateway.server.inject({ + method: 'POST', + url: '/a2a', + headers: { 'content-type': 'application/json', 'a2a-version': '1.0', ...headers }, + payload: JSON.stringify({ + jsonrpc: '2.0', + id: 'req-1', + method: 'SendMessage', + params: { message }, + }), + }); + return res.json(); +} + +const EXTENSION = { 'a2a-extensions': A2A_X402_EXTENSION_URI }; + +function buyMessage(resourceId: string, input: Record): Record { + return { + role: 'ROLE_USER', + messageId: 'msg-buy', + parts: [{ data: { resource: resourceId, input }, mediaType: 'application/json' }], + }; +} + +function payMessage(task: A2aTask, payload: Record): Record { + return { + role: 'ROLE_USER', + messageId: 'msg-pay', + taskId: task.id, + contextId: task.contextId, + parts: [{ text: 'Here is the payment.' }], + metadata: { 'x402.payment.status': 'payment-submitted', 'x402.payment.payload': payload }, + }; +} + +// A new A2A task carrying the proof in `_payment`, without the extension +async function a2aPayment( + resourceId: string, + proof: string, + input: Record = {}, +): Promise { + const { result } = await sendA2a( + buyMessage(resourceId, { ...input, [PAYMENT_INPUT_FIELD]: proof }), + ); + return result?.task as A2aTask; +} + +// A new A2A task waiting for an x402 extension payment +async function a2aChallenge( + resourceId: string, + input: Record = {}, +): Promise { + const task = (await sendA2a(buyMessage(resourceId, input), EXTENSION)).result?.task as A2aTask; + expect(task.status.state).toBe('TASK_STATE_INPUT_REQUIRED'); + return task; +} + +function challengeOf(task: A2aTask): Record { + return task.status.message?.metadata?.['x402.payment.required'] as Record; +} + +async function a2aPay(task: A2aTask, proof: string): Promise { + return sendA2a(payMessage(task, decodeHeader(proof)), EXTENSION); +} + +function artifactData(task: A2aTask): Record | undefined { + return task.artifacts[0]?.parts[0]?.data; +} + +function summaryOf(task: A2aTask): unknown { + return task.artifacts[0]?.metadata?.[DELIVERY_SUMMARY_META_KEY]; +} + describe('x402 retries over HTTP', () => { it('redelivers an exact resend, marked as a replay, without a second charge', async () => { const proof = await x402Proof('x402_report', ID); @@ -568,7 +700,7 @@ describe('MPP retries over HTTP', () => { expect((await first).statusCode).toBe(200); }); - it('answers a key with an unknown outcome as Problem Details without Retry-After', async () => { + it('answers a charged key with no stored response as Problem Details without Retry-After', async () => { // MPP settles before the backend, so this failure leaves a charge behind backendFails = true; const credential = await mppCredential('mpp_report'); @@ -625,3 +757,291 @@ describe('MPP retries over HTTP', () => { ).not.toContain('payment-identifier'); }); }); + +// Send a signed x402 proof for one input on one surface +type Send = ( + resourceId: string, + proof: string, + input?: Record, +) => Promise; + +interface Surface { + readonly send: Send; + // Validate the refusal format and return its error envelope + readonly refusal: (answer: unknown, reason: string) => unknown; +} + +function failedEnvelope(answer: unknown): unknown { + const task = answer as A2aTask; + expect(task.status.state).toBe('TASK_STATE_FAILED'); + return artifactData(task); +} + +const SURFACES: [string, Surface][] = [ + [ + 'MCP', + { + send: (resourceId, proof, input = {}) => + callMcp(resourceId, { ...input, [PAYMENT_INPUT_FIELD]: proof }), + refusal(answer) { + const result = answer as ToolResult; + expect(result.isError).toBe(true); + expect(result._meta?.['x402/error']).toBeUndefined(); + expect(result.structuredContent).not.toHaveProperty('accepts'); + return result.structuredContent; + }, + }, + ], + ['A2A _payment', { send: a2aPayment, refusal: failedEnvelope }], + [ + 'the A2A x402 extension', + { + send: async (resourceId, proof, input = {}) => + (await a2aPay(await a2aChallenge(resourceId, input), proof)).result?.task, + refusal(answer, reason) { + const message = (answer as A2aTask).status.message; + expect(message?.metadata).toMatchObject({ + 'x402.payment.status': 'payment-failed', + 'x402.payment.error': reason, + }); + const envelope = failedEnvelope(answer) as { message: string }; + expect(message?.parts).toEqual([{ text: envelope.message }]); + return envelope; + }, + }, + ], +]; + +interface Refusal { + readonly reason: string; + readonly code: string; + readonly message: string; + readonly retryable: boolean; + readonly details?: Record; + readonly settleCalls: number; + // Run the retry scenario and return its final response + readonly act: (send: Send) => Promise; +} + +const REFUSALS: [string, Refusal][] = [ + [ + 'a missing id where one is required', + { + reason: 'payment_identifier_required', + settleCalls: 0, + code: 'INPUT_INVALID', + message: 'This resource requires a payment identifier', + retryable: false, + act: async (send) => send('x402_required', await x402Proof('x402_required')), + }, + ], + [ + 'a malformed id', + { + reason: 'payment_identifier_invalid', + settleCalls: 0, + code: 'INPUT_INVALID', + message: 'A payment identifier must be 16 to 128 characters of A-Z, a-z, 0-9, "_" and "-"', + retryable: false, + act: async (send) => send('x402_report', await x402Proof('x402_report', 'short-id')), + }, + ], + [ + 'the id with other input', + { + reason: 'payment_identifier_conflict', + settleCalls: 1, + code: 'PAYMENT_REPLAYED', + message: 'This payment identifier belongs to another request', + retryable: false, + async act(send) { + await send('x402_report', await x402Proof('x402_report', ID), { city: 'Oslo' }); + return send('x402_report', await x402Proof('x402_report', ID), { city: 'Rome' }); + }, + }, + ], + [ + 'the id while its request is in flight', + { + reason: 'payment_identifier_in_flight', + settleCalls: 1, + code: 'PAYMENT_REPLAYED', + message: 'A request with this payment identifier is in progress', + retryable: true, + details: { retryAfterSeconds: 1 }, + async act(send) { + const release = holdBackend(); + const proof = await x402Proof('x402_report', ID); + const first = send('x402_report', proof); + await vi.waitFor(() => expect(backendCalls).toHaveLength(1)); + const busy = await send('x402_report', proof); + release(); + await first; + return busy; + }, + }, + ], + [ + 'the id of an unresolved request', + { + reason: 'payment_identifier_unresolved', + settleCalls: 1, + code: 'PAYMENT_REPLAYED', + message: + 'The earlier request with this payment identifier may have been charged and has no stored response', + retryable: false, + async act(send) { + facilitator.settle.mockRejectedValueOnce(new Error('connection reset')); + await send('x402_report', await x402Proof('x402_report', ID)); + return send('x402_report', await x402Proof('x402_report', ID)); + }, + }, + ], +]; + +describe.each(SURFACES)('x402 retry-key refusals over %s', (_surface, surface) => { + it.each(REFUSALS)('refuses %s with its reason and no challenge', async (_case, row) => { + const envelope = surface.refusal(await row.act(surface.send), row.reason); + + expect(envelope).toMatchObject({ + status: 'error', + code: row.code, + message: row.message, + retryable: row.retryable, + }); + expect((envelope as { details?: unknown }).details).toEqual({ + reason: row.reason, + ...row.details, + }); + expect(facilitator.settle).toHaveBeenCalledTimes(row.settleCalls); + }); +}); + +describe('x402 redelivery over MCP and A2A', () => { + it('redelivers an MCP resend with the settlement response and a redelivered summary', async () => { + const proof = await x402Proof('x402_report', ID); + const first = await callMcp('x402_report', { [PAYMENT_INPUT_FIELD]: proof }); + + const resend = await callMcp('x402_report', { [PAYMENT_INPUT_FIELD]: proof }); + + expect(first.isError).toBeUndefined(); + expect(first._meta?.[DELIVERY_SUMMARY_META_KEY]).not.toHaveProperty('redelivered'); + expect(resend.isError).toBeUndefined(); + expect(resend.structuredContent).toEqual(REPORT); + expect(resend._meta?.['x402/payment-response']).toEqual(first._meta?.['x402/payment-response']); + expect(resend._meta?.[DELIVERY_SUMMARY_META_KEY]).toMatchObject({ + redelivered: true, + payment: { status: 'settled', externalReference: TX }, + }); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + expect(backendCalls).toHaveLength(1); + }); + + it('completes an A2A _payment retry with a fresh proof as a redelivered task', async () => { + const first = await a2aPayment('x402_report', await x402Proof('x402_report', ID)); + + const retry = await a2aPayment('x402_report', await x402Proof('x402_report', ID)); + + expect(summaryOf(first)).not.toHaveProperty('redelivered'); + expect(retry.status.state).toBe('TASK_STATE_COMPLETED'); + expect(artifactData(retry)).toEqual(REPORT); + expect(summaryOf(retry)).toMatchObject({ redelivered: true, payment: { status: 'settled' } }); + expect(facilitator.verify).toHaveBeenCalledTimes(2); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + }); + + it('completes a new extension task paid with a fresh proof for the same id, without settling', async () => { + const task = await a2aChallenge('x402_report'); + const paid = (await a2aPay(task, await signX402(challengeOf(task), ID))).result?.task; + const again = await a2aChallenge('x402_report'); + + const retry = (await a2aPay(again, await signX402(challengeOf(again), ID))).result?.task; + + expect(paid?.status.state).toBe('TASK_STATE_COMPLETED'); + expect(retry?.id).toBe(again.id); + expect(retry?.status.state).toBe('TASK_STATE_COMPLETED'); + expect(retry?.status.message?.metadata).toMatchObject({ + 'x402.payment.status': 'payment-completed', + 'x402.payment.receipts': [{ success: true, transaction: TX }], + }); + expect(artifactData(retry as A2aTask)).toEqual(REPORT); + expect(summaryOf(retry as A2aTask)).toMatchObject({ redelivered: true }); + expect(facilitator.verify).toHaveBeenCalledTimes(2); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + expect(backendCalls).toHaveLength(1); + }); + + it('answers a resend to the consumed extension task with TaskNotFoundError', async () => { + const task = await a2aChallenge('x402_report'); + const proof = await signX402(challengeOf(task), ID); + await a2aPay(task, proof); + + const resend = await a2aPay(task, proof); + + expect(resend.error?.code).toBe(-32001); + expect(resend.result).toBeUndefined(); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + expect(backendCalls).toHaveLength(1); + }); + + it.each(SURFACES.slice(1))( + 'lets a wallet that can no longer pay recover by an exact resend over %s', + async (_surface, { send }) => { + const proof = await x402Proof('x402_report', ID); + await send('x402_report', proof); + facilitator.verify.mockResolvedValue({ isValid: false, invalidReason: 'insufficient_funds' }); + + const fresh = await send('x402_report', await x402Proof('x402_report', ID)); + const resend = (await send('x402_report', proof)) as A2aTask; + + expect(failedEnvelope(fresh)).toMatchObject({ + code: 'PAYMENT_INVALID', + details: { reason: 'insufficient_funds' }, + }); + expect(resend.status.state).toBe('TASK_STATE_COMPLETED'); + expect(artifactData(resend)).toEqual(REPORT); + expect(summaryOf(resend)).toMatchObject({ redelivered: true }); + // The exact resend reuses the stored response without another verification call + expect(facilitator.verify).toHaveBeenCalledTimes(2); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + }, + ); +}); + +describe('MPP over MCP and A2A', () => { + it('refuses an MCP credential resend as a replay, ignoring an Idempotency-Key header', async () => { + const challenged = await callMcp('mpp_report', {}); + const payment = challenged.structuredContent?.['payment'] as { + envelope: Record; + }; + const credential = await signMpp(payment.envelope['wwwAuthenticate']); + const headers = { 'idempotency-key': KEY }; + const paid = await callMcp('mpp_report', { [PAYMENT_INPUT_FIELD]: credential }, headers); + + const resend = await callMcp('mpp_report', { [PAYMENT_INPUT_FIELD]: credential }, headers); + + expect(paid.isError).toBeUndefined(); + expect(resend.isError).toBe(true); + expect(resend.structuredContent).toMatchObject({ code: 'PAYMENT_REPLAYED' }); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + expect(backendCalls).toHaveLength(1); + }); + + it('refuses an A2A credential resend as a replay, ignoring an Idempotency-Key header', async () => { + const challenged = await sendA2a(buyMessage('mpp_report', {})); + const payment = artifactData(challenged.result?.task as A2aTask)?.['payment'] as { + envelope: Record; + }; + const credential = await signMpp(payment.envelope['wwwAuthenticate']); + const message = buyMessage('mpp_report', { [PAYMENT_INPUT_FIELD]: credential }); + const headers = { 'idempotency-key': KEY }; + const paid = (await sendA2a(message, headers)).result?.task; + + const resend = (await sendA2a(message, headers)).result?.task; + + expect(paid?.status.state).toBe('TASK_STATE_COMPLETED'); + expect(failedEnvelope(resend)).toMatchObject({ code: 'PAYMENT_REPLAYED' }); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + expect(backendCalls).toHaveLength(1); + }); +}); diff --git a/tests/unit/core/domain/delivery-summary.test.ts b/tests/unit/core/domain/delivery-summary.test.ts index 62a2e38..1469f1f 100644 --- a/tests/unit/core/domain/delivery-summary.test.ts +++ b/tests/unit/core/domain/delivery-summary.test.ts @@ -1,5 +1,5 @@ -// A failed receipt write adds receiptCommitted: false while keeping receiptId; -// other persistence failures leave the wire format unchanged +// Receipt failures add receiptCommitted: false while retaining receiptId. +// Redelivery adds redelivered: true; other persistence failures leave the summary unchanged. import { describe, expect, it } from 'vitest'; import { type DeliveredOutcome, toDeliverySummary } from '../../../../src/core'; @@ -46,6 +46,14 @@ describe('toDeliverySummary', () => { expect(JSON.stringify(summary)).toBe(UNCHANGED); }); + it('marks a redelivery and leaves redelivered: false out of the summary', () => { + expect(toDeliverySummary({ ...outcome, redelivered: true })).toStrictEqual({ + ...JSON.parse(UNCHANGED), + redelivered: true, + }); + expect(JSON.stringify(toDeliverySummary({ ...outcome, redelivered: false }))).toBe(UNCHANGED); + }); + it('keeps the receipt id and adds receiptCommitted: false when the receipt write failed', () => { const summary = toDeliverySummary({ ...outcome, persistenceFailures: ['receipt'] }); expect(summary).toStrictEqual({ diff --git a/tests/unit/protocols-a2a/x402-extension.test.ts b/tests/unit/protocols-a2a/x402-extension.test.ts index 54c8921..8731145 100644 --- a/tests/unit/protocols-a2a/x402-extension.test.ts +++ b/tests/unit/protocols-a2a/x402-extension.test.ts @@ -146,6 +146,30 @@ describe('payment messages', () => { }); }); + it.each([ + ['INPUT_INVALID', 400, 'payment_identifier_required'], + ['INPUT_INVALID', 400, 'payment_identifier_invalid'], + ['PAYMENT_REPLAYED', 409, 'payment_identifier_in_flight'], + ] as const)('reports a %s retry-key refusal by its reason, %s', (code, httpStatus, reason) => { + const refused = new CommerceError(code, 'Refused', { httpStatus, details: { reason } }); + + expect(paymentFailureMetadata(refused)).toMatchObject({ + 'x402.payment.status': 'payment-failed', + 'x402.payment.error': reason, + 'x402.payment.receipts': [{ success: false, errorReason: reason }], + }); + }); + + it('keeps the commerce code for another input refusal', () => { + const refused = new CommerceError('INPUT_INVALID', 'Bad input', { + details: { reason: 'schema_mismatch' }, + }); + + expect(paymentFailureMetadata(refused)).toMatchObject({ + 'x402.payment.error': 'input_invalid', + }); + }); + it('fills an empty receipt network from the requirement and keeps one the error names', () => { const refused = new CommerceError('PAYMENT_INVALID', 'Refused', { details: { reason: 'insufficient_funds' }, From a0a31114aecbb72654b8f973a03e494e79d6c03d Mon Sep 17 00:00:00 2001 From: Revinand Date: Sat, 7 Nov 2026 18:56:24 +0100 Subject: [PATCH 08/20] feat(gateway): recover payment keys at startup --- config.example.yaml | 9 + docs/configuration.md | 10 + docs/contracts.md | 8 +- src/cli/commands/doctor/storage.ts | 7 + src/core/execution/index.ts | 1 + src/gateway/server.ts | 37 ++++ tests/integration/payment-retries.test.ts | 218 +++++++++++++++++++++- tests/unit/cli/doctor-storage.test.ts | 17 ++ tests/unit/gateway/server.test.ts | 63 +++++++ 9 files changed, 361 insertions(+), 9 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 4826c0b..4923ea4 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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: diff --git a/docs/configuration.md b/docs/configuration.md index cfd55f1..371a6d0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -117,6 +117,16 @@ starts at the initial retry check, before proof verification. `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). +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. +Those requests may have charged; with `redelivery` enabled, matching retries +get 409 `payment_identifier_unresolved`. `doctor` reports 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. diff --git a/docs/contracts.md b/docs/contracts.md index 159d670..168420e 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -243,8 +243,9 @@ persistence must not fail the commerce flow. - `redelivery` sets a retention deadline `retentionHours` hours after the initial retry check, before proof verification. It requires a store with the `idempotency_claims` and `response_redelivery` groups. Both `merchantKey: true` - and `redelivery` require the `deployment` option. Missing dependencies make - payment admission throw `CONFIG_INVALID` before any provider call. + and `redelivery` require the `deployment` option. `createGateway` throws + `CONFIG_INVALID` for missing dependencies at startup; a directly built + pipeline throws at payment admission, before any provider call. With `required` true, x402 retry-key extraction rejects a missing `id` with `INPUT_INVALID` (HTTP 400). - `CanonicalRequest.retryKey` holds the client's key for retrying a paid @@ -256,6 +257,9 @@ persistence must not fail the commerce flow. - `recoverIdempotencyKeys` takes an optional `endpointPrefix`. Recovery matches the prefix literally, including case; omit it to include all endpoints. The ACP adapter passes `/` when recovering receipt-store claims. + At startup, `createGateway` recovers `payment:` claims with `release: false` + only from stores that also implement `getIdempotencyKey`. These stores must + honor `endpointPrefix`. - `redelivered` on `DeliveredOutcome` and `DeliverySummary` indicates that the request reused a stored response without another charge. `toDeliverySummary` includes the field only when true; original delivery diff --git a/src/cli/commands/doctor/storage.ts b/src/cli/commands/doctor/storage.ts index 5081404..65f5855 100644 --- a/src/cli/commands/doctor/storage.ts +++ b/src/cli/commands/doctor/storage.ts @@ -277,5 +277,12 @@ function summaryChecks(summary: SqliteReceiptStoreSummary): DoctorCheck[] { detail: `${summary.unresolvedIdempotencyKeys} unresolved; a retry with the same Idempotency-Key gets 409 idempotency_unresolved`, }); } + if (summary.unresolvedPaymentKeys > 0) { + checks.push({ + name: 'Payment retry keys', + status: 'INFO', + detail: `${summary.unresolvedPaymentKeys} unresolved; a paid retry with the same key gets 409 payment_identifier_unresolved`, + }); + } return checks; } diff --git a/src/core/execution/index.ts b/src/core/execution/index.ts index 2939762..885397f 100644 --- a/src/core/execution/index.ts +++ b/src/core/execution/index.ts @@ -14,6 +14,7 @@ export { } from './backend-request'; export type { CreateExecutionPipelineOptions } from './pipeline'; export { createExecutionPipeline } from './pipeline'; +export { assertRetrySupport } from './pipeline/retry'; export { createResourceRegistry } from './registry'; export type { CreateStoreEventSinkOptions } from './store-event-sink'; export { createStoreEventSink } from './store-event-sink'; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 8cb1d92..b47ec5e 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -7,6 +7,7 @@ import { type AuthorizationProvider, type BackendExecutor, type Clock, + CommerceError, type CommerceResource, type ExecutionPipeline, type IdGenerator, @@ -18,12 +19,15 @@ import { type ResourceRegistry, systemClock, } from '../core'; +import { PAYMENT_KEY_ENDPOINT_PREFIX } from '../core/domain/store-records'; import { + assertRetrySupport, createExecutionPipeline, createResourceRegistry, createStoreEventSink, HttpBackendExecutor, } from '../core/execution'; +import { hasMethodGroup } from '../core/store-method-groups'; import { buildAccessControlHook } from './access-control'; import { type AdapterRuntime, @@ -92,6 +96,32 @@ function withProviderBackedMethodsFirst( : resource; } +// Mark interrupted payment claims unresolved so a retry returns 409 after a possible charge. +// Require getIdempotencyKey too: the retry gate cannot write payment claims without it, and older +// claim stores may ignore endpointPrefix and mark ACP claims unresolved. +async function recoverPaymentKeys(store: ReceiptStore, logger: Logger): Promise { + if ( + !hasMethodGroup(store, 'idempotency_claims') || + !hasMethodGroup(store, 'response_redelivery') + ) { + return; + } + let recovered: number; + try { + recovered = await store.recoverIdempotencyKeys({ + release: false, + endpointPrefix: PAYMENT_KEY_ENDPOINT_PREFIX, + }); + } catch (error) { + throw new CommerceError('STORAGE_ERROR', 'Payment retry keys could not be recovered', { + cause: error, + }); + } + if (recovered > 0) { + logger.warn({ recovered }, 'Payment retry keys left in flight are now unresolved'); + } +} + export async function createGateway(options: GatewayOptions): Promise { const clock = options.clock ?? systemClock; const ids = options.ids ?? createDefaultIdGenerator(); @@ -105,6 +135,13 @@ export async function createGateway(options: GatewayOptions): Promise void; readonly request: Promise } | undefined; beforeEach(async () => { mppNowMs = Date.now(); @@ -146,14 +156,26 @@ beforeEach(async () => { }); afterEach(async () => { + // Finish the paused purchase before resetting mocks for the next test + if (stopped !== undefined) { + stopped.release(); + await stopped.request; + stopped = undefined; + } await Promise.all(gateways.map((instance) => instance.close().catch(() => {}))); gateways = []; + await Promise.all(fileStores.map((store) => store.close())); + fileStores = []; + if (dir !== undefined) rmSync(dir, { recursive: true, force: true }); + dir = undefined; }); -function config(resources: readonly CommerceResource[]): GatewayConfig { +const BASE_URL = 'https://gw.example'; + +function config(resources: readonly CommerceResource[], publicBaseUrl = BASE_URL): GatewayConfig { return { version: 1, - merchant: { id: 'shop', name: 'Shop', publicBaseUrl: 'https://gw.example' }, + merchant: { id: 'shop', name: 'Shop', publicBaseUrl }, server: { port: 0, host: '127.0.0.1', allowedOrigins: [] }, storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, protocols: { @@ -167,10 +189,20 @@ function config(resources: readonly CommerceResource[]): GatewayConfig { }; } -async function startGateway(resources: readonly CommerceResource[]): Promise { +interface StartOptions { + readonly store?: ReceiptStore; + readonly publicBaseUrl?: string; + readonly adapters?: readonly ProtocolAdapter[]; + readonly logger?: Logger; +} + +async function startGateway( + resources: readonly CommerceResource[], + options: StartOptions = {}, +): Promise { const instance = await createGateway({ - config: config(resources), - store: createSqliteReceiptStore({ path: ':memory:' }), + config: config(resources, options.publicBaseUrl), + store: options.store ?? createSqliteReceiptStore({ path: ':memory:' }), paymentProviders: [ createX402PaymentProvider({ network: 'eip155:31337', @@ -195,8 +227,9 @@ async function startGateway(resources: readonly CommerceResource[]): Promise { expect(backendCalls).toHaveLength(1); }); }); + +const ACP_OPERATIONS = { + createCheckoutSession: 'acp_create', + updateCheckoutSession: 'acp_update', + getCheckoutSession: 'acp_get', + completeCheckoutSession: 'acp_complete', + cancelCheckoutSession: 'acp_cancel', +} as const; + +const CHECKOUT_RESOURCES: CommerceResource[] = Object.values(ACP_OPERATIONS).map((id) => ({ + id, + name: id, + handler: { type: 'http', method: 'POST', url: `http://backend.local/${id}` }, + pricing: { type: 'free' }, + exposedVia: ['acp'], + paymentMethods: [], +})); + +// Keep the receipt database across gateway restarts +function openStore(): SqliteReceiptStore { + dir ??= mkdtempSync(join(tmpdir(), 'oac-retries-')); + const store = createSqliteReceiptStore({ path: join(dir, 'receipts.sqlite') }); + fileStores.push(store); + return store; +} + +// Pause after the retry key is claimed to simulate a crash +async function stopMidPurchase(instance: GatewayInstance, proof: string): Promise { + const release = holdBackend(); + const request = invoke('x402_report', { [PAYMENT_HEADER]: proof }, '{}', instance); + await vi.waitFor(() => expect(backendCalls).toHaveLength(1)); + // Let later requests reach the backend while this one waits + hold = undefined; + stopped = { release, request }; +} + +function recordingLogger(warnings: unknown[]): Logger { + const logger: Logger = { + debug() {}, + info() {}, + error() {}, + warn: (obj, msg) => { + warnings.push([obj, msg]); + }, + child: () => logger, + }; + return logger; +} + +function fresh402Proof(): Promise { + return x402Proof('x402_report', ID); +} + +describe('payment retry keys across a restart', () => { + it('marks a key left in flight unresolved at start, so a retry gets 409', async () => { + const cleanStart: unknown[] = []; + await stopMidPurchase( + await startGateway(RESOURCES, { store: openStore(), logger: recordingLogger(cleanStart) }), + await fresh402Proof(), + ); + const warnings: unknown[] = []; + + const restarted = await startGateway(RESOURCES, { + store: openStore(), + logger: recordingLogger(warnings), + }); + const retry = await invoke( + 'x402_report', + { [PAYMENT_HEADER]: await fresh402Proof() }, + '{}', + restarted, + ); + + expect(cleanStart).toEqual([]); + expect(warnings).toEqual([ + [{ recovered: 1 }, 'Payment retry keys left in flight are now unresolved'], + ]); + expect(retry.statusCode).toBe(409); + expect(retry.json()).toMatchObject({ + code: 'PAYMENT_REPLAYED', + retryable: false, + details: { reason: 'payment_identifier_unresolved' }, + }); + expect(retry.headers[PAYMENT_REQUIRED_HEADER]).toBeUndefined(); + expect(facilitator.settle).not.toHaveBeenCalled(); + expect(backendCalls).toHaveLength(1); + }); + + it('frees an ACP claim under merchantIdempotent and still marks the payment key unresolved', async () => { + const before = openStore(); + await stopMidPurchase(await startGateway(RESOURCES, { store: before }), await fresh402Proof()); + const acpScope = { deployment: BASE_URL, endpoint: '/checkout_sessions', key: 'k'.repeat(64) }; + const expiresAt = new Date(Date.now() + 3_600_000).toISOString(); + await before.claimIdempotencyKey(acpScope, 'fingerprint', expiresAt); + const after = openStore(); + + const restarted = await startGateway([...RESOURCES, ...CHECKOUT_RESOURCES], { + store: after, + adapters: [ + createAcpAdapter({ + mountPath: '/acp', + token: 'acp-token', + operations: ACP_OPERATIONS, + idempotency: { retentionHours: 24, merchantIdempotent: true }, + store: after, + }), + ], + }); + const retry = await invoke( + 'x402_report', + { [PAYMENT_HEADER]: await fresh402Proof() }, + '{}', + restarted, + ); + + expect(await after.getIdempotencyKey(acpScope, 'fingerprint')).toBeUndefined(); + expect(retry.statusCode).toBe(409); + expect(retry.json()).toMatchObject({ details: { reason: 'payment_identifier_unresolved' } }); + expect(facilitator.settle).not.toHaveBeenCalled(); + }); + + // Count unresolved keys independently of the gateway's retry settings + async function unresolvedPaymentKeys(): Promise { + return (await openStore().summary({ orphanHoldMinAgeMs: 0 })).unresolvedPaymentKeys; + } + + it('recovers the key without the opt-in, sells again, and doctor still counts the key', async () => { + await stopMidPurchase( + await startGateway(RESOURCES, { store: openStore() }), + await fresh402Proof(), + ); + + const withoutOptIn = await startGateway([resource('x402_report', ['x402'])], { + store: openStore(), + }); + const purchase = await invoke( + 'x402_report', + { [PAYMENT_HEADER]: await fresh402Proof() }, + '{}', + withoutOptIn, + ); + + expect(purchase.statusCode).toBe(200); + expect(purchase.headers['idempotent-replayed']).toBeUndefined(); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + expect(await unresolvedPaymentKeys()).toBe(1); + }); + + it('sells again after publicBaseUrl changes, and doctor still counts the key', async () => { + await stopMidPurchase( + await startGateway(RESOURCES, { store: openStore() }), + await fresh402Proof(), + ); + + const moved = await startGateway(RESOURCES, { + store: openStore(), + publicBaseUrl: 'https://moved.example', + }); + const purchase = await invoke( + 'x402_report', + { [PAYMENT_HEADER]: await fresh402Proof() }, + '{}', + moved, + ); + + expect(purchase.statusCode).toBe(200); + expect(purchase.headers['idempotent-replayed']).toBeUndefined(); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + expect(await unresolvedPaymentKeys()).toBe(1); + }); +}); diff --git a/tests/unit/cli/doctor-storage.test.ts b/tests/unit/cli/doctor-storage.test.ts index d29d3b9..291ff61 100644 --- a/tests/unit/cli/doctor-storage.test.ts +++ b/tests/unit/cli/doctor-storage.test.ts @@ -267,6 +267,7 @@ describe('runDoctor: checks from the store summary', () => { }); expect(check(report, 'Authorization holds')).toBeUndefined(); expect(check(report, 'ACP claims')).toBeUndefined(); + expect(check(report, 'Payment retry keys')).toBeUndefined(); }); it('warns with the count of each unresolved state and where to resolve them', async () => { @@ -309,6 +310,22 @@ describe('runDoctor: checks from the store summary', () => { }); }); + it('reports unresolved payment retry keys as INFO, apart from ACP claims', async () => { + const report = await doctorWith( + makeGatewayConfig({ storage: { receipts: { driver: 'sqlite', path: receipts } } }), + withSummary({ unresolvedPaymentKeys: 3, unresolvedIdempotencyKeys: 1 }), + ); + + expect(check(report, 'Payment retry keys')).toEqual({ + name: 'Payment retry keys', + status: 'INFO', + detail: '3 unresolved; a paid retry with the same key gets 409 payment_identifier_unresolved', + }); + expect(check(report, 'ACP claims')?.detail).toBe( + '1 unresolved; a retry with the same Idempotency-Key gets 409 idempotency_unresolved', + ); + }); + it('adds no summary check, and leaves the import state unknown, when the summary throws', async () => { const file = join(dir, 'acp.sqlite'); writeFileSync(file, ''); diff --git a/tests/unit/gateway/server.test.ts b/tests/unit/gateway/server.test.ts index 6663d12..9241948 100644 --- a/tests/unit/gateway/server.test.ts +++ b/tests/unit/gateway/server.test.ts @@ -11,6 +11,7 @@ import type { ExecutionPipeline, IdGenerator, Logger, + ReceiptStore, } from '../../../src/core'; import { DELIVERY_SUMMARY_META_KEY, @@ -1537,6 +1538,68 @@ describe('createGateway HTTP surface', () => { }); }); +describe('createGateway startup with payment retry keys', () => { + // Support claims without the optional lookup used for redelivery + function storeWithoutRedelivery( + recover: NonNullable, + ): ReceiptStore { + return { + ...createFakeStore(), + claimIdempotencyKey: async () => ({ kind: 'reserved' }), + updateIdempotencyKey: async () => {}, + recoverIdempotencyKeys: recover, + }; + } + + function withRedelivery(): ReturnType { + const config = makeGatewayConfig(); + return { + ...config, + resources: config.resources.map((resource) => + resource.id === 'market_report' + ? { ...resource, idempotency: { redelivery: { retentionHours: 24, required: false } } } + : resource, + ), + }; + } + + it('starts a 2.6-era store without the opt-in and never asks it to recover', async () => { + const recover = vi.fn(async () => 0); + + await buildGateway({ store: storeWithoutRedelivery(recover) }); + + // Avoid recovery through stores that may ignore the payment prefix + expect(recover).not.toHaveBeenCalled(); + }); + + it('refuses a 2.6-era store for a resource with redelivery, before recovering', async () => { + const recover = vi.fn(async () => 0); + + await expect( + buildGateway({ config: withRedelivery(), store: storeWithoutRedelivery(recover) }), + ).rejects.toMatchObject({ + code: 'CONFIG_INVALID', + message: + 'Resource "market_report" sets idempotency, which requires a store with idempotency claims and getIdempotencyKey', + }); + expect(recover).not.toHaveBeenCalled(); + }); + + it('stops startup when payment key recovery fails', async () => { + const store: ReceiptStore = { + ...storeWithoutRedelivery(async () => { + throw new Error('disk I/O error'); + }), + getIdempotencyKey: async () => undefined, + }; + + await expect(buildGateway({ store })).rejects.toMatchObject({ + code: 'STORAGE_ERROR', + message: 'Payment retry keys could not be recovered', + }); + }); +}); + describe('the content type of a delivered HTTP response', () => { // The fixture's market_report is paid with x402 async function paidResponse(backendType: string | undefined, body: unknown, mimeType?: string) { From 322abf153ee13f20b6b8319bfc8a49d040cd1e85 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sat, 7 Nov 2026 19:56:50 +0100 Subject: [PATCH 09/20] test: retry a paid request after a lost response --- tests/e2e/payment/safe-retries.e2e.test.ts | 674 +++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 tests/e2e/payment/safe-retries.e2e.test.ts diff --git a/tests/e2e/payment/safe-retries.e2e.test.ts b/tests/e2e/payment/safe-retries.e2e.test.ts new file mode 100644 index 0000000..d6ef587 --- /dev/null +++ b/tests/e2e/payment/safe-retries.e2e.test.ts @@ -0,0 +1,674 @@ +/** + * Test paid retries on local Anvil with the gateway, a merchant HTTP server + * and MockUSDC. Simulate response loss by closing the buyer's socket after the + * merchant receives the request. The gateway ignores the disconnect and finishes + * the purchase; the buyer retries with the same payment identifier. x402 and MPP + * use a loopback facilitator behind a holding proxy. Each case checks on-chain + * balances; charged cases also verify the receipt's transaction on-chain. + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { createServer, request as httpRequest, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { x402Client } from '@x402/core/client'; +import type { PaymentRequired } from '@x402/core/types'; +import { PERMIT2_ADDRESS } from '@x402/evm'; +import { registerExactEvmScheme } from '@x402/evm/exact/client'; +import { Challenge, Receipt } from 'mppx'; +import { charge as clientCharge } from 'mppx/evm/client'; +import { + createTestClient, + createWalletClient, + erc20Abi, + http, + maxUint256, + publicActions, +} from 'viem'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { anvil as anvilChain } from 'viem/chains'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { runReconcileCommand } from '../../../src/cli/commands/reconcile'; +import { type GatewayConfig, parseConfig } from '../../../src/config'; +import { + type CommerceReceipt, + NOOP_LOGGER, + PAYMENT_HEADER, + PAYMENT_REQUIRED_HEADER, + PAYMENT_RESPONSE_HEADER, +} from '../../../src/core'; +import { createGateway } from '../../../src/gateway'; +import { createConfiguredPaymentProviders } from '../../../src/gateway/payment-providers'; +import { createPaymentProof } from '../../../src/payments/x402'; +import { type AnvilHandle, deployLocalChain, startAnvil } from '../../../src/payments/x402/testing'; +import { createSqliteReceiptStore, type SqliteReceiptStore } from '../../../src/storage/receipts'; +import { type HoldingProxy, startHoldingProxy } from '../../fixtures/x402/holding-proxy'; +import { + type LoopbackFacilitator, + startLoopbackFacilitator, +} from '../../fixtures/x402/loopback-facilitator'; +import { installMulticall3, installPermit2 } from '../../fixtures/x402/permit2-contracts'; +import { + assertBalanceDelta, + type BalanceSnapshot, + expectRealSettlement, + readBalances, +} from '../../fixtures/x402/settlement'; +import { createCapturingIo } from '../../unit/cli/fixtures'; + +const PORT = 18800; +const NETWORK = 'eip155:31337'; +const INPUT = { city: 'Berlin' }; +const ONE_USDC = 1_000_000n; +// x402's payment identifier and MPP's Idempotency-Key +const ID = 'purchase-0123456789abcdef'; +const KEY = 'order-42-retry'; +const SECRET = 'retry-e2e-challenge-secret'.padEnd(32, 'x'); +// Redelivery must retain the merchant's first response (call 1) +const STORED = { report: 'ok', call: 1 }; +// Mine past Anvil's finality lag +const FINALITY_BLOCKS = 65; + +type Flow = 'upfront' | 'authorization'; +// x402 EIP-3009 or Permit2; MPP EIP-3009 +type Rail = 'x402' | 'permit2' | 'mpp'; +type EvmChallenge = Parameters['createCredential']>[0]['challenge']; + +let anvil: AnvilHandle; +let deployment: Awaited>; +let merchantServer: Server; +let merchantUrl: string; +const cleanups: (() => Promise)[] = []; + +// Record the Idempotency-Key and return the call number after any pause +const merchant = { + keys: [] as (string | undefined)[], + status: 200, + pause: undefined as (() => Promise) | undefined, +}; + +interface Env { + readonly rail: Rail; + readonly config: GatewayConfig; + readonly receipts: string; + // Between the gateway and the facilitator + readonly front: HoldingProxy; + readonly facilitator: LoopbackFacilitator; +} + +interface Invocation { + readonly statusCode: number; + readonly headers: Record; + json(): Record; +} + +interface Gateway { + readonly rail: Rail; + readonly url: string; + readonly store: SqliteReceiptStore; + invoke(flow: Flow, headers?: Record): Promise; + close(): Promise; +} + +interface ReconcileJson { + readonly entries: readonly { + readonly outcome: string; + readonly reason: string; + readonly written: boolean; + }[]; +} + +function rawConfig(dir: string, facilitatorUrl: string, rail: Rail): Record { + const resource = (flow: Flow) => ({ + name: `Report, ${flow} flow`, + input: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + additionalProperties: false, + }, + backend: { type: 'http', method: 'POST', url: `${merchantUrl}/report`, timeoutMs: 60_000 }, + pricing: { type: 'fixed', amount: '1.00', currency: 'USDC' }, + expose: ['http'], + payments: [rail === 'mpp' ? 'mpp' : 'x402'], + paymentFlow: flow, + idempotency: { merchantKey: true, redelivery: { retentionHours: 24 } }, + }); + const facilitator = { mode: 'remote', url: facilitatorUrl, auth: { type: 'none' } }; + const domain = { assetName: deployment.assetName, assetVersion: deployment.assetVersion }; + const payments = + rail === 'mpp' + ? { + mpp: { + enabled: true, + network: NETWORK, + rpcUrl: anvil.rpcUrl, + asset: deployment.asset, + ...domain, + recipient: deployment.merchant.address, + realm: 'retry.e2e', + challengeSecret: SECRET, + facilitator, + }, + } + : { + x402: { + enabled: true, + network: NETWORK, + rpcUrl: anvil.rpcUrl, + asset: deployment.asset, + // Permit2 offers omit the token domain because the buyer signs Permit2's domain + ...(rail === 'permit2' ? { assetTransferMethod: 'permit2' } : domain), + assetDecimals: deployment.assetDecimals, + payTo: deployment.merchant.address, + maxTimeoutSeconds: 60, + facilitator, + }, + }; + return { + version: 1, + merchant: { id: 'retry-e2e', name: 'Retry E2E', publicBaseUrl: 'http://127.0.0.1:8080' }, + server: { port: 0, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: join(dir, 'receipts.sqlite') } }, + protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } }, + resources: { + report_upfront: resource('upfront'), + report_authorization: resource('authorization'), + }, + payments, + }; +} + +async function stack(rail: Rail = 'x402'): Promise { + const dir = mkdtempSync(join(tmpdir(), 'oac-retry-e2e-')); + const facilitator = await startLoopbackFacilitator({ + evm: { + chains: [{ network: NETWORK, rpcUrl: anvil.rpcUrl }], + signerPrivateKey: deployment.facilitator.privateKey, + }, + }); + const front = await startHoldingProxy(facilitator.url); + cleanups.push(async () => { + await front.close(); + await facilitator.close(); + rmSync(dir, { recursive: true, force: true }); + }); + return { + rail, + config: parseConfig(rawConfig(dir, front.url, rail), {}), + receipts: join(dir, 'receipts.sqlite'), + front, + facilitator, + }; +} + +// Start a gateway on a TCP socket using the environment's receipt database +async function startGateway(env: Env): Promise { + const store = createSqliteReceiptStore({ path: env.receipts, logger: NOOP_LOGGER }); + await store.init(); + const gateway = await createGateway({ + config: env.config, + store, + paymentProviders: await createConfiguredPaymentProviders(env.config.payments, NOOP_LOGGER), + protocolAdapters: [], + logger: NOOP_LOGGER, + }); + const { url } = await gateway.listen(); + let closing: Promise | undefined; + const close = () => { + closing ??= gateway.close().then(() => store.close()); + return closing; + }; + cleanups.push(close); + return { + rail: env.rail, + url, + store, + invoke: (flow, headers = {}) => + gateway.server.inject({ + method: 'POST', + url: `/api/resources/report_${flow}/invoke`, + headers: { 'content-type': 'application/json', ...headers }, + payload: INPUT, + }), + close, + }; +} + +const decode = (value: unknown): Record => + JSON.parse(Buffer.from(String(value), 'base64').toString('utf8')); + +// Answer a fresh challenge with ID for x402 or KEY for MPP +async function paidHeaders(gateway: Gateway, flow: Flow): Promise> { + const unpaid = await gateway.invoke(flow); + expect(unpaid.statusCode).toBe(402); + if (gateway.rail === 'mpp') { + const challenge = Challenge.deserialize(String(unpaid.headers['www-authenticate'])); + const client = clientCharge({ + account: privateKeyToAccount(deployment.buyer.privateKey), + authorization: { name: deployment.assetName, version: deployment.assetVersion }, + }); + const credential = await client.createCredential({ + challenge: challenge as unknown as EvmChallenge, + context: {}, + }); + return { authorization: String(credential), 'idempotency-key': KEY }; + } + const required = decode(unpaid.headers[PAYMENT_REQUIRED_HEADER]) as unknown as PaymentRequired; + const declared = required.extensions?.['payment-identifier'] as { info: object }; + const extensions = { + ...required.extensions, + 'payment-identifier': { ...declared, info: { ...declared.info, id: ID } }, + }; + if (gateway.rail === 'x402') { + const proof = await createPaymentProof({ + buyerPrivateKey: deployment.buyer.privateKey, + accepts: required.accepts[0] as unknown as Record, + extensions, + }); + return { [PAYMENT_HEADER]: proof }; + } + // Use the SDK for Permit2 and echoed extensions; createPaymentProof supports EIP-3009 only + const client = new x402Client(); + registerExactEvmScheme(client, { + signer: privateKeyToAccount(deployment.buyer.privateKey), + networks: [NETWORK], + }); + client.setSpendControls(false); + const payload = await client.createPaymentPayload({ ...required, extensions }); + return { [PAYMENT_HEADER]: Buffer.from(JSON.stringify(payload)).toString('base64') }; +} + +// Drop the buyer's socket after the merchant receives the request. Wait for +// resource.delivered, emitted after the receipt and response writes. +async function loseResponse( + gateway: Gateway, + flow: Flow, + headers: Record, +): Promise { + const request = httpRequest(`${gateway.url}/api/resources/report_${flow}/invoke`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + }); + let answered = false; + request.on('response', () => { + answered = true; + }); + // Destroying the socket raises ECONNRESET on the request + request.on('error', () => {}); + const closed = new Promise((resolve) => request.on('close', resolve)); + merchant.pause = async () => { + request.destroy(); + await closed; + }; + request.end(JSON.stringify(INPUT)); + await closed; + expect(answered).toBe(false); + await vi.waitFor( + async () => { + const events = await gateway.store.listEvents({ limit: 100 }); + expect(events.filter((event) => event.type === 'resource.delivered')).toHaveLength(1); + }, + { timeout: 30_000, interval: 50 }, + ); + merchant.pause = undefined; +} + +function balances(): Promise { + return readBalances({ + rpcUrl: anvil.rpcUrl, + asset: deployment.asset, + buyer: deployment.buyer.address, + merchant: deployment.merchant.address, + }); +} + +// Require exactly one receipt with a settled payment +async function settledReceipt(store: SqliteReceiptStore): Promise { + const settled = (await store.listReceipts()).filter( + (receipt) => receipt.payment?.status === 'settled', + ); + expect(settled).toHaveLength(1); + return settled[0] as CommerceReceipt; +} + +// Verify one transfer of the price since before; return the receipt's transaction hash +async function expectOneCharge(before: BalanceSnapshot, store: SqliteReceiptStore) { + const txHash = (await settledReceipt(store)).payment?.externalReference ?? ''; + await expectRealSettlement({ + rpcUrl: anvil.rpcUrl, + asset: deployment.asset, + buyer: deployment.buyer.address, + merchant: deployment.merchant.address, + before, + after: await balances(), + amountBaseUnits: ONE_USDC, + txHash, + }); + return txHash; +} + +const settleCalls = (env: Env) => + env.facilitator.requests.filter((line) => line === 'POST /settle').length; + +function expectRedelivered(response: Invocation, txHash: string): void { + expect(response.statusCode).toBe(200); + expect(response.headers['idempotent-replayed']).toBe('true'); + expect(response.json()).toEqual(STORED); + expect(decode(response.headers[PAYMENT_RESPONSE_HEADER])).toMatchObject({ + success: true, + transaction: txHash, + }); +} + +function expectUnresolved(response: Invocation): void { + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ + code: 'PAYMENT_REPLAYED', + retryable: false, + details: { reason: 'payment_identifier_unresolved' }, + }); + expect(response.headers['retry-after']).toBeUndefined(); + expect(response.headers[PAYMENT_REQUIRED_HEADER]).toBeUndefined(); +} + +function testClient() { + return createTestClient({ mode: 'anvil', transport: http(anvil.rpcUrl) }); +} + +function buyerWallet() { + return createWalletClient({ + account: privateKeyToAccount(deployment.buyer.privateKey), + chain: anvilChain, + transport: http(anvil.rpcUrl), + }).extend(publicActions); +} + +// Mine from payment expiry through finality, run reconcile, then restore the chain +async function reconcileExpired(env: Env): Promise { + const store = createSqliteReceiptStore({ path: env.receipts, logger: NOOP_LOGGER }); + await store.init(); + const page = await store.listTransactions(); + await store.close(); + expect(page.transactions).toHaveLength(1); + const expiresAt = page.transactions[0]?.payment.expiresAt ?? ''; + const snapshot = await testClient().snapshot(); + try { + await testClient().setNextBlockTimestamp({ timestamp: BigInt(Date.parse(expiresAt) / 1000) }); + await testClient().mine({ blocks: FINALITY_BLOCKS }); + const io = createCapturingIo(); + const code = await runReconcileCommand({ json: true, actor: 'retry-e2e', minAge: '0' }, io, { + loadConfig: async () => env.config, + }); + expect(io.err).toEqual([]); + expect(code).toBe(0); + return JSON.parse(io.out.join('\n')) as ReconcileJson; + } finally { + await testClient().revert({ id: snapshot }); + } +} + +beforeAll(async () => { + anvil = await startAnvil({ port: PORT, silent: true }); + deployment = await deployLocalChain({ rpcUrl: anvil.rpcUrl, buyerInitialBalance: '100.00' }); + await installMulticall3(anvil.rpcUrl); + await installPermit2(anvil.rpcUrl); + merchantServer = createServer(async (request, response) => { + for await (const _ of request); + const key = request.headers['idempotency-key']; + merchant.keys.push(typeof key === 'string' ? key : undefined); + const call = merchant.keys.length; + await merchant.pause?.(); + response.writeHead(merchant.status, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ report: 'ok', call })); + }); + await new Promise((resolve) => merchantServer.listen(0, '127.0.0.1', resolve)); + merchantUrl = `http://127.0.0.1:${(merchantServer.address() as AddressInfo).port}`; +}, 180_000); + +beforeEach(() => { + merchant.keys = []; + merchant.status = 200; + merchant.pause = undefined; +}); + +afterEach(async () => { + // Close gateways before the stack they use + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +}); + +afterAll(async () => { + merchantServer?.closeAllConnections(); + await new Promise((resolve) => merchantServer?.close(resolve)); + await anvil?.stop(); +}); + +describe('a retry after a lost response', () => { + it.each([ + ['authorization', 'an exact resend'], + ['authorization', 'a fresh proof'], + ['upfront', 'an exact resend'], + ['upfront', 'a fresh proof'], + ] as const)( + '%s flow: %s gets the stored response and the buyer is charged once', + async (flow, style) => { + const env = await stack(); + const gateway = await startGateway(env); + const headers = await paidHeaders(gateway, flow); + const before = await balances(); + + await loseResponse(gateway, flow, headers); + const txHash = await expectOneCharge(before, gateway.store); + const retry = style === 'an exact resend' ? headers : await paidHeaders(gateway, flow); + const response = await gateway.invoke(flow, retry); + + expectRedelivered(response, txHash); + expect(merchant.keys).toHaveLength(1); + expect(settleCalls(env)).toBe(1); + assertBalanceDelta(before, await balances(), ONE_USDC); + }, + ); + + it('redelivers a Permit2 exact resend with one charge', async () => { + const wallet = buyerWallet(); + const approval = await wallet.writeContract({ + address: deployment.asset, + abi: erc20Abi, + functionName: 'approve', + args: [PERMIT2_ADDRESS, maxUint256], + }); + await wallet.waitForTransactionReceipt({ hash: approval }); + const env = await stack('permit2'); + const gateway = await startGateway(env); + const headers = await paidHeaders(gateway, 'authorization'); + expect(decode(headers[PAYMENT_HEADER])).toMatchObject({ + accepted: { extra: { assetTransferMethod: 'permit2' } }, + extensions: { 'payment-identifier': { info: { id: ID } } }, + }); + const before = await balances(); + + await loseResponse(gateway, 'authorization', headers); + const txHash = await expectOneCharge(before, gateway.store); + const response = await gateway.invoke('authorization', headers); + + expectRedelivered(response, txHash); + expect(merchant.keys).toHaveLength(1); + expect(settleCalls(env)).toBe(1); + assertBalanceDelta(before, await balances(), ONE_USDC); + }); + + it('redelivers an MPP exact resend with its Idempotency-Key and one charge', async () => { + const env = await stack('mpp'); + const gateway = await startGateway(env); + const headers = await paidHeaders(gateway, 'upfront'); + const before = await balances(); + + await loseResponse(gateway, 'upfront', headers); + const txHash = await expectOneCharge(before, gateway.store); + const response = await gateway.invoke('upfront', headers); + + expectRedelivered(response, txHash); + expect(Receipt.deserialize(String(response.headers['payment-receipt']))).toMatchObject({ + reference: txHash, + status: 'success', + }); + expect(merchant.keys).toHaveLength(1); + expect(settleCalls(env)).toBe(1); + assertBalanceDelta(before, await balances(), ONE_USDC); + }); +}); + +describe('one payment identifier across requests', () => { + it('settles two concurrent proofs with one ID once, then redelivers to the refused request', async () => { + const env = await stack(); + const gateway = await startGateway(env); + const proofs = [ + await paidHeaders(gateway, 'authorization'), + await paidHeaders(gateway, 'authorization'), + ]; + const before = await balances(); + let release = () => {}; + const released = new Promise((resolve) => { + release = resolve; + }); + // Hold the claim owner's backend call so the other request sees an in-flight key + merchant.pause = () => released; + + const requests = proofs.map((headers) => gateway.invoke('authorization', headers)); + const busy = await Promise.race(requests); + release(); + const responses = await Promise.all(requests); + + expect(busy.statusCode).toBe(409); + expect(busy.headers['retry-after']).toBe('1'); + expect(busy.json()).toMatchObject({ + code: 'PAYMENT_REPLAYED', + retryable: true, + details: { reason: 'payment_identifier_in_flight' }, + }); + expect(responses.map((response) => response.statusCode).sort()).toEqual([200, 409]); + const txHash = await expectOneCharge(before, gateway.store); + const waited = proofs[responses.indexOf(busy)] as Record; + expectRedelivered(await gateway.invoke('authorization', waited), txHash); + expect(merchant.keys).toHaveLength(1); + expect(settleCalls(env)).toBe(1); + assertBalanceDelta(before, await balances(), ONE_USDC); + }); + + it('reuses the merchant key for a re-signed retry after authorization backend failure, charging once', async () => { + const env = await stack(); + const gateway = await startGateway(env); + const before = await balances(); + merchant.status = 503; + + const failed = await gateway.invoke( + 'authorization', + await paidHeaders(gateway, 'authorization'), + ); + expect(failed.statusCode).toBe(502); + expect(await balances()).toEqual(before); + merchant.status = 200; + const paid = await gateway.invoke('authorization', await paidHeaders(gateway, 'authorization')); + + expect(paid.statusCode).toBe(200); + expect(paid.headers['idempotent-replayed']).toBeUndefined(); + expect(paid.json()).toEqual({ report: 'ok', call: 2 }); + await expectOneCharge(before, gateway.store); + expect(merchant.keys).toHaveLength(2); + expect(merchant.keys[0]).toMatch(/^[0-9a-f]{64}$/); + expect(merchant.keys[1]).toBe(merchant.keys[0]); + expect(settleCalls(env)).toBe(1); + }); + + it('returns 409 without a challenge for both retry styles after a settled upfront purchase fails delivery, charging once', async () => { + const env = await stack(); + const gateway = await startGateway(env); + const headers = await paidHeaders(gateway, 'upfront'); + const before = await balances(); + merchant.status = 503; + + const failed = await gateway.invoke('upfront', headers); + expect(failed.statusCode).toBe(502); + expect(failed.json()).toMatchObject({ code: 'BACKEND_ERROR' }); + const txHash = await expectOneCharge(before, gateway.store); + expect(decode(failed.headers[PAYMENT_RESPONSE_HEADER])).toMatchObject({ + success: true, + transaction: txHash, + }); + merchant.status = 200; + + expectUnresolved(await gateway.invoke('upfront', headers)); + expectUnresolved(await gateway.invoke('upfront', await paidHeaders(gateway, 'upfront'))); + expect(merchant.keys).toHaveLength(1); + expect(settleCalls(env)).toBe(1); + assertBalanceDelta(before, await balances(), ONE_USDC); + }); + + it('redelivers an exact resend from an emptied wallet but rejects a fresh proof with 402 and no charge', async () => { + const env = await stack(); + const gateway = await startGateway(env); + const headers = await paidHeaders(gateway, 'authorization'); + const before = await balances(); + expect((await gateway.invoke('authorization', headers)).statusCode).toBe(200); + const txHash = await expectOneCharge(before, gateway.store); + const fresh = await paidHeaders(gateway, 'authorization'); + const snapshot = await testClient().snapshot(); + try { + const wallet = buyerWallet(); + const drain = await wallet.writeContract({ + address: deployment.asset, + abi: erc20Abi, + functionName: 'transfer', + args: [privateKeyToAccount(generatePrivateKey()).address, (await balances()).buyer], + }); + await wallet.waitForTransactionReceipt({ hash: drain }); + const drained = await balances(); + expect(drained.buyer).toBe(0n); + + const resend = await gateway.invoke('authorization', headers); + const refused = await gateway.invoke('authorization', fresh); + + expectRedelivered(resend, txHash); + expect(refused.statusCode).toBe(402); + expect(refused.json()).toMatchObject({ code: 'PAYMENT_INVALID' }); + expect(refused.headers[PAYMENT_REQUIRED_HEADER]).toEqual(expect.any(String)); + expect(await balances()).toEqual(drained); + expect(merchant.keys).toHaveLength(1); + expect(settleCalls(env)).toBe(1); + } finally { + await testClient().revert({ id: snapshot }); + } + }); +}); + +describe('a restart during settlement', () => { + it('keeps the ID unresolved before and after reconcile: both retry styles get 409 without settlement', async () => { + const env = await stack(); + const first = await startGateway(env); + const headers = await paidHeaders(first, 'upfront'); + const before = await balances(); + const held = env.front.hold((path) => path === '/settle', { forward: false }); + + // Keep /settle blocked until cleanup. Close the old store before restarting to prevent later writes. + void first.invoke('upfront', headers).catch(() => {}); + await held; + await first.close(); + const second = await startGateway(env); + + expect((await second.store.summary({ orphanHoldMinAgeMs: 0 })).unresolvedPaymentKeys).toBe(1); + expectUnresolved(await second.invoke('upfront', headers)); + expectUnresolved(await second.invoke('upfront', await paidHeaders(second, 'upfront'))); + await second.close(); + const report = await reconcileExpired(env); + expect(report.entries).toEqual([ + expect.objectContaining({ outcome: 'not-settled', reason: 'expired_unused', written: true }), + ]); + const third = await startGateway(env); + expectUnresolved(await third.invoke('upfront', headers)); + expectUnresolved(await third.invoke('upfront', await paidHeaders(third, 'upfront'))); + + expect(settleCalls(env)).toBe(0); + expect(await balances()).toEqual(before); + expect(merchant.keys).toEqual([]); + }); +}); From 5b760bdea87b418d2b4b95b22929a3ba4eb83596 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sat, 7 Nov 2026 19:57:38 +0100 Subject: [PATCH 10/20] test(payments): cover payment identifiers on Solana and NEAR offers --- .../unit/payments-x402/provider-near.test.ts | 96 +++++++++++++- tests/unit/payments-x402/provider-svm.test.ts | 117 ++++++++++++++++-- 2 files changed, 199 insertions(+), 14 deletions(-) diff --git a/tests/unit/payments-x402/provider-near.test.ts b/tests/unit/payments-x402/provider-near.test.ts index 67e9bd8..cfeaf0f 100644 --- a/tests/unit/payments-x402/provider-near.test.ts +++ b/tests/unit/payments-x402/provider-near.test.ts @@ -3,9 +3,17 @@ * readiness, with the remote facilitator mocked at the SDK HTTP client. */ import { KeyPair } from '@near-js/crypto'; -import type { SupportedResponse } from '@x402/core/types'; +import { x402Client } from '@x402/core/client'; +import type { PaymentRequired, SupportedResponse } from '@x402/core/types'; +import { ExactNearScheme } from '@x402/near/exact/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { CommerceError, CommerceResource, PaymentContext } from '../../../src/core'; +import type { + CommerceError, + CommerceResource, + PaymentContext, + PaymentRequirement, + PaymentSubmission, +} from '../../../src/core'; import { isCommerceError, NOOP_LOGGER } from '../../../src/core'; import { createX402PaymentProvider, @@ -289,6 +297,8 @@ describe('the NEAR locator', () => { const provider = makeProvider({ logger: { ...NOOP_LOGGER, warn } }); const { context } = await paidRequest(provider); verifyMock.mockResolvedValueOnce({ isValid: true, payer: BUYER }); + // verify() imports the NEAR module on first use, and that load would wait on a faked timer + await import('../../../src/payments/x402/near'); vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); try { const pending = provider.verify(context); @@ -508,3 +518,85 @@ describe('health()', () => { expect(health.detail).toMatch(/does not advertise/); }); }); + +describe('payment-identifier on a NEAR offer', () => { + const ID = 'purchase-0123456789abcdef'; + const KEY = 'payment-identifier'; + const STORING: CommerceResource = { + ...RESOURCE, + idempotency: { redelivery: { retentionHours: 24, required: false } }, + }; + const challenge = (provider: ReturnType) => + provider.createRequirement({ ...paymentContext(), resource: STORING }); + const verifyAs = ( + provider: ReturnType, + requirement: PaymentRequirement, + submission: PaymentSubmission, + ) => provider.verify({ requestId: 'req-1', resource: STORING, requirement, submission }); + + // Add info to the extension echoed by the SDK's NEAR client. Sign the fixture delegate + // offline; the default signer would fetch the access-key nonce and block height from RPC. + async function sdkProof( + requirement: PaymentRequirement, + info: Record, + ): Promise { + const { signedDelegateAction } = await buildSignedDelegate({ + senderId: BUYER, + ftReceiver: PAY_TO, + }); + const client = new x402Client(); + client.register( + NETWORK, + new ExactNearScheme({ createSignedDelegateAction: async () => signedDelegateAction }), + ); + client.setSpendControls(false); + client.onBeforePaymentCreation(async ({ paymentRequired }) => { + const extensions = paymentRequired.extensions as Record; + Object.assign((extensions[KEY] as { info: object }).info, info); + }); + const envelope = structuredClone(requirement.challenge.envelope) as unknown as PaymentRequired; + const signed = await client.createPaymentPayload(envelope); + return { method: 'x402', payload: Buffer.from(JSON.stringify(signed)).toString('base64') }; + } + + it('declares the extension on a resource that stores responses', async () => { + const requirement = await challenge(makeProvider()); + + expect(requirement.challenge.envelope).toMatchObject({ + extensions: { [KEY]: { info: { required: false } } }, + }); + }); + + it('extracts the SDK proof ID and verifies with the delegate sender as payer, excluding the extension from the facilitator call', async () => { + const provider = makeProvider(); + const requirement = await challenge(provider); + const submission = await sdkProof(requirement, { id: ID }); + // Derive the payer from the signed delegate when the facilitator omits it + verifyMock.mockResolvedValueOnce({ isValid: true }); + + const key = provider.retryKey?.({ resource: STORING, submission }); + const result = await verifyAs(provider, requirement, submission); + + expect(key).toBe(ID); + expect(result).toMatchObject({ status: 'verified', payer: BUYER }); + expect(verifyMock).toHaveBeenCalledTimes(1); + const sent = JSON.stringify(verifyMock.mock.calls[0]); + expect(sent).not.toContain(ID); + expect(sent).not.toContain(KEY); + }); + + it('rejects a changed required flag before calling the facilitator', async () => { + const provider = makeProvider(); + const requirement = await challenge(provider); + const submission = await sdkProof(requirement, { required: true, id: ID }); + + const result = await verifyAs(provider, requirement, submission); + + expect(result).toMatchObject({ + status: 'rejected', + rejectionReason: 'extension_echo_mismatch', + replayKey: expect.any(String), + }); + expect(verifyMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/payments-x402/provider-svm.test.ts b/tests/unit/payments-x402/provider-svm.test.ts index 45ee966..892d028 100644 --- a/tests/unit/payments-x402/provider-svm.test.ts +++ b/tests/unit/payments-x402/provider-svm.test.ts @@ -4,8 +4,10 @@ * the readiness checks against a Solana RPC */ import { generateKeyPairSigner, type KeyPairSigner } from '@solana/kit'; -import type { PaymentRequirements, SupportedResponse } from '@x402/core/types'; +import { x402Client } from '@x402/core/client'; +import type { PaymentRequired, PaymentRequirements, SupportedResponse } from '@x402/core/types'; import { decodeTransactionFromPayload, transactionMessageHash } from '@x402/svm'; +import { ExactSvmScheme as ExactSvmClientScheme } from '@x402/svm/exact/client'; import { ExactSvmScheme } from '@x402/svm/exact/server'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { @@ -13,6 +15,7 @@ import type { CommerceResource, PaymentContext, PaymentRequirement, + PaymentSubmission, } from '../../../src/core'; import { isCommerceError } from '../../../src/core'; import { createPaymentProof } from '../../../src/payments/x402/client'; @@ -129,6 +132,18 @@ async function paidRequest(provider: ReturnType) { }; } +// Mock getAccountInfo for the USDC mint +function mintResult(decimals: number) { + const account = mintAccount(USDC, decimals, 1_000_000n); + return { + context: { slot: 1 }, + value: { + owner: account.owner, + data: [Buffer.from(account.data ?? []).toString('base64'), 'base64'], + }, + }; +} + beforeAll(async () => { buyer = await generateKeyPairSigner(); payTo = (await generateKeyPairSigner()).address; @@ -475,17 +490,6 @@ describe('health()', () => { vi.unstubAllGlobals(); }); - const mintResult = (decimals: number) => { - const account = mintAccount(USDC, decimals, 1_000_000n); - return { - context: { slot: 1 }, - value: { - owner: account.owner, - data: [Buffer.from(account.data ?? []).toString('base64'), 'base64'], - }, - }; - }; - it('passes for the named cluster, a mint with the configured decimals and a fee payer', async () => { rpc.mockImplementation((method: string) => method === 'getGenesisHash' ? DEVNET_GENESIS : mintResult(6), @@ -547,3 +551,92 @@ describe('health()', () => { expect(health.detail).toMatch(detail); }); }); + +describe('payment-identifier on a Solana offer', () => { + const ID = 'purchase-0123456789abcdef'; + const KEY = 'payment-identifier'; + const STORING: CommerceResource = { + ...RESOURCE, + idempotency: { redelivery: { retentionHours: 24, required: false } }, + }; + const challenge = (provider: ReturnType) => + provider.createRequirement({ ...paymentContext(), resource: STORING }); + const verifyAs = ( + provider: ReturnType, + requirement: PaymentRequirement, + submission: PaymentSubmission, + ) => provider.verify({ requestId: 'req-1', resource: STORING, requirement, submission }); + + // Supply the mint and recent blockhash requested by the SDK client + beforeEach(() => { + vi.stubGlobal('fetch', async (_url: string, init: { body: string }) => { + const { method } = JSON.parse(init.body) as { method: string }; + expect(['getAccountInfo', 'getLatestBlockhash']).toContain(method); + const result = + method === 'getAccountInfo' + ? mintResult(6) + : { context: { slot: 1 }, value: { blockhash: BLOCKHASH, lastValidBlockHeight: 100 } }; + return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result })); + }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // Add info to the extension echoed by the SDK's Solana client + async function sdkProof( + requirement: PaymentRequirement, + info: Record, + ): Promise { + const client = new x402Client(); + client.register(DEVNET, new ExactSvmClientScheme(buyer, { rpcUrl: RPC_URL })); + client.setSpendControls(false); + client.onBeforePaymentCreation(async ({ paymentRequired }) => { + const extensions = paymentRequired.extensions as Record; + Object.assign((extensions[KEY] as { info: object }).info, info); + }); + const envelope = structuredClone(requirement.challenge.envelope) as unknown as PaymentRequired; + const signed = await client.createPaymentPayload(envelope); + return { method: 'x402', payload: Buffer.from(JSON.stringify(signed)).toString('base64') }; + } + + it('declares the extension on a resource that stores responses', async () => { + const requirement = await challenge(makeProvider()); + + expect(requirement.challenge.envelope).toMatchObject({ + extensions: { [KEY]: { info: { required: false } } }, + }); + }); + + it('extracts the SDK proof ID and verifies without sending the extension to the facilitator', async () => { + const provider = makeProvider(); + const requirement = await challenge(provider); + const submission = await sdkProof(requirement, { id: ID }); + verifyMock.mockResolvedValueOnce({ isValid: true, payer: buyer.address }); + + const key = provider.retryKey?.({ resource: STORING, submission }); + const result = await verifyAs(provider, requirement, submission); + + expect(key).toBe(ID); + expect(result.status).toBe('verified'); + expect(verifyMock).toHaveBeenCalledTimes(1); + const sent = JSON.stringify(verifyMock.mock.calls[0]); + expect(sent).not.toContain(ID); + expect(sent).not.toContain(KEY); + }); + + it('rejects a changed required flag before calling the facilitator', async () => { + const provider = makeProvider(); + const requirement = await challenge(provider); + const submission = await sdkProof(requirement, { required: true, id: ID }); + + const result = await verifyAs(provider, requirement, submission); + + expect(result).toMatchObject({ + status: 'rejected', + rejectionReason: 'extension_echo_mismatch', + replayKey: expect.any(String), + }); + expect(verifyMock).not.toHaveBeenCalled(); + }); +}); From fb2b0f5bb04de6ece539bdc47692ce12cf63f392 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sat, 7 Nov 2026 22:12:59 +0100 Subject: [PATCH 11/20] fix(core): tie retry answers to the purchase's proof and mandate --- docs/contract-surface.txt | 2 +- docs/contracts.md | 8 + docs/migration-3.0.md | 9 +- docs/protocols/a2a.md | 3 +- docs/protocols/ap2.md | 7 + docs/protocols/mpp.md | 3 +- docs/security.md | 4 + src/core/domain/store-records.ts | 9 +- src/core/execution/pipeline/retry.ts | 61 ++++-- src/core/interfaces/store.ts | 5 + src/storage/receipts/idempotency.ts | 32 +-- src/testing/conformance/record-cases.ts | 31 +++ .../core/execution/pipeline-retry.test.ts | 189 ++++++++++++++++-- .../unit/storage-receipts/conformance.test.ts | 1 + 14 files changed, 315 insertions(+), 49 deletions(-) diff --git a/docs/contract-surface.txt b/docs/contract-surface.txt index f7fa196..b1d3943 100644 --- a/docs/contract-surface.txt +++ b/docs/contract-surface.txt @@ -509,7 +509,7 @@ interface ReceiptListOptions { interface ReceiptStore { appendEvent: (event: CommerceEvent) => Promise; - claimIdempotencyKey?: (scope: IdempotencyScope, fingerprint: string, expiresAt: IsoTimestamp) => Promise; + claimIdempotencyKey?: (scope: IdempotencyScope, fingerprint: string, expiresAt: IsoTimestamp, owner?: string) => Promise; close: () => Promise; countReceipts: () => Promise; countUncertainPaymentAttempts: () => Promise; diff --git a/docs/contracts.md b/docs/contracts.md index 168420e..621c3f9 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -254,6 +254,14 @@ persistence must not fail the commerce flow. facilitator. - `ReceiptStore.getIdempotencyKey` reads a claim without writing. It returns `undefined` if the claim is absent or is `completed` and expired. + - `claimIdempotencyKey` takes an optional fourth argument, `owner`, an opaque, + non-secret tag stored with a new claim. Both `claimIdempotencyKey` and + `getIdempotencyKey` return it on `in-flight` and `unresolved` results. + Completed claims return their response instead. The tag is unavailable + after release or expiry. The retry gate uses the claiming proof's digest + as the tag and answers only that proof before verification. A store that + omits `owner` still works, but every proof must be verified before an + unfinished claim is refused. - `recoverIdempotencyKeys` takes an optional `endpointPrefix`. Recovery matches the prefix literally, including case; omit it to include all endpoints. The ACP adapter passes `/` when recovering receipt-store claims. diff --git a/docs/migration-3.0.md b/docs/migration-3.0.md index 2b8fe2d..a695860 100644 --- a/docs/migration-3.0.md +++ b/docs/migration-3.0.md @@ -34,7 +34,14 @@ required in 3.0.0. Custom stores will need to implement: - Pending tasks: `putPendingTask`, `getPendingTask` and `takePendingTask`. - Payment attempts: `findPaymentAttemptByReplayKey`. -The 3.0.0 plan also requires `getIdempotencyKey` from the proposed 2.7.0 API. +The 3.0.0 plan also requires `getIdempotencyKey`, added in 2.7.0. Custom stores +implementing it should follow these 2.7.0 rules, checked by the +`@devlab.group/agent-commerce/testing` conformance cases: + +- `claimIdempotencyKey` takes an optional `owner` tag, which both it and + `getIdempotencyKey` return on `in-flight` and `unresolved` results. A store + that omits it forces verification of every proof before the gateway refuses + an unfinished payment key. The 3.0.0 plan removes these store settings deprecated in 2.6.0: diff --git a/docs/protocols/a2a.md b/docs/protocols/a2a.md index e08ff95..1261b38 100644 --- a/docs/protocols/a2a.md +++ b/docs/protocols/a2a.md @@ -132,7 +132,8 @@ On a resource with [`idempotency.redelivery`](../configuration.md#resources) payment submission; a resend returns `TaskNotFoundError`. To retry, start a new task with the same input and submit a proof with the same `id`. Redelivery completes the new task with `payment-completed` and the original settlement -entry, without another settlement. A refusal fails the task and puts its +entry, without another settlement. If the resource requires a mandate, the new +task must carry the same `_authorization`. A refusal fails the task and puts its `payment_identifier_*` reason in `x402.payment.error`. An in-flight refusal also consumes the task, so retry on another new task. diff --git a/docs/protocols/ap2.md b/docs/protocols/ap2.md index 464be0a..cdcc95a 100644 --- a/docs/protocols/ap2.md +++ b/docs/protocols/ap2.md @@ -60,6 +60,13 @@ settlement. A backend failure under the `authorization` flow also releases the mandate because settlement has not started. Otherwise the settlement outcome determines its final state. +On a resource with [`idempotency.redelivery`](../configuration.md#resources), a +retry with the purchase's client key and original authorization submission, +byte for byte, can retrieve the stored response. The mandate is not verified +or reserved again because the purchase consumed it. A missing or different +mandate prevents redelivery, as does a purchase made before the resource +required one. + ## Agent Commerce checkout profile AP2 leaves the checkout payload to the implementation. This gateway requires: diff --git a/docs/protocols/mpp.md b/docs/protocols/mpp.md index 0c00e9d..3189301 100644 --- a/docs/protocols/mpp.md +++ b/docs/protocols/mpp.md @@ -77,7 +77,8 @@ After delivery, reuse the key and input to retrieve the stored response without another charge. An exact resend of `Authorization` skips verification, so an expired challenge still works while the response is retained. A new credential must answer a fresh challenge and verify as the same payer; it is not settled -again. +again. Invalid credentials get the usual 402 without revealing whether the key +exists. Make the key random and keep it secret: with the original credential, it unlocks the stored response. The MCP and A2A adapters do not extract this diff --git a/docs/security.md b/docs/security.md index fa9c818..2e739b4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -523,6 +523,10 @@ real funds. | **an MPP config on Base mainnet without `allowMainnet`, with a local facilitator, a noncanonical asset or Base Sepolia's EIP-712 name** | `CONFIG_INVALID` at load under the relevant `payments.mpp` field | `tests/unit/config/schema.test.ts` | | **a valid MPP payment on Base mainnet, then the same credential again** | settled once on chain; the later replay is refused with 402 or 409 and moves no funds again | `tests/mainnet/mpp-base.smoke.test.ts` (real funds, run by hand) | | **`start` with a missing package in an enabled x402, MPP, MCP or AP2 startup import** | `CONFIG_INVALID` names the setting and package before the gateway listens or opens the receipt store. Solana, NEAR and CDP imports begin during provider construction, but their failures surface in provider health checks or payment requests | `tests/unit/gateway/main.test.ts` (each feature, from source), `tests/unit/gateway/optional-peer.test.ts` (the error mapping), CI bare install (x402, from the built package) | +| **a forged or junk proof presented with a known key** | the normal `PAYMENT_INVALID` refusal, without returning a stored response or revealing whether the key exists or what input it holds | `tests/unit/core/execution/pipeline-retry.test.ts` ("gives a forged proof with a known key the normal refusal", "tells a junk proof with a used key and other input nothing about the key"), `tests/unit/payments-x402/payment-id.test.ts` ("refuses a proof signed for another amount, whatever id it carries") | +| **a junk proof probing an in-flight or unresolved key** | the same `PAYMENT_INVALID` refusal as for an unused key; only the proof that owns the claim gets the 409 before verification | `tests/unit/core/execution/pipeline-retry.test.ts` ("tells a junk proof nothing about an in-flight key", "tells a junk proof nothing about an unresolved key", "refuses an exact resend of an in-flight purchase before verifying it") | +| **one key with different input** | a verified proof gets 409 `payment_identifier_conflict` after one `verify()` that moves no funds; the input is neither run nor answered from the stored response | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses the same key with other input once the proof verifies, settling nothing"), `tests/integration/payment-retries.test.ts` ("answers the same id with other input with a 409 and no challenge") | +| **a redelivery without the mandate the purchase used** | an exact resend falls through to normal verification, and a verified fresh proof gets 409 `payment_identifier_conflict`; the same holds when the resource began requiring a mandate after the purchase | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses %s the purchase used, serving nothing", "serves nothing once the resource requires a mandate it did not when it paid") | A facilitator verdict is a returned result. The SDK throws when a remote `/verify` answers with a non-2xx status; the binding turns that throw back into diff --git a/src/core/domain/store-records.ts b/src/core/domain/store-records.ts index 12e97d3..22dbca3 100644 --- a/src/core/domain/store-records.ts +++ b/src/core/domain/store-records.ts @@ -21,14 +21,17 @@ export interface IdempotencyScope { */ export const PAYMENT_KEY_ENDPOINT_PREFIX = 'payment:'; -/** Result of claiming an idempotency key. Experimental: may change in a minor release */ +/** + * Result of claiming an idempotency key. `owner` is the tag the claim was + * created with, if any. Experimental: may change in a minor release. + */ export type IdempotencyClaim = /** The caller owns the key and must perform the operation */ | { readonly kind: 'reserved' } /** Same fingerprint; an earlier claim is still in flight */ - | { readonly kind: 'in-flight' } + | { readonly kind: 'in-flight'; readonly owner?: string } /** Same fingerprint; an earlier attempt's outcome is unknown */ - | { readonly kind: 'unresolved' } + | { readonly kind: 'unresolved'; readonly owner?: string } /** A different fingerprint, regardless of the claim's state */ | { readonly kind: 'conflict' } /** Same fingerprint; replay the stored response */ diff --git a/src/core/execution/pipeline/retry.ts b/src/core/execution/pipeline/retry.ts index a0dd9b7..141883e 100644 --- a/src/core/execution/pipeline/retry.ts +++ b/src/core/execution/pipeline/retry.ts @@ -38,14 +38,17 @@ export interface RetryTicket { readonly expiresAt: string; /** A credential: never log it, and never send it to a merchant or facilitator */ readonly clientKey: string; + /** Tags the claim; only this proof gets an answer before verification */ readonly proofDigest: string; + readonly authorizationDigest?: string; } export interface RetryGate { /** - * Inspect retry state without changing the claim. Return a stored response - * for an exact proof resend, or a ticket for verification. Return `undefined` - * if redelivery is disabled or a proof or key is missing; unusable claims throw. + * Read retry state without changing the claim. For the owning proof, return + * the stored response or refuse an unfinished claim. Otherwise, return a + * ticket for verification, or `undefined` if redelivery is disabled or the + * proof or key is missing. */ readonly open: ( flow: Flow, @@ -58,7 +61,8 @@ export interface RetryGate { /** * Claim the key after proof verification. Return `undefined` when this request * owns the purchase. Reuse a stored response only if the verified payer is - * defined and matches its recorded payer. + * defined and matches its recorded payer, and any required authorization is + * the one recorded with it. */ readonly claim: ( flow: Flow, @@ -83,6 +87,7 @@ export interface RetryGate { // The stored row's body. Of the backend's headers, it keeps only the content type interface StoredDelivery { readonly proofDigest: string; + readonly authorizationDigest?: string; readonly payer?: string; readonly contentType?: string; readonly body: unknown; @@ -170,6 +175,7 @@ export function createRetryGate(ctx: PipelineContext): RetryGate { } const { store } = support; + const { authorization } = request; const ticket: RetryTicket = { kind: 'retry', store, @@ -184,16 +190,23 @@ export function createRetryGate(ctx: PipelineContext): RetryGate { ).toISOString(), clientKey, proofDigest: sha256Hex(`${payment.method}:${payment.payload}`), + ...(authorization !== undefined + ? { authorizationDigest: sha256Hex(`${authorization.method}:${authorization.payload}`) } + : {}), }; const existing = await reading(flow, resource, () => store.getIdempotencyKey(ticket.scope, fingerprint), ); - const replay = await acceptOrRefuse(flow, resource, provider, existing); - if (replay === undefined) return ticket; - const stored = await storedDelivery(flow, resource, provider, replay); - return stored.proofDigest === ticket.proofDigest - ? redeliver(flow, resource, provider, replay.status, stored, pipelineStart) + // Refusing an unverified proof would reveal the key's existence or input + if (existing?.kind === 'in-flight' || existing?.kind === 'unresolved') { + if (existing.owner !== ticket.proofDigest) return ticket; + throw await refusal(flow, resource, provider, existing.kind); + } + if (existing?.kind !== 'replay') return ticket; + const stored = readStoredDelivery(existing.body); + return stored?.proofDigest === ticket.proofDigest && authorizes(resource, stored, ticket) + ? redeliver(flow, resource, provider, existing.status, stored, pipelineStart) : ticket; } @@ -205,14 +218,15 @@ export function createRetryGate(ctx: PipelineContext): RetryGate { verification: PaymentResult, pipelineStart: number, ): Promise { - const { scope, fingerprint, expiresAt } = ticket; + const { scope, fingerprint, expiresAt, proofDigest } = ticket; const claimed = await reading(flow, resource, () => - ticket.store.claimIdempotencyKey(scope, fingerprint, expiresAt), + ticket.store.claimIdempotencyKey(scope, fingerprint, expiresAt, proofDigest), ); const replay = await acceptOrRefuse(flow, resource, provider, claimed); if (replay === undefined) return undefined; const stored = await storedDelivery(flow, resource, provider, replay); - if (verification.payer === undefined || verification.payer !== stored.payer) { + const samePayer = verification.payer !== undefined && verification.payer === stored.payer; + if (!samePayer || !authorizes(resource, stored, ticket)) { throw await refusal(flow, resource, provider, 'conflict'); } return redeliver(flow, resource, provider, replay.status, stored, pipelineStart); @@ -228,6 +242,9 @@ export function createRetryGate(ctx: PipelineContext): RetryGate { const contentType = response.headers['content-type']; const stored: StoredDelivery = { proofDigest: ticket.proofDigest, + ...(ticket.authorizationDigest !== undefined + ? { authorizationDigest: ticket.authorizationDigest } + : {}), // Record the verified buyer; settlement may name a fee payer instead ...(verification.payer !== undefined ? { payer: verification.payer } : {}), // Keep only Content-Type; never persist a backend Set-Cookie header @@ -375,14 +392,32 @@ export function createRetryGate(ctx: PipelineContext): RetryGate { return { open, claim, finishDelivered, finishFailed }; } +// Match required authorization by its original bytes: the purchase consumed +// the mandate, so it cannot be verified again +function authorizes( + resource: CommerceResource, + stored: StoredDelivery, + ticket: RetryTicket, +): boolean { + if ((resource.authorization?.required.length ?? 0) === 0) return true; + return ( + stored.authorizationDigest !== undefined && + stored.authorizationDigest === ticket.authorizationDigest + ); +} + function readStoredDelivery(value: unknown): StoredDelivery | undefined { if (!isRecord(value) || !Object.hasOwn(value, 'body')) return undefined; - const { proofDigest, payer, contentType, body, receiptId } = value; + const { proofDigest, authorizationDigest, payer, contentType, body, receiptId } = value; if (typeof proofDigest !== 'string' || typeof receiptId !== 'string') return undefined; + if (authorizationDigest !== undefined && typeof authorizationDigest !== 'string') { + return undefined; + } if (payer !== undefined && typeof payer !== 'string') return undefined; if (contentType !== undefined && typeof contentType !== 'string') return undefined; return { proofDigest, + ...(authorizationDigest !== undefined ? { authorizationDigest } : {}), ...(payer !== undefined ? { payer } : {}), ...(contentType !== undefined ? { contentType } : {}), body, diff --git a/src/core/interfaces/store.ts b/src/core/interfaces/store.ts index 8abbb5a..5d00f5a 100644 --- a/src/core/interfaces/store.ts +++ b/src/core/interfaces/store.ts @@ -226,11 +226,16 @@ export interface ReceiptStore { * `expiresAt` has passed. Other states do not expire. Return `reserved` for * a new scope, or `conflict` for a different `fingerprint`, regardless of * the existing claim's state. + * + * New claims retain `owner`, an opaque non-secret tag returned with + * `in-flight` and `unresolved` results. Completed claims return their response + * instead; the tag is unavailable after release or expiry. */ claimIdempotencyKey?( scope: IdempotencyScope, fingerprint: string, expiresAt: IsoTimestamp, + owner?: string, ): Promise; /** Record a claim's outcome. Experimental: may change in a minor release */ updateIdempotencyKey?(scope: IdempotencyScope, update: IdempotencyUpdate): Promise; diff --git a/src/storage/receipts/idempotency.ts b/src/storage/receipts/idempotency.ts index ad34beb..0cf983a 100644 --- a/src/storage/receipts/idempotency.ts +++ b/src/storage/receipts/idempotency.ts @@ -5,6 +5,9 @@ * * Only `completed` rows expire. An unresolved row blocks retries; deleting it * must remain an operator's decision. + * + * Unfinished rows keep the claimant's `owner` tag in `body_json`; completion + * replaces it with the response. */ import type Database from 'better-sqlite3'; import { @@ -13,6 +16,7 @@ import { type IdempotencyScope, toCommerceError, } from '../../core'; +import { isRecord } from '../../core/is-record'; import type { StoreGroupMethods } from '../../core/store-method-groups'; import { storedIso } from './params'; @@ -42,13 +46,11 @@ const scopeParams = (scope: IdempotencyScope) => ({ // A different fingerprint conflicts in every claim state function claimOf(row: ClaimRow, fingerprint: string): IdempotencyClaim { if (row.fingerprint !== fingerprint) return { kind: 'conflict' }; - if (row.state === 'unresolved') return { kind: 'unresolved' }; - if (row.state !== 'completed') return { kind: 'in-flight' }; - return { - kind: 'replay', - status: row.status ?? 500, - body: row.body_json === null ? null : JSON.parse(row.body_json), - }; + const body: unknown = row.body_json === null ? null : JSON.parse(row.body_json); + if (row.state === 'completed') return { kind: 'replay', status: row.status ?? 500, body }; + const kind = row.state === 'unresolved' ? 'unresolved' : 'in-flight'; + const owner = isRecord(body) ? body['owner'] : undefined; + return typeof owner === 'string' ? { kind, owner } : { kind }; } export function createIdempotencyMethods(db: Database.Database, clock: Clock): IdempotencyMethods { @@ -59,8 +61,8 @@ export function createIdempotencyMethods(db: Database.Database, clock: Clock): I ); const insertClaim = db.prepare( `INSERT INTO idempotency_keys - (deployment, endpoint, idempotency_key, fingerprint, state, created_at, expires_at) - VALUES (@deployment, @endpoint, @idempotency_key, @fingerprint, 'in_flight', @created_at, @expires_at)`, + (deployment, endpoint, idempotency_key, fingerprint, state, body_json, created_at, expires_at) + VALUES (@deployment, @endpoint, @idempotency_key, @fingerprint, 'in_flight', @body_json, @created_at, @expires_at)`, ); const expireCompleted = db.prepare( "DELETE FROM idempotency_keys WHERE expires_at <= ? AND state = 'completed'", @@ -81,7 +83,12 @@ export function createIdempotencyMethods(db: Database.Database, clock: Clock): I // Sweep expired completed rows during the claim; no background worker is needed const claim = db.transaction( - (scope: IdempotencyScope, fingerprint: string, expiresAt: string): IdempotencyClaim => { + ( + scope: IdempotencyScope, + fingerprint: string, + expiresAt: string, + owner: string | undefined, + ): IdempotencyClaim => { const now = clock.nowIso(); expireCompleted.run(now); const existing = selectClaim.get(scopeParams(scope)); @@ -89,6 +96,7 @@ export function createIdempotencyMethods(db: Database.Database, clock: Clock): I insertClaim.run({ ...scopeParams(scope), fingerprint, + body_json: owner !== undefined ? JSON.stringify({ owner }) : null, created_at: now, expires_at: expiresAt, }); @@ -99,9 +107,9 @@ export function createIdempotencyMethods(db: Database.Database, clock: Clock): I ); return { - async claimIdempotencyKey(scope, fingerprint, expiresAt) { + async claimIdempotencyKey(scope, fingerprint, expiresAt, owner) { try { - return claim.immediate(scope, fingerprint, storedIso(expiresAt, 'expiresAt')); + return claim.immediate(scope, fingerprint, storedIso(expiresAt, 'expiresAt'), owner); } catch (err) { throw toCommerceError(err, 'STORAGE_ERROR', 'Failed to claim idempotency key'); } diff --git a/src/testing/conformance/record-cases.ts b/src/testing/conformance/record-cases.ts index e62fa95..96822d6 100644 --- a/src/testing/conformance/record-cases.ts +++ b/src/testing/conformance/record-cases.ts @@ -218,6 +218,37 @@ const CLAIM_CASES: readonly CaseSpec[] = [ assert.deepEqual(await s.getIdempotencyKey(scope('unknown'), 'fp'), { kind: 'unresolved' }); }, }, + { + name: 'an unfinished claim returns its owner until it completes, and a conflict never does', + uses: CLAIM_READS, + async run({ store }) { + const s = claimReads(store); + const owned = { kind: 'in-flight', owner: 'proof-1' } as const; + assert.deepEqual(await s.claimIdempotencyKey(scope('k1'), 'fp', isoAt(DAY), 'proof-1'), { + kind: 'reserved', + }); + assert.deepEqual( + await s.claimIdempotencyKey(scope('k1'), 'fp', isoAt(DAY), 'proof-2'), + owned, + ); + assert.deepEqual(await s.getIdempotencyKey(scope('k1'), 'fp'), owned); + assert.deepEqual(await s.getIdempotencyKey(scope('k1'), 'other'), { kind: 'conflict' }); + + assert.equal(await s.recoverIdempotencyKeys({ release: false }), 1); + assert.deepEqual(await s.getIdempotencyKey(scope('k1'), 'fp'), { + kind: 'unresolved', + owner: 'proof-1', + }); + + await s.claimIdempotencyKey(scope('k2'), 'fp', isoAt(DAY), 'proof-3'); + await s.updateIdempotencyKey(scope('k2'), { kind: 'completed', status: 200, body: 'ok' }); + assert.deepEqual(await s.getIdempotencyKey(scope('k2'), 'fp'), { + kind: 'replay', + status: 200, + body: 'ok', + }); + }, + }, { name: 'recovery marks in-flight claims unresolved and counts them', uses: CLAIMS, diff --git a/tests/unit/core/execution/pipeline-retry.test.ts b/tests/unit/core/execution/pipeline-retry.test.ts index af653ae..be7f22c 100644 --- a/tests/unit/core/execution/pipeline-retry.test.ts +++ b/tests/unit/core/execution/pipeline-retry.test.ts @@ -55,6 +55,8 @@ const MERCHANT_KEY_FOR_ANON_PROOF = 'c60d2bf5d522d82f49c16d5e6771ae799127a6ed083dd3c3c2dd0bc130024b6f'; // sha256sum of x402:alice/1 const PROOF_DIGEST = '847918077efb9a729f6444e770b7b22a980d287fff7fa938dc5814f119fff09b'; +const proofDigest = (payload: string) => + createHash('sha256').update(`x402:${payload}`).digest('hex'); const REDELIVERY = { redelivery: { retentionHours: 24, required: false } }; const OPTED_IN: Partial = { @@ -369,6 +371,63 @@ describe.each([ expect(again.body).toEqual(first.body); }); + it.each([ + ['an exact resend without the mandate', 'alice/1', undefined], + ['a fresh proof without the mandate', 'alice/2', undefined], + ['a fresh proof with another mandate', 'alice/2', 'other-mandate'], + ])('refuses %s the purchase used, serving nothing', async (_name, payload, mandate) => { + const { pipeline, calls } = setup({ + settleAfterBackend, + authorization: true, + resource: { authorization: { required: ['ap2'] } }, + }); + await delivered( + pipeline.execute(paid('alice/1', { authorization: { method: 'ap2', payload: 'mandate' } })), + ); + const before = { ...calls }; + const retried = paid(payload, { + ...(mandate !== undefined ? { authorization: { method: 'ap2', payload: mandate } } : {}), + }); + + const error = await refusal(pipeline.execute(retried)); + + // The fake accepts the spent proof to exercise retry conflict handling + expect(error).toMatchObject({ code: 'PAYMENT_REPLAYED', httpStatus: 409 }); + expect(error.details).toEqual({ reason: 'payment_identifier_conflict' }); + expect(JSON.stringify(toErrorEnvelope(error))).not.toContain('paid data'); + expect(calls).toMatchObject({ + settle: before.settle, + backend: before.backend, + ap2: before.ap2, + }); + }); + + it.each([ + ['an exact resend without a mandate', 'alice/1', false], + ['a fresh proof with a mandate', 'alice/2', true], + ])( + 'serves %s nothing once the resource requires a mandate it did not when it paid', + async (_name, payload, withMandate) => { + const path = join(mkdtempSync(join(tmpdir(), 'retry-gate-')), 'receipts.sqlite'); + const before = setup({ settleAfterBackend, path }); + await delivered(before.pipeline.execute(paid())); + const after = setup({ + settleAfterBackend, + path, + authorization: true, + resource: { authorization: { required: ['ap2'] } }, + }); + const authorization = { method: 'ap2', payload: 'mandate' } as const; + const retried = paid(payload, withMandate ? { authorization } : {}); + + const error = await refusal(after.pipeline.execute(retried)); + + // The fake accepts the spent proof to exercise retry conflict handling + expect(error.details).toEqual({ reason: 'payment_identifier_conflict' }); + expect(after.calls).toMatchObject({ settle: 0, backend: 0 }); + }, + ); + it('rebuilds the original outcome, apart from the request id and duration', async () => { const { pipeline } = setup({ settleAfterBackend, @@ -459,16 +518,31 @@ describe.each([ expect((await row(store))?.kind).toBe('replay'); }); - it('refuses the same key with other input before any provider call', async () => { + it('refuses the same key with other input once the proof verifies, settling nothing', async () => { const { pipeline, calls } = setup({ settleAfterBackend }); await delivered(pipeline.execute(paid())); const before = { ...calls }; - const error = await refusal(pipeline.execute(paid('alice/1', { input: { city: 'Oslo' } }))); + const error = await refusal(pipeline.execute(paid('alice/2', { input: { city: 'Oslo' } }))); expect(error).toMatchObject({ code: 'PAYMENT_REPLAYED', httpStatus: 409 }); expect(error.details).toEqual({ reason: 'payment_identifier_conflict' }); - expect(calls).toEqual(before); + expect(calls).toEqual({ + ...before, + createRequirement: before.createRequirement + 1, + verify: before.verify + 1, + }); + }); + + it('tells a junk proof with a used key and other input nothing about the key', async () => { + const { pipeline, calls } = setup({ settleAfterBackend }); + await delivered(pipeline.execute(paid())); + + const error = await refusal(pipeline.execute(paid('forged/1', { input: { city: 'Oslo' } }))); + + expect(error.code).toBe('PAYMENT_INVALID'); + expect(error.details?.['reason']).toBe('invalid_signature'); + expect(calls.settle).toBe(1); }); }); @@ -532,10 +606,62 @@ describe('retry gate: an unresolved or concurrent key', () => { reason: 'payment_identifier_in_flight', retryAfterSeconds: 1, }); - expect(calls.verify).toBe(before.verify); + expect(calls.verify).toBe(before.verify + 1); expect(settles).toBe(1); }); + it('refuses an exact resend of an in-flight purchase before verifying it', async () => { + const settling = deferred(); + const entered = deferred(); + const { pipeline, calls } = setup({ + provider: { + settle: async () => { + entered.resolve(); + await settling.promise; + return { status: 'settled', provider: 'x402', amount: '0.01', currency: 'USDC' }; + }, + }, + }); + + const first = pipeline.execute(paid()); + await entered.promise; + const before = { ...calls }; + const error = await refusal(pipeline.execute(paid())); + const after = { ...calls }; + settling.resolve(); + await delivered(first); + + expect(error).toMatchObject({ retryable: true }); + expect(error.details?.['reason']).toBe('payment_identifier_in_flight'); + expect(after).toEqual(before); + }); + + it('tells a junk proof nothing about an in-flight key', async () => { + const settling = deferred(); + const entered = deferred(); + const { pipeline } = setup({ + provider: { + settle: async () => { + entered.resolve(); + await settling.promise; + return { status: 'settled', provider: 'x402', amount: '0.01', currency: 'USDC' }; + }, + }, + }); + + const first = pipeline.execute(paid()); + await entered.promise; + const sameInput = await refusal(pipeline.execute(paid('forged/1'))); + const otherInput = await refusal(pipeline.execute(paid('forged/2', { input: { a: 1 } }))); + settling.resolve(); + await delivered(first); + + for (const error of [sameInput, otherInput]) { + expect(error.code).toBe('PAYMENT_INVALID'); + expect(error.details?.['reason']).toBe('invalid_signature'); + } + }); + it('lets one settle when both pass the read before either claims', async () => { const barrier = deferred(); const { pipeline, calls } = setup({ verifyBarrier: barrier.promise }); @@ -574,14 +700,18 @@ describe('retry gate: an unresolved or concurrent key', () => { const lost = await Promise.race(runs); const during = await row(store); settling.resolve(); - await Promise.all(runs); + const results = await Promise.all(runs); + const won = results.findIndex((result) => (result as DeliveredOutcome).kind === 'delivered'); expect(lost).toMatchObject({ details: { reason: 'payment_identifier_in_flight' } }); - expect(during).toEqual({ kind: 'in-flight' }); + expect(during).toEqual({ + kind: 'in-flight', + owner: proofDigest(won === 0 ? 'alice/1' : 'alice/2'), + }); expect((await row(store))?.kind).toBe('replay'); }); - it('answers an unresolved key with 409 and no challenge, calling no provider', async () => { + it('answers an unresolved key with 409 and no challenge, verifying only a fresh proof', async () => { const { pipeline, calls } = setup({ settleAfterBackend: true, provider: { @@ -594,13 +724,36 @@ describe('retry gate: an unresolved or concurrent key', () => { const before = { ...calls }; const resend = await refusal(pipeline.execute(paid())); + const afterResend = { ...calls }; const fresh = await refusal(pipeline.execute(paid('alice/2'))); for (const error of [resend, fresh]) { expect(error).toMatchObject({ code: 'PAYMENT_REPLAYED', httpStatus: 409, retryable: false }); expect(error.details).toEqual({ reason: 'payment_identifier_unresolved' }); } - expect(calls).toEqual(before); + expect(afterResend).toEqual(before); + expect(calls).toEqual({ + ...before, + createRequirement: before.createRequirement + 1, + verify: before.verify + 1, + }); + }); + + it('tells a junk proof nothing about an unresolved key', async () => { + const { pipeline, store } = setup({ + provider: { + settle: async () => { + throw new Error('facilitator lost the response'); + }, + }, + }); + await refusal(pipeline.execute(paid())); + + const error = await refusal(pipeline.execute(paid('forged/1'))); + + expect(error.code).toBe('PAYMENT_INVALID'); + expect(error.details?.['reason']).toBe('invalid_signature'); + expect((await row(store))?.kind).toBe('unresolved'); }); it('marks every refusal as a rejected payment, by reason', async () => { @@ -846,7 +999,7 @@ describe('retry gate: how a claim ends', () => { }, }, 'PAYMENT_SETTLEMENT_FAILED', - { kind: 'unresolved' }, + { kind: 'unresolved', owner: PROOF_DIGEST }, ], [ 'settlement uncertain after the backend', @@ -859,7 +1012,7 @@ describe('retry gate: how a claim ends', () => { }, }, 'PAYMENT_SETTLEMENT_FAILED', - { kind: 'unresolved' }, + { kind: 'unresolved', owner: PROOF_DIGEST }, ], [ 'backend failed after an upfront settlement', @@ -869,7 +1022,7 @@ describe('retry gate: how a claim ends', () => { }, }, 'BACKEND_ERROR', - { kind: 'unresolved' }, + { kind: 'unresolved', owner: PROOF_DIGEST }, ], ] as const)('leaves the expected claim after: %s', async (_name, options, code, expected) => { const { pipeline, store } = setup(options as Setup); @@ -997,7 +1150,7 @@ describe('retry gate: store writes that fail after a charge', () => { expect(outcome.body).toEqual({ report: 'paid data' }); expect(outcome.persistenceFailures).toEqual(['receipt', 'response']); expect(attempted).toEqual(['unresolved']); - expect(await row(store)).toEqual({ kind: 'unresolved' }); + expect(await row(store)).toEqual({ kind: 'unresolved', owner: PROOF_DIGEST }); }); it('delivers, and closes the key, when the completed write fails', async () => { @@ -1047,7 +1200,7 @@ describe('retry gate: store writes that fail after a charge', () => { const error = await refusal(pipeline.execute(paid())); expect(error.code).toBe('BACKEND_ERROR'); - expect(await row(store)).toEqual({ kind: 'in-flight' }); + expect(await row(store)).toEqual({ kind: 'in-flight', owner: PROOF_DIGEST }); const { transactions } = await store.listTransactions({}); expect(transactions[0]?.persistenceFailures).toEqual(['response']); }); @@ -1062,7 +1215,7 @@ describe('retry gate: store writes that fail after a charge', () => { const error = await refusal(pipeline.execute(paid())); expect(error.code).toBe('STORAGE_ERROR'); - expect(await row(store)).toEqual({ kind: 'in-flight' }); + expect(await row(store)).toEqual({ kind: 'in-flight', owner: PROOF_DIGEST }); }); it('keeps the original error when the unresolved write fails', async () => { @@ -1078,7 +1231,7 @@ describe('retry gate: store writes that fail after a charge', () => { const error = await refusal(pipeline.execute(paid())); expect(error.code).toBe('PAYMENT_SETTLEMENT_FAILED'); - expect(await row(store)).toEqual({ kind: 'in-flight' }); + expect(await row(store)).toEqual({ kind: 'in-flight', owner: PROOF_DIGEST }); const { transactions } = await store.listTransactions({}); expect(transactions[0]?.persistenceFailures).toEqual(['response']); }); @@ -1115,8 +1268,9 @@ describe('retry gate: a stored row it cannot serve', () => { ['a receipt id that is not text', { ...STORED, receiptId: 7 }], ['no body', { ...STORED, body: undefined }], ['a payer that is not text', { ...STORED, payer: 7 }], + ['an authorization digest that is not text', { ...STORED, authorizationDigest: 7 }], ['a content type that is not text', { ...STORED, contentType: 7 }], - ])('answers unresolved for a row with %s, calling no provider', async (_name, body) => { + ])('answers unresolved for a row with %s, settling nothing', async (_name, body) => { const { pipeline, store, calls } = setup(); await plantRow(store, body); // With the receipt present, only the row's own fields can refuse @@ -1126,7 +1280,8 @@ describe('retry gate: a stored row it cannot serve', () => { expect(error.code).toBe('PAYMENT_REPLAYED'); expect(error.details).toEqual({ reason: 'payment_identifier_unresolved' }); - expect(calls).toMatchObject({ createRequirement: 0, verify: 0, settle: 0 }); + // Verify first when the stored row cannot identify its owning proof + expect(calls).toMatchObject({ createRequirement: 1, verify: 1, settle: 0 }); }); it.each([ diff --git a/tests/unit/storage-receipts/conformance.test.ts b/tests/unit/storage-receipts/conformance.test.ts index 1f09db8..767c6f5 100644 --- a/tests/unit/storage-receipts/conformance.test.ts +++ b/tests/unit/storage-receipts/conformance.test.ts @@ -108,6 +108,7 @@ describe('the conformance runner', () => { expect(cases.filter((c) => c.skip?.includes('getIdempotencyKey')).map((c) => c.name)).toEqual([ 'reading an idempotency scope answers as a claim would and creates nothing', 'an expired completed claim reads as absent, and other states never expire', + 'an unfinished claim returns its owner until it completes, and a conflict never does', 'recovery with an endpoint prefix leaves other endpoints alone', 'an endpoint prefix is literal text', ]); From 79c06aff603e590f836e2a5c17f63dda376e74a8 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sat, 7 Nov 2026 22:17:52 +0100 Subject: [PATCH 12/20] fix: bound the lifetime of payment retry keys --- docs/configuration.md | 21 +++++---- docs/contracts.md | 16 +++++-- docs/migration-3.0.md | 3 ++ docs/security.md | 6 +++ src/config/schema/resources.ts | 3 +- src/core/execution/pipeline/retry.ts | 22 +++++++-- src/core/interfaces/store.ts | 12 ++--- src/storage/receipts/idempotency.ts | 29 ++++++++---- src/storage/receipts/store.ts | 10 ++-- src/testing/conformance/record-cases.ts | 26 ++++++++++- tests/unit/config/schema.test.ts | 17 +++++++ .../core/execution/pipeline-retry.test.ts | 46 +++++++++++++++++++ .../unit/storage-receipts/conformance.test.ts | 3 +- tests/unit/storage-receipts/summary.test.ts | 16 +++++++ 14 files changed, 194 insertions(+), 36 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 371a6d0..e22710c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,16 +112,21 @@ 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 a positive integer (default 24). The retention window -starts at the initial retry check, before proof verification. `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). +`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. `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). 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. -Those requests may have charged; with `redelivery` enabled, matching retries -get 409 `payment_identifier_unresolved`. `doctor` reports unresolved keys as -INFO. Run one gateway process per receipt database. +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. @@ -819,7 +824,7 @@ below 24 hours · an invalid or zero `payTo`/`asset`/`recipient` · an `mimeType` that is not a media type, or on a resource that is not fixed-price 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 a positive integer · +`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`. diff --git a/docs/contracts.md b/docs/contracts.md index 621c3f9..95096e3 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -241,9 +241,12 @@ persistence must not fail the commerce flow. enabled, the pipeline uses a derived backend `Idempotency-Key` for the paid operation; an adapter's key takes precedence. - `redelivery` sets a retention deadline `retentionHours` hours after the - initial retry check, before proof verification. It requires a store with - the `idempotency_claims` and `response_redelivery` groups. Both `merchantKey: true` - and `redelivery` require the `deployment` option. `createGateway` throws + initial retry check, before proof verification. `retentionHours` is an + integer from 1 to 8760 (one year), default 24. Config load rejects other + values; a directly built pipeline throws `CONFIG_INVALID` before payment + work. `redelivery` requires a store with the `idempotency_claims` + and `response_redelivery` groups. Both `merchantKey: true` and `redelivery` + require the `deployment` option. `createGateway` throws `CONFIG_INVALID` for missing dependencies at startup; a directly built pipeline throws at payment admission, before any provider call. With `required` true, x402 retry-key extraction rejects a missing `id` @@ -253,7 +256,8 @@ persistence must not fail the commerce flow. Treat the key as a credential: never log it or send it to a merchant or facilitator. - `ReceiptStore.getIdempotencyKey` reads a claim without writing. It returns - `undefined` if the claim is absent or is `completed` and expired. + `undefined` for an absent or expired claim, using `claimIdempotencyKey`'s + expiry rule. Otherwise, it returns the same result as `claimIdempotencyKey`. - `claimIdempotencyKey` takes an optional fourth argument, `owner`, an opaque, non-secret tag stored with a new claim. Both `claimIdempotencyKey` and `getIdempotencyKey` return it on `in-flight` and `unresolved` results. @@ -262,6 +266,10 @@ persistence must not fail the commerce flow. as the tag and answers only that proof before verification. A store that omits `owner` still works, but every proof must be verified before an unfinished claim is refused. + - A claim sweep deletes expired `completed` claims and expired `unresolved` + claims on `payment:` endpoints, which hold client retry keys. `unresolved` + claims on other endpoints, including ACP's, and `in-flight` claims never + expire. `getIdempotencyKey` must apply the same expiry rule. - `recoverIdempotencyKeys` takes an optional `endpointPrefix`. Recovery matches the prefix literally, including case; omit it to include all endpoints. The ACP adapter passes `/` when recovering receipt-store claims. diff --git a/docs/migration-3.0.md b/docs/migration-3.0.md index a695860..865bec5 100644 --- a/docs/migration-3.0.md +++ b/docs/migration-3.0.md @@ -42,6 +42,9 @@ implementing it should follow these 2.7.0 rules, checked by the `getIdempotencyKey` return on `in-flight` and `unresolved` results. A store that omits it forces verification of every proof before the gateway refuses an unfinished payment key. +- `unresolved` claims on `payment:` endpoints expire at `expiresAt`, as + `completed` claims do. Apply this rule in both the claim sweep and + `getIdempotencyKey`. Other `unresolved` claims never expire. The 3.0.0 plan removes these store settings deprecated in 2.6.0: diff --git a/docs/security.md b/docs/security.md index 2e739b4..13b581f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -526,7 +526,13 @@ real funds. | **a forged or junk proof presented with a known key** | the normal `PAYMENT_INVALID` refusal, without returning a stored response or revealing whether the key exists or what input it holds | `tests/unit/core/execution/pipeline-retry.test.ts` ("gives a forged proof with a known key the normal refusal", "tells a junk proof with a used key and other input nothing about the key"), `tests/unit/payments-x402/payment-id.test.ts` ("refuses a proof signed for another amount, whatever id it carries") | | **a junk proof probing an in-flight or unresolved key** | the same `PAYMENT_INVALID` refusal as for an unused key; only the proof that owns the claim gets the 409 before verification | `tests/unit/core/execution/pipeline-retry.test.ts` ("tells a junk proof nothing about an in-flight key", "tells a junk proof nothing about an unresolved key", "refuses an exact resend of an in-flight purchase before verifying it") | | **one key with different input** | a verified proof gets 409 `payment_identifier_conflict` after one `verify()` that moves no funds; the input is neither run nor answered from the stored response | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses the same key with other input once the proof verifies, settling nothing"), `tests/integration/payment-retries.test.ts` ("answers the same id with other input with a 409 and no challenge") | +| **a crash during settlement** | the key becomes `unresolved` at startup. Exact resends and fresh proofs get 409 without settlement, even after `reconcile` records the attempt as not settled. The key expires at its retention deadline | `tests/integration/payment-retries.test.ts` ("marks a key left in flight unresolved at start, so a retry gets 409"), `tests/e2e/payment/safe-retries.e2e.test.ts` ("keeps the ID unresolved before and after reconcile: both retry styles get 409 without settlement") | +| **a key on a settled purchase with no stored response, such as an `upfront` backend failure** | while the key is unexpired, 409 `payment_identifier_unresolved` without a challenge or `Retry-After` | `tests/e2e/payment/safe-retries.e2e.test.ts` ("returns 409 without a challenge for both retry styles after a settled upfront purchase fails delivery, charging once"), `tests/integration/payment-retries.test.ts` ("answers a key with an unknown outcome with a 409, no Retry-After and no challenge"), `tests/unit/core/execution/pipeline-retry.test.ts` ("answers an unresolved key with 409 and no challenge, verifying only a fresh proof") | +| **an unresolved payment key after its retention window** | the key expires like a completed one; retries then follow the normal purchase flow. Unresolved ACP records never expire (see the ACP row above) | `tests/unit/core/execution/pipeline-retry.test.ts` ("keeps an unresolved key closed until retention ends, then sells again"), `tests/unit/storage-receipts/conformance.test.ts` ("an expired unresolved payment key reads as absent and can be claimed again") | +| **a stored response after its retention window** | the response is no longer served; retries follow the normal purchase flow. A later claim sweep deletes the expired row | `tests/unit/core/execution/pipeline-retry.test.ts` ("still serves a retry just before retention ends", "treats a retry after retention as a new purchase"), `tests/unit/storage-receipts/conformance.test.ts` ("an expired completed claim reads as absent, and other checkout claims never expire") | +| **removing `redelivery` or changing `merchant.publicBaseUrl` while a key is unresolved** | new requests cannot read the key; a fresh proof follows the normal purchase flow. `doctor` counts the unresolved key until its retention deadline | `tests/integration/payment-retries.test.ts` ("recovers the key without the opt-in, sells again, and doctor still counts the key", "sells again after publicBaseUrl changes, and doctor still counts the key") | | **a redelivery without the mandate the purchase used** | an exact resend falls through to normal verification, and a verified fresh proof gets 409 `payment_identifier_conflict`; the same holds when the resource began requiring a mandate after the purchase | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses %s the purchase used, serving nothing", "serves nothing once the resource requires a mandate it did not when it paid") | +| **a retention window outside 1 to 8760 hours** | config load refuses it, and a pipeline built without config throws `CONFIG_INVALID` before any payment work | `tests/unit/config/schema.test.ts` ("refuses a retention of %s hours as too long", "refuses a retention of %s", "accepts the longest retention, one year"), `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses a retention of %s hours before any provider call", "accepts a retention of one year") | A facilitator verdict is a returned result. The SDK throws when a remote `/verify` answers with a non-2xx status; the binding turns that throw back into diff --git a/src/config/schema/resources.ts b/src/config/schema/resources.ts index c330a14..a7e9d43 100644 --- a/src/config/schema/resources.ts +++ b/src/config/schema/resources.ts @@ -8,6 +8,7 @@ import { type Pricing, RESERVED_INPUT_FIELDS, } from '../../core'; +import { MAX_REDELIVERY_RETENTION_HOURS } from '../../core/execution/pipeline/retry'; import { MPP_PROFILE } from '../../payments/mpp/constants'; import { MCP_TOOL_NAME_PATTERN } from '../../protocols/mcp/constants'; import { normalizeResourceAuthorization } from './ap2'; @@ -310,7 +311,7 @@ function normalizeResourceIdempotency( retentionHours: toNumber( raw.redelivery.retentionHours ?? DEFAULT_REDELIVERY_RETENTION_HOURS, `${path}.redelivery.retentionHours`, - { min: 1 }, + { min: 1, max: MAX_REDELIVERY_RETENTION_HOURS }, ), required, }; diff --git a/src/core/execution/pipeline/retry.ts b/src/core/execution/pipeline/retry.ts index 141883e..362ca76 100644 --- a/src/core/execution/pipeline/retry.ts +++ b/src/core/execution/pipeline/retry.ts @@ -94,6 +94,9 @@ interface StoredDelivery { readonly receiptId: string; } +/** The longest `redelivery.retentionHours` a resource may set: one year */ +export const MAX_REDELIVERY_RETENTION_HOURS = 8760; + // The delay ACP gives its own in-flight answer const IN_FLIGHT_RETRY_AFTER_SECONDS = 1; @@ -128,12 +131,23 @@ export function assertRetrySupport( ): RetrySupport | undefined { const { merchantKey, redelivery } = resource.idempotency ?? {}; if (merchantKey !== true && redelivery === undefined) return undefined; + const invalid = (message: string) => + new CommerceError('CONFIG_INVALID', message, { + ...(requestId !== undefined ? { requestId } : {}), + resourceId: resource.id, + }); const unsupported = (needs: string) => - new CommerceError( - 'CONFIG_INVALID', - `Resource "${resource.id}" sets idempotency, which requires ${needs}`, - { ...(requestId !== undefined ? { requestId } : {}), resourceId: resource.id }, + invalid(`Resource "${resource.id}" sets idempotency, which requires ${needs}`); + // Bound retention to one year before computing its deadline + const hours = redelivery?.retentionHours; + if ( + hours !== undefined && + !(Number.isInteger(hours) && hours >= 1 && hours <= MAX_REDELIVERY_RETENTION_HOURS) + ) { + throw invalid( + `Resource "${resource.id}" sets idempotency.redelivery.retentionHours outside 1 to ${MAX_REDELIVERY_RETENTION_HOURS}`, ); + } if (deployment === undefined) throw unsupported("the pipeline's deployment option"); if (redelivery === undefined) return { deployment }; if ( diff --git a/src/core/interfaces/store.ts b/src/core/interfaces/store.ts index 5d00f5a..180b672 100644 --- a/src/core/interfaces/store.ts +++ b/src/core/interfaces/store.ts @@ -222,10 +222,10 @@ export interface ReceiptStore { * Idempotency claims for protocol adapters. A store must implement all three * methods or none. Experimental: may change in a minor release. * - * Atomically claim `scope` after deleting `completed` claims whose - * `expiresAt` has passed. Other states do not expire. Return `reserved` for - * a new scope, or `conflict` for a different `fingerprint`, regardless of - * the existing claim's state. + * Atomically claim `scope` after deleting expired `completed` claims and + * expired `unresolved` claims on `PAYMENT_KEY_ENDPOINT_PREFIX` endpoints. + * Expiry occurs at `expiresAt`; other claims do not expire. Return `reserved` + * for a new scope, or `conflict` for a different `fingerprint` in any state. * * New claims retain `owner`, an opaque non-secret tag returned with * `in-flight` and `unresolved` results. Completed claims return their response @@ -252,8 +252,8 @@ export interface ReceiptStore { }): Promise; /** * Read a claim without writing. Return `undefined` if the claim is absent - * or is `completed` and expired. Otherwise, return the same result as - * `claimIdempotencyKey`. Experimental: may change in a minor release. + * or expired under `claimIdempotencyKey`'s rule. Otherwise, return the same + * result as `claimIdempotencyKey`. Experimental: may change in a minor release. */ getIdempotencyKey?( scope: IdempotencyScope, diff --git a/src/storage/receipts/idempotency.ts b/src/storage/receipts/idempotency.ts index 0cf983a..31d7668 100644 --- a/src/storage/receipts/idempotency.ts +++ b/src/storage/receipts/idempotency.ts @@ -3,8 +3,9 @@ * state names (`in_flight`) so legacy rows can be copied without translating * their states. * - * Only `completed` rows expire. An unresolved row blocks retries; deleting it - * must remain an operator's decision. + * `completed` rows and unresolved payment retry keys expire at their retention + * deadline; retries then follow the normal purchase flow. Unresolved ACP rows + * block retries until an operator deletes them. * * Unfinished rows keep the claimant's `owner` tag in `body_json`; completion * replaces it with the response. @@ -16,6 +17,7 @@ import { type IdempotencyScope, toCommerceError, } from '../../core'; +import { PAYMENT_KEY_ENDPOINT_PREFIX } from '../../core/domain/store-records'; import { isRecord } from '../../core/is-record'; import type { StoreGroupMethods } from '../../core/store-method-groups'; import { storedIso } from './params'; @@ -53,6 +55,14 @@ function claimOf(row: ClaimRow, fingerprint: string): IdempotencyClaim { return typeof owner === 'string' ? { kind, owner } : { kind }; } +function hasExpired(row: ClaimRow, endpoint: string, now: string): boolean { + if (row.expires_at > now) return false; + return ( + row.state === 'completed' || + (row.state === 'unresolved' && endpoint.startsWith(PAYMENT_KEY_ENDPOINT_PREFIX)) + ); +} + export function createIdempotencyMethods(db: Database.Database, clock: Clock): IdempotencyMethods { const SCOPE = 'deployment = @deployment AND endpoint = @endpoint AND idempotency_key = @idempotency_key'; @@ -64,8 +74,10 @@ export function createIdempotencyMethods(db: Database.Database, clock: Clock): I (deployment, endpoint, idempotency_key, fingerprint, state, body_json, created_at, expires_at) VALUES (@deployment, @endpoint, @idempotency_key, @fingerprint, 'in_flight', @body_json, @created_at, @expires_at)`, ); - const expireCompleted = db.prepare( - "DELETE FROM idempotency_keys WHERE expires_at <= ? AND state = 'completed'", + // The same rule as hasExpired + const expireFinished = db.prepare( + `DELETE FROM idempotency_keys WHERE expires_at <= @now + AND (state = 'completed' OR (state = 'unresolved' AND ${ENDPOINT_HAS_PREFIX}))`, ); const completeClaim = db.prepare( `UPDATE idempotency_keys SET state = 'completed', status = @status, body_json = @body_json @@ -81,7 +93,7 @@ export function createIdempotencyMethods(db: Database.Database, clock: Clock): I `UPDATE idempotency_keys SET state = 'unresolved' WHERE ${IN_FLIGHT}`, ); - // Sweep expired completed rows during the claim; no background worker is needed + // Sweep expired rows during the claim; no background worker is needed const claim = db.transaction( ( scope: IdempotencyScope, @@ -90,7 +102,7 @@ export function createIdempotencyMethods(db: Database.Database, clock: Clock): I owner: string | undefined, ): IdempotencyClaim => { const now = clock.nowIso(); - expireCompleted.run(now); + expireFinished.run({ now, prefix: PAYMENT_KEY_ENDPOINT_PREFIX }); const existing = selectClaim.get(scopeParams(scope)); if (existing === undefined) { insertClaim.run({ @@ -119,8 +131,9 @@ export function createIdempotencyMethods(db: Database.Database, clock: Clock): I try { const existing = selectClaim.get(scopeParams(scope)); if (existing === undefined) return undefined; - const expired = existing.state === 'completed' && existing.expires_at <= clock.nowIso(); - return expired ? undefined : claimOf(existing, fingerprint); + return hasExpired(existing, scope.endpoint, clock.nowIso()) + ? undefined + : claimOf(existing, fingerprint); } catch (err) { throw toCommerceError(err, 'STORAGE_ERROR', 'Failed to read idempotency key'); } diff --git a/src/storage/receipts/store.ts b/src/storage/receipts/store.ts index 6fc72f0..616708b 100644 --- a/src/storage/receipts/store.ts +++ b/src/storage/receipts/store.ts @@ -509,11 +509,12 @@ function storeOn(db: Database.Database, options: SqliteReceiptStoreOptions): Sql const countOrphanHoldsStmt = db.prepare<[string], { count: number }>( `SELECT COUNT(*) AS count FROM authorization_holds h WHERE ${ORPHAN_HOLD_WHERE}`, ); + // Count only unexpired payment keys that can still block retries const countUnresolvedKeysStmt = db.prepare< - { prefix: string }, + { prefix: string; now: string }, { payment: number; other: number } >( - `SELECT COUNT(*) FILTER (WHERE ${ENDPOINT_HAS_PREFIX}) AS payment, + `SELECT COUNT(*) FILTER (WHERE ${ENDPOINT_HAS_PREFIX} AND expires_at > @now) AS payment, COUNT(*) FILTER (WHERE NOT ${ENDPOINT_HAS_PREFIX}) AS other FROM idempotency_keys WHERE state = 'unresolved'`, ); @@ -821,7 +822,10 @@ function storeOn(db: Database.Database, options: SqliteReceiptStoreOptions): Sql unresolvedTransactions[state] = (unresolvedTransactions[state] ?? 0) + 1; } const orphansBefore = new Date(Date.parse(now) - orphanHoldMinAgeMs).toISOString(); - const unresolvedKeys = countUnresolvedKeysStmt.get({ prefix: PAYMENT_KEY_ENDPOINT_PREFIX }); + const unresolvedKeys = countUnresolvedKeysStmt.get({ + prefix: PAYMENT_KEY_ENDPOINT_PREFIX, + now, + }); return { unresolvedTransactions, orphanHolds: countOrphanHoldsStmt.get(orphansBefore)?.count ?? 0, diff --git a/src/testing/conformance/record-cases.ts b/src/testing/conformance/record-cases.ts index 96822d6..14ffe29 100644 --- a/src/testing/conformance/record-cases.ts +++ b/src/testing/conformance/record-cases.ts @@ -7,6 +7,7 @@ import type { PendingTask, ReceiptStore, } from '../../core'; +import { PAYMENT_KEY_ENDPOINT_PREFIX } from '../../core/domain/store-records'; import { STORE_METHOD_GROUPS, type StoreGroupMethods } from '../../core/store-method-groups'; import { type CaseSpec, isoAt } from './support'; @@ -198,7 +199,7 @@ const CLAIM_CASES: readonly CaseSpec[] = [ }, }, { - name: 'an expired completed claim reads as absent, and other states never expire', + name: 'an expired completed claim reads as absent, and other checkout claims never expire', uses: CLAIM_READS, async run({ store, clock }) { const s = claimReads(store); @@ -218,6 +219,29 @@ const CLAIM_CASES: readonly CaseSpec[] = [ assert.deepEqual(await s.getIdempotencyKey(scope('unknown'), 'fp'), { kind: 'unresolved' }); }, }, + { + name: 'an expired unresolved payment key reads as absent and can be claimed again', + uses: CLAIM_READS, + async run({ store, clock }) { + const s = claimReads(store); + const paid = scope('paid', `${PAYMENT_KEY_ENDPOINT_PREFIX}report`); + await s.claimIdempotencyKey(paid, 'fp', isoAt(MINUTE)); + await s.updateIdempotencyKey(paid, { kind: 'unresolved' }); + await s.claimIdempotencyKey(scope('acp'), 'fp', isoAt(MINUTE)); + await s.updateIdempotencyKey(scope('acp'), { kind: 'unresolved' }); + + clock.advance(MINUTE - 1); + assert.deepEqual(await s.getIdempotencyKey(paid, 'fp'), { kind: 'unresolved' }); + + clock.advance(1); + assert.equal(await s.getIdempotencyKey(paid, 'fp'), undefined); + assert.deepEqual(await s.getIdempotencyKey(scope('acp'), 'fp'), { kind: 'unresolved' }); + assert.deepEqual(await s.claimIdempotencyKey(paid, 'fp', isoAt(DAY)), { kind: 'reserved' }); + assert.deepEqual(await s.claimIdempotencyKey(scope('acp'), 'fp', isoAt(DAY)), { + kind: 'unresolved', + }); + }, + }, { name: 'an unfinished claim returns its owner until it completes, and a conflict never does', uses: CLAIM_READS, diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index bae37e8..d766917 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -2604,6 +2604,23 @@ describe('resource idempotency', () => { expect(resource(raw, 'market_report')?.idempotency?.redelivery?.retentionHours).toBe(1); }); + it('accepts the longest retention, one year', () => { + const raw = withIdempotency('market_report', { redelivery: { retentionHours: 8760 } }); + + expect(resource(raw, 'market_report')?.idempotency?.redelivery?.retentionHours).toBe(8760); + }); + + it.each([[8761], ['8761'], [3_000_000_000]])( + 'refuses a retention of %s hours as too long', + (retentionHours) => { + expectConfigInvalid( + () => + parseConfig(withIdempotency('market_report', { redelivery: { retentionHours } }), {}), + `${REDELIVERY_PATH}.retentionHours`, + ); + }, + ); + it.each([[0], [-1], [1.5], ['soon']])('refuses a retention of %s', (retentionHours) => { expectConfigInvalid( () => parseConfig(withIdempotency('market_report', { redelivery: { retentionHours } }), {}), diff --git a/tests/unit/core/execution/pipeline-retry.test.ts b/tests/unit/core/execution/pipeline-retry.test.ts index be7f22c..04204b5 100644 --- a/tests/unit/core/execution/pipeline-retry.test.ts +++ b/tests/unit/core/execution/pipeline-retry.test.ts @@ -929,6 +929,30 @@ describe('retry gate: retention', () => { expect(calls.settle).toBe(2); }); + it('keeps an unresolved key closed until retention ends, then sells again', async () => { + let settles = 0; + const { pipeline, calls, clock } = setup({ + provider: { + settle: async () => { + settles += 1; + calls.settle += 1; + if (settles === 1) throw new Error('facilitator lost the response'); + return { status: 'settled', provider: 'x402', amount: '0.01', currency: 'USDC' }; + }, + }, + }); + await refusal(pipeline.execute(paid())); + + clock.advance(24 * HOUR_MS - 1); + const closed = await refusal(pipeline.execute(paid('alice/2'))); + clock.advance(1); + const again = await delivered(pipeline.execute(paid('alice/3'))); + + expect(closed.details?.['reason']).toBe('payment_identifier_unresolved'); + expect(again).not.toHaveProperty('redelivered'); + expect(calls.settle).toBe(2); + }); + it("uses the resource's own retention", async () => { const { pipeline, calls, clock } = setup({ resource: { idempotency: { redelivery: { retentionHours: 2, required: false } } }, @@ -1426,6 +1450,28 @@ describe('retry gate: a pipeline that cannot serve the resource', () => { expect(providerCalls).toEqual([]); }); + it.each([[0], [1.5], [8761], [3_000_000_000], [Number.POSITIVE_INFINITY]])( + 'refuses a retention of %s hours before any provider call', + async (retentionHours) => { + const resource = { idempotency: { redelivery: { retentionHours, required: false } } }; + const { pipeline, providerCalls } = pipelineWith(resource, sqliteWithout(), DEPLOYMENT); + + const error = await refusal(pipeline.execute(paid())); + + expect(error).toMatchObject({ code: 'CONFIG_INVALID', resourceId: 'res-1' }); + expect(providerCalls).toEqual([]); + }, + ); + + it('accepts a retention of one year', () => { + const resource = makeResource({ + ...OPTED_IN, + idempotency: { redelivery: { retentionHours: 8760, required: false } }, + }); + + expect(assertRetrySupport(resource, sqliteWithout(), DEPLOYMENT)).toBeDefined(); + }); + it('names no request when a caller outside a request checks support', () => { const resource = makeResource(OPTED_IN); diff --git a/tests/unit/storage-receipts/conformance.test.ts b/tests/unit/storage-receipts/conformance.test.ts index 767c6f5..23a6f28 100644 --- a/tests/unit/storage-receipts/conformance.test.ts +++ b/tests/unit/storage-receipts/conformance.test.ts @@ -107,7 +107,8 @@ describe('the conformance runner', () => { expect(cases.filter((c) => c.skip?.includes('getIdempotencyKey')).map((c) => c.name)).toEqual([ 'reading an idempotency scope answers as a claim would and creates nothing', - 'an expired completed claim reads as absent, and other states never expire', + 'an expired completed claim reads as absent, and other checkout claims never expire', + 'an expired unresolved payment key reads as absent and can be claimed again', 'an unfinished claim returns its owner until it completes, and a conflict never does', 'recovery with an endpoint prefix leaves other endpoints alone', 'an endpoint prefix is literal text', diff --git a/tests/unit/storage-receipts/summary.test.ts b/tests/unit/storage-receipts/summary.test.ts index bd6e215..ed47a40 100644 --- a/tests/unit/storage-receipts/summary.test.ts +++ b/tests/unit/storage-receipts/summary.test.ts @@ -158,6 +158,22 @@ describe('SQLite receipt store summary', () => { }); }); + it('stops counting a payment key once its retention ends, and keeps counting other claims', async () => { + const scope = (endpoint: string) => ({ deployment: 'https://gw.example', endpoint, key: 'k' }); + for (const endpoint of ['/acp/checkout_sessions', 'payment:report']) { + await store.claimIdempotencyKey(scope(endpoint), 'fp', isoAt(MINUTE)); + await store.updateIdempotencyKey(scope(endpoint), { kind: 'unresolved' }); + } + + clock.advance(MINUTE - 1); + const before = await store.summary(AGE); + clock.advance(1); + const after = await store.summary(AGE); + + expect(before).toMatchObject({ unresolvedIdempotencyKeys: 1, unresolvedPaymentKeys: 1 }); + expect(after).toMatchObject({ unresolvedIdempotencyKeys: 1, unresolvedPaymentKeys: 0 }); + }); + it('lists the recorded legacy imports, not a start with nothing to import', async () => { const db = new Database(path); const insert = db.prepare( From ce0d4d13eed9ee8240ead92d9ee37d5deb589275 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sat, 7 Nov 2026 22:21:50 +0100 Subject: [PATCH 13/20] fix: correct IDs, events and retry hints on retry answers --- docs/contracts.md | 12 ++- docs/protocols/a2a.md | 8 +- docs/protocols/x402.md | 3 +- docs/security.md | 1 + src/core/execution/pipeline/retry.ts | 93 ++++++++++++++----- src/protocols/a2a/payment-resume.ts | 16 +++- tests/integration/payment-retries.test.ts | 9 +- .../core/execution/pipeline-retry.test.ts | 59 +++++++++--- .../unit/protocols-a2a/payment-resume.test.ts | 93 +++++++++++++++++++ 9 files changed, 253 insertions(+), 41 deletions(-) create mode 100644 tests/unit/protocols-a2a/payment-resume.test.ts diff --git a/docs/contracts.md b/docs/contracts.md index 95096e3..18fc7bb 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -250,7 +250,10 @@ persistence must not fail the commerce flow. `CONFIG_INVALID` for missing dependencies at startup; a directly built pipeline throws at payment admission, before any provider call. With `required` true, x402 retry-key extraction rejects a missing `id` - with `INPUT_INVALID` (HTTP 400). + with `INPUT_INVALID` (HTTP 400). Missing required IDs, malformed IDs and + non-I-JSON input used with a key produce errors carrying `requestId` and + emit `payment.rejected`. The event's `data.reason` is the error's reason + token, or its code if no reason is set. - `CanonicalRequest.retryKey` holds the client's key for retrying a paid request. `PaymentProvider.retryKey` extracts it from a proof without I/O. Treat the key as a credential: never log it or send it to a merchant or @@ -279,7 +282,10 @@ persistence must not fail the commerce flow. - `redelivered` on `DeliveredOutcome` and `DeliverySummary` indicates that the request reused a stored response without another charge. `toDeliverySummary` includes the field only when true; original delivery - summaries omit it. + summaries omit it. The outcome and its events use the redelivery request's + `transactionId`. The original purchase's ID remains `receipt.transactionId` + and is copied to `resource.delivered` as `data.originalTransactionId` when + available. - `persistenceFailures` gains `response` when a delivered response cannot be stored for its client key. The pipeline attempts to mark the claim `unresolved`; in-flight and unresolved claims refuse retries with 409. @@ -287,6 +293,8 @@ persistence must not fail the commerce flow. `RETRYABLE_ERROR_CODES` for one error instance, in either direction. An in-flight client-key 409 sets it to true so callers can wait and resend without a new payment. Other refusals from the retry gate are not retryable. + The A2A x402 extension reports the in-flight refusal with `retryable: false` + and no `retryAfterSeconds`, because the proof's task has been consumed. Omitting the override keeps the code's default. - These fields and methods are experimental and may change in a minor release. The 3.0.0 migration plan makes `getIdempotencyKey` required. diff --git a/docs/protocols/a2a.md b/docs/protocols/a2a.md index 1261b38..2462668 100644 --- a/docs/protocols/a2a.md +++ b/docs/protocols/a2a.md @@ -133,9 +133,11 @@ payment submission; a resend returns `TaskNotFoundError`. To retry, start a new task with the same input and submit a proof with the same `id`. Redelivery completes the new task with `payment-completed` and the original settlement entry, without another settlement. If the resource requires a mandate, the new -task must carry the same `_authorization`. A refusal fails the task and puts its -`payment_identifier_*` reason in `x402.payment.error`. An in-flight refusal -also consumes the task, so retry on another new task. +task must carry the same `_authorization`. A refusal fails the task and records +its `payment_identifier_*` reason in `x402.payment.error`. An in-flight refusal +reports `retryable: false` without `retryAfterSeconds`, because the task has +been consumed. Start a new task with the same payment `id`. For the same refusal, +a `_payment` call reports `retryable: true` and `retryAfterSeconds: 1`. The extension applies only to x402. An MPP resource still returns a terminal task. diff --git a/docs/protocols/x402.md b/docs/protocols/x402.md index 628d6d5..efc3a0a 100644 --- a/docs/protocols/x402.md +++ b/docs/protocols/x402.md @@ -137,7 +137,8 @@ ID on retries so they identify the same purchase. During retry-key extraction, a supplied `id` must contain 16 to 128 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`. Invalid IDs fail with `INPUT_INVALID` and reason `payment_identifier_invalid`; a missing ID with `required: true` fails -with reason `payment_identifier_required`. Verification checks the echoed +with reason `payment_identifier_required`. Both errors carry the request ID +and emit `payment.rejected`. Verification checks the echoed `info.required`, rejecting a changed or missing value as `extension_echo_mismatch`. Additional `info` fields are allowed. With `required: false`, omitting the extension disables redelivery unless the caller diff --git a/docs/security.md b/docs/security.md index 13b581f..7271a1f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -533,6 +533,7 @@ real funds. | **removing `redelivery` or changing `merchant.publicBaseUrl` while a key is unresolved** | new requests cannot read the key; a fresh proof follows the normal purchase flow. `doctor` counts the unresolved key until its retention deadline | `tests/integration/payment-retries.test.ts` ("recovers the key without the opt-in, sells again, and doctor still counts the key", "sells again after publicBaseUrl changes, and doctor still counts the key") | | **a redelivery without the mandate the purchase used** | an exact resend falls through to normal verification, and a verified fresh proof gets 409 `payment_identifier_conflict`; the same holds when the resource began requiring a mandate after the purchase | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses %s the purchase used, serving nothing", "serves nothing once the resource requires a mandate it did not when it paid") | | **a retention window outside 1 to 8760 hours** | config load refuses it, and a pipeline built without config throws `CONFIG_INVALID` before any payment work | `tests/unit/config/schema.test.ts` ("refuses a retention of %s hours as too long", "refuses a retention of %s", "accepts the longest retention, one year"), `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses a retention of %s hours before any provider call", "accepts a retention of one year") | +| **an A2A in-flight refusal that tells the client to resend on the consumed task** | the failed task reports `retryable: false` with no `retryAfterSeconds`; the client starts a new task with the same payment `id` | `tests/unit/protocols-a2a/payment-resume.test.ts` ("does not tell the client to retry an in-flight payment id on the consumed task") | A facilitator verdict is a returned result. The SDK throws when a remote `/verify` answers with a non-2xx status; the binding turns that throw back into diff --git a/src/core/execution/pipeline/retry.ts b/src/core/execution/pipeline/retry.ts index 362ca76..b197905 100644 --- a/src/core/execution/pipeline/retry.ts +++ b/src/core/execution/pipeline/retry.ts @@ -1,5 +1,5 @@ // The retry gate: a client key either unlocks a stored response or claims the purchase -import type { PaymentProvider, PaymentResult } from '../../domain/payment'; +import type { PaymentProvider, PaymentResult, PaymentSubmission } from '../../domain/payment'; import type { CommerceReceipt } from '../../domain/receipt'; import type { DeliveredOutcome } from '../../domain/request'; import type { CommerceResource } from '../../domain/resource'; @@ -9,7 +9,7 @@ import { type IdempotencyUpdate, PAYMENT_KEY_ENDPOINT_PREFIX, } from '../../domain/store-records'; -import { CommerceError } from '../../errors'; +import { CommerceError, isCommerceError } from '../../errors'; import type { BackendResponse } from '../../interfaces/backend'; import type { ReceiptStore } from '../../interfaces/store'; import { isRecord } from '../../is-record'; @@ -176,15 +176,21 @@ export function createRetryGate(ctx: PipelineContext): RetryGate { if (support?.store === undefined || retentionHours === undefined || payment === undefined) { return undefined; } - const clientKey = request.retryKey ?? provider.retryKey?.({ resource, submission: payment }); + const clientKey = + request.retryKey ?? (await providerRetryKey(flow, resource, provider, payment)); if (clientKey === undefined) return undefined; const fingerprint = inputDigest(validInput); if (fingerprint === undefined) { - throw new CommerceError( - 'INPUT_INVALID', - `Input for resource "${resource.id}" is not I-JSON, so a retry key cannot identify it`, - { requestId: request.requestId, resourceId: resource.id }, + throw await rejected( + flow, + resource, + provider, + new CommerceError( + 'INPUT_INVALID', + `Input for resource "${resource.id}" is not I-JSON, so a retry key cannot identify it`, + { requestId: request.requestId, resourceId: resource.id }, + ), ); } @@ -330,13 +336,38 @@ export function createRetryGate(ctx: PipelineContext): RetryGate { // These 409s omit a payment challenge to avoid prompting another charge. // Only in-flight claims are retryable; the active request may finish on its own. - async function refusal( + function refusal( flow: Flow, resource: CommerceResource, provider: PaymentProvider, kind: RefusedClaim, ): Promise { const { reason, message } = REFUSALS[kind]; + const inFlight = kind === 'in-flight'; + return rejected( + flow, + resource, + provider, + new CommerceError('PAYMENT_REPLAYED', message, { + requestId: flow.request.requestId, + resourceId: resource.id, + details: { + reason, + ...(inFlight ? { retryAfterSeconds: IN_FLIGHT_RETRY_AFTER_SECONDS } : {}), + }, + ...(inFlight ? { retryable: true } : {}), + }), + ); + } + + // Emit the admission refusal with this request's ID + async function rejected( + flow: Flow, + resource: CommerceResource, + provider: PaymentProvider, + error: CommerceError, + ): Promise { + const reason = error.details?.['reason']; await safeEmit( buildEvent(flow, { type: 'payment.rejected', @@ -344,22 +375,42 @@ export function createRetryGate(ctx: PipelineContext): RetryGate { adapter: flow.request.protocol, paymentProvider: provider.name, status: 'error', - data: { reason }, + data: { reason: typeof reason === 'string' ? reason : error.code }, }), ); - const inFlight = kind === 'in-flight'; - return new CommerceError('PAYMENT_REPLAYED', message, { + if (error.requestId !== undefined) return error; + return new CommerceError(error.code, error.message, { requestId: flow.request.requestId, - resourceId: resource.id, - details: { - reason, - ...(inFlight ? { retryAfterSeconds: IN_FLIGHT_RETRY_AFTER_SECONDS } : {}), - }, - ...(inFlight ? { retryable: true } : {}), + resourceId: error.resourceId ?? resource.id, + httpStatus: error.httpStatus, + retryable: error.retryable, + ...(error.details !== undefined ? { details: error.details } : {}), + ...(error.cause !== undefined ? { cause: error.cause } : {}), }); } - // Writes no receipt, attempt or journal row: the original purchase holds them + // Treat an untyped provider error as provider unavailability + async function providerRetryKey( + flow: Flow, + resource: CommerceResource, + provider: PaymentProvider, + submission: PaymentSubmission, + ): Promise { + try { + return provider.retryKey?.({ resource, submission }); + } catch (error) { + const typed = isCommerceError(error) + ? error + : new CommerceError('PAYMENT_PROVIDER_UNAVAILABLE', 'Payment provider is unavailable', { + cause: error, + }); + throw await rejected(flow, resource, provider, typed); + } + } + + // Write no new receipt, payment attempt or journal row. The outcome and events use + // this request's transaction ID; the receipt and, when available, the event's + // originalTransactionId identify the original purchase. async function redeliver( flow: Flow, resource: CommerceResource, @@ -374,7 +425,7 @@ export function createRetryGate(ctx: PipelineContext): RetryGate { throw await refusal(flow, resource, provider, 'unresolved'); } const durationMs = Math.round(options.clock.monotonicMs() - pipelineStart); - const { transactionId } = receipt; + const original = receipt.transactionId; await safeEmit( buildEvent(flow, { type: 'resource.delivered', @@ -384,14 +435,14 @@ export function createRetryGate(ctx: PipelineContext): RetryGate { durationMs, data: { redelivered: true, - ...(transactionId !== undefined ? { originalTransactionId: transactionId } : {}), + ...(original !== undefined ? { originalTransactionId: original } : {}), }, }), ); return { kind: 'delivered', requestId: request.requestId, - ...(transactionId !== undefined ? { transactionId } : {}), + transactionId: flow.transactionId, resourceId: resource.id, backendStatus: status, body: stored.body, diff --git a/src/protocols/a2a/payment-resume.ts b/src/protocols/a2a/payment-resume.ts index 09a3f03..ff19da4 100644 --- a/src/protocols/a2a/payment-resume.ts +++ b/src/protocols/a2a/payment-resume.ts @@ -116,6 +116,20 @@ export async function resumePayment( ); } +// The follow-up consumes the task before the pipeline runs; resends get TaskNotFoundError. +// Suppress its retry delay so the client starts a new task with the same payment id. +function withoutRetryDelay(error: CommerceError): CommerceError { + if (error.details?.['reason'] !== 'payment_identifier_in_flight') return error; + const { retryAfterSeconds: _delay, ...details } = error.details; + return new CommerceError(error.code, error.message, { + httpStatus: error.httpStatus, + retryable: false, + details, + ...(error.requestId !== undefined ? { requestId: error.requestId } : {}), + ...(error.resourceId !== undefined ? { resourceId: error.resourceId } : {}), + }); +} + // The pending purchase, now with the x402 `PaymentPayload` as its proof async function payPending( context: ProtocolAdapterContext, @@ -152,7 +166,7 @@ async function payPending( // payment this task can report error = new CommerceError('PAYMENT_INVALID', 'The payment was not accepted.', { requestId }); } catch (err) { - error = toCommerceError(err); + error = withoutRetryDelay(toCommerceError(err)); context.logger.warn( { resourceId: pending.resourceId, requestId, err: toLogInfo(error) }, 'a2a adapter: x402 extension payment failed', diff --git a/tests/integration/payment-retries.test.ts b/tests/integration/payment-retries.test.ts index e202775..926e12a 100644 --- a/tests/integration/payment-retries.test.ts +++ b/tests/integration/payment-retries.test.ts @@ -802,6 +802,8 @@ interface Surface { readonly send: Send; // Validate the refusal format and return its error envelope readonly refusal: (answer: unknown, reason: string) => unknown; + // Protocol-specific retry flags and details, keyed by refusal reason + readonly refusalOverrides?: Record>; } function failedEnvelope(answer: unknown): unknown { @@ -829,6 +831,10 @@ const SURFACES: [string, Surface][] = [ [ 'the A2A x402 extension', { + // The consumed task requires a new task with the same payment id + refusalOverrides: { + payment_identifier_in_flight: { retryable: false, details: {} }, + }, send: async (resourceId, proof, input = {}) => (await a2aPay(await a2aChallenge(resourceId, input), proof)).result?.task, refusal(answer, reason) { @@ -933,7 +939,8 @@ const REFUSALS: [string, Refusal][] = [ ]; describe.each(SURFACES)('x402 retry-key refusals over %s', (_surface, surface) => { - it.each(REFUSALS)('refuses %s with its reason and no challenge', async (_case, row) => { + it.each(REFUSALS)('refuses %s with its reason and no challenge', async (_case, original) => { + const row = { ...original, ...surface.refusalOverrides?.[original.reason] }; const envelope = surface.refusal(await row.act(surface.send), row.reason); expect(envelope).toMatchObject({ diff --git a/tests/unit/core/execution/pipeline-retry.test.ts b/tests/unit/core/execution/pipeline-retry.test.ts index 04204b5..7fbcd64 100644 --- a/tests/unit/core/execution/pipeline-retry.test.ts +++ b/tests/unit/core/execution/pipeline-retry.test.ts @@ -306,25 +306,51 @@ describe('retry gate: requests it leaves alone', () => { details: { reason: 'payment_identifier_invalid' }, }); }; - const { pipeline, calls } = setup({ provider: { retryKey } }); + const { pipeline, calls, events } = setup({ provider: { retryKey } }); + const request = paid('alice/1', { retryKey: undefined as never }); - const error = await refusal( - pipeline.execute(paid('alice/1', { retryKey: undefined as never })), - ); + const error = await refusal(pipeline.execute(request)); - expect(error.code).toBe('INPUT_INVALID'); + expect(error).toMatchObject({ + code: 'INPUT_INVALID', + requestId: request.requestId, + resourceId: 'res-1', + }); expect(error.details?.['reason']).toBe('payment_identifier_invalid'); expect(calls.createRequirement).toBe(0); + const rejected = events.filter((event) => event.type === 'payment.rejected'); + expect(rejected.map((event) => event.data)).toEqual([{ reason: 'payment_identifier_invalid' }]); + }); + + it("reports a provider's untyped key failure as the provider's, before any payment work", async () => { + const retryKey: NonNullable = () => { + throw new Error('decoder bug'); + }; + const { pipeline, calls } = setup({ provider: { retryKey } }); + const request = paid('alice/1', { retryKey: undefined as never }); + + const error = await refusal(pipeline.execute(request)); + + expect(error).toMatchObject({ + code: 'PAYMENT_PROVIDER_UNAVAILABLE', + requestId: request.requestId, + }); + expect(error.message).not.toContain('decoder bug'); + expect(calls.createRequirement).toBe(0); }); it('refuses input that is not I-JSON before any payment work', async () => { - const { pipeline, calls, store } = setup(); + const { pipeline, calls, store, events } = setup(); + const request = paid('alice/1', { input: { text: '\uD800' } }); - const error = await refusal(pipeline.execute(paid('alice/1', { input: { text: '\uD800' } }))); + const error = await refusal(pipeline.execute(request)); - expect(error.code).toBe('INPUT_INVALID'); + expect(error).toMatchObject({ code: 'INPUT_INVALID', requestId: request.requestId }); expect(calls).toMatchObject({ createRequirement: 0, verify: 0, settle: 0 }); expect(await store.countReceipts()).toBe(0); + expect(events.find((event) => event.type === 'payment.rejected')?.data).toEqual({ + reason: 'INPUT_INVALID', + }); }); }); @@ -428,7 +454,7 @@ describe.each([ }, ); - it('rebuilds the original outcome, apart from the request id and duration', async () => { + it('rebuilds the original outcome, apart from the request and transaction ids and duration', async () => { const { pipeline } = setup({ settleAfterBackend, backend: async () => ({ @@ -446,9 +472,13 @@ describe.each([ expect(again).toEqual({ ...first, requestId: request.requestId, + transactionId: again.transactionId, durationMs: again.durationMs, redelivered: true, }); + expect(again.transactionId).toEqual(expect.any(String)); + expect(again.transactionId).not.toBe(first.transactionId); + expect(again.receipt.transactionId).toBe(first.transactionId); expect(again.payment?.metadata).toMatchObject({ receipt: 'mpp-receipt', amountBaseUnits: '10000', @@ -473,13 +503,18 @@ describe.each([ const first = await delivered(pipeline.execute(paid())); events.length = 0; - await delivered(pipeline.execute(paid())); + const again = await delivered(pipeline.execute(paid())); expect(events.map((event) => event.type)).toEqual(['resource.requested', 'resource.delivered']); expect(events[1]?.data).toEqual({ redelivered: true, originalTransactionId: first.transactionId, }); + // Redelivery uses a new transaction ID; event data keeps the purchase ID + expect(events.map((event) => event.transactionId)).toEqual([ + again.transactionId, + again.transactionId, + ]); }); it('refuses another payer, with no stored body in the error', async () => { @@ -1332,14 +1367,14 @@ describe('retry gate: a stored row it cannot serve', () => { expect(calls.settle).toBe(0); }); - it('serves a receipt that has no transaction id', async () => { + it('serves a receipt that has no transaction id, naming no original', async () => { const { pipeline, store, events } = setup(); await plantRow(store, STORED); await store.saveReceipt({ ...RECEIPT, payment: PAYMENT }); const again = await delivered(pipeline.execute(paid())); - expect(again).not.toHaveProperty('transactionId'); + expect(again.transactionId).toBe(events.at(-1)?.transactionId); expect(events.at(-1)?.data).toEqual({ redelivered: true }); }); diff --git a/tests/unit/protocols-a2a/payment-resume.test.ts b/tests/unit/protocols-a2a/payment-resume.test.ts new file mode 100644 index 0000000..ad68219 --- /dev/null +++ b/tests/unit/protocols-a2a/payment-resume.test.ts @@ -0,0 +1,93 @@ +// Pipeline refusals after a payment follow-up consumes the task +import { describe, expect, it, vi } from 'vitest'; +import { + type Clock, + CommerceError, + type Logger, + type ProtocolAdapterContext, +} from '../../../src/core'; +import { resumePayment } from '../../../src/protocols/a2a/payment-resume'; +import type { A2aTask } from '../../../src/protocols/a2a/types'; +import { createPendingPayments } from '../../../src/protocols/a2a/x402-extension'; + +const NOW = Date.parse('2026-11-07T00:00:00.000Z'); +const clock: Clock = { + now: () => new Date(NOW), + nowIso: () => new Date(NOW).toISOString(), + monotonicMs: () => NOW, +}; +const logger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => logger, +}; + +async function resumeWith(refusal: CommerceError) { + const pendingPayments = createPendingPayments(clock); + await pendingPayments.put('task-1', { contextId: 'ctx-1', resourceId: 'report', input: {} }); + let n = 0; + const context = { + pipeline: { execute: vi.fn().mockRejectedValue(refusal) }, + logger, + clock, + ids: { next: (prefix = 'id') => `${prefix}-${++n}` }, + } as unknown as ProtocolAdapterContext; + const message = { + taskId: 'task-1', + messageId: 'msg-1', + role: 'ROLE_USER', + metadata: { + 'x402.payment.status': 'payment-submitted', + 'x402.payment.payload': { scheme: 'exact' }, + }, + }; + + const response = await resumePayment(context, pendingPayments, 1, { message }); + const task = (response as { result: { task: A2aTask } }).result.task; + const envelope = task.artifacts?.[0]?.parts[0]?.data as Record; + return { task, envelope, pendingPayments }; +} + +describe('resumePayment pipeline refusals', () => { + it('does not tell the client to retry an in-flight payment id on the consumed task', async () => { + const inFlight = new CommerceError('PAYMENT_REPLAYED', 'A request is in progress', { + httpStatus: 409, + retryable: true, + requestId: 'req-1', + resourceId: 'report', + details: { reason: 'payment_identifier_in_flight', retryAfterSeconds: 1, extra: 'kept' }, + }); + + const { task, envelope, pendingPayments } = await resumeWith(inFlight); + + expect(task.status.state).toBe('TASK_STATE_FAILED'); + expect(envelope).toEqual({ + status: 'error', + code: 'PAYMENT_REPLAYED', + message: 'A request is in progress', + retryable: false, + requestId: 'req-1', + resourceId: 'report', + details: { reason: 'payment_identifier_in_flight', extra: 'kept' }, + }); + expect(task.status.message?.metadata).toMatchObject({ + 'x402.payment.status': 'payment-failed', + 'x402.payment.error': 'payment_identifier_in_flight', + }); + // The consumed task cannot accept another submission + expect(await pendingPayments.get('task-1')).toBeUndefined(); + }); + + it('keeps the retryable flag of every other refusal', async () => { + const unresolved = new CommerceError('PAYMENT_REPLAYED', 'Unresolved', { + httpStatus: 409, + details: { reason: 'payment_identifier_unresolved' }, + }); + const backend = new CommerceError('BACKEND_TIMEOUT', 'Timed out', { retryable: true }); + + expect((await resumeWith(unresolved)).envelope.retryable).toBe(false); + expect((await resumeWith(backend)).envelope.retryable).toBe(true); + }); +}); From 8c9ab22b59e19ec786290cab10432e708f2190f3 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sat, 7 Nov 2026 22:24:43 +0100 Subject: [PATCH 14/20] refactor: hash the paid input once and share the Idempotency-Key rule --- src/core/execution/journal.ts | 3 +- src/core/execution/merchant-key.ts | 13 ++++----- src/core/execution/pipeline/admission.ts | 18 +++++++----- src/core/execution/pipeline/retry.ts | 2 +- src/core/idempotency-key.ts | 11 +++++++ src/core/store-method-groups.ts | 2 +- src/gateway/routes.ts | 12 ++++---- src/gateway/server.ts | 4 +-- src/payments/x402/provider/payment-id.ts | 2 +- src/protocols/acp/idempotency/store.ts | 3 +- src/protocols/acp/request-guards.ts | 7 +---- src/storage/receipts/idempotency.ts | 4 +-- .../unit/core/execution/merchant-key.test.ts | 9 ++++-- tests/unit/protocols-acp/adapter.test.ts | 29 +++++++++++++++++++ 14 files changed, 78 insertions(+), 41 deletions(-) create mode 100644 src/core/idempotency-key.ts diff --git a/src/core/execution/journal.ts b/src/core/execution/journal.ts index ae65708..464d5a8 100644 --- a/src/core/execution/journal.ts +++ b/src/core/execution/journal.ts @@ -78,7 +78,7 @@ export function newTransaction( resource: CommerceResource, requirement: PaymentRequirement, verification: PaymentResult, - validInput: unknown, + digest: string | undefined, authorization: NewTransaction['authorization'], ): NewTransaction { const raw = verification.metadata?.[RECONCILIATION_METADATA_KEY]; @@ -86,7 +86,6 @@ export function newTransaction( const expiresAt = typeof locator?.['expiresAt'] === 'string' ? locator['expiresAt'] : undefined; const network = verification.network ?? requirement.network; const asset = verification.asset ?? requirement.asset; - const digest = inputDigest(validInput); return { id: flow.transactionId, protocol: flow.request.protocol, diff --git a/src/core/execution/merchant-key.ts b/src/core/execution/merchant-key.ts index a6054aa..3f5b61c 100644 --- a/src/core/execution/merchant-key.ts +++ b/src/core/execution/merchant-key.ts @@ -1,13 +1,13 @@ // The `Idempotency-Key` that names a paid operation to the merchant import { createHash } from 'node:crypto'; -import { inputDigest } from './journal'; export interface PaidOperation { /** `merchant.publicBaseUrl`, which outlives credential rotation */ readonly deployment: string; readonly resourceId: string; readonly replayKey: string; - readonly input: unknown; + /** `inputDigest` of the validated input */ + readonly inputDigest: string | undefined; /** Client retry credential; only its hash enters the payment identity */ readonly clientKey?: string; readonly payer?: string; @@ -18,19 +18,18 @@ export function sha256Hex(text: string): string { } /** - * Derive a merchant operation key, or return `undefined` when `inputDigest` does. + * Derive a merchant operation key, or return `undefined` without an input digest. * * Use the hash of `clientKey` with `payer` when both are nonempty; otherwise * use `replayKey`. The client key keeps the same identity across proof changes; * the payer distinguishes buyers sharing that key. The input digest separates - * bodies when a rejected proof is retried. Serialize fields as a JSON array so - * delimiters in URLs or resource IDs stay within their fields. Format changes + * bodies when a rejected proof is retried. Serialize fields as a JSON array to + * keep delimiters in URLs or resource IDs within their fields. Format changes * alter derived keys and can break merchant deduplication. */ export function paidOperationKey(operation: PaidOperation): string | undefined { - const digest = inputDigest(operation.input); + const { deployment, resourceId, replayKey, inputDigest: digest, clientKey, payer } = operation; if (digest === undefined) return undefined; - const { deployment, resourceId, replayKey, clientKey, payer } = operation; const identity = clientKey && payer ? `retry:${sha256Hex(clientKey)}:${payer}` : replayKey; return sha256Hex(JSON.stringify(['paid-operation', deployment, resourceId, identity, digest])); } diff --git a/src/core/execution/pipeline/admission.ts b/src/core/execution/pipeline/admission.ts index 16b1d31..cb4aea7 100644 --- a/src/core/execution/pipeline/admission.ts +++ b/src/core/execution/pipeline/admission.ts @@ -10,7 +10,7 @@ import type { } from '../../domain/request'; import type { CommerceResource, Pricing } from '../../domain/resource'; import { CommerceError, isCommerceError, toCommerceError } from '../../errors'; -import { type Flow, newTransaction } from '../journal'; +import { type Flow, inputDigest, newTransaction } from '../journal'; import { paidOperationKey } from '../merchant-key'; import { REUSE_REJECTED_AUTHORIZATION_METADATA_KEY } from '../settlement-order'; import { type AuthorizeAndReserve, resolveAuthorizationProviders } from './authorization'; @@ -65,8 +65,8 @@ export function createPaymentAdmission( ); } - // Resolved before the challenge so the 402 can name what the retry must - // also carry, and so an uncheckable method fails before payment starts + // Resolve authorization before building the challenge, so the 402 lists required + // methods and unsupported methods fail before payment starts const authProviders = resolveAuthorizationProviders( options.authorizationProviders ?? [], resource, @@ -208,9 +208,11 @@ export function createPaymentAdmission( }); } const replayKey = verification.replayKey; + // Reuse one input digest for the retry claim, journal and merchant key + const digest = retry?.fingerprint ?? inputDigest(validInput); - // Claim before authorization holds and replay reservations so a losing request has - // no new reservation to release + // Claim the retry key before authorization holds or replay reservations, so a losing + // request has no new reservation to release if (retry !== undefined) { const stored = await retryGate.claim( flow, @@ -251,12 +253,12 @@ export function createPaymentAdmission( resource, requirement, verification, - validInput, + digest, hold !== undefined ? { ...hold.record, reservationId: hold.reservationId } : undefined, ), }); // Require a matching transaction ID and recordTransactionStep before enabling - // journaling, so legacy stores can ignore the transaction data + // journaling; legacy stores can then ignore the transaction data flow.journaled = attempt.transactionId === flow.transactionId && options.store.recordTransactionStep !== undefined; @@ -298,7 +300,7 @@ export function createPaymentAdmission( deployment: support.deployment, resourceId: resource.id, replayKey, - input: validInput, + inputDigest: digest, ...(retry !== undefined ? { clientKey: retry.clientKey } : {}), ...(verification.payer !== undefined ? { payer: verification.payer } : {}), }) diff --git a/src/core/execution/pipeline/retry.ts b/src/core/execution/pipeline/retry.ts index b197905..0eba0b6 100644 --- a/src/core/execution/pipeline/retry.ts +++ b/src/core/execution/pipeline/retry.ts @@ -272,7 +272,7 @@ export function createRetryGate(ctx: PipelineContext): RetryGate { body: response.body ?? null, receiptId: receipt.id, }; - // Redelivery needs the original receipt, so a failed receipt write must block completion + // Complete the claim only if its original receipt was saved const receiptSaved = !flow.persistenceFailures.includes('receipt'); const update = { kind: 'completed', status: response.status, body: stored } as const; if (receiptSaved && (await write(flow, ticket, update))) return; diff --git a/src/core/idempotency-key.ts b/src/core/idempotency-key.ts new file mode 100644 index 0000000..56c5253 --- /dev/null +++ b/src/core/idempotency-key.ts @@ -0,0 +1,11 @@ +/** Maximum `Idempotency-Key` length for ACP and MPP */ +export const MAX_IDEMPOTENCY_KEY_LENGTH = 255; + +// Keys can be echoed in response headers and unlock stored responses. +// Printable ASCII prevents CRLF response splitting; errors must not expose the key. +const USABLE_KEY = new RegExp(`^[\\x20-\\x7e]{1,${MAX_IDEMPOTENCY_KEY_LENGTH}}$`); + +/** True for 1 to 255 printable ASCII characters */ +export function isUsableIdempotencyKey(value: string): boolean { + return USABLE_KEY.test(value); +} diff --git a/src/core/store-method-groups.ts b/src/core/store-method-groups.ts index ccc1435..effa1b8 100644 --- a/src/core/store-method-groups.ts +++ b/src/core/store-method-groups.ts @@ -12,7 +12,7 @@ export const STORE_METHOD_GROUPS = { 'listOrphanAuthorizationHolds', ], pending_tasks: ['putPendingTask', 'getPendingTask', 'takePendingTask'], - // Separate from `idempotency_claims` so stores can support claims without redelivery + // Separate from `idempotency_claims`: a store can support claims without redelivery response_redelivery: ['getIdempotencyKey'], } as const satisfies Record; diff --git a/src/gateway/routes.ts b/src/gateway/routes.ts index 1400eaf..569c890 100644 --- a/src/gateway/routes.ts +++ b/src/gateway/routes.ts @@ -29,6 +29,7 @@ import { toPaymentRequiredEnvelope, } from '../core'; import { journalOf, resolveManually } from '../core/execution/reconcile'; +import { isUsableIdempotencyKey } from '../core/idempotency-key'; import { isRecord } from '../core/is-record'; import { mppProblem, mppProblemDetail } from '../payments/mpp/problems'; import { CONTENT_DIGEST_METADATA_KEY, contentDigest } from '../payments/mpp/transport'; @@ -506,7 +507,7 @@ function sendInvokeError( if (PROVIDER_UNAVAILABLE_CODES.has(error.code)) { reply.header('retry-after', String(PROVIDER_RETRY_AFTER_SECONDS)); } - // In-flight retry claims supply this delay so the client can wait for completion + // Tell clients to wait before retrying an in-flight claim const retryAfter = details?.['retryAfterSeconds']; if (typeof retryAfter === 'number' && Number.isSafeInteger(retryAfter) && retryAfter >= 0) { reply.header('retry-after', String(retryAfter)); @@ -544,7 +545,7 @@ function sendInvokeError( paymentMethod === 'mpp' && (MPP_PROBLEM_CODES.has(error.code) || reason?.startsWith('payment_identifier_') === true) ) { - // RFC 9457 `status` is the HTTP status, so it replaces the envelope's + // RFC 9457 `status` is the HTTP status, which replaces the envelope's // `status: 'error'`; the other envelope fields stay as extension members const { status: _envelopeStatus, ...members } = envelope; reply @@ -567,14 +568,11 @@ function sendInvokeError( reply.status(status).send(envelope); } -// Use ACP's printable-ASCII length limit. The key unlocks a stored response, -// so validation errors must not quote it. -const IDEMPOTENCY_KEY_PATTERN = /^[\x20-\x7e]{1,255}$/; - +// The key unlocks a stored response, so validation errors must not quote it function mppRetryKey(request: FastifyRequest, resourceId: string): string | undefined { const value = request.headers['idempotency-key']; if (value === undefined) return undefined; - if (typeof value === 'string' && IDEMPOTENCY_KEY_PATTERN.test(value)) return value; + if (typeof value === 'string' && isUsableIdempotencyKey(value)) return value; throw new CommerceError( 'INPUT_INVALID', 'The Idempotency-Key header must be 1 to 255 printable ASCII characters', diff --git a/src/gateway/server.ts b/src/gateway/server.ts index b47ec5e..a3b4f90 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -96,7 +96,7 @@ function withProviderBackedMethodsFirst( : resource; } -// Mark interrupted payment claims unresolved so a retry returns 409 after a possible charge. +// Mark interrupted payment claims unresolved: a retry then returns 409 after a possible charge. // Require getIdempotencyKey too: the retry gate cannot write payment claims without it, and older // claim stores may ignore endpointPrefix and mark ACP claims unresolved. async function recoverPaymentKeys(store: ReceiptStore, logger: Logger): Promise { @@ -136,7 +136,7 @@ export async function createGateway(options: GatewayOptions): Promise | undefined { const redelivery = resource.idempotency?.redelivery; if (redelivery === undefined) return undefined; - // Build a fresh declaration so a buyer's in-place ID update cannot affect another challenge + // A fresh declaration keeps a buyer's in-place ID update from reaching another challenge return { [PAYMENT_IDENTIFIER]: { info: { required: redelivery.required }, diff --git a/src/protocols/acp/idempotency/store.ts b/src/protocols/acp/idempotency/store.ts index 87592cd..ee9fee8 100644 --- a/src/protocols/acp/idempotency/store.ts +++ b/src/protocols/acp/idempotency/store.ts @@ -23,6 +23,7 @@ */ import type { Database } from 'better-sqlite3'; import { type Logger, NOOP_LOGGER } from '../../../core'; +import { MAX_IDEMPOTENCY_KEY_LENGTH } from '../../../core/idempotency-key'; import { openSqliteDatabase } from '../../../storage/sqlite'; // v2 keys rows by the deployment's public base URL; v1 keyed them by a @@ -30,7 +31,7 @@ import { openSqliteDatabase } from '../../../storage/sqlite'; const SCHEMA_VERSION = 2; /** Longest `Idempotency-Key` ACP allows */ -export const ACP_MAX_IDEMPOTENCY_KEY_LENGTH = 255; +export const ACP_MAX_IDEMPOTENCY_KEY_LENGTH = MAX_IDEMPOTENCY_KEY_LENGTH; /** Scope of one claim: which deployment, which endpoint, which key */ export interface AcpIdempotencyScope { diff --git a/src/protocols/acp/request-guards.ts b/src/protocols/acp/request-guards.ts index 7ced711..9268ad6 100644 --- a/src/protocols/acp/request-guards.ts +++ b/src/protocols/acp/request-guards.ts @@ -6,6 +6,7 @@ * pipeline. */ import type { IncomingMessage } from 'node:http'; +import { isUsableIdempotencyKey } from '../../core/idempotency-key'; import { readCappedBody } from '../http'; import { ACP_API_VERSION, @@ -305,12 +306,6 @@ function forwardedHeaders( return forwarded; } -function isUsableIdempotencyKey(value: string): boolean { - return ( - value.length > 0 && value.length <= ACP_MAX_IDEMPOTENCY_KEY_LENGTH && isVisibleAscii(value) - ); -} - /** * The caller's correlation id, echoed back but never used as the gateway's own * request id. It is caller-controlled text bound for a response header, so it diff --git a/src/storage/receipts/idempotency.ts b/src/storage/receipts/idempotency.ts index 31d7668..bbfc10b 100644 --- a/src/storage/receipts/idempotency.ts +++ b/src/storage/receipts/idempotency.ts @@ -1,7 +1,7 @@ /** * Idempotency claims use the ACP store's rules and v2 columns. Keep its stored - * state names (`in_flight`) so legacy rows can be copied without translating - * their states. + * state names (`in_flight`) to copy legacy rows without translating their + * states. * * `completed` rows and unresolved payment retry keys expire at their retention * deadline; retries then follow the normal purchase flow. Unresolved ACP rows diff --git a/tests/unit/core/execution/merchant-key.test.ts b/tests/unit/core/execution/merchant-key.test.ts index c230b80..5e02373 100644 --- a/tests/unit/core/execution/merchant-key.test.ts +++ b/tests/unit/core/execution/merchant-key.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { inputDigest } from '../../../../src/core/execution/journal'; import { paidOperationKey } from '../../../../src/core/execution/merchant-key'; // Independent sha256sum fixtures detect format changes that could break merchant deduplication. @@ -11,7 +12,7 @@ const operation = { deployment: 'https://gw.example', resourceId: 'report', replayKey: '0xabc', - input: { city: 'Oslo' }, + inputDigest: inputDigest({ city: 'Oslo' }), }; const retried = { ...operation, clientKey: 'client-key-1', payer: '0xPAYER' }; @@ -31,7 +32,7 @@ describe('paidOperationKey', () => { it.each([ ['payer', { payer: '0xOTHER' }], ['client key', { clientKey: 'client-key-2' }], - ['input', { input: { city: 'Bergen' } }], + ['input', { inputDigest: inputDigest({ city: 'Bergen' }) }], ['resource', { resourceId: 'other' }], ['deployment', { deployment: 'https://other.example' }], ])('gives another %s a different key', (_name, change) => { @@ -65,6 +66,8 @@ describe('paidOperationKey', () => { }); it('gives no key for input that is not I-JSON', () => { - expect(paidOperationKey({ ...operation, input: { text: '\uD800' } })).toBeUndefined(); + expect( + paidOperationKey({ ...operation, inputDigest: inputDigest({ text: '\uD800' }) }), + ).toBeUndefined(); }); }); diff --git a/tests/unit/protocols-acp/adapter.test.ts b/tests/unit/protocols-acp/adapter.test.ts index e97721d..ca76a95 100644 --- a/tests/unit/protocols-acp/adapter.test.ts +++ b/tests/unit/protocols-acp/adapter.test.ts @@ -230,6 +230,18 @@ describe('ACP request guards', () => { 400, 'idempotency_key_invalid', ], + [ + 'an Idempotency-Key with a DEL character', + { 'idempotency-key': 'k\x7fx' }, + 400, + 'idempotency_key_invalid', + ], + [ + 'an Idempotency-Key with a non-ASCII character', + { 'idempotency-key': 'cl\u00e9' }, + 400, + 'idempotency_key_invalid', + ], [ 'a non-JSON content type', { 'content-type': 'application/x-www-form-urlencoded' }, @@ -258,6 +270,23 @@ describe('ACP request guards', () => { expect(execute).not.toHaveBeenCalled(); }); + // ACP does not parse the RFC 9651 quoted form: the quotes belong to the key + it.each([ + ['1 character', 'k'], + ['255 characters', 'k'.repeat(255)], + ['a quoted string', '"idem-key-1"'], + ['inner spaces', 'order 42'], + ])('accepts an Idempotency-Key of %s', async (_label, key) => { + const { context } = setup(deliveredFor('createCheckoutSession')); + + const result = await checkout(context, { + headers: goodHeaders({ 'idempotency-key': key }), + body: VALID_CREATE, + }); + + expect(result.status).toBe(201); + }); + it.each([ ['a missing', undefined], ['an unsupported', '2025-09-29'], From ed570602effaf3ce19127dc79f658d87b7d705a2 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sat, 7 Nov 2026 22:28:18 +0100 Subject: [PATCH 15/20] fix(mpp): parse quoted Idempotency-Key, redact it, use 422 for conflicts --- docs/protocols/mpp.md | 33 ++++++---- docs/security.md | 30 ++++++--- src/gateway/logger.ts | 11 +++- src/gateway/routes.ts | 25 ++++++-- src/payments/mpp/problems.ts | 14 +++-- tests/integration/payment-retries.test.ts | 74 +++++++++++++++++++++-- tests/unit/gateway/logger.test.ts | 8 ++- tests/unit/payments-mpp/problems.test.ts | 13 ++++ 8 files changed, 170 insertions(+), 38 deletions(-) diff --git a/docs/protocols/mpp.md b/docs/protocols/mpp.md index 3189301..616ddfa 100644 --- a/docs/protocols/mpp.md +++ b/docs/protocols/mpp.md @@ -69,9 +69,11 @@ sessions and the discovery extension. Experimental and disabled by default. On a resource with [`idempotency.redelivery`](../configuration.md#resources), the HTTP invoke -route reads the buyer's `Idempotency-Key` header. It accepts 1 to 255 printable -ASCII characters; invalid values return HTTP 400 `bad-request` with reason -`payment_identifier_invalid`. +route reads the buyer's `Idempotency-Key` header. Quoted values follow RFC 9651 +String syntax: `"abc"` and `abc` are the same key, and `\"` and `\\` are +unescaped. Bare keys, as sent by ACP clients, are also accepted. The decoded +key must contain 1 to 255 printable ASCII characters, as in ACP; invalid keys +return HTTP 400 `bad-request` with reason `payment_identifier_invalid`. After delivery, reuse the key and input to retrieve the stored response without another charge. An exact resend of `Authorization` skips verification, so an @@ -81,16 +83,21 @@ again. Invalid credentials get the usual 402 without revealing whether the key exists. Make the key random and keep it secret: with the original credential, it -unlocks the stored response. The MCP and A2A adapters do not extract this -header, and `redelivery.required` does not apply to MPP. - -Retry-key 409s use Problem Details with `type: about:blank` and title `Conflict`. -Using `invalid-challenge` could prompt a new challenge and another payment. -`details.reason` identifies the state: - -- `payment_identifier_conflict`: the key belongs to different input or another payer. -- `payment_identifier_in_flight`: a request with the key is still running. -- `payment_identifier_unresolved`: the earlier request may have been charged but has no +unlocks the stored response. The gateway's logger redacts the header; keep it +out of proxy, APM and access logs too. The MCP and A2A adapters do not extract +this header, and `redelivery.required` does not apply to MPP. + +Conflict, in-flight and unresolved refusals use Problem Details with +`type: about:blank` and the HTTP status phrase as `title`. An `invalid-challenge` +response could prompt another payment. Reusing a key with other input or +another payer returns 422 `Unprocessable Content`, following section 2.7 of +`draft-ietf-httpapi-idempotency-key-header-08`. A client may interpret 409 as +"retry unchanged" and keep retrying a request that cannot succeed. x402 uses +409 for this case. `details.reason` identifies the state: + +- `payment_identifier_conflict` (422): the key belongs to different input or another payer. +- `payment_identifier_in_flight` (409 `Conflict`, with `Retry-After`): a request with the key is still running. +- `payment_identifier_unresolved` (409 `Conflict`): the earlier request may have been charged but has no usable stored response. [http.md](http.md) lists the redelivery headers. diff --git a/docs/security.md b/docs/security.md index 7271a1f..b5cf7b2 100644 --- a/docs/security.md +++ b/docs/security.md @@ -29,13 +29,13 @@ signing key. Signing quotes does not transfer funds. The default request log records the method, the URL with any query string replaced by `?[REDACTED]`, the host, and the remote address and port, but no request headers. The built-in logger redacts the `Authorization`, -`PAYMENT-SIGNATURE` and `Agent-Authorization` request headers and the -`privateKey`, `signerPrivateKey`, `signature`, `seed`, `mnemonic`, `secret`, -`challengeSecret`, `apiKey`, `adminToken` and `token` fields. Some call sites -reduce caught errors with `describeError`. MCP, A2A and ACP log `toLogInfo()`: -the `CommerceError.toInfo()` fields, which can include details, plus the -`cause` chain with every URL cut to its scheme and host. The rest of each -cause's message is logged as thrown. +`PAYMENT-SIGNATURE`, `Agent-Authorization` and `Idempotency-Key` request +headers and the `privateKey`, `signerPrivateKey`, `signature`, `seed`, +`mnemonic`, `secret`, `challengeSecret`, `apiKey`, `adminToken` and `token` +fields. Some call sites reduce caught errors with `describeError`. MCP, A2A and +ACP log `toLogInfo()`: the `CommerceError.toInfo()` fields, which can include +details, plus the `cause` chain with every URL cut to its scheme and host. The +rest of each cause's message is logged as thrown. Logger field redaction covers the top level and one nested level. It does not cover a secret at `a.b.privateKey`, so do not log raw nested objects that may @@ -44,6 +44,15 @@ redaction is recursive, and the pipeline omits raw payment and authorization proofs from receipts. Custom code that writes receipt or event metadata must keep them out too. +On a resource with [`idempotency.redelivery`](configuration.md#resources), the +client's retry key is a credential. On x402, `PAYMENT-SIGNATURE` contains both +the proof and the buyer's `id` in unsigned `extensions`. MPP uses a separate +`Idempotency-Key` with the original `Authorization` credential. These can +retrieve the stored response until retention ends, with matching input and any +required mandate. Generate keys randomly and keep these credentials out of +proxy, APM and access logs. The gateway stores the key's SHA-256 hash; it does +not log the raw key, record it in events or send it to the merchant or facilitator. + Environment-substitution errors name the template token and path, never its resolved value. Later validation errors may echo substituted non-secret values, such as URLs, so keep secrets in fields designated for credentials. @@ -523,15 +532,20 @@ real funds. | **an MPP config on Base mainnet without `allowMainnet`, with a local facilitator, a noncanonical asset or Base Sepolia's EIP-712 name** | `CONFIG_INVALID` at load under the relevant `payments.mpp` field | `tests/unit/config/schema.test.ts` | | **a valid MPP payment on Base mainnet, then the same credential again** | settled once on chain; the later replay is refused with 402 or 409 and moves no funds again | `tests/mainnet/mpp-base.smoke.test.ts` (real funds, run by hand) | | **`start` with a missing package in an enabled x402, MPP, MCP or AP2 startup import** | `CONFIG_INVALID` names the setting and package before the gateway listens or opens the receipt store. Solana, NEAR and CDP imports begin during provider construction, but their failures surface in provider health checks or payment requests | `tests/unit/gateway/main.test.ts` (each feature, from source), `tests/unit/gateway/optional-peer.test.ts` (the error mapping), CI bare install (x402, from the built package) | +| **another payer's valid proof under a stored key** | 409 `payment_identifier_conflict` after verification (422 on MPP); the error carries no stored body and nothing settles | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses another payer, with no stored body in the error", "refuses a verified proof with an unknown payer"), `tests/integration/payment-retries.test.ts` ("answers a key used by another payer with a 422 about:blank, never invalid-challenge") | | **a forged or junk proof presented with a known key** | the normal `PAYMENT_INVALID` refusal, without returning a stored response or revealing whether the key exists or what input it holds | `tests/unit/core/execution/pipeline-retry.test.ts` ("gives a forged proof with a known key the normal refusal", "tells a junk proof with a used key and other input nothing about the key"), `tests/unit/payments-x402/payment-id.test.ts` ("refuses a proof signed for another amount, whatever id it carries") | | **a junk proof probing an in-flight or unresolved key** | the same `PAYMENT_INVALID` refusal as for an unused key; only the proof that owns the claim gets the 409 before verification | `tests/unit/core/execution/pipeline-retry.test.ts` ("tells a junk proof nothing about an in-flight key", "tells a junk proof nothing about an unresolved key", "refuses an exact resend of an in-flight purchase before verifying it") | -| **one key with different input** | a verified proof gets 409 `payment_identifier_conflict` after one `verify()` that moves no funds; the input is neither run nor answered from the stored response | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses the same key with other input once the proof verifies, settling nothing"), `tests/integration/payment-retries.test.ts` ("answers the same id with other input with a 409 and no challenge") | +| **one key with different input** | a verified proof gets 409 `payment_identifier_conflict` (422 on MPP) after one `verify()` that moves no funds; the input is neither run nor answered from the stored response | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses the same key with other input once the proof verifies, settling nothing"), `tests/integration/payment-retries.test.ts` ("answers the same id with other input with a 409 and no challenge") | | **a crash during settlement** | the key becomes `unresolved` at startup. Exact resends and fresh proofs get 409 without settlement, even after `reconcile` records the attempt as not settled. The key expires at its retention deadline | `tests/integration/payment-retries.test.ts` ("marks a key left in flight unresolved at start, so a retry gets 409"), `tests/e2e/payment/safe-retries.e2e.test.ts` ("keeps the ID unresolved before and after reconcile: both retry styles get 409 without settlement") | | **a key on a settled purchase with no stored response, such as an `upfront` backend failure** | while the key is unexpired, 409 `payment_identifier_unresolved` without a challenge or `Retry-After` | `tests/e2e/payment/safe-retries.e2e.test.ts` ("returns 409 without a challenge for both retry styles after a settled upfront purchase fails delivery, charging once"), `tests/integration/payment-retries.test.ts` ("answers a key with an unknown outcome with a 409, no Retry-After and no challenge"), `tests/unit/core/execution/pipeline-retry.test.ts` ("answers an unresolved key with 409 and no challenge, verifying only a fresh proof") | | **an unresolved payment key after its retention window** | the key expires like a completed one; retries then follow the normal purchase flow. Unresolved ACP records never expire (see the ACP row above) | `tests/unit/core/execution/pipeline-retry.test.ts` ("keeps an unresolved key closed until retention ends, then sells again"), `tests/unit/storage-receipts/conformance.test.ts` ("an expired unresolved payment key reads as absent and can be claimed again") | +| **exposure of client keys and proofs** | client keys are stored as SHA-256 hashes, and logs redact proof and `Idempotency-Key` headers. Raw keys and proofs stay out of events and merchant requests; the merchant key derives from the client key hash and payer. The facilitator receives the proof without extensions | `tests/unit/core/execution/pipeline-retry.test.ts` ("puts no client key or proof in a log line, an event or an error", "never sends the client key to the merchant"), `tests/unit/gateway/logger.test.ts` ("redacts every wire header that carries a credential or a proof"), `tests/unit/payments-x402/payment-id.test.ts` ("sends the facilitator no part of the extension"), `tests/integration/merchant-idempotency.test.ts` ("sends the merchant no part of the proof") | | **a stored response after its retention window** | the response is no longer served; retries follow the normal purchase flow. A later claim sweep deletes the expired row | `tests/unit/core/execution/pipeline-retry.test.ts` ("still serves a retry just before retention ends", "treats a retry after retention as a new purchase"), `tests/unit/storage-receipts/conformance.test.ts` ("an expired completed claim reads as absent, and other checkout claims never expire") | | **removing `redelivery` or changing `merchant.publicBaseUrl` while a key is unresolved** | new requests cannot read the key; a fresh proof follows the normal purchase flow. `doctor` counts the unresolved key until its retention deadline | `tests/integration/payment-retries.test.ts` ("recovers the key without the opt-in, sells again, and doctor still counts the key", "sells again after publicBaseUrl changes, and doctor still counts the key") | +| **an MPP key conflict, in-flight or unresolved answer that invites a new challenge** | Problem Details with `type: about:blank` and no `WWW-Authenticate`; never `invalid-challenge`, which could prompt a second payment | `tests/integration/payment-retries.test.ts` ("answers a key used by another payer with a 422 about:blank, never invalid-challenge", "answers a request in flight as Problem Details with Retry-After", "answers a charged key with no stored response as Problem Details without Retry-After"), `tests/unit/payments-mpp/problems.test.ts` ("answers the retry-key 409 %s with about:blank, not invalid-challenge") | +| **an MPP key conflict read as "retry unchanged"** | a key reused with other input or another payer answers 422 `Unprocessable Content`, not 409; in-flight and unresolved stay 409 `Conflict` | `tests/integration/payment-retries.test.ts` ("answers a key used by another payer with a 422 about:blank, never invalid-challenge"), `tests/unit/payments-mpp/problems.test.ts` ("answers a retry key reused with other input as 422 about:blank") | | **a redelivery without the mandate the purchase used** | an exact resend falls through to normal verification, and a verified fresh proof gets 409 `payment_identifier_conflict`; the same holds when the resource began requiring a mandate after the purchase | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses %s the purchase used, serving nothing", "serves nothing once the resource requires a mandate it did not when it paid") | +| **the payment proof header as a bearer credential** | with matching input and any required mandate, `PAYMENT-SIGNATURE` retrieves the stored response until retention ends. Keep it out of proxy, APM and access logs; the gateway's logger redacts it | `tests/integration/payment-retries.test.ts` ("redelivers an exact resend, marked as a replay, without a second charge"), `tests/unit/gateway/logger.test.ts` ("redacts every wire header that carries a credential or a proof") | | **a retention window outside 1 to 8760 hours** | config load refuses it, and a pipeline built without config throws `CONFIG_INVALID` before any payment work | `tests/unit/config/schema.test.ts` ("refuses a retention of %s hours as too long", "refuses a retention of %s", "accepts the longest retention, one year"), `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses a retention of %s hours before any provider call", "accepts a retention of one year") | | **an A2A in-flight refusal that tells the client to resend on the consumed task** | the failed task reports `retryable: false` with no `retryAfterSeconds`; the client starts a new task with the same payment `id` | `tests/unit/protocols-a2a/payment-resume.test.ts` ("does not tell the client to retry an in-flight payment id on the consumed task") | diff --git a/src/gateway/logger.ts b/src/gateway/logger.ts index 2ce09d9..7eec6e1 100644 --- a/src/gateway/logger.ts +++ b/src/gateway/logger.ts @@ -51,9 +51,14 @@ const SECRET_FIELD_NAMES = [ 'token', ] as const; -// Credential and proof headers. The proof headers come from the wire constants, -// so a renamed one stays redacted; `agent-authorization` carries an AP2 mandate. -const SECRET_HEADERS = ['authorization', PAYMENT_HEADER, AUTHORIZATION_HEADER] as const; +// Proof headers use wire constants so renames stay redacted. agent-authorization +// carries an AP2 mandate; MPP's idempotency-key is a retry credential on redelivery resources. +const SECRET_HEADERS = [ + 'authorization', + PAYMENT_HEADER, + AUTHORIZATION_HEADER, + 'idempotency-key', +] as const; export const REDACT_PATHS: readonly string[] = [ ...SECRET_HEADERS.map((name) => `req.headers[${JSON.stringify(name)}]`), diff --git a/src/gateway/routes.ts b/src/gateway/routes.ts index 569c890..5f6fe06 100644 --- a/src/gateway/routes.ts +++ b/src/gateway/routes.ts @@ -548,12 +548,15 @@ function sendInvokeError( // RFC 9457 `status` is the HTTP status, which replaces the envelope's // `status: 'error'`; the other envelope fields stay as extension members const { status: _envelopeStatus, ...members } = envelope; + // draft-ietf-httpapi-idempotency-key-header-08 section 2.7 uses 422 for payload conflicts. + // A client may treat 409 as "retry unchanged" and loop on a permanent conflict. + const problemStatus = reason === 'payment_identifier_conflict' ? 422 : error.httpStatus; reply .header('content-type', 'application/problem+json') - .status(error.httpStatus) + .status(problemStatus) .send({ - ...mppProblem(error.code, error.httpStatus, reason), - status: error.httpStatus, + ...mppProblem(error.code, problemStatus, reason), + status: problemStatus, // The reason token stays in `message` and `details.reason` detail: mppProblemDetail(reason, error.message), ...members, @@ -568,11 +571,23 @@ function sendInvokeError( reply.status(status).send(envelope); } -// The key unlocks a stored response, so validation errors must not quote it +// RFC 9651 section 4.2.5: a quoted string of printable ASCII, where only `"` +// and `\` are escaped +const SF_STRING = /^"((?:[\x20\x21\x23-\x5b\x5d-\x7e]|\\["\\])*)"$/; + +// Accept Structured Field Strings from the Idempotency-Key draft and bare keys +// from ACP/Stripe clients. Normalize "abc" and abc to the same purchase key. +// The key unlocks a stored response, so validation errors must not quote it. function mppRetryKey(request: FastifyRequest, resourceId: string): string | undefined { const value = request.headers['idempotency-key']; if (value === undefined) return undefined; - if (typeof value === 'string' && isUsableIdempotencyKey(value)) return value; + if (typeof value === 'string') { + const trimmed = value.trim(); + const key = trimmed.startsWith('"') + ? SF_STRING.exec(trimmed)?.[1]?.replace(/\\(["\\])/g, '$1') + : trimmed; + if (key !== undefined && isUsableIdempotencyKey(key)) return key; + } throw new CommerceError( 'INPUT_INVALID', 'The Idempotency-Key header must be 1 to 255 printable ASCII characters', diff --git a/src/payments/mpp/problems.ts b/src/payments/mpp/problems.ts index a0242e3..fe104fe 100644 --- a/src/payments/mpp/problems.ts +++ b/src/payments/mpp/problems.ts @@ -40,9 +40,12 @@ const REASON_TYPES: ReadonlyMap = new Map([ ['payment_identifier_invalid', 'bad-request'], ]); -// Use a generic conflict so clients do not treat a retry key's refusal as an -// invalid challenge and risk paying again with a new key -const RETRY_KEY_CONFLICT = { type: 'about:blank', title: 'Conflict' } as const; +// Use about:blank so clients do not mistake retry-key refusals for invalid +// challenges and pay again. RFC 9457 section 4.2.1 uses the HTTP status phrase as its title. +const RETRY_KEY_TITLES: ReadonlyMap = new Map([ + [409, 'Conflict'], + [422, 'Unprocessable Content'], +]); /** The Problem Details `type` URI and `title` for an MPP payment error */ export function mppProblem( @@ -50,7 +53,10 @@ export function mppProblem( httpStatus: number, reason?: string, ): { readonly type: string; readonly title: string } { - if (httpStatus === 409 && reason?.startsWith('payment_identifier_')) return RETRY_KEY_CONFLICT; + const retryKeyTitle = reason?.startsWith('payment_identifier_') + ? RETRY_KEY_TITLES.get(httpStatus) + : undefined; + if (retryKeyTitle !== undefined) return { type: 'about:blank', title: retryKeyTitle }; const problem = problemType(code, httpStatus, reason); return { type: `${MPP_PROBLEM_BASE_URI}${problem}`, title: TITLES[problem] }; } diff --git a/tests/integration/payment-retries.test.ts b/tests/integration/payment-retries.test.ts index 926e12a..b2a3ccd 100644 --- a/tests/integration/payment-retries.test.ts +++ b/tests/integration/payment-retries.test.ts @@ -675,16 +675,81 @@ describe('MPP retries over HTTP', () => { expect(refused.headers['www-authenticate']).toBeUndefined(); }); + it.each([ + ['an unterminated quote', '"secretkey'], + ['text after the closing quote', '"secret"key"'], + ['an unknown escape', '"secret\\key"'], + ['an empty quoted string', '""'], + ['a quoted key of 256 characters', `"${'k'.repeat(256)}"`], + ])('refuses %s without quoting the key', async (_case, key) => { + const refused = await invoke('mpp_report', { 'idempotency-key': key }); + + expect(refused.statusCode).toBe(400); + expect(refused.json()).toMatchObject({ + type: 'https://paymentauth.org/problems/bad-request', + status: 400, + code: 'INPUT_INVALID', + details: { reason: 'payment_identifier_invalid' }, + }); + expect(refused.body).not.toContain('secret'); + }); + it.each([ ['1', 'k'], ['255', 'k'.repeat(255)], + ['a quoted 255', `"${'k'.repeat(255)}"`], + // Only a leading quote selects the quoted form + ['a bare 3', 'k"k'], ])('accepts a %s-character key', async (_length, key) => { const challenged = await invoke('mpp_report', { 'idempotency-key': key }); expect(challenged.statusCode).toBe(402); }); - it('answers a key used by another payer with about:blank, never invalid-challenge', async () => { + it.each([ + ['a quoted retry of a bare key', KEY, `"${KEY}"`], + ['a bare retry of a quoted key', `"${KEY}"`, KEY], + // RFC 9651 escapes: \" is a quote and \\ is a backslash + ['an escaped quote', '"a\\"b"', 'a"b'], + ['an escaped backslash', '"a\\\\b"', 'a\\b'], + ])('reads %s as the same key', async (_case, firstKey, retryKey) => { + const credential = await mppCredential('mpp_report'); + const first = await invoke('mpp_report', { + authorization: credential, + 'idempotency-key': firstKey, + }); + mppNowMs += 301_000; + + const retry = await invoke('mpp_report', { + authorization: credential, + 'idempotency-key': retryKey, + }); + + expect(first.statusCode).toBe(200); + expect(retry.statusCode).toBe(200); + expect(retry.json()).toEqual(REPORT); + expect(retry.headers['idempotent-replayed']).toBe('true'); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + expect(JSON.stringify(backendCalls)).not.toContain(KEY); + }); + + it('keeps a quoted key distinct from another key', async () => { + await invoke('mpp_report', { + authorization: await mppCredential('mpp_report'), + 'idempotency-key': `"${KEY}"`, + }); + + const other = await invoke('mpp_report', { + authorization: await mppCredential('mpp_report', otherBuyer), + 'idempotency-key': `${KEY}-2`, + }); + + expect(other.statusCode).toBe(200); + expect(other.headers['idempotent-replayed']).toBeUndefined(); + expect(facilitator.settle).toHaveBeenCalledTimes(2); + }); + + it('answers a key used by another payer with a 422 about:blank, never invalid-challenge', async () => { await invoke('mpp_report', { authorization: await mppCredential('mpp_report'), 'idempotency-key': KEY, @@ -695,12 +760,13 @@ describe('MPP retries over HTTP', () => { 'idempotency-key': KEY, }); - expect(conflict.statusCode).toBe(409); + // The Idempotency-Key draft uses 422 for different input; clients may read 409 as "retry unchanged" + expect(conflict.statusCode).toBe(422); expect(conflict.headers['content-type']).toMatch(/^application\/problem\+json/); expect(conflict.json()).toMatchObject({ type: 'about:blank', - title: 'Conflict', - status: 409, + title: 'Unprocessable Content', + status: 422, code: 'PAYMENT_REPLAYED', retryable: false, details: { reason: 'payment_identifier_conflict' }, diff --git a/tests/unit/gateway/logger.test.ts b/tests/unit/gateway/logger.test.ts index 4e72c6c..f3028b1 100644 --- a/tests/unit/gateway/logger.test.ts +++ b/tests/unit/gateway/logger.test.ts @@ -69,7 +69,13 @@ describe('createGatewayLogger', () => { // Asserts the redaction, not the spelling of the path: a new header // constant with no path has to fail here, and rewriting an existing path // in another notation that still redacts must not - for (const header of ['authorization', PAYMENT_HEADER, AUTHORIZATION_HEADER]) { + for (const header of [ + 'authorization', + PAYMENT_HEADER, + AUTHORIZATION_HEADER, + // Redelivery makes this header a credential for the stored response + 'idempotency-key', + ]) { const { pino: root, core } = createGatewayLogger({ level: 'info', prettyPrint: false }); const lines = captureLines(root); diff --git a/tests/unit/payments-mpp/problems.test.ts b/tests/unit/payments-mpp/problems.test.ts index 9c6c187..10cd674 100644 --- a/tests/unit/payments-mpp/problems.test.ts +++ b/tests/unit/payments-mpp/problems.test.ts @@ -44,6 +44,19 @@ describe('mppProblem', () => { }); }); + it('answers a retry key reused with other input as 422 about:blank', () => { + expect(mppProblem('PAYMENT_REPLAYED', 422, 'payment_identifier_conflict')).toEqual({ + type: 'about:blank', + title: 'Unprocessable Content', + }); + }); + + it('does not give a retry-key title to a status it has none for', () => { + expect(mppProblem('PAYMENT_REPLAYED', 402, 'payment_identifier_conflict').type).toBe( + type('invalid-challenge'), + ); + }); + it('titles a bad retry key Bad Request', () => { expect(mppProblem('INPUT_INVALID', 400, 'payment_identifier_invalid').title).toBe( 'Bad Request', From fc7744b422fe144e3885bf9a7ef5582357e61b00 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sat, 7 Nov 2026 22:37:51 +0100 Subject: [PATCH 16/20] docs: document retries after a lost response --- docs/configuration.md | 25 +- docs/contracts.md | 12 +- docs/payment-flow.md | 71 +++ docs/protocols/ap2.md | 3 +- docs/protocols/http.md | 8 +- docs/protocols/mcp.md | 14 +- docs/protocols/mpp.md | 3 +- docs/protocols/x402.md | 25 +- docs/security.md | 524 ++++++++++-------- docs/storage.md | 9 +- src/core/execution/merchant-key.ts | 5 +- .../core/execution/pipeline-retry.test.ts | 25 + 12 files changed, 458 insertions(+), 266 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index e22710c..c0f3d20 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -88,11 +88,13 @@ 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. When both the client -retry key and verified payer are nonempty, the identity uses the key's hash -and payer; otherwise it uses the payment's replay identity. The same identity -and input keep the same key across retries. Changing `merchant.publicBaseUrl` -changes generated keys. +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 @@ -114,9 +116,16 @@ 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. `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). +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. diff --git a/docs/contracts.md b/docs/contracts.md index 18fc7bb..e7e9b7d 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -231,9 +231,9 @@ persistence must not fail the commerce flow. challenge. A lookup failure returns `STORAGE_ERROR` without a challenge; a store without the method keeps the 402 response. - The method is experimental. The 3.0.0 migration plan makes it required. -- The planned 2.7.0 API adds optional contracts for paid retries. Existing 2.6 - resources, stores and providers need no changes. The pipeline supports - merchant keys and client retry keys; SQLite implements claim lookup and +- 2.7.0 adds optional contracts for paid retries. Existing 2.6 resources, + stores and providers need no changes. The pipeline supports merchant keys + and client retry keys; SQLite implements claim lookup, claim owners and recovery filtering. The x402 provider extracts `payment-identifier` IDs; the HTTP invoke route reads MPP's `Idempotency-Key` into `retryKey`. Direct pipeline requests and custom provider hooks can also supply keys. @@ -289,6 +289,7 @@ persistence must not fail the commerce flow. - `persistenceFailures` gains `response` when a delivered response cannot be stored for its client key. The pipeline attempts to mark the claim `unresolved`; in-flight and unresolved claims refuse retries with 409. + Resolving or reconciling the payment attempt does not change the claim. - `CommerceErrorOptions.retryable` overrides the code's default from `RETRYABLE_ERROR_CODES` for one error instance, in either direction. An in-flight client-key 409 sets it to true so callers can wait and resend @@ -573,7 +574,10 @@ pending task input before writing it. Hold `metadata` values under secret-shaped keys are redacted, as with other metadata. Idempotency bodies, the remaining task input and `adapterData` are stored as JSON without redaction so they can be replayed or resumed. Callers must keep secrets out -of those fields. +of those fields. For completed `redelivery` claims, the idempotency body holds +the unencrypted paid merchant response. Expiry ends reuse; a later claim sweep +deletes the row. +Backups persist separately; see [security](security.md#buyer-protection). With `legacyStores`, `init()` imports each source once. Each file's rows and its `legacy_imports` marker commit in one transaction. Later starts skip diff --git a/docs/payment-flow.md b/docs/payment-flow.md index 3655f68..b2b03b4 100644 --- a/docs/payment-flow.md +++ b/docs/payment-flow.md @@ -165,6 +165,77 @@ key. The built-in SQLite store keeps it beside payment attempts. It is claimed before the payment replay key; failures release it only when no money could have moved. See [AP2](protocols/ap2.md#replay-states). +## Retry after a lost response + +Experimental and disabled by default. A resource with +[`idempotency.redelivery`](configuration.md#resources) stores delivered +response under a buyer-chosen key so a lost response can be retrieved without +another charge. The key is the x402 +[`payment-identifier`](protocols/x402.md#payment-identifier) `id`, the MPP +[`Idempotency-Key`](protocols/mpp.md#idempotency-key) header, or +`CanonicalRequest.retryKey` from a direct caller. Requests without a key follow +the flow above. + +A key identifies one purchase of one resource with one input. Its row is +scoped by `merchant.publicBaseUrl`, the resource and the key's hash. The +fingerprint covers only the input digest. A fresh proof from the same payer +can retrieve the response even if the network, asset or amount has changed, +provided it verifies against a current offer. + +For a request with a proof and a key: + +1. Before verification, the gateway reads the row without writing. Only the + exact proof that owns it can retrieve the stored response or get 409 + `payment_identifier_in_flight` or `payment_identifier_unresolved` for an + unfinished claim. This path performs no verification, settlement or backend + call, even if the proof or challenge has expired. +2. Other proofs are verified first. Invalid or forged proofs get the normal + refusal, without revealing whether the key exists or what input it holds. +3. A verified proof claims the key before authorization holds and replay + reservations. A new key proceeds as an ordinary purchase; an unfinished + claim refuses as in step 1. A completed claim returns the response for the + same input and verified payer, without settling the fresh proof. Different + input, a different or unknown payer, or a different mandate gets 409 + `payment_identifier_conflict` after one `verify()` that moves no funds. + +If the resource requires authorization, both routes require the purchase's +original authorization submission, byte for byte. The mandate is not verified +again because the purchase consumed it. Without it, an exact resend falls +through to verification; a verified fresh proof gets 409 +`payment_identifier_conflict`. Purchases made before the resource required +authorization cannot be redelivered. + +The purchase outcome determines the key's state: + +| Purchase outcome | Key state | +| ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | +| Delivered | `completed`: retries get the stored response | +| Uncharged failure: AP2 or replay refusal, refused settlement, or backend failure in the `authorization` flow | Released: the key can buy again | +| Uncertain settlement, or backend failure after `upfront` settlement | `unresolved`: retries get 409 without a stored response | +| Delivered, but the response or receipt could not be stored | `unresolved`, or in flight until the next start if that write also fails; the buyer still received the response | +| Crash or restart while in flight | `unresolved` at the next start, even if the crash preceded settlement | + +Retry-key 409s carry no challenge, because a challenge could prompt a second +payment. Only the in-flight 409 is retryable (`retryable: true`, +`retryAfterSeconds: 1`). `reconcile` and `resolve` record the payment attempt's +outcome but do not change the key. + +Completed and unresolved keys expire `retentionHours` (1 to 8760, default 24) +after the claiming request first reads the key, which is before verification. +After expiry, retries follow the normal purchase flow. Removing `redelivery` +from a resource, renaming it or changing `merchant.publicBaseUrl` also hides +its rows. ACP's unresolved records never expire. + +A redelivery writes no receipt, payment attempt or journal row. Its outcome +carries its own `transactionId`, as do its events. When available, the purchase's +ID is `receipt.transactionId` and is copied to the `resource.delivered` event's +`data.originalTransactionId`. + +An exact resend is an exception to the fail-closed rule: it retrieves an +already-paid response without verification, settlement or a new backend call. +[Security](security.md#buyer-protection) covers what the key protects and how to +treat it. + ## Transactions After `execute()` resolves a resource, core allocates a `transactionId` for the diff --git a/docs/protocols/ap2.md b/docs/protocols/ap2.md index cdcc95a..c6490cb 100644 --- a/docs/protocols/ap2.md +++ b/docs/protocols/ap2.md @@ -65,7 +65,8 @@ retry with the purchase's client key and original authorization submission, byte for byte, can retrieve the stored response. The mandate is not verified or reserved again because the purchase consumed it. A missing or different mandate prevents redelivery, as does a purchase made before the resource -required one. +required one. See +[retry after a lost response](../payment-flow.md#retry-after-a-lost-response). ## Agent Commerce checkout profile diff --git a/docs/protocols/http.md b/docs/protocols/http.md index 0f7ce6c..cb9ab86 100644 --- a/docs/protocols/http.md +++ b/docs/protocols/http.md @@ -51,5 +51,9 @@ retry challenge. Other outcomes: [backend response relay rules](../security.md#backend-response-relay), and includes `PAYMENT-RESPONSE`, `Cache-Control: private` and `Idempotent-Replayed: true`. MPP also includes `Payment-Receipt` on 2xx - redeliveries. Retry-key 409s omit the challenge; only an in-flight refusal - includes `Retry-After`. + redeliveries. Retry-key refusals omit challenges. Reusing a key with other + input or another payer returns 409 on x402 and 422 on MPP. On either rail, + in-flight claims return 409 with `Retry-After: 1`; unresolved claims return + 409 without it. Matching exact resends are answered before verification; + other proofs are verified first. See + [retry after a lost response](../payment-flow.md#retry-after-a-lost-response). diff --git a/docs/protocols/mcp.md b/docs/protocols/mcp.md index e4c2b87..5f031b4 100644 --- a/docs/protocols/mcp.md +++ b/docs/protocols/mcp.md @@ -101,11 +101,15 @@ On a resource with [`idempotency.redelivery`](../configuration.md#resources) (experimental), x402 can redeliver a stored result for the same [payment identifier](x402.md#payment-identifier) and input. The result includes the original `_meta["x402/payment-response"]` and sets `redelivered: true` in -its `agent-commerce/delivery` summary. Refusals with a `payment_identifier_*` -reason return the error envelope without a challenge or `_meta["x402/error"]`. -Only an in-flight refusal sets `retryable: true` and `details.retryAfterSeconds`. -MPP calls carry no retry key -([mpp.md](mpp.md#idempotency-key)). +its `agent-commerce/delivery` summary. An exact resend retrieves the stored +result without verification until retention ends; other proofs are verified +first. The proof contains the identifier, so keep it out of logs. If the +resource requires a mandate, include the same `_authorization`. Refusals with a +`payment_identifier_*` reason return the error envelope without a challenge or +`_meta["x402/error"]`. Only an in-flight refusal sets `retryable: true` and +`details.retryAfterSeconds: 1`. MPP calls carry no retry key +([mpp.md](mpp.md#idempotency-key)). See +[retry after a lost response](../payment-flow.md#retry-after-a-lost-response). For payment-required and error results, `content[0].text` is a JSON copy of `structuredContent`; `content[1].text` is a sentence for readers. Unexpected diff --git a/docs/protocols/mpp.md b/docs/protocols/mpp.md index 616ddfa..1b06a19 100644 --- a/docs/protocols/mpp.md +++ b/docs/protocols/mpp.md @@ -80,7 +80,8 @@ another charge. An exact resend of `Authorization` skips verification, so an expired challenge still works while the response is retained. A new credential must answer a fresh challenge and verify as the same payer; it is not settled again. Invalid credentials get the usual 402 without revealing whether the key -exists. +exists. See +[retry after a lost response](../payment-flow.md#retry-after-a-lost-response). Make the key random and keep it secret: with the original credential, it unlocks the stored response. The gateway's logger redacts the header; keep it diff --git a/docs/protocols/x402.md b/docs/protocols/x402.md index efc3a0a..16f7aee 100644 --- a/docs/protocols/x402.md +++ b/docs/protocols/x402.md @@ -127,12 +127,29 @@ Experimental and disabled by default. A resource with `redelivery.required`. To retrieve a stored response without another charge, add an `id` to the echoed `info` and reuse it with the same input. An exact resend of the proof skips verification while the response is retained, even -if the wallet can no longer cover the price. A new proof must verify as the -same payer and may be rejected without another charge. +after the proof has expired or if the wallet can no longer cover the price. A +fresh proof must verify against a current offer as the same payer; it is not +settled. A refused proof, including one from a wallet that cannot cover the +price, gets the normal 402 without a charge. +[Retry after a lost response](../payment-flow.md#retry-after-a-lost-response) +describes the full rules. Keep one `id` per purchase. The SDK's `appendPaymentIdentifierToExtensions` -generates a new ID on each call without an explicit `id`. Supply the original -ID on retries so they identify the same purchase. +generates a new ID on each call unless given an explicit `id`. A new ID can +start a second purchase, so generate one randomly and reuse it on retries. +The proof's unsigned `extensions` carry the `id`, so `PAYMENT-SIGNATURE` +contains both the proof and retry key. With matching input and any required +mandate, it retrieves the stored response until retention ends. Keep this +credential out of proxy, APM and access logs; the gateway's logger redacts it. + +The wire format follows x402's `payment-identifier` extension, with one +deviation: its table specifies 409 for the same `id` with a different payload. +The gateway fingerprints only the input digest, allowing a fresh proof from +the same verified payer to retrieve the response for the same `id` and input, +even if the network, asset or amount has changed. This supports retries with +new signatures. Other input or another payer gets 409 +`payment_identifier_conflict` after one verification that moves no funds. +Invalid proofs get the normal refusal without revealing whether the `id` exists. During retry-key extraction, a supplied `id` must contain 16 to 128 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`. Invalid IDs fail with `INPUT_INVALID` and diff --git a/docs/security.md b/docs/security.md index b5cf7b2..6b55912 100644 --- a/docs/security.md +++ b/docs/security.md @@ -137,7 +137,11 @@ before validating resource input. Covered in detail in [payment-flow.md](payment-flow.md). The invariants: 1. Paid resources fail closed: payment or authorization failures do not deliver - the resource. + the resource. With `idempotency.redelivery`, matching exact resends can + retrieve an already-paid response without verification until retention + ends, even after proof expiry. This performs no settlement or new backend + call. See + [retry after a lost response](payment-flow.md#retry-after-a-lost-response). 2. `verify` has no fund-moving side effects; only `settle` does. 3. Replay is defended twice: on-chain via EIP-3009 `authorizationState`, the Permit2 nonce bitmap, Solana's processed-transaction check or the NEAR @@ -313,241 +317,256 @@ named state transition or returned outcome; end-to-end payment tests also check balances on chain. The two mainnet smoke tests run manually because they move real funds. -| Scenario | Outcome | Where | -| --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| missing `PAYMENT-SIGNATURE` | 402, no delivery | `tests/unit/gateway/server.test.ts` | -| malformed `PAYMENT-SIGNATURE` over HTTP | `PAYMENT_INVALID`; no backend call | `tests/integration/adversarial-payment.test.ts` | -| **an x402 v1 client sending only `X-PAYMENT`** | 402 with `error: "invalid_x402_version"` in `PAYMENT-REQUIRED`; no backend call | `tests/unit/gateway/server.test.ts` | -| x402 signature, nonce or payer changed after signing | rejected before settlement; balances unchanged | `tests/e2e/payment/x402-settlement.e2e.test.ts` | -| network substitution | `invalid_network` before settlement | `tests/e2e/payment` | -| requirement changed to another deployed token, or buyer proof signed for that token | `wrong_asset` or invalid signature before settlement; balances unchanged | `tests/e2e/payment/x402-settlement.e2e.test.ts` | -| proof recipient differs from configured `payTo` | `invalid_exact_evm_payload_recipient_mismatch` before settlement | `tests/e2e/payment/x402-settlement.e2e.test.ts`, `tests/unit/payments-x402/provider.test.ts` | -| amount differs from the price in either direction | `invalid_exact_evm_payload_authorization_value_mismatch` before settlement | `tests/e2e/payment/x402-settlement.e2e.test.ts` | -| **an x402 proof whose `accepted` differs from the offer, or whose `resource.url` differs from the challenge's** | `invalid_payment_requirements` before the facilitator is called | `tests/unit/payments-x402/provider-remote-facilitator.test.ts` | -| **multi-offer x402 proof whose `accepted` matches zero or multiple offers** | `invalid_payment_requirements`; no offer is selected and the facilitator is not called | `tests/unit/payments-x402/provider-remote-facilitator.test.ts` | -| **an AP2 mandate approved for one x402 offer, presented with a proof for another** | selected destination, network or asset fails mandate binding; no settlement | `tests/unit/core/execution/pipeline-authorization.test.ts` | -| **an EIP-3009 authorization signed for one EVM chain, presented as another chain's offer** | `invalid_exact_evm_signature`: signature fails against the other chain's EIP-712 domain; neither chain's balances change | `tests/e2e/payment/x402-multichain.e2e.test.ts` | -| **the same payer, token address and nonce on two EVM chains** | both settle: the replay key includes the chain id | same | -| **a Permit2 proof naming another spender, token, amount or witness recipient than the offer** | `invalid_permit2_spender`, `permit2_token_mismatch`, `permit2_amount_mismatch` or `invalid_permit2_recipient_mismatch` before the facilitator is called; balances unchanged | `tests/unit/payments-x402/provider-remote-facilitator.test.ts`, `tests/e2e/payment/x402-permit2.e2e.test.ts` | -| **a Permit2 proof altered after signing** | `invalid_permit2_signature` from the facilitator; balances unchanged | `tests/e2e/payment/x402-permit2.e2e.test.ts` | -| **a Permit2 payment from a buyer without a Permit2 allowance** | 412 with `error: permit2_allowance_required` in a fresh `PAYMENT-REQUIRED`; `permit2_simulation_failed` and 402 on a chain without Multicall3; balances unchanged | `tests/e2e/payment/x402-permit2.e2e.test.ts` | -| **a proof of the other transfer method, or one carrying both an EIP-3009 and a Permit2 authorization** | `unsupported_scheme` before the facilitator is called, which would settle the Permit2 one | `tests/unit/payments-x402/provider-remote-facilitator.test.ts`, `tests/unit/payments-x402/provider.test.ts` | -| **an EIP-3009 authorization whose value, time bounds or nonce are not in canonical form** | `invalid_payload` before the facilitator is called: amounts and times must be decimal, the nonce 32 bytes of hex | `tests/unit/payments-x402/provider.test.ts` | -| **a gas-sponsoring extension in a Permit2 payload** | not forwarded: the facilitator receives no `extensions` | `tests/unit/payments-x402/provider-remote-facilitator.test.ts` | -| **an ERC-8409 quote altered after signing, or signed by another key** | a buyer that verifies the signature against the published issuer rejects it | `tests/unit/payments-x402/quote.test.ts`, `tests/integration/x402-quotes.test.ts` | -| **an altered ERC-8409 quote echoed in a payment** | ignored: verification and settlement use the offer and payer authorization; the facilitator receives no `extensions` | `tests/integration/x402-quotes.test.ts` | -| **a leaked quote signing key** | its holder can sign quotes as the merchant. Keep the key unfunded. Rotation changes the published issuer after a restart; `doctor` detects a gateway still using the old key | `tests/integration/x402-quotes.test.ts`, `tests/unit/cli/doctor.test.ts` | -| **unpaid requests that vary the input to force quote signing** | each cache miss requires a signature per EVM offer; repeated input reuses a cached quote while a full payment window remains | `tests/unit/payments-x402/quote.test.ts` | -| **a quote signing key that controls an EVM `payTo`, or a well-known Anvil key outside the local chain** | `CONFIG_INVALID` during provider construction; the message omits the key | `tests/unit/payments-x402/quote.test.ts` | -| **one Permit2 authorization on two requests at once** | settles once; the other gets 409 `PAYMENT_REPLAYED`. The key is chain, Permit2, owner and nonce | `tests/e2e/payment/x402-permit2.e2e.test.ts` | -| **a Permit2 offer on a network without Permit2 or its x402 proxy** | health fails, naming the missing contract | `tests/e2e/payment/x402-permit2.e2e.test.ts` | -| **a Solana transaction paying another wallet or a smaller amount, presented as the offer** | the facilitator refuses it: `invalid_exact_svm_payload_recipient_mismatch` or `invalid_exact_svm_payload_amount_mismatch`; balances unchanged | `tests/e2e/payment/x402-solana.e2e.test.ts` | -| **an echoed Solana offer with an address in another case, or a transaction that does not decode** | `invalid_payment_requirements`, or an `invalid_exact_svm_payload_transaction*` code, before the facilitator is called | `tests/unit/payments-x402/provider-svm.test.ts`, `tests/unit/payments-x402/svm.test.ts` | -| **the same Solana transaction with other signature bytes, while the first copy is settling** | 409 `PAYMENT_REPLAYED`: the key is the network and the message hash, not the signatures; one transfer | `tests/e2e/payment/x402-solana.e2e.test.ts`, `tests/unit/payments-x402/svm.test.ts` | -| **a settled Solana transaction presented again** | the facilitator's simulation refuses it: `invalid_exact_svm_transaction_simulation_failed`; balances unchanged | `tests/e2e/payment/x402-solana.e2e.test.ts` | -| **a Solana broadcast that lands but whose RPC response is an error** | the SDK's catch-all without a signature: `settlement-uncertain`, no delivery | `tests/e2e/payment/x402-solana.e2e.test.ts` | -| **a Solana settlement already in flight at the facilitator, or confirmed without a visible transfer** | `duplicate_settlement` or `post_settlement_transfer_not_confirmed`: `settlement-uncertain`, no delivery | `tests/unit/payments-x402/provider-svm.test.ts` | -| **an unconfirmed broadcast the facilitator could not record for a retry** | the catch-all with a transaction and an `errorMessage` starting `settlement_pending`: `settlement-uncertain`, no delivery | `tests/unit/payments-x402/provider-svm.test.ts`, `tests/unit/payments-x402/provider-sdk-mocked.test.ts` | -| **a settlement success naming another network, or no transaction in its family's form** | outcome unknown: `settlement-uncertain`, no delivery | `tests/unit/payments-x402/provider-svm.test.ts`, `tests/unit/payments-x402/provider-remote-facilitator.test.ts` | -| **a settlement failure that names a transaction, with a reason other than its family's on-chain failure** | outcome unknown: `settlement-uncertain`, no delivery. The EVM SDK reports a mined transfer whose `Transfer` log it cannot match this way | `tests/unit/payments-x402/provider-sdk-mocked.test.ts`, `tests/unit/payments-x402/provider-near.test.ts` | -| **a Solana RPC serving another cluster, a mint with other decimals, or a facilitator without a fee payer** | health fails; without a fee payer no challenge is built either | `tests/unit/payments-x402/provider-svm.test.ts`, `tests/e2e/payment/x402-solana.e2e.test.ts` | -| **a Solana payee without a token account for the mint** | health fails: the payment's transaction cannot create the account | `tests/unit/payments-x402/provider-svm.test.ts` | -| **a Solana facilitator refusing the offer itself: a fee payer it does not manage or that is missing, another scheme or network** | no verdict: `PAYMENT_PROVIDER_UNAVAILABLE`, not a buyer rejection | `tests/unit/payments-x402/provider-svm.test.ts` | -| **a NEAR delegate for another token, recipient, amount, method or deposit, or with an extra action** | refused before the facilitator: `invalid_exact_near_payload_*`; balances unchanged | `tests/unit/payments-x402/near.test.ts`, `tests/e2e/payment/x402-near.e2e.test.ts` | -| **a NEAR delegate in a non-canonical encoding: trailing bytes, other base64, truncated or oversized** | `invalid_exact_near_payload_signed_delegate_action` or `invalid_exact_near_payload_shape` before the facilitator | `tests/unit/payments-x402/near.test.ts` | -| **a NEAR delegate signed with a function-call access key** | the facilitator refuses it: `invalid_exact_near_function_call_key_not_allowed`; balances unchanged | `tests/e2e/payment/x402-near.e2e.test.ts` | -| **another NEAR delegate under the same sender, key and nonce, while the first is settling** | 409 `PAYMENT_REPLAYED`: the key is the access-key nonce, not the delegate bytes; one transfer | `tests/e2e/payment/x402-near.e2e.test.ts`, `tests/unit/payments-x402/near.test.ts` | -| **a NEAR delegate signed again with the same nonce after a backend failure or a refused settlement** | the retry takes over the rejected attempt; one transfer | `tests/e2e/payment/x402-near.e2e.test.ts` | -| **a settled NEAR delegate presented again** | the facilitator refuses it: `invalid_exact_near_payload_delegate_action_nonce_already_used`; balances unchanged | `tests/e2e/payment/x402-near.e2e.test.ts` | -| **a NEAR submission whose RPC response is lost** | `settlement_failed` without a hash: `settlement-uncertain`, no delivery; the transfer lands | `tests/e2e/payment/x402-near.e2e.test.ts`, `tests/unit/payments-x402/provider-near.test.ts` | -| **a NEAR settle failure whose reason the SDK does not return before submitting** | `settlement-uncertain`, no delivery; the reason may follow a submission | `tests/unit/payments-x402/provider-near.test.ts` | -| **a NEAR facilitator failing to read the chain during verify or settle** | no verdict: 503 `PAYMENT_PROVIDER_UNAVAILABLE`. At settle nothing was submitted, so the attempt is recorded `rejected` and a retry with the same nonce can reserve it | `tests/unit/payments-x402/provider-near.test.ts`, `tests/unit/core/execution/pipeline.test.ts` | -| **a NEAR RPC serving another chain, a token without code or with other decimals, or an unregistered payee** | health fails | `tests/unit/payments-x402/provider-near.test.ts`, `tests/e2e/payment/x402-near.e2e.test.ts` | -| **EVM token decimals that differ from `assetDecimals`** | health fails, naming both values | `tests/unit/payments-x402/provider-health-mocked.test.ts`, `tests/e2e/payment/x402-builtin-token.e2e.test.ts` | -| **x402 options on two networks sharing one RPC endpoint, or mixing testnet and mainnet** | `CONFIG_INVALID` at load | `tests/unit/config/x402-options.test.ts` | -| **a proof for an x402 offer absent from the resource's own list** | refused before the facilitator: `invalid_network` on another network, `invalid_payment_requirements` otherwise; balances unchanged | `tests/e2e/payment/x402-multichain.e2e.test.ts`, `tests/unit/payments-x402/provider-remote-facilitator.test.ts` | -| **the reason an x402 offer fails its health check, which can name an RPC host** | discovery publishes only each offer's `status`; the gateway logs the reason at debug level | `tests/unit/gateway/server.test.ts` | -| replay, sequentially | refused, no second transfer | `tests/e2e/payment/x402-settlement.e2e.test.ts`, `tests/mainnet/x402.smoke.test.ts` | -| **duplicate concurrent request** | settles once, other gets `PAYMENT_REPLAYED` | `tests/integration/adversarial-payment.test.ts` | -| **replay after a gateway restart** | still refused - the reservation is in SQLite | same | -| **an invalid x402 proof over HTTP** | 402 with a fresh `PAYMENT-REQUIRED` carrying an `error` code; no delivery | `tests/unit/gateway/server.test.ts` | -| **a settlement rejected by the facilitator** | 402 with a failed `PAYMENT-RESPONSE`; MPP also sends a fresh `WWW-Authenticate`; no delivery | `tests/unit/gateway/server.test.ts`, `tests/integration/mpp-carriers.test.ts` | -| **an x402 replay of a settled attempt** | 402 with a fresh challenge whose error is the paid network's replay code: EVM's or NEAR's spent nonce, or `duplicate_settlement` on Solana | `tests/unit/gateway/server.test.ts`, `tests/unit/protocols-mcp/result-mapping.test.ts` | -| **a replay while the first attempt is unfinished** | 409 `PAYMENT_REPLAYED` without a fresh challenge; the original payment may still settle. This includes rejections for a landed transfer or an expired MPP challenge when the provider supplies a replay key | `tests/unit/gateway/server.test.ts`, `tests/unit/protocols-mcp/result-mapping.test.ts`, `tests/unit/core/execution/pipeline.test.ts`, `tests/e2e/payment/restore.e2e.test.ts`, `tests/integration/mpp-carriers.test.ts`, `tests/unit/payments-mpp/provider.test.ts` | -| **a settlement with no verdict** | 502 with a failed `PAYMENT-RESPONSE`; `settlement_pending` when a transaction hash is known; no delivery | same | -| **a backend failure after an MPP settlement** (`upfront` flow) | the error omits `Payment-Receipt`; `PAYMENT-RESPONSE` reports the settlement | `tests/integration/mpp-carriers.test.ts` | -| **a backend failure under the MPP `authorization` flow** | nothing settles; the same credential again gets 402 `PAYMENT_REPLAYED` with a fresh challenge and moves no funds | `tests/integration/mpp-carriers.test.ts`, `tests/e2e/payment/mpp-settlement.e2e.test.ts` | -| **an MPP settlement refused after the backend ran** (`authorization` flow) | the response is withheld; 402 with a fresh `WWW-Authenticate` and a failed `PAYMENT-RESPONSE` | `tests/integration/mpp-carriers.test.ts`, `tests/unit/payments-mpp/provider.test.ts` | -| **an x402 A2A extension payment sent twice on one task** | after validating a follow-up message, the adapter atomically takes its task before execution; a competing message that loses the take gets `TaskNotFoundError` without another settlement | `tests/integration/a2a-x402-extension.test.ts`, `tests/unit/protocols-a2a/pending-store.test.ts`, `tests/unit/storage-receipts/conformance.test.ts` | -| **unauthenticated callers filling the store with unpaid A2A extension tasks** | new unpaid tasks are refused with `GATEWAY_BUSY` when the receipt store already holds 256 unexpired tasks. Tasks expire with their requirement or within an hour; expired tasks cannot be paid, and waiting tasks are not evicted | `tests/unit/protocols-a2a/x402-extension.test.ts`, `tests/unit/protocols-a2a/pending-store.test.ts`, `tests/integration/a2a-x402-extension.test.ts` | -| expired authorization (`validBefore` in the past) | refused before settlement | `tests/e2e/payment` | -| not-yet-valid authorization (`validAfter` in the future) | refused before settlement | `tests/e2e/payment` | -| a `{param}` in the host position of `backend.url` | refused at config load | `tests/unit/config/schema.test.ts` | -| an unknown key nested under an `additionalProperties` schema | rejected by the closed schema | same | -| **a built-in mainnet redeclared as a testnet under `payments.x402.networks`** | `CONFIG_INVALID` at load; a built-in network cannot be redeclared | `tests/unit/config/x402-options.test.ts` | -| **the in-process facilitator configured for any network but the local `eip155:31337`** | `CONFIG_INVALID` at load; `eip155:84532` gets a pointer to the local id | same | -| **conflicting USDC metadata, or another token in public-network MPP, Solana or NEAR config** | `CONFIG_INVALID` at load | same | -| **a resource priced in a label other than `USD` or `USDC` against an option that names its network's USDC** | `CONFIG_INVALID` at load, as when the option omits the asset | `tests/unit/config/x402-options.test.ts` | -| **facilitator `auth.type: cdp` at a URL other than CDP's, or an RPC URL that is not http(s)** | `CONFIG_INVALID` at load; neither message repeats the URL's path or query | `tests/unit/config/schema.test.ts`, `tests/unit/config/x402-options.test.ts` | -| remote facilitator timeout during settlement | provider throws `PAYMENT_PROVIDER_UNAVAILABLE`; pipeline marks the attempt uncertain and returns `PAYMENT_SETTLEMENT_FAILED` | `tests/unit/payments-x402/provider-remote-facilitator.test.ts`, `tests/unit/core/execution/pipeline.test.ts` | -| an unclassified local `settle()` throw | provider throws `PAYMENT_PROVIDER_UNAVAILABLE`; pipeline marks the attempt uncertain and returns `PAYMENT_SETTLEMENT_FAILED` | `tests/unit/payments-x402/provider-sdk-mocked.test.ts`, `tests/unit/core/execution/pipeline.test.ts` | -| a local `settle()` throw wrapping an RPC error, not a revert | same as an unclassified throw; not `transaction_reverted` | `tests/unit/payments-x402/provider-sdk-mocked.test.ts` | -| **a broadcast that lands but whose RPC response is an error** | the SDK's catch-all with no transaction hash; provider throws `PAYMENT_PROVIDER_UNAVAILABLE`; pipeline records `settlement-uncertain`, no delivery | `tests/e2e/payment/x402-settlement.e2e.test.ts`, `tests/e2e/payment/mpp-settlement.e2e.test.ts` | -| **a Permit2 broadcast that lands but whose RPC response is an error** | the SDK's catch-all with its message appended and no hash; handled as the row above | `tests/e2e/payment/x402-permit2.e2e.test.ts` | -| **remote facilitator 401 / 5xx during verification** | transport failure, not a buyer rejection: 503 with `Retry-After: 5` on the HTTP route | `tests/integration/adversarial-payment.test.ts`, `tests/unit/gateway/server.test.ts` | -| **malformed remote-facilitator response** | transport failure; never read as a verdict | same | -| **remote facilitator refuses verification with a 400 and an `invalidReason`** | a buyer rejection carrying the sanitized reason; any other status with that body stays a transport failure | `tests/integration/adversarial-payment.test.ts`, `tests/unit/payments-x402/provider-remote-facilitator.test.ts` | -| **remote facilitator refuses settlement with a 400 and an `errorReason`** | a settlement rejection carrying the sanitized reason; any other status, or a body naming a transaction, stays a transport failure | `tests/unit/payments-x402/provider-remote-facilitator.test.ts` | -| **facilitator rejection reason is empty, over 128 characters or outside `[A-Za-z0-9_.-]`** | replaced with `invalid_payment` or `settlement_failed` | `tests/unit/payments-x402/provider-sdk-mocked.test.ts` | -| backend timeout | `BACKEND_TIMEOUT` | `tests/unit/core/execution` | -| backend 500 after an `upfront` payment | receipt records paid-and-undelivered; payer told it settled | `tests/unit/gateway`, `tests/unit/core/execution` | -| **a rejected x402 attempt presented again** (backend failure or refused settlement) | the same proof takes over the attempt and settles at most once; of two concurrent retries, one gets 409; the replaced row stays in `payment_attempt_history` | `tests/unit/storage-receipts/payment-attempts.test.ts`, `tests/e2e/payment/x402-settlement.e2e.test.ts` | -| **x402 settlement refused after the backend ran** (`authorization` flow) | the response is withheld; 402 with a failed `PAYMENT-RESPONSE` | `tests/integration/x402-payment-flow.test.ts` | -| payment-attempt reservation or replay-key lookup failure | `STORAGE_ERROR`, never mislabeled `PAYMENT_REPLAYED` or answered with a fresh challenge | `tests/unit/core/execution/pipeline.test.ts` | -| **the journal row cannot be inserted with the replay reservation** | `STORAGE_ERROR` before settlement; no new replay reservation; release of any AP2 hold is attempted | `tests/integration/transaction-journal.test.ts` | -| **a payment-attempt, receipt or journal write fails after the replay reservation** | Delivery or the original commerce error is preserved. Delivered outcomes and terminal events carry known failure labels; each write failure is logged at `error` level. A failed `completed` step adds no detail to those snapshots. The pipeline does not add the list to thrown errors | `tests/integration/transaction-journal.test.ts`, `tests/e2e/payment/crash-matrix.e2e.test.ts` | -| **a gateway crash during a paid purchase, from before settlement to before the receipt commit, in either payment flow** | after restart, operator state reflects recorded steps and unresolved entries remain visible. `reconcile` can resolve `reserved` attempts at least `--min-age` old from final chain evidence, marking them `rejected` only with proof of an expired, unused authorization. It can also retry pending AP2 holds. After a payment and its hold are resolved, later runs leave them unchanged. Unknown delivery remains for operator review | `tests/e2e/payment/crash-matrix.e2e.test.ts` | -| **SIGTERM while a settlement is held, in either payment flow, then SIGKILL after the grace period** | With AP2 disabled, the gateway drains until killed. After restart, the attempt remains `reserved`; its replay returns 409 without a challenge and moves no funds, whether or not the original transfer landed | `tests/e2e/payment/crash-matrix.e2e.test.ts` | -| **shutdown with a request in flight** | In-flight requests finish before adapters stop; `close()` resolves after both. A second SIGTERM or SIGINT exits immediately with status 1 | `tests/unit/gateway/shutdown.test.ts`, `tests/unit/gateway/main.test.ts` | -| **a backup taken while the gateway writes, into a directory that holds files, or of a 2.5 database** | Each copy is a consistent snapshot at 0600, hashed in `backup.json`; a new directory is created at 0700. A non-empty `--out` is refused without writing files. Read-only sources keep their schema and import state, including v4 for 2.5 | `tests/unit/cli/backup.test.ts` | -| **a restore of a backup older than some purchases** | Spent EVM proofs cannot pay again, including those absent from the backup. Attempts restored as uncertain still need `reconcile` or `resolve`. An AP2 mandate consumed after the backup can authorize again until it expires | `tests/e2e/payment/restore.e2e.test.ts` | -| **a receipt write fails before a paid MCP or A2A response** | The `agent-commerce/delivery` summary retains the receipt ID and adds `receiptCommitted: false`; a successful receipt save leaves the field absent | `tests/integration/receipt-committed.test.ts`, `demo/agent/test/run.test.ts` | -| **an unused authorization that can still land** | `unknown` (`not_expired`) while it remains valid at finality; `not-settled` requires unused state at a final block beyond EVM's last valid second or NEAR's `maxBlockHeight` | `tests/e2e/payment/x402-reconcile.e2e.test.ts`, `tests/e2e/payment/x402-near.e2e.test.ts` | -| **a used EVM nonce without a matching transfer (`cancelAuthorization`, `invalidateUnorderedNonces` or missing RPC logs)** | `unknown` (`used_without_record`); missing logs or receipts alone do not prove that a payment was not settled | same | -| **an RPC serving a chain other than the configured offer's (EVM chain ID, Solana genesis hash, NEAR `chain_id`)** | `unknown` (`chain_mismatch`) before reading state or transfer evidence | `tests/e2e/payment/x402-reconcile.e2e.test.ts`, `tests/e2e/payment/x402-solana.e2e.test.ts`, `tests/e2e/payment/x402-near.e2e.test.ts` | -| **a referenced EVM or NEAR transaction that is not yet final** | `unknown` (`not_final`); definitive outcomes require final evidence. Solana accepts only successful finalized transactions as settlement evidence | `tests/e2e/payment/x402-reconcile.e2e.test.ts`, `tests/unit/payments-x402/reconcile-svm-near.test.ts` | -| **an EIP-3009 attempt referencing another payment's transfer of the same amount** | that reference does not prove settlement; `settled` requires this authorization's `AuthorizationUsed` log and a matching transfer | same | -| **two Permit2 payments with the same owner, amount and validity window** | each attempt must match a proxy call carrying its own nonce | same | -| **a Solana or NEAR attempt referencing another payment's transaction** | that reference does not prove settlement; Solana requires a matching message hash, and NEAR requires the attempt's delegate (sender, access key and nonce) plus a matching successful transfer receipt | `tests/unit/payments-x402/reconcile-svm-near.test.ts`, `tests/e2e/payment/x402-solana.e2e.test.ts`, `tests/e2e/payment/x402-near.e2e.test.ts` | -| **a Solana transaction that never landed** | `unknown`: `history_too_long` at the search page cap; otherwise `not_expired` while the blockhash is valid, then `blockhash_expired`. Expiry alone does not prove `not-settled` | `tests/unit/payments-x402/reconcile-svm-near.test.ts`, `tests/e2e/payment/x402-solana.e2e.test.ts` | -| **a NEAR delegate's nonce used by another transaction, or its access key deleted** | `unknown` (`used_without_record` or `access_key_missing`); key state alone does not prove that the delegate executed | `tests/unit/payments-x402/reconcile-svm-near.test.ts`, `tests/e2e/payment/x402-near.e2e.test.ts` | -| **competing payment resolutions or a later reconciliation run** | the update requires the expected status and no existing resolution, allowing at most one resolution write. Later runs skip payment resolution but can retry pending holds. A losing manual HTTP resolution returns 409 `already_resolved`, or `not_resolvable` if the status changed without a resolution. SQLite rejects late pipeline writes to rows with recorded resolutions, logs them at `warn` and throws `STORAGE_ERROR` | `tests/unit/core/execution/reconcile.test.ts`, `tests/e2e/payment/x402-reconcile.e2e.test.ts`, `tests/unit/core/execution/resolve.test.ts`, `tests/unit/gateway/operator-transactions.test.ts` | -| **an in-flight `reserved` attempt younger than `--min-age`** | excluded from reconciliation candidates; the default minimum age is 15 minutes. Manual resolution also refuses a `reserved` attempt younger than 15 minutes (`too_recent`) | `tests/unit/core/execution/reconcile.test.ts`, `tests/unit/core/execution/resolve.test.ts` | -| **a `reserved` AP2 hold whose request recorded no payment attempt** | eligible for release through an enabled AP2 provider once its last update is older than `--min-age`; the gateway reserves an attempt before calling settlement. Current or replaced attempts exclude a hold. Dry runs report scanned holds without releasing them. An omitted or empty list of authorization providers skips both hold scans | `tests/unit/core/execution/reconcile.test.ts` | -| **`reconcile` or `resolve` with AP2 enabled and an existing, unimported AP2 file** | `STORAGE_ERROR` before payment or hold updates; these commands do not import legacy files. The receipt store is initialized after a read-only schema check | `tests/unit/cli/resolve.test.ts` | -| **`reconcile` or `resolve` on a receipt database below schema v5, such as one a 2.5 gateway still uses** | `STORAGE_ERROR` before payment, hold or schema updates; the message directs the operator to run `agent-commerce backup`, then start 2.6.0 once | `tests/unit/cli/reconcile.test.ts` | -| **`reconcile`, `resolve` or `doctor` run beside a live gateway** | These commands neither import legacy files nor recover ACP claims. `reconcile` and `resolve` open the receipt store without legacy import options; `doctor` opens it read-only. They leave the gateway's existing in-flight ACP claims unchanged | `tests/unit/cli/reconcile.test.ts`, `tests/unit/cli/doctor-storage.test.ts` | -| **reconciliation calling a facilitator or broadcasting** | x402 and MPP reconciliation read the configured offer's RPC without calling the facilitator or broadcasting | `tests/e2e/payment/x402-reconcile.e2e.test.ts`, `tests/e2e/payment/x402-solana.e2e.test.ts`, `tests/e2e/payment/x402-near.e2e.test.ts` | -| **an attempt without a recorded locator** | x402 and MPP return `unknown` (`no_locator`), leaving the attempt unresolved | `tests/unit/payments-x402/reconcile.test.ts`, `tests/unit/core/execution/reconcile.test.ts` | -| **an RPC error during reconciliation** | `unknown` (`rpc_error`); RPC URLs in the report retain the origin and omit userinfo, path and query | `tests/e2e/payment/x402-reconcile.e2e.test.ts` | -| **an operator rejecting an attempt before its recorded expiry** | `resolve` and `POST /api/payment-attempts/:id/resolve` refuse `rejected` while the recorded expiry is in the future (`not_expired`). A `settled` verdict has no expiry check; neither verdict is checked on chain | `tests/unit/core/execution/resolve.test.ts`, `tests/unit/gateway/operator-transactions.test.ts` | -| **a `rejected` resolution without a valid recorded expiry, including Solana, NEAR and attempts written before the journal** | refused (`expiry_unknown`) unless the operator supplies `confirmExpired` or `--confirm-expired`; this confirmation does not bypass a future recorded expiry | `tests/unit/core/execution/resolve.test.ts`, `tests/unit/gateway/operator-transactions.test.ts`, `tests/unit/cli/resolve.test.ts` | -| **the operator view or resolve called without the token, with a wrong token, from a disallowed Origin, or with a malformed cursor** | a disallowed Origin returns 403 before the token check; otherwise, no configured token gives 404 and a missing or wrong bearer token gives 401 before ledger access. A malformed cursor or unknown state returns `INPUT_INVALID`; a resolve body over 4 KiB gets 413 | `tests/unit/gateway/operator-transactions.test.ts` | -| **a journal row, locator, resolution or operator response carrying a proof, signature, mandate, input body or secret** | built-in locators contain public chain fields, and core records an input digest when available instead of the body. SQLite redacts values under secret-shaped keys in locators, authorizations and resolutions before writing. Operator routes return those stored values; providers and callers must keep secrets out of other fields | `tests/fixtures/x402/locator.ts` (through the provider tests), `tests/unit/storage-receipts/transactions.test.ts`, `tests/unit/gateway/operator-transactions.test.ts` | -| **a run stopping between the attempt write and the hold write** | the journal hold remains `reserved` or `uncertain`, so a later run with AP2 enabled can retry the update. The reserving request ID accompanies each update, preventing a late release from changing a later reservation | `tests/unit/core/execution/resolve.test.ts`, `tests/unit/authorization-ap2/replay-store.test.ts`, `tests/unit/cli/resolve.test.ts` | -| a local reader racing the ledger's creation | database is 0600 before SQLite opens it; WAL sidecars stay 0600 | `tests/unit/storage-receipts/permissions.test.ts`, `tests/unit/storage-receipts/persistence.test.ts` | -| RPC unreachable during verify | `PAYMENT_PROVIDER_UNAVAILABLE`, not "bad signature" | `tests/unit/payments-x402` | -| **an external `$ref` in an imported document** | refused; zero outbound requests | `tests/unit/openapi/load.test.ts` | -| **an imported path value naming another host** | percent-encoded into one segment of the configured origin | `tests/integration/openapi-import.test.ts` | -| **an imported query group colliding with a pinned backend query** | `INPUT_INVALID` before payment; nothing settled | same | -| **an imported operation with an unsupported required parameter** | never becomes a resource at all | same | -| **`__proto__` in an imported document, as a `$ref` segment, parameter name or property name** | refused; a `$ref` resolves own members only, so it never reaches `Object.prototype` | `tests/unit/openapi` | -| **an ACP request with missing, non-bearer, empty or wrong authorization** | 401 before the body is read; zero merchant calls | `tests/unit/protocols-acp/adapter.test.ts` | -| **an ACP request naming an unsupported API version** | 400 naming `supported_versions`; never mapped to the pinned one | same | -| **an ACP POST with no or an over-long `Idempotency-Key`** | 400; zero merchant calls | `tests/conformance/acp/protocol.test.ts` | -| **an ACP key replayed while the first request is in flight** | 409; the merchant is called exactly once | `tests/conformance/acp/idempotency.test.ts` | -| **an ACP operation the merchant acted on before timing out** | 409 `idempotency_unresolved` by default; the gateway makes no second merchant call | same | -| **an ACP retry after a merchant 500 or 503** | claim held by default; with `merchantIdempotent`, released for retry with the same derived key | `tests/conformance/acp/idempotency.test.ts`, `tests/conformance/acp/errors.test.ts` | -| **an ACP retry after a merchant 429** | claim released because the gateway treats 429 as unprocessed; the same key runs again | `tests/conformance/acp/idempotency.test.ts` | -| **an unresolved ACP record outliving its retention window** | kept; only a completed record expires | `tests/unit/protocols-acp/idempotency.test.ts` | -| **two ACP claims of one key racing in the receipt database** | for the same deployment, endpoint and key, one claim wins and permits one pipeline execution | `tests/unit/storage-receipts/conformance.test.ts`, `tests/unit/protocols-acp/idempotency-receipt-store.test.ts` | -| **an ACP claim left in flight by a crashed gateway** | marked unresolved by default; with `merchantIdempotent`, released at the next start | same | -| **an ACP claim outliving a bearer-token rotation** | kept; the scope is the deployment, never the credential | same | -| **an ACP key reused with a different body** | 422; the merchant is called exactly once | same | -| **a merchant answering an ACP route with a non-ACP document** | refused as `processing_error`; its body never forwarded | `tests/conformance/acp/errors.test.ts` | -| **a merchant putting an HTML tag or comment in ACP markdown** | refused as `processing_error`; code spans and autolinks remain text | `tests/conformance/acp/errors.test.ts` | -| **an ACP session id containing an encoded `/`** | stays one path segment and retains its own idempotency scope | `tests/unit/protocols-acp/adapter.test.ts` | -| **a merchant leaking a connection string or stack in an error body** | body text is not relayed; a valid ACP error contributes its type, safe code and optional param, while the gateway supplies the message | same | -| **an ACP `Request-Id` carrying a header-injection payload** | dropped, never echoed | `tests/unit/protocols-acp/adapter.test.ts` | -| **an ACP caller header naming a header the operator configured** | the configured value reaches the merchant | `tests/integration/acp-over-gateway.test.ts`, `tests/unit/core/execution/backend-http.test.ts` | -| **an ACP bearer token bound for the merchant** | never forwarded; only the six listed caller headers are | `tests/conformance/acp/protocol.test.ts`, `tests/integration/acp-over-gateway.test.ts` | -| **a forwarded header with CR/LF or an illegal name** | ACP drops a value that is not printable ASCII or exceeds 2048 characters; the backend executor refuses an illegal forwarded name or value as `INPUT_INVALID` before pricing | `tests/unit/protocols-acp/adapter.test.ts`, `tests/unit/core/execution/backend-http.test.ts`, `tests/unit/core/execution/pipeline.test.ts` | -| **a forwarded header naming `Idempotency-Key` or `Content-Type`** | never forwarded; the merchant gets the derived key and the executor's content type | `tests/unit/core/execution/backend-http.test.ts` | -| **a backend page with script, relayed to a browser** | delivered as text with `Content-Security-Policy: sandbox` and `nosniff`, so the script cannot act as the gateway's origin | `tests/unit/gateway/server.test.ts` | -| **a merchant `Retry-After` that is huge, an HTTP date or absent** | clamped to 1-300 seconds; 5 when absent or not whole seconds | `tests/conformance/acp/errors.test.ts` | -| **a merchant ACP error with an `idempotency_*` code** | not relayed; the gateway's own code is sent | same | -| **an ACP checkout resource configured as paid** | refused at config load; a defensive runtime challenge maps to 500 | `tests/unit/config/schema.test.ts`, `tests/unit/protocols-acp/checkout-mapping.test.ts` | -| **an ACP `body` or `path` schema that load closes before it accepts what the adapter sends** | refused at config load, not as INPUT_INVALID on every request | `tests/unit/config/schema.test.ts`, `tests/unit/cli/examples.test.ts` | -| **an AP2 mandate with a holder-key binding (`cnf`)** | `AUTHORIZATION_INVALID`, `unsupported_mandate_type`; nothing settles | `tests/unit/authorization-ap2/verifier.test.ts` | -| **an A2A request sent without an `id`, as a notification** | `-32600`; nothing runs | `tests/conformance/a2a/errors.test.ts` | -| **an x402 A2A extension payment whose `contextId` is not the task's** | `-32602`; the task stays pending | `tests/integration/a2a-x402-extension.test.ts` | -| **an A2A `GetTask` or `CancelTask` naming a pending x402 task** | `-32001` `TaskNotFoundError`; the task stays payable | `tests/integration/a2a-x402-extension.test.ts` | -| **an A2A 0.x follow-up, with a non-user role, on a pending task** | `-32602`; the task stays pending | same | -| **an A2A message carrying a large array of invalid parts or task references** | refused by its length (`PROTOCOL_UNSUPPORTED`) before any element is validated, so no validation issue is built per element | `tests/unit/protocols-a2a/message-mapping.test.ts` | -| **an AP2 mandate on an x402 extension payment message beside another resource or input** | verified against the stored purchase; the other resource and input are ignored | same | -| **an MCP placeholder `_payment` beside a real proof in `_meta`** | the `_meta` proof settles; `_payment` is ignored | `tests/integration/mcp-payment-bindings.test.ts` | -| **an MCP `_meta["x402/payment"]` string that is not base64 `PaymentPayload` JSON** | `PAYMENT_INVALID` with a fresh challenge; nothing settles | same | -| **a JSON-RPC batch to `/mcp` under a revision that removed batching** | HTTP 400 with `-32600`; no tool runs | `tests/conformance/mcp/mcp-conformance.test.ts` | -| **an AP2 mandate with a tampered signature** | `AUTHORIZATION_INVALID`; nothing settles | `tests/integration/ap2-x402-conformance.test.ts` | -| **an expired AP2 mandate** | `AUTHORIZATION_INVALID`; nothing settles | same | -| **a mandate from an issuer that is not configured** | refused at the trust allowlist, before any signature check | same, `tests/unit/authorization-ap2` | -| **a mandate claiming a trusted `kid` but signed with another key** | refused at the signature; `kid` selects the key, never labels it | same | -| **a mandate naming a `kid` the issuer does not have** | refused; no "try every key" fallback | same | -| **a mandate with no `iss` whose `kid` two trusted issuers share** | `unknown_key`; neither key is tried | `tests/unit/authorization-ap2` | -| **a mandate token with no `aud` or `exp`** | absence alone is allowed by default; strict settings may refuse it, and the checkout JWT has its own required `aud` and `exp` | same | -| **a delegation chain or key-bound presentation** (autonomous mode) | `unsupported_mandate_type`; nothing settles | same | -| **an AP2 delegation hop presented alone** (key-bound `typ`, top-level `sd_hash` or `issuer_jwt_hash`) | `unsupported_mandate_type`; nothing settles | same | -| **a mandate whose `checkout_hash` does not match its checkout JWT** | `checkout_binding_failed`; nothing settles | same | -| **a mandate approved for another resource, input, amount, currency, payment method, network or asset** | `purchase_mismatch`, one coarse reason; nothing settles | same | -| **a mandate silent about the chain the requirement names** | refused - fail closed both ways | same | -| **a mandate presented twice** | `AUTHORIZATION_REPLAYED`; the second purchase moves no funds | same, `tests/e2e/authorization` | -| **the same mandate re-presented with a fresh, valid payment proof** | still refused; balances unchanged on a real chain | `tests/e2e/authorization` | -| **a mandate replayed under selective disclosure** (one mandate, many presentation strings) | refused - the replay key is the issuer-signed token, not the presentation | `tests/unit/authorization-ap2` | -| **a released mandate re-presented after another mandate reserved or spent the same checkout** | refused as replayed; the released row remains released | `tests/unit/authorization-ap2/replay-store.test.ts` | -| **the AP2 replay store unreachable** | `AUTHORIZATION_PROVIDER_UNAVAILABLE`, retryable, never the buyer's fault | `tests/integration/ap2-runtime.test.ts` | -| **a valid mandate presented without a payment proof** | 402 challenge; the mandate is not consumed | `tests/integration/ap2-x402-conformance.test.ts` | -| **a payment proof rejected at verification** | the mandate is never reserved; a corrected proof reuses it | same | -| **settlement definitively refused after the mandate was reserved** | the reservation is released; a corrected proof reuses the mandate | same | -| **settlement throws without a verdict, with or without a transaction hash** | the mandate is marked uncertain, not handed back | `tests/integration/ap2-x402-conformance.test.ts`, `tests/unit/core/execution/pipeline-authorization.test.ts` | -| **the AP2 replay store failing to record a consume or release after the payment outcome** | the payment outcome is preserved. Pending replay and journal holds stay `reserved` or `uncertain`, blocking reuse; `reconcile` with AP2 enabled can retry the consume or release | `tests/e2e/payment/crash-matrix.e2e.test.ts`, `tests/unit/core/execution/resolve.test.ts` | -| **two holds racing on one mandate, or two mandates on one checkout** | one reservation wins; the other is refused as replayed | `tests/unit/storage-receipts/conformance.test.ts` | -| **a custom store that reserves one replay key twice** | Fails the `./testing` cases for repeated reservations, including variations in other fields and concurrent calls. These calls must reserve only one attempt per replay key. Failures outside these cases may go undetected | `tests/unit/storage-receipts/conformance.test.ts` | -| **an AP2 presentation reaching a stored hold, pending task or idempotency claim** | the AP2 provider stores the mandate reference, checkout `jti` and issuer IDs; A2A keeps parsed mandates in memory. Task input outside reserved fields, `adapterData` and idempotency bodies are unredacted; callers must exclude presentations | `tests/unit/storage-receipts/no-secrets.test.ts`, `tests/unit/protocols-a2a/pending-store.test.ts`, `tests/unit/authorization-ap2/provider.test.ts` | -| **a mandate spent before the 2.6.0 upgrade, presented after it** | refused as replayed after a successful legacy import: hold states are preserved, and an import cannot unblock a reference already held in the receipt database | `tests/unit/storage-receipts/legacy-import.test.ts`, `tests/e2e/payment/upgrade.e2e.test.ts` (both payment flows, on Anvil) | -| **a deprecated store key removed, or its file missing, before the first 2.6.0 start** | `start` returns `STORAGE_ERROR` for an enabled feature with past-use evidence and no recorded import or prior check. An empty file or `:memory:` explicitly accepts the loss; `doctor` reports that refusal as `FAIL` | `tests/unit/storage-receipts/legacy-import.test.ts`, `tests/unit/cli/doctor-storage.test.ts` | -| **an import failing part way** | the failed source's rows and import marker roll back together; earlier source imports remain committed. `start` stops with `STORAGE_ERROR`, releases acquired locks and retries the failed source on the next start | same | -| **a 2.5 gateway writing a deprecated file after the import** | refused while `start` holds the file's write lock, which is released at shutdown | same | -| **a 2.5 gateway opening a receipt database 2.6.0 migrated** | refused by its schema version check; the file is unchanged | `tests/unit/storage-receipts/migration-v5.test.ts` | -| **a free resource configured to require a mandate** | refused at config load, and again on the execution path | `tests/unit/config/ap2.test.ts`, `tests/unit/core/execution` | -| **an oversized `Agent-Authorization` header** | `AUTHORIZATION_INVALID`; the pipeline is not called | `tests/integration/authorization-carrier.test.ts` | -| **an MPP challenge edited after issue, or signed with another gateway's secret** | `challenge_not_issued` | `tests/unit/payments-mpp/provider.test.ts` | -| **an MPP challenge issued for another resource, price, recipient, asset or network** | refused on the binding check, one reason per field | same | -| **an MPP credential presented after its challenge expired** | `challenge_expired`, on the gateway's clock | same | -| **an MPP authorization whose nonce is not the challenge hash** | `wrong_nonce` | same | -| **an MPP credential sent with a body other than the one its challenge was issued for** | `body_digest_mismatch` before settlement; the same credential works with the original body bytes | `tests/integration/mpp-carriers.test.ts`, `tests/unit/payments-mpp/provider.test.ts` | -| **an MPP credential type other than EIP-3009 `authorization`** | `unsupported_credential` | same | -| **an MPP authorization signed by someone other than the payer, or for another amount or recipient** | `invalid_signature`, `wrong_amount` or `wrong_recipient` | same | -| **an MPP authorization outside its `validAfter`/`validBefore` window** | `authorization_not_yet_valid` or `authorization_expired` | same | -| **a valid MPP credential** | local checks, then the facilitator's read-only check; nothing is broadcast | `tests/unit/payments-mpp/provider.test.ts` | -| **an MPP copy that passes the facilitator check while its replay key is already reserved** | `PAYMENT_REPLAYED`; no second settlement | `tests/unit/payments-mpp/provider.test.ts` | -| **a settled MPP credential presented again** | 402 `invalid-challenge`, reason `challenge_already_used`, from the facilitator's reason or the token's `authorizationState`; no second settlement | `tests/integration/mpp-carriers.test.ts`, `tests/unit/payments-mpp/provider.test.ts` | -| **the facilitator is unreachable during MPP verification** | `verify` throws `PAYMENT_PROVIDER_UNAVAILABLE` (503) before anything is reserved | `tests/e2e/payment/mpp-settlement.e2e.test.ts` | -| **an MPP settlement times out after a possible broadcast** | provider throws `PAYMENT_PROVIDER_UNAVAILABLE`; pipeline records `settlement-uncertain` | `tests/unit/payments-mpp/provider.test.ts`, `tests/unit/core/execution/pipeline.test.ts` | -| **an `Authorization` header in another scheme on an MPP resource** | no payment: `402` with a challenge | `tests/integration/mpp-carriers.test.ts` | -| **an MPP credential in the x402 `PAYMENT-SIGNATURE` header** | ignored: `402` | same | -| **a valid MPP credential from the mppx client** | settled on chain once: buyer down and merchant up by the price, and `Payment-Receipt` names the transaction | `tests/e2e/payment/mpp-settlement.e2e.test.ts` | -| **an MPP broadcast that is never confirmed** | `settlement-uncertain`; no response; under `upfront` the backend never runs; the recorded transaction lands once mined | `tests/e2e/payment/mpp-settlement.e2e.test.ts`, `tests/unit/payments-mpp/provider.test.ts` | -| **an MPP buyer with no funds** | facilitator check returns `PAYMENT_INVALID` before broadcast; nothing moved | same | -| **an AP2-gated MPP payment with no mandate, or a mandate approved for x402** | `AUTHORIZATION_REQUIRED` or `AUTHORIZATION_INVALID`; nothing settles | `tests/e2e/authorization/ap2-mpp.e2e.test.ts` | -| **an MPP config on Base mainnet without `allowMainnet`, with a local facilitator, a noncanonical asset or Base Sepolia's EIP-712 name** | `CONFIG_INVALID` at load under the relevant `payments.mpp` field | `tests/unit/config/schema.test.ts` | -| **a valid MPP payment on Base mainnet, then the same credential again** | settled once on chain; the later replay is refused with 402 or 409 and moves no funds again | `tests/mainnet/mpp-base.smoke.test.ts` (real funds, run by hand) | -| **`start` with a missing package in an enabled x402, MPP, MCP or AP2 startup import** | `CONFIG_INVALID` names the setting and package before the gateway listens or opens the receipt store. Solana, NEAR and CDP imports begin during provider construction, but their failures surface in provider health checks or payment requests | `tests/unit/gateway/main.test.ts` (each feature, from source), `tests/unit/gateway/optional-peer.test.ts` (the error mapping), CI bare install (x402, from the built package) | -| **another payer's valid proof under a stored key** | 409 `payment_identifier_conflict` after verification (422 on MPP); the error carries no stored body and nothing settles | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses another payer, with no stored body in the error", "refuses a verified proof with an unknown payer"), `tests/integration/payment-retries.test.ts` ("answers a key used by another payer with a 422 about:blank, never invalid-challenge") | -| **a forged or junk proof presented with a known key** | the normal `PAYMENT_INVALID` refusal, without returning a stored response or revealing whether the key exists or what input it holds | `tests/unit/core/execution/pipeline-retry.test.ts` ("gives a forged proof with a known key the normal refusal", "tells a junk proof with a used key and other input nothing about the key"), `tests/unit/payments-x402/payment-id.test.ts` ("refuses a proof signed for another amount, whatever id it carries") | -| **a junk proof probing an in-flight or unresolved key** | the same `PAYMENT_INVALID` refusal as for an unused key; only the proof that owns the claim gets the 409 before verification | `tests/unit/core/execution/pipeline-retry.test.ts` ("tells a junk proof nothing about an in-flight key", "tells a junk proof nothing about an unresolved key", "refuses an exact resend of an in-flight purchase before verifying it") | -| **one key with different input** | a verified proof gets 409 `payment_identifier_conflict` (422 on MPP) after one `verify()` that moves no funds; the input is neither run nor answered from the stored response | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses the same key with other input once the proof verifies, settling nothing"), `tests/integration/payment-retries.test.ts` ("answers the same id with other input with a 409 and no challenge") | -| **a crash during settlement** | the key becomes `unresolved` at startup. Exact resends and fresh proofs get 409 without settlement, even after `reconcile` records the attempt as not settled. The key expires at its retention deadline | `tests/integration/payment-retries.test.ts` ("marks a key left in flight unresolved at start, so a retry gets 409"), `tests/e2e/payment/safe-retries.e2e.test.ts` ("keeps the ID unresolved before and after reconcile: both retry styles get 409 without settlement") | -| **a key on a settled purchase with no stored response, such as an `upfront` backend failure** | while the key is unexpired, 409 `payment_identifier_unresolved` without a challenge or `Retry-After` | `tests/e2e/payment/safe-retries.e2e.test.ts` ("returns 409 without a challenge for both retry styles after a settled upfront purchase fails delivery, charging once"), `tests/integration/payment-retries.test.ts` ("answers a key with an unknown outcome with a 409, no Retry-After and no challenge"), `tests/unit/core/execution/pipeline-retry.test.ts` ("answers an unresolved key with 409 and no challenge, verifying only a fresh proof") | -| **an unresolved payment key after its retention window** | the key expires like a completed one; retries then follow the normal purchase flow. Unresolved ACP records never expire (see the ACP row above) | `tests/unit/core/execution/pipeline-retry.test.ts` ("keeps an unresolved key closed until retention ends, then sells again"), `tests/unit/storage-receipts/conformance.test.ts` ("an expired unresolved payment key reads as absent and can be claimed again") | -| **exposure of client keys and proofs** | client keys are stored as SHA-256 hashes, and logs redact proof and `Idempotency-Key` headers. Raw keys and proofs stay out of events and merchant requests; the merchant key derives from the client key hash and payer. The facilitator receives the proof without extensions | `tests/unit/core/execution/pipeline-retry.test.ts` ("puts no client key or proof in a log line, an event or an error", "never sends the client key to the merchant"), `tests/unit/gateway/logger.test.ts` ("redacts every wire header that carries a credential or a proof"), `tests/unit/payments-x402/payment-id.test.ts` ("sends the facilitator no part of the extension"), `tests/integration/merchant-idempotency.test.ts` ("sends the merchant no part of the proof") | -| **a stored response after its retention window** | the response is no longer served; retries follow the normal purchase flow. A later claim sweep deletes the expired row | `tests/unit/core/execution/pipeline-retry.test.ts` ("still serves a retry just before retention ends", "treats a retry after retention as a new purchase"), `tests/unit/storage-receipts/conformance.test.ts` ("an expired completed claim reads as absent, and other checkout claims never expire") | -| **removing `redelivery` or changing `merchant.publicBaseUrl` while a key is unresolved** | new requests cannot read the key; a fresh proof follows the normal purchase flow. `doctor` counts the unresolved key until its retention deadline | `tests/integration/payment-retries.test.ts` ("recovers the key without the opt-in, sells again, and doctor still counts the key", "sells again after publicBaseUrl changes, and doctor still counts the key") | -| **an MPP key conflict, in-flight or unresolved answer that invites a new challenge** | Problem Details with `type: about:blank` and no `WWW-Authenticate`; never `invalid-challenge`, which could prompt a second payment | `tests/integration/payment-retries.test.ts` ("answers a key used by another payer with a 422 about:blank, never invalid-challenge", "answers a request in flight as Problem Details with Retry-After", "answers a charged key with no stored response as Problem Details without Retry-After"), `tests/unit/payments-mpp/problems.test.ts` ("answers the retry-key 409 %s with about:blank, not invalid-challenge") | -| **an MPP key conflict read as "retry unchanged"** | a key reused with other input or another payer answers 422 `Unprocessable Content`, not 409; in-flight and unresolved stay 409 `Conflict` | `tests/integration/payment-retries.test.ts` ("answers a key used by another payer with a 422 about:blank, never invalid-challenge"), `tests/unit/payments-mpp/problems.test.ts` ("answers a retry key reused with other input as 422 about:blank") | -| **a redelivery without the mandate the purchase used** | an exact resend falls through to normal verification, and a verified fresh proof gets 409 `payment_identifier_conflict`; the same holds when the resource began requiring a mandate after the purchase | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses %s the purchase used, serving nothing", "serves nothing once the resource requires a mandate it did not when it paid") | -| **the payment proof header as a bearer credential** | with matching input and any required mandate, `PAYMENT-SIGNATURE` retrieves the stored response until retention ends. Keep it out of proxy, APM and access logs; the gateway's logger redacts it | `tests/integration/payment-retries.test.ts` ("redelivers an exact resend, marked as a replay, without a second charge"), `tests/unit/gateway/logger.test.ts` ("redacts every wire header that carries a credential or a proof") | -| **a retention window outside 1 to 8760 hours** | config load refuses it, and a pipeline built without config throws `CONFIG_INVALID` before any payment work | `tests/unit/config/schema.test.ts` ("refuses a retention of %s hours as too long", "refuses a retention of %s", "accepts the longest retention, one year"), `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses a retention of %s hours before any provider call", "accepts a retention of one year") | -| **an A2A in-flight refusal that tells the client to resend on the consumed task** | the failed task reports `retryable: false` with no `retryAfterSeconds`; the client starts a new task with the same payment `id` | `tests/unit/protocols-a2a/payment-resume.test.ts` ("does not tell the client to retry an in-flight payment id on the consumed task") | +| Scenario | Outcome | Where | +| --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| missing `PAYMENT-SIGNATURE` | 402, no delivery | `tests/unit/gateway/server.test.ts` | +| malformed `PAYMENT-SIGNATURE` over HTTP | `PAYMENT_INVALID`; no backend call | `tests/integration/adversarial-payment.test.ts` | +| **an x402 v1 client sending only `X-PAYMENT`** | 402 with `error: "invalid_x402_version"` in `PAYMENT-REQUIRED`; no backend call | `tests/unit/gateway/server.test.ts` | +| x402 signature, nonce or payer changed after signing | rejected before settlement; balances unchanged | `tests/e2e/payment/x402-settlement.e2e.test.ts` | +| network substitution | `invalid_network` before settlement | `tests/e2e/payment` | +| requirement changed to another deployed token, or buyer proof signed for that token | `wrong_asset` or invalid signature before settlement; balances unchanged | `tests/e2e/payment/x402-settlement.e2e.test.ts` | +| proof recipient differs from configured `payTo` | `invalid_exact_evm_payload_recipient_mismatch` before settlement | `tests/e2e/payment/x402-settlement.e2e.test.ts`, `tests/unit/payments-x402/provider.test.ts` | +| amount differs from the price in either direction | `invalid_exact_evm_payload_authorization_value_mismatch` before settlement | `tests/e2e/payment/x402-settlement.e2e.test.ts` | +| **an x402 proof whose `accepted` differs from the offer, or whose `resource.url` differs from the challenge's** | `invalid_payment_requirements` before the facilitator is called | `tests/unit/payments-x402/provider-remote-facilitator.test.ts` | +| **multi-offer x402 proof whose `accepted` matches zero or multiple offers** | `invalid_payment_requirements`; no offer is selected and the facilitator is not called | `tests/unit/payments-x402/provider-remote-facilitator.test.ts` | +| **an AP2 mandate approved for one x402 offer, presented with a proof for another** | selected destination, network or asset fails mandate binding; no settlement | `tests/unit/core/execution/pipeline-authorization.test.ts` | +| **an EIP-3009 authorization signed for one EVM chain, presented as another chain's offer** | `invalid_exact_evm_signature`: signature fails against the other chain's EIP-712 domain; neither chain's balances change | `tests/e2e/payment/x402-multichain.e2e.test.ts` | +| **the same payer, token address and nonce on two EVM chains** | both settle: the replay key includes the chain id | same | +| **a Permit2 proof naming another spender, token, amount or witness recipient than the offer** | `invalid_permit2_spender`, `permit2_token_mismatch`, `permit2_amount_mismatch` or `invalid_permit2_recipient_mismatch` before the facilitator is called; balances unchanged | `tests/unit/payments-x402/provider-remote-facilitator.test.ts`, `tests/e2e/payment/x402-permit2.e2e.test.ts` | +| **a Permit2 proof altered after signing** | `invalid_permit2_signature` from the facilitator; balances unchanged | `tests/e2e/payment/x402-permit2.e2e.test.ts` | +| **a Permit2 payment from a buyer without a Permit2 allowance** | 412 with `error: permit2_allowance_required` in a fresh `PAYMENT-REQUIRED`; `permit2_simulation_failed` and 402 on a chain without Multicall3; balances unchanged | `tests/e2e/payment/x402-permit2.e2e.test.ts` | +| **a proof of the other transfer method, or one carrying both an EIP-3009 and a Permit2 authorization** | `unsupported_scheme` before the facilitator is called, which would settle the Permit2 one | `tests/unit/payments-x402/provider-remote-facilitator.test.ts`, `tests/unit/payments-x402/provider.test.ts` | +| **an EIP-3009 authorization whose value, time bounds or nonce are not in canonical form** | `invalid_payload` before the facilitator is called: amounts and times must be decimal, the nonce 32 bytes of hex | `tests/unit/payments-x402/provider.test.ts` | +| **a gas-sponsoring extension in a Permit2 payload** | not forwarded: the facilitator receives no `extensions` | `tests/unit/payments-x402/provider-remote-facilitator.test.ts` | +| **an ERC-8409 quote altered after signing, or signed by another key** | a buyer that verifies the signature against the published issuer rejects it | `tests/unit/payments-x402/quote.test.ts`, `tests/integration/x402-quotes.test.ts` | +| **an altered ERC-8409 quote echoed in a payment** | ignored: verification and settlement use the offer and payer authorization; the facilitator receives no `extensions` | `tests/integration/x402-quotes.test.ts` | +| **a leaked quote signing key** | its holder can sign quotes as the merchant. Keep the key unfunded. Rotation changes the published issuer after a restart; `doctor` detects a gateway still using the old key | `tests/integration/x402-quotes.test.ts`, `tests/unit/cli/doctor.test.ts` | +| **unpaid requests that vary the input to force quote signing** | each cache miss requires a signature per EVM offer; repeated input reuses a cached quote while a full payment window remains | `tests/unit/payments-x402/quote.test.ts` | +| **a quote signing key that controls an EVM `payTo`, or a well-known Anvil key outside the local chain** | `CONFIG_INVALID` during provider construction; the message omits the key | `tests/unit/payments-x402/quote.test.ts` | +| **one Permit2 authorization on two requests at once** | settles once; the other gets 409 `PAYMENT_REPLAYED`. The key is chain, Permit2, owner and nonce | `tests/e2e/payment/x402-permit2.e2e.test.ts` | +| **a Permit2 offer on a network without Permit2 or its x402 proxy** | health fails, naming the missing contract | `tests/e2e/payment/x402-permit2.e2e.test.ts` | +| **a Solana transaction paying another wallet or a smaller amount, presented as the offer** | the facilitator refuses it: `invalid_exact_svm_payload_recipient_mismatch` or `invalid_exact_svm_payload_amount_mismatch`; balances unchanged | `tests/e2e/payment/x402-solana.e2e.test.ts` | +| **an echoed Solana offer with an address in another case, or a transaction that does not decode** | `invalid_payment_requirements`, or an `invalid_exact_svm_payload_transaction*` code, before the facilitator is called | `tests/unit/payments-x402/provider-svm.test.ts`, `tests/unit/payments-x402/svm.test.ts` | +| **the same Solana transaction with other signature bytes, while the first copy is settling** | 409 `PAYMENT_REPLAYED`: the key is the network and the message hash, not the signatures; one transfer | `tests/e2e/payment/x402-solana.e2e.test.ts`, `tests/unit/payments-x402/svm.test.ts` | +| **a settled Solana transaction presented again** | the facilitator's simulation refuses it: `invalid_exact_svm_transaction_simulation_failed`; balances unchanged | `tests/e2e/payment/x402-solana.e2e.test.ts` | +| **a Solana broadcast that lands but whose RPC response is an error** | the SDK's catch-all without a signature: `settlement-uncertain`, no delivery | `tests/e2e/payment/x402-solana.e2e.test.ts` | +| **a Solana settlement already in flight at the facilitator, or confirmed without a visible transfer** | `duplicate_settlement` or `post_settlement_transfer_not_confirmed`: `settlement-uncertain`, no delivery | `tests/unit/payments-x402/provider-svm.test.ts` | +| **an unconfirmed broadcast the facilitator could not record for a retry** | the catch-all with a transaction and an `errorMessage` starting `settlement_pending`: `settlement-uncertain`, no delivery | `tests/unit/payments-x402/provider-svm.test.ts`, `tests/unit/payments-x402/provider-sdk-mocked.test.ts` | +| **a settlement success naming another network, or no transaction in its family's form** | outcome unknown: `settlement-uncertain`, no delivery | `tests/unit/payments-x402/provider-svm.test.ts`, `tests/unit/payments-x402/provider-remote-facilitator.test.ts` | +| **a settlement failure that names a transaction, with a reason other than its family's on-chain failure** | outcome unknown: `settlement-uncertain`, no delivery. The EVM SDK reports a mined transfer whose `Transfer` log it cannot match this way | `tests/unit/payments-x402/provider-sdk-mocked.test.ts`, `tests/unit/payments-x402/provider-near.test.ts` | +| **a Solana RPC serving another cluster, a mint with other decimals, or a facilitator without a fee payer** | health fails; without a fee payer no challenge is built either | `tests/unit/payments-x402/provider-svm.test.ts`, `tests/e2e/payment/x402-solana.e2e.test.ts` | +| **a Solana payee without a token account for the mint** | health fails: the payment's transaction cannot create the account | `tests/unit/payments-x402/provider-svm.test.ts` | +| **a Solana facilitator refusing the offer itself: a fee payer it does not manage or that is missing, another scheme or network** | no verdict: `PAYMENT_PROVIDER_UNAVAILABLE`, not a buyer rejection | `tests/unit/payments-x402/provider-svm.test.ts` | +| **a NEAR delegate for another token, recipient, amount, method or deposit, or with an extra action** | refused before the facilitator: `invalid_exact_near_payload_*`; balances unchanged | `tests/unit/payments-x402/near.test.ts`, `tests/e2e/payment/x402-near.e2e.test.ts` | +| **a NEAR delegate in a non-canonical encoding: trailing bytes, other base64, truncated or oversized** | `invalid_exact_near_payload_signed_delegate_action` or `invalid_exact_near_payload_shape` before the facilitator | `tests/unit/payments-x402/near.test.ts` | +| **a NEAR delegate signed with a function-call access key** | the facilitator refuses it: `invalid_exact_near_function_call_key_not_allowed`; balances unchanged | `tests/e2e/payment/x402-near.e2e.test.ts` | +| **another NEAR delegate under the same sender, key and nonce, while the first is settling** | 409 `PAYMENT_REPLAYED`: the key is the access-key nonce, not the delegate bytes; one transfer | `tests/e2e/payment/x402-near.e2e.test.ts`, `tests/unit/payments-x402/near.test.ts` | +| **a NEAR delegate signed again with the same nonce after a backend failure or a refused settlement** | the retry takes over the rejected attempt; one transfer | `tests/e2e/payment/x402-near.e2e.test.ts` | +| **a settled NEAR delegate presented again** | the facilitator refuses it: `invalid_exact_near_payload_delegate_action_nonce_already_used`; balances unchanged | `tests/e2e/payment/x402-near.e2e.test.ts` | +| **a NEAR submission whose RPC response is lost** | `settlement_failed` without a hash: `settlement-uncertain`, no delivery; the transfer lands | `tests/e2e/payment/x402-near.e2e.test.ts`, `tests/unit/payments-x402/provider-near.test.ts` | +| **a NEAR settle failure whose reason the SDK does not return before submitting** | `settlement-uncertain`, no delivery; the reason may follow a submission | `tests/unit/payments-x402/provider-near.test.ts` | +| **a NEAR facilitator failing to read the chain during verify or settle** | no verdict: 503 `PAYMENT_PROVIDER_UNAVAILABLE`. At settle nothing was submitted, so the attempt is recorded `rejected` and a retry with the same nonce can reserve it | `tests/unit/payments-x402/provider-near.test.ts`, `tests/unit/core/execution/pipeline.test.ts` | +| **a NEAR RPC serving another chain, a token without code or with other decimals, or an unregistered payee** | health fails | `tests/unit/payments-x402/provider-near.test.ts`, `tests/e2e/payment/x402-near.e2e.test.ts` | +| **EVM token decimals that differ from `assetDecimals`** | health fails, naming both values | `tests/unit/payments-x402/provider-health-mocked.test.ts`, `tests/e2e/payment/x402-builtin-token.e2e.test.ts` | +| **x402 options on two networks sharing one RPC endpoint, or mixing testnet and mainnet** | `CONFIG_INVALID` at load | `tests/unit/config/x402-options.test.ts` | +| **a proof for an x402 offer absent from the resource's own list** | refused before the facilitator: `invalid_network` on another network, `invalid_payment_requirements` otherwise; balances unchanged | `tests/e2e/payment/x402-multichain.e2e.test.ts`, `tests/unit/payments-x402/provider-remote-facilitator.test.ts` | +| **the reason an x402 offer fails its health check, which can name an RPC host** | discovery publishes only each offer's `status`; the gateway logs the reason at debug level | `tests/unit/gateway/server.test.ts` | +| replay, sequentially | refused, no second transfer | `tests/e2e/payment/x402-settlement.e2e.test.ts`, `tests/mainnet/x402.smoke.test.ts` | +| **duplicate concurrent request** | settles once, other gets `PAYMENT_REPLAYED` | `tests/integration/adversarial-payment.test.ts` | +| **replay after a gateway restart** | still refused - the reservation is in SQLite | same | +| **an invalid x402 proof over HTTP** | 402 with a fresh `PAYMENT-REQUIRED` carrying an `error` code; no delivery | `tests/unit/gateway/server.test.ts` | +| **a settlement rejected by the facilitator** | 402 with a failed `PAYMENT-RESPONSE`; MPP also sends a fresh `WWW-Authenticate`; no delivery | `tests/unit/gateway/server.test.ts`, `tests/integration/mpp-carriers.test.ts` | +| **an x402 replay of a settled attempt** | 402 with a fresh challenge whose error is the paid network's replay code: EVM's or NEAR's spent nonce, or `duplicate_settlement` on Solana | `tests/unit/gateway/server.test.ts`, `tests/unit/protocols-mcp/result-mapping.test.ts` | +| **a replay while the first attempt is unfinished** | 409 `PAYMENT_REPLAYED` without a fresh challenge; the original payment may still settle. This includes rejections for a landed transfer or an expired MPP challenge when the provider supplies a replay key | `tests/unit/gateway/server.test.ts`, `tests/unit/protocols-mcp/result-mapping.test.ts`, `tests/unit/core/execution/pipeline.test.ts`, `tests/e2e/payment/restore.e2e.test.ts`, `tests/integration/mpp-carriers.test.ts`, `tests/unit/payments-mpp/provider.test.ts` | +| **a settlement with no verdict** | 502 with a failed `PAYMENT-RESPONSE`; `settlement_pending` when a transaction hash is known; no delivery | same | +| **a backend failure after an MPP settlement** (`upfront` flow) | the error omits `Payment-Receipt`; `PAYMENT-RESPONSE` reports the settlement | `tests/integration/mpp-carriers.test.ts` | +| **a backend failure under the MPP `authorization` flow** | nothing settles; the same credential again gets 402 `PAYMENT_REPLAYED` with a fresh challenge and moves no funds | `tests/integration/mpp-carriers.test.ts`, `tests/e2e/payment/mpp-settlement.e2e.test.ts` | +| **an MPP settlement refused after the backend ran** (`authorization` flow) | the response is withheld; 402 with a fresh `WWW-Authenticate` and a failed `PAYMENT-RESPONSE` | `tests/integration/mpp-carriers.test.ts`, `tests/unit/payments-mpp/provider.test.ts` | +| **an x402 A2A extension payment sent twice on one task** | after validating a follow-up message, the adapter atomically takes its task before execution; a competing message that loses the take gets `TaskNotFoundError` without another settlement | `tests/integration/a2a-x402-extension.test.ts`, `tests/unit/protocols-a2a/pending-store.test.ts`, `tests/unit/storage-receipts/conformance.test.ts` | +| **unauthenticated callers filling the store with unpaid A2A extension tasks** | new unpaid tasks are refused with `GATEWAY_BUSY` when the receipt store already holds 256 unexpired tasks. Tasks expire with their requirement or within an hour; expired tasks cannot be paid, and waiting tasks are not evicted | `tests/unit/protocols-a2a/x402-extension.test.ts`, `tests/unit/protocols-a2a/pending-store.test.ts`, `tests/integration/a2a-x402-extension.test.ts` | +| expired authorization (`validBefore` in the past) | refused before settlement | `tests/e2e/payment` | +| not-yet-valid authorization (`validAfter` in the future) | refused before settlement | `tests/e2e/payment` | +| a `{param}` in the host position of `backend.url` | refused at config load | `tests/unit/config/schema.test.ts` | +| an unknown key nested under an `additionalProperties` schema | rejected by the closed schema | same | +| **a built-in mainnet redeclared as a testnet under `payments.x402.networks`** | `CONFIG_INVALID` at load; a built-in network cannot be redeclared | `tests/unit/config/x402-options.test.ts` | +| **the in-process facilitator configured for any network but the local `eip155:31337`** | `CONFIG_INVALID` at load; `eip155:84532` gets a pointer to the local id | same | +| **conflicting USDC metadata, or another token in public-network MPP, Solana or NEAR config** | `CONFIG_INVALID` at load | same | +| **a resource priced in a label other than `USD` or `USDC` against an option that names its network's USDC** | `CONFIG_INVALID` at load, as when the option omits the asset | `tests/unit/config/x402-options.test.ts` | +| **facilitator `auth.type: cdp` at a URL other than CDP's, or an RPC URL that is not http(s)** | `CONFIG_INVALID` at load; neither message repeats the URL's path or query | `tests/unit/config/schema.test.ts`, `tests/unit/config/x402-options.test.ts` | +| remote facilitator timeout during settlement | provider throws `PAYMENT_PROVIDER_UNAVAILABLE`; pipeline marks the attempt uncertain and returns `PAYMENT_SETTLEMENT_FAILED` | `tests/unit/payments-x402/provider-remote-facilitator.test.ts`, `tests/unit/core/execution/pipeline.test.ts` | +| an unclassified local `settle()` throw | provider throws `PAYMENT_PROVIDER_UNAVAILABLE`; pipeline marks the attempt uncertain and returns `PAYMENT_SETTLEMENT_FAILED` | `tests/unit/payments-x402/provider-sdk-mocked.test.ts`, `tests/unit/core/execution/pipeline.test.ts` | +| a local `settle()` throw wrapping an RPC error, not a revert | same as an unclassified throw; not `transaction_reverted` | `tests/unit/payments-x402/provider-sdk-mocked.test.ts` | +| **a broadcast that lands but whose RPC response is an error** | the SDK's catch-all with no transaction hash; provider throws `PAYMENT_PROVIDER_UNAVAILABLE`; pipeline records `settlement-uncertain`, no delivery | `tests/e2e/payment/x402-settlement.e2e.test.ts`, `tests/e2e/payment/mpp-settlement.e2e.test.ts` | +| **a Permit2 broadcast that lands but whose RPC response is an error** | the SDK's catch-all with its message appended and no hash; handled as the row above | `tests/e2e/payment/x402-permit2.e2e.test.ts` | +| **remote facilitator 401 / 5xx during verification** | transport failure, not a buyer rejection: 503 with `Retry-After: 5` on the HTTP route | `tests/integration/adversarial-payment.test.ts`, `tests/unit/gateway/server.test.ts` | +| **malformed remote-facilitator response** | transport failure; never read as a verdict | same | +| **remote facilitator refuses verification with a 400 and an `invalidReason`** | a buyer rejection carrying the sanitized reason; any other status with that body stays a transport failure | `tests/integration/adversarial-payment.test.ts`, `tests/unit/payments-x402/provider-remote-facilitator.test.ts` | +| **remote facilitator refuses settlement with a 400 and an `errorReason`** | a settlement rejection carrying the sanitized reason; any other status, or a body naming a transaction, stays a transport failure | `tests/unit/payments-x402/provider-remote-facilitator.test.ts` | +| **facilitator rejection reason is empty, over 128 characters or outside `[A-Za-z0-9_.-]`** | replaced with `invalid_payment` or `settlement_failed` | `tests/unit/payments-x402/provider-sdk-mocked.test.ts` | +| backend timeout | `BACKEND_TIMEOUT` | `tests/unit/core/execution` | +| backend 500 after an `upfront` payment | receipt records paid-and-undelivered; payer told it settled | `tests/unit/gateway`, `tests/unit/core/execution` | +| **a rejected x402 attempt presented again** (backend failure or refused settlement) | the same proof takes over the attempt and settles at most once; of two concurrent retries, one gets 409; the replaced row stays in `payment_attempt_history` | `tests/unit/storage-receipts/payment-attempts.test.ts`, `tests/e2e/payment/x402-settlement.e2e.test.ts` | +| **x402 settlement refused after the backend ran** (`authorization` flow) | the response is withheld; 402 with a failed `PAYMENT-RESPONSE` | `tests/integration/x402-payment-flow.test.ts` | +| payment-attempt reservation or replay-key lookup failure | `STORAGE_ERROR`, never mislabeled `PAYMENT_REPLAYED` or answered with a fresh challenge | `tests/unit/core/execution/pipeline.test.ts` | +| **the journal row cannot be inserted with the replay reservation** | `STORAGE_ERROR` before settlement; no new replay reservation; release of any AP2 hold is attempted | `tests/integration/transaction-journal.test.ts` | +| **a payment-attempt, receipt or journal write fails after the replay reservation** | Delivery or the original commerce error is preserved. Delivered outcomes and terminal events carry known failure labels; each write failure is logged at `error` level. A failed `completed` step adds no detail to those snapshots. The pipeline does not add the list to thrown errors | `tests/integration/transaction-journal.test.ts`, `tests/e2e/payment/crash-matrix.e2e.test.ts` | +| **a gateway crash during a paid purchase, from before settlement to before the receipt commit, in either payment flow** | after restart, operator state reflects recorded steps and unresolved entries remain visible. `reconcile` can resolve `reserved` attempts at least `--min-age` old from final chain evidence, marking them `rejected` only with proof of an expired, unused authorization. It can also retry pending AP2 holds. After a payment and its hold are resolved, later runs leave them unchanged. Unknown delivery remains for operator review | `tests/e2e/payment/crash-matrix.e2e.test.ts` | +| **SIGTERM while a settlement is held, in either payment flow, then SIGKILL after the grace period** | With AP2 disabled, the gateway drains until killed. After restart, the attempt remains `reserved`; its replay returns 409 without a challenge and moves no funds, whether or not the original transfer landed | `tests/e2e/payment/crash-matrix.e2e.test.ts` | +| **shutdown with a request in flight** | In-flight requests finish before adapters stop; `close()` resolves after both. A second SIGTERM or SIGINT exits immediately with status 1 | `tests/unit/gateway/shutdown.test.ts`, `tests/unit/gateway/main.test.ts` | +| **a backup taken while the gateway writes, into a directory that holds files, or of a 2.5 database** | Each copy is a consistent snapshot at 0600, hashed in `backup.json`; a new directory is created at 0700. A non-empty `--out` is refused without writing files. Read-only sources keep their schema and import state, including v4 for 2.5 | `tests/unit/cli/backup.test.ts` | +| **a restore of a backup older than some purchases** | Spent EVM proofs cannot pay again, including those absent from the backup. Attempts restored as uncertain still need `reconcile` or `resolve`. An AP2 mandate consumed after the backup can authorize again until it expires | `tests/e2e/payment/restore.e2e.test.ts` | +| **a receipt write fails before a paid MCP or A2A response** | The `agent-commerce/delivery` summary retains the receipt ID and adds `receiptCommitted: false`; a successful receipt save leaves the field absent | `tests/integration/receipt-committed.test.ts`, `demo/agent/test/run.test.ts` | +| **an unused authorization that can still land** | `unknown` (`not_expired`) while it remains valid at finality; `not-settled` requires unused state at a final block beyond EVM's last valid second or NEAR's `maxBlockHeight` | `tests/e2e/payment/x402-reconcile.e2e.test.ts`, `tests/e2e/payment/x402-near.e2e.test.ts` | +| **a used EVM nonce without a matching transfer (`cancelAuthorization`, `invalidateUnorderedNonces` or missing RPC logs)** | `unknown` (`used_without_record`); missing logs or receipts alone do not prove that a payment was not settled | same | +| **an RPC serving a chain other than the configured offer's (EVM chain ID, Solana genesis hash, NEAR `chain_id`)** | `unknown` (`chain_mismatch`) before reading state or transfer evidence | `tests/e2e/payment/x402-reconcile.e2e.test.ts`, `tests/e2e/payment/x402-solana.e2e.test.ts`, `tests/e2e/payment/x402-near.e2e.test.ts` | +| **a referenced EVM or NEAR transaction that is not yet final** | `unknown` (`not_final`); definitive outcomes require final evidence. Solana accepts only successful finalized transactions as settlement evidence | `tests/e2e/payment/x402-reconcile.e2e.test.ts`, `tests/unit/payments-x402/reconcile-svm-near.test.ts` | +| **an EIP-3009 attempt referencing another payment's transfer of the same amount** | that reference does not prove settlement; `settled` requires this authorization's `AuthorizationUsed` log and a matching transfer | same | +| **two Permit2 payments with the same owner, amount and validity window** | each attempt must match a proxy call carrying its own nonce | same | +| **a Solana or NEAR attempt referencing another payment's transaction** | that reference does not prove settlement; Solana requires a matching message hash, and NEAR requires the attempt's delegate (sender, access key and nonce) plus a matching successful transfer receipt | `tests/unit/payments-x402/reconcile-svm-near.test.ts`, `tests/e2e/payment/x402-solana.e2e.test.ts`, `tests/e2e/payment/x402-near.e2e.test.ts` | +| **a Solana transaction that never landed** | `unknown`: `history_too_long` at the search page cap; otherwise `not_expired` while the blockhash is valid, then `blockhash_expired`. Expiry alone does not prove `not-settled` | `tests/unit/payments-x402/reconcile-svm-near.test.ts`, `tests/e2e/payment/x402-solana.e2e.test.ts` | +| **a NEAR delegate's nonce used by another transaction, or its access key deleted** | `unknown` (`used_without_record` or `access_key_missing`); key state alone does not prove that the delegate executed | `tests/unit/payments-x402/reconcile-svm-near.test.ts`, `tests/e2e/payment/x402-near.e2e.test.ts` | +| **competing payment resolutions or a later reconciliation run** | the update requires the expected status and no existing resolution, allowing at most one resolution write. Later runs skip payment resolution but can retry pending holds. A losing manual HTTP resolution returns 409 `already_resolved`, or `not_resolvable` if the status changed without a resolution. SQLite rejects late pipeline writes to rows with recorded resolutions, logs them at `warn` and throws `STORAGE_ERROR` | `tests/unit/core/execution/reconcile.test.ts`, `tests/e2e/payment/x402-reconcile.e2e.test.ts`, `tests/unit/core/execution/resolve.test.ts`, `tests/unit/gateway/operator-transactions.test.ts` | +| **an in-flight `reserved` attempt younger than `--min-age`** | excluded from reconciliation candidates; the default minimum age is 15 minutes. Manual resolution also refuses a `reserved` attempt younger than 15 minutes (`too_recent`) | `tests/unit/core/execution/reconcile.test.ts`, `tests/unit/core/execution/resolve.test.ts` | +| **a `reserved` AP2 hold whose request recorded no payment attempt** | eligible for release through an enabled AP2 provider once its last update is older than `--min-age`; the gateway reserves an attempt before calling settlement. Current or replaced attempts exclude a hold. Dry runs report scanned holds without releasing them. An omitted or empty list of authorization providers skips both hold scans | `tests/unit/core/execution/reconcile.test.ts` | +| **`reconcile` or `resolve` with AP2 enabled and an existing, unimported AP2 file** | `STORAGE_ERROR` before payment or hold updates; these commands do not import legacy files. The receipt store is initialized after a read-only schema check | `tests/unit/cli/resolve.test.ts` | +| **`reconcile` or `resolve` on a receipt database below schema v5, such as one a 2.5 gateway still uses** | `STORAGE_ERROR` before payment, hold or schema updates; the message directs the operator to run `agent-commerce backup`, then start 2.6.0 once | `tests/unit/cli/reconcile.test.ts` | +| **`reconcile`, `resolve` or `doctor` run beside a live gateway** | These commands neither import legacy files nor recover ACP claims. `reconcile` and `resolve` open the receipt store without legacy import options; `doctor` opens it read-only. They leave the gateway's existing in-flight ACP claims unchanged | `tests/unit/cli/reconcile.test.ts`, `tests/unit/cli/doctor-storage.test.ts` | +| **reconciliation calling a facilitator or broadcasting** | x402 and MPP reconciliation read the configured offer's RPC without calling the facilitator or broadcasting | `tests/e2e/payment/x402-reconcile.e2e.test.ts`, `tests/e2e/payment/x402-solana.e2e.test.ts`, `tests/e2e/payment/x402-near.e2e.test.ts` | +| **an attempt without a recorded locator** | x402 and MPP return `unknown` (`no_locator`), leaving the attempt unresolved | `tests/unit/payments-x402/reconcile.test.ts`, `tests/unit/core/execution/reconcile.test.ts` | +| **an RPC error during reconciliation** | `unknown` (`rpc_error`); RPC URLs in the report retain the origin and omit userinfo, path and query | `tests/e2e/payment/x402-reconcile.e2e.test.ts` | +| **an operator rejecting an attempt before its recorded expiry** | `resolve` and `POST /api/payment-attempts/:id/resolve` refuse `rejected` while the recorded expiry is in the future (`not_expired`). A `settled` verdict has no expiry check; neither verdict is checked on chain | `tests/unit/core/execution/resolve.test.ts`, `tests/unit/gateway/operator-transactions.test.ts` | +| **a `rejected` resolution without a valid recorded expiry, including Solana, NEAR and attempts written before the journal** | refused (`expiry_unknown`) unless the operator supplies `confirmExpired` or `--confirm-expired`; this confirmation does not bypass a future recorded expiry | `tests/unit/core/execution/resolve.test.ts`, `tests/unit/gateway/operator-transactions.test.ts`, `tests/unit/cli/resolve.test.ts` | +| **the operator view or resolve called without the token, with a wrong token, from a disallowed Origin, or with a malformed cursor** | a disallowed Origin returns 403 before the token check; otherwise, no configured token gives 404 and a missing or wrong bearer token gives 401 before ledger access. A malformed cursor or unknown state returns `INPUT_INVALID`; a resolve body over 4 KiB gets 413 | `tests/unit/gateway/operator-transactions.test.ts` | +| **a journal row, locator, resolution or operator response carrying a proof, signature, mandate, input body or secret** | built-in locators contain public chain fields, and core records an input digest when available instead of the body. SQLite redacts values under secret-shaped keys in locators, authorizations and resolutions before writing. Operator routes return those stored values; providers and callers must keep secrets out of other fields | `tests/fixtures/x402/locator.ts` (through the provider tests), `tests/unit/storage-receipts/transactions.test.ts`, `tests/unit/gateway/operator-transactions.test.ts` | +| **a run stopping between the attempt write and the hold write** | the journal hold remains `reserved` or `uncertain`, so a later run with AP2 enabled can retry the update. The reserving request ID accompanies each update, preventing a late release from changing a later reservation | `tests/unit/core/execution/resolve.test.ts`, `tests/unit/authorization-ap2/replay-store.test.ts`, `tests/unit/cli/resolve.test.ts` | +| a local reader racing the ledger's creation | database is 0600 before SQLite opens it; WAL sidecars stay 0600 | `tests/unit/storage-receipts/permissions.test.ts`, `tests/unit/storage-receipts/persistence.test.ts` | +| RPC unreachable during verify | `PAYMENT_PROVIDER_UNAVAILABLE`, not "bad signature" | `tests/unit/payments-x402` | +| **an external `$ref` in an imported document** | refused; zero outbound requests | `tests/unit/openapi/load.test.ts` | +| **an imported path value naming another host** | percent-encoded into one segment of the configured origin | `tests/integration/openapi-import.test.ts` | +| **an imported query group colliding with a pinned backend query** | `INPUT_INVALID` before payment; nothing settled | same | +| **an imported operation with an unsupported required parameter** | never becomes a resource at all | same | +| **`__proto__` in an imported document, as a `$ref` segment, parameter name or property name** | refused; a `$ref` resolves own members only, so it never reaches `Object.prototype` | `tests/unit/openapi` | +| **an ACP request with missing, non-bearer, empty or wrong authorization** | 401 before the body is read; zero merchant calls | `tests/unit/protocols-acp/adapter.test.ts` | +| **an ACP request naming an unsupported API version** | 400 naming `supported_versions`; never mapped to the pinned one | same | +| **an ACP POST with no or an over-long `Idempotency-Key`** | 400; zero merchant calls | `tests/conformance/acp/protocol.test.ts` | +| **an ACP key replayed while the first request is in flight** | 409; the merchant is called exactly once | `tests/conformance/acp/idempotency.test.ts` | +| **an ACP operation the merchant acted on before timing out** | 409 `idempotency_unresolved` by default; the gateway makes no second merchant call | same | +| **an ACP retry after a merchant 500 or 503** | claim held by default; with `merchantIdempotent`, released for retry with the same derived key | `tests/conformance/acp/idempotency.test.ts`, `tests/conformance/acp/errors.test.ts` | +| **an ACP retry after a merchant 429** | claim released because the gateway treats 429 as unprocessed; the same key runs again | `tests/conformance/acp/idempotency.test.ts` | +| **an unresolved ACP record outliving its retention window** | kept; only a completed record expires | `tests/unit/protocols-acp/idempotency.test.ts` | +| **two ACP claims of one key racing in the receipt database** | for the same deployment, endpoint and key, one claim wins and permits one pipeline execution | `tests/unit/storage-receipts/conformance.test.ts`, `tests/unit/protocols-acp/idempotency-receipt-store.test.ts` | +| **an ACP claim left in flight by a crashed gateway** | marked unresolved by default; with `merchantIdempotent`, released at the next start | same | +| **an ACP claim outliving a bearer-token rotation** | kept; the scope is the deployment, never the credential | same | +| **an ACP key reused with a different body** | 422; the merchant is called exactly once | same | +| **a merchant answering an ACP route with a non-ACP document** | refused as `processing_error`; its body never forwarded | `tests/conformance/acp/errors.test.ts` | +| **a merchant putting an HTML tag or comment in ACP markdown** | refused as `processing_error`; code spans and autolinks remain text | `tests/conformance/acp/errors.test.ts` | +| **an ACP session id containing an encoded `/`** | stays one path segment and retains its own idempotency scope | `tests/unit/protocols-acp/adapter.test.ts` | +| **a merchant leaking a connection string or stack in an error body** | body text is not relayed; a valid ACP error contributes its type, safe code and optional param, while the gateway supplies the message | same | +| **an ACP `Request-Id` carrying a header-injection payload** | dropped, never echoed | `tests/unit/protocols-acp/adapter.test.ts` | +| **an ACP caller header naming a header the operator configured** | the configured value reaches the merchant | `tests/integration/acp-over-gateway.test.ts`, `tests/unit/core/execution/backend-http.test.ts` | +| **an ACP bearer token bound for the merchant** | never forwarded; only the six listed caller headers are | `tests/conformance/acp/protocol.test.ts`, `tests/integration/acp-over-gateway.test.ts` | +| **a forwarded header with CR/LF or an illegal name** | ACP drops a value that is not printable ASCII or exceeds 2048 characters; the backend executor refuses an illegal forwarded name or value as `INPUT_INVALID` before pricing | `tests/unit/protocols-acp/adapter.test.ts`, `tests/unit/core/execution/backend-http.test.ts`, `tests/unit/core/execution/pipeline.test.ts` | +| **a forwarded header naming `Idempotency-Key` or `Content-Type`** | never forwarded; the merchant gets the derived key and the executor's content type | `tests/unit/core/execution/backend-http.test.ts` | +| **a backend page with script, relayed to a browser** | delivered as text with `Content-Security-Policy: sandbox` and `nosniff`, so the script cannot act as the gateway's origin | `tests/unit/gateway/server.test.ts` | +| **a merchant `Retry-After` that is huge, an HTTP date or absent** | clamped to 1-300 seconds; 5 when absent or not whole seconds | `tests/conformance/acp/errors.test.ts` | +| **a merchant ACP error with an `idempotency_*` code** | not relayed; the gateway's own code is sent | same | +| **an ACP checkout resource configured as paid** | refused at config load; a defensive runtime challenge maps to 500 | `tests/unit/config/schema.test.ts`, `tests/unit/protocols-acp/checkout-mapping.test.ts` | +| **an ACP `body` or `path` schema that load closes before it accepts what the adapter sends** | refused at config load, not as INPUT_INVALID on every request | `tests/unit/config/schema.test.ts`, `tests/unit/cli/examples.test.ts` | +| **an AP2 mandate with a holder-key binding (`cnf`)** | `AUTHORIZATION_INVALID`, `unsupported_mandate_type`; nothing settles | `tests/unit/authorization-ap2/verifier.test.ts` | +| **an A2A request sent without an `id`, as a notification** | `-32600`; nothing runs | `tests/conformance/a2a/errors.test.ts` | +| **an x402 A2A extension payment whose `contextId` is not the task's** | `-32602`; the task stays pending | `tests/integration/a2a-x402-extension.test.ts` | +| **an A2A `GetTask` or `CancelTask` naming a pending x402 task** | `-32001` `TaskNotFoundError`; the task stays payable | `tests/integration/a2a-x402-extension.test.ts` | +| **an A2A 0.x follow-up, with a non-user role, on a pending task** | `-32602`; the task stays pending | same | +| **an A2A message carrying a large array of invalid parts or task references** | refused by its length (`PROTOCOL_UNSUPPORTED`) before any element is validated, so no validation issue is built per element | `tests/unit/protocols-a2a/message-mapping.test.ts` | +| **an AP2 mandate on an x402 extension payment message beside another resource or input** | verified against the stored purchase; the other resource and input are ignored | same | +| **an MCP placeholder `_payment` beside a real proof in `_meta`** | the `_meta` proof settles; `_payment` is ignored | `tests/integration/mcp-payment-bindings.test.ts` | +| **an MCP `_meta["x402/payment"]` string that is not base64 `PaymentPayload` JSON** | `PAYMENT_INVALID` with a fresh challenge; nothing settles | same | +| **a JSON-RPC batch to `/mcp` under a revision that removed batching** | HTTP 400 with `-32600`; no tool runs | `tests/conformance/mcp/mcp-conformance.test.ts` | +| **an AP2 mandate with a tampered signature** | `AUTHORIZATION_INVALID`; nothing settles | `tests/integration/ap2-x402-conformance.test.ts` | +| **an expired AP2 mandate** | `AUTHORIZATION_INVALID`; nothing settles | same | +| **a mandate from an issuer that is not configured** | refused at the trust allowlist, before any signature check | same, `tests/unit/authorization-ap2` | +| **a mandate claiming a trusted `kid` but signed with another key** | refused at the signature; `kid` selects the key, never labels it | same | +| **a mandate naming a `kid` the issuer does not have** | refused; no "try every key" fallback | same | +| **a mandate with no `iss` whose `kid` two trusted issuers share** | `unknown_key`; neither key is tried | `tests/unit/authorization-ap2` | +| **a mandate token with no `aud` or `exp`** | absence alone is allowed by default; strict settings may refuse it, and the checkout JWT has its own required `aud` and `exp` | same | +| **a delegation chain or key-bound presentation** (autonomous mode) | `unsupported_mandate_type`; nothing settles | same | +| **an AP2 delegation hop presented alone** (key-bound `typ`, top-level `sd_hash` or `issuer_jwt_hash`) | `unsupported_mandate_type`; nothing settles | same | +| **a mandate whose `checkout_hash` does not match its checkout JWT** | `checkout_binding_failed`; nothing settles | same | +| **a mandate approved for another resource, input, amount, currency, payment method, network or asset** | `purchase_mismatch`, one coarse reason; nothing settles | same | +| **a mandate silent about the chain the requirement names** | refused - fail closed both ways | same | +| **a mandate presented twice** | `AUTHORIZATION_REPLAYED`; the second purchase moves no funds | same, `tests/e2e/authorization` | +| **the same mandate re-presented with a fresh, valid payment proof** | still refused; balances unchanged on a real chain | `tests/e2e/authorization` | +| **a mandate replayed under selective disclosure** (one mandate, many presentation strings) | refused - the replay key is the issuer-signed token, not the presentation | `tests/unit/authorization-ap2` | +| **a released mandate re-presented after another mandate reserved or spent the same checkout** | refused as replayed; the released row remains released | `tests/unit/authorization-ap2/replay-store.test.ts` | +| **the AP2 replay store unreachable** | `AUTHORIZATION_PROVIDER_UNAVAILABLE`, retryable, never the buyer's fault | `tests/integration/ap2-runtime.test.ts` | +| **a valid mandate presented without a payment proof** | 402 challenge; the mandate is not consumed | `tests/integration/ap2-x402-conformance.test.ts` | +| **a payment proof rejected at verification** | the mandate is never reserved; a corrected proof reuses it | same | +| **settlement definitively refused after the mandate was reserved** | the reservation is released; a corrected proof reuses the mandate | same | +| **settlement throws without a verdict, with or without a transaction hash** | the mandate is marked uncertain, not handed back | `tests/integration/ap2-x402-conformance.test.ts`, `tests/unit/core/execution/pipeline-authorization.test.ts` | +| **the AP2 replay store failing to record a consume or release after the payment outcome** | the payment outcome is preserved. Pending replay and journal holds stay `reserved` or `uncertain`, blocking reuse; `reconcile` with AP2 enabled can retry the consume or release | `tests/e2e/payment/crash-matrix.e2e.test.ts`, `tests/unit/core/execution/resolve.test.ts` | +| **two holds racing on one mandate, or two mandates on one checkout** | one reservation wins; the other is refused as replayed | `tests/unit/storage-receipts/conformance.test.ts` | +| **a custom store that reserves one replay key twice** | Fails the `./testing` cases for repeated reservations, including variations in other fields and concurrent calls. These calls must reserve only one attempt per replay key. Failures outside these cases may go undetected | `tests/unit/storage-receipts/conformance.test.ts` | +| **an AP2 presentation reaching a stored hold, pending task or idempotency claim** | the AP2 provider stores the mandate reference, checkout `jti` and issuer IDs; A2A keeps parsed mandates in memory. Task input outside reserved fields, `adapterData` and idempotency bodies are unredacted; callers must exclude presentations | `tests/unit/storage-receipts/no-secrets.test.ts`, `tests/unit/protocols-a2a/pending-store.test.ts`, `tests/unit/authorization-ap2/provider.test.ts` | +| **a mandate spent before the 2.6.0 upgrade, presented after it** | refused as replayed after a successful legacy import: hold states are preserved, and an import cannot unblock a reference already held in the receipt database | `tests/unit/storage-receipts/legacy-import.test.ts`, `tests/e2e/payment/upgrade.e2e.test.ts` (both payment flows, on Anvil) | +| **a deprecated store key removed, or its file missing, before the first 2.6.0 start** | `start` returns `STORAGE_ERROR` for an enabled feature with past-use evidence and no recorded import or prior check. An empty file or `:memory:` explicitly accepts the loss; `doctor` reports that refusal as `FAIL` | `tests/unit/storage-receipts/legacy-import.test.ts`, `tests/unit/cli/doctor-storage.test.ts` | +| **an import failing part way** | the failed source's rows and import marker roll back together; earlier source imports remain committed. `start` stops with `STORAGE_ERROR`, releases acquired locks and retries the failed source on the next start | same | +| **a 2.5 gateway writing a deprecated file after the import** | refused while `start` holds the file's write lock, which is released at shutdown | same | +| **a 2.5 gateway opening a receipt database 2.6.0 migrated** | refused by its schema version check; the file is unchanged | `tests/unit/storage-receipts/migration-v5.test.ts` | +| **a free resource configured to require a mandate** | refused at config load, and again on the execution path | `tests/unit/config/ap2.test.ts`, `tests/unit/core/execution` | +| **an oversized `Agent-Authorization` header** | `AUTHORIZATION_INVALID`; the pipeline is not called | `tests/integration/authorization-carrier.test.ts` | +| **an MPP challenge edited after issue, or signed with another gateway's secret** | `challenge_not_issued` | `tests/unit/payments-mpp/provider.test.ts` | +| **an MPP challenge issued for another resource, price, recipient, asset or network** | refused on the binding check, one reason per field | same | +| **an MPP credential presented after its challenge expired** | `challenge_expired`, on the gateway's clock | same | +| **an MPP authorization whose nonce is not the challenge hash** | `wrong_nonce` | same | +| **an MPP credential sent with a body other than the one its challenge was issued for** | `body_digest_mismatch` before settlement; the same credential works with the original body bytes | `tests/integration/mpp-carriers.test.ts`, `tests/unit/payments-mpp/provider.test.ts` | +| **an MPP credential type other than EIP-3009 `authorization`** | `unsupported_credential` | same | +| **an MPP authorization signed by someone other than the payer, or for another amount or recipient** | `invalid_signature`, `wrong_amount` or `wrong_recipient` | same | +| **an MPP authorization outside its `validAfter`/`validBefore` window** | `authorization_not_yet_valid` or `authorization_expired` | same | +| **a valid MPP credential** | local checks, then the facilitator's read-only check; nothing is broadcast | `tests/unit/payments-mpp/provider.test.ts` | +| **an MPP copy that passes the facilitator check while its replay key is already reserved** | `PAYMENT_REPLAYED`; no second settlement | `tests/unit/payments-mpp/provider.test.ts` | +| **a settled MPP credential presented again** | 402 `invalid-challenge`, reason `challenge_already_used`, from the facilitator's reason or the token's `authorizationState`; no second settlement | `tests/integration/mpp-carriers.test.ts`, `tests/unit/payments-mpp/provider.test.ts` | +| **the facilitator is unreachable during MPP verification** | `verify` throws `PAYMENT_PROVIDER_UNAVAILABLE` (503) before anything is reserved | `tests/e2e/payment/mpp-settlement.e2e.test.ts` | +| **an MPP settlement times out after a possible broadcast** | provider throws `PAYMENT_PROVIDER_UNAVAILABLE`; pipeline records `settlement-uncertain` | `tests/unit/payments-mpp/provider.test.ts`, `tests/unit/core/execution/pipeline.test.ts` | +| **an `Authorization` header in another scheme on an MPP resource** | no payment: `402` with a challenge | `tests/integration/mpp-carriers.test.ts` | +| **an MPP credential in the x402 `PAYMENT-SIGNATURE` header** | ignored: `402` | same | +| **a valid MPP credential from the mppx client** | settled on chain once: buyer down and merchant up by the price, and `Payment-Receipt` names the transaction | `tests/e2e/payment/mpp-settlement.e2e.test.ts` | +| **an MPP broadcast that is never confirmed** | `settlement-uncertain`; no response; under `upfront` the backend never runs; the recorded transaction lands once mined | `tests/e2e/payment/mpp-settlement.e2e.test.ts`, `tests/unit/payments-mpp/provider.test.ts` | +| **an MPP buyer with no funds** | facilitator check returns `PAYMENT_INVALID` before broadcast; nothing moved | same | +| **an AP2-gated MPP payment with no mandate, or a mandate approved for x402** | `AUTHORIZATION_REQUIRED` or `AUTHORIZATION_INVALID`; nothing settles | `tests/e2e/authorization/ap2-mpp.e2e.test.ts` | +| **an MPP config on Base mainnet without `allowMainnet`, with a local facilitator, a noncanonical asset or Base Sepolia's EIP-712 name** | `CONFIG_INVALID` at load under the relevant `payments.mpp` field | `tests/unit/config/schema.test.ts` | +| **a valid MPP payment on Base mainnet, then the same credential again** | settled once on chain; the later replay is refused with 402 or 409 and moves no funds again | `tests/mainnet/mpp-base.smoke.test.ts` (real funds, run by hand) | +| **`start` with a missing package in an enabled x402, MPP, MCP or AP2 startup import** | `CONFIG_INVALID` names the setting and package before the gateway listens or opens the receipt store. Solana, NEAR and CDP imports begin during provider construction, but their failures surface in provider health checks or payment requests | `tests/unit/gateway/main.test.ts` (each feature, from source), `tests/unit/gateway/optional-peer.test.ts` (the error mapping), CI bare install (x402, from the built package) | +| **a retry after a lost response, on either payment flow** | with `redelivery`, an exact resend or a fresh proof from the same payer gets the stored response: one settlement, one merchant call, one charge | `tests/e2e/payment/safe-retries.e2e.test.ts` ("%s flow: %s gets the stored response and the buyer is charged once"), `tests/unit/core/execution/pipeline-retry.test.ts` ("serves an exact resend with no provider, AP2 or backend call") | +| **an exact resend or a fresh proof under a completed key** | neither settles: the resend skips verification, and the fresh proof is verified and discarded | `tests/unit/core/execution/pipeline-retry.test.ts` ("serves an exact resend with no provider, AP2 or backend call", "serves a fresh proof from the same payer, verifying it with no AP2 or settle") | +| **another payer's valid proof under a stored key** | 409 `payment_identifier_conflict` after verification (422 on MPP); the error carries no stored body and nothing settles | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses another payer, with no stored body in the error", "refuses a verified proof with an unknown payer"), `tests/integration/payment-retries.test.ts` ("answers a key used by another payer with a 422 about:blank, never invalid-challenge") | +| **a forged or junk proof presented with a known key** | the normal `PAYMENT_INVALID` refusal, without returning a stored response or revealing whether the key exists or what input it holds | `tests/unit/core/execution/pipeline-retry.test.ts` ("gives a forged proof with a known key the normal refusal", "tells a junk proof with a used key and other input nothing about the key"), `tests/unit/payments-x402/payment-id.test.ts` ("refuses a proof signed for another amount, whatever id it carries") | +| **a junk proof probing an in-flight or unresolved key** | the same `PAYMENT_INVALID` refusal as for an unused key; only the proof that owns the claim gets the 409 before verification | `tests/unit/core/execution/pipeline-retry.test.ts` ("tells a junk proof nothing about an in-flight key", "tells a junk proof nothing about an unresolved key", "refuses an exact resend of an in-flight purchase before verifying it") | +| **one key with different input** | a verified proof gets 409 `payment_identifier_conflict` (422 on MPP) after one `verify()` that moves no funds; the input is neither run nor answered from the stored response | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses the same key with other input once the proof verifies, settling nothing"), `tests/integration/payment-retries.test.ts` ("answers the same id with other input with a 409 and no challenge") | +| **two concurrent requests with one key** | one settles; the other gets 409 `payment_identifier_in_flight` and can retry for the stored response once the first completes | `tests/unit/core/execution/pipeline-retry.test.ts` ("lets one of two concurrent requests settle after the other is in flight", "lets one settle when both pass the read before either claims"), `tests/e2e/payment/safe-retries.e2e.test.ts` ("settles two concurrent proofs with one ID once, then redelivers to the refused request") | +| **a restart with ACP `merchantIdempotent`** | the ACP claim is freed for retry; a payment retry key left in flight becomes `unresolved` instead of being released | `tests/integration/payment-retries.test.ts` ("frees an ACP claim under merchantIdempotent and still marks the payment key unresolved"), `tests/unit/storage-receipts/conformance.test.ts` ("recovery with an endpoint prefix leaves other endpoints alone") | +| **a crash during settlement** | the key becomes `unresolved` at startup. Exact resends and fresh proofs get 409 without settlement, even after `reconcile` records the attempt as not settled. The key expires at its retention deadline | `tests/integration/payment-retries.test.ts` ("marks a key left in flight unresolved at start, so a retry gets 409"), `tests/e2e/payment/safe-retries.e2e.test.ts` ("keeps the ID unresolved before and after reconcile: both retry styles get 409 without settlement") | +| **a key on a settled purchase with no stored response, such as an `upfront` backend failure** | while the key is unexpired, 409 `payment_identifier_unresolved` without a challenge or `Retry-After` | `tests/e2e/payment/safe-retries.e2e.test.ts` ("returns 409 without a challenge for both retry styles after a settled upfront purchase fails delivery, charging once"), `tests/integration/payment-retries.test.ts` ("answers a key with an unknown outcome with a 409, no Retry-After and no challenge"), `tests/unit/core/execution/pipeline-retry.test.ts` ("answers an unresolved key with 409 and no challenge, verifying only a fresh proof") | +| **an unresolved payment key after its retention window** | the key expires like a completed one; retries then follow the normal purchase flow. Unresolved ACP records never expire (see the ACP row above) | `tests/unit/core/execution/pipeline-retry.test.ts` ("keeps an unresolved key closed until retention ends, then sells again"), `tests/unit/storage-receipts/conformance.test.ts` ("an expired unresolved payment key reads as absent and can be claimed again") | +| **a new key on a spent proof** | the replay reservation still refuses it with `PAYMENT_REPLAYED`, and the new key is released | `tests/unit/core/execution/pipeline-retry.test.ts` ("keeps a spent proof under a new key refused, and frees the new key") | +| **a junk proof writing a key before verification** | no claim is written for a junk proof, a throwing `verify` or a verified proof with no replay key | `tests/unit/core/execution/pipeline-retry.test.ts` ("writes nothing for a junk proof", "writes nothing for a proof whose verification throws", "writes nothing for a verified proof with no replay key") | +| **exposure of client keys and proofs** | client keys are stored as SHA-256 hashes, and logs redact proof and `Idempotency-Key` headers. Raw keys and proofs stay out of events and merchant requests; the merchant key derives from the client key hash and payer. The facilitator receives the proof without extensions | `tests/unit/core/execution/pipeline-retry.test.ts` ("puts no client key or proof in a log line, an event or an error", "never sends the client key to the merchant"), `tests/unit/gateway/logger.test.ts` ("redacts every wire header that carries a credential or a proof"), `tests/unit/payments-x402/payment-id.test.ts` ("sends the facilitator no part of the extension"), `tests/integration/merchant-idempotency.test.ts` ("sends the merchant no part of the proof") | +| **a stored response leaking `Set-Cookie` or cached publicly** | the row keeps only the content type; a redelivery carries `Cache-Control: private` and, for a text body, the sandbox policy | `tests/unit/core/execution/pipeline-retry.test.ts` ("stores only the content type of the backend headers"), `tests/integration/payment-retries.test.ts` ("redelivers a text body with its type and sandbox policy, and no Set-Cookie", "redelivers an exact resend, marked as a replay, without a second charge") | +| **an x402 echo that drops or changes `required`** | `extension_echo_mismatch` before the facilitator is called | `tests/unit/payments-x402/payment-id.test.ts` ("refuses an echo that %s, before the facilitator") | +| **a stored response after its retention window** | the response is no longer served; retries follow the normal purchase flow. A later claim sweep deletes the expired row | `tests/unit/core/execution/pipeline-retry.test.ts` ("still serves a retry just before retention ends", "treats a retry after retention as a new purchase"), `tests/unit/storage-receipts/conformance.test.ts` ("an expired completed claim reads as absent, and other checkout claims never expire") | +| **a store write that fails after a charge** | the response is still delivered, `persistenceFailures` gains `response`, and the key becomes `unresolved` (or stays in flight if that write also fails); a retry gets 409 and no second charge | `tests/unit/core/execution/pipeline-retry.test.ts` ("delivers, and closes the key, when the receipt is not saved", "delivers, and closes the key, when the completed write fails", "delivers, and keeps the key closed, when both writes fail") | +| **a re-signed retry after an `authorization`-flow backend failure** | with `merchantKey` and `redelivery`, the merchant receives the same `Idempotency-Key` for the new proof | `tests/unit/core/execution/pipeline-retry.test.ts` ("keeps the merchant key across a re-signed retry"), `tests/integration/merchant-idempotency.test.ts` ("sends the same key when a proof is retried after a backend failure"), `tests/e2e/payment/safe-retries.e2e.test.ts` ("reuses the merchant key for a re-signed retry after authorization backend failure, charging once") | +| **two buyers sharing one client key, or `merchantKey` on a resource without `redelivery`** | with `redelivery`, the merchant key hashes the client key with the payer; without it the client key is not read, and the proof's replay key names the operation | `tests/unit/core/execution/merchant-key.test.ts` ("gives another %s a different key", "falls back to the replay key when the payer is %s"), `tests/unit/core/execution/pipeline-retry.test.ts` ("reads no client key for the merchant key without redelivery") | +| **a failure after the claim: AP2 refusal, replay refusal, storage error, refused settlement or `authorization`-flow backend failure** | the claim is released and the key can buy again; a possible charge leaves the key `unresolved` instead | `tests/unit/core/execution/pipeline-retry.test.ts` ("leaves the expected claim after: %s", "releases the claim when the mandate is missing", "releases the claim when the replay reservation fails", "lets a released key buy again") | +| **a wallet that can no longer afford the price** | an exact resend still gets the stored response; a fresh proof gets 402 `PAYMENT_INVALID` and no charge | `tests/e2e/payment/safe-retries.e2e.test.ts` ("redelivers an exact resend from an emptied wallet but rejects a fresh proof with 402 and no charge"), `tests/integration/payment-retries.test.ts` ("lets a wallet that can no longer pay recover by an exact resend over %s") | +| **a store without `getIdempotencyKey`, or a pipeline without `deployment`, under a resource with `redelivery`** | `CONFIG_INVALID` before any provider call; `createGateway` refuses to start with such a store | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses before any provider call with %s"), `tests/unit/gateway/server.test.ts` ("refuses a 2.6-era store for a resource with redelivery, before recovering") | +| **removing `redelivery` or changing `merchant.publicBaseUrl` while a key is unresolved** | new requests cannot read the key; a fresh proof follows the normal purchase flow. `doctor` counts the unresolved key until its retention deadline | `tests/integration/payment-retries.test.ts` ("recovers the key without the opt-in, sells again, and doctor still counts the key", "sells again after publicBaseUrl changes, and doctor still counts the key") | +| **an MPP key conflict, in-flight or unresolved answer that invites a new challenge** | Problem Details with `type: about:blank` and no `WWW-Authenticate`; never `invalid-challenge`, which could prompt a second payment | `tests/integration/payment-retries.test.ts` ("answers a key used by another payer with a 422 about:blank, never invalid-challenge", "answers a request in flight as Problem Details with Retry-After", "answers a charged key with no stored response as Problem Details without Retry-After"), `tests/unit/payments-mpp/problems.test.ts` ("answers the retry-key 409 %s with about:blank, not invalid-challenge") | +| **an MPP key conflict read as "retry unchanged"** | a key reused with other input or another payer answers 422 `Unprocessable Content`, not 409; in-flight and unresolved stay 409 `Conflict` | `tests/integration/payment-retries.test.ts` ("answers a key used by another payer with a 422 about:blank, never invalid-challenge"), `tests/unit/payments-mpp/problems.test.ts` ("answers a retry key reused with other input as 422 about:blank") | +| **an exact resend after the proof or challenge expired** | the stored response until retention ends, with no verification and no settlement | `tests/integration/payment-retries.test.ts` ("redelivers an exact resend after its challenge expired, with the receipt"), `tests/unit/core/execution/pipeline-retry.test.ts` ("serves an exact resend with no provider, AP2 or backend call") | +| **a redelivery without the mandate the purchase used** | an exact resend falls through to normal verification, and a verified fresh proof gets 409 `payment_identifier_conflict`; the same holds when the resource began requiring a mandate after the purchase | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses %s the purchase used, serving nothing", "serves nothing once the resource requires a mandate it did not when it paid") | +| **the payment proof header as a bearer credential** | with matching input and any required mandate, `PAYMENT-SIGNATURE` retrieves the stored response until retention ends. Keep it out of proxy, APM and access logs; the gateway's logger redacts it | `tests/integration/payment-retries.test.ts` ("redelivers an exact resend, marked as a replay, without a second charge"), `tests/unit/gateway/logger.test.ts` ("redacts every wire header that carries a credential or a proof") | +| **a retention window outside 1 to 8760 hours** | config load refuses it, and a pipeline built without config throws `CONFIG_INVALID` before any payment work | `tests/unit/config/schema.test.ts` ("refuses a retention of %s hours as too long", "refuses a retention of %s", "accepts the longest retention, one year"), `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses a retention of %s hours before any provider call", "accepts a retention of one year") | +| **an A2A in-flight refusal that tells the client to resend on the consumed task** | the failed task reports `retryable: false` with no `retryAfterSeconds`; the client starts a new task with the same payment `id` | `tests/unit/protocols-a2a/payment-resume.test.ts` ("does not tell the client to retry an in-flight payment id on the consumed task") | A facilitator verdict is a returned result. The SDK throws when a remote `/verify` answers with a non-2xx status; the binding turns that throw back into @@ -641,9 +660,36 @@ with `agent-commerce resolve` or `POST /api/payment-attempts/:id/resolve`. The gateway attempts to save a receipt before sending the response. If the connection then drops, a saved receipt for a successful backend call still -counts as delivered and is excluded from `?undelivered=true`. The gateway does -not redeliver the response or accept the settled payment proof again. The -buyer must pay for a new request or ask the merchant for a refund. +counts as delivered and is excluded from `?undelivered=true`. Without +`idempotency.redelivery`, the gateway does not redeliver the response or accept +the settled payment proof again. The buyer must pay for a new request or ask the +merchant for a refund. + +A resource with `idempotency.redelivery` (experimental, disabled by default) +lets the buyer retry a purchase with its original key. See +[retry after a lost response](payment-flow.md#retry-after-a-lost-response). + +- Reuse one random key per purchase. The SDK's `appendPaymentIdentifierToExtensions` + generates a new `id` unless given one; a new key can start a second purchase. +- An exact resend of the purchase's proof gets the stored response, even after + the proof has expired or when the wallet can no longer cover the price. A + fresh proof gets it only if it verifies as the same payer, and it is never + settled. If verification refuses it, for example because the wallet can no + longer cover the price, the buyer gets 402 and nothing is charged. +- The retry must carry the same input and, on a resource that requires a + mandate, the same mandate. A verified proof with other input or another + mandate gets 409 `payment_identifier_conflict`, or 422 on MPP. An exact resend + without the mandate is verified like any other proof. +- A purchase that may have charged but has no stored response returns 409 + `payment_identifier_unresolved` until retention ends. After expiry, removal + of `redelivery` or a change to `merchant.publicBaseUrl`, reusing the key + follows the normal purchase flow. + +Stored merchant responses, including any personal data, are unencrypted in the +receipt database and its backups. `retentionHours` (1 to 8760, default 24) limits +reuse, not how long every copy exists: a later claim sweep deletes expired rows, +and backups persist until separately removed. Enable `redelivery` per resource +with this in mind, and set the window only as long as buyers need to retry. ## Threats we are not addressing diff --git a/docs/storage.md b/docs/storage.md index 785d186..f4af17f 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -7,6 +7,10 @@ across restarts: journal; - from 2.6.0, AP2 mandate holds, ACP idempotency claims and A2A x402 tasks waiting for payment; +- from 2.7.0, payment retry keys and, on a resource with + [`idempotency.redelivery`](configuration.md#resources), responses delivered + with a client retry key, unencrypted. `retentionHours` limits reuse; a later + claim sweep deletes expired rows (see [security.md](security.md#buyer-protection)); - a record of each deprecated store file it imported. The gateway creates the file at 0600, and any missing directory at 0700, and @@ -81,7 +85,7 @@ does not migrate or import records, or take a write lock on the sources. permissions. - It writes `receipts.sqlite`, a consistent snapshot taken with `VACUUM INTO`. It refuses a receipt database at a schema other than v4 (2.5) - or v5 (2.6). + or v5 (2.6). The snapshot includes the database's unencrypted stored responses. - For enabled AP2 or ACP, it also copies any configured legacy file that exists, is not `:memory:` and has no recorded import. The copies are named `ap2-replay.sqlite` or `acp-idempotency.sqlite`. Before the first 2.6.0 @@ -121,6 +125,9 @@ Restoring a backup discards local records written after its snapshot: until it expires. That purchase still requires payment. - Retrying an ACP checkout under a key claimed after the backup reaches the merchant again with the same derived `Idempotency-Key`. +- Payment retry keys claimed after the snapshot are lost. A fresh proof with + such a key follows the normal purchase flow; an exact resend faces the + spent-proof checks above. A transaction recorded as unresolved in the backup still needs `reconcile` or `resolve` to establish its outcome. diff --git a/src/core/execution/merchant-key.ts b/src/core/execution/merchant-key.ts index 3f5b61c..ad0d654 100644 --- a/src/core/execution/merchant-key.ts +++ b/src/core/execution/merchant-key.ts @@ -8,7 +8,10 @@ export interface PaidOperation { readonly replayKey: string; /** `inputDigest` of the validated input */ readonly inputDigest: string | undefined; - /** Client retry credential; only its hash enters the payment identity */ + /** + * Client retry credential; only its hash enters the payment identity. Pass it + * only with `redelivery`, so the retry gate reserves it before the merchant call. + */ readonly clientKey?: string; readonly payer?: string; } diff --git a/tests/unit/core/execution/pipeline-retry.test.ts b/tests/unit/core/execution/pipeline-retry.test.ts index 7fbcd64..2911277 100644 --- a/tests/unit/core/execution/pipeline-retry.test.ts +++ b/tests/unit/core/execution/pipeline-retry.test.ts @@ -517,6 +517,31 @@ describe.each([ ]); }); + it('serves a fresh proof from the same payer on other payment terms, settling nothing', async () => { + // Fingerprint only input to allow fresh proofs, unlike the spec's payload check + const { pipeline, calls } = setup({ + settleAfterBackend, + provider: { + verify: async ({ submission }) => ({ + status: 'verified', + provider: 'x402', + payer: 'alice', + replayKey: `replay-${submission.payload}`, + ...(submission.payload === 'alice/1' + ? { amount: '0.01', currency: 'USDC', network: 'eip155:8453' } + : { amount: '0.02', currency: 'USDT', network: 'eip155:137' }), + }), + }, + }); + const first = await delivered(pipeline.execute(paid())); + + const again = await delivered(pipeline.execute(paid('alice/2'))); + + expect(again.redelivered).toBe(true); + expect(again.payment).toEqual(first.payment); + expect(calls.settle).toBe(1); + }); + it('refuses another payer, with no stored body in the error', async () => { const { pipeline, store, calls } = setup({ settleAfterBackend }); await delivered(pipeline.execute(paid())); From 2c745f21afcc11bf91c06dddd6bc13d1e7a3b07b Mon Sep 17 00:00:00 2001 From: Revinand Date: Sat, 7 Nov 2026 22:57:24 +0100 Subject: [PATCH 17/20] docs: document safe retries --- README.md | 14 ++++++++++++-- docs/payment-flow.md | 3 ++- docs/security.md | 2 +- skills/agent-commerce/references/config.md | 6 ++++++ 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 55cf809..fc0cd0d 100644 --- a/README.md +++ b/README.md @@ -341,7 +341,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, @@ -365,6 +366,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. @@ -671,7 +680,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) · diff --git a/docs/payment-flow.md b/docs/payment-flow.md index b2b03b4..6053a72 100644 --- a/docs/payment-flow.md +++ b/docs/payment-flow.md @@ -217,7 +217,8 @@ The purchase outcome determines the key's state: Retry-key 409s carry no challenge, because a challenge could prompt a second payment. Only the in-flight 409 is retryable (`retryable: true`, -`retryAfterSeconds: 1`). `reconcile` and `resolve` record the payment attempt's +`retryAfterSeconds: 1`). Wait, then resend with the same key; a new key can +start another purchase. `reconcile` and `resolve` record the payment attempt's outcome but do not change the key. Completed and unresolved keys expire `retentionHours` (1 to 8760, default 24) diff --git a/docs/security.md b/docs/security.md index 6b55912..c7dbeab 100644 --- a/docs/security.md +++ b/docs/security.md @@ -563,7 +563,7 @@ real funds. | **an MPP key conflict, in-flight or unresolved answer that invites a new challenge** | Problem Details with `type: about:blank` and no `WWW-Authenticate`; never `invalid-challenge`, which could prompt a second payment | `tests/integration/payment-retries.test.ts` ("answers a key used by another payer with a 422 about:blank, never invalid-challenge", "answers a request in flight as Problem Details with Retry-After", "answers a charged key with no stored response as Problem Details without Retry-After"), `tests/unit/payments-mpp/problems.test.ts` ("answers the retry-key 409 %s with about:blank, not invalid-challenge") | | **an MPP key conflict read as "retry unchanged"** | a key reused with other input or another payer answers 422 `Unprocessable Content`, not 409; in-flight and unresolved stay 409 `Conflict` | `tests/integration/payment-retries.test.ts` ("answers a key used by another payer with a 422 about:blank, never invalid-challenge"), `tests/unit/payments-mpp/problems.test.ts` ("answers a retry key reused with other input as 422 about:blank") | | **an exact resend after the proof or challenge expired** | the stored response until retention ends, with no verification and no settlement | `tests/integration/payment-retries.test.ts` ("redelivers an exact resend after its challenge expired, with the receipt"), `tests/unit/core/execution/pipeline-retry.test.ts` ("serves an exact resend with no provider, AP2 or backend call") | -| **a redelivery without the mandate the purchase used** | an exact resend falls through to normal verification, and a verified fresh proof gets 409 `payment_identifier_conflict`; the same holds when the resource began requiring a mandate after the purchase | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses %s the purchase used, serving nothing", "serves nothing once the resource requires a mandate it did not when it paid") | +| **a redelivery without the mandate the purchase used** | an exact resend falls through to normal verification, and a verified fresh proof gets 409 `payment_identifier_conflict`; the same holds when the resource began requiring a mandate after the purchase | `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses %s the purchase used, serving nothing", "serves %s nothing once the resource requires a mandate it did not when it paid") | | **the payment proof header as a bearer credential** | with matching input and any required mandate, `PAYMENT-SIGNATURE` retrieves the stored response until retention ends. Keep it out of proxy, APM and access logs; the gateway's logger redacts it | `tests/integration/payment-retries.test.ts` ("redelivers an exact resend, marked as a replay, without a second charge"), `tests/unit/gateway/logger.test.ts` ("redacts every wire header that carries a credential or a proof") | | **a retention window outside 1 to 8760 hours** | config load refuses it, and a pipeline built without config throws `CONFIG_INVALID` before any payment work | `tests/unit/config/schema.test.ts` ("refuses a retention of %s hours as too long", "refuses a retention of %s", "accepts the longest retention, one year"), `tests/unit/core/execution/pipeline-retry.test.ts` ("refuses a retention of %s hours before any provider call", "accepts a retention of one year") | | **an A2A in-flight refusal that tells the client to resend on the consumed task** | the failed task reports `retryable: false` with no `retryAfterSeconds`; the client starts a new task with the same payment `id` | `tests/unit/protocols-a2a/payment-resume.test.ts` ("does not tell the client to retry an in-flight payment id on the consumed task") | diff --git a/skills/agent-commerce/references/config.md b/skills/agent-commerce/references/config.md index 685b886..d9d3869 100644 --- a/skills/agent-commerce/references/config.md +++ b/skills/agent-commerce/references/config.md @@ -77,6 +77,12 @@ resources: payment; `application/json` by default. The HTTP response itself carries the API's own `Content-Type` when it is text. Set it, as in `mimeType: text/html`, only on a paid x402 resource whose API returns something else. +- **idempotency** (experimental, disabled by default, fixed-price resources only): + `merchantKey: true` sends a derived `Idempotency-Key` to the backend for paid + operations. `redelivery: { retentionHours: 24 }` stores paid responses, + unencrypted, in the receipt database for retrieval with the buyer's retry + key without another charge. Enable redelivery only if the merchant accepts + unencrypted storage. ## x402 networks From 13952665cf69fe0b17f689e4f8ae98d547dd4f8c Mon Sep 17 00:00:00 2001 From: Revinand Date: Sun, 8 Nov 2026 00:42:15 +0100 Subject: [PATCH 18/20] feat(x402): sign CDP facilitator JWTs without @coinbase/x402 --- CONTRIBUTING.md | 8 +- README.md | 11 +- docker/node.Dockerfile | 4 +- docs/configuration.md | 7 +- docs/contracts.md | 16 +- docs/migration-2.1.md | 4 +- docs/security.md | 7 +- examples/base-mainnet-payai/README.md | 2 +- examples/base-mainnet/README.md | 14 +- examples/base-mainnet/config.yaml | 5 +- package-lock.json | 354 ------------------ package.json | 10 - .../skills/agent-commerce/SKILL.md | 19 +- .../agent-commerce/references/config.md | 4 +- .../agent-commerce/references/production.md | 4 +- src/cli/lib/versions.ts | 1 - src/config/schema/raw.ts | 3 +- src/mpp.ts | 3 +- src/payments/x402/cdp-auth.ts | 97 +++++ src/payments/x402/facilitator.ts | 45 +-- src/payments/x402/guardrails.ts | 9 +- src/x402.ts | 5 +- tests/unit/cli/packaging.test.ts | 10 +- tests/unit/config/schema.test.ts | 2 +- .../payments-x402/facilitator-cdp.test.ts | 283 ++++++++------ 25 files changed, 336 insertions(+), 591 deletions(-) create mode 100644 src/payments/x402/cdp-auth.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a6f42b..317ea2d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index c0e610b..d2009da 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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` @@ -435,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 diff --git a/docker/node.Dockerfile b/docker/node.Dockerfile index 11f5813..0e04db3 100644 --- a/docker/node.Dockerfile +++ b/docker/node.Dockerfile @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index c0f3d20..bb8f103 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -745,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 diff --git a/docs/contracts.md b/docs/contracts.md index e7e9b7d..d2b2976 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -518,14 +518,14 @@ persistence must not fail the commerce flow. ## Published entry points -| Entry | Surface | Imported optional peers | -| -------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@devlab.group/agent-commerce` | Core contract, config, gateway, receipt store, A2A and ACP adapters | None | -| `@devlab.group/agent-commerce/ap2` | AP2 verification and checkout signing | `jose`, `@sd-jwt/core`, `canonicalize` | -| `@devlab.group/agent-commerce/mcp` | MCP adapter | `@modelcontextprotocol/server` | -| `@devlab.group/agent-commerce/mpp` | MPP provider and profile metadata | `@coinbase/x402` (only for `auth.type: cdp`), `@x402/core`, `@x402/evm`, `mppx`, `viem` | -| `@devlab.group/agent-commerce/x402` | x402 provider and client proof helper | `@x402/core`, `@x402/evm`, `viem`; `@coinbase/x402` only for `auth.type: cdp`; `@x402/svm` and `@solana/kit` only for a Solana offer; `@x402/near` only for a NEAR offer | -| `@devlab.group/agent-commerce/testing` | Experimental `ReceiptStore` conformance cases | None; imports neither a test runner nor a SQLite driver | +| Entry | Surface | Imported optional peers | +| -------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `@devlab.group/agent-commerce` | Core contract, config, gateway, receipt store, A2A and ACP adapters | None | +| `@devlab.group/agent-commerce/ap2` | AP2 verification and checkout signing | `jose`, `@sd-jwt/core`, `canonicalize` | +| `@devlab.group/agent-commerce/mcp` | MCP adapter | `@modelcontextprotocol/server` | +| `@devlab.group/agent-commerce/mpp` | MPP provider and profile metadata | `@x402/core`, `@x402/evm`, `mppx`, `viem` | +| `@devlab.group/agent-commerce/x402` | x402 provider and client proof helper | `@x402/core`, `@x402/evm`, `viem`; `@x402/svm` and `@solana/kit` only for a Solana offer; `@x402/near` only for a NEAR offer | +| `@devlab.group/agent-commerce/testing` | Experimental `ReceiptStore` conformance cases | None; imports neither a test runner nor a SQLite driver | The main entry and CLI must not import optional peers. `package.json` is the authoritative export and dependency map. diff --git a/docs/migration-2.1.md b/docs/migration-2.1.md index b3ac273..058e706 100644 --- a/docs/migration-2.1.md +++ b/docs/migration-2.1.md @@ -62,8 +62,8 @@ gateway treats that option like one that omits the asset: price its resources in Config load refuses two settings that 2.0 accepted but could not use: - `facilitator.auth.type: cdp` with a `url` other than - `https://api.cdp.coinbase.com/platform/v2/x402`, the URL `@coinbase/x402` - signs for. + `https://api.cdp.coinbase.com/platform/v2/x402`, the only facilitator URL + the gateway signs CDP requests for. - An `rpcUrl` that is not an `http://` or `https://` URL. ### MPP settles before the backend diff --git a/docs/security.md b/docs/security.md index c7dbeab..23617de 100644 --- a/docs/security.md +++ b/docs/security.md @@ -258,10 +258,9 @@ Runtime dependencies are pinned exactly, and the release workflow runs `npm audit`. Review the current audit rather than relying on a count recorded in this document. -`@coinbase/x402` is loaded dynamically only for facilitator `auth.type: cdp`. -It adds `@coinbase/cdp-sdk`, `axios`, and a Solana dependency tree, so audit that -optional installation before using it. Static-token facilitators can use -`auth.type: bearer` without this peer. +Facilitator `auth.type: cdp` signs JWTs with Node's built-in crypto, without an +extra package. Auth error messages and the binding's description omit the API +key secret. ## Development keys diff --git a/examples/base-mainnet-payai/README.md b/examples/base-mainnet-payai/README.md index d7b5a07..cbcd843 100644 --- a/examples/base-mainnet-payai/README.md +++ b/examples/base-mainnet-payai/README.md @@ -7,7 +7,7 @@ says ordinary exact payments need no API key within a free credit allowance. The allowance is per receiving wallet and never resets; beyond it, PayAI requires credits and [authentication](https://docs.payai.network/x402/facilitators/authentication). -The gateway still needs its x402 peers, but not `@coinbase/x402`. +The gateway still needs its x402 peers. Check the payment kinds PayAI advertises before spending real USDC: diff --git a/examples/base-mainnet/README.md b/examples/base-mainnet/README.md index 8689a28..e0f5f1d 100644 --- a/examples/base-mainnet/README.md +++ b/examples/base-mainnet/README.md @@ -16,13 +16,13 @@ Two things are structurally different from the local and testnet examples: ## What you need -| | | -| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MERCHANT_WALLET` | your wallet. Address only: the gateway never takes a merchant key. See [Paying into a contract](#paying-into-a-contract-address). | -| `ALLOW_X402_MAINNET=true` | the explicit opt-in. Never a default. | -| CDP credentials, or a bearer token | `cdp` requires the optional `@coinbase/x402` peer and CDP's URL, already configured. `bearer` needs no extra package; set its facilitator's https `url`. | -| `ADMIN_TOKEN` | required here: it guards the receipt routes, which carry your ledger. | -| A Base RPC | health checks only. Use a dedicated endpoint: the public one's outages become your readiness failures. | +| | | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `MERCHANT_WALLET` | your wallet. Address only: the gateway never takes a merchant key. See [Paying into a contract](#paying-into-a-contract-address). | +| `ALLOW_X402_MAINNET=true` | the explicit opt-in. Never a default. | +| CDP credentials, or a bearer token | `cdp` uses your CDP API key and the configured CDP URL. For `bearer`, set the facilitator's HTTPS `url`. Neither needs an extra peer. | +| `ADMIN_TOKEN` | required here: it guards the receipt routes, which carry your ledger. | +| A Base RPC | health checks only. Use a dedicated endpoint: the public one's outages become your readiness failures. | To run without a facilitator credential, see [base-mainnet-payai](../base-mainnet-payai/), which sets `allowUnauthenticatedFacilitator`. diff --git a/examples/base-mainnet/config.yaml b/examples/base-mainnet/config.yaml index 5394e5d..08fc492 100644 --- a/examples/base-mainnet/config.yaml +++ b/examples/base-mainnet/config.yaml @@ -105,9 +105,8 @@ payments: # required to be something other than `none` on mainnet, unless # allowUnauthenticatedFacilitator is set (see examples/base-mainnet-payai) # - # `cdp` needs the optional peer @coinbase/x402, which signs a fresh JWT - # per request. For any facilitator that takes a static token, set its - # url and use: + # `cdp` signs each request with a fresh JWT using your CDP API key. + # For a facilitator with a static token, set its url and use: # auth: # type: bearer # token: ${X402_FACILITATOR_TOKEN} diff --git a/package-lock.json b/package-lock.json index 8ff6df4..8cfebbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "devDependencies": { "@a2a-js/sdk": "1.3.0", "@biomejs/biome": "2.5.9", - "@coinbase/x402": "2.1.0", "@modelcontextprotocol/client": "2.3.0", "@modelcontextprotocol/sdk": "1.32.0", "@modelcontextprotocol/server": "2.3.0", @@ -67,7 +66,6 @@ "npm": ">=10" }, "peerDependencies": { - "@coinbase/x402": "2.1.0", "@modelcontextprotocol/server": "2.3.0", "@sd-jwt/core": "0.20.1", "@solana/kit": "5.5.1", @@ -81,9 +79,6 @@ "viem": "2.57.1" }, "peerDependenciesMeta": { - "@coinbase/x402": { - "optional": true - }, "@modelcontextprotocol/server": { "optional": true }, @@ -425,83 +420,6 @@ "node": ">= 20.12.0" } }, - "node_modules/@coinbase/cdp-sdk": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@coinbase/cdp-sdk/-/cdp-sdk-1.55.0.tgz", - "integrity": "sha512-5PbUg3n3Jk9nm8nEStskRv6jTrVZKkgwxMFjW+i/xUDDzK1fXksKwXdjgUiHB1hf0FZx1LiYBosrh4IULxFyPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@solana-program/system": "^0.10.0", - "@solana-program/token": "^0.9.0", - "@solana/kit": "^5.5.1", - "abitype": "1.0.6", - "axios": "1.16.0", - "axios-retry": "^4.5.0", - "bs58": "^6.0.0", - "jose": "^6.2.0", - "md5": "^2.3.0", - "uncrypto": "^0.1.3", - "viem": "^2.47.0", - "zod": "^3.25.76" - }, - "peerDependencies": { - "@x402/core": "^2.21.0", - "@x402/evm": "^2.21.0", - "@x402/extensions": "^2.21.0", - "@x402/svm": "^2.21.0" - }, - "peerDependenciesMeta": { - "@x402/core": { - "optional": true - }, - "@x402/evm": { - "optional": true - }, - "@x402/extensions": { - "optional": true - }, - "@x402/svm": { - "optional": true - } - } - }, - "node_modules/@coinbase/cdp-sdk/node_modules/abitype": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.6.tgz", - "integrity": "sha512-MMSqYh4+C/aVqI2RQaWqbvI4Kxo5cQV40WQ4QFtDnNzCkqChm8MuENhElmynZlO0qUy/ObkEUaXtKqYnx1Kp3A==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/wevm" - }, - "peerDependencies": { - "typescript": ">=5.0.4", - "zod": "^3 >=3.22.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/@coinbase/x402": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@coinbase/x402/-/x402-2.1.0.tgz", - "integrity": "sha512-aKeM+cz//+FjzPVu/zgz7830x0KLtKarwCyxoeC71QgCn+Xcf0NhFpn3Qyw0H496y5YOuR/IQ67gP8DZ/hXFqQ==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@coinbase/cdp-sdk": "^1.29.0", - "@x402/core": "^2.0.0", - "viem": "^2.21.26", - "zod": "^3.24.2" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -2342,16 +2260,6 @@ "@solana/kit": "^5.0" } }, - "node_modules/@solana-program/system": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@solana-program/system/-/system-0.10.0.tgz", - "integrity": "sha512-Go+LOEZmqmNlfr+Gjy5ZWAdY5HbYzk2RBewD9QinEU/bBSzpFfzqDRT55JjFRBGJUvMgf3C2vfXEGT4i8DSI4g==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "@solana/kit": "^5.0" - } - }, "node_modules/@solana-program/token": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@solana-program/token/-/token-0.9.0.tgz", @@ -3732,19 +3640,6 @@ "node": ">=0.4.0" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -3821,13 +3716,6 @@ "js-tokens": "^10.0.0" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -3857,39 +3745,6 @@ "fastq": "^1.17.1" } }, - "node_modules/axios": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", - "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.6", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/axios-retry": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", - "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "is-retry-allowed": "^2.2.0" - }, - "peerDependencies": { - "axios": "0.x || 1.x" - } - }, - "node_modules/base-x": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", - "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", - "dev": true, - "license": "MIT" - }, "node_modules/better-sqlite3": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", @@ -3962,16 +3817,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bs58": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", - "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", - "dev": true, - "license": "MIT", - "dependencies": { - "base-x": "^5.0.0" - } - }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -4075,16 +3920,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/charenc": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -4108,19 +3943,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", @@ -4231,16 +4053,6 @@ "node": ">= 8" } }, - "node_modules/crypt": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4276,16 +4088,6 @@ } } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4413,22 +4215,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -4832,67 +4618,6 @@ "rollup": "^4.34.8" } }, - "node_modules/follow-redirects": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", - "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5033,22 +4758,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", @@ -5130,20 +4839,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -5187,13 +4882,6 @@ "node": ">= 10" } }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true, - "license": "MIT" - }, "node_modules/is-my-ip-valid": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-my-ip-valid/-/is-my-ip-valid-1.0.1.tgz", @@ -5229,19 +4917,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-retry-allowed": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", - "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5769,18 +5444,6 @@ "node": ">= 0.4" } }, - "node_modules/md5": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", - "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "charenc": "0.0.2", - "crypt": "0.0.2", - "is-buffer": "~1.1.6" - } - }, "node_modules/media-typer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", @@ -6444,16 +6107,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -7390,13 +7043,6 @@ "dev": true, "license": "MIT" }, - "node_modules/uncrypto": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", - "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", - "dev": true, - "license": "MIT" - }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", diff --git a/package.json b/package.json index 8bc242a..199a6ef 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,6 @@ "zod": "3.25.76" }, "peerDependencies": { - "@coinbase/x402": "2.1.0", "@modelcontextprotocol/server": "2.3.0", "@sd-jwt/core": "0.20.1", "@solana/kit": "5.5.1", @@ -135,9 +134,6 @@ "viem": "2.57.1" }, "peerDependenciesMeta": { - "@coinbase/x402": { - "optional": true - }, "@modelcontextprotocol/server": { "optional": true }, @@ -175,7 +171,6 @@ "devDependencies": { "@a2a-js/sdk": "1.3.0", "@biomejs/biome": "2.5.9", - "@coinbase/x402": "2.1.0", "@modelcontextprotocol/client": "2.3.0", "@modelcontextprotocol/sdk": "1.32.0", "@modelcontextprotocol/server": "2.3.0", @@ -211,11 +206,6 @@ "vitest": "4.1.12" }, "overrides": { - "@coinbase/x402": { - "zod": "3.25.76", - "@coinbase/cdp-sdk": "1.55.0", - "axios": "1.20.0" - }, "@sd-jwt/core": { "@owf/identity-common": "0.3.2" }, diff --git a/plugins/agent-commerce-gateway/skills/agent-commerce/SKILL.md b/plugins/agent-commerce-gateway/skills/agent-commerce/SKILL.md index df3b600..0a8001a 100644 --- a/plugins/agent-commerce-gateway/skills/agent-commerce/SKILL.md +++ b/plugins/agent-commerce-gateway/skills/agent-commerce/SKILL.md @@ -142,18 +142,17 @@ resources: Install only what the config enables, at the exact versions `npx --no -- agent-commerce version` prints: -| Enabled in config | Install | -| ---------------------------- | ------------------------------------ | -| `payments.x402` | `@x402/core @x402/evm viem` | -| an x402 Solana offer | x402 peers + `@x402/svm @solana/kit` | -| an x402 NEAR offer | x402 peers + `@x402/near` | -| `payments.mpp` | x402 peers + `mppx` | -| `protocols.mcp` | `@modelcontextprotocol/server` | -| facilitator `auth.type: cdp` | `@coinbase/x402` | +| Enabled in config | Install | +| -------------------- | ------------------------------------ | +| `payments.x402` | `@x402/core @x402/evm viem` | +| an x402 Solana offer | x402 peers + `@x402/svm @solana/kit` | +| an x402 NEAR offer | x402 peers + `@x402/near` | +| `payments.mpp` | x402 peers + `mppx` | +| `protocols.mcp` | `@modelcontextprotocol/server` | Missing x402 or MCP startup imports stop `start` with an error naming the -package. Solana, NEAR and CDP imports begin during provider construction, but -their failures surface through health checks or payment requests. Inspect +package. Solana and NEAR imports begin during provider construction; failures +surface through health checks or payment requests. Inspect `/ready` and the payment checks in `doctor`; a listening server alone does not establish readiness. diff --git a/plugins/agent-commerce-gateway/skills/agent-commerce/references/config.md b/plugins/agent-commerce-gateway/skills/agent-commerce/references/config.md index d9d3869..8e536c6 100644 --- a/plugins/agent-commerce-gateway/skills/agent-commerce/references/config.md +++ b/plugins/agent-commerce-gateway/skills/agent-commerce/references/config.md @@ -127,8 +127,8 @@ payments: token: ${X402_FACILITATOR_TOKEN} ``` -`auth.type` is `none`, `bearer` or `cdp` (Coinbase, which needs the -`@coinbase/x402` peer). `https://x402.org/facilitator` advertises Base Sepolia +`auth.type` is `none`, `bearer` or `cdp` (Coinbase, signed with the CDP API +key). `https://x402.org/facilitator` advertises Base Sepolia and Solana Devnet with no API authentication; check its `/supported` response and gateway readiness before relying on it. `mode: local` signs inside the gateway and accepts only `eip155:31337`; health checks also require an Anvil diff --git a/plugins/agent-commerce-gateway/skills/agent-commerce/references/production.md b/plugins/agent-commerce-gateway/skills/agent-commerce/references/production.md index 8490094..bb85430 100644 --- a/plugins/agent-commerce-gateway/skills/agent-commerce/references/production.md +++ b/plugins/agent-commerce-gateway/skills/agent-commerce/references/production.md @@ -59,8 +59,8 @@ payments: apiKeySecret: ${CDP_API_KEY_SECRET} ``` -`auth.type: cdp` needs the `@coinbase/x402` peer. For an unauthenticated -facilitator, see +`auth.type: cdp` signs with your CDP API key and needs no extra peer. For an +unauthenticated facilitator, see [examples/base-mainnet-payai](https://github.com/devlab-group/agent-commerce/tree/master/examples/base-mainnet-payai). After the change, `npx --no -- agent-commerce doctor --env-file .env` prints `LIVE MAINNET MODE - REAL FUNDS`. diff --git a/src/cli/lib/versions.ts b/src/cli/lib/versions.ts index 59c4d71..b12014d 100644 --- a/src/cli/lib/versions.ts +++ b/src/cli/lib/versions.ts @@ -28,7 +28,6 @@ const PINS: readonly (readonly [name: string, via: string])[] = [ ['@x402/svm', 'payments/x402'], ['@solana/kit', 'payments/x402'], ['@x402/near', 'payments/x402'], - ['@coinbase/x402', 'payments/x402'], ['viem', 'payments/x402'], ['mppx', 'payments/mpp'], ['jose', 'authorization/ap2'], diff --git a/src/config/schema/raw.ts b/src/config/schema/raw.ts index a22eb17..25592d4 100644 --- a/src/config/schema/raw.ts +++ b/src/config/schema/raw.ts @@ -290,8 +290,7 @@ const ResourcesMapSchema = z.record(z.string().min(1), ResourceEntrySchema); const FacilitatorAuthSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('none') }).strict(), z.object({ type: z.literal('bearer'), token: z.string().min(1) }).strict(), - // Coinbase Developer Platform. Needs the optional peer `@coinbase/x402`, - // which signs a fresh JWT per request. + // Coinbase Developer Platform: the gateway signs a fresh JWT per request z .object({ type: z.literal('cdp'), diff --git a/src/mpp.ts b/src/mpp.ts index 73be5fc..89112e0 100644 --- a/src/mpp.ts +++ b/src/mpp.ts @@ -7,8 +7,7 @@ * * MPP checks the credential locally, then verifies and settles through the x402 * facilitator named in its options. That install covers the local facilitator - * and remote facilitators with `none` or `bearer` auth; `auth.type: cdp` also - * needs `@coinbase/x402`. + * and every remote facilitator auth type. */ export { diff --git a/src/payments/x402/cdp-auth.ts b/src/payments/x402/cdp-auth.ts new file mode 100644 index 0000000..a6ce9c7 --- /dev/null +++ b/src/payments/x402/cdp-auth.ts @@ -0,0 +1,97 @@ +/** + * Signs the JWT that CDP's x402 facilitator requires on every request, in the + * format `@coinbase/cdp-sdk` produces. A PEM EC P-256 key signs ES256; a base64 + * Ed25519 key (32-byte seed, then 32-byte public key) signs EdDSA. Each token + * is valid for 120 seconds and names one method, host and path in `uris`. + */ +import { createPrivateKey, type KeyObject, randomBytes, sign } from 'node:crypto'; +import { CommerceError } from '../../core'; +import { CDP_FACILITATOR_URL } from './guardrails'; + +const TOKEN_LIFETIME_SECONDS = 120; + +interface SigningKey { + readonly alg: 'ES256' | 'EdDSA'; + readonly key: KeyObject; +} + +/** + * Create the SDK's path-keyed header factory. Invalid secrets throw + * CONFIG_INVALID when the factory is called, failing health checks and + * leaving payments without a verification verdict. + */ +export function createCdpAuthHeaders( + apiKeyId: string, + apiKeySecret: string, + now: () => number = Date.now, +): () => Promise>> { + const signingKey = readKey(apiKeySecret); + const { host, pathname } = new URL(CDP_FACILITATOR_URL); + + return async () => { + if (!signingKey) { + // Never echo the secret: it is the credential + throw new CommerceError( + 'CONFIG_INVALID', + 'x402 provider: facilitator.auth.apiKeySecret is not a CDP API key secret. ' + + 'Use the PEM EC private key or the base64 Ed25519 key that CDP issued.', + ); + } + const issuedAt = Math.floor(now() / 1000); + const header = (method: string, route: string) => ({ + Authorization: `Bearer ${signJwt(signingKey, apiKeyId, `${method} ${host}${pathname}/${route}`, issuedAt)}`, + }); + return { + verify: header('POST', 'verify'), + settle: header('POST', 'settle'), + supported: header('GET', 'supported'), + }; + }; +} + +function readKey(secret: string): SigningKey | undefined { + try { + if (secret.includes('-----BEGIN')) { + const key = createPrivateKey(secret); + const p256 = + key.asymmetricKeyType === 'ec' && key.asymmetricKeyDetails?.namedCurve === 'prime256v1'; + return p256 ? { alg: 'ES256', key } : undefined; + } + const raw = Buffer.from(secret, 'base64'); + if (raw.length !== 64) return undefined; + const key = createPrivateKey({ + format: 'jwk', + key: { + kty: 'OKP', + crv: 'Ed25519', + d: raw.subarray(0, 32).toString('base64url'), + x: raw.subarray(32).toString('base64url'), + }, + }); + return { alg: 'EdDSA', key }; + } catch { + return undefined; + } +} + +function signJwt(signingKey: SigningKey, apiKeyId: string, uri: string, issuedAt: number): string { + const { alg, key } = signingKey; + const header = { alg, kid: apiKeyId, typ: 'JWT', nonce: randomBytes(16).toString('hex') }; + const claims = { + sub: apiKeyId, + iss: 'cdp', + uris: [uri], + iat: issuedAt, + nbf: issuedAt, + exp: issuedAt + TOKEN_LIFETIME_SECONDS, + }; + const input = `${base64url(header)}.${base64url(claims)}`; + // JWS ES256 carries the raw 64-byte r||s, not DER + const signature = + alg === 'ES256' + ? sign('sha256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' }) + : sign(null, Buffer.from(input), key); + return `${input}.${signature.toString('base64url')}`; +} + +const base64url = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); diff --git a/src/payments/x402/facilitator.ts b/src/payments/x402/facilitator.ts index cbd6043..b9ebc99 100644 --- a/src/payments/x402/facilitator.ts +++ b/src/payments/x402/facilitator.ts @@ -1,7 +1,6 @@ /** * Shared facilitator interface and remote HTTP binding. The in-process EVM * binding lives in `local-facilitator.ts` to keep EVM peers out of this module. - * `@x402/core` loads statically; `@coinbase/x402` loads only for `auth.type: cdp`. * Both bindings use the SDK to verify authorizations and broadcast transfers. * * Each verify or settle call gets a session with its own `transportFailed()` @@ -19,6 +18,7 @@ import { type VerifyResponse, } from '@x402/core/types'; import { CommerceError } from '../../core'; +import { createCdpAuthHeaders } from './cdp-auth'; import type { FacilitatorAuth } from './guardrails'; /** One verify or one settle, with its own transport-failure flag */ @@ -72,45 +72,6 @@ export interface RemoteFacilitatorOptions { // The SDK's path-keyed auth-header shape. A flat object throws inside it type AuthHeaderFactory = () => Promise>>; -/** - * CDP needs a fresh JWT per request over method, host and path, which a static - * header cannot express. The optional peer `@coinbase/x402` does the signing. - * It is imported dynamically, so only `auth.type: cdp` pulls in the CDP SDK and - * its Solana, axios and JOSE dependencies. - * - * The import starts at construction, so a missing peer fails the health check - * before any payment needs it. A payment that arrives first fails as - * `PAYMENT_PROVIDER_UNAVAILABLE`, never as a rejection blamed on the buyer. - */ -function cdpAuthHeaders(apiKeyId: string, apiKeySecret: string): AuthHeaderFactory { - const loading = import('@coinbase/x402').then( - (mod) => mod.createCdpAuthHeaders(apiKeyId, apiKeySecret), - (cause: unknown) => { - throw new CommerceError( - 'CONFIG_INVALID', - 'x402 provider: facilitator.auth.type is "cdp", which needs the optional peer ' + - '"@coinbase/x402". Install it (npm install @coinbase/x402), or use a facilitator ' + - 'that accepts a static token with auth.type "bearer".', - { cause }, - ); - }, - ); - // Nothing awaits this before the first call, so without a handler a failed - // import would be an unhandled rejection at startup - loading.catch(() => {}); - - return async () => { - const create = await loading; - if (!create) { - throw new CommerceError( - 'PAYMENT_PROVIDER_UNAVAILABLE', - 'x402 provider: @coinbase/x402 returned no auth-header factory', - ); - } - return (await create()) as Record>; - }; -} - /** * A 400 from `/verify` whose body names an `invalidReason` is the facilitator * refusing the payment, so it returns as an invalid verdict. The SDK throws it @@ -174,7 +135,9 @@ export function createRemoteFacilitatorBinding( return { verify: headers, settle: headers, supported: headers }; }; } else if (auth.type === 'cdp') { - createAuthHeaders = cdpAuthHeaders(auth.apiKeyId, auth.apiKeySecret); + // CDP JWTs bind method, host and path. Unreadable secrets cause + // PAYMENT_PROVIDER_UNAVAILABLE during payment, rather than a payer rejection. + createAuthHeaders = createCdpAuthHeaders(auth.apiKeyId, auth.apiKeySecret); } const client = new HTTPFacilitatorClient({ diff --git a/src/payments/x402/guardrails.ts b/src/payments/x402/guardrails.ts index ab0ea5d..a37c002 100644 --- a/src/payments/x402/guardrails.ts +++ b/src/payments/x402/guardrails.ts @@ -28,8 +28,8 @@ import { * - `none`: send no credential; use it when the facilitator accepts * anonymous requests. * - `bearer`: send a static token; no extra peer package is needed. - * - `cdp`: Coinbase Developer Platform. The optional peer `@coinbase/x402`, - * imported only for this type, signs a fresh JWT per request. + * - `cdp`: Coinbase Developer Platform. The gateway signs a fresh JWT per + * request with the API key; no extra peer package is needed. * * Any other auth type is refused at config load. */ @@ -121,8 +121,7 @@ export function resolveX402Deployment(input: X402DeploymentInput): X402Deploymen if (input.facilitator.mode === 'remote') { assertFacilitatorUrlIsSafe(input.facilitator.url, mode, at); - // `@coinbase/x402` signs each request for CDP's own host and route, - // whatever URL is configured, so no other facilitator can accept the credential + // CDP JWTs bind requests to CDP's host and route, so reject other URLs if (input.facilitator.auth.type === 'cdp' && !isCdpFacilitatorUrl(input.facilitator.url)) { throw invalid( `${at}: facilitator.auth.type "cdp" signs requests for ${CDP_FACILITATOR_URL} only, so facilitator ${describeOrigin(input.facilitator.url)} cannot accept its credential. Set facilitator.url to ${CDP_FACILITATOR_URL}, or use auth.type "bearer" or "none".`, @@ -245,7 +244,7 @@ function assertFacilitatorUrlIsSafe(url: string, mode: DeploymentMode, at: strin ); } -/** The facilitator URL `@coinbase/x402` signs its JWTs for */ +/** The facilitator URL that `auth.type: cdp` signs its JWTs for */ export const CDP_FACILITATOR_URL = 'https://api.cdp.coinbase.com/platform/v2/x402'; // The SDK client strips trailing slashes before appending its route diff --git a/src/x402.ts b/src/x402.ts index 1624a5f..8781096 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -7,9 +7,8 @@ * npm install @devlab.group/agent-commerce @x402/core @x402/evm viem * import { x402 } from '@devlab.group/agent-commerce/x402'; * - * Solana offers also need `@x402/svm` and `@solana/kit`, NEAR offers - * `@x402/near`, and a remote facilitator with `auth.type: cdp` needs - * `@coinbase/x402`. Each loads only when used. + * Solana offers also need `@x402/svm` and `@solana/kit`, and NEAR offers + * `@x402/near`. Each loads only when used. * * The peers are pinned exactly: x402's zod schemas and EIP-712 domain * construction cross this boundary, so version skew is a correctness problem. diff --git a/tests/unit/cli/packaging.test.ts b/tests/unit/cli/packaging.test.ts index 9f858dc..7056cbd 100644 --- a/tests/unit/cli/packaging.test.ts +++ b/tests/unit/cli/packaging.test.ts @@ -170,7 +170,7 @@ describe('published package metadata', () => { // seams. const overrides = manifest.overrides as Record | undefined; expect(overrides?.['zod']).toBeUndefined(); - for (const pkg of ['@x402/core', '@x402/evm', '@coinbase/x402']) { + for (const pkg of ['@x402/core', '@x402/evm']) { expect((overrides?.[pkg] as Record | undefined)?.['zod']).toBe( manifest.dependencies?.['zod'], ); @@ -225,7 +225,6 @@ describe('published package metadata', () => { expect(exportsField?.[subpath]).toBeDefined(); } for (const peer of [ - '@coinbase/x402', '@modelcontextprotocol/server', '@sd-jwt/core', '@solana/kit', @@ -642,11 +641,9 @@ describe.skipIf(!existsSync(libEntry))('optional-peer subpaths', () => { it('imports only its own peer, in each subpath', () => { expect(bareImportsOf(mcpEntry)).toEqual(['@modelcontextprotocol/server']); - // Dynamic imports: `@coinbase/x402` loads only for facilitator auth.type - // `cdp`, `@x402/svm` and `@solana/kit` only for a Solana offer, - // `@x402/near` only for a NEAR offer + // Dynamic imports: `@x402/svm` and `@solana/kit` load only for a Solana + // offer, `@x402/near` only for a NEAR offer expect(bareImportsOf(x402Entry).sort()).toEqual([ - '@coinbase/x402', '@solana/kit', '@x402/core', '@x402/evm', @@ -656,7 +653,6 @@ describe.skipIf(!existsSync(libEntry))('optional-peer subpaths', () => { ]); // MPP settles through an x402 facilitator, so its entry also imports the x402 peers expect(bareImportsOf(mppEntry).sort()).toEqual([ - '@coinbase/x402', '@solana/kit', '@x402/core', '@x402/evm', diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index d766917..13ad6f1 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -647,7 +647,7 @@ describe('parseConfig', () => { expect(message).toContain('apiKeySecret is empty'); }); - // `@coinbase/x402` signs for CDP's host and route whatever URL is set + // The CDP JWT names CDP's host and route whatever URL is set it.each([ ['another host', 'https://facilitator.example.com'], ['another CDP route', 'https://api.cdp.coinbase.com/platform/v1/x402'], diff --git a/tests/unit/payments-x402/facilitator-cdp.test.ts b/tests/unit/payments-x402/facilitator-cdp.test.ts index cbbabe5..6926ba1 100644 --- a/tests/unit/payments-x402/facilitator-cdp.test.ts +++ b/tests/unit/payments-x402/facilitator-cdp.test.ts @@ -1,135 +1,202 @@ /** - * The CDP auth type, and what happens when its optional peer is absent. A - * missing `@coinbase/x402` must not throw at construction. It must surface as - * a CONFIG_INVALID that the health check can report before anyone pays. - * - * `vi.doMock` + `vi.resetModules` rather than a hoisted `vi.mock`: the two - * cases need the same specifier to resolve differently, which one hoisted - * factory cannot express. + * CDP API-key JWTs in `@coinbase/cdp-sdk`'s format. Invalid secrets allow + * construction but fail header generation with CONFIG_INVALID. Health checks + * can expose this before payment. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateKeyPairSync, type KeyObject, verify } from 'node:crypto'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; +import { isCommerceError } from '../../../src/core'; +import { createCdpAuthHeaders } from '../../../src/payments/x402/cdp-auth'; +import { createRemoteFacilitatorBinding } from '../../../src/payments/x402/facilitator'; -const FACILITATOR_URL = 'https://facilitator.example.com'; +const NOW_MS = 1_800_000_000_750; +const NOW_S = 1_800_000_000; -interface CapturedConfig { - url: string; - createAuthHeaders?: () => Promise>>; -} +const ec = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); +const ed = generateKeyPairSync('ed25519'); +const edJwk = ed.privateKey.export({ format: 'jwk' }); +// CDP's Ed25519 secret: base64 of the 32-byte seed, then the 32-byte public key +const ED_SECRET = Buffer.concat([ + Buffer.from(edJwk.d as string, 'base64url'), + Buffer.from(edJwk.x as string, 'base64url'), +]).toString('base64'); +const EC_PKCS8 = ec.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); -// Captures what the binding hands to the SDK's HTTP client -function mockHttpClient(captured: CapturedConfig[]): void { - vi.doMock('@x402/core/http', () => ({ - FacilitatorResponseError: class extends Error {}, - HTTPFacilitatorClient: class { - constructor(config: CapturedConfig) { - captured.push(config); - } - verify() { - throw new Error('not used'); - } - settle() { - throw new Error('not used'); - } - getSupported() { - throw new Error('not used'); - } - }, - })); +function decode(token: string) { + const [header, claims, signature] = token.split('.') as [string, string, string]; + return { + header: JSON.parse(Buffer.from(header, 'base64url').toString()), + claims: JSON.parse(Buffer.from(claims, 'base64url').toString()), + input: Buffer.from(`${header}.${claims}`), + signature: Buffer.from(signature, 'base64url'), + }; } -async function buildBinding(auth: { - type: 'cdp'; - apiKeyId: string; - apiKeySecret: string; -}): Promise { - const captured: CapturedConfig[] = []; - mockHttpClient(captured); - const { createRemoteFacilitatorBinding } = await import('../../../src/payments/x402/facilitator'); - createRemoteFacilitatorBinding({ url: FACILITATOR_URL, auth }); - const config = captured[0]; - if (!config) throw new Error('the binding built no HTTP client'); - return config; +function bearer(headers: Record | undefined): string { + const value = headers?.['Authorization'] ?? ''; + expect(value.startsWith('Bearer ')).toBe(true); + return value.slice('Bearer '.length); } -describe('facilitator auth: cdp', () => { - beforeEach(() => { - vi.resetModules(); +describe('CDP auth headers', () => { + it.each([ + ['a PKCS8 PEM EC P-256 key', EC_PKCS8, 'ES256', ec.publicKey], + [ + 'a SEC1 PEM EC P-256 key', + ec.privateKey.export({ type: 'sec1', format: 'pem' }).toString(), + 'ES256', + ec.publicKey, + ], + ['a base64 Ed25519 seed and public key', ED_SECRET, 'EdDSA', ed.publicKey], + ] as const)( + 'signs with %s a token CDP can verify', + async (_, secret, alg, publicKey: KeyObject) => { + const headers = await createCdpAuthHeaders('key-id', secret, () => NOW_MS)(); + + const { header, claims, input, signature } = decode(bearer(headers['verify'])); + expect(header).toEqual({ + alg, + kid: 'key-id', + typ: 'JWT', + nonce: expect.stringMatching(/^[0-9a-f]{32}$/), + }); + expect(claims).toEqual({ + sub: 'key-id', + iss: 'cdp', + uris: ['POST api.cdp.coinbase.com/platform/v2/x402/verify'], + iat: NOW_S, + nbf: NOW_S, + exp: NOW_S + 120, + }); + const valid = + alg === 'ES256' + ? verify('sha256', input, { key: publicKey, dsaEncoding: 'ieee-p1363' }, signature) + : verify(null, input, publicKey, signature); + expect(valid).toBe(true); + }, + ); + + it('binds each route to its own method and path', async () => { + const headers = await createCdpAuthHeaders('key-id', EC_PKCS8)(); + + // Path-keyed, not flat: the JWT is signed over method, host and path + expect(Object.keys(headers).sort()).toEqual(['settle', 'supported', 'verify']); + expect(decode(bearer(headers['verify'])).claims.uris).toEqual([ + 'POST api.cdp.coinbase.com/platform/v2/x402/verify', + ]); + expect(decode(bearer(headers['settle'])).claims.uris).toEqual([ + 'POST api.cdp.coinbase.com/platform/v2/x402/settle', + ]); + expect(decode(bearer(headers['supported'])).claims.uris).toEqual([ + 'GET api.cdp.coinbase.com/platform/v2/x402/supported', + ]); }); - afterEach(() => { - vi.doUnmock('@coinbase/x402'); - vi.doUnmock('@x402/core/http'); + it('gives every token a fresh nonce', async () => { + const create = createCdpAuthHeaders('key-id', ED_SECRET); + const first = await create(); + const second = await create(); + + const nonces = [first['verify'], first['settle'], second['verify']].map( + (headers) => decode(bearer(headers)).header.nonce, + ); + expect(new Set(nonces).size).toBe(3); }); - it('signs each request through @coinbase/x402, path-keyed as the SDK requires', async () => { - const seen: Array<{ id: string; secret: string }> = []; - vi.doMock('@coinbase/x402', () => ({ - createCdpAuthHeaders: (id: string, secret: string) => { - seen.push({ id, secret }); - return async () => ({ - verify: { Authorization: 'Bearer jwt-verify' }, - settle: { Authorization: 'Bearer jwt-settle' }, - supported: { Authorization: 'Bearer jwt-supported' }, - }); - }, - })); - - const config = await buildBinding({ - type: 'cdp', - apiKeyId: 'key-id', - apiKeySecret: 'key-secret', - }); - expect(config.url).toBe(FACILITATOR_URL); - expect(config.createAuthHeaders).toBeDefined(); - - const headers = await config.createAuthHeaders?.(); - // Per path, not flat: the JWT is signed over method, host and path, so one - // header for every route would be wrong even if the SDK accepted it - expect(headers).toEqual({ - verify: { Authorization: 'Bearer jwt-verify' }, - settle: { Authorization: 'Bearer jwt-settle' }, - supported: { Authorization: 'Bearer jwt-supported' }, - }); - expect(seen).toEqual([{ id: 'key-id', secret: 'key-secret' }]); + it.each([ + ['text that is no key', 'key-secret'], + ['a 32-byte base64 value', Buffer.alloc(32, 7).toString('base64')], + [ + 'a P-384 EC key', + generateKeyPairSync('ec', { namedCurve: 'secp384r1' }) + .privateKey.export({ type: 'pkcs8', format: 'pem' }) + .toString(), + ], + [ + 'an RSA key', + generateKeyPairSync('rsa', { modulusLength: 2048 }) + .privateKey.export({ type: 'pkcs8', format: 'pem' }) + .toString(), + ], + ['a truncated PEM key', EC_PKCS8.slice(0, 80)], + ])( + 'refuses %s as CONFIG_INVALID from the first request, not at construction', + async (_, secret) => { + const create = createCdpAuthHeaders('key-id', secret); + + const error = await create().then( + () => undefined, + (err: unknown) => err, + ); + expect(isCommerceError(error) && error.code).toBe('CONFIG_INVALID'); + expect(String((error as Error).message)).not.toContain(secret); + }, + ); +}); + +describe('remote facilitator binding with auth.type cdp', () => { + const requests: Array<{ + method: string | undefined; + url: string | undefined; + authorization: string | undefined; + }> = []; + const server = createServer((req, res) => { + requests.push({ method: req.method, url: req.url, authorization: req.headers.authorization }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ kinds: [], extensions: [], signers: {} })); + }); + + afterEach(async () => { + requests.length = 0; + await new Promise((done) => server.close(() => done())); }); - it('turns a missing @coinbase/x402 into CONFIG_INVALID naming the peer', async () => { - vi.doMock('@coinbase/x402', () => { - throw new Error("Cannot find package '@coinbase/x402'"); + async function listen(): Promise { + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + return `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + } + + it('sends the supported route its own signed token', async () => { + const binding = createRemoteFacilitatorBinding({ + url: await listen(), + auth: { type: 'cdp', apiKeyId: 'key-id', apiKeySecret: EC_PKCS8 }, }); + if (binding.kind !== 'remote') throw new Error('expected a remote binding'); - const config = await buildBinding({ - type: 'cdp', - apiKeyId: 'key-id', - apiKeySecret: 'key-secret', + await binding.supported(); + + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe('GET'); + const token = (requests[0]?.authorization ?? '').replace(/^Bearer /, ''); + const { claims, input, signature } = decode(token); + expect(claims.uris).toEqual(['GET api.cdp.coinbase.com/platform/v2/x402/supported']); + expect( + verify('sha256', input, { key: ec.publicKey, dsaEncoding: 'ieee-p1363' }, signature), + ).toBe(true); + }); + + it('sends nothing when the secret is unreadable', async () => { + const binding = createRemoteFacilitatorBinding({ + url: await listen(), + auth: { type: 'cdp', apiKeyId: 'key-id', apiKeySecret: 'key-secret' }, }); + if (binding.kind !== 'remote') throw new Error('expected a remote binding'); - // Construction must not throw: the provider is built synchronously, and - // the diagnosis belongs where it can be reported - expect(config.createAuthHeaders).toBeDefined(); - // Imported here, not at the top: `vi.resetModules()` gives the module under - // test a fresh graph, and `instanceof` against the outer graph's - // `CommerceError` would be false - const { isCommerceError } = await import('../../../src/core'); - await expect(config.createAuthHeaders?.()).rejects.toSatisfy( - (err: unknown) => - isCommerceError(err) && - err.code === 'CONFIG_INVALID' && - err.message.includes('@coinbase/x402'), + const error = await binding.supported().then( + () => undefined, + (err: unknown) => err, ); + + expect(isCommerceError(error) && error.code).toBe('CONFIG_INVALID'); + expect(requests).toEqual([]); }); - it('never puts the credential in anything the binding describes', async () => { - vi.doMock('@coinbase/x402', () => ({ - createCdpAuthHeaders: () => async () => ({}), - })); - mockHttpClient([]); - const { createRemoteFacilitatorBinding } = await import( - '../../../src/payments/x402/facilitator' - ); + it('never puts the credential in anything the binding describes', () => { const binding = createRemoteFacilitatorBinding({ - url: FACILITATOR_URL, + url: 'https://facilitator.example.com', auth: { type: 'cdp', apiKeyId: 'key-id', apiKeySecret: 'super-secret' }, }); // `describe` reaches logs, health details and doctor output From 7de7f3bf3f00774c82a08c6e306bc9d05bae3c18 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sun, 8 Nov 2026 00:44:23 +0100 Subject: [PATCH 19/20] build(deps): move x402 to 2.30.0 and @solana/kit to 8.6.0 --- .github/workflows/ci.yml | 2 +- docs/protocols.md | 2 +- examples/multi-network-mainnet/README.md | 2 +- examples/near-testnet/README.md | 2 +- examples/solana-devnet/README.md | 2 +- package-lock.json | 1064 +++++++++++-------- package.json | 28 +- tests/unit/cli/packaging.test.ts | 26 +- tests/unit/cli/version-command.test.ts | 4 +- tests/unit/payments-x402/payment-id.test.ts | 2 +- 10 files changed, 674 insertions(+), 460 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2df7f58..96f8624 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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'; diff --git a/docs/protocols.md b/docs/protocols.md index 58d9b2a..e64e7a9 100644 --- a/docs/protocols.md +++ b/docs/protocols.md @@ -9,7 +9,7 @@ subset. Each protocol's page describes that subset in detail. | ------------ | ------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | **HTTP** | Supported | native | resource routes and rail-specific payment headers | | **MCP** | Supported | MCP `2026-07-28` and `2025-11-25` through `@modelcontextprotocol/server@2.3.0` | tool discovery, invocation, payment-required and error mapping | -| **x402** | Supported | x402 v2, `@x402/core@2.25.0`, `@x402/evm@2.25.0`, `@x402/svm@2.25.0`, `@x402/near@2.25.0` | `exact` on EVM through EIP-3009 or Permit2, on Solana, and on NEAR (partial): challenge, verification, settlement and replay binding | +| **x402** | Supported | x402 v2, `@x402/core@2.30.0`, `@x402/evm@2.30.0`, `@x402/svm@2.30.0`, `@x402/near@2.30.0` | `exact` on EVM through EIP-3009 or Permit2, on Solana, and on NEAR (partial): challenge, verification, settlement and replay binding | | **A2A** | Experimental | v1.0.1; negotiation `1.0`; `JSONRPC` binding | Agent Card, `SendMessage`, paid flow and the x402 extension | | **ACP** | Experimental | stable snapshot `2026-04-17`; REST binding | discovery and the five checkout operations | | **AP2** | Experimental | v0.2.0, tag 2026-04-28, commit `b4587ac`; Direct mode | closed Checkout Mandate verification before settlement | diff --git a/examples/multi-network-mainnet/README.md b/examples/multi-network-mainnet/README.md index 60cdb5d..7958b65 100644 --- a/examples/multi-network-mainnet/README.md +++ b/examples/multi-network-mainnet/README.md @@ -16,7 +16,7 @@ USDC, and startup refuses an Anvil development address as `payTo`. | ------------------ | ----------------------------------------------------------------------------------------------------- | | Merchant addresses | `MERCHANT_WALLET` for the EVM networks, `SOLANA_MERCHANT_WALLET` with an existing USDC token account. | | A facilitator | `X402_FACILITATOR_URL` and `X402_FACILITATOR_TOKEN`, listing all four networks in `/supported`. | -| Solana support | `npm install @x402/svm@2.25.0 @solana/kit@5.5.1` | +| Solana support | `npm install @x402/svm@2.30.0 @solana/kit@8.6.0` | | An admin token | `ADMIN_TOKEN` protects operator routes: receipts, events, payment attempts and transactions. | Check that the facilitator's `/supported` lists every network; otherwise diff --git a/examples/near-testnet/README.md b/examples/near-testnet/README.md index f273ac9..694fee3 100644 --- a/examples/near-testnet/README.md +++ b/examples/near-testnet/README.md @@ -8,7 +8,7 @@ Omitting asset fields selects the built-in testnet USDC profile. The gateway loads NEAR support only when a NEAR offer is configured: ```bash -npm install @x402/near@2.25.0 +npm install @x402/near@2.30.0 ``` ## What you need diff --git a/examples/solana-devnet/README.md b/examples/solana-devnet/README.md index 6cfd2b3..0f0329a 100644 --- a/examples/solana-devnet/README.md +++ b/examples/solana-devnet/README.md @@ -8,7 +8,7 @@ out the asset fields, so payments settle in Circle's devnet USDC mint. The gateway loads Solana support only when a Solana offer is configured: ```bash -npm install @x402/svm@2.25.0 @solana/kit@5.5.1 +npm install @x402/svm@2.30.0 @solana/kit@8.6.0 ``` ## What you need diff --git a/package-lock.json b/package-lock.json index 8cfebbd..8dad810 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,18 +36,18 @@ "@near-js/signers": "2.5.1", "@near-js/transactions": "2.5.1", "@sd-jwt/core": "0.20.1", - "@solana/kit": "5.5.1", + "@solana/kit": "8.6.0", "@types/better-sqlite3": "9.6.0", "@types/node": "24.13.3", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "@vitejs/plugin-react": "6.0.5", "@vitest/coverage-v8": "4.1.12", - "@x402/core": "2.25.0", - "@x402/evm": "2.25.0", - "@x402/mcp": "2.25.0", - "@x402/near": "2.25.0", - "@x402/svm": "2.25.0", + "@x402/core": "2.30.0", + "@x402/evm": "2.30.0", + "@x402/mcp": "2.30.0", + "@x402/near": "2.30.0", + "@x402/svm": "2.30.0", "canonicalize": "5.0.0", "jose": "6.2.12", "mppx": "0.13.1", @@ -68,11 +68,11 @@ "peerDependencies": { "@modelcontextprotocol/server": "2.3.0", "@sd-jwt/core": "0.20.1", - "@solana/kit": "5.5.1", - "@x402/core": "2.25.0", - "@x402/evm": "2.25.0", - "@x402/near": "2.25.0", - "@x402/svm": "2.25.0", + "@solana/kit": "8.6.0", + "@x402/core": "2.30.0", + "@x402/evm": "2.30.0", + "@x402/near": "2.30.0", + "@x402/svm": "2.30.0", "canonicalize": "5.0.0", "jose": "6.2.12", "mppx": "0.13.1", @@ -2251,80 +2251,122 @@ } }, "node_modules/@solana-program/compute-budget": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@solana-program/compute-budget/-/compute-budget-0.11.0.tgz", - "integrity": "sha512-7f1ePqB/eURkTwTOO9TNIdUXZcyrZoX3Uy2hNo7cXMfNhPFWp9AVgIyRNBc2jf15sdUa9gNpW+PfP2iV8AYAaw==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@solana-program/compute-budget/-/compute-budget-0.18.1.tgz", + "integrity": "sha512-ZVWknu3XvF6NI3zAuT6TXV19lQ7iDPzToM9gwI6KnUlnzHHP3u7IAIzDM7hLueQ6T7vLGjIUjYFCVg4mLHRZcg==", "dev": true, "license": "Apache-2.0", "peerDependencies": { - "@solana/kit": "^5.0" + "@solana/kit": "^8.0.0" } }, - "node_modules/@solana-program/token": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@solana-program/token/-/token-0.9.0.tgz", - "integrity": "sha512-vnZxndd4ED4Fc56sw93cWZ2djEeeOFxtaPS8SPf5+a+JZjKA/EnKqzbE1y04FuMhIVrLERQ8uR8H2h72eZzlsA==", + "node_modules/@solana-program/record": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@solana-program/record/-/record-0.4.1.tgz", + "integrity": "sha512-3iurtMAc3POMKVSZBqpNZQeoxAYVIxi0VZNOjkdaZqxr5glCxkWWruT8JekDwNFM0FnXxvtdu7tFFn3j6Kpegg==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@solana-program/system": "^0.14.0" + }, "peerDependencies": { - "@solana/kit": "^5.0" + "@solana/kit": "^8.0.0" } }, - "node_modules/@solana-program/token-2022": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@solana-program/token-2022/-/token-2022-0.6.1.tgz", - "integrity": "sha512-Ex02cruDMGfBMvZZCrggVR45vdQQSI/unHVpt/7HPt/IwFYB4eTlXtO8otYZyqV/ce5GqZ8S6uwyRf0zy6fdbA==", + "node_modules/@solana-program/system": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/@solana-program/system/-/system-0.14.1.tgz", + "integrity": "sha512-K6ZiIrAKoJAfcwfUdsoFvfSAxeRJaRrV7BPFTJvUVRS4m3zszN+nLcdaNMMe2F1/zTriZqC+xh+Kgw6ziGUqpQ==", "dev": true, "license": "Apache-2.0", "peerDependencies": { - "@solana/kit": "^5.0", - "@solana/sysvars": "^5.0" + "@solana/kit": "^8.0.0" } }, - "node_modules/@solana/accounts": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/accounts/-/accounts-5.5.1.tgz", - "integrity": "sha512-TfOY9xixg5rizABuLVuZ9XI2x2tmWUC/OoN556xwfDlhBHBjKfszicYYOyD6nbFmwTGYarCmyGIdteXxTXIdhQ==", + "node_modules/@solana-program/token": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@solana-program/token/-/token-0.16.1.tgz", + "integrity": "sha512-X9dsvbh+VDq4SuCfvxn95P1DCvtBYHJOBiVmgBJMfW8l/lp8uSwEhy9IurpcdltnGi5kNIkCm4sk4YOXKAMiCw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@solana/addresses": "5.5.1", - "@solana/codecs-core": "5.5.1", - "@solana/codecs-strings": "5.5.1", - "@solana/errors": "5.5.1", - "@solana/rpc-spec": "5.5.1", - "@solana/rpc-types": "5.5.1" + "@solana-program/system": "^0.14.0" }, - "engines": { - "node": ">=20.18.0" + "peerDependencies": { + "@solana/kit": "^8.0.0" + } + }, + "node_modules/@solana-program/token-2022": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@solana-program/token-2022/-/token-2022-0.16.1.tgz", + "integrity": "sha512-VEQHBcFFGErW+gDGQAjuLRjAbg2x7uJqoWV9EgwG82exPpqHP2VGcV0sb7DXc8uXP+IvkmZeVH81gtuKyMxpZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.9.7", + "@solana-program/record": "^0.4.0", + "@solana-program/system": "^0.14.0", + "@solana-program/zk-elgamal-proof": "^0.4.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "@solana/kit": "^8.0.0", + "@solana/sysvars": "^8.0.0", + "@solana/zk-sdk": "^0.5.1" }, "peerDependenciesMeta": { - "typescript": { + "@solana/zk-sdk": { "optional": true } } }, - "node_modules/@solana/addresses": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/addresses/-/addresses-5.5.1.tgz", - "integrity": "sha512-5xoah3Q9G30HQghu/9BiHLb5pzlPKRC3zydQDmE3O9H//WfayxTFppsUDCL6FjYUHqj/wzK6CWHySglc2RkpdA==", + "node_modules/@solana-program/token-2022/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@solana-program/zk-elgamal-proof": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@solana-program/zk-elgamal-proof/-/zk-elgamal-proof-0.4.0.tgz", + "integrity": "sha512-bf2Q3f25xcCNaLm/MR4lwoAguJzEW2eeLdrJb6rUjMcA63qcgyJOWl6To7Bn4e+0OyWBa+J7YP2BUhi//VPz3g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@solana-program/system": "^0.14.0" + }, + "peerDependencies": { + "@solana/kit": "^8.0.0" + } + }, + "node_modules/@solana/accounts": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/accounts/-/accounts-8.6.0.tgz", + "integrity": "sha512-yIcrKjbUXzs1K6KkcRJfAHFOsn8Jpp9AeczcHOHQoBv11AxHM5KmdCTv8rnv7OObq696sd3gpbamVxtqnL0meQ==", "dev": true, "license": "MIT", "dependencies": { - "@solana/assertions": "5.5.1", - "@solana/codecs-core": "5.5.1", - "@solana/codecs-strings": "5.5.1", - "@solana/errors": "5.5.1", - "@solana/nominal-types": "5.5.1" + "@solana/addresses": "8.6.0", + "@solana/codecs-core": "8.6.0", + "@solana/codecs-strings": "8.6.0", + "@solana/errors": "8.6.0", + "@solana/rpc-spec": "8.6.0", + "@solana/rpc-types": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2332,20 +2374,24 @@ } } }, - "node_modules/@solana/assertions": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/assertions/-/assertions-5.5.1.tgz", - "integrity": "sha512-YTCSWAlGwSlVPnWtWLm3ukz81wH4j2YaCveK+TjpvUU88hTy6fmUqxi0+hvAMAe4zKXpJyj3Az7BrLJRxbIm4Q==", + "node_modules/@solana/addresses": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/addresses/-/addresses-8.6.0.tgz", + "integrity": "sha512-V30spa+zKsY7i4elEEOZQB/1xpRc0CzbSypI/yTZwj/Bg1o09T6A+pflMDTBoIWH3sglmigdK1jApEOOA7Bypg==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "5.5.1" + "@solana/assertions": "8.6.0", + "@solana/codecs-core": "8.6.0", + "@solana/codecs-strings": "8.6.0", + "@solana/errors": "8.6.0", + "@solana/nominal-types": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2353,24 +2399,20 @@ } } }, - "node_modules/@solana/codecs": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-5.5.1.tgz", - "integrity": "sha512-Vea29nJub/bXjfzEV7ZZQ/PWr1pYLZo3z0qW0LQL37uKKVzVFRQlwetd7INk3YtTD3xm9WUYr7bCvYUk3uKy2g==", + "node_modules/@solana/assertions": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/assertions/-/assertions-8.6.0.tgz", + "integrity": "sha512-zQFbZoPFE3GqBw/7JyZxaH5t/Ibm7eHOmOfRI542/zz0BHWSnMkR0N5aSndPJd9creMUP+mFtX8hAr6bZ6rDBg==", "dev": true, "license": "MIT", "dependencies": { - "@solana/codecs-core": "5.5.1", - "@solana/codecs-data-structures": "5.5.1", - "@solana/codecs-numbers": "5.5.1", - "@solana/codecs-strings": "5.5.1", - "@solana/options": "5.5.1" + "@solana/errors": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2379,19 +2421,19 @@ } }, "node_modules/@solana/codecs-core": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-5.5.1.tgz", - "integrity": "sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-8.6.0.tgz", + "integrity": "sha512-1N2XZ1yCJGmdTE3g5Fh6/UOE6y46qing+7CZpxfrgB4Q+1l+R/d0z4G15IpQUNl3vzqML8CFF791f/A1XSVmJA==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "5.5.1" + "@solana/errors": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2400,21 +2442,21 @@ } }, "node_modules/@solana/codecs-data-structures": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-5.5.1.tgz", - "integrity": "sha512-97bJWGyUY9WvBz3mX1UV3YPWGDTez6btCfD0ip3UVEXJbItVuUiOkzcO5iFDUtQT5riKT6xC+Mzl+0nO76gd0w==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-8.6.0.tgz", + "integrity": "sha512-BLwclFSLVqugUktXucjGfpUFSaX4V7payjSi6x0DDdLs6PaFfjiH3ykQPV5QRkTeOlf0xC6dOMUUntDhYzVyGw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/codecs-core": "5.5.1", - "@solana/codecs-numbers": "5.5.1", - "@solana/errors": "5.5.1" + "@solana/codecs-core": "8.6.0", + "@solana/codecs-numbers": "8.6.0", + "@solana/errors": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2423,20 +2465,20 @@ } }, "node_modules/@solana/codecs-numbers": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-5.5.1.tgz", - "integrity": "sha512-rllMIZAHqmtvC0HO/dc/21wDuWaD0B8Ryv8o+YtsICQBuiL/0U4AGwH7Pi5GNFySYk0/crSuwfIqQFtmxNSPFw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-8.6.0.tgz", + "integrity": "sha512-GRIArvWJiYxH5w1RMKn7IOCLy0uYB/WRT2kFW/uyXHzL1VNFyud2BIzG0D3iM+AxTd85SdS/x93aV7NEw0mTOQ==", "dev": true, "license": "MIT", "dependencies": { - "@solana/codecs-core": "5.5.1", - "@solana/errors": "5.5.1" + "@solana/codecs-core": "8.6.0", + "@solana/errors": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2445,22 +2487,22 @@ } }, "node_modules/@solana/codecs-strings": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-5.5.1.tgz", - "integrity": "sha512-7klX4AhfHYA+uKKC/nxRGP2MntbYQCR3N6+v7bk1W/rSxYuhNmt+FN8aoThSZtWIKwN6BEyR1167ka8Co1+E7A==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-8.6.0.tgz", + "integrity": "sha512-bLS/o5NdCRb9xj23zLACPjxYvDhdjvSDH4XoBAmQ1CPn3cDvVqVvAz0T41Bs/LMykhamkmpqTcWLoTOVqjTz3w==", "dev": true, "license": "MIT", "dependencies": { - "@solana/codecs-core": "5.5.1", - "@solana/codecs-numbers": "5.5.1", - "@solana/errors": "5.5.1" + "@solana/codecs-core": "8.6.0", + "@solana/codecs-numbers": "8.6.0", + "@solana/errors": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { "fastestsmallesttextencoderdecoder": "^1.0.22", - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "fastestsmallesttextencoderdecoder": { @@ -2472,14 +2514,14 @@ } }, "node_modules/@solana/errors": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-5.5.1.tgz", - "integrity": "sha512-vFO3p+S7HoyyrcAectnXbdsMfwUzY2zYFUc2DEe5BwpiE9J1IAxPBGjOWO6hL1bbYdBrlmjNx8DXCslqS+Kcmg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-8.6.0.tgz", + "integrity": "sha512-viHL51ymR1HZB7ZTp0ESkLOyHCa4bPfok9dvRYbMmYokY5ys1xinISKHUhcOHsg9zbuAWBRJUOnZRWAPbb4+yA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "5.6.2", - "commander": "14.0.2" + "commander": "15.0.0" }, "bin": { "errors": "bin/cli.mjs" @@ -2488,7 +2530,7 @@ "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2496,27 +2538,39 @@ } } }, - "node_modules/@solana/errors/node_modules/commander": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", - "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "node_modules/@solana/fast-stable-stringify": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/fast-stable-stringify/-/fast-stable-stringify-8.6.0.tgz", + "integrity": "sha512-PVuzLesqjvuW1qeg0brkUtpRnQYCLS0mB7SsFu+iio+dxFzbrrHzkSYVoUBEEbjXysCq1L350Frs1+v7CEsXGQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=20" + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@solana/fast-stable-stringify": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/fast-stable-stringify/-/fast-stable-stringify-5.5.1.tgz", - "integrity": "sha512-Ni7s2FN33zTzhTFgRjEbOVFO+UAmK8qi3Iu0/GRFYK4jN696OjKHnboSQH/EacQ+yGqS54bfxf409wU5dsLLCw==", + "node_modules/@solana/fixed-points": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/fixed-points/-/fixed-points-8.6.0.tgz", + "integrity": "sha512-yS0XIHwWETtTjAgUHPhpQpcigPvC6phba3vW7rvVPSvVz1FhxNki82ojyl1pISglK7IkUTYcGhaXDhsY/oz8rQ==", "dev": true, "license": "MIT", + "dependencies": { + "@solana/codecs-core": "8.6.0", + "@solana/errors": "8.6.0" + }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2525,16 +2579,16 @@ } }, "node_modules/@solana/functional": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/functional/-/functional-5.5.1.tgz", - "integrity": "sha512-tTHoJcEQq3gQx5qsdsDJ0LEJeFzwNpXD80xApW9o/PPoCNimI3SALkZl+zNW8VnxRrV3l3yYvfHWBKe/X3WG3w==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/functional/-/functional-8.6.0.tgz", + "integrity": "sha512-greLf2tiMJ5oWaSdp3zkAcUF3sIG3CU1UssxoMYNHy/xywbZifKnOl5syQjKY1XdYPZZmLAWJBFYCkzVns5bxA==", "dev": true, "license": "MIT", "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2543,24 +2597,24 @@ } }, "node_modules/@solana/instruction-plans": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/instruction-plans/-/instruction-plans-5.5.1.tgz", - "integrity": "sha512-7z3CB7YMcFKuVvgcnNY8bY6IsZ8LG61Iytbz7HpNVGX2u1RthOs1tRW8luTzSG1MPL0Ox7afyAVMYeFqSPHnaQ==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/instruction-plans/-/instruction-plans-8.6.0.tgz", + "integrity": "sha512-hq1bLZStauHKmwHWheCoF9UxcFlJsYm9X4saUj4llkJl6GmTrDTmeiE93mJngz4GO6cAkm65D/AjYRcp69jUVw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "5.5.1", - "@solana/instructions": "5.5.1", - "@solana/keys": "5.5.1", - "@solana/promises": "5.5.1", - "@solana/transaction-messages": "5.5.1", - "@solana/transactions": "5.5.1" + "@solana/errors": "8.6.0", + "@solana/instructions": "8.6.0", + "@solana/keys": "8.6.0", + "@solana/promises": "8.6.0", + "@solana/transaction-messages": "8.6.0", + "@solana/transactions": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2569,20 +2623,20 @@ } }, "node_modules/@solana/instructions": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/instructions/-/instructions-5.5.1.tgz", - "integrity": "sha512-h0G1CG6S+gUUSt0eo6rOtsaXRBwCq1+Js2a+Ps9Bzk9q7YHNFA75/X0NWugWLgC92waRp66hrjMTiYYnLBoWOQ==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/instructions/-/instructions-8.6.0.tgz", + "integrity": "sha512-1mXhgf5XaIJir02TgkUwzNRJs8mCCRRRsW45ZwqCp66bnIxmxEy3a91twgGXvBfE6dvzzg3TNSjcfqGQ/ADBog==", "dev": true, "license": "MIT", "dependencies": { - "@solana/codecs-core": "5.5.1", - "@solana/errors": "5.5.1" + "@solana/codecs-core": "8.6.0", + "@solana/errors": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2591,23 +2645,24 @@ } }, "node_modules/@solana/keys": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/keys/-/keys-5.5.1.tgz", - "integrity": "sha512-KRD61cL7CRL+b4r/eB9dEoVxIf/2EJ1Pm1DmRYhtSUAJD2dJ5Xw8QFuehobOGm9URqQ7gaQl+Fkc1qvDlsWqKg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/keys/-/keys-8.6.0.tgz", + "integrity": "sha512-vxj3zmcHfY0EqDSJR8nn7w/qQAdCG3uSyGGcIfLgf/ZhVUgVDYDUyUqrxSL5DxAs64czhSu+ufRh7B5AKwMtaQ==", "dev": true, "license": "MIT", "dependencies": { - "@solana/assertions": "5.5.1", - "@solana/codecs-core": "5.5.1", - "@solana/codecs-strings": "5.5.1", - "@solana/errors": "5.5.1", - "@solana/nominal-types": "5.5.1" + "@solana/assertions": "8.6.0", + "@solana/codecs-core": "8.6.0", + "@solana/codecs-strings": "8.6.0", + "@solana/errors": "8.6.0", + "@solana/nominal-types": "8.6.0", + "@solana/promises": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2616,40 +2671,71 @@ } }, "node_modules/@solana/kit": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-5.5.1.tgz", - "integrity": "sha512-irKUGiV2yRoyf+4eGQ/ZeCRxa43yjFEL1DUI5B0DkcfZw3cr0VJtVJnrG8OtVF01vT0OUfYOcUn6zJW5TROHvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@solana/accounts": "5.5.1", - "@solana/addresses": "5.5.1", - "@solana/codecs": "5.5.1", - "@solana/errors": "5.5.1", - "@solana/functional": "5.5.1", - "@solana/instruction-plans": "5.5.1", - "@solana/instructions": "5.5.1", - "@solana/keys": "5.5.1", - "@solana/offchain-messages": "5.5.1", - "@solana/plugin-core": "5.5.1", - "@solana/programs": "5.5.1", - "@solana/rpc": "5.5.1", - "@solana/rpc-api": "5.5.1", - "@solana/rpc-parsed-types": "5.5.1", - "@solana/rpc-spec-types": "5.5.1", - "@solana/rpc-subscriptions": "5.5.1", - "@solana/rpc-types": "5.5.1", - "@solana/signers": "5.5.1", - "@solana/sysvars": "5.5.1", - "@solana/transaction-confirmation": "5.5.1", - "@solana/transaction-messages": "5.5.1", - "@solana/transactions": "5.5.1" + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-8.6.0.tgz", + "integrity": "sha512-UaigU+LiTzkU237FxfiJhxufvEfb1J02djJOnGpSz1GAjZtbZ8oc4EkQ1bS4sHewSRyR9RUk6yhBb5bLHG0w4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/accounts": "8.6.0", + "@solana/addresses": "8.6.0", + "@solana/codecs": "8.6.0", + "@solana/errors": "8.6.0", + "@solana/functional": "8.6.0", + "@solana/instruction-plans": "8.6.0", + "@solana/instructions": "8.6.0", + "@solana/keys": "8.6.0", + "@solana/offchain-messages": "8.6.0", + "@solana/plugin-core": "8.6.0", + "@solana/plugin-interfaces": "8.6.0", + "@solana/program-client-core": "8.6.0", + "@solana/programs": "8.6.0", + "@solana/promises": "8.6.0", + "@solana/rpc": "8.6.0", + "@solana/rpc-api": "8.6.0", + "@solana/rpc-parsed-types": "8.6.0", + "@solana/rpc-spec-types": "8.6.0", + "@solana/rpc-subscriptions": "8.6.0", + "@solana/rpc-types": "8.6.0", + "@solana/signers": "8.6.0", + "@solana/subscribable": "8.6.0", + "@solana/sysvars": "8.6.0", + "@solana/transaction-confirmation": "8.6.0", + "@solana/transaction-introspection": "8.6.0", + "@solana/transaction-messages": "8.6.0", + "@solana/transactions": "8.6.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/kit/node_modules/@solana/codecs": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-8.6.0.tgz", + "integrity": "sha512-7vUVLdYlTay3rsijiBPN4g992V8RXbgY2DNAXrsE6eHFnwaX8gzdDNASgF4Z0mbgNC026qdLG6LTY4l16tvE8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "8.6.0", + "@solana/codecs-data-structures": "8.6.0", + "@solana/codecs-numbers": "8.6.0", + "@solana/codecs-strings": "8.6.0", + "@solana/fixed-points": "8.6.0", + "@solana/options": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2658,16 +2744,16 @@ } }, "node_modules/@solana/nominal-types": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/nominal-types/-/nominal-types-5.5.1.tgz", - "integrity": "sha512-I1ImR+kfrLFxN5z22UDiTWLdRZeKtU0J/pkWkO8qm/8WxveiwdIv4hooi8pb6JnlR4mSrWhq0pCIOxDYrL9GIQ==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/nominal-types/-/nominal-types-8.6.0.tgz", + "integrity": "sha512-uuRReiuEWfTTwtlJNvTSStuHrDF45Itl2k3BGg/4kHWxqUdtS5u4PK4bMgzoPWoyPorDj5y6TEMa+MydM5D7Sg==", "dev": true, "license": "MIT", "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2676,26 +2762,26 @@ } }, "node_modules/@solana/offchain-messages": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/offchain-messages/-/offchain-messages-5.5.1.tgz", - "integrity": "sha512-g+xHH95prTU+KujtbOzj8wn+C7ZNoiLhf3hj6nYq3MTyxOXtBEysguc97jJveUZG0K97aIKG6xVUlMutg5yxhw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/offchain-messages/-/offchain-messages-8.6.0.tgz", + "integrity": "sha512-VFic3fotmL9fIjs7Amk402CMWLeWwhNQvAF9cyZO++x42oU2FNeTe27rxfgFLYPaIHaRonlII+5pmsHQHA240Q==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "5.5.1", - "@solana/codecs-core": "5.5.1", - "@solana/codecs-data-structures": "5.5.1", - "@solana/codecs-numbers": "5.5.1", - "@solana/codecs-strings": "5.5.1", - "@solana/errors": "5.5.1", - "@solana/keys": "5.5.1", - "@solana/nominal-types": "5.5.1" + "@solana/addresses": "8.6.0", + "@solana/codecs-core": "8.6.0", + "@solana/codecs-data-structures": "8.6.0", + "@solana/codecs-numbers": "8.6.0", + "@solana/codecs-strings": "8.6.0", + "@solana/errors": "8.6.0", + "@solana/keys": "8.6.0", + "@solana/nominal-types": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2704,23 +2790,23 @@ } }, "node_modules/@solana/options": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/options/-/options-5.5.1.tgz", - "integrity": "sha512-eo971c9iLNLmk+yOFyo7yKIJzJ/zou6uKpy6mBuyb/thKtS/haiKIc3VLhyTXty3OH2PW8yOlORJnv4DexJB8A==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/options/-/options-8.6.0.tgz", + "integrity": "sha512-WFsTtIQukNGGHRImcmn8Se8o6A4vu4wY0fuLSeB/L7C45FPoQI2fTIud6hf9g2fum0dR3U0pFNIciwQjlVq2LA==", "dev": true, "license": "MIT", "dependencies": { - "@solana/codecs-core": "5.5.1", - "@solana/codecs-data-structures": "5.5.1", - "@solana/codecs-numbers": "5.5.1", - "@solana/codecs-strings": "5.5.1", - "@solana/errors": "5.5.1" + "@solana/codecs-core": "8.6.0", + "@solana/codecs-data-structures": "8.6.0", + "@solana/codecs-numbers": "8.6.0", + "@solana/codecs-strings": "8.6.0", + "@solana/errors": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2729,16 +2815,74 @@ } }, "node_modules/@solana/plugin-core": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/plugin-core/-/plugin-core-5.5.1.tgz", - "integrity": "sha512-VUZl30lDQFJeiSyNfzU1EjYt2QZvoBFKEwjn1lilUJw7KgqD5z7mbV7diJhT+dLFs36i0OsjXvq5kSygn8YJ3A==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/plugin-core/-/plugin-core-8.6.0.tgz", + "integrity": "sha512-wvjpR66FreDxDXfF5RrIay7aXW+JfcmSj1TiDu+rUyWaV1Xu390E/KKL3dM6OfkIOtP66H9M9w1XihB8syMkfg==", "dev": true, "license": "MIT", "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/plugin-interfaces": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/plugin-interfaces/-/plugin-interfaces-8.6.0.tgz", + "integrity": "sha512-ta+JYsKD7GMUoBLbeE1nYKDPN9nOwQN1y04xR4tbyfDbNsGLPz3fTKH8Y48bOs552sW4OcUB8F3pGVZefqPyWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/accounts": "8.6.0", + "@solana/addresses": "8.6.0", + "@solana/instruction-plans": "8.6.0", + "@solana/keys": "8.6.0", + "@solana/rpc-spec": "8.6.0", + "@solana/rpc-subscriptions-spec": "8.6.0", + "@solana/rpc-types": "8.6.0", + "@solana/signers": "8.6.0", + "@solana/transactions": "8.6.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/program-client-core": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/program-client-core/-/program-client-core-8.6.0.tgz", + "integrity": "sha512-e6Wx2Y2UrQ0dPB6hndVcyUXQwBt9/DLr8k2P7tRgW0JNVhHNylZQ6aYQAqxZzS8Z92gGz7l3XL+r9tCLKBkIVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/accounts": "8.6.0", + "@solana/addresses": "8.6.0", + "@solana/codecs-core": "8.6.0", + "@solana/errors": "8.6.0", + "@solana/instruction-plans": "8.6.0", + "@solana/instructions": "8.6.0", + "@solana/plugin-interfaces": "8.6.0", + "@solana/rpc-api": "8.6.0", + "@solana/signers": "8.6.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2747,20 +2891,20 @@ } }, "node_modules/@solana/programs": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/programs/-/programs-5.5.1.tgz", - "integrity": "sha512-7U9kn0Jsx1NuBLn5HRTFYh78MV4XN145Yc3WP/q5BlqAVNlMoU9coG5IUTJIG847TUqC1lRto3Dnpwm6T4YRpA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/programs/-/programs-8.6.0.tgz", + "integrity": "sha512-JKGtqXtiT4zon7xZkmys2G1/HchwbNjzbP+E+0Rp/Cko+svB9w9SE2PROEVJZS8UH98lbi1epjsHYAgESamwvg==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "5.5.1", - "@solana/errors": "5.5.1" + "@solana/addresses": "8.6.0", + "@solana/errors": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2769,16 +2913,16 @@ } }, "node_modules/@solana/promises": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/promises/-/promises-5.5.1.tgz", - "integrity": "sha512-T9lfuUYkGykJmppEcssNiCf6yiYQxJkhiLPP+pyAc2z84/7r3UVIb2tNJk4A9sucS66pzJnVHZKcZVGUUp6wzA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/promises/-/promises-8.6.0.tgz", + "integrity": "sha512-HLlAMwXnEyhg9HxVJGDlWgFlM4QUt/Qq0nL69bbC7COCHhUQFRcKLq5uIbaXPy7FNJlNBSTTCBZwI0IK9up0SQ==", "dev": true, "license": "MIT", "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2787,27 +2931,27 @@ } }, "node_modules/@solana/rpc": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/rpc/-/rpc-5.5.1.tgz", - "integrity": "sha512-ku8zTUMrkCWci66PRIBC+1mXepEnZH/q1f3ck0kJZ95a06bOTl5KU7HeXWtskkyefzARJ5zvCs54AD5nxjQJ+A==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/rpc/-/rpc-8.6.0.tgz", + "integrity": "sha512-u5dg96ThDVAeklC9vtVpuYL7wRqkyolm++qYKd6yL7tPRdKZNxdGHbFHQR2qIcMQ0oVfEA9tGnQhplj+6Yt/0g==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "5.5.1", - "@solana/fast-stable-stringify": "5.5.1", - "@solana/functional": "5.5.1", - "@solana/rpc-api": "5.5.1", - "@solana/rpc-spec": "5.5.1", - "@solana/rpc-spec-types": "5.5.1", - "@solana/rpc-transformers": "5.5.1", - "@solana/rpc-transport-http": "5.5.1", - "@solana/rpc-types": "5.5.1" + "@solana/errors": "8.6.0", + "@solana/fast-stable-stringify": "8.6.0", + "@solana/functional": "8.6.0", + "@solana/rpc-api": "8.6.0", + "@solana/rpc-spec": "8.6.0", + "@solana/rpc-spec-types": "8.6.0", + "@solana/rpc-transformers": "8.6.0", + "@solana/rpc-transport-http": "8.6.0", + "@solana/rpc-types": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2816,29 +2960,29 @@ } }, "node_modules/@solana/rpc-api": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/rpc-api/-/rpc-api-5.5.1.tgz", - "integrity": "sha512-XWOQQPhKl06Vj0xi3RYHAc6oEQd8B82okYJ04K7N0Vvy3J4PN2cxeK7klwkjgavdcN9EVkYCChm2ADAtnztKnA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-api/-/rpc-api-8.6.0.tgz", + "integrity": "sha512-NFHtOB9z4HyV8frN84o+d9jKj5luRzrySZJLgwMgcG/Q2fWahTDrP+ZqRKLmqibvFBg/fwhcxl77+TfKZjpvhA==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "5.5.1", - "@solana/codecs-core": "5.5.1", - "@solana/codecs-strings": "5.5.1", - "@solana/errors": "5.5.1", - "@solana/keys": "5.5.1", - "@solana/rpc-parsed-types": "5.5.1", - "@solana/rpc-spec": "5.5.1", - "@solana/rpc-transformers": "5.5.1", - "@solana/rpc-types": "5.5.1", - "@solana/transaction-messages": "5.5.1", - "@solana/transactions": "5.5.1" + "@solana/addresses": "8.6.0", + "@solana/codecs-core": "8.6.0", + "@solana/codecs-strings": "8.6.0", + "@solana/errors": "8.6.0", + "@solana/keys": "8.6.0", + "@solana/rpc-parsed-types": "8.6.0", + "@solana/rpc-spec": "8.6.0", + "@solana/rpc-transformers": "8.6.0", + "@solana/rpc-types": "8.6.0", + "@solana/transaction-messages": "8.6.0", + "@solana/transactions": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2847,16 +2991,16 @@ } }, "node_modules/@solana/rpc-parsed-types": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/rpc-parsed-types/-/rpc-parsed-types-5.5.1.tgz", - "integrity": "sha512-HEi3G2nZqGEsa3vX6U0FrXLaqnUCg4SKIUrOe8CezD+cSFbRTOn3rCLrUmJrhVyXlHoQVaRO9mmeovk31jWxJg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-parsed-types/-/rpc-parsed-types-8.6.0.tgz", + "integrity": "sha512-wZKtQOS3RdHZqJ3ycpdibup31tfbY6lHYGUKjmzeF6N2E2y7WEpB7qiEr9AMBB00mDUtorcqBMpOli6PyT4Gog==", "dev": true, "license": "MIT", "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2865,20 +3009,21 @@ } }, "node_modules/@solana/rpc-spec": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/rpc-spec/-/rpc-spec-5.5.1.tgz", - "integrity": "sha512-m3LX2bChm3E3by4mQrH4YwCAFY57QBzuUSWqlUw7ChuZ+oLLOq7b2czi4i6L4Vna67j3eCmB3e+4tqy1j5wy7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec/-/rpc-spec-8.6.0.tgz", + "integrity": "sha512-V1c+r6HXTb/nCEl5SHKLPzzb6aMEbr0lFOcgjEX8TQ8fE6ZlLzNz3tKvcgbBlOJCeyVXSrcNl3tv8i8xNp+n/g==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "5.5.1", - "@solana/rpc-spec-types": "5.5.1" + "@solana/errors": "8.6.0", + "@solana/rpc-spec-types": "8.6.0", + "@solana/subscribable": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2887,16 +3032,19 @@ } }, "node_modules/@solana/rpc-spec-types": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/rpc-spec-types/-/rpc-spec-types-5.5.1.tgz", - "integrity": "sha512-6OFKtRpIEJQs8Jb2C4OO8KyP2h2Hy1MFhatMAoXA+0Ik8S3H+CicIuMZvGZ91mIu/tXicuOOsNNLu3HAkrakrw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec-types/-/rpc-spec-types-8.6.0.tgz", + "integrity": "sha512-6+2F2BN2CXp4g7cnvLp2mRo/XwdN5At7f3HbdEablxNe1UvI5S8WIEY3l/Y+nh2NQIaEVz9fLjmTYV+UfZNzEg==", "dev": true, "license": "MIT", + "dependencies": { + "@solana/errors": "8.6.0" + }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2905,29 +3053,29 @@ } }, "node_modules/@solana/rpc-subscriptions": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions/-/rpc-subscriptions-5.5.1.tgz", - "integrity": "sha512-CTMy5bt/6mDh4tc6vUJms9EcuZj3xvK0/xq8IQ90rhkpYvate91RjBP+egvjgSayUg9yucU9vNuUpEjz4spM7w==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions/-/rpc-subscriptions-8.6.0.tgz", + "integrity": "sha512-ZaoFtiKg25KqYykBslB8TDWqfm7S162FyTJvFI0HL0U94K1O675IG2h913mes3voatigpqLneFxkNrVs5yE7tA==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "5.5.1", - "@solana/fast-stable-stringify": "5.5.1", - "@solana/functional": "5.5.1", - "@solana/promises": "5.5.1", - "@solana/rpc-spec-types": "5.5.1", - "@solana/rpc-subscriptions-api": "5.5.1", - "@solana/rpc-subscriptions-channel-websocket": "5.5.1", - "@solana/rpc-subscriptions-spec": "5.5.1", - "@solana/rpc-transformers": "5.5.1", - "@solana/rpc-types": "5.5.1", - "@solana/subscribable": "5.5.1" + "@solana/errors": "8.6.0", + "@solana/fast-stable-stringify": "8.6.0", + "@solana/functional": "8.6.0", + "@solana/promises": "8.6.0", + "@solana/rpc-spec-types": "8.6.0", + "@solana/rpc-subscriptions-api": "8.6.0", + "@solana/rpc-subscriptions-channel-websocket": "8.6.0", + "@solana/rpc-subscriptions-spec": "8.6.0", + "@solana/rpc-transformers": "8.6.0", + "@solana/rpc-types": "8.6.0", + "@solana/subscribable": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2936,25 +3084,25 @@ } }, "node_modules/@solana/rpc-subscriptions-api": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-api/-/rpc-subscriptions-api-5.5.1.tgz", - "integrity": "sha512-5Oi7k+GdeS8xR2ly1iuSFkAv6CZqwG0Z6b1QZKbEgxadE1XGSDrhM2cn59l+bqCozUWCqh4c/A2znU/qQjROlw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-api/-/rpc-subscriptions-api-8.6.0.tgz", + "integrity": "sha512-Cbyi0ZpwNCcxRgjjc1vKVWBdwNOAKGT1VjryfQU/FDySUo2Lg8u5LUsJb6d53QD5dhEU+0nooxfBmRUUyebZug==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "5.5.1", - "@solana/keys": "5.5.1", - "@solana/rpc-subscriptions-spec": "5.5.1", - "@solana/rpc-transformers": "5.5.1", - "@solana/rpc-types": "5.5.1", - "@solana/transaction-messages": "5.5.1", - "@solana/transactions": "5.5.1" + "@solana/addresses": "8.6.0", + "@solana/keys": "8.6.0", + "@solana/rpc-subscriptions-spec": "8.6.0", + "@solana/rpc-transformers": "8.6.0", + "@solana/rpc-types": "8.6.0", + "@solana/transaction-messages": "8.6.0", + "@solana/transactions": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2963,23 +3111,23 @@ } }, "node_modules/@solana/rpc-subscriptions-channel-websocket": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-channel-websocket/-/rpc-subscriptions-channel-websocket-5.5.1.tgz", - "integrity": "sha512-7tGfBBrYY8TrngOyxSHoCU5shy86iA9SRMRrPSyBhEaZRAk6dnbdpmUTez7gtdVo0BCvh9nzQtUycKWSS7PnFQ==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-channel-websocket/-/rpc-subscriptions-channel-websocket-8.6.0.tgz", + "integrity": "sha512-k1UmMiIJFeo7wAUTiqg7Ni6gdsJAd65D3Wxqdtf5iBX9TjkvWGrL3KXdJq2ZzS1c9eqd220W0v7fkFTA8tfX5Q==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "5.5.1", - "@solana/functional": "5.5.1", - "@solana/rpc-subscriptions-spec": "5.5.1", - "@solana/subscribable": "5.5.1", - "ws": "^8.19.0" + "@solana/errors": "8.6.0", + "@solana/functional": "8.6.0", + "@solana/rpc-subscriptions-spec": "8.6.0", + "@solana/subscribable": "8.6.0", + "ws": "^8.21.3" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2987,23 +3135,45 @@ } } }, + "node_modules/@solana/rpc-subscriptions-channel-websocket/node_modules/ws": { + "version": "8.22.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz", + "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@solana/rpc-subscriptions-spec": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-spec/-/rpc-subscriptions-spec-5.5.1.tgz", - "integrity": "sha512-iq+rGq5fMKP3/mKHPNB6MC8IbVW41KGZg83Us/+LE3AWOTWV1WT20KT2iH1F1ik9roi42COv/TpoZZvhKj45XQ==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-spec/-/rpc-subscriptions-spec-8.6.0.tgz", + "integrity": "sha512-m7lBQS9hgzWN1ZI+JUy5rV8ctG7oy0WbGHoj4vevB4esK2SYUyI0eQB4GrQhVwGnkyTxATS/R91ZZzWYDKGXlg==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "5.5.1", - "@solana/promises": "5.5.1", - "@solana/rpc-spec-types": "5.5.1", - "@solana/subscribable": "5.5.1" + "@solana/errors": "8.6.0", + "@solana/promises": "8.6.0", + "@solana/rpc-spec-types": "8.6.0", + "@solana/subscribable": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -3012,23 +3182,23 @@ } }, "node_modules/@solana/rpc-transformers": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/rpc-transformers/-/rpc-transformers-5.5.1.tgz", - "integrity": "sha512-OsWqLCQdcrRJKvHiMmwFhp9noNZ4FARuMkHT5us3ustDLXaxOjF0gfqZLnMkulSLcKt7TGXqMhBV+HCo7z5M8Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-transformers/-/rpc-transformers-8.6.0.tgz", + "integrity": "sha512-aSfctQeLTszEQdNGDrPWbi/7voJYrYo/rvIZRDbokYQRDwMuphw7kENGdwaQB7A9XSX0tsPe+dKkbxjWf0Druw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "5.5.1", - "@solana/functional": "5.5.1", - "@solana/nominal-types": "5.5.1", - "@solana/rpc-spec-types": "5.5.1", - "@solana/rpc-types": "5.5.1" + "@solana/errors": "8.6.0", + "@solana/functional": "8.6.0", + "@solana/nominal-types": "8.6.0", + "@solana/rpc-spec-types": "8.6.0", + "@solana/rpc-types": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -3037,22 +3207,22 @@ } }, "node_modules/@solana/rpc-transport-http": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/rpc-transport-http/-/rpc-transport-http-5.5.1.tgz", - "integrity": "sha512-yv8GoVSHqEV0kUJEIhkdOVkR2SvJ6yoWC51cJn2rSV7plr6huLGe0JgujCmB7uZhhaLbcbP3zxXxu9sOjsi7Fg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-transport-http/-/rpc-transport-http-8.6.0.tgz", + "integrity": "sha512-jr7ef/UOIO7bRjI7DEXji3J7nCFO3jIYPGw3L0fyF7VEJox9Bh8TNHsn9G9jv1kqdwkUnFrPPWVMvmAWQwSmvg==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "5.5.1", - "@solana/rpc-spec": "5.5.1", - "@solana/rpc-spec-types": "5.5.1", - "undici-types": "^7.19.2" + "@solana/errors": "8.6.0", + "@solana/rpc-spec": "8.6.0", + "@solana/rpc-spec-types": "8.6.0", + "undici-types": "^8.12.3" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -3061,31 +3231,32 @@ } }, "node_modules/@solana/rpc-transport-http/node_modules/undici-types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.29.0.tgz", - "integrity": "sha512-vamA8dGlzMwhpyYpQp9d8vka3o4D/yn5I7ez7Or+msDA4bZ8Uh+Zy91WvWf3I73gDAkFha9JcYRqm2li0Npfgg==", + "version": "8.12.4", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.12.4.tgz", + "integrity": "sha512-GNCQH3MbVGB8OAVAoTiLH8pu4wx5Ni3DWKpnBzGj3WDsi8EwmK0aOVBooNEt+g5YL1eHPxcS9XUTouwFU6j1kQ==", "dev": true, "license": "MIT" }, "node_modules/@solana/rpc-types": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/rpc-types/-/rpc-types-5.5.1.tgz", - "integrity": "sha512-bibTFQ7PbHJJjGJPmfYC2I+/5CRFS4O2p9WwbFraX1Keeel+nRrt/NBXIy8veP5AEn2sVJIyJPpWBRpCx1oATA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-types/-/rpc-types-8.6.0.tgz", + "integrity": "sha512-gg05ZjZq8sFcf0JKO6/MQ1oYSFQuT5SU51dhirGjfl8UCxX/Q6ocsUgzb9a/zPNrRsXJkduaZF1bARah7MrrYw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "5.5.1", - "@solana/codecs-core": "5.5.1", - "@solana/codecs-numbers": "5.5.1", - "@solana/codecs-strings": "5.5.1", - "@solana/errors": "5.5.1", - "@solana/nominal-types": "5.5.1" + "@solana/addresses": "8.6.0", + "@solana/codecs-core": "8.6.0", + "@solana/codecs-numbers": "8.6.0", + "@solana/codecs-strings": "8.6.0", + "@solana/errors": "8.6.0", + "@solana/fixed-points": "8.6.0", + "@solana/nominal-types": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -3094,27 +3265,28 @@ } }, "node_modules/@solana/signers": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/signers/-/signers-5.5.1.tgz", - "integrity": "sha512-FY0IVaBT2kCAze55vEieR6hag4coqcuJ31Aw3hqRH7mv6sV8oqwuJmUrx+uFwOp1gwd5OEAzlv6N4hOOple4sQ==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/signers/-/signers-8.6.0.tgz", + "integrity": "sha512-q8FNQfGnMsHc7VLBhh+TwsMfodiYtYhoTqNj6lDIP2bz93l173rzcZWSRH/hg/7ksFdhHuV4nEkWj0L65N0S0Q==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "5.5.1", - "@solana/codecs-core": "5.5.1", - "@solana/errors": "5.5.1", - "@solana/instructions": "5.5.1", - "@solana/keys": "5.5.1", - "@solana/nominal-types": "5.5.1", - "@solana/offchain-messages": "5.5.1", - "@solana/transaction-messages": "5.5.1", - "@solana/transactions": "5.5.1" + "@solana/addresses": "8.6.0", + "@solana/codecs-core": "8.6.0", + "@solana/errors": "8.6.0", + "@solana/instruction-plans": "8.6.0", + "@solana/instructions": "8.6.0", + "@solana/keys": "8.6.0", + "@solana/nominal-types": "8.6.0", + "@solana/offchain-messages": "8.6.0", + "@solana/transaction-messages": "8.6.0", + "@solana/transactions": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -3123,19 +3295,20 @@ } }, "node_modules/@solana/subscribable": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/subscribable/-/subscribable-5.5.1.tgz", - "integrity": "sha512-9K0PsynFq0CsmK1CDi5Y2vUIJpCqkgSS5yfDN0eKPgHqEptLEaia09Kaxc90cSZDZU5mKY/zv1NBmB6Aro9zQQ==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/subscribable/-/subscribable-8.6.0.tgz", + "integrity": "sha512-z6qRloUTxoI459dUV2K/HQZO1HYuP5f6NQxXxd25TtM2TDpnjOzoO3pJAlOZP3cwXGgtGk6UWmMEY1a4j0TILw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/errors": "5.5.1" + "@solana/errors": "8.6.0", + "@solana/promises": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -3144,22 +3317,24 @@ } }, "node_modules/@solana/sysvars": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/sysvars/-/sysvars-5.5.1.tgz", - "integrity": "sha512-k3Quq87Mm+geGUu1GWv6knPk0ALsfY6EKSJGw9xUJDHzY/RkYSBnh0RiOrUhtFm2TDNjOailg8/m0VHmi3reFA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/sysvars/-/sysvars-8.6.0.tgz", + "integrity": "sha512-oAIVThVcZr2qDwdvc3Fx+CKCbG1d4VwwRDXbu/tDKVDUIZXDWoTP//BX0u1FimkuirMHHW9Sl+AsSGFba0KPew==", "dev": true, "license": "MIT", "dependencies": { - "@solana/accounts": "5.5.1", - "@solana/codecs": "5.5.1", - "@solana/errors": "5.5.1", - "@solana/rpc-types": "5.5.1" + "@solana/accounts": "8.6.0", + "@solana/codecs-core": "8.6.0", + "@solana/codecs-data-structures": "8.6.0", + "@solana/codecs-numbers": "8.6.0", + "@solana/errors": "8.6.0", + "@solana/rpc-types": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -3168,28 +3343,56 @@ } }, "node_modules/@solana/transaction-confirmation": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/transaction-confirmation/-/transaction-confirmation-5.5.1.tgz", - "integrity": "sha512-j4mKlYPHEyu+OD7MBt3jRoX4ScFgkhZC6H65on4Fux6LMScgivPJlwnKoZMnsgxFgWds0pl+BYzSiALDsXlYtw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/transaction-confirmation/-/transaction-confirmation-8.6.0.tgz", + "integrity": "sha512-kd7LwN1k6L+k9jXMjzm6KqGDuUsETZ1pWMnWY7ZT+1bDjRPbki/RylQc+2CIF7YYphT5GIloTmUziAAmhyw4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "8.6.0", + "@solana/codecs-strings": "8.6.0", + "@solana/errors": "8.6.0", + "@solana/keys": "8.6.0", + "@solana/promises": "8.6.0", + "@solana/rpc": "8.6.0", + "@solana/rpc-subscriptions": "8.6.0", + "@solana/rpc-types": "8.6.0", + "@solana/transaction-messages": "8.6.0", + "@solana/transactions": "8.6.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/transaction-introspection": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/transaction-introspection/-/transaction-introspection-8.6.0.tgz", + "integrity": "sha512-/7MYRLnM7AF0983oaicjhvWWQEdeFgknBiKG5LVFd8WT68m5vL18d7/fZHkvsA41zWkIIrRVuh0PM4COeENNlQ==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "5.5.1", - "@solana/codecs-strings": "5.5.1", - "@solana/errors": "5.5.1", - "@solana/keys": "5.5.1", - "@solana/promises": "5.5.1", - "@solana/rpc": "5.5.1", - "@solana/rpc-subscriptions": "5.5.1", - "@solana/rpc-types": "5.5.1", - "@solana/transaction-messages": "5.5.1", - "@solana/transactions": "5.5.1" + "@solana/addresses": "8.6.0", + "@solana/codecs-core": "8.6.0", + "@solana/codecs-strings": "8.6.0", + "@solana/errors": "8.6.0", + "@solana/instructions": "8.6.0", + "@solana/rpc-types": "8.6.0", + "@solana/transaction-messages": "8.6.0", + "@solana/transactions": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -3198,27 +3401,27 @@ } }, "node_modules/@solana/transaction-messages": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/transaction-messages/-/transaction-messages-5.5.1.tgz", - "integrity": "sha512-aXyhMCEaAp3M/4fP0akwBBQkFPr4pfwoC5CLDq999r/FUwDax2RE/h4Ic7h2Xk+JdcUwsb+rLq85Y52hq84XvQ==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/transaction-messages/-/transaction-messages-8.6.0.tgz", + "integrity": "sha512-Zb7mB8Xu+AXaNiSmgghsyliGNoi7HO4hKdyQC0Qth4hW9QOBMdjSJ687KIUwsAzDSVXwdIbsnkUPpwCl1fcMdw==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "5.5.1", - "@solana/codecs-core": "5.5.1", - "@solana/codecs-data-structures": "5.5.1", - "@solana/codecs-numbers": "5.5.1", - "@solana/errors": "5.5.1", - "@solana/functional": "5.5.1", - "@solana/instructions": "5.5.1", - "@solana/nominal-types": "5.5.1", - "@solana/rpc-types": "5.5.1" + "@solana/addresses": "8.6.0", + "@solana/codecs-core": "8.6.0", + "@solana/codecs-data-structures": "8.6.0", + "@solana/codecs-numbers": "8.6.0", + "@solana/errors": "8.6.0", + "@solana/functional": "8.6.0", + "@solana/instructions": "8.6.0", + "@solana/nominal-types": "8.6.0", + "@solana/rpc-types": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -3227,30 +3430,30 @@ } }, "node_modules/@solana/transactions": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/transactions/-/transactions-5.5.1.tgz", - "integrity": "sha512-8hHtDxtqalZ157pnx6p8k10D7J/KY/biLzfgh9R09VNLLY3Fqi7kJvJCr7M2ik3oRll56pxhraAGCC9yIT6eOA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@solana/transactions/-/transactions-8.6.0.tgz", + "integrity": "sha512-m2i0dxDtB9mGqhJd714OQpKzGFp9cQbU80kwIBFflOrHdZ2cJzZ61xjGe+vegz/WGsqPec604pUbHKXXIFkNjg==", "dev": true, "license": "MIT", "dependencies": { - "@solana/addresses": "5.5.1", - "@solana/codecs-core": "5.5.1", - "@solana/codecs-data-structures": "5.5.1", - "@solana/codecs-numbers": "5.5.1", - "@solana/codecs-strings": "5.5.1", - "@solana/errors": "5.5.1", - "@solana/functional": "5.5.1", - "@solana/instructions": "5.5.1", - "@solana/keys": "5.5.1", - "@solana/nominal-types": "5.5.1", - "@solana/rpc-types": "5.5.1", - "@solana/transaction-messages": "5.5.1" + "@solana/addresses": "8.6.0", + "@solana/codecs-core": "8.6.0", + "@solana/codecs-data-structures": "8.6.0", + "@solana/codecs-numbers": "8.6.0", + "@solana/codecs-strings": "8.6.0", + "@solana/errors": "8.6.0", + "@solana/functional": "8.6.0", + "@solana/instructions": "8.6.0", + "@solana/keys": "8.6.0", + "@solana/nominal-types": "8.6.0", + "@solana/rpc-types": "8.6.0", + "@solana/transaction-messages": "8.6.0" }, "engines": { "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -3518,43 +3721,73 @@ } }, "node_modules/@x402/core": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.25.0.tgz", - "integrity": "sha512-5Ys0XYz3FKutxVKoXC46R/XPT/oaAbuj7ahzrlVHwQxZJPH9u6u91IOh0ztz+7G/zYGsaXR8l3ety205QtEnUw==", + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.30.0.tgz", + "integrity": "sha512-gvMxCum2QlNI6jI/3NL1jdrmOgtX9sWEWxSIOmeI67/5ndOJvYOtP7JZLSO8o8af+O+A+Vhhjza1NCda0yVppg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "zod": "^3.24.2" + "zod": "^4.6.6" + } + }, + "node_modules/@x402/core/node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/@x402/evm": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.25.0.tgz", - "integrity": "sha512-EmnL4MyGW8weEUlq0qC1uJUeoBmtp9GWGwyPl+qkC0CYItRHn748puhYAxK8GsrofscQrt63HsFCB0Gp3iEROA==", + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.30.0.tgz", + "integrity": "sha512-/iaEB820OPJb1JkUqaCTq90FwjbmBB/rAFaH7ZAL2AmcesSVPO/FjdHD2wmXqBzXkEnPG9f6m1SuNpCtVlA9LQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@x402/core": "~2.25.0", + "@x402/core": "~2.30.0", "viem": "^2.48.11", - "zod": "^3.24.2" + "zod": "^4.6.6" + } + }, + "node_modules/@x402/evm/node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/@x402/mcp": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@x402/mcp/-/mcp-2.25.0.tgz", - "integrity": "sha512-CgDtfQAWx9AOy8kTsHGizCBlTtYg6cB1tcPg/NVFcMZxa3sJxJCf6A4GpwIgwh8LekJjwFacWGUAI5oAe9NlrA==", + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/@x402/mcp/-/mcp-2.30.0.tgz", + "integrity": "sha512-4aW3iKSFbTJ6X5b9/u1MyoNGDY898jcEnKcR5hzDnkd74tpCBGPTMwjbVZBnsKsmju89BzI/BXWdMgdorpKvrw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", - "@x402/core": "~2.25.0", - "zod": "^3.24.2" + "@modelcontextprotocol/sdk": "^1.32.0", + "@x402/core": "~2.30.0", + "zod": "^4.6.6" + } + }, + "node_modules/@x402/mcp/node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/@x402/near": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@x402/near/-/near-2.25.0.tgz", - "integrity": "sha512-/zVEDbRNB63bCRZkSiakjpX4pztJ/gLY9jCV8FAeSzqVtrhb1R4u5fdduDBC9LhQqPQCZSJQUoBH/j9iX6+L0g==", + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/@x402/near/-/near-2.30.0.tgz", + "integrity": "sha512-UyRBL8PQeNlQImwJ9j/6vKSV8ks7jTL2c8Xs+cnWmIXjVjBeYZ0N3tmdagAO2XgNJJQ04v30JvZODDNhI8DS0A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3564,25 +3797,26 @@ "@near-js/transactions": "^2.5.1", "@near-js/utils": "^2.5.1", "@noble/hashes": "^1.8.0", - "@x402/core": "~2.25.0", + "@x402/core": "~2.30.0", "borsh": "1.0.0" } }, "node_modules/@x402/svm": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@x402/svm/-/svm-2.25.0.tgz", - "integrity": "sha512-Xs0GbtmVGiDiFz6RAuJXh0DFRbbpfXqlZzZQjX2k7O78G4gX2+segX6Aw76u4wWax7Xx+HGUBj68YPuyfPQBnw==", + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/@x402/svm/-/svm-2.30.0.tgz", + "integrity": "sha512-Orh66DJYt737ry50m/5i1X6Z8FNcXVhy3g9C0dckVaebmXcUzugScN66E+m+KkogaKbok5mqQRi2ISc12j3mtg==", "dev": true, "license": "Apache-2.0", "dependencies": { "@noble/hashes": "^1.8.0", - "@solana-program/compute-budget": "^0.11.0", - "@solana-program/token": "^0.9.0", - "@solana-program/token-2022": "^0.6.1", - "@x402/core": "~2.25.0" + "@solana-program/compute-budget": "^0.18.0", + "@solana-program/token": "^0.16.0", + "@solana-program/token-2022": "^0.16.0", + "@solana/program-client-core": "^8.0.0", + "@x402/core": "~2.30.0" }, "peerDependencies": { - "@solana/kit": ">=5.1.0" + "@solana/kit": ">=8.0.0" } }, "node_modules/abitype": { diff --git a/package.json b/package.json index 199a6ef..69c6e55 100644 --- a/package.json +++ b/package.json @@ -123,11 +123,11 @@ "peerDependencies": { "@modelcontextprotocol/server": "2.3.0", "@sd-jwt/core": "0.20.1", - "@solana/kit": "5.5.1", - "@x402/core": "2.25.0", - "@x402/evm": "2.25.0", - "@x402/near": "2.25.0", - "@x402/svm": "2.25.0", + "@solana/kit": "8.6.0", + "@x402/core": "2.30.0", + "@x402/evm": "2.30.0", + "@x402/near": "2.30.0", + "@x402/svm": "2.30.0", "canonicalize": "5.0.0", "jose": "6.2.12", "mppx": "0.13.1", @@ -180,18 +180,18 @@ "@near-js/signers": "2.5.1", "@near-js/transactions": "2.5.1", "@sd-jwt/core": "0.20.1", - "@solana/kit": "5.5.1", + "@solana/kit": "8.6.0", "@types/better-sqlite3": "9.6.0", "@types/node": "24.13.3", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "@vitejs/plugin-react": "6.0.5", "@vitest/coverage-v8": "4.1.12", - "@x402/core": "2.25.0", - "@x402/evm": "2.25.0", - "@x402/mcp": "2.25.0", - "@x402/near": "2.25.0", - "@x402/svm": "2.25.0", + "@x402/core": "2.30.0", + "@x402/evm": "2.30.0", + "@x402/mcp": "2.30.0", + "@x402/near": "2.30.0", + "@x402/svm": "2.30.0", "canonicalize": "5.0.0", "jose": "6.2.12", "mppx": "0.13.1", @@ -208,12 +208,6 @@ "overrides": { "@sd-jwt/core": { "@owf/identity-common": "0.3.2" - }, - "@x402/core": { - "zod": "3.25.76" - }, - "@x402/evm": { - "zod": "3.25.76" } } } diff --git a/tests/unit/cli/packaging.test.ts b/tests/unit/cli/packaging.test.ts index 7056cbd..f35aeda 100644 --- a/tests/unit/cli/packaging.test.ts +++ b/tests/unit/cli/packaging.test.ts @@ -160,36 +160,22 @@ describe('published package metadata', () => { // and CI install with `npm ci`, which fails without package-lock.json. expect(existsSync(resolve(pkgRoot, 'package-lock.json'))).toBe(true); expect(existsSync(resolve(pkgRoot, 'pnpm-lock.yaml'))).toBe(false); - }); - - it('pins zod for the x402 packages, per package rather than repo-wide', () => { - // Two zod majors in one graph break `instanceof` across module - // boundaries, so every package that shares zod values with ours resolves - // to *our* zod. Not repo-wide: `@scalar/openapi-parser` and `mppx` need - // zod 4 and get their own nested copies, and only plain values cross those - // seams. - const overrides = manifest.overrides as Record | undefined; - expect(overrides?.['zod']).toBeUndefined(); - for (const pkg of ['@x402/core', '@x402/evm']) { - expect((overrides?.[pkg] as Record | undefined)?.['zod']).toBe( - manifest.dependencies?.['zod'], - ); - } expect(manifest.pnpm).toBeUndefined(); }); - it('pins every @x402 package to one version older than 2.26.0', () => { + it('pins every @x402 package to one version outside 2.26.0-2.28.0', () => { // Keep the @x402 pins aligned. The EVM facilitator in 2.26.0-2.28.0 can - // leave a failed `eth_getCode` promise unhandled, exiting Node. Raise the - // bound only for a release that fixes this + // leave a failed `eth_getCode` promise unhandled, exiting Node; 2.29.0 + // attaches a handler const versions = new Set( Object.entries({ ...manifest.peerDependencies, ...manifest.devDependencies }) .filter(([name]) => name.startsWith('@x402/')) .map(([, version]) => version), ); expect(versions.size).toBe(1); - const [major, minor] = ([...versions][0] as string).split('.').map(Number); - expect(major === 2 && (minor as number) < 26).toBe(true); + const [major, minor] = ([...versions][0] as string).split('.').map(Number) as [number, number]; + expect(major).toBe(2); + expect(minor < 26 || minor > 28).toBe(true); }); it('keeps the AP2 crypto libraries optional and exactly pinned', () => { diff --git a/tests/unit/cli/version-command.test.ts b/tests/unit/cli/version-command.test.ts index 556cb90..6901a3a 100644 --- a/tests/unit/cli/version-command.test.ts +++ b/tests/unit/cli/version-command.test.ts @@ -38,8 +38,8 @@ describe('runVersion', () => { expect(version, name).toBe(declared[name]); } expect(joined).toMatch(/@modelcontextprotocol\/server\s+2\.3\.0/); - expect(joined).toMatch(/@x402\/core\s+2\.25\.0/); - expect(joined).toMatch(/@x402\/evm\s+2\.25\.0/); + expect(joined).toMatch(/@x402\/core\s+2\.30\.0/); + expect(joined).toMatch(/@x402\/evm\s+2\.30\.0/); expect(joined).toMatch(/better-sqlite3\s+13\.0\.3/); }); diff --git a/tests/unit/payments-x402/payment-id.test.ts b/tests/unit/payments-x402/payment-id.test.ts index 2f3623d..5cfeb9e 100644 --- a/tests/unit/payments-x402/payment-id.test.ts +++ b/tests/unit/payments-x402/payment-id.test.ts @@ -30,7 +30,7 @@ const NONCE = `0x${'01'.repeat(32)}` as const; const PINNED_ID = 'pay_0123456789abcdef'; const KEY = 'payment-identifier'; -// declarePaymentIdentifierExtension(required) from @x402/extensions 2.25.0 +// declarePaymentIdentifierExtension(required) from @x402/extensions 2.30.0 function declared(required: boolean): Record { return { info: { required }, From ad4bd8beca35a78063ce71dfb323fb7521f166f5 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sun, 8 Nov 2026 00:53:38 +0100 Subject: [PATCH 20/20] build(deps): move to Zod 4.6.7 --- package-lock.json | 87 ++--------------------- package.json | 2 +- src/config/schema/raw.ts | 8 ++- src/protocols/a2a/message-mapping.ts | 9 ++- tests/unit/cli/skill.test.ts | 19 ++--- tests/unit/config/x402-options.test.ts | 2 +- tests/unit/payments-x402/manifest.test.ts | 2 +- 7 files changed, 26 insertions(+), 103 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8dad810..0d025ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "picocolors": "1.1.1", "pino": "10.3.1", "yaml": "2.9.0", - "zod": "3.25.76" + "zod": "4.6.7" }, "bin": { "agent-commerce": "dist/cli/index.js" @@ -1060,16 +1060,6 @@ "node": ">=20" } }, - "node_modules/@modelcontextprotocol/client/node_modules/zod": { - "version": "4.6.7", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", - "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@modelcontextprotocol/core": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.0.tgz", @@ -1083,16 +1073,6 @@ "node": ">=20" } }, - "node_modules/@modelcontextprotocol/core/node_modules/zod": { - "version": "4.6.7", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", - "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.0.tgz", @@ -1148,16 +1128,6 @@ "node": ">=20" } }, - "node_modules/@modelcontextprotocol/server/node_modules/zod": { - "version": "4.6.7", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", - "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -2189,15 +2159,6 @@ "node": "^18 || >=20" } }, - "node_modules/@scalar/types/node_modules/zod": { - "version": "4.6.7", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", - "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@scure/base": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", @@ -3730,16 +3691,6 @@ "zod": "^4.6.6" } }, - "node_modules/@x402/core/node_modules/zod": { - "version": "4.6.7", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", - "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@x402/evm": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.30.0.tgz", @@ -3752,16 +3703,6 @@ "zod": "^4.6.6" } }, - "node_modules/@x402/evm/node_modules/zod": { - "version": "4.6.7", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", - "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@x402/mcp": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/@x402/mcp/-/mcp-2.30.0.tgz", @@ -3774,16 +3715,6 @@ "zod": "^4.6.6" } }, - "node_modules/@x402/mcp/node_modules/zod": { - "version": "4.6.7", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", - "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@x402/near": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/@x402/near/-/near-2.30.0.tgz", @@ -5841,16 +5772,6 @@ "node": ">=22.12" } }, - "node_modules/mppx/node_modules/zod": { - "version": "4.6.7", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", - "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7633,9 +7554,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 69c6e55..d774424 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "picocolors": "1.1.1", "pino": "10.3.1", "yaml": "2.9.0", - "zod": "3.25.76" + "zod": "4.6.7" }, "peerDependencies": { "@modelcontextprotocol/server": "2.3.0", diff --git a/src/config/schema/raw.ts b/src/config/schema/raw.ts index 25592d4..adb0b95 100644 --- a/src/config/schema/raw.ts +++ b/src/config/schema/raw.ts @@ -1,5 +1,5 @@ // The Zod schemas for `config.yaml`: shape only, before any business rule -import { type ZodTypeAny, z } from 'zod'; +import { z } from 'zod'; import { X402_PAYMENT_FLOWS } from '../../payments/x402/payment-flow'; import { ACP_WELL_KNOWN_PATH } from '../../protocols/acp/constants'; @@ -142,7 +142,9 @@ const AcpIdempotencySchema = z // Operation names are checked against `ACP_CHECKOUT_OPERATIONS` in the // business pass, which names a missing or misspelled operation -const AcpCheckoutSchema = z.object({ operations: z.record(z.string().min(1)) }).strict(); +const AcpCheckoutSchema = z + .object({ operations: z.record(z.string(), z.string().min(1)) }) + .strict(); // Optional discovery metadata, omitted when not configured const AcpDiscoverySchema = z @@ -228,7 +230,7 @@ const PricingSchema = z.discriminatedUnion('type', [ .strict(), ]); -const JsonSchemaValueSchema: ZodTypeAny = z.record(z.string(), z.unknown()); +const JsonSchemaValueSchema = z.record(z.string(), z.unknown()); // Settlement network, payee and token, resolved by x402/options.ts. Network // and payee fields follow the SDK's `PaymentOption`; asset fields override defaults diff --git a/src/protocols/a2a/message-mapping.ts b/src/protocols/a2a/message-mapping.ts index 32d66fd..a387907 100644 --- a/src/protocols/a2a/message-mapping.ts +++ b/src/protocols/a2a/message-mapping.ts @@ -55,11 +55,10 @@ export interface A2aInvocation { } /** - * Shape only; every semantic rule is checked below, where the failure can name - * itself. Array elements stay `unknown`, checked after the length rules. Zod 3 - * builds one issue per failing element, so typed elements would let a body of - * many bad elements allocate an issue for each. Every array past its first - * element is refused anyway. + * Check shape here and semantic rules separately so errors identify the failed + * rule. Keep array elements `unknown` until length checks run. Typed elements + * could allocate one Zod issue per bad entry in a large array, although only + * one part and no task references are supported. */ const MessageSchema = z.object({ role: z.string(), diff --git a/tests/unit/cli/skill.test.ts b/tests/unit/cli/skill.test.ts index 4e228c4..b8b05ce 100644 --- a/tests/unit/cli/skill.test.ts +++ b/tests/unit/cli/skill.test.ts @@ -63,24 +63,25 @@ function merge(base: unknown, patch: unknown): unknown { } // Every key path the config schema accepts, with `*` for a record key -function schemaPaths(schema: z.ZodTypeAny, prefix: string[] = [], out = new Set()) { +function schemaPaths(schema: z.ZodType, prefix: string[] = [], out = new Set()) { if (prefix.length > 8) return out; if (prefix.length > 0) out.add(prefix.join('.')); - const def = schema._def as Record; - const inner = [def['innerType'], def['schema'], def['type'], def['in'], def['out']]; + // Optional, nullable and default wrappers keep their schema in `innerType`, pipes in `in` and `out` + const def = schema._def as unknown as Record; + const inner = [def['innerType'], def['in'], def['out']]; if (schema instanceof z.ZodObject) { - for (const [key, value] of Object.entries(schema.shape as Record)) { + for (const [key, value] of Object.entries(schema.shape as Record)) { schemaPaths(value, [...prefix, key], out); } } else if (schema instanceof z.ZodRecord) { - schemaPaths(schema.valueSchema as z.ZodTypeAny, [...prefix, '*'], out); + schemaPaths(schema.valueType as z.ZodType, [...prefix, '*'], out); } else if (schema instanceof z.ZodUnion || schema instanceof z.ZodDiscriminatedUnion) { - for (const option of schema.options as z.ZodTypeAny[]) schemaPaths(option, prefix, out); + for (const option of schema.options as z.ZodType[]) schemaPaths(option, prefix, out); } else if (schema instanceof z.ZodIntersection) { - schemaPaths(schema._def.left, prefix, out); - schemaPaths(schema._def.right, prefix, out); + schemaPaths(schema._def.left as z.ZodType, prefix, out); + schemaPaths(schema._def.right as z.ZodType, prefix, out); } else if (schema instanceof z.ZodLazy) { - schemaPaths(schema.schema, prefix, out); + schemaPaths(schema.unwrap() as z.ZodType, prefix, out); } else if (!(schema instanceof z.ZodArray)) { for (const next of inner) if (next instanceof z.ZodType) schemaPaths(next, prefix, out); } diff --git a/tests/unit/config/x402-options.test.ts b/tests/unit/config/x402-options.test.ts index 7a7e685..1aafbc6 100644 --- a/tests/unit/config/x402-options.test.ts +++ b/tests/unit/config/x402-options.test.ts @@ -73,7 +73,7 @@ describe('payments.x402 forms', () => { expect(refusal(withX402({ ...operational(), options: [] })).message).toContain('is empty'); // A schema refusal names its path in the message expect(refusal(withX402({ ...operational(), options: MINIMAL })).message).toContain( - '"payments.x402.options": Expected array', + '"payments.x402.options": Invalid input: expected array', ); }); diff --git a/tests/unit/payments-x402/manifest.test.ts b/tests/unit/payments-x402/manifest.test.ts index 56610e6..449b5e8 100644 --- a/tests/unit/payments-x402/manifest.test.ts +++ b/tests/unit/payments-x402/manifest.test.ts @@ -64,7 +64,7 @@ describe('readLocalChainManifest', () => { it('throws when the manifest is not an object', () => { const cwd = tmpCwd(); writeManifest(cwd, '[]'); - expect(() => readLocalChainManifest(cwd)).toThrow(/Expected object/); + expect(() => readLocalChainManifest(cwd)).toThrow(/expected object/); }); it('throws when chainId is missing or the wrong type', () => {