Skip to content

Latest commit

 

History

History
533 lines (463 loc) · 98.1 KB

security.md

File metadata and controls

533 lines (463 loc) · 98.1 KB

Security model

SECURITY.md explains how to report a vulnerability. This page describes the gateway's trust boundaries, controls, and known limits.

Trust boundaries

       UNTRUSTED                  GATEWAY PROCESS                       TRUSTED
  ────────────────────       ──────────────────────────       ─────────────────────────

  agent input          ────► validates agent input      ────► merchant backend
  payment proofs             may hold a local                 administrator configured
  authorization proofs       facilitator gas key
  protocol traffic

                             configuration              ◄──── administrator
                             environment                ◄──── operator

Agent input is untrusted. Configuration and environment variables are trusted operator input, so only they may select backend URLs. The payment flow needs no buyer or merchant key; local facilitator mode holds a separate key that pays gas and broadcasts transactions.

Secret handling

The default request log records the method, the URL with any query string replaced by ?[REDACTED], the host, and the remote address and port, but no request headers. The built-in logger redacts the Authorization, PAYMENT-SIGNATURE and Agent-Authorization request headers and the privateKey, signerPrivateKey, signature, seed, mnemonic, secret, challengeSecret, apiKey, adminToken and token fields. Some call sites reduce caught errors with describeError. MCP, A2A and ACP log toLogInfo(): the CommerceError.toInfo() fields, which can include details, plus the cause chain with every URL cut to its scheme and host. The rest of each cause's message is logged as thrown.

Logger field redaction covers the top level and one nested level. It does not cover a secret at a.b.privateKey, so do not log raw nested objects that may contain credentials. Do not put secrets in CommerceError details. Receipt redaction is recursive, and the pipeline omits raw payment and authorization proofs from receipts. Custom code that writes receipt or event metadata must keep them out too.

Environment-substitution errors name the template token and path, never its resolved value. Later validation errors may echo substituted non-secret values, such as URLs, so keep secrets in fields designated for credentials.

Authorization trust (AP2)

AP2 uses operator-configured public keys; it performs no JWKS, issuer metadata, revocation, jku, or x5u lookup. Removing a key from configuration and restarting the process revokes it. See AP2 trust and rotation.

Local policy fixes verification to ES256 over P-256. The presented iss and kid select one configured key. If iss is absent, kid selects the issuer; a kid shared by two mandate issuers is refused. The verifier does not try other keys after selecting one.

Replay identity uses the issuer-signed token. Checkout binding hashes the compact checkout JWT as presented. Neither value is reconstructed from parsed JSON before hashing.

SSRF

The built-in HTTP executor calls only configured backend URLs:

  • backend URLs are administrator-controlled configuration only;
  • request input can replace {param} tokens in the configured path or query. Tokens in the scheme, host, or port are rejected at config load, and substituted values are URL-encoded;
  • without inputBindings, remaining input becomes query parameters for GET and DELETE, or the JSON body for POST, PUT and PATCH. Bindings instead select the path, query and body groups;
  • redirects are not followed (redirect: 'manual'); a 3xx is a BACKEND_ERROR;
  • every call has a timeout and a 1 MiB response-body cap.

Empty path values are rejected. Values . and .. are also rejected because URL parsing would normalize them and could remove configured path segments. After substitution, the path must still start with the template's literal prefix. Request input also cannot replace a query parameter already fixed in backend.url; collisions fail as INPUT_INVALID.

There is no IP/CIDR allowlist or private-address blocklist. A configured URL such as http://169.254.169.254/… will be called, so configuration is privileged.

The OpenAPI importer gets backend URLs from the operator's document or --base-url. It rejects relative or unresolved server URLs and makes no network requests. Review its generated configuration before use.

Backend response relay

On a non-2xx response, the executor returns the backend status but not its body. It logs at most 512 body characters at debug; default log settings may hide that detail. This prevents stack traces, internal hosts, and similar backend details from reaching the client.

