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/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 e0adc37..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` @@ -341,7 +336,8 @@ prints the `unsupported` lists for A2A, ACP and AP2 when they are enabled. Minor releases may break experimental configurations, integrations or types: MPP, A2A, ACP, AP2, ERC-8409 quotes, `agent-commerce import openapi`, `PaymentProvider.reconcile`, the `ReceiptStore` methods and types added in -2.6.0, and the `./testing` entry. +2.6.0, the `idempotency` resource settings with the store and provider methods +added in 2.7.0, and the `./testing` entry. The journal's `Transaction` type, `ReceiptStore` journal methods and `/api/transactions` response shape are supported. The `transactionId` fields, @@ -365,6 +361,14 @@ differ from 2.0; see [Upgrading from 2.0 to 2.x](docs/migration-2.1.md). - **Replay-safe twice over.** The EIP-3009 or Permit2 nonce stops a double spend on-chain; the gateway additionally reserves a `replayKey` derived from the authorization before it settles anything. +- **Retry after a lost response (experimental, disabled by default).** With + `idempotency.redelivery`, buyers can retrieve a stored response without + another charge using the same wallet, input and x402 `payment-identifier` + `id` or MPP `Idempotency-Key`. Matching exact resends skip verification + until retention ends, an exception to fail closed. Responses are stored + unencrypted in the receipt database. `idempotency.merchantKey` sends a + derived `Idempotency-Key` to the merchant for paid operations. See + [Retry after a lost response](docs/payment-flow.md#retry-after-a-lost-response). - **On-chain settlement in CI.** The end-to-end test asserts the buyer's balance falls and the merchant's rises by exactly the price, with a real transaction hash in the receipt. A log line saying "payment successful" would not count. @@ -426,9 +430,9 @@ EIP-3009 authorization without ETH, and the facilitator pays gas. Because the authorization fixes the recipient, amount and chain, the facilitator cannot redirect the transfer. It can see authorizations and refuse service. -Remote facilitator auth supports `none`, `bearer` and `cdp`. `cdp` loads the -optional peer `@coinbase/x402`, which signs a fresh JWT per request; bearer auth -needs no extra peer. Other types are rejected at config load. +Remote facilitator auth supports `none`, `bearer` and `cdp`. CDP auth signs a +fresh JWT per request with the CDP API key. No auth mode needs an extra peer +package. Config load rejects other types. ### Base Sepolia @@ -671,7 +675,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). mainnet, Solana devnet and Solana mainnet, receipts, doctor, deterministic demo, experimental A2A v1.0.1 and ACP `2026-04-17` checkout adapters, experimental AP2 v0.2.0 mandate verification, experimental MPP `charge` -payments, and experimental OpenAPI import. +payments, experimental OpenAPI import, and experimental retries after a lost +response. **Next** - a `doctor` GitHub Action · UCP · autonomous-mode AP2 (open mandates, agent key binding, constraint evaluation) · 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/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 913426e..bb8f103 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,6 +83,64 @@ fits the body (see [security.md](security.md#backend-response-relay)); this setting does not change it. Config load accepts `mimeType` only on a fixed-price resource that lists `x402` in `payments`. +Enable `idempotency.merchantKey: true` to send an `Idempotency-Key` header with +paid backend calls. This setting is experimental and disabled by default. +The merchant decides how to handle repeated keys. + +The generated key hashes `merchant.publicBaseUrl`, the resource ID, a payment +identity and the canonical input digest with SHA-256. The identity defaults to +the payment's replay identity. With `redelivery`, requests that have both a +client retry key and a verified payer use the key's hash and payer instead. +The retry claim prevents concurrent purchases under that key, and re-signed +retries keep the same merchant key. With `merchantKey` alone, the client key +is ignored; a different replay identity produces a different merchant key. +Changing `merchant.publicBaseUrl` also changes generated keys. + +An adapter's operation key takes precedence over the generated key. Either +overrides an `Idempotency-Key` in `backend.headers`. Without a client retry +key, input with no canonical JSON digest, such as a string containing a lone +surrogate, receives no generated key and can still be delivered. Config load +accepts `idempotency` only on a fixed-price resource. + +For paid requests with a client retry key, `idempotency.redelivery` stores the +response for reuse with matching input, without another charge. It is +experimental and disabled by default. The x402 provider extracts the buyer's +`id` from the echoed `payment-identifier` extension (see +[x402.md](protocols/x402.md#payment-identifier)), and the HTTP invoke route +reads MPP's `Idempotency-Key` header (see +[mpp.md](protocols/mpp.md#idempotency-key)). Direct pipeline requests can set +`CanonicalRequest.retryKey`, which takes precedence over provider extraction. +Custom providers can implement `PaymentProvider.retryKey`. With redelivery +enabled, input without a canonical JSON digest fails with `INPUT_INVALID` +when a retry key is used. + +`retentionHours` is an integer from 1 to 8760 (one year), default 24. Config +load rejects other values. The window starts at the initial retry check, before +proof verification. Until it ends, a matching exact resend gets the stored +response without verification, even after the proof or challenge expires. A +verified fresh proof from the same payer can also retrieve it. After expiry, +retries follow the normal purchase flow. Stored responses are unencrypted in +the receipt database and its backups; expiry does not erase them immediately. +See [security.md](security.md#buyer-protection). `required` defaults to false. +Setting it to true requires `x402` in `payments` and makes x402 retry-key +extraction reject a missing `id` with `INPUT_INVALID` (HTTP 400). +[Retry after a lost response](payment-flow.md#retry-after-a-lost-response) +describes the rules. + +At startup, the gateway marks in-flight payment retry keys `unresolved`, even +if no resource still enables `redelivery`, and logs a nonzero count at WARN. +Recovery includes requests that crashed before settlement. An interrupted +request may have charged, so matching retries with `redelivery` enabled get +409 `payment_identifier_unresolved` until the key's retention deadline. After +expiry, retries follow the normal purchase flow, as with completed keys. +Reconciling or resolving the payment attempt does not change the key. `doctor` +reports unexpired unresolved keys as INFO. Run one gateway process per receipt +database. + +Removing `redelivery`, renaming a resource or changing `merchant.publicBaseUrl` +makes its stored responses and unresolved keys unavailable to new requests. +Reusing a key can then start a new purchase. + `url` supports `{param}` templating from validated input; values are URL-encoded. Remaining input becomes query string for `GET`/`DELETE` and a JSON body otherwise - unless `backend.inputBindings` says otherwise, below. @@ -687,9 +745,10 @@ facilitator: Omitting `auth` is equivalent to `type: none`: the gateway sends no credential. Use it only with a facilitator that accepts anonymous requests. Supported types are `none`, `bearer` (a static token) and `cdp` (a fresh JWT per request). -The `cdp` type requires the optional `@coinbase/x402` peer. It signs each -request for `https://api.cdp.coinbase.com/platform/v2/x402`, so config load -refuses `cdp` with any other `url`. +For `cdp`, `apiKeySecret` must be CDP's PEM EC P-256 private key or base64 +Ed25519 key. JWTs bind requests to CDP's host and path, so config load rejects +any `url` other than `https://api.cdp.coinbase.com/platform/v2/x402`. An +unreadable secret fails the health check with a detail naming `apiKeySecret`. Deployment mode - `local`, `testnet` or `mainnet` - comes from the network: `eip155:31337` is local, and every other network is the testnet or mainnet it @@ -773,8 +832,11 @@ resource for two operations, or maps a paid one · an ACP idempotency retention below 24 hours · an invalid or zero `payTo`/`asset`/`recipient` · an `x402.accepts` list that is empty or on a resource that cannot use it · a `mimeType` that is not a media type, or on a resource that is not fixed-price -with `x402` in `payments` · an MPP `challengeSecret` with length below 32 or a -multi-line `realm`. +with `x402` in `payments` · an `idempotency` block on a resource that is not +fixed-price, or with neither `merchantKey` nor `redelivery` · a +`redelivery.retentionHours` that is not an integer from 1 to 8760 · +`redelivery.required: true` on a resource without `x402` in `payments` · an MPP +`challengeSecret` with length below 32 or a multi-line `realm`. It exits non-zero on any of them. diff --git a/docs/contract-surface.txt b/docs/contract-surface.txt index d760299..b1d3943 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 { @@ -164,6 +165,7 @@ interface CommerceErrorOptions { readonly httpStatus?: number; readonly requestId?: string; readonly resourceId?: string; + readonly retryable?: boolean; } interface CommerceEvent { @@ -200,6 +202,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 +219,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 +230,7 @@ interface DeliverySummary { readonly payment?: { readonly status: "verified" | "settled" | "rejected"; readonly amount: DecimalAmount; readonly currency: string; readonly externalReference?: string; readonly network?: string; }; readonly receiptCommitted?: false; readonly receiptId: string; + readonly redelivered?: boolean; readonly requestId: string; readonly resourceId: string; } @@ -379,6 +384,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; @@ -503,13 +509,14 @@ 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; 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 +531,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..d2b2976 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -231,6 +231,74 @@ 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. +- 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. + - `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. + - `redelivery` sets a retention deadline `retentionHours` hours after the + 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` + 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 + facilitator. + - `ReceiptStore.getIdempotencyKey` reads a claim without writing. It returns + `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. + 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. + - 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. + 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 + 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. + 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 + 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. ### Protocols and gateway @@ -450,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. @@ -506,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 @@ -1065,6 +1136,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/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/migration-3.0.md b/docs/migration-3.0.md index e005a8d..865bec5 100644 --- a/docs/migration-3.0.md +++ b/docs/migration-3.0.md @@ -34,6 +34,18 @@ 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`, 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. +- `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: - `authorization.ap2.replay.path` and `protocols.acp.idempotency.path`. diff --git a/docs/payment-flow.md b/docs/payment-flow.md index 3655f68..6053a72 100644 --- a/docs/payment-flow.md +++ b/docs/payment-flow.md @@ -165,6 +165,78 @@ 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`). 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) +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.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/docs/protocols/a2a.md b/docs/protocols/a2a.md index 23248fb..2462668 100644 --- a/docs/protocols/a2a.md +++ b/docs/protocols/a2a.md @@ -126,6 +126,19 @@ 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. If the resource requires a mandate, the new +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. @@ -139,18 +152,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/ap2.md b/docs/protocols/ap2.md index 464be0a..c6490cb 100644 --- a/docs/protocols/ap2.md +++ b/docs/protocols/ap2.md @@ -60,6 +60,14 @@ 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. See +[retry after a lost response](../payment-flow.md#retry-after-a-lost-response). + ## Agent Commerce checkout profile AP2 leaves the checkout payload to the implementation. This gateway requires: diff --git a/docs/protocols/http.md b/docs/protocols/http.md index c0a698c..cb9ab86 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,17 @@ 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 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 2b6eb4d..5f031b4 100644 --- a/docs/protocols/mcp.md +++ b/docs/protocols/mcp.md @@ -97,6 +97,20 @@ 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. 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 exception messages and stack traces are omitted. diff --git a/docs/protocols/mpp.md b/docs/protocols/mpp.md index 5981627..1b06a19 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,41 @@ 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. 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 +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. 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 +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/protocols/x402.md b/docs/protocols/x402.md index 2c21d95..16f7aee 100644 --- a/docs/protocols/x402.md +++ b/docs/protocols/x402.md @@ -119,6 +119,49 @@ 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. An exact +resend of the proof skips verification while the response is retained, even +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 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 +reason `payment_identifier_invalid`; a missing ID with `required: true` fails +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 +supplies `CanonicalRequest.retryKey` directly. The facilitator receives no +extensions. + ## ERC-8409 signed quotes Experimental and disabled by default. With @@ -138,7 +181,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/docs/security.md b/docs/security.md index fa9c818..23617de 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. @@ -128,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 @@ -245,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 @@ -304,225 +316,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) | +| 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 %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") | 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 @@ -616,9 +659,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/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/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 8ff6df4..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" @@ -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", @@ -37,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", @@ -67,23 +66,19 @@ "npm": ">=10" }, "peerDependencies": { - "@coinbase/x402": "2.1.0", "@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", "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", @@ -1142,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", @@ -1165,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", @@ -1230,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", @@ -2271,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", @@ -2333,90 +2212,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/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": "^8.0.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==", + "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/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==", + "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": "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==", + "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": { - "@solana/kit": "^5.0", - "@solana/sysvars": "^5.0" + "@solana/kit": "^8.0.0", + "@solana/sysvars": "^8.0.0", + "@solana/zk-sdk": "^0.5.1" + }, + "peerDependenciesMeta": { + "@solana/zk-sdk": { + "optional": true + } } }, - "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-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": { - "@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" + "@noble/hashes": "1.8.0" }, "engines": { - "node": ">=20.18.0" + "node": "^14.21.3 || >=16" }, - "peerDependencies": { - "typescript": "^5.0.0" + "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" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "@solana/kit": "^8.0.0" } }, - "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/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": { @@ -2424,20 +2335,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": { @@ -2445,24 +2360,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": { @@ -2471,19 +2382,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": { @@ -2492,21 +2403,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": { @@ -2515,20 +2426,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": { @@ -2537,22 +2448,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": { @@ -2564,14 +2475,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" @@ -2580,7 +2491,7 @@ "node": ">=20.18.0" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -2588,27 +2499,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": { @@ -2617,16 +2540,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": { @@ -2635,24 +2558,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": { @@ -2661,20 +2584,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": { @@ -2683,23 +2606,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": { @@ -2708,40 +2632,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": { @@ -2750,16 +2705,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": { @@ -2768,26 +2723,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": { @@ -2796,23 +2751,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": { @@ -2821,16 +2776,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": { @@ -2839,20 +2852,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": { @@ -2861,16 +2874,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": { @@ -2879,27 +2892,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": { @@ -2908,29 +2921,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": { @@ -2939,16 +2952,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": { @@ -2957,20 +2970,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": { @@ -2979,16 +2993,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": { @@ -2997,29 +3014,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": { @@ -3028,25 +3045,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": { @@ -3055,23 +3072,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": { @@ -3079,23 +3096,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": { @@ -3104,23 +3143,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": { @@ -3129,22 +3168,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": { @@ -3153,31 +3192,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": { @@ -3186,27 +3226,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": { @@ -3215,19 +3256,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": { @@ -3236,22 +3278,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": { @@ -3260,28 +3304,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": "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-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.0.0" + "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": "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.4.0" }, "peerDependenciesMeta": { "typescript": { @@ -3290,27 +3362,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": { @@ -3319,30 +3391,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": { @@ -3610,43 +3682,43 @@ } }, "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/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/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/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": { @@ -3656,25 +3728,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": { @@ -3732,19 +3805,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 +3881,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 +3910,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 +3982,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 +4085,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 +4108,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 +4218,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 +4253,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 +4380,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 +4783,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 +4923,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 +5004,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 +5047,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 +5082,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 +5609,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", @@ -5944,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", @@ -6444,16 +6262,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 +7198,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", @@ -7753,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 8bc242a..d774424 100644 --- a/package.json +++ b/package.json @@ -118,26 +118,22 @@ "picocolors": "1.1.1", "pino": "10.3.1", "yaml": "2.9.0", - "zod": "3.25.76" + "zod": "4.6.7" }, "peerDependencies": { - "@coinbase/x402": "2.1.0", "@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", "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", @@ -185,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", @@ -211,19 +206,8 @@ "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" - }, - "@x402/core": { - "zod": "3.25.76" - }, - "@x402/evm": { - "zod": "3.25.76" } } } 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 685b886..8e536c6 100644 --- a/plugins/agent-commerce-gateway/skills/agent-commerce/references/config.md +++ b/plugins/agent-commerce-gateway/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 @@ -121,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/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/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 9062961..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 @@ -267,6 +269,19 @@ const ResourceEntrySchema = z .object({ required: z.array(z.string().min(1)).min(1) }) .strict() .optional(), + idempotency: z + .object({ + merchantKey: BooleanOrString.optional(), + redelivery: z + .object({ + retentionHours: NumberOrString.optional(), + required: BooleanOrString.optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), }) .strict(); @@ -277,8 +292,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/config/schema/resources.ts b/src/config/schema/resources.ts index cb0a7b8..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'; @@ -25,12 +26,13 @@ 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); const SUPPORTED_PAYMENT_METHODS: ReadonlySet = new Set(PAYMENT_METHOD_NAMES); +const DEFAULT_REDELIVERY_RETENTION_HOURS = 24; export function normalizeResource( id: string, @@ -235,6 +237,8 @@ export function normalizeResource( ); } + const idempotency = normalizeResourceIdempotency(id, entry, paymentMethods); + const pricing: Pricing = entry.pricing.type === 'free' ? { type: 'free' } @@ -271,6 +275,52 @@ export function normalizeResource( exposedVia: entry.expose as CommerceResource['exposedVia'], paymentMethods: paymentMethods as CommerceResource['paymentMethods'], ...(authorization !== undefined ? { authorization } : {}), + ...(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, max: MAX_REDELIVERY_RETENTION_HOURS }, + ), + required, + }; + } + return { + ...(raw.merchantKey !== undefined + ? { merchantKey: toBoolean(raw.merchantKey, `${path}.merchantKey`) } + : {}), + ...(redelivery !== undefined ? { redelivery } : {}), }; } 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..970efd5 100644 --- a/src/core/domain/resource.ts +++ b/src/core/domain/resource.ts @@ -84,6 +84,23 @@ export interface CommerceResource { readonly authorization?: { readonly required: readonly AuthorizationMethodName[]; }; + /** + * 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?: { + /** 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?: { + /** Hours from the initial retry check to the stored response's expiry */ + readonly retentionHours: number; + /** If true, reject a missing `id` during x402 retry-key extraction with HTTP 400 */ + readonly required: boolean; + }; + }; } /** Read-only view of every configured resource */ diff --git a/src/core/domain/store-records.ts b/src/core/domain/store-records.ts index f30a2c9..22dbca3 100644 --- a/src/core/domain/store-records.ts +++ b/src/core/domain/store-records.ts @@ -15,14 +15,23 @@ export interface IdempotencyScope { readonly key: string; } -/** Result of claiming an idempotency key. Experimental: may change in a minor release */ +/** + * 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. `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/domain/wire.ts b/src/core/domain/wire.ts index dd51a06..a53fe9f 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 */ @@ -195,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/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/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/core/execution/journal.ts b/src/core/execution/journal.ts index ac3169c..464d5a8 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 */ @@ -73,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]; @@ -81,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 new file mode 100644 index 0000000..ad0d654 --- /dev/null +++ b/src/core/execution/merchant-key.ts @@ -0,0 +1,38 @@ +// The `Idempotency-Key` that names a paid operation to the merchant +import { createHash } from 'node:crypto'; + +export interface PaidOperation { + /** `merchant.publicBaseUrl`, which outlives credential rotation */ + readonly deployment: string; + readonly resourceId: string; + readonly replayKey: string; + /** `inputDigest` of the validated input */ + readonly inputDigest: string | undefined; + /** + * 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; +} + +export function sha256Hex(text: string): string { + return createHash('sha256').update(text).digest('hex'); +} + +/** + * 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 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 { deployment, resourceId, replayKey, inputDigest: digest, clientKey, payer } = operation; + if (digest === undefined) return undefined; + 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..cb4aea7 100644 --- a/src/core/execution/pipeline/admission.ts +++ b/src/core/execution/pipeline/admission.ts @@ -3,14 +3,20 @@ 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'; +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'; 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 { @@ -24,20 +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) { + 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( @@ -51,14 +65,24 @@ 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, 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({ @@ -184,90 +208,124 @@ 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); - const hold = await authorizeAndReserve( - flow, - request, - resource, - authProviders, - requirement, - validInput, - ); + // 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, + 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, + digest, + hold !== undefined ? { ...hold.record, reservationId: hold.reservationId } : undefined, + ), + }); + // Require a matching transaction ID and recordTransactionStep before enabling + // journaling; legacy stores can then 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 = + support !== undefined && resource.idempotency?.merchantKey === true + ? paidOperationKey({ + deployment: support.deployment, + resourceId: resource.id, + replayKey, + inputDigest: digest, + ...(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, - }, - }; + 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 diff --git a/src/core/execution/pipeline/context.ts b/src/core/execution/pipeline/context.ts index c84e922..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; @@ -28,6 +29,11 @@ export interface CreateExecutionPipelineOptions { readonly logger: Logger; readonly clock: Clock; readonly ids: IdGenerator; + /** + * 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; } export type HoldAction = 'consume' | 'release' | 'markUncertain'; @@ -67,6 +73,10 @@ 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; + /** 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 1c07e56..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 @@ -182,13 +196,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) { @@ -303,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..0eba0b6 --- /dev/null +++ b/src/core/execution/pipeline/retry.ts @@ -0,0 +1,491 @@ +// The retry gate: a client key either unlocks a stored response or claims the purchase +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'; +import { + type IdempotencyClaim, + type IdempotencyScope, + type IdempotencyUpdate, + PAYMENT_KEY_ENDPOINT_PREFIX, +} from '../../domain/store-records'; +import { CommerceError, isCommerceError } 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; + /** Tags the claim; only this proof gets an answer before verification */ + readonly proofDigest: string; + readonly authorizationDigest?: string; +} + +export interface RetryGate { + /** + * 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, + 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, and any required authorization is + * the one recorded with it. + */ + 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 authorizationDigest?: string; + readonly payer?: string; + readonly contentType?: string; + readonly body: unknown; + 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; + +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 earlier request with this payment identifier may have been charged and has no stored response', + }, +} 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 invalid = (message: string) => + new CommerceError('CONFIG_INVALID', message, { + ...(requestId !== undefined ? { requestId } : {}), + resourceId: resource.id, + }); + const unsupported = (needs: string) => + 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 ( + !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 ?? (await providerRetryKey(flow, resource, provider, payment)); + if (clientKey === undefined) return undefined; + + const fingerprint = inputDigest(validInput); + if (fingerprint === undefined) { + 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 }, + ), + ); + } + + const { store } = support; + const { authorization } = request; + 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}`), + ...(authorization !== undefined + ? { authorizationDigest: sha256Hex(`${authorization.method}:${authorization.payload}`) } + : {}), + }; + + const existing = await reading(flow, resource, () => + store.getIdempotencyKey(ticket.scope, fingerprint), + ); + // 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; + } + + async function claim( + flow: Flow, + resource: CommerceResource, + provider: PaymentProvider, + ticket: RetryTicket, + verification: PaymentResult, + pipelineStart: number, + ): Promise { + const { scope, fingerprint, expiresAt, proofDigest } = ticket; + const claimed = await reading(flow, resource, () => + 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); + 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); + } + + 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, + ...(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 + ...(contentType !== undefined ? { contentType } : {}), + body: response.body ?? null, + receiptId: receipt.id, + }; + // 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; + 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. + 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', + resourceId: resource.id, + adapter: flow.request.protocol, + paymentProvider: provider.name, + status: 'error', + data: { reason: typeof reason === 'string' ? reason : error.code }, + }), + ); + if (error.requestId !== undefined) return error; + return new CommerceError(error.code, error.message, { + requestId: flow.request.requestId, + resourceId: error.resourceId ?? resource.id, + httpStatus: error.httpStatus, + retryable: error.retryable, + ...(error.details !== undefined ? { details: error.details } : {}), + ...(error.cause !== undefined ? { cause: error.cause } : {}), + }); + } + + // 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, + 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 original = receipt.transactionId; + await safeEmit( + buildEvent(flow, { + type: 'resource.delivered', + resourceId: resource.id, + adapter: request.protocol, + status: 'ok', + durationMs, + data: { + redelivered: true, + ...(original !== undefined ? { originalTransactionId: original } : {}), + }, + }), + ); + return { + kind: 'delivered', + requestId: request.requestId, + transactionId: flow.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 }; +} + +// 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, 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, + 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/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/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/core/interfaces/store.ts b/src/core/interfaces/store.ts index b301099..180b672 100644 --- a/src/core/interfaces/store.ts +++ b/src/core/interfaces/store.ts @@ -222,15 +222,20 @@ 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 + * 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; @@ -240,7 +245,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; + /** Match endpoints by a literal, case-sensitive prefix; omit to include all endpoints */ + readonly endpointPrefix?: string; + }): Promise; + /** + * Read a claim without writing. Return `undefined` if the claim is absent + * or expired under `claimIdempotencyKey`'s rule. 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..effa1b8 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`: a store can support claims without redelivery + response_redelivery: ['getIdempotencyKey'], } as const satisfies Record; export type StoreMethodGroup = keyof typeof STORE_METHOD_GROUPS; 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 24c2524..5f6fe06 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'; @@ -346,6 +347,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 +369,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 +423,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 +489,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 +503,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)); } + // 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)); + } // 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,17 +541,22 @@ function sendInvokeError( } const envelope = toErrorEnvelope(error); - if (paymentMethod === 'mpp' && MPP_PROBLEM_CODES.has(error.code)) { - // RFC 9457 `status` is the HTTP status, so it replaces the envelope's + if ( + paymentMethod === 'mpp' && + (MPP_PROBLEM_CODES.has(error.code) || reason?.startsWith('payment_identifier_') === true) + ) { + // 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; - const reason = typeof details?.['reason'] === 'string' ? details['reason'] : undefined; + // 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, @@ -544,6 +571,30 @@ function sendInvokeError( reply.status(status).send(envelope); } +// 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') { + 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', + { 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/gateway/server.ts b/src/gateway/server.ts index add0a87..a3b4f90 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: 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 { + 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> = { '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,6 +37,14 @@ 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 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 */ @@ -43,11 +53,15 @@ export function mppProblem( httpStatus: number, reason?: string, ): { readonly type: string; readonly title: string } { + 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] }; } -// 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 +97,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/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/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/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/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..2cd7b65 --- /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; + // A fresh declaration keeps a buyer's in-place ID update from reaching 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/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/src/protocols/a2a/payment-resume.ts b/src/protocols/a2a/payment-resume.ts index 37b214e..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,16 +166,20 @@ 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', ); } 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/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/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 b67cf26..bbfc10b 100644 --- a/src/storage/receipts/idempotency.ts +++ b/src/storage/receipts/idempotency.ts @@ -1,10 +1,14 @@ /** * 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. * - * 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. */ import type Database from 'better-sqlite3'; import { @@ -13,6 +17,8 @@ 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'; @@ -21,9 +27,17 @@ 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'>; + +/** + * 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, @@ -31,19 +45,39 @@ 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' }; + 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 }; +} + +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'; 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 - (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'", + // 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 @@ -53,48 +87,58 @@ 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 + // Sweep expired 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); + expireFinished.run({ now, prefix: PAYMENT_KEY_ENDPOINT_PREFIX }); const existing = selectClaim.get(scopeParams(scope)); if (existing === undefined) { insertClaim.run({ ...scopeParams(scope), fingerprint, + body_json: owner !== undefined ? JSON.stringify({ owner }) : null, created_at: now, expires_at: expiresAt, }); 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); }, ); 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'); } }, + async getIdempotencyKey(scope, fingerprint) { + try { + const existing = selectClaim.get(scopeParams(scope)); + if (existing === undefined) return undefined; + return hasExpired(existing, scope.endpoint, clock.nowIso()) + ? undefined + : claimOf(existing, fingerprint); + } catch (err) { + throw toCommerceError(err, 'STORAGE_ERROR', 'Failed to read idempotency key'); + } + }, + async updateIdempotencyKey(scope, update) { try { switch (update.kind) { @@ -117,9 +161,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..616708b 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,14 @@ 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'", + // Count only unexpired payment keys that can still block retries + const countUnresolvedKeysStmt = db.prepare< + { prefix: string; now: string }, + { payment: number; other: number } + >( + `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'`, ); const countLiveTasksStmt = db.prepare<[string], { count: number }>( 'SELECT COUNT(*) AS count FROM pending_tasks WHERE expires_at > ?', @@ -813,10 +822,15 @@ 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, + now, + }); 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 64a710f..14ffe29 100644 --- a/src/testing/conformance/record-cases.ts +++ b/src/testing/conformance/record-cases.ts @@ -7,10 +7,12 @@ 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'; 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 +21,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 +168,111 @@ 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 checkout claims 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: '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, + 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, @@ -200,6 +309,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/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/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([]); + }); +}); 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/merchant-idempotency.test.ts b/tests/integration/merchant-idempotency.test.ts new file mode 100644 index 0000000..fadd91f --- /dev/null +++ b/tests/integration/merchant-idempotency.test.ts @@ -0,0 +1,189 @@ +/** + * Check paid backend requests through config parsing, the gateway, SQLite and + * the HTTP executor. A fake payment provider keeps these tests independent of + * a chain. + */ +import { createServer, type IncomingHttpHeaders, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; + +// Keep test output quiet and avoid spawning a pino-pretty worker thread +process.env['NODE_ENV'] = 'test'; + +import { parseConfig } from '../../src/config'; +import { PAYMENT_HEADER } from '../../src/core'; +import { createGateway, type GatewayInstance } from '../../src/gateway'; +import { createSqliteReceiptStore } from '../../src/storage/receipts'; +import { createFakePaymentProvider } from '../unit/core/execution/helpers'; + +// sha256sum of ["paid-operation","https://gw.example","report","replay-valid-proof","sha256:"] +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/integration/payment-retries.test.ts b/tests/integration/payment-retries.test.ts new file mode 100644 index 0000000..b2a3ccd --- /dev/null +++ b/tests/integration/payment-retries.test.ts @@ -0,0 +1,1324 @@ +/** + * 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 { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +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, + DELIVERY_SUMMARY_META_KEY, + type Logger, + PAYMENT_HEADER, + PAYMENT_INPUT_FIELD, + PAYMENT_REQUIRED_HEADER, + PAYMENT_RESPONSE_HEADER, + type ProtocolAdapter, + type ReceiptStore, +} 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 { 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 { createAcpAdapter } from '../../src/protocols/acp'; +import { createMcpAdapter } from '../../src/protocols/mcp'; +import { createSqliteReceiptStore, type SqliteReceiptStore } 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', 'mcp', 'a2a'], + 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; +let dir: string | undefined; +let fileStores: SqliteReceiptStore[] = []; +let stopped: { readonly release: () => void; readonly request: Promise } | undefined; + +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 () => { + // 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; +}); + +const BASE_URL = 'https://gw.example'; + +function config(resources: readonly CommerceResource[], publicBaseUrl = BASE_URL): GatewayConfig { + return { + version: 1, + merchant: { id: 'shop', name: 'Shop', publicBaseUrl }, + server: { port: 0, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { + http: { enabled: true }, + mcp: { enabled: true, mountPath: '/mcp' }, + a2a: { enabled: true, mountPath: '/a2a' }, + acp: { enabled: false, mountPath: '/acp' }, + }, + resources: [...resources], + payments: {}, + }; +} + +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, options.publicBaseUrl), + store: options.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: [createMcpAdapter(), createA2aAdapter(), ...(options.adapters ?? [])], + backend, + ...(options.logger !== undefined ? { logger: options.logger } : {}), + }); + 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 from the HTTP route +async function x402Proof(resourceId: string, id?: string, body = '{}'): Promise { + 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' + ] as { info: object }; + return createPaymentProof({ + buyerPrivateKey: BUYER_KEY, + accepts, + ...(id !== undefined + ? { extensions: { 'payment-identifier': { ...declared, info: { ...declared.info, id } } } } + : {}), + }); +} + +// 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); + 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: {} })); +} + +// Hold the backend until the returned function runs +function holdBackend(): () => void { + let release = () => {}; + hold = new Promise((resolve) => { + release = resolve; + }); + 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); + + 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([ + ['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.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, + }); + + const conflict = await invoke('mpp_report', { + authorization: await mppCredential('mpp_report', otherBuyer), + 'idempotency-key': KEY, + }); + + // 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: 'Unprocessable Content', + status: 422, + 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 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'); + 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'); + }); +}); + +// 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; + // Protocol-specific retry flags and details, keyed by refusal reason + readonly refusalOverrides?: Record>; +} + +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', + { + // 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) { + 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, original) => { + const row = { ...original, ...surface.refusalOverrides?.[original.reason] }; + 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); + }); +}); + +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 9a44c02..291ff61 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: [], @@ -266,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 () => { @@ -308,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/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/cli/packaging.test.ts b/tests/unit/cli/packaging.test.ts index 9f858dc..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', '@coinbase/x402']) { - 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', () => { @@ -225,7 +211,6 @@ describe('published package metadata', () => { expect(exportsField?.[subpath]).toBeDefined(); } for (const peer of [ - '@coinbase/x402', '@modelcontextprotocol/server', '@sd-jwt/core', '@solana/kit', @@ -642,11 +627,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 +639,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/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/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/config/schema.test.ts b/tests/unit/config/schema.test.ts index 7150c19..13ad6f1 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, @@ -646,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'], @@ -2516,3 +2517,170 @@ 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('refuses an empty block, which does nothing', () => { + expectConfigInvalid( + () => parseConfig(withIdempotency('market_report', {}), {}), + 'resources.market_report.idempotency', + '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('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 } }), {}), + `${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/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/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/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/merchant-key.test.ts b/tests/unit/core/execution/merchant-key.test.ts new file mode 100644 index 0000000..5e02373 --- /dev/null +++ b/tests/unit/core/execution/merchant-key.test.ts @@ -0,0 +1,73 @@ +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. +// 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', + inputDigest: inputDigest({ 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', { inputDigest: inputDigest({ 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, inputDigest: inputDigest({ 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); + }, + ); +}); 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..2911277 --- /dev/null +++ b/tests/unit/core/execution/pipeline-retry.test.ts @@ -0,0 +1,1563 @@ +/** + * 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 proofDigest = (payload: string) => + createHash('sha256').update(`x402:${payload}`).digest('hex'); + +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, events } = setup({ provider: { retryKey } }); + const request = paid('alice/1', { retryKey: undefined as never }); + + const error = await refusal(pipeline.execute(request)); + + 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, events } = setup(); + const request = paid('alice/1', { input: { text: '\uD800' } }); + + const error = await refusal(pipeline.execute(request)); + + 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', + }); + }); +}); + +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.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 and transaction ids 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, + 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', + }); + 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; + + 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('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())); + + 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 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/2', { input: { city: 'Oslo' } }))); + + expect(error).toMatchObject({ code: 'PAYMENT_REPLAYED', httpStatus: 409 }); + expect(error.details).toEqual({ reason: 'payment_identifier_conflict' }); + 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); + }); +}); + +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 + 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 }); + + 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(); + 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', + 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, verifying only a fresh proof', 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 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(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 () => { + 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('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 } } }, + }); + 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', owner: PROOF_DIGEST }, + ], + [ + 'settlement uncertain after the backend', + { + settleAfterBackend: true, + provider: { + settle: async () => { + throw new Error('facilitator lost the response'); + }, + }, + }, + 'PAYMENT_SETTLEMENT_FAILED', + { kind: 'unresolved', owner: PROOF_DIGEST }, + ], + [ + 'backend failed after an upfront settlement', + { + backend: async () => { + throw new CommerceError('BACKEND_ERROR', 'merchant returned 500'); + }, + }, + 'BACKEND_ERROR', + { 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); + + 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', owner: PROOF_DIGEST }); + }); + + 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', owner: PROOF_DIGEST }); + 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', owner: PROOF_DIGEST }); + }); + + 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', owner: PROOF_DIGEST }); + 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 }], + ['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, 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 + 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' }); + // Verify first when the stored row cannot identify its owning proof + expect(calls).toMatchObject({ createRequirement: 1, verify: 1, 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, 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.transactionId).toBe(events.at(-1)?.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.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); + + 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())); + }); +}); 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/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) { diff --git a/tests/unit/payments-mpp/problems.test.ts b/tests/unit/payments-mpp/problems.test.ts index bb4e975..10cd674 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,36 @@ 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('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', + ); + }); + it('titles the unpaid challenge Payment Required', () => { expect(mppProblem('PAYMENT_REQUIRED', 402).title).toBe('Payment Required'); }); @@ -44,6 +75,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.', 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 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', () => { 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..5cfeb9e --- /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.30.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 }, + }); + }); +}); 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(); + }); +}); 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); + }); +}); 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' }, 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'], 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 0e24a61..23a6f28 100644 --- a/tests/unit/storage-receipts/conformance.test.ts +++ b/tests/unit/storage-receipts/conformance.test.ts @@ -97,4 +97,23 @@ 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 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', + ]); + 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', diff --git a/tests/unit/storage-receipts/summary.test.ts b/tests/unit/storage-receipts/summary.test.ts index f69bb6f..ed47a40 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,45 @@ 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('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(