diff --git a/docs/configuration.md b/docs/configuration.md index bb8f103..bbe2c63 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -748,7 +748,8 @@ are `none`, `bearer` (a static token) and `cdp` (a fresh JWT per request). 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`. +unreadable secret stops `start` with an error naming `apiKeySecret`. PEM +values may contain literal `\n` escapes copied from CDP's JSON key file. Deployment mode - `local`, `testnet` or `mainnet` - comes from the network: `eip155:31337` is local, and every other network is the testnet or mainnet it @@ -836,9 +837,13 @@ 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`. +`challengeSecret` with length below 32 or a multi-line `realm` · a remote +facilitator `url` with a query string or fragment. -It exits non-zero on any of them. +Validation exits non-zero for these errors. It warns without failing if +`merchant.publicBaseUrl` is not an http(s) URL, or if +`redelivery.required: true` is set on a resource whose first enabled rail is not +x402. ## Environment diff --git a/docs/contracts.md b/docs/contracts.md index d2b2976..257dad4 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -468,7 +468,8 @@ persistence must not fail the commerce flow. - `GatewayOptions.authorizationProviders` and readiness reporting are additive. A failing authorization provider blocks readiness. `/ready` replaces its raw detail with `authorization-provider-unreachable`; thrown health errors are - logged at `error`, while returned details are logged at `debug`. + logged at `error`. Returned failures log at `warn` when the check first + fails or fails again after recovery; other returned details use `debug`. - The `./ap2` entry exports the AP2 provider and `createCheckoutJwt`. The signing helper runs in the merchant process; the gateway only verifies its result. It uses the same RFC 8785 input hash and rejects invalid keys or amounts before diff --git a/docs/payment-flow.md b/docs/payment-flow.md index 6053a72..a73e641 100644 --- a/docs/payment-flow.md +++ b/docs/payment-flow.md @@ -168,7 +168,7 @@ 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 +[`idempotency.redelivery`](configuration.md#resources) stores the 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 diff --git a/docs/security.md b/docs/security.md index 23617de..4f9054d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -32,8 +32,8 @@ request headers. The built-in logger redacts the `Authorization`, `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 +fields. Some call sites reduce caught errors with `describeError`. HTTP invoke, +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. @@ -221,9 +221,10 @@ implemented. ## Health details `GET /ready` exposes fixed status terms, not dependency messages. Returned -details are logged at `debug`. Store and provider health throws are logged at -`error`; adapter throws become failed health results whose details are logged -at `debug`. Only `fail` blocks readiness. Results are cached briefly and +failures log at `warn` when the check first fails or fails again after recovery; +other returned details use `debug`. Store and provider health throws are logged +at `error`; adapter throws become failed results and follow the logging rules +for returned results. Only `fail` blocks readiness. Results are cached briefly, and concurrent probes share one in-flight check. ## Denial of service @@ -316,256 +317,257 @@ 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) | -| **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") | +| 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, a facilitator URL with a query or fragment, or an RPC URL that is not http(s)** | `CONFIG_INVALID` at load; no message repeats the URL's path or query | `tests/unit/config/schema.test.ts`, `tests/unit/config/x402-options.test.ts` | +| **facilitator `auth.type: cdp` with an invalid `apiKeySecret`** | `CONFIG_INVALID` when `start` builds the provider, before the gateway listens or opens the receipt store; the message names `apiKeySecret`, not its value | `tests/unit/payments-x402/facilitator-cdp.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 and NEAR 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 diff --git a/plugins/agent-commerce-gateway/README.md b/plugins/agent-commerce-gateway/README.md index 6b46f21..2b5a6b1 100644 --- a/plugins/agent-commerce-gateway/README.md +++ b/plugins/agent-commerce-gateway/README.md @@ -62,7 +62,7 @@ party it belongs to: ## Requirements Node.js 22 or later, npm 10 or later, and `@devlab.group/agent-commerce` -2.2.0 or later. +2.7.0 or later. ## Install in Claude Code diff --git a/plugins/agent-commerce-gateway/skills/agent-commerce/SKILL.md b/plugins/agent-commerce-gateway/skills/agent-commerce/SKILL.md index 0a8001a..3db80fd 100644 --- a/plugins/agent-commerce-gateway/skills/agent-commerce/SKILL.md +++ b/plugins/agent-commerce-gateway/skills/agent-commerce/SKILL.md @@ -2,7 +2,7 @@ name: agent-commerce description: Put the Agent Commerce Gateway (npm @devlab.group/agent-commerce) in front of an existing HTTP API so AI agents can call and pay for its endpoints over HTTP or MCP, with x402 stablecoin payments. Use when adding agent payments, x402 or paid MCP tools to an API, when configuring, validating or starting the gateway, or when fixing errors from its validate, doctor or start commands. license: Apache-2.0 -compatibility: Node.js >= 22 and npm >= 10; @devlab.group/agent-commerce >= 2.2.0. Needs a shell that can run npm and npx. +compatibility: Node.js >= 22 and npm >= 10; @devlab.group/agent-commerce >= 2.7.0. Needs a shell that can run npm and npx. metadata: package: "@devlab.group/agent-commerce" repository: https://github.com/devlab-group/agent-commerce @@ -57,7 +57,7 @@ npm --version # 10 or later mkdir agent-commerce && cd agent-commerce npm init -y npm install @devlab.group/agent-commerce -npx --no -- agent-commerce --version # below 2.2.0: stop and propose an upgrade +npx --no -- agent-commerce --version # below 2.7.0: stop and propose an upgrade npx --no -- agent-commerce start --help ``` diff --git a/src/cli/commands/doctor/payments.ts b/src/cli/commands/doctor/payments.ts index b2d8f39..51ce656 100644 --- a/src/cli/commands/doctor/payments.ts +++ b/src/cli/commands/doctor/payments.ts @@ -284,7 +284,7 @@ function railOffer(option: { }; } -// Discovery exposes offer statuses; reasons stay in gateway debug logs +// Discovery exposes offer statuses; reasons stay in gateway logs function offerHealthCheck( live: RailTerms, named: (network: string) => string, diff --git a/src/config/schema/normalize.ts b/src/config/schema/normalize.ts index 352d897..87a89bd 100644 --- a/src/config/schema/normalize.ts +++ b/src/config/schema/normalize.ts @@ -3,7 +3,7 @@ import { CommerceError } from '../../core'; import { normalizeAcp, validateAcpCheckoutMapping } from './acp'; import { normalizeAp2, validateLegacyStoresIsolated } from './ap2'; import { normalizeMpp } from './mpp'; -import { type RawConfig, SUPPORTED_CONFIG_VERSION } from './raw'; +import { isHttpUrl, type RawConfig, SUPPORTED_CONFIG_VERSION } from './raw'; import { normalizeResource } from './resources'; import type { GatewayConfig, NormalizedProtocols } from './types'; import { toBoolean, toNumber } from './values'; @@ -78,6 +78,13 @@ export function normalize(raw: RawConfig): GatewayConfig { ); } + // Preserve 2.x compatibility: warn about invalid URLs instead of rejecting them + if (!isHttpUrl(raw.merchant.publicBaseUrl)) { + console.warn( + `[agent-commerce] merchant.publicBaseUrl ${JSON.stringify(raw.merchant.publicBaseUrl)} is not an http:// or https:// URL. The Host check may then refuse every caller but loopback, and the A2A Agent Card and ACP discovery publish broken URLs.`, + ); + } + return { version: SUPPORTED_CONFIG_VERSION, merchant: { diff --git a/src/config/schema/raw.ts b/src/config/schema/raw.ts index adb0b95..9a5a918 100644 --- a/src/config/schema/raw.ts +++ b/src/config/schema/raw.ts @@ -24,7 +24,7 @@ const RpcUrl = z .min(1) .refine(isHttpUrl, { message: 'must be an http:// or https:// URL' }); -function isHttpUrl(value: string): boolean { +export function isHttpUrl(value: string): boolean { try { const { protocol } = new URL(value); return protocol === 'http:' || protocol === 'https:'; diff --git a/src/config/schema/resources.ts b/src/config/schema/resources.ts index a7e9d43..1fce779 100644 --- a/src/config/schema/resources.ts +++ b/src/config/schema/resources.ts @@ -237,7 +237,13 @@ export function normalizeResource( ); } - const idempotency = normalizeResourceIdempotency(id, entry, paymentMethods); + // The gateway serves a resource on the first rail it lists that is enabled + const servingRail = paymentMethods.find( + (method) => + (method === 'x402' && x402?.config.enabled === true) || + (method === 'mpp' && mpp?.enabled === true), + ); + const idempotency = normalizeResourceIdempotency(id, entry, paymentMethods, servingRail); const pricing: Pricing = entry.pricing.type === 'free' @@ -283,6 +289,7 @@ function normalizeResourceIdempotency( id: string, entry: RawResourceEntry, paymentMethods: readonly string[], + servingRail: string | undefined, ): CommerceResource['idempotency'] { const raw = entry.idempotency; if (raw === undefined) return undefined; @@ -307,6 +314,12 @@ function normalizeResourceIdempotency( `${path}.redelivery.required`, ); } + // A warning, not a refusal: 2.7.0 loads this config + if (required && servingRail !== undefined && servingRail !== 'x402') { + console.warn( + `[agent-commerce] Resource "${id}" sets idempotency.redelivery.required, which applies to x402 only, but the gateway serves it on ${servingRail}, the first enabled rail in its payments. The setting has no effect; list "x402" first or remove it.`, + ); + } redelivery = { retentionHours: toNumber( raw.redelivery.retentionHours ?? DEFAULT_REDELIVERY_RETENTION_HOURS, diff --git a/src/core/interfaces/store.ts b/src/core/interfaces/store.ts index 180b672..aa43535 100644 --- a/src/core/interfaces/store.ts +++ b/src/core/interfaces/store.ts @@ -223,7 +223,7 @@ export interface ReceiptStore { * methods or none. Experimental: may change in a minor release. * * Atomically claim `scope` after deleting expired `completed` claims and - * expired `unresolved` claims on `PAYMENT_KEY_ENDPOINT_PREFIX` endpoints. + * expired `unresolved` claims on endpoints that start with `payment:`. * Expiry occurs at `expiresAt`; other claims do not expire. Return `reserved` * for a new scope, or `conflict` for a different `fingerprint` in any state. * diff --git a/src/gateway/readiness.ts b/src/gateway/readiness.ts index fcedfa1..2591419 100644 --- a/src/gateway/readiness.ts +++ b/src/gateway/readiness.ts @@ -91,16 +91,20 @@ interface Probed { readonly health: AdapterHealth; } +// Names of the checks that failed at their last evaluation +type FailingChecks = Set; + /** - * One probe for the store, the adapters and both provider kinds. A `health()` - * that throws counts as failing and is logged at error; a returned detail is - * logged at debug. + * Probe the store, an adapter or a provider. A thrown `health()` error fails + * the check and logs at error. Returned failures log at warn on the first + * failure or after recovery; other returned details log at debug. */ async function probe( target: { readonly name: string; health(): Promise }, kind: string, details: HealthDetails, options: Pick, + failing: FailingChecks, ): Promise { let health: AdapterHealth; let threw = false; @@ -112,7 +116,16 @@ async function probe( health = { status: 'fail', checkedAt: options.clock.nowIso() }; threw = true; } - if (health.detail !== undefined) { + const key = `${kind}:${target.name}`; + const startsFailing = health.status === 'fail' && !failing.has(key); + if (health.status === 'fail') failing.add(key); + else failing.delete(key); + if (startsFailing && !threw) { + options.logger.warn( + { name: target.name, ...(health.detail !== undefined ? { detail: health.detail } : {}) }, + `${kind} health check failed (detail not sent to the client)`, + ); + } else if (health.detail !== undefined) { options.logger.debug( { name: target.name, detail: health.detail }, `${kind} health detail (not sent to the client)`, @@ -141,25 +154,35 @@ export interface ProbedProvider { readonly health: AdapterHealth; } -async function evaluate(options: CheckReadinessOptions): Promise { +async function evaluate( + options: CheckReadinessOptions, + failing: FailingChecks, +): Promise { const [store, adapters, paymentProviders, authorizationProviders] = await Promise.all([ - probe({ name: 'store', health: () => options.store.health() }, 'store', STORE_DETAIL, options), + probe( + { name: 'store', health: () => options.store.health() }, + 'store', + STORE_DETAIL, + options, + failing, + ), Promise.all( options.adapterRuntimes.map(async (runtime) => ({ runtime, probed: await probe( - // getAdapterHealth turns a throw into a failed result, so an adapter - // failure is logged at debug as a detail rather than at error + // getAdapterHealth converts throws to failed results, which use + // the logging rules for returned results { name: runtime.adapter.name, health: () => getAdapterHealth(runtime, options.clock) }, 'adapter', ADAPTER_DETAIL, options, + failing, ), })), ), Promise.all( options.paymentProviders.map((provider) => - probe(provider, 'payment provider', PAYMENT_PROVIDER_DETAIL, options), + probe(provider, 'payment provider', PAYMENT_PROVIDER_DETAIL, options, failing), ), ), // An unusable authorization provider blocks readiness on the same threshold @@ -167,7 +190,7 @@ async function evaluate(options: CheckReadinessOptions): Promise { // without it, and serving the challenge anyway promises what we cannot honor Promise.all( options.authorizationProviders.map((provider) => - probe(provider, 'authorization provider', AUTHORIZATION_PROVIDER_DETAIL, options), + probe(provider, 'authorization provider', AUTHORIZATION_PROVIDER_DETAIL, options, failing), ), ), ]); @@ -214,13 +237,14 @@ export function createReadinessProbe( ): ReadinessProbe { let cached: { readonly evaluation: Evaluation; readonly at: number } | undefined; let inFlight: Promise | undefined; + const failing: FailingChecks = new Set(); const current = async (): Promise => { const now = options.clock.monotonicMs(); if (cached && now - cached.at < ttlMs) return cached.evaluation; if (inFlight) return inFlight; - inFlight = evaluate(options) + inFlight = evaluate(options, failing) .then((evaluation) => { cached = { evaluation, at: options.clock.monotonicMs() }; return evaluation; diff --git a/src/gateway/routes.ts b/src/gateway/routes.ts index 5f6fe06..ee38de5 100644 --- a/src/gateway/routes.ts +++ b/src/gateway/routes.ts @@ -28,6 +28,7 @@ import { toErrorEnvelope, toPaymentRequiredEnvelope, } from '../core'; +import { toLogInfo } from '../core/errors'; import { journalOf, resolveManually } from '../core/execution/reconcile'; import { isUsableIdempotencyKey } from '../core/idempotency-key'; import { isRecord } from '../core/is-record'; @@ -448,7 +449,16 @@ async function handleInvoke( } reply.status(outcome.backendStatus).send(outcome.body); } catch (error) { - sendInvokeError(reply, toCommerceError(error), paymentMethod); + const commerceError = toCommerceError(error); + // Preserve the cause chain omitted from responses. Log 5xx failures at warn + // and other errors at debug to limit noise from unauthenticated requests. + const entry = { resourceId, requestId: request.id, err: toLogInfo(commerceError) }; + if (commerceError.httpStatus >= 500) { + options.logger.warn(entry, 'http adapter: execution failed'); + } else { + options.logger.debug(entry, 'http adapter: execution failed'); + } + sendInvokeError(reply, commerceError, paymentMethod); } } diff --git a/src/gateway/run.ts b/src/gateway/run.ts index aa198fa..4c2cbf3 100644 --- a/src/gateway/run.ts +++ b/src/gateway/run.ts @@ -2,7 +2,7 @@ * Concrete startup shared by `main.ts` and `agent-commerce start`. * * Await enabled rail, AP2 and MCP imports before opening the receipt store, - * so missing startup peers leave no database behind. Solana, NEAR and CDP loads + * so missing startup peers leave no database behind. Solana and NEAR loads * begin during provider construction, but their errors surface in health * checks or requests. Store failures abort startup; adapter start failures * are isolated. `createGateway` accepts injected implementations without diff --git a/src/payments/x402/cdp-auth.ts b/src/payments/x402/cdp-auth.ts index a6ce9c7..0a71434 100644 --- a/src/payments/x402/cdp-auth.ts +++ b/src/payments/x402/cdp-auth.ts @@ -16,9 +16,8 @@ interface SigningKey { } /** - * 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. + * Create the SDK's path-keyed header factory. Validate the secret during + * construction and throw CONFIG_INVALID before returning a factory if it is invalid. */ export function createCdpAuthHeaders( apiKeyId: string, @@ -26,25 +25,39 @@ export function createCdpAuthHeaders( now: () => number = Date.now, ): () => Promise>> { const signingKey = readKey(apiKeySecret); + 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 { 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)}`, - }); + // Sign a route only on its first read from this header object + const header = (method: string, route: string) => { + let value: Record | undefined; + return () => + (value ??= { + Authorization: `Bearer ${signJwt(signingKey, apiKeyId, `${method} ${host}${pathname}/${route}`, issuedAt)}`, + }); + }; + const verify = header('POST', 'verify'); + const settle = header('POST', 'settle'); + const supported = header('GET', 'supported'); return { - verify: header('POST', 'verify'), - settle: header('POST', 'settle'), - supported: header('GET', 'supported'), + get verify() { + return verify(); + }, + get settle() { + return settle(); + }, + get supported() { + return supported(); + }, }; }; } @@ -52,7 +65,8 @@ export function createCdpAuthHeaders( function readKey(secret: string): SigningKey | undefined { try { if (secret.includes('-----BEGIN')) { - const key = createPrivateKey(secret); + // Expand literal newline escapes from CDP's JSON key file before parsing PEM + const key = createPrivateKey(secret.replace(/\\n/g, '\n')); const p256 = key.asymmetricKeyType === 'ec' && key.asymmetricKeyDetails?.namedCurve === 'prime256v1'; return p256 ? { alg: 'ES256', key } : undefined; diff --git a/src/payments/x402/facilitator.ts b/src/payments/x402/facilitator.ts index b9ebc99..8cc1ea5 100644 --- a/src/payments/x402/facilitator.ts +++ b/src/payments/x402/facilitator.ts @@ -135,8 +135,8 @@ export function createRemoteFacilitatorBinding( return { verify: headers, settle: headers, supported: headers }; }; } else if (auth.type === 'cdp') { - // CDP JWTs bind method, host and path. Unreadable secrets cause - // PAYMENT_PROVIDER_UNAVAILABLE during payment, rather than a payer rejection. + // CDP JWTs bind method, host and path. An unreadable secret throws + // CONFIG_INVALID here, before the gateway listens createAuthHeaders = createCdpAuthHeaders(auth.apiKeyId, auth.apiKeySecret); } diff --git a/src/payments/x402/guardrails.ts b/src/payments/x402/guardrails.ts index a37c002..b19c653 100644 --- a/src/payments/x402/guardrails.ts +++ b/src/payments/x402/guardrails.ts @@ -226,6 +226,14 @@ function assertFacilitatorUrlIsSafe(url: string, mode: DeploymentMode, at: strin details: { path: `${at}.facilitator.url` }, }); } + // The SDK appends `/verify` to the URL string, which would land the route + // inside a query or fragment. The raw test also catches an empty `?` or `#`. + if (/[?#]/.test(url)) { + throw invalid( + `${at}: facilitator ${describeOrigin(url)} has a query string or fragment in facilitator.url. The x402 client appends its routes to the URL, so they would land inside it. Remove it; send a credential with facilitator.auth instead.`, + `${at}.facilitator.url`, + ); + } if (parsed.protocol === 'https:') return; if (parsed.protocol !== 'http:') { @@ -253,9 +261,7 @@ function isCdpFacilitatorUrl(url: string): boolean { const parsed = new URL(url); const expected = new URL(CDP_FACILITATOR_URL); return ( - parsed.origin === expected.origin && - parsed.pathname.replace(/\/+$/, '') === expected.pathname && - parsed.search === '' + parsed.origin === expected.origin && parsed.pathname.replace(/\/+$/, '') === expected.pathname ); } catch { return false; diff --git a/src/payments/x402/svm.ts b/src/payments/x402/svm.ts index 42dd71c..a6f5b1f 100644 --- a/src/payments/x402/svm.ts +++ b/src/payments/x402/svm.ts @@ -11,10 +11,13 @@ import { address, getAddressEncoder, + getBase64Encoder, getCompiledTransactionMessageDecoder, getProgramDerivedAddress, + getTransactionDecoder, + type Transaction, } from '@solana/kit'; -import { decodeTransactionFromPayload, transactionMessageHash } from '@x402/svm'; +import { transactionMessageHash } from '@x402/svm'; import { isRecord } from '../../core/is-record'; import { LOCATOR_VERSION } from './locator'; import { computeSvmReplayKey } from './replay-key'; @@ -58,7 +61,7 @@ export function inspectSvmPayload( let messageHash: string; let recentBlockhash: string; try { - const decoded = decodeTransactionFromPayload({ transaction }); + const decoded = decodeTransaction(transaction); messageHash = transactionMessageHash(decoded); recentBlockhash = getCompiledTransactionMessageDecoder().decode( decoded.messageBytes, @@ -78,8 +81,14 @@ export function inspectSvmPayload( */ export function messageHashOf(transaction: string): string | undefined { try { - return transactionMessageHash(decodeTransactionFromPayload({ transaction })); + return transactionMessageHash(decodeTransaction(transaction)); } catch { return undefined; } } + +// Match decodeTransactionFromPayload without its failure logging, which exposes +// caller input and a stack trace through console.error +function decodeTransaction(base64: string): Transaction { + return getTransactionDecoder().decode(getBase64Encoder().encode(base64)); +} diff --git a/src/storage/receipts/store.ts b/src/storage/receipts/store.ts index 616708b..4830316 100644 --- a/src/storage/receipts/store.ts +++ b/src/storage/receipts/store.ts @@ -126,7 +126,7 @@ export interface SqliteReceiptStoreSummary { readonly orphanHolds: number; /** Unresolved claims whose endpoints do not start with `payment:` */ readonly unresolvedIdempotencyKeys: number; - /** Unresolved claims whose endpoints start with `payment:` */ + /** Unexpired unresolved claims whose endpoints start with `payment:` */ readonly unresolvedPaymentKeys: number; readonly unexpiredPendingTasks: number; readonly legacyImports: readonly SqliteLegacyImport[]; diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index 13ad6f1..838c7a5 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -647,11 +647,28 @@ describe('parseConfig', () => { expect(message).toContain('apiKeySecret is empty'); }); + // The SDK appends `/verify` to the URL string, so a query or fragment + // would swallow the route + it.each([ + ['a query string', 'https://facilitator.example.com/api?api_key=abc', 'none'], + ['an empty query', 'https://facilitator.example.com/api?', 'none'], + ['a fragment', 'https://facilitator.example.com/api#abc', 'none'], + ['a query on the CDP URL', 'https://api.cdp.coinbase.com/platform/v2/x402?tenant=abc', 'cdp'], + ['a fragment on the CDP URL', 'https://api.cdp.coinbase.com/platform/v2/x402#abc', 'cdp'], + ])('rejects a facilitator url with %s', (_label, url, type) => { + const auth = + type === 'cdp' ? { type, apiKeyId: 'key-id', apiKeySecret: 'key-secret' } : { type }; + const message = messageFor( + withX402({ payTo: MERCHANT, facilitator: { mode: 'remote', url, auth } }), + ); + expect(message).toContain('query string or fragment'); + expect(message).not.toContain('abc'); + }); + // 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'], - ['a query string', 'https://api.cdp.coinbase.com/platform/v2/x402?tenant=a'], ])('rejects CDP auth against %s', (_label, url) => { const message = messageFor( withX402({ @@ -666,7 +683,6 @@ describe('parseConfig', () => { expect(message).toContain( 'signs requests for https://api.cdp.coinbase.com/platform/v2/x402 only', ); - expect(message).not.toContain('tenant=a'); }); it('accepts CDP auth with a trailing slash on its URL', () => { @@ -1122,6 +1138,29 @@ describe('parseConfig', () => { } }); + // Preserve 2.x compatibility by warning about an invalid URL + it.each([ + ['https//shop.example', 1], + ['shop.example', 1], + ['ftp://shop.example', 1], + ['https://shop.example', 0], + ['http://localhost:8080', 0], + ])('merchant.publicBaseUrl %s warns %i time(s)', (publicBaseUrl, warnings) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const raw = validRawConfig(); + (raw['merchant'] as Record)['publicBaseUrl'] = publicBaseUrl; + + expect(parseConfig(raw, {}).merchant.publicBaseUrl).toBe(publicBaseUrl); + const calls = warn.mock.calls.filter(([message]) => + String(message).includes('merchant.publicBaseUrl'), + ); + expect(calls).toHaveLength(warnings); + } finally { + warn.mockRestore(); + } + }); + describe('unsupported schema keyword warning', () => { let warnSpy: ReturnType; beforeEach(() => { @@ -2659,9 +2698,44 @@ describe('resource idempotency', () => { expect(idempotency?.redelivery).toEqual({ retentionHours: 24, required: false }); }); - function mppResource(redelivery: unknown): Record { + // The flag applies to x402 only, and the gateway serves a resource on the + // first enabled rail it lists: an enabled MPP listed first leaves it unused + it.each([ + [['mpp', 'x402'], true, 1], + [['x402', 'mpp'], true, 0], + [['mpp', 'x402'], false, 0], + ])( + 'with payments %j and MPP enabled %s, warns %i time(s) that required has no effect', + (payments, mppEnabled, warnings) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const raw = mppResource({ required: true }, { keepX402: true }); + ((raw['payments'] as Record)['mpp'] as Record)[ + 'enabled' + ] = mppEnabled; + const resources = raw['resources'] as Record>; + (resources['market_report'] as Record)['payments'] = payments; + + expect(resource(raw, 'market_report')?.idempotency?.redelivery?.required).toBe(true); + const calls = warn.mock.calls.filter(([message]) => + String(message).includes('redelivery.required'), + ); + expect(calls.map(([message]) => String(message).includes('serves it on mpp'))).toEqual( + Array(warnings).fill(true), + ); + } finally { + warn.mockRestore(); + } + }, + ); + + function mppResource( + redelivery: unknown, + { keepX402 = false }: { keepX402?: boolean } = {}, + ): Record { const raw = validRawConfig(); raw['payments'] = { + ...(keepX402 ? (raw['payments'] as Record) : {}), mpp: { enabled: true, rpcUrl: 'https://sepolia.example/v2/key', diff --git a/tests/unit/gateway/readiness.test.ts b/tests/unit/gateway/readiness.test.ts index 0e6e0ce..4f4d8b2 100644 --- a/tests/unit/gateway/readiness.test.ts +++ b/tests/unit/gateway/readiness.test.ts @@ -125,3 +125,59 @@ describe('createReadinessProbe', () => { expect((await probe.check()).ready).toBe(false); }); }); + +describe('readiness logging', () => { + // At the default info level, warn explains a returned failure behind /ready's 503 + it('logs a failing detail at warn once per outage, and at debug while it lasts', async () => { + const lines: Array<{ level: string; entry: Record }> = []; + const record = + (level: string) => + (entry: Record): void => { + lines.push({ level, entry }); + }; + const logger: Logger = { + debug: record('debug'), + info: record('info'), + warn: record('warn'), + error: record('error'), + child: () => logger, + }; + let status: 'pass' | 'fail' = 'fail'; + const provider = { + ...createFakePaymentProvider(), + health: async () => ({ status, checkedAt: 'x', detail: 'rpc https://rpc.example timed out' }), + }; + const clock = createFakeClock(); + const probe = createReadinessProbe( + { + store: createFakeStore(), + adapterRuntimes: [], + paymentProviders: [provider], + authorizationProviders: [], + clock, + logger, + }, + 100, + ); + const warnings = () => + lines.filter( + ({ level, entry }) => + level === 'warn' && entry['detail'] === 'rpc https://rpc.example timed out', + ).length; + + expect((await probe.check()).ready).toBe(false); + clock.advance(101); + await probe.check(); + expect(warnings()).toBe(1); + expect(lines.filter(({ level }) => level === 'debug')).toHaveLength(1); + + // Recovery clears the failure marker, so the next failure warns again + status = 'pass'; + clock.advance(101); + expect((await probe.check()).ready).toBe(true); + status = 'fail'; + clock.advance(101); + await probe.check(); + expect(warnings()).toBe(2); + }); +}); diff --git a/tests/unit/gateway/server.test.ts b/tests/unit/gateway/server.test.ts index 9241948..5a8f5b5 100644 --- a/tests/unit/gateway/server.test.ts +++ b/tests/unit/gateway/server.test.ts @@ -955,6 +955,75 @@ describe('createGateway HTTP surface', () => { expect(res.headers['retry-after']).toBe('5'); }); + // Responses omit the cause chain; preserve it in HTTP logs + it('logs a 5xx invoke error with its cause at warn, and a 4xx at debug', async () => { + const { CommerceError } = await import('../../../src/core'); + const lines: Array<{ level: string; entry: Record }> = []; + const logger: Logger = { + debug: (entry) => lines.push({ level: 'debug', entry }), + info: (entry) => lines.push({ level: 'info', entry }), + warn: (entry) => lines.push({ level: 'warn', entry }), + error: (entry) => lines.push({ level: 'error', entry }), + child: () => logger, + }; + const provider = x402Provider({ + verify: async () => { + throw new CommerceError('PAYMENT_PROVIDER_UNAVAILABLE', 'no verdict', { + cause: new Error('Facilitator verify failed (401)'), + }); + }, + }); + const gateway = await buildGateway({ paymentProviders: [provider], logger }); + + expect((await invokePaid(gateway)).statusCode).toBe(503); + const failed = lines.filter(({ entry }) => entry['resourceId'] === 'market_report'); + expect(failed).toHaveLength(1); + expect(failed[0]?.level).toBe('warn'); + expect(failed[0]?.entry['err']).toMatchObject({ + code: 'PAYMENT_PROVIDER_UNAVAILABLE', + cause: ['Error: Facilitator verify failed (401)'], + }); + + const missing = await gateway.server.inject({ + method: 'POST', + url: '/api/resources/no_such_resource/invoke', + payload: {}, + }); + expect(missing.statusCode).toBe(404); + const notFound = lines.filter(({ entry }) => entry['resourceId'] === 'no_such_resource'); + expect(notFound.map(({ level }) => level)).toEqual(['debug']); + }); + + // The client sees only INTERNAL_ERROR; the log must retain the underlying error + it('logs an unexpected invoke error, a 500, at warn with the original error', async () => { + const lines: Array<{ level: string; entry: Record }> = []; + const logger: Logger = { + debug: (entry) => lines.push({ level: 'debug', entry }), + info: (entry) => lines.push({ level: 'info', entry }), + warn: (entry) => lines.push({ level: 'warn', entry }), + error: (entry) => lines.push({ level: 'error', entry }), + child: () => logger, + }; + const gateway = await buildGateway({ paymentProviders: [x402Provider()], logger }); + (gateway.pipeline as { execute: ExecutionPipeline['execute'] }).execute = async () => { + throw new TypeError('offer is undefined'); + }; + + const res = await gateway.server.inject({ + method: 'POST', + url: '/api/resources/market_report/invoke', + payload: {}, + }); + + expect(res.statusCode).toBe(500); + const failed = lines.filter(({ entry }) => entry['resourceId'] === 'market_report'); + expect(failed.map(({ level }) => level)).toEqual(['warn']); + expect(failed[0]?.entry['err']).toMatchObject({ + code: 'INTERNAL_ERROR', + cause: ['TypeError: offer is undefined'], + }); + }); + it('sends no Retry-After on a refused payment', async () => { const gateway = await buildGateway({ paymentProviders: [x402Provider()] }); const res = await gateway.server.inject({ diff --git a/tests/unit/payments-x402/facilitator-cdp.test.ts b/tests/unit/payments-x402/facilitator-cdp.test.ts index 6926ba1..4fa5830 100644 --- a/tests/unit/payments-x402/facilitator-cdp.test.ts +++ b/tests/unit/payments-x402/facilitator-cdp.test.ts @@ -1,17 +1,29 @@ /** - * 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. + * CDP API-key JWTs in `@coinbase/cdp-sdk`'s format. An invalid secret fails + * construction with CONFIG_INVALID, which stops `start` before it listens. */ 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 { afterEach, describe, expect, it, vi } from 'vitest'; import { isCommerceError } from '../../../src/core'; import { createCdpAuthHeaders } from '../../../src/payments/x402/cdp-auth'; import { createRemoteFacilitatorBinding } from '../../../src/payments/x402/facilitator'; +// Count signatures to verify that unread routes stay unsigned +const signatures = vi.hoisted(() => ({ count: 0 })); +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sign: (...args: Parameters) => { + signatures.count += 1; + return actual.sign(...args); + }, + }; +}); + const NOW_MS = 1_800_000_000_750; const NOW_S = 1_800_000_000; @@ -51,6 +63,13 @@ describe('CDP auth headers', () => { ec.publicKey, ], ['a base64 Ed25519 seed and public key', ED_SECRET, 'EdDSA', ed.publicKey], + // Simulate literal newline escapes copied from CDP's JSON key file + [ + 'a PEM EC key with JSON-escaped newlines', + EC_PKCS8.replace(/\n/g, '\\n'), + 'ES256', + ec.publicKey, + ], ] as const)( 'signs with %s a token CDP can verify', async (_, secret, alg, publicKey: KeyObject) => { @@ -95,6 +114,17 @@ describe('CDP auth headers', () => { ]); }); + // The SDK reads `verify` to tell a path-keyed object from a flat one, then + // the route it calls + it('signs only the routes that are read, each once', async () => { + const create = createCdpAuthHeaders('key-id', EC_PKCS8); + signatures.count = 0; + const headers = await create(); + + expect(bearer(headers['settle'])).toBe(bearer(headers['settle'])); + expect(signatures.count).toBe(1); + }); + it('gives every token a fresh nonce', async () => { const create = createCdpAuthHeaders('key-id', ED_SECRET); const first = await create(); @@ -122,19 +152,17 @@ describe('CDP auth headers', () => { .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); - }, - ); + ])('refuses %s as CONFIG_INVALID at construction', (_, secret) => { + let error: unknown; + try { + createCdpAuthHeaders('key-id', secret); + } catch (err) { + error = err; + } + expect(isCommerceError(error) && error.code).toBe('CONFIG_INVALID'); + expect(String((error as Error).message)).toContain('apiKeySecret'); + expect(String((error as Error).message)).not.toContain(secret); + }); }); describe('remote facilitator binding with auth.type cdp', () => { @@ -178,29 +206,22 @@ describe('remote facilitator binding with auth.type cdp', () => { ).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'); - - const error = await binding.supported().then( - () => undefined, - (err: unknown) => err, - ); - - expect(isCommerceError(error) && error.code).toBe('CONFIG_INVALID'); - expect(requests).toEqual([]); + it('refuses to build a binding whose secret is unreadable', () => { + expect(() => + createRemoteFacilitatorBinding({ + url: 'https://facilitator.example.com', + auth: { type: 'cdp', apiKeyId: 'key-id', apiKeySecret: 'key-secret' }, + }), + ).toThrow(expect.objectContaining({ code: 'CONFIG_INVALID' })); }); it('never puts the credential in anything the binding describes', () => { const binding = createRemoteFacilitatorBinding({ url: 'https://facilitator.example.com', - auth: { type: 'cdp', apiKeyId: 'key-id', apiKeySecret: 'super-secret' }, + auth: { type: 'cdp', apiKeyId: 'key-id', apiKeySecret: ED_SECRET }, }); // `describe` reaches logs, health details and doctor output - expect(binding.describe).not.toContain('super-secret'); + expect(binding.describe).not.toContain(ED_SECRET); expect(binding.describe).not.toContain('key-id'); expect(binding.describe).toContain('auth=cdp'); }); diff --git a/tests/unit/payments-x402/svm.test.ts b/tests/unit/payments-x402/svm.test.ts index 4b6d50b..3b096fa 100644 --- a/tests/unit/payments-x402/svm.test.ts +++ b/tests/unit/payments-x402/svm.test.ts @@ -1,8 +1,12 @@ import { generateKeyPairSigner } from '@solana/kit'; import { decodeTransactionFromPayload, transactionMessageHash } from '@x402/svm'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import { computeSvmReplayKey } from '../../../src/payments/x402/replay-key'; -import { associatedTokenAccount, inspectSvmPayload } from '../../../src/payments/x402/svm'; +import { + associatedTokenAccount, + inspectSvmPayload, + messageHashOf, +} from '../../../src/payments/x402/svm'; import { BLOCKHASH, buildSvmPayment, @@ -78,4 +82,17 @@ describe('inspectSvmPayload', () => { 'invalid_exact_svm_payload_transaction_could_not_be_decoded', ); }); + + // Keep malformed caller input off stderr to prevent forged log entries + it('keeps an undecodable transaction off the console', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const forged = '!!\n{"level":50,"msg":"FORGED"}\n'; + expect(keyOf(forged)).toBe('invalid_exact_svm_payload_transaction_could_not_be_decoded'); + expect(messageHashOf(forged)).toBeUndefined(); + expect(error).not.toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); });