On POST /api/resources/:id/invoke, a delivery keeps the backend's Content-Type when that is a text type (text/*, JSON, XML or JavaScript) that fits the relayed body: a JSON type for a parsed JSON body, any other for text. The header then says charset=utf-8, because the gateway relays text as UTF-8. Otherwise JSON goes out as application/json and text as text/plain. Binary responses are not supported: the executor decodes every body as UTF-8, so a binary type is never relayed. Every text delivery also carries Content-Security-Policy: sandbox and X-Content-Type-Options: nosniff, so an HTML page from the backend cannot run script as the gateway's origin.

Input validation

Input Check
Resource input Config-loaded schemas are closed recursively by default; an explicit additionalProperties setting is preserved. For a config-loaded resource, a missing input accepts only an empty object. A directly constructed resource without an input schema accepts any input. Validation checks own properties; top-level __proto__, _payment and _authorization are removed first.
Path parameters Empty values and dot segments are rejected; other values are URL-encoded.
Body size 256 KiB. Fastify enforces it on parsed routes; raw protocol mounts enforce the same exported limit on the socket, replying 413 when they still can. MCP, A2A and ACP also cap what their adapters buffer.
Content type HTTP invoke accepts JSON, plain text or an empty body. Raw mounts delegate parsing to their protocol: MCP applies its own rules, ACP requires JSON for a POST body, and A2A does not check content type.
Payment proof The selected provider decodes and validates it.
Configuration Strict Zod shape validation followed by business-rule validation before startup.

The gateway removes the reserved _payment and _authorization carriers before validating resource input.

Payment security

Covered in detail in payment-flow.md. The invariants:

  1. Paid resources fail closed: payment or authorization failures do not deliver the resource.
  2. verify has no fund-moving side effects; only settle does.
  3. Replay is defended twice: on-chain via EIP-3009 authorizationState, the Permit2 nonce bitmap, Solana's processed-transaction check or the NEAR access-key nonce, and in the gateway via a replayKey reserved under a UNIQUE constraint before settlement. The key is derived from the authorization, not the request, so a replay against a different request still collides.
  4. The payment flow does not need buyer or merchant keys. In local mode the configured facilitator key pays gas and broadcasts a transfer whose recipient the buyer fixed in the signed authorization.
  5. The effective settlement destination is visible in /.well-known/agent-commerce and in doctor, so misconfiguration is noticeable rather than silent.
  6. A remote facilitator, with the Solana fee payer or NEAR relayer that pays for broadcasting, is trusted with liveness and with its settlement report, not with the payment terms. The buyer's signature fixes the token, amount and recipient, so the facilitator cannot redirect funds, but it can delay or drop a broadcast until the authorization expires. The gateway accepts a reported success once it names the offer's network and a transaction in that family's form; it does not read the chain to confirm it.

Authentication and exposure

All routes first pass Host validation. Requests with an Origin header also pass the server.allowedOrigins check, whose default is empty.

Route Audience Authentication
POST /api/resources/:id/invoke Agents None; paid resources require payment.
/mcp Agents None; available when MCP is enabled; paid resources require payment.
GET /api/resources, /health, /.well-known/agent-commerce Public None.
GET /ready Operators None; it returns fixed status vocabulary rather than raw details.
GET /api/receipts, /api/events Operators server.adminToken, compared in constant time.
GET /.well-known/agent-card.json Public None; available when A2A is enabled.
/a2a Agents None; available when A2A is enabled; paid resources require payment.
GET /.well-known/acp.json Public None; it omits private configuration.
/acp/checkout_sessions… Agents protocols.acp.auth.token, compared in constant time.

The operator routes carry the merchant's commerce ledger. With no server.adminToken configured they return 404, not open data - a missing control must not read as an absent restriction.

An allowed browser origin receives CORS headers. Requests without Origin do not. The shared onRequest hook covers raw mounts such as MCP and uses Host validation to limit DNS-rebinding attacks.

Every published port in docker-compose.yml binds 127.0.0.1. Port 8545 in particular is an Anvil node with unlocked accounts and the full anvil_* admin namespace; on a shared network that would be unauthenticated control of the dev chain.

ACP

Every ACP checkout route requires Authorization: Bearer <token>. The adapter hashes both values before comparison, so different token lengths do not change the comparison. Authentication and idempotency-key shape checks run before the request body is read.

Discovery at /.well-known/acp.json is public. It omits the bearer token, backend URLs, mapped resource ids, and idempotency database path. The token is not persisted; idempotency keys are scoped by the public base URL, so rotating the token does not release outstanding claims.

Signature and Timestamp verification are not implemented or advertised, and Signature cannot replace the bearer token. The adapter forwards both to the merchant unverified, among the headers sent to the merchant. It never forwards the bearer token, and a configured backend header wins over a caller header of the same name. Use TLS because the bearer token is otherwise sent in clear text.

Completion payment_data passes to the merchant backend as business input. The built-in gateway does not log it, store it in a receipt, or treat it as an Agent Commerce payment proof. Delegated payment and delegated authentication are not 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 concurrent probes share one in-flight check.

Denial of service

Available bounds include:

  • a 256 KiB request cap on parsed routes and raw protocol mounts
  • a 1 MiB response cap and timeout in the built-in backend executor
  • a bounded list limit on every receipts/events query, applied in the store
  • 20 concurrent requests per mounted protocol adapter, checked before the body is read; excess requests get GATEWAY_BUSY with Retry-After: 1
  • on /mcp, 8 concurrent tool calls and a queue of 64; excess work gets GATEWAY_BUSY
  • readiness memoization and single-flight, shared by /ready and /.well-known/agent-commerce, so polling either cannot amplify into one upstream RPC call per request
  • the audit request id is always generated, so a caller cannot write its own string into audit rows or reuse another flow's id; a client X-Request-Id is logged only when it matches [A-Za-z0-9._:-]{1,64}
  • an 8192-byte Agent-Authorization header cap, checked before decoding; MCP and A2A authorization carriers use the request body cap instead

Rate limiting, per-agent quotas, and adaptive backpressure are not implemented. Add them at the edge for public deployments.

Dependencies

Runtime dependencies are pinned exactly, and the release workflow runs npm audit. Review the current audit rather than relying on a count recorded in this document.

@coinbase/x402 is loaded dynamically only for facilitator auth.type: cdp. It adds @coinbase/cdp-sdk, axios, and a Solana dependency tree, so audit that optional installation before using it. Static-token facilitators can use auth.type: bearer without this peer.

Development keys

The repository contains Anvil's well-known development accounts, used only on the local demo chain and labeled LOCAL DEVELOPMENT ONLY - DO NOT FUND. They are public knowledge and anyone can spend from them. Never send real assets to those addresses, and never reuse them anywhere else.

Shared x402 deployment checks reject a well-known Anvil recipient on a non-local deployment and reject the zero address. Config validation and x402 provider construction both run these checks. The separate well-known Anvil signer-key check runs only when a local x402 provider is constructed, where it rejects that key against a non-local RPC.

The MPP provider builds its x402 settlement provider from its own options, so configured and direct MPP construction both run these checks.

Mainnet

Mainnet requires allowMainnet: true, a remote HTTPS facilitator and a non-development recipient. Built-in mainnets require their canonical USDC address and EIP-712 domain; declared mainnets have no default asset to check. With auth.type: none, also set allowUnauthenticatedFacilitator: true. Config validation and provider construction share these checks. The provider also checks RPC and local-key safety.

A facilitator cannot change an EIP-3009 transfer's signed recipient, amount, or chain. A Permit2 signature binds the same terms and the x402 exact proxy as the only spender, which pays only the signed witness recipient. On Solana, the buyer signs the transaction message; the facilitator adds its fee payer signature without changing that message. On NEAR, the relayer wraps the buyer's signed delegate in its own transaction without changing the token, recipient or amount; the signature names no chain. A facilitator can observe authorizations and refuse to broadcast them. A request without facilitator credentials falls under the facilitator's anonymous-access terms and limits, so mainnet requires a separate acknowledgment for that configuration.

The in-process facilitator serves only the local chain, eip155:31337, which keeps its gas-paying key off every public network. Config accepts any non-empty local key, but provider construction requires 32-byte hex and rejects well-known Anvil keys against public RPCs. Local health checks also require the RPC to answer as Anvil, since an RPC can report any chain id.

Adversarial scenarios, and where each is tested

This table maps scenarios to their closest tests. Some unit tests cover only the 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
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
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, with no fresh challenge; the first attempt may still settle same
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 task removed before payment; second message gets TaskNotFoundError without settling again tests/integration/a2a-x402-extension.test.ts
unauthenticated callers filling memory with unpaid A2A extension tasks 256 pending tasks per adapter, each expiring with its requirement or within an hour. A full store fails new purchases with GATEWAY_BUSY and never evicts a waiting task tests/unit/protocols-a2a/x402-extension.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
an asset field that contradicts the network's USDC, or another token on a public built-in network 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 failure STORAGE_ERROR, never mislabeled PAYMENT_REPLAYED tests/unit/core/execution/pipeline.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
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 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
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 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 a verdict only for a 400 whose body names an invalidReason. Every other remote-facilitator throw, and every verification throw from the local facilitator, becomes PAYMENT_PROVIDER_UNAVAILABLE.

With the local binding, the pinned SDK returns a result for every settlement failure from gas estimation through the receipt wait, so a settlement throw comes from its checks before anything is sent. The provider rejects such a throw as transaction_reverted when a contract revert is in its cause chain. The rule depends on that ordering: viem also reports an RPC error on a send as a revert, so a revert alone does not prove that nothing moved. Any other settlement throw becomes PAYMENT_PROVIDER_UNAVAILABLE; the pipeline records the attempt settlement-uncertain and returns PAYMENT_SETTLEMENT_FAILED.

For a failure it catches, the SDK returns invalid_exact_evm_transaction_failed (invalid_exact_svm_transaction_failed on Solana) unless the error message matches a known failure such as a used nonce. On the Permit2 path the reason also carries the error message after a colon. The reason comes with a transaction in two cases: the transaction failed on chain, or the SDK could not confirm it and could not record it for a retry. The second has an errorMessage starting with settlement_pending, and the provider treats it as an unknown outcome. Any other failure that names a transaction is unknown too: the EVM SDK reports a mined transfer whose Transfer log it cannot match that way. Without a transaction the provider cannot tell a refused call from a lost response, because of that viem behavior, so it throws PAYMENT_PROVIDER_UNAVAILABLE. A revert during gas estimation that the SDK does not recognize is therefore also recorded settlement-uncertain, although nothing was sent.

Facilitator rejection reasons reach clients and storage only after trimming and a 128-character, [A-Za-z0-9_.-] check. Other values become the fixed token invalid_payment during verification or settlement_failed during settlement; the raw value, with any URL cut to its origin, is available only in debug logs.

OpenAPI import

The importer reads one local, operator-supplied document and writes ordinary resource configuration for review. It is not on the request path.

  • It rejects sources over 10 MiB and all external $ref values. Internal references have cycle and depth checks.
  • It makes no network requests and does not walk the filesystem.
  • OpenAPI security declarations produce a warning and review comment; they do not import credentials or turn security headers into caller input.
  • Vendor extensions cannot set pricing, payments, backend URLs, or exposure.
  • Existing output requires --force; failed runs write nothing; successful writes use a temporary sibling and rename.
  • The importer infers neither pricing nor exposure. --free can add free pricing and --expose can add exposure; paid pricing must be written by hand. Imported resources then use the same validation and execution pipeline as handwritten resources.

See OpenAPI import for supported syntax and limitations.

Threats we are not addressing

Buyer identity and screening · fraud and disputes · refunds and chargebacks · multi-tenancy and RBAC · host compromise · supply-chain attestation · side-channel and timing analysis · protocol-level censorship or MEV around settlement · availability guarantees · a malicious facilitator withholding settlement (it cannot redirect funds, but it can decline to broadcast, and fail-closed means the resource is simply not delivered).