diff --git a/.env.example b/.env.example index 1e0cd89..d11118c 100644 --- a/.env.example +++ b/.env.example @@ -24,7 +24,7 @@ MERCHANT_API_BASE_URL=http://localhost:3000 RECEIPT_STORE_PATH=./data/receipts.sqlite # x402 / local deterministic chain -X402_NETWORK=eip155:84532 +X402_NETWORK=eip155:31337 X402_RPC_URL=http://localhost:8545 # The six variables below come from .deploy/local.json, which diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bc06bc..8cfa554 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,14 @@ env: # Keep this version in sync with the Foundry image digest in # docker/chain.Dockerfile. A floating `nightly` would change test inputs. FOUNDRY_VERSION: 'v1.7.1' + # The Solana E2E starts solana-test-validator. The Agave release is pinned by + # version and by the SHA-256 GitHub records for the Linux archive. + AGAVE_VERSION: 'v4.1.1' + AGAVE_SHA256: 'a5c8e74b8ffa9ce906872b812849057c7fb21cf036ba08f219eb335e20fa4fb3' + # NEAR E2E tests use nearcore's sandbox. No archive digest is published; + # this SHA-256 was recorded when the version was pinned. + NEAR_SANDBOX_VERSION: '2.13.4' + NEAR_SANDBOX_SHA256: '5690a4635172263cf026683f75965cac09dd916912584f12709974fed620163f' jobs: quality: @@ -96,9 +104,25 @@ jobs: - uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1 with: version: ${{ env.FOUNDRY_VERSION }} + - name: Install the Agave CLI + run: | + archive="$RUNNER_TEMP/agave.tar.bz2" + curl -sSfL -o "$archive" \ + "https://github.com/anza-xyz/agave/releases/download/${AGAVE_VERSION}/solana-release-x86_64-unknown-linux-gnu.tar.bz2" + echo "${AGAVE_SHA256} $archive" | sha256sum -c - + tar -xjf "$archive" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/solana-release/bin" >> "$GITHUB_PATH" + - name: Install the NEAR sandbox + run: | + archive="$RUNNER_TEMP/near-sandbox.tar.gz" + curl -sSfL -o "$archive" \ + "https://s3-us-west-1.amazonaws.com/build.nearprotocol.com/nearcore/Linux-x86_64/${NEAR_SANDBOX_VERSION}/near-sandbox.tar.gz" + echo "${NEAR_SANDBOX_SHA256} $archive" | sha256sum -c - + tar -xzf "$archive" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/Linux-x86_64" >> "$GITHUB_PATH" - run: npm ci - # These tests start local Anvil, deploy MockUSDC and run local services; - # they do not need a public RPC or hosted facilitator. + # Tests run Anvil, solana-test-validator and the NEAR sandbox locally, + # with deployed or preloaded tokens and no public RPC or facilitator. - run: npm run test:e2e package: @@ -188,7 +212,7 @@ jobs: // Subpaths must share CommerceError with the main entry; separate // bundled copies would fail the instanceof check. let caught; - try { x402({ asset: 'nope', payTo: '0x0', network: 'eip155:84532' }); } catch (e) { caught = e; } + try { x402({ asset: 'nope', payTo: '0x0', network: 'eip155:31337' }); } catch (e) { caught = e; } if (!(caught instanceof CommerceError)) throw new Error('CommerceError identity broken across subpaths'); console.log('subpath install ok: mcp + x402 compose with the main entry'); " diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md index f562af7..fbd4c2c 100644 --- a/.oss-scanner/threat_model.md +++ b/.oss-scanner/threat_model.md @@ -4,33 +4,35 @@ The full security notes are in the repository's `SECURITY.md` and `docs/security ## What this project does and where untrusted input enters -Agent Commerce Gateway is a self-hosted TypeScript gateway that sits in front of a merchant's existing HTTP API and sells calls to it to AI agents. Agents reach it over plain HTTP (`/api/resources/:id/invoke`), MCP (`/mcp`), A2A (`/a2a`) and, when enabled, ACP checkout routes. Paid resources take x402 v2 (`exact`, EVM / EIP-3009) or MPP payments, and can also require an AP2 Checkout Mandate. The gateway never holds merchant or buyer funds: payments go from the buyer to the merchant's configured address. +Agent Commerce Gateway is a self-hosted TypeScript gateway that sits in front of a merchant's existing HTTP API and sells calls to it to AI agents. Agents reach it over plain HTTP (`/api/resources/:id/invoke`), MCP (`/mcp`), A2A (`/a2a`) and, when enabled, ACP checkout routes. Paid resources take x402 v2 `exact` payments (EVM with EIP-3009 or Permit2, Solana, and NEAR as partial support) or MPP payments, and can also require an AP2 Checkout Mandate. A resource can offer several networks or tokens; the gateway settles the offer the proof pays. Solana and NEAR settle only through a remote facilitator. The gateway never holds merchant or buyer funds: payments go from the buyer to the merchant's configured address. - **Untrusted:** everything a caller sends. - HTTP, MCP JSON-RPC, A2A JSON-RPC and ACP request bodies. - Path parameters, query strings and resource inputs. - `Host` and `Origin` headers. - - Payment proofs: x402 `PAYMENT-SIGNATURE`, MPP `Authorization: Payment …` credentials, and the `_payment` field on MCP and A2A. + - Payment proofs: x402 `PAYMENT-SIGNATURE`, MPP `Authorization: Payment …` credentials, the `_payment` field on MCP and A2A, and `x402.payment.payload` in A2A x402 extension messages. An x402 proof carries an EIP-3009 or Permit2 authorization, a partially signed Solana transaction or a signed NEAR delegate action, plus the offer the buyer says it accepted. - AP2 mandates (SD-JWT). - **Semi-trusted:** - - responses from the payment facilitator and the RPC node; + - responses from the payment facilitator: verify and settle results, whose reason strings are sanitized, and `/supported`, which supplies the Solana fee payer; + - the EVM, Solana and NEAR RPC nodes, read for health checks and by MPP's nonce check, and Anvil, which settles local payments; - responses from the merchant backend (the built-in executor enforces a timeout and a 1 MiB body cap). -- **Trusted:** the operator's configuration file and environment, including backend URLs, admin and ACP tokens, keys and facilitator settings. +- **Trusted:** the operator's configuration file and environment, including backend URLs, admin and ACP tokens, keys, facilitator settings and declared EVM networks, whose `kind` (testnet or mainnet) the operator asserts. ## Components that matter most / least - **Most important:** - - **The payment path:** `src/core` (request pipeline), `src/payments/x402`, `src/payments/mpp`. - - Verify, then replay reservation, then settle, then deliver. - - Every failure must fail closed. - - Delivery must happen at most once per payment authorization. + - **The payment path:** `src/core` (request pipeline), `src/payments/x402` (offer selection, verification and settlement classification per chain family in `provider/`, replay keys in `replay-key.ts`), `src/payments/mpp`. + - Select the offer the proof pays, verify, reserve the replay key, then settle before the response is released: before the backend call under `upfront` (MPP's default), after it under `authorization` (x402's default). + - Every failure must fail closed. A settlement whose outcome is unknown withholds the response and keeps the replay key reserved (`settlement-uncertain`). + - Delivery must happen at most once per payment authorization. Replay keys follow what each chain consumes: the EIP-3009 nonce, the Permit2 owner nonce, the Solana transaction message and the NEAR access-key nonce. + - A facilitator or RPC failure must not be recorded as the buyer's refusal. - **AP2 verification:** `src/authorization/ap2`. - **Protocol adapters:** `src/protocols/mcp`, `src/protocols/a2a`, `src/protocols/acp`, `src/protocols/http.ts`. - **Access control and routing:** `src/gateway`. - Host-header (DNS-rebinding) validation in `access-control.ts`. - The admin token on `/api/receipts` and `/api/events`. - The ACP bearer token. - - **Storage** (`src/storage`): receipts, events, replay keys. + - **Storage** (`src/storage`): receipts, events, payment attempts with their replay keys, and the history of attempts a retry reclaimed. - **Secret redaction** in logs and receipts. - **Lower priority:** - `src/cli` (operator tooling); @@ -39,11 +41,12 @@ Agent Commerce Gateway is a self-hosted TypeScript gateway that sits in front of - **Out of scope:** - `demo/`, `examples/`, `scripts/`, `contracts/` (local demo chain); - the deliberately public Anvil development keys; - - the internals of third-party SDKs (x402, MCP SDK, mppx). How the gateway uses them is in scope. + - the internals of third-party SDKs (`@x402/*`, `@solana/kit`, the MCP SDK, mppx). How the gateway uses them is in scope. ## How to exercise it -- `npm test` runs the unit, integration and conformance suites (Vitest). The local-chain suites spawn Anvil, which this image installs. +- `npm test` runs the unit, integration and conformance suites (Vitest). A few of them spawn Anvil, which this image installs. +- `npm run test:e2e` settles payments on Anvil, `solana-test-validator` and a NEAR sandbox. This image installs only Anvil, so the Solana and NEAR suites skip themselves. - `tests/conformance/` drives the MCP, A2A and ACP adapters with the official client SDKs. - `config-demo.yaml` describes a complete local setup. `npm run agent-commerce -- doctor --config config-demo.yaml` checks it. @@ -51,7 +54,7 @@ Agent Commerce Gateway is a self-hosted TypeScript gateway that sits in front of - **Critical:** - a paid resource delivered without a successfully verified and settled payment; - - a payment that settles to a recipient, amount, asset or network other than the configured one; + - a payment that settles to a recipient, amount, asset or network other than one of the resource's configured offers; - one payment authorization or AP2 mandate unlocking more than one delivery; - an AP2 requirement bypassed; - any path that discloses a private key. @@ -60,17 +63,20 @@ Agent Commerce Gateway is a self-hosted TypeScript gateway that sits in front of - an ACP bearer-token bypass; - a bypass of the Host-header validation; - credentials, payment proofs or tokens written to logs or receipts despite redaction; - - code execution or path traversal reachable from untrusted input. -- **Medium:** denial of service from unauthenticated input that gets past the configured limits (body size, timeouts, batch handling); disclosure of non-secret configuration. + - code execution or path traversal reachable from untrusted input; + - a buyer asked to pay again after a settlement that may have landed, such as an unknown outcome recorded as refused. +- **Medium:** denial of service from unauthenticated input that gets past the configured limits (body size, timeouts, batch handling, the 256-task A2A pending-payment store); disclosure of non-secret configuration. - **Low:** findings that need a malicious or careless operator configuration; overly detailed error messages without secrets. ## Anything to leave alone -`SECURITY.md` → "What the gateway does not protect against" lists behavior that is documented and intended. Please do not report it: +`SECURITY.md` → "What the gateway does not protect against" and the protocol pages under `docs/protocols/` list behavior that is documented and intended. Please do not report it: - SSRF through backend URLs the operator configured; - no buyer vetting or KYC; - irreversible on-chain settlement; - the documented divergence between payment and delivery in the `upfront` and `authorization` flows; -- reserved authorizations never being released; +- an authorization staying reserved after a settlement whose outcome is unknown; only a rejected x402 attempt can be reserved again, and the chain still spends it once; +- NEAR network binding through the offer and the facilitator's RPC only, since the delegate signature names no chain; +- unpaid A2A extension tasks living in process memory, so a restart or another instance loses them; - a compromised host; - the demo dashboard's `VITE_ADMIN_TOKEN` placeholder. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 992a9bb..9db9190 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,7 +102,7 @@ moves money and never unlocks a resource on its own. Required before review: rejection reason that is not an oracle for trust policy - [ ] nothing of the proof itself in a receipt, an event or a log -AP2 is the worked example: [`docs/ap2.md`](docs/ap2.md). +AP2 is the worked example: [`docs/protocols/ap2.md`](docs/protocols/ap2.md). ## Publishing diff --git a/README.md b/README.md index ece09f7..94e7ff3 100644 --- a/README.md +++ b/README.md @@ -98,17 +98,20 @@ const { url } = await gateway.listen(); Components with extra dependencies use separate subpaths, so a gateway serving free HTTP resources does not install MCP, EVM, JOSE or SD-JWT packages. -| You want | Install | Import | -| --------------------------------------- | ---------------------------------- | ------------------------------------------ | -| gateway, config, receipts, A2A/ACP, CLI | `@devlab.group/agent-commerce` | `from '@devlab.group/agent-commerce'` | -| expose resources as MCP tools | `+ @modelcontextprotocol/server` | `from '@devlab.group/agent-commerce/mcp'` | -| accept x402 payments | `+ @x402/core @x402/evm viem` | `from '@devlab.group/agent-commerce/x402'` | -| accept MPP payments | `+ mppx viem @x402/core @x402/evm` | `from '@devlab.group/agent-commerce/mpp'` | -| verify AP2 mandates, sign checkout JWTs | `+ jose @sd-jwt/core canonicalize` | `from '@devlab.group/agent-commerce/ap2'` | -| authenticate to a CDP facilitator | `+ @coinbase/x402` | (no import - loaded on demand) | - -MPP settles through an x402 facilitator, so its entry also needs the x402 -peers. +| You want | Install | Import | +| --------------------------------------- | ------------------------------------ | ------------------------------------------ | +| gateway, config, receipts, A2A/ACP, CLI | `@devlab.group/agent-commerce` | `from '@devlab.group/agent-commerce'` | +| expose resources as MCP tools | `+ @modelcontextprotocol/server` | `from '@devlab.group/agent-commerce/mcp'` | +| accept x402 payments | `+ @x402/core @x402/evm viem` | `from '@devlab.group/agent-commerce/x402'` | +| accept x402 on Solana | x402 peers `+ @x402/svm @solana/kit` | (no import - loaded for a Solana offer) | +| accept x402 on NEAR | x402 peers `+ @x402/near` | (no import - loaded for a NEAR offer) | +| accept MPP payments | `+ mppx viem @x402/core @x402/evm` | `from '@devlab.group/agent-commerce/mpp'` | +| verify AP2 mandates, sign checkout JWTs | `+ jose @sd-jwt/core canonicalize` | `from '@devlab.group/agent-commerce/ap2'` | +| authenticate to a CDP facilitator | `+ @coinbase/x402` | (no import - loaded on demand) | + +The x402 entry needs `@x402/core`, `@x402/evm` and `viem` even when every +offer is on Solana or NEAR. MPP settles through an x402 facilitator, so its +entry also needs the x402 peers. ```bash npm install @devlab.group/agent-commerce @modelcontextprotocol/server @x402/core @x402/evm viem @@ -246,25 +249,25 @@ See [docs/configuration.md](docs/configuration.md). ## Protocol support -| Protocol | Status | Pinned revision | -| -------- | ------------ | ------------------------------------------------------------------------- | -| **HTTP** | Supported | native routes | -| **MCP** | Supported | `2026-07-28` and `2025-11-25`, `@modelcontextprotocol/server@2.3.0` | -| **x402** | Supported | x402 v2 (`@x402/core`, `@x402/evm`), scheme `exact`, EVM | -| **MPP** | Experimental | `mppx@0.13.1`, `charge` + `evm` + EIP-3009, USDC on Base Sepolia and Base | -| **A2A** | Experimental | A2A v1.0.1, binding `JSONRPC`, method `SendMessage`, x402 extension | -| **ACP** | Experimental | ACP `2026-04-17`, REST checkout + discovery | -| **AP2** | Experimental | AP2 `v0.2.0`, Direct Checkout Mandate verification | -| UCP | Planned | no implementation | - -[AP2](docs/ap2.md) is an authorization method, not a transport. It verifies a +| Protocol | Status | Pinned revision | +| -------- | ------------ | ------------------------------------------------------------------------------------------------------------ | +| **HTTP** | Supported | native routes | +| **MCP** | Supported | `2026-07-28` and `2025-11-25`, `@modelcontextprotocol/server@2.3.0` | +| **x402** | Supported | x402 v2 (`@x402/core`, `@x402/evm`, `@x402/svm`, `@x402/near`), scheme `exact`, EVM and Solana; NEAR partial | +| **MPP** | Experimental | `mppx@0.13.1`, `charge` + `evm` + EIP-3009, USDC on Base Sepolia and Base | +| **A2A** | Experimental | A2A v1.0.1, binding `JSONRPC`, method `SendMessage`, x402 extension | +| **ACP** | Experimental | ACP `2026-04-17`, REST checkout + discovery | +| **AP2** | Experimental | AP2 `v0.2.0`, Direct Checkout Mandate verification | +| UCP | Planned | no implementation | + +[AP2](docs/protocols/ap2.md) is an authorization method, not a transport. It verifies a mandate before a paid resource settles; the gateway does not issue the mandate or its Checkout Receipt. The experimental entries above ship off by default with documented subsets: -[A2A](docs/protocols.md#a2a), [ACP](docs/protocols.md#acp), -[AP2](docs/ap2.md#not-implemented) and [MPP](docs/protocols.md#mpp). Supported -[MCP](docs/protocols.md#mcp) and [x402](docs/protocols.md#x402) are also scoped. +[A2A](docs/protocols/a2a.md), [ACP](docs/protocols/acp.md), +[AP2](docs/protocols/ap2.md#not-implemented) and [MPP](docs/protocols/mpp.md). Supported +[MCP](docs/protocols/mcp.md) and [x402](docs/protocols/x402.md) are also scoped. ACP exposes only its five checkout operations; merchant `payment_data` is never converted into a gateway payment proof. @@ -272,6 +275,15 @@ converted into a gateway payment proof. `unsupported` for registered adapters and providers. `agent-commerce doctor` prints the `unsupported` lists for A2A, ACP and AP2 when they are enabled. +### Versioning + +Minor releases may break experimental configurations, integrations or types. +This applies to MPP, A2A, ACP, AP2 and `agent-commerce import openapi`. +Runtime adapters and providers report `status: experimental` in +`GET /.well-known/agent-commerce`. Pin an exact version and test upgrades +before deploying them. 2.1 also changes some supported x402 behavior and +types; see [Upgrading from 2.0](docs/migration-2.1.md). + ## Payment model - **Non-custodial.** Buyer payments go directly to the merchant address in @@ -279,9 +291,9 @@ prints the `unsupported` lists for A2A, ACP and AP2 when they are enabled. private keys. Local facilitator mode does hold a separate gas-paying signer. - **Fail closed.** Missing, malformed, expired, replayed, wrong-amount, wrong-recipient, wrong-network and wrong-asset payments are rejected. -- **Replay-safe twice over.** EIP-3009 stops a double spend on-chain; the - gateway additionally reserves a `replayKey` derived from the authorization - before it settles anything. +- **Replay-safe twice over.** The EIP-3009 or Permit2 nonce stops a double + spend on-chain; the gateway additionally reserves a `replayKey` derived from + the authorization before it settles anything. - **On-chain settlement in CI.** The end-to-end test asserts the buyer's balance falls and the merchant's rises by exactly the price, with a real transaction hash in the receipt. A log line saying "payment successful" would not count. @@ -303,21 +315,30 @@ Choose the network in config; Base mainnet also requires `allowMainnet: true`. These transactions each moved 0.01 USDC from a buyer to a merchant through a remote facilitator: -| Rail | Network | Transaction | -| ---- | ------------ | --------------------------------------------------------------------------------------------------------------------- | -| x402 | Base Sepolia | [`0xea41b234c4…`](https://sepolia.basescan.org/tx/0xea41b234c4645a4d335589ec9753646aa7cccd1b97e9e15823b88bff7b54a247) | -| x402 | Base | [`0x57ec81c2a3…`](https://basescan.org/tx/0x57ec81c2a360d14d59a43cf4e24be09a6bd75cbe6185016372895bda73e42763) | -| MPP | Base Sepolia | [`0x5d9fcd111b…`](https://sepolia.basescan.org/tx/0x5d9fcd111b615bb8fc9e56d55096f119fc81c3c72c334f813cfaecee402d0ac3) | -| MPP | Base | [`0xf4255bd7ec…`](https://basescan.org/tx/0xf4255bd7ec46ed06505dedfb5f9537924bd2633816e7952ec6f2b468395b1087) | - -The buyer signed each EIP-3009 authorization without ETH; the remote facilitator -paid gas and broadcast it. The gateway path received no buyer or merchant key. -The public-network smoke suites verify balance changes and fetch the transaction -receipt from the chain instead of trusting the gateway's response. +| Rail | Network | Method | Transaction | +| ---- | ------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| x402 | Base Sepolia | EIP-3009 | [`0xea41b234c4…`](https://sepolia.basescan.org/tx/0xea41b234c4645a4d335589ec9753646aa7cccd1b97e9e15823b88bff7b54a247) | +| x402 | Base Sepolia | Permit2 | [`0xa66f56cf39…`](https://sepolia.basescan.org/tx/0xa66f56cf39daeae958b5067c99383da4974c2ae6450786c0f9088700fa6439fe) | +| x402 | Base | EIP-3009 | [`0x57ec81c2a3…`](https://basescan.org/tx/0x57ec81c2a360d14d59a43cf4e24be09a6bd75cbe6185016372895bda73e42763) | +| x402 | Base | Permit2 | [`0x79d3da1145…`](https://basescan.org/tx/0x79d3da114522d4b056b29fad34d6492ce5662b92efd8ef2fd1ac21295ab71a42) | +| x402 | Solana Devnet | SPL transfer | [`5289xbeyHJ…`](https://explorer.solana.com/tx/5289xbeyHJPX1vX9DsLwGGJaGJJCH45qUtArhoTyxdgmWbXqZSjVPuJ3jyyLG4VrT9mFTCkLKhcoNfb1tuJ5qheb?cluster=devnet) | +| x402 | Solana | SPL transfer | [`E7gAuG8pMA…`](https://explorer.solana.com/tx/E7gAuG8pMArQjgRvGCwqm6S4qpt4xFNuhUTxeaZ5M1M1z1VA8eybyJJAXyo9paecCKnyrxYEuppoAemhntnnzTa) | +| x402 | NEAR Testnet | NEP-141 transfer | [`5tdtGLv4TB…`](https://testnet.nearblocks.io/txns/5tdtGLv4TBifK7c12hEdRuvxvNNrWjcWDH4e1ApUcE1W) | +| x402 | NEAR | NEP-141 transfer | [`7hU3imHepV…`](https://nearblocks.io/txns/7hU3imHepVZazGv53QfWYMGnWX5sVFxJAepemmwVSzt2) | +| MPP | Base Sepolia | EIP-3009 | [`0x5d9fcd111b…`](https://sepolia.basescan.org/tx/0x5d9fcd111b615bb8fc9e56d55096f119fc81c3c72c334f813cfaecee402d0ac3) | +| MPP | Base | EIP-3009 | [`0xf4255bd7ec…`](https://basescan.org/tx/0xf4255bd7ec46ed06505dedfb5f9537924bd2633816e7952ec6f2b468395b1087) | + +Facilitators broadcast these payments and paid their ETH, SOL or NEAR fees. +Permit2 requires an initial, gas-paying token approval from the buyer. The +NEAR runs used the suite's SDK facilitator and relayer; support remains +partial pending hosted-facilitator settlement. Buyer and merchant keys stayed +outside the gateway. The smoke suites verified settlement on-chain. Reproduce with `npm run test:testnet`, which spends test USDC, or `npm run test:mainnet`, which spends real USDC. Both run only by hand; see -[Base mainnet](#base-mainnet). +[Base mainnet](#base-mainnet) and [smoke tests](docs/smoke-tests.md). The +suites also cover NEAR testnet and mainnet (settled) and Arbitrum Sepolia (not +yet run). ### The facilitator model @@ -363,10 +384,9 @@ from [faucet.circle.com](https://faucet.circle.com); the buyer needs no ETH. `npm run test:testnet` drives the whole flow and reads the result back off the chain. -Chain id 84532 belongs to **both** Base Sepolia and this project's local dev -chain, deliberately. Nothing infers "public network" from it - `local`, -`testnet` and `mainnet` are derived from the network *and* the facilitator -together, and reported by `doctor`, `health()` and `/.well-known`. +The local dev chain is `eip155:31337`, Anvil's default chain id, so the network +says whether a deployment is local, testnet or mainnet. `doctor`, `health()` +and `/.well-known` report it. ### Base mainnet @@ -405,7 +425,7 @@ PASS Protocols http=on mcp=on (/mcp) a2a=off acp=off INFO A2A disabled INFO ACP disabled INFO AP2 disabled -PASS Payments x402 v2 (scheme=exact) enabled - LOCAL dev chain (eip155:84532, chain id shared with Base Sepolia), destination=0x7099…79C8, facilitator=local (in-process), paymentFlow=authorization +PASS Payments x402 v2 (scheme=exact) enabled - LOCAL dev chain (eip155:31337), destination=0x7099…79C8, facilitator=local (in-process), paymentFlow=authorization INFO Payments (MPP) MPP not configured PASS Storage sqlite schema v2 writable; receipts=2 PASS Protocol versions reported by gateway /.well-known/agent-commerce @@ -413,8 +433,10 @@ PASS Protocol versions reported by gateway /.well-known/agent-commerce Score: 7/7 checks passed ``` -`doctor` cross-checks the gateway's live settlement configuration against the -resolved local config and fails if they differ. +`doctor` compares live settlement terms with local config for each rail and +resource: offer order, network, token, payee and transfer method, plus decimals +and payment flows when published. Mismatches fail the check. It also names +failing offers when health is published; reasons appear in gateway debug logs. Exits non-zero if anything fails. `--json` for machines. @@ -460,10 +482,11 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). ## Roadmap -**Now** - MCP, x402 v2, settlement on the local chain, Base Sepolia and Base -mainnet, receipts, doctor, deterministic demo, experimental A2A v1.0.1 and ACP -`2026-04-17` checkout adapters, experimental AP2 v0.2.0 mandate verification, -experimental MPP `charge` payments, and experimental OpenAPI import. +**Now** - MCP, x402 v2, settlement on the local chain, Base Sepolia, Base +mainnet, Solana devnet and Solana mainnet, receipts, doctor, deterministic +demo, experimental A2A v1.0.1 and ACP `2026-04-17` checkout adapters, +experimental AP2 v0.2.0 mandate verification, experimental MPP `charge` +payments, and experimental OpenAPI import. **Next** - a `doctor` GitHub Action · UCP · autonomous-mode AP2 (open mandates, agent key binding, constraint evaluation) · @@ -480,8 +503,10 @@ they are added to a release. | [Architecture](docs/architecture.md) | how the pieces fit | | [Payment flow](docs/payment-flow.md) | the paid round trip and failure paths | | [Protocols](docs/protocols.md) | supported and unsupported protocol features | -| [AP2](docs/ap2.md) | mandate verification and the trust model | +| [AP2](docs/protocols/ap2.md) | mandate verification and the trust model | | [Configuration](docs/configuration.md) | `config.yaml` reference | +| [Upgrading from 2.0](docs/migration-2.1.md) | changes that can break a 2.0 setup | +| [Smoke tests](docs/smoke-tests.md) | settling on public networks, by hand | | [OpenAPI import](docs/openapi-import.md) | generate resources from an existing API | | [Security model](docs/security.md) | trust boundaries, and what we do not defend | | [Contracts](docs/contracts.md) | the frozen cross-area contract | diff --git a/SECURITY.md b/SECURITY.md index bcbbc7b..05ef61a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -81,7 +81,7 @@ custodian. the agent approved that exact purchase. It is verified and reserved before settlement, spendable once, and never a substitute for payment. Trust is static public keys in configuration, with no key discovery of any kind. See - [`docs/ap2.md`](docs/ap2.md). + [`docs/protocols/ap2.md`](docs/protocols/ap2.md). - **Bounded built-in HTTP calls.** The included HTTP backend executor applies a timeout and a 1 MiB response-body cap. Custom backend executors must enforce their own limits. diff --git a/config-demo.yaml b/config-demo.yaml index 7529b1c..f56d23f 100644 --- a/config-demo.yaml +++ b/config-demo.yaml @@ -97,7 +97,7 @@ resources: payments: x402: enabled: true - network: ${X402_NETWORK:-eip155:84532} + network: ${X402_NETWORK:-eip155:31337} rpcUrl: ${X402_RPC_URL:-http://127.0.0.1:8545} asset: ${X402_ASSET} assetName: ${X402_ASSET_NAME:-MockUSDC} diff --git a/config.example.yaml b/config.example.yaml index 9323452..288b348 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -47,7 +47,7 @@ protocols: # Serves JSON-RPC `SendMessage` at mountPath and the Agent Card at # /.well-known/agent-card.json. Enable at least one resource with # `expose: [a2a]`. Clients send `A2A-Version: 1.0`. - # See docs/protocols.md#a2a for supported features. + # See docs/protocols/a2a.md for supported features. a2a: enabled: false mountPath: /a2a @@ -60,7 +60,7 @@ protocols: # and the ACP MCP binding are not implemented. # # Enabling it needs the five mapped resources too - a complete, validating - # config is in examples/acp-checkout/. See docs/protocols.md#acp. + # config is in examples/acp-checkout/. See docs/protocols/acp.md. acp: enabled: false mountPath: /acp @@ -140,7 +140,8 @@ payments: mode: local signerPrivateKey: ${X402_FACILITATOR_PRIVATE_KEY} # - # A public testnet instead - no key anywhere in this file: + # A public testnet instead - no key anywhere in this file. Drop the asset + # fields: a public network settles only in its USDC. # # network: eip155:84532 # facilitator: @@ -149,13 +150,11 @@ payments: # auth: # type: none # - # Base mainnet. None of these are defaults. `auth` is required unless the - # facilitator takes no credential and allowUnauthenticatedFacilitator: true - # is set instead: + # Base mainnet, also without the asset fields. None of these are defaults. + # `auth` is required unless the facilitator takes no credential and + # allowUnauthenticatedFacilitator: true is set instead: # # network: eip155:8453 - # asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" # USDC on Base - # assetName: "USD Coin" # the EIP-712 domain name, not "USDC" # allowMainnet: true # facilitator: # mode: remote @@ -170,6 +169,7 @@ payments: # # mpp: # enabled: true + # network: eip155:31337 # the local chain; the default is Base Sepolia # rpcUrl: ${X402_RPC_URL} # asset: ${X402_ASSET} # assetName: ${X402_ASSET_NAME} @@ -177,10 +177,11 @@ payments: # recipient: ${MERCHANT_WALLET} # realm: localhost # challengeSecret: ${MPP_CHALLENGE_SECRET} # at least 32 characters - # # Same values and default as x402 above. Under authorization, the - # # backend must answer before the challenge expires, by default 300 s - # # after it was issued (challengeTtlSeconds). - # paymentFlow: authorization + # # Same values as x402 above, but the default is upfront: MPP's charge + # # intent forbids running the backend before the payment is verified. + # # Under authorization, the backend must answer before the challenge + # # expires, by default 300 s after it was issued (challengeTtlSeconds). + # paymentFlow: upfront # facilitator: # mode: local # signerPrivateKey: ${X402_FACILITATOR_PRIVATE_KEY} @@ -198,7 +199,7 @@ payments: # beside the old one, moving the signer to the new `kid`, then removing the old. # # Needs the optional peers: npm install jose @sd-jwt/core canonicalize -# Full reference: docs/ap2.md +# Full reference: docs/protocols/ap2.md # --------------------------------------------------------------------------- # authorization: # ap2: diff --git a/demo/agent/src/run.ts b/demo/agent/src/run.ts index 36a095b..9594007 100644 --- a/demo/agent/src/run.ts +++ b/demo/agent/src/run.ts @@ -208,14 +208,21 @@ export async function runDemoAgent(deps: DemoAgentDeps = {}): Promise { } const envelope = structured; log.gateway( - `402 payment required: ${envelope.payment.amount} ${envelope.payment.currency} to ${envelope.payment.destination}`, + `402 payment required: ${envelope.payment.amount} ${envelope.payment.currency}, ${envelope.payment.accepts.length} offer(s)`, ); - const accepts = envelope.payment.accepts[0]; + // Pay the offer for the demo token on the demo network, not whichever + // happens to come first + const network = process.env['X402_NETWORK'] ?? LOCAL_NETWORK; + const accepts = envelope.payment.accepts.find( + (offer) => + offer['network'] === network && + String(offer['asset']).toLowerCase() === manifest.asset.toLowerCase(), + ); if (accepts === undefined) { throw new DemoAgentStepError( 'read payment requirement', - 'PaymentRequiredEnvelope.payment.accepts was empty', + `no offer pays ${manifest.asset} on ${network}`, ); } @@ -224,7 +231,7 @@ export async function runDemoAgent(deps: DemoAgentDeps = {}): Promise { merchant: manifest.merchant.address, asset: manifest.asset, maxValue: MAX_DEMO_PAYMENT_UNITS, - network: process.env['X402_NETWORK'] ?? LOCAL_NETWORK, + network, }); log.buyer( `challenge checked before signing: pays ${accepts.amount} units of ${accepts.asset} to ${accepts.payTo}`, diff --git a/demo/agent/test/run.test.ts b/demo/agent/test/run.test.ts index 422b982..2e4264d 100644 --- a/demo/agent/test/run.test.ts +++ b/demo/agent/test/run.test.ts @@ -26,7 +26,7 @@ const PAID_TOOL: Tool = { function makeManifest(overrides: Partial = {}): LocalChainManifest { return { - chainId: 84532, + chainId: 31337, rpcUrl: 'http://127.0.0.1:8545', asset: '0x5fbdb2315678afecb367f032d93f642f64180aa3', assetName: 'MockUSDC', @@ -84,7 +84,7 @@ const PAYMENT_REQUIRED_ENVELOPE = { { scheme: 'exact', // Pinned by the buyer before signing: the EIP-712 chain id comes from it - network: 'eip155:84532', + network: 'eip155:31337', payTo: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', asset: '0x5fbdb2315678afecb367f032d93f642f64180aa3', amount: '10000', @@ -435,7 +435,7 @@ describe('the buyer checks the 402 challenge before signing', () => { merchant: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', asset: '0x5FbDB2315678afecb367f032d93F642f64180aa3', maxValue: MAX_DEMO_PAYMENT_UNITS, - network: 'eip155:84532', + network: 'eip155:31337', }; const good = { payTo: expected.merchant, diff --git a/demo/dashboard/src/components/StatusPanel.tsx b/demo/dashboard/src/components/StatusPanel.tsx index 88a0a76..4f147be 100644 --- a/demo/dashboard/src/components/StatusPanel.tsx +++ b/demo/dashboard/src/components/StatusPanel.tsx @@ -46,6 +46,19 @@ function DescriptorRow({ descriptor }: { readonly descriptor: AdapterDescriptor ); } +// EIP-3009 offers require token domain metadata; other offers may omit it +function describeAsset(offer: { + readonly asset: string; + readonly assetName?: string; + readonly assetVersion?: string; + readonly assetTransferMethod?: string; +}): string { + const method = offer.assetTransferMethod === 'permit2' ? ', Permit2' : ''; + return offer.assetName !== undefined + ? `${offer.assetName} v${offer.assetVersion ?? '?'} (${offer.asset}${method})` + : `${offer.asset}${method === '' ? '' : ` (Permit2)`}`; +} + /** * Shows gateway components and their reported capabilities and limits from * `GET /.well-known/agent-commerce` @@ -70,6 +83,16 @@ export function StatusPanel({ wellKnown, error }: StatusPanelProps) { } const x402 = wellKnown.payments.x402; + // The rail's offers serve every resource without its own list + const offerRows = + x402 === undefined + ? [] + : [ + ...(x402.options ?? [x402]).map((offer) => ({ offer, resource: 'default' })), + ...Object.entries(x402.resourceOptions ?? {}).flatMap(([resource, list]) => + list.map((offer) => ({ offer, resource })), + ), + ]; return (
@@ -107,34 +130,52 @@ export function StatusPanel({ wellKnown, error }: StatusPanelProps) { {x402 === undefined || !x402.enabled ? (

x402 is not enabled.

) : ( - - - - - - - - - - - - - - - - - - - - - - - -
Network{x402.network}
Asset - {x402.assetName} v{x402.assetVersion} ({x402.asset}) -
Pays to - {x402.payTo} -
Facilitator{x402.facilitator.mode === 'local' ? 'local (in-process)' : 'remote'}
Deployment{x402.mode}
+ <> + + + + + + + + + + + + {offerRows.map(({ offer, resource }) => ( + + + + + + {'status' in offer && offer.status !== undefined ? ( + + ) : ( + + )} + + ))} + +
ResourceNetworkAssetPays toHealth
{resource}{offer.network}{describeAsset(offer)} + {offer.payTo} + {offer.status}-
+ + + + + + + + + + + + + + + +
Facilitator{x402.facilitator.mode === 'local' ? 'local (in-process)' : 'remote'}
Deployment{x402.mode}
Payment flow{x402.paymentFlow}
+ )}
); diff --git a/demo/dashboard/test/components.test.ts b/demo/dashboard/test/components.test.ts index 4196836..fbf668f 100644 --- a/demo/dashboard/test/components.test.ts +++ b/demo/dashboard/test/components.test.ts @@ -116,7 +116,7 @@ describe('StatusPanel', () => { payments: { x402: { enabled: true, - network: 'eip155:84532', + network: 'eip155:31337', asset: '0x5fbdb2315678afecb367f032d93f642f64180aa3', assetName: 'MockUSDC', assetVersion: '2', @@ -125,11 +125,12 @@ describe('StatusPanel', () => { maxTimeoutSeconds: 120, facilitator: { mode: 'local' }, mode: 'local', + paymentFlow: 'authorization', }, }, }, }); - expect(html).toContain('eip155:84532'); + expect(html).toContain('eip155:31337'); expect(html).toContain('0x70997970C51812dc3A010C7d01b50e0d17dc79C8'); // Config accepts a local facilitator off the dev chain too, so the label // says where it runs, not which chain it is on @@ -137,6 +138,91 @@ describe('StatusPanel', () => { expect(html).not.toContain('dev chain'); }); + it('lists every x402 offer, naming a token without an EIP-712 domain by its address', () => { + const evm = { + network: 'eip155:84532', + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + assetName: 'USDC', + assetVersion: '2', + assetDecimals: 6, + payTo: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + }; + const html = render(StatusPanel, { + wellKnown: { + ...baseDoc, + adapters: [], + payments: { + x402: { + enabled: true, + ...evm, + maxTimeoutSeconds: 60, + facilitator: { mode: 'remote' }, + mode: 'testnet', + paymentFlow: 'authorization', + options: [ + evm, + { + network: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + asset: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + assetDecimals: 6, + payTo: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + }, + { + network: 'near:testnet', + asset: '3e2210e1184b45b64c8a434c0a7e7b23cc04ea7eb7a6c3c32520d03d4afcb8af', + assetDecimals: 6, + payTo: 'merchant.testnet', + }, + ], + }, + }, + }, + }); + for (const text of [ + 'eip155:84532', + 'USDC v2', + '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + 'merchant.testnet', + '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + ]) { + expect(html).toContain(text); + } + expect(html).not.toContain('undefined'); + }); + + it("shows a resource's own offers and each offer's health", () => { + const offer = { + network: 'eip155:84532', + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + assetName: 'USDC', + assetVersion: '2', + assetDecimals: 6, + payTo: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + }; + const own = { ...offer, payTo: '0x1111111111111111111111111111111111111111' }; + const html = render(StatusPanel, { + wellKnown: { + ...baseDoc, + adapters: [], + payments: { + x402: { + enabled: true, + ...offer, + maxTimeoutSeconds: 60, + facilitator: { mode: 'remote' }, + mode: 'testnet', + paymentFlow: 'authorization', + resourceOptions: { market_report: [{ ...own, status: 'fail' }] }, + }, + }, + }, + }); + expect(html).toMatch(/default<\/td>eip155:84532<\/td>/); + expect(html).toMatch( + /market_report<\/td>.*0x1111111111111111111111111111111111111111.*class="status-fail">fail { const html = render(StatusPanel, { wellKnown: { diff --git a/docker-compose.yml b/docker-compose.yml index 59ca134..eb4ad54 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -103,7 +103,7 @@ services: MERCHANT_API_BASE_URL: http://merchant-api:3000 RECEIPT_STORE_PATH: /workspace/data/receipts.sqlite AGENT_COMMERCE_CONFIG: /workspace/config-demo.yaml - X402_NETWORK: eip155:84532 + X402_NETWORK: eip155:31337 X402_RPC_URL: http://anvil:8545 LOCAL_CHAIN_MANIFEST: /workspace/.deploy/local.json # Operator routes carry the merchant's commerce ledger. This demo value is diff --git a/docker/chain.Dockerfile b/docker/chain.Dockerfile index 3d12340..2692d15 100644 --- a/docker/chain.Dockerfile +++ b/docker/chain.Dockerfile @@ -1,9 +1,8 @@ # --------------------------------------------------------------------------- -# Deterministic local EVM: Anvil on chain id 84532, matching the demo -# network `eip155:84532`. +# Deterministic local EVM: Anvil on chain id 31337, its default, matching the +# demo network `eip155:31337`. # -# This is a private, disposable chain. It is NOT Base Sepolia and it holds no -# real value. Its accounts are Anvil's well-known development keys: +# This is a private, disposable chain that holds no real value. Its accounts are Anvil's well-known development keys: # LOCAL DEVELOPMENT ONLY - DO NOT FUND. # --------------------------------------------------------------------------- # Pinned by digest, not `:latest`: a mutable image would make the deterministic @@ -14,11 +13,7 @@ FROM ghcr.io/foundry-rs/foundry@sha256:8347b728d5d393dac1c018691b36f506d23b9dcd7 EXPOSE 8545 -# `--network ethereum` is required: anvil otherwise infers the `optimism` -# network family from Base Sepolia's chain id 84532, and this image is built -# without that family, so anvil refuses to start. Plain EVM execution is -# enough; what matters is that the chain reports 84532, so the EIP-712 domain -# MockUSDC builds from `block.chainid` matches the chain id x402 derives from -# `eip155:84532`. +# The chain id must be 31337: MockUSDC builds its EIP-712 domain from +# `block.chainid`, and x402 derives the domain's chain id from `eip155:31337`. ENTRYPOINT ["anvil"] -CMD ["--host", "0.0.0.0", "--port", "8545", "--chain-id", "84532", "--network", "ethereum", "--accounts", "10", "--balance", "10000", "--silent"] +CMD ["--host", "0.0.0.0", "--port", "8545", "--chain-id", "31337", "--accounts", "10", "--balance", "10000", "--silent"] diff --git a/docker/gateway-entrypoint.sh b/docker/gateway-entrypoint.sh index fb186d9..9e4f350 100644 --- a/docker/gateway-entrypoint.sh +++ b/docker/gateway-entrypoint.sh @@ -43,6 +43,6 @@ export X402_FACILITATOR_PRIVATE_KEY="${X402_FACILITATOR_PRIVATE_KEY:-${v[5]}}" echo "[gateway] asset ${X402_ASSET}" echo "[gateway] settlement payTo ${MERCHANT_WALLET}" -echo "[gateway] network ${X402_NETWORK:-eip155:84532} via ${X402_RPC_URL:-unset}" +echo "[gateway] network ${X402_NETWORK:-eip155:31337} via ${X402_RPC_URL:-unset}" exec "$@" diff --git a/docs/architecture.md b/docs/architecture.md index df8363d..b8527e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -114,15 +114,15 @@ CanonicalRequest `verify` never moves money; only `settle` does. The replay reservation sits deliberately **between** them: a duplicate authorization is rejected before any -funds move. The provider's requirement chooses the flow: x402 and MPP settle -after a successful backend call by default, and before it under `upfront`. -Under the default flow, a backend failure settles nothing. +funds move. The provider's requirement chooses the flow: x402 settles after a +successful backend call by default, and MPP before it. Under `authorization`, +a backend failure settles nothing. Authorization is optional per resource. When required, its reservation sits between payment verification and settlement. It is consumed when the payment settles, marked when the outcome is unknown, and released when no money moved: a rejected settlement, a failed replay reservation, or a backend failure before -settlement. See [ap2.md](ap2.md#pipeline-position). +settlement. See [AP2](protocols/ap2.md#pipeline-position). ## Correlation diff --git a/docs/configuration.md b/docs/configuration.md index f554aaa..4a42ef2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -67,12 +67,13 @@ Each `backend.headers` entry must be a legal HTTP header name and value. Config load refuses one that fetch would reject, such as a value with an embedded newline, and the error names the header but never prints its value. A configured header wins over the same header that ACP forwards from the agent; -see [Headers sent to the merchant](protocols.md#headers-sent-to-the-merchant). +see [Headers sent to the merchant](protocols/acp.md#headers-sent-to-the-merchant). `payments` orders the rails a paid resource accepts. The gateway selects the first enabled rail in that order; it does not let each request choose a rail or fall back after a failure. Config load rejects a paid resource with no enabled -listed rail. +listed rail. A resource can replace the x402 rail's offers with +[its own](#a-resources-own-offers). `url` supports `{param}` templating from validated input; values are URL-encoded. Remaining input becomes query string for `GET`/`DELETE` and a JSON @@ -186,7 +187,7 @@ is refused. Each operation sends a fixed canonical envelope built from `path`, carrying `{ checkout_session_id }`, and `body`, carrying the ACP document; the -[operation table](protocols.md#checkout-operations) shows which of the two each +[operation table](protocols/acp.md#checkout-operations) shows which of the two each one sends. A cancel carries `body` only when the caller sends one, such as an `intent_trace`. Bind it explicitly: @@ -231,19 +232,19 @@ Set `merchantIdempotent: true` only if the merchant stores and replays results under the `Idempotency-Key` the gateway derives for each checkout POST. With this setting, the gateway releases a claim after a 5xx so the same key can be retried, as ACP requires. Without merchant deduplication, that retry could -repeat the side effect. See [ACP idempotency](protocols.md#idempotency). +repeat the side effect. See [ACP idempotency](protocols/acp.md#idempotency). The gateway forwards an `Idempotency-Key` header to the merchant on every side-effecting checkout call, derived so that it is stable across retries, a -restart and a token rotation. See [protocols.md](protocols.md#idempotency) for +restart and a token rotation. See [ACP](protocols/acp.md#idempotency) for what the merchant should do with it. -See [protocols.md](protocols.md#acp) for the wire contract. +See [ACP](protocols/acp.md) for the wire contract. ## `authorization.ap2` Experimental, off by default, and absent from a config that predates it. Full -reference, including the checkout profile and the trust model: [ap2.md](ap2.md). +reference, including the checkout profile and the trust model: [AP2](protocols/ap2.md). ```yaml authorization: @@ -310,13 +311,15 @@ idempotency store; and a `clockSkewSeconds` above the ceiling. payments: x402: enabled: true - network: ${X402_NETWORK} # CAIP-2; eip155:84532 locally + network: ${X402_NETWORK} # CAIP-2; eip155:31337 locally rpcUrl: ${X402_RPC_URL} - asset: ${X402_ASSET} # ERC-20 with EIP-3009 + payTo: ${MERCHANT_WALLET} # merchant-controlled. NEVER the gateway's. + # Optional on a built-in network, which settles in its USDC. Set all four + # for another token, such as the local MockUSDC. + asset: ${X402_ASSET} # ERC-20 with EIP-3009, unless the option uses Permit2 assetName: ${X402_ASSET_NAME} # EIP-712 domain name assetVersion: ${X402_ASSET_VERSION} assetDecimals: ${X402_ASSET_DECIMALS} - payTo: ${MERCHANT_WALLET} # merchant-controlled. NEVER the gateway's. maxTimeoutSeconds: 120 paymentFlow: authorization # default; or upfront facilitator: @@ -325,23 +328,200 @@ payments: ``` `paymentFlow` sets the order of a payment and the backend call, with the same -values under `payments.x402` and `payments.mpp`. The default, `authorization`, -settles only after the backend succeeds. If the backend fails, no payment -settles. `upfront` settles first; a later backend failure leaves the payment -settled. A resource with fixed pricing that lists `x402` or `mpp` can set its -own `paymentFlow`, which applies to whichever rail serves it. Configuration -rejects the field on other resources. See [protocols.md](protocols.md#x402) for +values under `payments.x402` and `payments.mpp`. `authorization`, the x402 +default, settles only after the backend succeeds. If the backend fails, no +payment settles. `upfront`, the MPP default, settles first; a later backend +failure leaves the payment settled. MPP defaults to `upfront` because its +`charge` intent forbids running the backend before the payment is verified. +A resource with fixed pricing that lists `x402` or `mpp` can set its own +`paymentFlow`, which applies to whichever rail serves it. Configuration +rejects the field on other resources. See [x402](protocols/x402.md) for the flow details. Startup rejects a `payTo` that is not a plausible address or is the zero address, and the effective destination is printed in a safe, visible form so a -presenter can confirm where money goes. +presenter can confirm where money goes. `validate` checks an EVM address's +form (`0x` and 40 hex characters); gateway startup also checks the EIP-55 +checksum of mixed-case addresses. + +### The settlement asset + +Omit the four asset fields to use the [built-in network's](#network-and-facilitator) +USDC address, EIP-712 domain and decimals. Resources using this default must +be priced in `USDC` or `USD`. `USD` treats USDC at face value, without an +exchange-rate lookup, and the payment records `USDC` (see +[payment-flow.md](payment-flow.md#amounts)). + +Public built-in networks, testnet or mainnet, settle only in their USDC. On the +local chain or a declared EVM network, set `asset`, `assetName`, +`assetVersion` and `assetDecimals` together; no fields are inherited from +USDC. A [Permit2](#permit2) token needs only `asset` and `assetDecimals`. +Overrides for the default token must match its metadata. Naming the default +token's address is the same as omitting it, so those resources are priced in +`USDC` or `USD` too. Payments in another token use the resource's label. + +### Permit2 + +For an ERC-20 without `transferWithAuthorization`, set +`assetTransferMethod: permit2` to use +[Permit2](https://github.com/Uniswap/permit2) and the x402 exact Permit2 proxy. +This EVM-only option defaults to `eip3009`. Permit2 uses its own EIP-712 +domain, so `assetName` and `assetVersion` are unnecessary. + +```yaml +payments: + x402: + network: eip155:31337 + payTo: ${MERCHANT_WALLET} + asset: ${TOKEN_ADDRESS} + assetDecimals: 18 + assetTransferMethod: permit2 +``` + +Before paying, the buyer approves the Permit2 contract, +`0x000000000022D473030F116dDEE9F6B43aC78BA3`, to spend the token. The gateway +sends no approval and accepts no gas-sponsoring extension. Without that +allowance it answers [412 Precondition Failed](protocols/http.md). + +Other tokens require the local chain or a declared network, as above. +Health checks require code at Permit2 and the proxy, +`0x402085c248EeA27D92E8b30b2C58ed07f9E20001`. For a remote facilitator they +check only that it advertises `exact` on the network, not that it settles +Permit2. + +### Solana + +Solana options settle the network's USDC through a remote facilitator that +pays transaction fees. They require the optional peers `@x402/svm` and +`@solana/kit`, loaded only for Solana options. + +```yaml +payments: + x402: + network: solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 # built-in Solana Devnet + rpcUrl: ${SOLANA_DEVNET_RPC_URL} + payTo: ${SOLANA_MERCHANT_WALLET} # no asset fields: the network's USDC + facilitator: + mode: remote + url: ${X402_FACILITATOR_URL} +``` + +- `payTo` is the merchant's wallet; its USDC token account must already exist. + The SDK client does not create it, and health fails without it. +- Each offer names the facilitator's fee payer, read from its `/supported` + response. Without one, readiness fails and no challenge is built. +- Health checks that the RPC's genesis hash matches the network id, that the + mint has the configured decimals, and that the facilitator lists the network + with a fee payer. +- A transaction expires with its blockhash, about 60 to 90 seconds after the + buyer signs. The default `authorization` flow settles after the backend, so + use `upfront` for a handler that can take longer. + +### NEAR + +NEAR options settle the network's NEP-141 USDC through a remote facilitator. +The buyer signs a NEP-366 delegate action containing one `ft_transfer`; the +facilitator's relayer submits it and pays gas. The buyer needs no NEAR for +payment fees, but an implicit account must first receive NEAR to exist. +The optional peer `@x402/near` loads only for NEAR options. + +```yaml +payments: + x402: + network: near:testnet # built-in NEAR Testnet + rpcUrl: ${NEAR_TESTNET_RPC_URL} + payTo: ${NEAR_MERCHANT_ACCOUNT} # no asset fields: the network's USDC + facilitator: + mode: remote + url: ${X402_FACILITATOR_URL} +``` + +- `payTo` and `asset` are NEAR account IDs: named (`merchant.near`), + subaccounts and implicit 64-hex accounts. The suffix does not select the + network. +- `payTo` must be registered with the token for storage (NEP-145 + `storage_deposit`), or every transfer to it fails. Health fails until it is. +- The buyer signs with a full-access key, `ed25519` or `secp256k1`: + `ft_transfer` needs one attached yoctoNEAR, which a function-call key cannot + attach. +- Health checks that the RPC's `status` names the network's chain, that the + token has code and the configured decimals, and that the facilitator lists + the network. +- The signed delegate names no chain. The offer's network and the RPC the + facilitator uses keep a proof on one network; use separate buyer keys for + mainnet and testnet. +- The gateway reserves the access key's nonce. The SDK reference client + retries with the same nonce until a transaction uses it. A rejected attempt, + from a backend failure or a settlement refused before submission, settled + nothing, so the gateway permits that retry. A delegate that expires during a + slow backend call is refused at settlement, and the client signs again; use + `upfront` for handlers that can outlast `maxTimeoutSeconds`. + +### Several options + +Use `options` to offer x402 resources on several networks or in several +tokens; the buyer pays one offer. Entries use the fields above, and a one-entry +list resolves like the direct form. Config load rejects mixed forms, empty +lists, an object in place of the list, duplicate (network, asset, `payTo`) +combinations, and mixed deployment modes (local, testnet, mainnet). + +```yaml +payments: + x402: + networks: + eip155:8453: { rpcUrl: ${BASE_RPC_URL} } + eip155:137: { rpcUrl: ${POLYGON_RPC_URL} } + options: + - network: eip155:8453 + payTo: ${MERCHANT_WALLET} + - network: eip155:137 + payTo: ${MERCHANT_WALLET} +``` + +Each network needs a distinct RPC endpoint for health checks. Set +`payments.x402.networks..rpcUrl`, or use the shared `payments.x402.rpcUrl` +as a fallback. RPC URLs must use `http://` or `https://`. Equivalent URLs, +such as those with and without a trailing slash, count as one endpoint. +All offers use the facilitator under `payments.x402`; it must advertise their +networks and scheme. + +When options use default assets, price the resource in `USD` or their shared +token symbol. Decimal precision cannot exceed the least precise option. + +The proof's `accepted` echo selects an offer before verification; zero or +multiple matches are rejected. AP2 binds to the selected destination, network +and asset, preventing a mandate for one offer from authorizing another. + +### A resource's own offers + +A paid resource's `x402.accepts` replaces the rail's offers for that resource, +matching the x402 SDK's route-level `accepts`. Rail options remain required +and serve resources without overrides. Entries use the `options` fields; +each charges the resource's `pricing`. + +```yaml +resources: + market_report: + # backend, pricing and expose as above + payments: [x402] + x402: + accepts: + - network: eip155:137 + payTo: ${POLYGON_MERCHANT_WALLET} +``` + +Entries share the rail's `networks`, RPC endpoints, facilitator, mainnet +acknowledgements and deployment mode. Price validation uses the resource's +own tokens and decimals. Config load rejects empty lists, repeated entries, +and `x402.accepts` on free resources, without `x402` in `payments`, or without +`payments.x402`. ### `payments.mpp` -MPP accepts the `USDC` currency label on `eip155:84532`, shared by Base Sepolia -and the local development chain, and on Base mainnet `eip155:8453`. It defaults -to `eip155:84532`; `asset` selects the token contract. The gateway builds its +MPP accepts the `USDC` currency label on the local chain `eip155:31337`, Base +Sepolia `eip155:84532` and Base mainnet `eip155:8453`. It defaults to Base +Sepolia, so a local config names `eip155:31337`; `asset` selects the token +contract. The gateway builds its settlement provider from this block, so `payments.x402` need not be enabled as a resource rail. @@ -349,7 +529,7 @@ resource rail. payments: mpp: enabled: true - network: eip155:84532 # optional; eip155:8453 is Base mainnet + network: eip155:31337 # optional; defaults to eip155:84532 (Base Sepolia) rpcUrl: ${MPP_RPC_URL} asset: ${MPP_ASSET} # token contract on the selected RPC assetName: USDC # EIP-712 domain name @@ -358,7 +538,7 @@ payments: realm: api.example.com # the challenge realm, single line challengeSecret: ${MPP_CHALLENGE_SECRET} # minimum length 32 challengeTtlSeconds: 300 # optional - paymentFlow: authorization # default; or upfront + paymentFlow: upfront # default; or authorization facilitator: mode: remote url: ${MPP_FACILITATOR_URL} @@ -380,24 +560,59 @@ development address, and either a facilitator credential or ## Network and facilitator -`network` is a CAIP-2 identifier and must be one this build knows: +`network` is a CAIP-2 identifier, either built in or declared: + +| `network` | Network | Kind | Notes | +| ----------------------------------------- | ----------------- | ------- | ------------------------------------------ | +| `eip155:31337` | Local Anvil | local | Anvil's default chain id; no default asset | +| `eip155:84532` | Base Sepolia | testnet | | +| `eip155:8453` | Base | mainnet | | +| `eip155:1` | Ethereum | mainnet | | +| `eip155:137` | Polygon | mainnet | | +| `eip155:42161` | Arbitrum One | mainnet | | +| `eip155:421614` | Arbitrum Sepolia | testnet | | +| `eip155:43114` | Avalanche C-Chain | mainnet | | +| `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | Solana | mainnet | [remote facilitator only](#solana) | +| `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | Solana Devnet | testnet | [remote facilitator only](#solana) | +| `near:mainnet` | NEAR | mainnet | [remote facilitator only](#near) | +| `near:testnet` | NEAR Testnet | testnet | [remote facilitator only](#near) | + +Every built-in EVM network is supported: the signing code is the same on +each, and on 2026-10-10 each network's USDC signing domain matched the chain +and Permit2 and its x402 proxy were deployed. Settlement has been demonstrated +on Base Sepolia and Base, through both EIP-3009 and Permit2, and on Solana +devnet and mainnet. NEAR has settled on testnet and mainnet through the SDK +facilitator run for the test. Every network also needs a facilitator that +lists it; health fails otherwise. + +Declare another EVM network with its `kind` to select the mainnet guardrails. +Custom networks have no default asset: set all four asset fields. Support for +them is partial, since the gateway cannot check a chain and token you supply +in advance. Built-in networks cannot be redeclared; their entry may set +`rpcUrl` only. -| `network` | | Notes | -| -------------- | ------------ | ------------------------------------------ | -| `eip155:84532` | Base Sepolia | the chain id the local dev chain also uses | -| `eip155:8453` | Base | mainnet; real funds | +```yaml +payments: + x402: + networks: + eip155:10: { kind: mainnet, displayName: OP Mainnet } +``` -Anything else is `CONFIG_INVALID` at load. The chain id is signed into the -buyer's EIP-712 domain, so an unrecognized network is never guessed at. +Unknown IDs raise `CONFIG_INVALID` at config load. The chain ID is part of +the buyer's signed EIP-712 domain and must resolve explicitly. `facilitator` decides who verifies and broadcasts: ```yaml facilitator: - mode: local # in-process signer; refused on mainnet + mode: local # in-process signer; eip155:31337 only signerPrivateKey: ${X402_FACILITATOR_PRIVATE_KEY} ``` +Local mode requires Anvil on `eip155:31337`. Other networks require remote mode. +A config that still pairs the local facilitator with `eip155:84532`, the id the +local chain used before, is refused with a pointer to `eip155:31337`. + ```yaml facilitator: mode: remote # HTTP; this gateway holds no facilitator signing key @@ -409,16 +624,19 @@ facilitator: Omitting `auth` is equivalent to `type: none`: the gateway sends no credential. Use it only with a facilitator that accepts anonymous requests. Supported types are `none`, `bearer` (a static token) and `cdp` (a fresh JWT per request). -The `cdp` type requires the optional `@coinbase/x402` peer. +The `cdp` type requires the optional `@coinbase/x402` peer. It signs each +request for `https://api.cdp.coinbase.com/platform/v2/x402`, so config load +refuses `cdp` with any other `url`. -Deployment mode - `local`, `testnet` or `mainnet` - comes from the network and -facilitator together. Chain id 84532 identifies both the local chain and public -Base Sepolia. `doctor`, `health()` and `/.well-known/agent-commerce` report the -resolved mode. +Deployment mode - `local`, `testnet` or `mainnet` - comes from the network: +`eip155:31337` is local, and every other network is the testnet or mainnet it +names. `doctor`, `health()` and `/.well-known/agent-commerce` report it. In local +mode `health()` also requires the RPC to answer Anvil's `anvil_nodeInfo`, +because an RPC can report any chain id. ## Mainnet guardrails -`eip155:8453` moves real money, so a config naming it must also say so. All of +A mainnet moves real money, so a config naming one must also say so. All of these are refused at config load, before the gateway starts: | Refused | Because | @@ -428,7 +646,7 @@ these are refused at config load, before the gateway starts: | `facilitator.auth.type: none` without explicit acceptance | an unauthenticated counterparty must be acknowledged | | a non-HTTPS `facilitator.url` | authorizations and settlement results would be unencrypted | | a well-known Anvil `payTo` or MPP `recipient` | its private key is public | -| an `asset` other than canonical USDC on Base | it could settle an unintended token | +| an `asset` other than USDC on a built-in mainnet | it could settle an unintended token | | the wrong USDC `assetName` or `assetVersion` | the buyer signs that EIP-712 domain | The same rules apply to any non-local deployment where they make sense: plain @@ -443,17 +661,23 @@ reports the parsed configuration and, when reachable, the live provider health. Base Sepolia USDC reports `"USDC"`; Base mainnet reports `"USD Coin"`. The buyer signs this name in the EIP-712 domain, so a mismatch would reject every payment -after signing. The network registry (`src/payments/x402/networks.ts`) pins both -values, and config validation rejects a mainnet mismatch before startup. +after signing. The network registry (`src/payments/x402/networks.ts`) pins the +domain of each built-in network's USDC, and config validation rejects a +mismatch before startup. ### Running against a public network Working configurations live in `examples/base-sepolia/` and `examples/base-mainnet/` (plus `examples/base-mainnet-payai/`, which uses an -unauthenticated facilitator). `npm run test:testnet` and `npm run test:mainnet` -drive the whole flow against the real chains and read balances and the transaction -receipt back off them. The testnet suite spends test USDC and the mainnet suite -real USDC; both skip themselves without credentials and never run in CI. +unauthenticated facilitator). Additional examples cover Arbitrum Sepolia +(`examples/arbitrum-sepolia/`, not yet run), Solana devnet +(`examples/solana-devnet/`) and NEAR testnet (`examples/near-testnet/`). +`examples/multi-network-mainnet/` combines Base and Solana, which have +settled, with Polygon and Arbitrum One, which have not run. +`npm run test:testnet` and `npm run test:mainnet` drive the whole flow against +the real chains and read each settlement back from them. The testnet +suite spends test USDC and the mainnet suite real USDC; both skip themselves +without credentials and never run in CI. See [smoke tests](smoke-tests.md). ## Unsupported JSON Schema keywords have a cost @@ -483,7 +707,8 @@ protocol mounts that overlap · a mount that claims a route the gateway already serves, including the A2A Agent Card and ACP discovery paths · an ACP checkout mapping that is incomplete, names a missing or non-`acp` resource, reuses one resource for two operations, or maps a paid one · an ACP idempotency retention -below 24 hours · an invalid or zero `payTo`/`asset`/`recipient` · an MPP +below 24 hours · an invalid or zero `payTo`/`asset`/`recipient` · an +`x402.accepts` list that is empty or on a resource that cannot use it · an MPP `challengeSecret` with length below 32 or a multi-line `realm`. It exits non-zero on any of them. diff --git a/docs/contract-surface.txt b/docs/contract-surface.txt index f1f4fd8..bf16cb2 100644 --- a/docs/contract-surface.txt +++ b/docs/contract-surface.txt @@ -1,6 +1,6 @@ # Semantic surface of src/core/public-types.ts # Generated by scripts/contract-surface.mjs - do not edit by hand. -# 87 exported symbols. +# 88 exported symbols. interface AdapterDescriptor { readonly capabilities: ReadonlyArray; @@ -14,11 +14,17 @@ interface AdapterDescriptor { interface AdapterHealth { readonly checkedAt: string; + readonly components?: ReadonlyArray; readonly detail?: string; readonly durationMs?: number; readonly status: "pass" | "warn" | "fail"; } +interface AdapterHealthComponent { + readonly name: string; + readonly status: "pass" | "warn" | "fail"; + } + interface AdapterHttpRoute { handleHttp: (req: IncomingMessage, res: ServerResponse) => Promise; readonly method: "GET" | "POST"; @@ -267,6 +273,7 @@ interface PaymentAttemptReservation { readonly payee?: string; readonly payer?: string; readonly provider: string; + readonly reclaimRejected?: boolean; readonly replayKey: string; readonly requestId: string; readonly resourceId: string; @@ -300,6 +307,7 @@ interface PaymentProvider { health: () => Promise; readonly descriptor: AdapterDescriptor; readonly name: PaymentMethodName; + selectRequirement?: (context: PaymentVerificationContext) => Promise; settle: (context: PaymentSettlementContext) => Promise; verify: (context: PaymentVerificationContext) => Promise; } @@ -308,7 +316,7 @@ interface PaymentRequiredEnvelope { readonly authorization?: { readonly required: readonly AuthorizationRequirement[]; }; readonly code: "PAYMENT_REQUIRED"; readonly message: string; - readonly payment: { readonly provider: PaymentMethodName; readonly version: string; readonly amount: DecimalAmount; readonly currency: string; readonly destination: string; readonly network?: string; readonly asset?: string; readonly expiresAt?: IsoTimestamp; readonly accepts: readonly Readonly>[]; readonly envelope?: Readonly>; }; + readonly payment: { readonly provider: PaymentMethodName; readonly version: string; readonly amount: DecimalAmount; readonly currency: string; readonly destination?: string; readonly network?: string; readonly asset?: string; readonly expiresAt?: IsoTimestamp; readonly accepts: readonly Readonly>[]; readonly envelope?: Readonly>; }; readonly requestId: string; readonly resourceId: string; readonly status: "payment-required"; @@ -327,7 +335,7 @@ interface PaymentRequirement { readonly asset?: string; readonly challenge: PaymentChallenge; readonly currency: string; - readonly destination: string; + readonly destination?: string; readonly expiresAt?: string; readonly id: string; readonly metadata?: Readonly>; diff --git a/docs/contracts.md b/docs/contracts.md index 7bf50d5..61a56c1 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -63,6 +63,9 @@ This table is for navigation, not enumeration. `true` has `settle` run after a successful backend call instead of before it; the `authorization` flow of x402 and MPP does. A backend failure then settles nothing. A rejected or unconfirmed settlement withholds the response. + `metadata.reuseRejectedAuthorization: true` lets the pipeline reserve a + rejected attempt's authorization again with `reclaimRejected`. x402 sets + it; MPP's single-use credentials cannot be reused. 5. `EventSink.emit` and event persistence must not fail a commerce flow. 6. `HttpBackendExecutor` is the only built-in outbound HTTP path to merchant backends and always applies a timeout. A custom `GatewayOptions.backend` @@ -104,8 +107,16 @@ persistence must not fail the commerce flow. protocol names are defined by `ProtocolName` and `PROTOCOL_NAMES`. - `toCommerceError` no longer exposes an arbitrary `Error.message`. It keeps the original error as the non-serialized `cause`. -- `PaymentAttempt.status` gained `settlement-uncertain`, recorded whenever - `settle()` throws, with or without a transaction hash. +- `PaymentAttempt.status` gained `settlement-uncertain`, recorded when + `settle()` throws, with or without a transaction hash. A throw whose + `details.settlementNotSubmitted` is `true` records `rejected` instead and + answers `PAYMENT_PROVIDER_UNAVAILABLE`: the provider knows nothing was + submitted, so the authorization can be reserved again. +- Optional `PaymentAttemptReservation.reclaimRejected` lets + `reservePaymentAttempt` reclaim an attempt the same provider rejected (see + [Receipt store](#receipt-store)). This permits x402 retries after a backend + failure or a refused settlement, which previously returned 402. A custom + `ReceiptStore` that ignores the field still refuses these retries. - `GATEWAY_BUSY` is a retryable 503 used for transient load shedding. - `ReceiptStore` gained `countReceipts` and `countUndeliveredReceipts`; list results are capped and cannot provide reliable totals. @@ -141,9 +152,104 @@ persistence must not fail the commerce flow. forwarded name or value is `INPUT_INVALID` before pricing. ACP is the only adapter that sets the field. A custom `BackendExecutor` that ignores it forwards nothing. +- `PaymentProvider.selectRequirement` optionally selects a proof's offer + before `verify`. It returns the selected destination, network, asset and + metadata while preserving `id` and the full `challenge`. Verification, + authorization, replay reservation and settlement use this requirement. + Providers without the hook keep their existing path. +- Optional `AdapterHealth.components` lists `AdapterHealthComponent` results + with a `name` and `status`, such as one per x402 offer. Discovery may publish + these fields; they contain no detail. Omission preserves existing reporting. +- `PaymentRequirement.destination` and `PaymentRequiredEnvelope.payment.destination` + are optional. Multi-offer x402 challenges omit destination, network and + asset; single-offer and selected x402 requirements include all three. The + envelope fields are deprecated: read individual offers in `accepts`. With + `strictNullChecks`, callers passing either `destination` as a required + `string` must now handle `undefined`. ### Protocols and gateway +- The x402 provider accepts `X402MultiOfferProviderOptions` with one shared + facilitator. Multi-offer challenges use the offer-independent URL + `resource://agent-commerce/resources/`. `GatewayConfig.payments.x402.options` + holds resolved options; single-option fields mirror the first. `/.well-known` + includes `payments.x402.options` only for multiple offers and omits RPC URLs. +- An x402 offer takes `assetTransferMethod` (`X402AssetTransferMethod`, + `eip3009` by default, or `permit2`). `X402ProviderOptions` now extends + `X402OfferOptions`. + - `assetName` and `assetVersion` became optional in both option types, in + `GatewayConfig.payments.x402` and its `options`, and in `/.well-known`. + EIP-3009 still requires both: config load supplies the default asset's + metadata or requires explicit values; provider construction rejects missing + values. Permit2 may omit them. With `strictNullChecks`, readers must + handle `undefined`. + - `/.well-known` publishes `assetTransferMethod: permit2` beside a Permit2 + offer. + - The provider no longer forwards a payload's `extensions` to the + facilitator. + - `createPaymentProof` refuses an offer naming a method other than + `eip3009` with `INPUT_INVALID`. + - The invoke route returns 412 for a missing Permit2 allowance + (`permit2_allowance_required`), preserving `PAYMENT-REQUIRED` as specified + by the `exact` EVM scheme. +- The x402 provider serves Solana offers (`solana:` networks) through a remote + facilitator. + - `X402OfferOptions.asset` and `payTo` widened from `0x${string}` to + `string` to carry a Solana mint and wallet. Code reading either field as + `0x${string}` must narrow it. + - The descriptor's `supportedSpec` names `family=eip155`, `family=solana` + or both; capabilities gain `exact-svm`, and `unsupported` no longer + lists `svm`. + - A settlement success must name the offer's network and a transaction in + its family's form, for EVM offers too. Anything else unlocks nothing, and + the pipeline records the attempt `settlement-uncertain`. + - A settlement failure that names a transaction is a rejection only with + the family's on-chain failure reason: `invalid_exact_evm_transaction_failed`, + `invalid_exact_svm_transaction_failed` or NEAR's `settlement_failed`. Any + other reason leaves the outcome unknown. A rejection keeps its transaction + as the attempt's `externalReference`. +- The x402 provider serves NEAR offers (`near:mainnet`, `near:testnet`) + through a remote facilitator. + - `X402OfferOptions.asset` and `payTo` carry NEAR account IDs for a NEAR + offer. The descriptor's `supportedSpec` can name `family=near`, + capabilities gain `exact-near`, and `unsupported` no longer lists a + chain family. + - `@x402/near` is the optional peer for NEAR offers, loaded only when one + is configured. + - Verification reasons that name the facilitator's failure to read the + chain or its configuration, such as + `invalid_exact_near_access_key_lookup_failed`, are no verdict: + `PAYMENT_PROVIDER_UNAVAILABLE`. At settle the SDK returns them before + submitting, so the provider throws with `details.settlementNotSubmitted` + and the attempt is recorded `rejected`. + - `createX402PaymentProvider` refuses a `maxTimeoutSeconds` that is not a + positive integer with `CONFIG_INVALID`, as config load already did: the + NEAR facilitator refuses every payment under such a value. +- x402 verification and settlement `currency` uses the network's default + token symbol when paying that asset, otherwise the resource's currency. + Settlement preserves a differently labeled price, such as `USD`, in + `metadata.quote` (`amount`, `currency`). Payment attempts, receipts and + `PAYMENT-RESPONSE` now record `USDC` instead of repeating a `USD` quote. +- A replay error's `details.network` names the network of the offer it paid. +- `/.well-known` publishes each rail's `paymentFlow`, and + `resourcePaymentFlows` when a resource that lists the rail sets its own; + `WellKnownDocument` gains both fields for x402 and MPP. A rail's + `resourcePaymentFlows` in `GatewayConfig` no longer includes resources that + do not list that rail. +- A resource's `x402.accepts` overrides its offers, matching the SDK's + route-level `accepts`. + - `X402ProviderOptions.resourceOffers`, keyed by resource id, replaces the + provider's offers for those resources, under the same facilitator and + settings. An offer that several lists repeat is built and health-checked + once. + - `GatewayConfig.payments.x402.resourceOptions` holds the resolved lists, and + `/.well-known` publishes them as `payments.x402.resourceOptions`. + `WellKnownX402Offer` names the offer shape both lists share. +- x402 health checks all offers, reporting `components` named + ` ` with lowercase EVM addresses. Any failing offer + fails the provider. `/.well-known` copies statuses into `options` and + `resourceOptions`; `/ready` is unchanged. + - `ProtocolName` gained `a2a` and `acp`; `PROTOCOL_NAMES` is the runtime list used by validation. It is derived from a record keyed by the union, so a missing or extra name fails to compile. The type remains hand-written so @@ -214,18 +320,21 @@ persistence must not fail the commerce flow. then verifies and settles through the x402 facilitator named in its options, using x402-compatible replay keys. - `MppProviderOptions` and `GatewayConfig.payments.mpp` accept optional - `paymentFlow` and `resourcePaymentFlows`. They use the x402 flow values and - default to `authorization`, which settles after a successful backend call. + `paymentFlow` and `resourcePaymentFlows` with the x402 flow values. +- MPP's default `paymentFlow` is `upfront`; it was `authorization`. The + `charge` intent counts a backend call made before verification completes as + partial access, and EVM charge verification includes the transfer. A + deployment that wants the old order must set `paymentFlow: authorization`. ## Published entry points -| Entry | Surface | Imported optional peers | -| ----------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `@devlab.group/agent-commerce` | Core contract, config, gateway, receipt store, A2A and ACP adapters | None | -| `@devlab.group/agent-commerce/ap2` | AP2 verification and checkout signing | `jose`, `@sd-jwt/core`, `canonicalize` | -| `@devlab.group/agent-commerce/mcp` | MCP adapter | `@modelcontextprotocol/server` | -| `@devlab.group/agent-commerce/mpp` | MPP provider and profile metadata | `@coinbase/x402` (only for `auth.type: cdp`), `@x402/core`, `@x402/evm`, `mppx`, `viem` | -| `@devlab.group/agent-commerce/x402` | x402 provider and client proof helper | `@coinbase/x402` (only for `auth.type: cdp`), `@x402/core`, `@x402/evm`, `viem` | +| Entry | Surface | Imported optional peers | +| ----------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@devlab.group/agent-commerce` | Core contract, config, gateway, receipt store, A2A and ACP adapters | None | +| `@devlab.group/agent-commerce/ap2` | AP2 verification and checkout signing | `jose`, `@sd-jwt/core`, `canonicalize` | +| `@devlab.group/agent-commerce/mcp` | MCP adapter | `@modelcontextprotocol/server` | +| `@devlab.group/agent-commerce/mpp` | MPP provider and profile metadata | `@coinbase/x402` (only for `auth.type: cdp`), `@x402/core`, `@x402/evm`, `mppx`, `viem` | +| `@devlab.group/agent-commerce/x402` | x402 provider and client proof helper | `@x402/core`, `@x402/evm`, `viem`; `@coinbase/x402` only for `auth.type: cdp`; `@x402/svm` and `@solana/kit` only for a Solana offer; `@x402/near` only for a NEAR offer | The main entry and CLI must not import optional peers. `package.json` is the authoritative export and dependency map. @@ -258,30 +367,54 @@ returns 402 and a fresh challenge if that status is `settled` or `rejected`. It returns 409 for any other status or when the status is unavailable. That includes the legacy `failed`, which may hide a settlement with no verdict. +A `rejected` attempt settled nothing: its backend failed first, or the provider +returned a verdict that no transfer occurred. With `reclaimRejected: true`, the +same provider can replace it with a new reservation; only one concurrent claim +wins. This lets the NEAR reference client retry using the access key's next +nonce. The SQLite store first copies the replaced row into +`payment_attempt_history`, so the earlier request and its rejection reason +stay on record. + ## x402 Published as `@devlab.group/agent-commerce/x402`. ```ts -export interface X402ProviderOptions { +/** One settlement option: network, token and merchant payee */ +export interface X402OfferOptions { readonly network: string; // CAIP-2, for example 'eip155:84532' readonly rpcUrl: string; - readonly asset: `0x${string}`; - readonly assetName: string; - readonly assetVersion: string; + readonly asset: string; // ERC-20 contract, Solana mint or NEAR token account + /** The token's EIP-712 domain; an EIP-3009 offer needs both */ + readonly assetName?: string; + readonly assetVersion?: string; readonly assetDecimals: number; /** Merchant-controlled settlement destination. */ - readonly payTo: `0x${string}`; + readonly payTo: string; // on Solana the wallet, on NEAR the account + /** EVM only. Default 'eip3009'. A Permit2 buyer approves Permit2 for the token first */ + readonly assetTransferMethod?: X402AssetTransferMethod; +} + +export type X402AssetTransferMethod = 'eip3009' | 'permit2'; + +/** A single offer and the settings every offer shares */ +export interface X402ProviderOptions extends X402OfferOptions { readonly maxTimeoutSeconds?: number; /** 'authorization' (default) settles after a successful backend call; 'upfront' first */ readonly paymentFlow?: X402PaymentFlow; /** Per-resource overrides of `paymentFlow`, keyed by resource id */ readonly resourcePaymentFlows?: Readonly>; + /** Offers that replace the provider's for a resource, keyed by resource id */ + readonly resourceOffers?: Readonly>; readonly facilitator: X402FacilitatorConfig; /** Must be true before mainnet settlement. */ readonly allowMainnet?: boolean; /** Must be true for an unauthenticated mainnet facilitator. */ readonly allowUnauthenticatedFacilitator?: boolean; + /** EVM networks outside the built-in registry; `network` may name one */ + readonly customNetworks?: Readonly< + Record + >; readonly logger?: Logger; readonly clock?: Clock; readonly ids?: IdGenerator; @@ -296,8 +429,14 @@ export type X402FacilitatorConfig = | { readonly mode: 'local'; readonly signerPrivateKey: string } | { readonly mode: 'remote'; readonly url: string; readonly auth: FacilitatorAuth }; +/** Several offers per paid resource, settled through one facilitator */ +export interface X402MultiOfferProviderOptions + extends Omit { + readonly offers: readonly X402OfferOptions[]; +} + export function createX402PaymentProvider( - options: X402ProviderOptions, + options: X402ProviderOptions | X402MultiOfferProviderOptions, ): PaymentProvider; export type DeploymentMode = 'local' | 'testnet' | 'mainnet'; @@ -308,12 +447,15 @@ export interface NetworkProfile { readonly chainId: number; readonly displayName: string; readonly kind: 'testnet' | 'mainnet'; - /** Canonical USDC and its EIP-712 domain, enforced on mainnet only */ + /** The local Anvil dev chain, where the in-process facilitator runs */ + readonly local?: boolean; + /** The network's USDC and its EIP-712 domain; a public network settles only in it */ readonly canonicalAsset?: { readonly symbol: string; readonly address: string; readonly name: string; readonly version: string; + readonly decimals: number; }; } ``` @@ -345,9 +487,9 @@ export interface MppProviderOptions { readonly allowUnauthenticatedFacilitator?: boolean; readonly logger?: Logger; readonly challengeTtlSeconds?: number; // default 300 - /** CAIP-2 network: 'eip155:84532' (default) or 'eip155:8453' */ + /** CAIP-2 network: 'eip155:31337' (local), 'eip155:84532' (default) or 'eip155:8453' */ readonly network?: string; - /** 'authorization' (default) settles after a successful backend call; 'upfront' first */ + /** 'upfront' (default) settles before the backend; 'authorization' after it */ readonly paymentFlow?: X402PaymentFlow; /** Per-resource overrides of `paymentFlow`, keyed by resource id */ readonly resourcePaymentFlows?: Readonly>; @@ -362,7 +504,7 @@ Construction fails with `CONFIG_INVALID` for a recipient or asset that is not an address, an empty EIP-712 domain name or version, an empty or multi-line realm, a challenge secret with length below 32, a TTL that is not a positive whole number, or an unsupported `network`. The supported networks are -`eip155:84532` and `eip155:8453`. The provider then builds an internal x402 +`eip155:31337` (local), `eip155:84532` and `eip155:8453`. The provider then builds an internal x402 provider from the same network, asset, recipient and facilitator options, so every x402 construction check applies, including the mainnet guardrails. `X402FacilitatorConfig` is the type exported by the `./x402` entry. @@ -560,6 +702,18 @@ export type AcpProtocolConfig = readonly discovery?: AcpDiscoveryConfig; }; +/** One resolved x402 option */ +export interface X402OptionConfig { + readonly network: string; + readonly rpcUrl: string; // networks..rpcUrl, else the shared rpcUrl + readonly asset: string; + readonly assetName?: string; // always present for EIP-3009 + readonly assetVersion?: string; + readonly assetDecimals: number; + readonly payTo: string; + readonly assetTransferMethod?: 'permit2'; // absent for EIP-3009 +} + export interface GatewayConfig { readonly version: 1; readonly merchant: { @@ -589,20 +743,25 @@ export interface GatewayConfig { readonly network: string; readonly rpcUrl: string; readonly asset: string; - readonly assetName: string; - readonly assetVersion: string; + readonly assetName?: string; // always present for EIP-3009 + readonly assetVersion?: string; readonly assetDecimals: number; readonly payTo: string; + readonly assetTransferMethod?: 'permit2'; // absent for EIP-3009 readonly maxTimeoutSeconds: number; readonly paymentFlow?: X402PaymentFlow; // default 'authorization' readonly resourcePaymentFlows?: Readonly>; readonly facilitator: X402FacilitatorConfig; readonly allowMainnet?: boolean; readonly allowUnauthenticatedFacilitator?: boolean; + readonly networks?: X402ProviderOptions['customNetworks']; // declared EVM networks + readonly options?: readonly X402OptionConfig[]; // the fields above mirror the first + // Each resource's own offers, replacing `options` for it + readonly resourceOptions?: Readonly>; }; readonly mpp?: { readonly enabled: boolean; - readonly network: 'eip155:84532' | 'eip155:8453'; + readonly network: 'eip155:31337' | 'eip155:84532' | 'eip155:8453'; readonly rpcUrl: string; readonly asset: string; readonly assetName: string; @@ -611,7 +770,7 @@ export interface GatewayConfig { readonly realm: string; readonly challengeSecret: string; readonly challengeTtlSeconds?: number; - readonly paymentFlow?: X402PaymentFlow; // default 'authorization' + readonly paymentFlow?: X402PaymentFlow; // default 'upfront' readonly resourcePaymentFlows?: Readonly>; readonly facilitator: X402FacilitatorConfig; readonly allowMainnet?: boolean; @@ -651,7 +810,7 @@ accepted. ```json { - "chainId": 84532, + "chainId": 31337, "rpcUrl": "http://127.0.0.1:8545", "hostRpcUrl": "http://127.0.0.1:8545", "asset": "0x...", @@ -693,7 +852,8 @@ the demo buyer; it is not a published package entry. `createPaymentProof` is the x402 client helper exported from the `./x402` entry. The demo buyer and the tests use it. Signing is offline: the chain id comes from the requirement's CAIP-2 network. The gateway does not call it or hold the buyer -key. +key. It signs only EIP-3009; another `extra.assetTransferMethod` yields +`INPUT_INVALID`. Use the SDK's `x402Client` for Permit2. ```ts export interface CreatePaymentProofOptions { diff --git a/docs/contributing-adapters.md b/docs/contributing-adapters.md index 878eb21..dfeb941 100644 --- a/docs/contributing-adapters.md +++ b/docs/contributing-adapters.md @@ -16,7 +16,9 @@ A new name crosses the public contract and runtime wiring. After approval: 1. Add the name to `ProtocolName` and `PROTOCOL_NAME_KEYS`, or `PaymentMethodName` and `PAYMENT_METHOD_NAME_KEYS`, in `src/core/domain/common.ts`. The runtime lists derive from those records. -2. Add its strict config block and normalization in `src/config/schema.ts`. +2. Add its strict config block to `src/config/schema/raw.ts`, and its + normalization to a module under `src/config/schema/` that `normalize.ts` + calls. 3. Wire a protocol adapter in `src/gateway/main.ts`, or build a payment rail in `src/gateway/payment-providers.ts`. 4. For a rail, add its incoming proof and payment-challenge branches in @@ -29,8 +31,8 @@ A new name crosses the public contract and runtime wiring. After approval: entry, package export, optional peer and the real-package check in `tests/unit/cli/packaging.test.ts`. 6. Add its descriptor, discovery and `doctor` output. Update the frozen - contract, conformance coverage, and a dedicated section in - [protocols.md](protocols.md). + contract and conformance coverage, and add a page under `docs/protocols/` + that [protocols.md](protocols.md) lists. ## Protocol adapters @@ -176,5 +178,6 @@ const provider: PaymentProvider = { success - [ ] no buyer or merchant production key is held by the gateway - [ ] the descriptor lists supported and unsupported behavior accurately -- [ ] [protocols.md](protocols.md) and [payment-flow.md](payment-flow.md) are +- [ ] the rail's page under `docs/protocols/`, the support matrix in + [protocols.md](protocols.md), and [payment-flow.md](payment-flow.md) are updated diff --git a/docs/migration-2.1.md b/docs/migration-2.1.md new file mode 100644 index 0000000..7d7e338 --- /dev/null +++ b/docs/migration-2.1.md @@ -0,0 +1,100 @@ +# Upgrading from 2.0 to 2.1 + +2.1 adds x402 payments on more EVM networks, Permit2, Solana and NEAR, several +offers per resource, and health per offer. The changes below can break a 2.0 +configuration, integration or type check. Review them before upgrading. + +| Change | Affects | +| --------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| [The local chain is `eip155:31337`](#the-local-chain-is-eip15531337) | local development on Anvil, x402 and MPP | +| [Public networks settle only in their USDC](#public-networks-settle-only-in-their-usdc) | another token on Base Sepolia | +| [Prices in USDC need a USDC label](#prices-in-usdc-need-a-usdc-label) | resources priced in neither `USD` nor `USDC` on Base or Base Sepolia | +| [URLs that cannot work are refused](#urls-that-cannot-work-are-refused) | `auth.type: cdp` at another URL; an RPC URL that is not http(s) | +| [MPP settles before the backend](#mpp-settles-before-the-backend) | MPP resources without `paymentFlow` | +| [Payments in USDC record `USDC`](#payments-in-usdc-record-usdc) | receipts and events of resources not priced in `USDC` | +| [`destination` is optional](#destination-is-optional) | TypeScript code reading payment requirements | +| [`assetName` and `assetVersion` are optional](#assetname-and-assetversion-are-optional) | TypeScript code and discovery clients reading x402 settings | + +## Configuration + +### The local chain is `eip155:31337` + +2.0 ran the local Anvil chain under Base Sepolia's id, `eip155:84532`. In 2.1 +that id always means the public Base Sepolia testnet, and the local chain uses +Anvil's default id, `eip155:31337`. Config load refuses `eip155:84532` with +`facilitator.mode: local`, for x402 and MPP, and the message names the local +chain id. + +To migrate a local setup: + +1. Set the network, usually `X402_NETWORK`, to `eip155:31337`. +2. Start Anvil with its default chain id, as `npm run chain:start` does, and + deploy the mock token again with `npm run chain:deploy`. +3. Buyer code that sets the chain id itself must use 31337; clients that read + it from the challenge need no change. + +A setup that settles on the real Base Sepolia needs a remote facilitator, as in +2.0. + +### Public networks settle only in their USDC + +2.0 required the network's USDC on mainnet only. 2.1 also refuses another +token on a public testnet, such as a mock token on Base Sepolia. Omit the asset +fields to settle in the network's USDC, or use the local chain or a network +you declare under `payments.x402.networks` (see +[configuration.md](configuration.md#network-and-facilitator)). + +### Prices in USDC need a USDC label + +2.0 let a resource keep any currency label when its option named the network's +USDC as `asset`, and charged the price in USDC at face value. 2.1 treats that +option like one that omits the asset: price its resources in `USDC` or `USD`. + +### URLs that cannot work are refused + +Config load refuses two settings that 2.0 accepted but could not use: + +- `facilitator.auth.type: cdp` with a `url` other than + `https://api.cdp.coinbase.com/platform/v2/x402`, the URL `@coinbase/x402` + signs for. +- An `rpcUrl` that is not an `http://` or `https://` URL. + +### MPP settles before the backend + +2.0 ran an MPP resource's backend before settling, like x402. 2.1 defaults MPP +to `upfront`, which settles first: the `charge` intent counts a backend call +before payment as partial access. A backend failure now leaves the payment +settled, and the receipt records the failed delivery. To keep the 2.0 order, +set `paymentFlow: authorization` under `payments.mpp` or on the resource; see +[MPP](protocols/mpp.md) for what that order costs. + +## Receipts and events + +### Payments in USDC record `USDC` + +In 2.0 a payment recorded the resource's `pricing.currency`. In 2.1 an x402 +payment in the network's USDC records `USDC` whatever the resource's label. +When the label differs, as with `USD`, the settlement keeps the resource's +price as `metadata.quote`. Payments in other tokens still record the +resource's label. Code that reads `currency` from receipts, events or the +payment summary returned with a delivery should accept `USDC`. + +## TypeScript + +### `destination` is optional + +`PaymentRequirement.destination` and +`PaymentRequiredEnvelope.payment.destination` are optional. A challenge with +several x402 offers has no single destination, network or asset; read each +offer in `accepts`. Single-offer and selected requirements still carry all +three. The envelope fields are deprecated. With `strictNullChecks`, code that +uses either field as a `string` must handle `undefined`. + +### `assetName` and `assetVersion` are optional + +They are optional in `X402ProviderOptions`, `X402OfferOptions`, +`GatewayConfig.payments.x402` and its `options`, and in +`GET /.well-known/agent-commerce`. A Permit2 offer has neither, because Permit2 +signs under its own domain. EIP-3009 offers still need both: config load fills +them from the network's USDC or requires them, and provider construction +refuses an EIP-3009 offer without them. diff --git a/docs/payment-flow.md b/docs/payment-flow.md index 42c836b..4492093 100644 --- a/docs/payment-flow.md +++ b/docs/payment-flow.md @@ -21,7 +21,7 @@ The diagram shows the default `authorization` flow: the backend answers before the payment settles, so a backend failure settles nothing, and a refused settlement withholds the backend's response. Under the `upfront` flow, configured per rail or per resource, `settle` moves above the backend call; see -[protocols.md](protocols.md#x402). +[x402](protocols/x402.md). ```text buyer gateway chain / backend @@ -64,9 +64,9 @@ Over HTTP, x402 sends the challenge in the `402` body and base64 `PAYMENT-REQUIRED` header, accepts the proof in `PAYMENT-SIGNATURE`, and returns the settlement result in `PAYMENT-RESPONSE`. MCP and A2A carry the same proof in the reserved `_payment` input field. MCP also accepts the selected rail's -`_meta` carrier; see [Payment over MCP](protocols.md#payment-over-mcp). A2A +`_meta` carrier; see [Payment over MCP](protocols/mcp.md#payment-over-mcp). A2A also accepts the x402 extension's task metadata; see -[x402 extension](protocols.md#x402-extension). +[x402 extension](protocols/a2a.md#x402-extension). ### MPP @@ -80,12 +80,12 @@ MPP uses the same verification and replay checks, with these differences: replay key, and neither broadcasts the payment. - Settlement uses an x402 facilitator. MPP derives the same replay key as x402, so one receipt store cannot reserve the authorization through both rails. -- MPP uses the same flow values and default as x402. Under `authorization`, - the backend must respond before the challenge expires; see - [MPP](protocols.md#mpp). +- MPP uses the same flow values as x402 but defaults to `upfront`, as its + `charge` intent requires. Under `authorization`, the backend must respond + before the challenge expires; see [MPP](protocols/mpp.md). Over HTTP, MPP uses `WWW-Authenticate`, `Authorization: Payment ...` and -`Payment-Receipt`. See the complete [HTTP header table](protocols.md#http-surface). +`Payment-Receipt`. See the complete [HTTP header table](protocols/http.md). ## Why the money cannot be redirected @@ -95,6 +95,11 @@ merchant destination. The signature covers `from`, `to`, `value`, id through the EIP-712 domain. A broadcaster can execute that transfer or nothing; it cannot change the destination or amount. +With [Permit2](configuration.md#permit2), the buyer signs +`PermitWitnessTransferFrom`, binding the token, amount, nonce, deadline, +x402 exact proxy as spender, and merchant destination as witness. The proxy +transfers only the signed amount to that destination. + So the gateway can confirm a payment without holding the funds or being able to redirect them. @@ -115,7 +120,7 @@ to redirect them. | copy reaches gateway reservation before the original settles | `PAYMENT_REPLAYED` | no | | provider/RPC unavailable during verification | `PAYMENT_PROVIDER_UNAVAILABLE` | no | | settlement transaction fails | `PAYMENT_SETTLEMENT_FAILED` | no | -| backend fails before settlement (`authorization` flow) | `BACKEND_ERROR` / `BACKEND_TIMEOUT` | no; no payment settled | +| backend fails before settlement (`authorization` flow) | `BACKEND_ERROR` / `BACKEND_TIMEOUT` | no; nothing settled; x402 can retry | | backend fails after settlement (`upfront` flow) | `BACKEND_ERROR` / `BACKEND_TIMEOUT` | no; payment recorded, delivery failed | After settlement, a backend failure needs reconciliation rather than a @@ -125,15 +130,22 @@ rollback. The gateway records a `settled` payment attempt and a ## Replay: two independent defenses 1. **On-chain.** EIP-3009 marks `authorizationState[from][nonce]` used; a second - `transferWithAuthorization` with the same nonce reverts. This prevents a - double *spend*. -2. **In the gateway.** A replayed authorization could still be presented twice - in quick succession and unlock a second delivery before the first settles. - So the pipeline reserves `replayKey`, derived only from - `(chainId, asset, payer, nonce)`, under a `UNIQUE` constraint **before** - calling `settle`. A duplicate is `PAYMENT_REPLAYED`: it returns 409 while - the first attempt is unfinished or its status is unavailable, and 402 with - a fresh challenge when its stored status is treated as finished. + `transferWithAuthorization` with the same nonce reverts. Permit2 marks the + owner's nonce used in its own bitmap, across every token. A Solana cluster + refuses a transaction it has already processed, and one whose blockhash + has expired. NEAR refuses a delegate whose nonce is at or below its access + key's nonce. This prevents a double *spend*. +2. **In the gateway.** Concurrent submissions could unlock a second delivery + before the first settles. The pipeline reserves `replayKey`, derived from + `(chainId, asset, payer, nonce)` for EIP-3009, `(chainId, Permit2, owner, nonce)` + for Permit2, the network and SHA-256 of the transaction message for Solana, + or `(network, sender, public key, nonce)` for NEAR, under a `UNIQUE` + constraint **before** calling `settle`. A duplicate returns + `PAYMENT_REPLAYED`: 409 while the first attempt is unfinished or its status is + unavailable; 402 with a fresh challenge when its stored status is treated as finished. + A rejected x402 attempt settled nothing: the backend failed, settlement was + refused or the facilitator never submitted it. The same authorization can + reserve it again. MPP keeps the 402: a credential is single-use per challenge. Because the key comes from the authorization rather than the request, replaying it against another request still collides. @@ -141,7 +153,7 @@ it against another request still collides. A resource that also requires an AP2 mandate gets a third, independent reservation, in its own database and on its own key. It is claimed before the payment replay key and released only by a failure that provably moved no money. -See [ap2.md](ap2.md#replay-states). +See [AP2](protocols/ap2.md#replay-states). ## Amounts @@ -150,18 +162,24 @@ floats. Conversion to base units (6 decimals for USDC) happens inside the payment provider. An amount with more precision than the asset supports is a configuration error, not a rounding opportunity. +An x402 payment in the network's default USDC records `currency: USDC`, +keeping a resource's `USD` price as `metadata.quote` on settlement. Other +configured tokens use the resource's currency label. + ## Local deterministic settlement The demo and CI settle for real, on a chain they own: -- Anvil, `--chain-id 84532`, advertised as the CAIP-2 network `eip155:84532`. - That id is shared with the public Base Sepolia testnet, so nothing infers - "public network" from it; `health()` probes for `anvil_nodeInfo` instead. +- Anvil on its default chain id 31337, advertised as the CAIP-2 network + `eip155:31337`. `health()` also probes for `anvil_nodeInfo`, because an RPC + can report any chain id. - `MockUSDC`: 6 decimals, EIP-3009, EIP-712 domain `("MockUSDC", "2")`. - Anvil's well-known accounts as deployer/facilitator, merchant and buyer, labeled `LOCAL DEVELOPMENT ONLY - DO NOT FUND`. - The E2E asserts the buyer's balance falls and the merchant's rises by exactly the price, and that the receipt carries a real transaction hash. +- A second E2E settles on Anvil chains 31338 and 31339 through a loopback + SDK `x402Facilitator`, exercising the remote path used for public networks. No public RPC, no public chain, no hosted facilitator, no real money. @@ -171,21 +189,18 @@ Both x402 and MPP have settled on Base Sepolia (`eip155:84532`) and Base mainnet (`eip155:8453`) through the same pipeline and remote facilitator model. Public-network configuration follows these rules: -- **The deployment mode is derived, not declared.** `local`, `testnet` and - `mainnet` come from the network *and* the facilitator together, because chain - id 84532 belongs to both the local dev chain and public Base Sepolia. Nothing - infers "public network" from the id alone. +- **The deployment mode comes from the network.** `eip155:31337` is the local + chain; every other network is the testnet or mainnet it names, and the + in-process facilitator is refused on all of them. - **Mainnet is refused unless every guardrail is satisfied** - an explicit `allowMainnet`, a remote facilitator over HTTPS, a second explicit acknowledgment if no facilitator credential is configured, a non-development - settlement destination, and canonical USDC with the EIP-712 domain the token - reports. Config validation checks these before startup, so - `agent-commerce validate` catches them and the gateway will not start - without them. See [configuration.md](configuration.md). + settlement destination, and canonical USDC with its EIP-712 domain on + built-in mainnets. Config validation checks these before startup; + `agent-commerce validate` reports missing guards. See [configuration.md](configuration.md). - **`assetName` is part of that.** Base mainnet's USDC reports `"USD Coin"`, Base Sepolia's reports `"USDC"`, and the buyer signs that string into their - EIP-712 domain. Config validation rejects a mainnet mismatch before the buyer - signs. + EIP-712 domain. Config validation rejects a mismatch before the buyer signs. - **`health()` asks the right question per mode.** In local mode it probes `anvil_nodeInfo`, so a local facilitator pointed at a real node reports unhealthy and `/ready` refuses to serve. In remote mode it asks the diff --git a/docs/protocols.md b/docs/protocols.md index 56997db..9fdcb6e 100644 --- a/docs/protocols.md +++ b/docs/protocols.md @@ -1,26 +1,27 @@ # Protocol support -This page records the implemented subset and pinned revision for each protocol. +This page lists each protocol's status, pinned revision and implemented +subset. Each protocol's page describes that subset in detail. ## Support matrix -| Protocol | Status | Revision | Implemented subset | -| -------- | ------------ | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | -| **HTTP** | Supported | native | resource routes and rail-specific payment headers | -| **MCP** | Supported | MCP `2026-07-28` and `2025-11-25` through `@modelcontextprotocol/server@2.3.0` | tool discovery, invocation, payment-required and error mapping | -| **x402** | Supported | x402 v2, `@x402/core@2.25.0`, `@x402/evm@2.25.0` | `exact` EVM/EIP-3009 challenge, verification, settlement and replay binding | -| **A2A** | Experimental | v1.0.1; negotiation `1.0`; `JSONRPC` binding | Agent Card, `SendMessage`, paid flow and the x402 extension | -| **ACP** | Experimental | stable snapshot `2026-04-17`; REST binding | discovery and the five checkout operations | -| **AP2** | Experimental | v0.2.0, tag 2026-04-28, commit `b4587ac`; Direct mode | closed Checkout Mandate verification before settlement | -| **MPP** | Experimental | drafts at `tempoxyz/mpp-specs@806fdb8`; `mppx@0.13.1` | `charge`/`evm`/EIP-3009 over HTTP, MCP and A2A | -| UCP | Planned | - | no implementation | +| Protocol | Status | Revision | Implemented subset | +| -------- | ------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| **HTTP** | Supported | native | resource routes and rail-specific payment headers | +| **MCP** | Supported | MCP `2026-07-28` and `2025-11-25` through `@modelcontextprotocol/server@2.3.0` | tool discovery, invocation, payment-required and error mapping | +| **x402** | Supported | x402 v2, `@x402/core@2.25.0`, `@x402/evm@2.25.0`, `@x402/svm@2.25.0`, `@x402/near@2.25.0` | `exact` on EVM through EIP-3009 or Permit2, on Solana, and on NEAR (partial): challenge, verification, settlement and replay binding | +| **A2A** | Experimental | v1.0.1; negotiation `1.0`; `JSONRPC` binding | Agent Card, `SendMessage`, paid flow and the x402 extension | +| **ACP** | Experimental | stable snapshot `2026-04-17`; REST binding | discovery and the five checkout operations | +| **AP2** | Experimental | v0.2.0, tag 2026-04-28, commit `b4587ac`; Direct mode | closed Checkout Mandate verification before settlement | +| **MPP** | Experimental | drafts at `tempoxyz/mpp-specs@806fdb8`; `mppx@0.13.1` | `charge`/`evm`/EIP-3009 over HTTP, MCP and A2A | +| UCP | Planned | - | no implementation | Experimental components are disabled unless configured and support only the -subsets below. Planned means there is no adapter or endpoint. +subsets their pages describe. Planned means there is no adapter or endpoint. AP2 is an authorization method, not a transport or payment rail. It appears in `authorizationProviders` in gateway discovery and has no AP2-specific mount or -discovery endpoint. See [ap2.md](ap2.md). +discovery endpoint. See [AP2](protocols/ap2.md). An AP2-gated resource still requires its configured payment proof. A mandate authorizes the purchase but does not settle it. @@ -30,683 +31,20 @@ and providers, including `supportedSpec`, `capabilities`, `unsupported` and `status`. When enabled, A2A, ACP and AP2 also print their detailed unsupported lists in `agent-commerce doctor`. -## MCP - -The MCP endpoint supports `2026-07-28`. The SDK's stateless fallback also -accepts 2025-era clients that use `initialize`. `server/discover` advertises -only `2026-07-28`. - -Resources with `expose: [mcp]` become MCP tools: - -- tool name: resource id; -- description: resource description, plus price and proof instructions when - paid, and authorization instructions when the resource requires it; -- input schema: canonical resource schema plus an optional `_payment` string - for a paid resource and an optional `_authorization` object - (`{ method, payload }`) for a resource that requires authorization. - -Calling an unknown tool returns JSON-RPC error `-32602`. For requests -without modern MCP headers, malformed `tools/call` params such as a non-string -`name` or non-object `arguments` return the same code with a short message. -The SDK handles requests carrying `Mcp-Method` or a `MCP-Protocol-Version` of -`2026-07-28` or later. - -Only `2025-03-26` has JSON-RPC batches. A batch whose `MCP-Protocol-Version` -names another revision gets HTTP 400 and `-32600`; the SDK serves a batch sent -without that header. `tools/list` is not paginated, so a `cursor` gets -`-32602`. Under `2026-07-28` the list carries `ttlMs: 60000` and -`cacheScope: "public"`. The adapter's own transport errors use `-32600` for a -405 or an unreadable body and `-32603` for a 500 or 503. - -### Payment over MCP - -A paid tool accepts a proof in the gateway's `_payment` argument or the -selected rail's MCP `_meta` carrier. The latter lets x402 and MPP clients -use their native payment format: - -| Rail | Challenge | Proof | Delivered result | -| ---- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------- | -| x402 | v2 `PaymentRequired` in `structuredContent`, `content[0].text` and `_meta["x402/error"]` | `_meta["x402/payment"]`, a `PaymentPayload` object or its base64 JSON string | `_meta["x402/payment-response"]` | -| MPP | `_meta["org.paymentauth/payment-required"]` with the challenges | `_meta["org.paymentauth/credential"]`, a credential object | `_meta["org.paymentauth/receipt"]` | - -The x402 MCP transport defines an object proof. The gateway also emits challenges in `_meta["x402/error"]` and accepts -base64 string proofs used by the `cloudflare/agents` client; that client has -not been tested here. An undecodable string gets `PAYMENT_INVALID` and a fresh challenge. The MPP -credential and receipt carriers follow `draft-payment-transport-mcp-00`. - -If both carriers are present, the rail's `_meta` proof wins. A placeholder -in `_payment` therefore cannot break a client wrapper's paid retry. Tool -descriptions and payment-required messages name both carriers. - -The MPP draft uses JSON-RPC errors `-32042` for challenges and `-32043` for -refusals. This gateway puts both in tool-result `_meta`, which `mppx` accepts -but the draft does not define. MCP `2026-07-28` reserves `-32042` for protocol -use. The gateway reads payment `_meta` only inside `params` and does not -advertise payment support in its capabilities. - -Without a proof, the tool returns `isError: true` and the gateway's -payment-required envelope in `structuredContent`. For x402, the v2 -`PaymentRequired` fields appear alongside that envelope so x402 clients can -read them: - -```jsonc -{ - "x402Version": 2, - "resource": { /* v2 resource descriptor */ }, - "accepts": [ /* x402 payment requirements */ ], - "status": "payment-required", - "code": "PAYMENT_REQUIRED", - "requestId": "...", - "resourceId": "market_report", - "message": "Payment of 0.01 USDC is required for resource \"market_report\". …", - "payment": { - "provider": "x402", - "version": "2", - "amount": "0.01", - "currency": "USDC", - "destination": "0x…", - "network": "eip155:84532", - "asset": "0x…", - "expiresAt": "…", - "accepts": [ /* provider-native requirements */ ], - "envelope": { /* provider-native challenge document */ } - } -} -``` - -For x402, `envelope` is the complete v2 `PaymentRequired` document and -`accepts` is its requirements list. A `_payment` retry carries the base64 proof. - -For MPP, `envelope.wwwAuthenticate` is the serialized `WWW-Authenticate` value -and `envelope.challenges` holds the challenge objects. A `_payment` retry -carries the complete `Authorization: Payment ...` credential. - -When the pipeline supplies a retry challenge, a 402 refusal includes it in -the rail's format. The x402 challenge carries the reason in `error`, in both -`structuredContent` and `_meta["x402/error"]`. MPP adds a `problem` with its -problem type, a `detail` sentence and the reason token in `reason`. An x402 -settlement failure or a backend failure after settlement also includes -`_meta["x402/payment-response"]`. Every failure retains the gateway's error -envelope in `structuredContent`. - -For payment-required and error results, `content[0].text` is a JSON copy of -`structuredContent`; `content[1].text` is a sentence for readers. Unexpected -exception messages and stack traces are omitted. - -### MCP exclusions - -The adapter does not implement MCP resources, prompts, sampling, completions, -elicitation, roots, logging, tasks or tool-list-change notifications. It has -no MCP authorization, sessions, subscriptions, pagination or HTTP+SSE -transport. The gateway, rather than the MCP adapter, enforces Host and Origin -checks. - -The MCP adapter maps payment carriers and results. It does not verify or -settle payments or call merchant backends. - -## A2A - -**Experimental.** Enable with `protocols.a2a.enabled: true`. - -| Property | Value | -| ------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| Binding | JSON-RPC 2.0 over HTTP(S) | -| Method | `SendMessage`, not legacy `message/send` | -| Version | `A2A-Version: 1.0` header, or an `A2A-Version` query parameter when the header is absent; patch suffixes are accepted but ignored | -| Agent Card | `GET /.well-known/agent-card.json` | -| Default mount | `/a2a` | -| Task model | synchronous; terminal except an unpaid x402 extension task | - -Each A2A resource becomes an Agent Card skill. Paid skills have a `paid` tag -and a price in their description. A2A v1.0 has no input-schema field for -skills, so each skill includes an illustrative JSON call example with -required input. The agent description explains the call shape and points to -`/api/resources` for input schemas. It also names -`input._payment` when any skill is paid. Enabling the adapter requires at -least one A2A resource. - -The card has a content-based `ETag` and `Cache-Control: public, max-age=300`. -A matching `If-None-Match` request receives `304` without a body. - -### Invocation and payment - -The adapter requires a non-empty `messageId` and exactly one structured data -part in `SendMessage`. The part has this shape: - -```json -{ - "data": { - "resource": "market_report", - "input": { "symbol": "ETH" } - }, - "mediaType": "application/json" -} -``` - -A paid retry adds the same reserved field used by MCP: - -```json -{ - "data": { - "resource": "market_report", - "input": { - "symbol": "ETH", - "_payment": "" - } - }, - "mediaType": "application/json" -} -``` - -`_payment` is this gateway's carrier, not part of A2A or of either rail. MPP -defines no A2A binding, so `_payment` is the only way to pay an MPP skill. - -For a new purchase, the adapter refuses text, file, inline-byte and URL -parts, multipart messages, non-user roles, and task or context continuation. -The text and file refusals name the expected part shape. A payment message can -resume a pending x402 extension task. - -### x402 extension - -A client activates the extension with -`A2A-Extensions: https://github.com/google-a2a/a2a-x402/v0.1` and can then -pay without `_payment`. The gateway uses x402 v2 documents from the -[x402 A2A transport](https://github.com/x402-foundation/x402/blob/main/specs/transports-v2/a2a.md). -Its examples use lowercase task states and roles, `kind` on parts, and the -`X-A2A-Extensions` header. This gateway uses the A2A 1.0 forms: -`TASK_STATE_INPUT_REQUIRED`, `ROLE_AGENT`, parts without `kind`, and -`A2A-Extensions`. The Agent Card advertises the extension as optional, with -`params: { "x402Version": 2 }`, when x402 is a skill's selected payment rail. - -A response carries the `A2A-Extensions` header when its task status message -lists the extension: the input-required task, and every task answered to a -payment message, even one sent without the header. Other responses, such as an -MPP skill's task or a JSON-RPC error, omit it. An extension status message -carries `contextId`, `taskId`, `extensions: []` and the -`x402.payment.*` metadata. - -1. An x402 paid call without a proof returns `TASK_STATE_INPUT_REQUIRED`. The status - message carries `x402.payment.status: payment-required` and the v2 - `PaymentRequired` in `x402.payment.required`. -2. The client sends a message with that `taskId` and sets - `x402.payment.status` to `payment-submitted` in the message metadata. It - puts the `PaymentPayload` in `x402.payment.payload`. The gateway reruns the - stored purchase through input validation, payment verification, replay - reservation and settlement. -3. A successful purchase returns `TASK_STATE_COMPLETED`, - `payment-completed` and a settlement entry in `x402.payment.receipts`. A - payment failure returns `TASK_STATE_FAILED`, `payment-failed`, an - `x402.payment.error` code and a failed receipt. If the backend fails after - settlement, the task fails but its status message reports - `payment-completed` with the settlement entry. A client can also send - `payment-rejected` to end the task without paying. - -The gateway holds unpaid tasks in process memory until the payment -requirement expires, for no longer than one hour. If the requirement has no -expiry, the task waits ten minutes. When 256 tasks are pending, the oldest is -evicted. A restart or a payment routed to another instance loses the task; -the client receives `TaskNotFoundError` and must start a new purchase. -`GetTask` and `CancelTask` also answer `TaskNotFoundError` for a pending task, -which stays payable. - -These follow-ups return `-32602` and keep the task pending: a malformed payment -message, a `contextId` that differs from the task's, a role other than -`ROLE_USER`, and a malformed `_authorization`. Clients published for this -extension URI send x402 v1 payloads over A2A 0.x. The 0.x role gets `-32602`, -and an x402 v1 payload fails the task with -`x402.payment.error: invalid_x402_version`. A failed receipt always names a -`network`, the requirement's when the error carries none. - -If the first message carried no AP2 mandate, the payment message may carry one -in a data part as `{"data": {"input": {"_authorization": {...}}}}`. It is -verified against the stored purchase, and the rest of that part is ignored. A -mandate from the first message takes precedence. - -The extension applies only to x402. An MPP resource still returns a terminal -task. - -### Results - -These outcomes return a terminal task with one artifact: - -| Outcome | State | Artifact data | -| --------------------------------- | ---------------------- | -------------------------------------------------------- | -| Delivered | `TASK_STATE_COMPLETED` | Merchant response and `agent-commerce/delivery` metadata | -| Payment required on terminal path | `TASK_STATE_FAILED` | Shared payment-required envelope | -| Commerce failure | `TASK_STATE_FAILED` | Shared error envelope | - -Every `TASK_STATE_FAILED` task has a `ROLE_AGENT` status message whose text is -the envelope's `message`; after an extension payment, the text summarizes the -payment outcome instead. Only extension status messages carry `metadata` and -`extensions`. - -With the x402 extension, an unpaid task is `TASK_STATE_INPUT_REQUIRED`; its -payment terms are in the status message metadata. If the client declines, -the task ends with no artifact. - -A non-object merchant body is wrapped as `{ "value": ... }`. Without the x402 -extension, payment required is terminal; the buyer sends a new message with -the proof. - -The adapter returns JSON-RPC errors for invalid requests and A2A features it -cannot serve. Alongside `-32700`, `-32600`, `-32601`, `-32602` and `-32603`, it -uses these A2A codes: - -| Code | A2A error | Returned for | -| -------- | ----------------------------------- | ----------------------------------------------------------------------------------------------- | -| `-32001` | `TaskNotFoundError` | an unknown, expired or completed `taskId`; `GetTask` and `CancelTask` for any id | -| `-32003` | `PushNotificationNotSupportedError` | the four push notification configuration methods | -| `-32004` | `UnsupportedOperationError` | other unsupported methods and message features listed below | -| `-32005` | `ContentTypeNotSupportedError` | a text, file, inline-byte or URL part, or a non-JSON `mediaType` | -| `-32009` | `VersionNotSupportedError` | an unsupported version; a missing header and parameter, or an empty header, is treated as `0.3` | - -Multipart messages, a `contextId` and non-empty `referenceTaskIds` return -`-32004`. Missing `messageId` or a non-user role returns `-32602`. A request -without an `id` returns `-32600` without running. Each A2A error carries a -`google.rpc.ErrorInfo` in `error.data`, with a reason such as -`VERSION_NOT_SUPPORTED` and the domain `a2a-protocol.org`. - -### A2A exclusions - -Unsupported methods are `SendStreamingMessage`, `GetTask`, `ListTasks`, -`CancelTask`, `SubscribeToTask`, the four task-push-notification-config -methods and `GetExtendedAgentCard`. The adapter also excludes REST and gRPC -bindings, SSE, persistent tasks, task continuation beyond the pending x402 -payment, push delivery, multi-turn continuation, authenticated -extended cards, A2A authentication and other artifact types. - -The A2A adapter accepts `_payment` but does not verify or settle payments -or call merchant backends. `@a2a-js/sdk` is a test-only dependency. - -## ACP - -**Experimental.** Enable with `protocols.acp.enabled: true`. - -| Property | Value | -| ----------------------------------- | ------------------------------------------------------------------------ | -| Binding | REST over HTTP(S) | -| Snapshot and accepted `API-Version` | `2026-04-17` only | -| Vendored schema | `src/protocols/acp/spec/2026-04-17/` | -| Discovery | `GET /.well-known/acp.json` | -| Default mount | `/acp` | -| Service | `checkout` only | -| Authentication | bearer token on every checkout route | -| Idempotency | durable key on every POST, completed rows retained for at least 24 hours | - -The schema is vendored and never fetched. Runtime code imports only the dated -snapshot; it reads nothing from `spec/unreleased`. The adapter validates its -discovery document at startup and fails with `CONFIG_INVALID` if it does not -match. A version upgrade adds a new snapshot rather than editing this one. - -### Required headers - -| Header | Scope | Rule | -| ----------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `Authorization` | every checkout route | `Bearer `, constant-time comparison; a refusal is 401 with `WWW-Authenticate: Bearer` | -| `API-Version` | every checkout route | exactly `2026-04-17`; missing and unsupported are distinct errors | -| `Content-Type` | every POST | if present, `application/json` or any media type ending in `+json`; an empty body may omit it, but a supplied non-JSON type still gets 415 | -| `Idempotency-Key` | every POST | 1-255 printable ASCII characters | -| `Request-Id` | optional | bounded and filtered; echoed on every checkout response, refusals included; separate from the gateway request id | - -An absent, older, newer or malformed API version is not mapped to the supported -snapshot. - -### Headers sent to the merchant - -The gateway forwards `Accept-Language`, `User-Agent`, `Request-Id`, -`API-Version`, `Signature` and `Timestamp` to the merchant when present, -printable ASCII and at most 2048 characters long. It never forwards the -agent's `Authorization`, the caller's `Idempotency-Key`, which the derived key -replaces, or any other caller header. A `backend.headers` entry of the same name -wins, so an operator who wants a fixed `User-Agent` at the merchant must -configure one. - -`Signature` and `Timestamp` pass through unverified. The gateway re-serializes -the JSON body, so a merchant that verifies the raw request bytes fails; only one -that verifies a canonical form of the parsed JSON can succeed. No signing agent -has been tested against this path. - -### Checkout operations - -| Operation | Route | Success | Canonical input | -| ------------------------- | ---------------------------------------------- | ------- | ----------------------------------- | -| `createCheckoutSession` | `POST {mount}/checkout_sessions` | `201` | `{ body }` | -| `updateCheckoutSession` | `POST {mount}/checkout_sessions/{id}` | `200` | `{ path, body }` | -| `getCheckoutSession` | `GET {mount}/checkout_sessions/{id}` | `200` | `{ path }` | -| `completeCheckoutSession` | `POST {mount}/checkout_sessions/{id}/complete` | `200` | `{ path, body }` | -| `cancelCheckoutSession` | `POST {mount}/checkout_sessions/{id}/cancel` | `200` | `{ path }`, plus `body` if supplied | - -Each operation maps to a resource in -`protocols.acp.checkout.operations`. All five mappings are required, and each -operation must use a different resource. Each resource must include -`expose: [acp]`, be free at the Agent Commerce invocation layer and have an -empty payments list. - -Request bodies are validated against the pinned schema before pipeline -execution; an invalid request returns `400 invalid_request_body`. Successful -merchant responses are validated before return. An invalid merchant document or -success status outside the ACP contract becomes a safe `processing_error`; the -merchant body is not relayed or cached. - -Validation accepts unknown values in the `supported` and `required` lists of -`capabilities.interventions`, in both directions, and passes them through, -because the capability negotiation RFC (4.6.2) says to ignore unknown -capability values. It also accepts an unknown value of an enum whose schema -description calls it extensible. Unknown fields, and unknown values of every -other enum, are still refused. - -Completion accepts the schema's two outcomes: a completed session with an -order, or an ordinary non-completed session without one. - -See [examples/acp-checkout](../examples/acp-checkout). - -### ACP payment boundary - -ACP `payment_data` belongs to the merchant checkout flow. It remains ordinary -backend input and is not converted into an Agent Commerce `_payment`, x402 or -MPP proof. - -Config rejects paid checkout resources. If the pipeline nevertheless returns a -payment challenge, the adapter logs the broken deployment and returns a safe -500 because ACP has no Agent Commerce challenge representation. - -The ACP adapter contains no payment logic and never calls a merchant backend -directly; it sends one canonical request through the pipeline. Its bearer token -is used only for constant-time request authentication and is not written to the -idempotency database, logs or responses. - -### Idempotency - -The adapter validates `Idempotency-Key` before reading a POST body. After body -parsing, it claims the key before calling the merchant. The scope is -`(deployment public base URL, concrete endpoint path, caller key)`; bearer -credentials are not part of it. Two clients using the same caller key on the -same endpoint therefore share one claim. - -The body fingerprint uses parsed JSON. Object key order and numeric spelling -such as `1` versus `1.0` normalize; array order, type, and null versus absence -remain distinct. - -| Situation | Response | -| --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| first request | atomically claimed before merchant execution | -| same key and body, still running | `409 idempotency_in_flight` with `Retry-After` | -| same key and body, completed | stored response with `Idempotent-Replayed: true`; no merchant call | -| same key, different body | `422 idempotency_conflict`; no merchant call | -| ACP response below 500 after the claim, including pipeline `INPUT_INVALID` | cached | -| ACP response 500 or higher after the merchant may have run | `409 idempotency_unresolved` on retry by default; with `merchantIdempotent`, the claim is released for a retry | -| ACP response 500 or higher when the merchant was not called or answered 429 | claim released; a clean retry may run | - -The ACP response status controls claim finalization. The gateway treats a -merchant 429 as unprocessed, releases the claim and returns ACP 503. Merchants -must send 429 before side effects. A merchant 503 also maps to ACP 503 but may -follow partial work, so its claim stays unresolved unless -`merchantIdempotent` is set. Merchant 401 and 403 map to ACP 502 and stay -unresolved. A post-claim pipeline `INPUT_INVALID` maps to ACP 400 and is cached. - -Request guards run before the claim, so their failures are never stored. These -include routing, authentication, header, body, JSON, schema and content-type -errors. The response echoes a filtered `Request-Id`; a POST also echoes a -well-formed `Idempotency-Key`, even when a guard fails before checking it. A -malformed key is never echoed, and routing 405 includes `Allow`. - -ACP requires a fresh attempt after a 5xx. By default, the gateway keeps the -claim when the merchant may have acted, to avoid a duplicate order. Set -`protocols.acp.idempotency.merchantIdempotent: true` only if the merchant -deduplicates by the derived key. The gateway then releases the claim after a -5xx and frees claims left in flight when it next starts. - -An in-flight row becomes completed, released or unresolved when the request -finishes. A crash or a SQLite error while finalizing can leave it in-flight; -the next gateway start marks it unresolved by default or frees it when -`merchantIdempotent` is enabled. Startup logs the count. This assumes one -gateway process per idempotency database file. - -Only completed records expire, after at least 24 hours. Unresolved records -remain for operator reconciliation. This avoids rerunning an unknown remote -side effect but is not an exactly-once transaction across HTTP and SQLite. -Expired completed rows are purged lazily when a later request claims a key; -there is no background sweep. - -The schema-v1-to-v2 migration rebuilds the idempotency table empty because its -bearer-token-scoped keys cannot be translated. If it drops non-completed rows, -startup logs a warning that tells the operator to reconcile them with the -merchant before trusting a retry. - -For side-effecting ACP operations, the gateway sends the merchant a derived -`Idempotency-Key`: SHA-256 over deployment, endpoint and caller key. It is -stable across retries, restarts and bearer-token rotation; differs across -endpoints and deployments; and reveals no caller key. A static -`Idempotency-Key` in backend headers is replaced by this derived value. - -The merchant should store and replay results under the derived key. In the -default mode, the gateway holds uncertain claims to block duplicate checkout -calls. With `merchantIdempotent` enabled, the merchant must prevent duplicates -on retry. A proxy retry outside the gateway can also repeat a side effect if -the merchant does not deduplicate. - -### ACP errors - -| Cause | Response | -| --------------------------------------------------------- | -------------------------------------------------------------- | -| pipeline `INPUT_INVALID` | `400 invalid_request_body` | -| merchant 404 | `404 checkout_session_not_found` | -| merchant 405 on cancel | `405 checkout_session_not_cancelable` | -| merchant 405 on another operation | `405 method_not_allowed` | -| merchant 400 or 422 | the same status, `invalid_request_body` | -| valid merchant ACP error on a relayed status | that status, with its `type`, safe `code` and optional `param` | -| merchant 409 | `409 checkout_session_conflict` | -| merchant 429 or 503 | `503 service_unavailable` with `Retry-After` | -| merchant 401, 403, another 5xx or another unmapped status | `502 processing_error` | -| backend timeout | `504 service_unavailable` | -| load shedding | `503 service_unavailable` | -| mapping, storage or unexpected payment challenge | `500 processing_error` | - -ACP errors contain `type`, `code`, `message`, a safe `param` when available, -and `supported_versions` for API-version errors. For a valid merchant ACP error -on a relayed status, the gateway uses its `type` and a snake_case `code` of at -most 64 characters. A code starting with `idempotency_` is not relayed, -because only the gateway issues those codes. It also includes a bounded, -printable `param` when present. The gateway supplies its own `message`; no -other merchant body fields are sent to the agent. Merchant 401/403 is not -presented as a failure of the agent's ACP bearer token. - -`Retry-After` on a 503 for a merchant 429 or 503 is the merchant's value when -it is whole seconds, clamped to 1-300, and 5 otherwise. - -Session ids such as `gid://shop/Checkout/1` are accepted when encoded as one -URL segment. Empty ids and ASCII control characters are refused. Merchant -markdown containing an HTML tag or comment is refused as `processing_error` -before it reaches the agent. - -### ACP discovery - -```json -{ - "protocol": { - "name": "acp", - "version": "2026-04-17", - "supported_versions": ["2026-04-17"] - }, - "api_base_url": "https://merchant.example.com/acp", - "transports": ["rest"], - "capabilities": { "services": ["checkout"] } -} -``` - -Discovery is public while ACP is enabled and uses -`Cache-Control: public, max-age=3600`. Optional documentation, currency, -locale and intervention metadata appears only when configured. Tokens, backend -URLs, resource ids and database paths are excluded. - -### ACP exclusions - -The adapter excludes carts, feed and standalone orders; delegated payment and -authentication; the ACP MCP binding; webhooks and outbound delivery; the client -role; `Signature` and `Timestamp` verification; discounts and the general -extension framework; seller-backed payment handlers; and other ACP versions. -A `Signature` header does not replace bearer authentication. - -No ACP SDK ships. Conformance tests use the vendored official schema and examples. - -## x402 - -The x402 provider supports v2 `exact` on EVM through EIP-3009 -`transferWithAuthorization`. Requirements explicitly include the token name -and version used by the EIP-712 domain. - -Verification checks the scheme, signature, recipient, amount, validity window, -payer balance, network and asset. Local refusals use x402 error codes where -available, such as `invalid_network`. The provider derives a replay key from -the authorization, which the pipeline reserves before settlement. A successful -settlement records the transaction hash. - -The signature covers neither the flow nor the resource, so the proof's -`accepted` must match the offer: amount, asset, `payTo` ignoring letter case, -and every `extra` key the gateway sent, where an absent `extra.paymentFlow` -means `authorization`. A `resource.url` in the proof must equal the -challenge's. A mismatch is `invalid_payment_requirements`, refused before the -facilitator is called. `maxTimeoutSeconds` and `extra` keys added by the client -are not compared. The facilitator receives the payload without `resource`, -since its `resource://` URL is the gateway's own identifier. - -x402 v1 is not accepted. A payload declaring an `x402Version` other than 2 is -refused as `invalid_x402_version` on every carrier. A request carrying only the -v1 `X-PAYMENT` header gets the 402 challenge with -`error: "invalid_x402_version"` in `PAYMENT-REQUIRED`. - -`payments.x402.paymentFlow` chooses when an x402 payment settles, and -`payments.mpp.paymentFlow` does the same for MPP. A resource's `paymentFlow` -overrides the setting of whichever rail serves it: - -| Flow | Order | Backend fails | Settlement refused after backend | -| ------------------------- | ------------------------------------- | ------------------------------------------------------ | -------------------------------- | -| `authorization` (default) | verify, reserve, call backend, settle | no settlement; attempt recorded as rejected | response withheld; 402 | -| `upfront` | verify, reserve, settle, call backend | payment stays settled; receipt records failed delivery | not applicable | - -The gateway omits `extra.paymentFlow` for the default `authorization` flow. -It sets `extra.paymentFlow: "upfront"` for the other flow, as required by -[x402 v2](https://github.com/x402-foundation/x402/blob/main/specs/x402-specification-v2.md#61-asset-transfer-methods-and-payment-flow-models). -The gateway verifies the proof before calling the backend in either flow. -For `upfront`, it adds a read-only check before the x402 v2 -`settle → resource` sequence. Under `authorization`, the backend may finish -work before settlement. A refusal leaves that work unpaid; an uncertain -settlement leaves its payment status unknown. In both cases, the gateway -withholds the backend response. Use `upfront` when backend side effects must -follow payment. Backend time counts toward `maxTimeoutSeconds`: the payment -authorization must remain valid at settlement. - -A backend failure under `authorization` still consumes the authorization at -this gateway, although its nonce stays unused on chain. Presenting the same -proof again returns 402 with `invalid_exact_evm_nonce_already_used`; the client -signs a fresh authorization. - -Unsupported: SVM, Permit2, the `upto`, `batch-settlement` and `auth-capture` -schemes, multi-asset routing and dynamic pricing. - -Facilitator authentication supports `none`, `bearer` and `cdp`; config rejects -other auth types. Remote mode works on Base Sepolia. Config accepts local mode -there with a non-Anvil key, but local health checks require Anvil, so a -deployment against public Base Sepolia reports unhealthy. Base mainnet requires -remote mode and the guardrails in -[configuration.md](configuration.md#mainnet-guardrails). - -## MPP - -**Experimental.** Enable with `payments.mpp.enabled: true`. - -The gateway targets `draft-httpauth-payment-01`, -`draft-payment-intent-charge-00`, and `draft-evm-charge-00` at -`tempoxyz/mpp-specs@806fdb8`. It pins the pre-1.0 `mppx@0.13.1` as an -optional peer. For EIP-3009, the gateway follows `mppx`: it hashes -`JSON.stringify([id, realm])` with keccak256 to derive the nonce. The EVM -draft instead hashes `abi.encodePacked(id, realm)`. Credentials using the -draft nonce, including those produced by `mppx` 0.12 or earlier, fail -verification with `wrong_nonce`. - -Later drafts or `mppx` releases may change the wire format again, so MPP -remains experimental. - -The implemented profile is `charge` intent, `evm` method and EIP-3009 -`authorization` credential, using USDC on Base Sepolia or Base. The challenge -is HMAC-bound to the resource and terms. The credential is verified locally, -then checked by the x402 settlement provider. Both checks run in `verify`, -before replay reservation; settlement broadcasts later. - -For an HTTP request with a body, the challenge includes an RFC 9530 -`sha-256` digest of the original body bytes. The paid retry must send the same -bytes: even equivalent JSON with different spacing returns -`body_digest_mismatch`. MCP and A2A challenges omit the digest because their -payment proof travels in the request body. - -MPP and x402 derive the same replay identity for the same EIP-3009 -authorization, so reuse across rails collides in one receipt store. - -`payments.mpp.paymentFlow` takes the [x402 flow values](#x402), with the same -`authorization` default. Under `authorization`, backend time counts toward the -challenge lifetime, `challengeTtlSeconds` (default 300), because `mppx` signs -`validBefore` equal to the challenge's `expires`. If the backend outlasts the -remaining window, settlement is refused and the response withheld, leaving the -backend's work unpaid as with x402. Use `upfront` for a backend with side -effects. - -Over HTTP, the unpaid 402 and MPP verification, replay, settlement, and -provider errors use `application/problem+json` and the core draft's problem -types; the unpaid one is `payment-required`. Envelope members such as `code`, -`message`, and `details` stay as extension members, and `status` is the numeric -HTTP status. `detail` is a sentence; the reason token stays in `message` and -`details.reason`. A successful `Payment-Receipt` includes `challengeId` and -`chainId`. - -A credential whose challenge already paid for a request is `invalid-challenge` -with reason `challenge_already_used`. The gateway learns this from the -facilitator's reason or the token's `authorizationState`. When neither shows -it, including when that read fails, the facilitator's reason is reported -instead, under `verification-failed`. - -Terminate TLS in front of deployed MPP endpoints. The core draft forbids -issuing challenges over plain HTTP. - -Base mainnet deployments must satisfy the -[mainnet guardrails](configuration.md#mainnet-guardrails). - -Unsupported variants are listed in `src/payments/mpp/descriptor.ts`: non-EVM -methods, Permit2/transaction/hash credentials, splits, subscriptions, EVM -sessions and the discovery extension. - -## HTTP surface - -| Route | Purpose | -| -------------------------------------------------- | ---------------------------------------------------------- | -| `GET /health` | liveness | -| `GET /ready` | readiness for the store, adapters and configured providers | -| `GET /.well-known/agent-commerce` | merchant, protocol and provider discovery | -| `GET /api/resources` | canonical resource list | -| `POST /api/resources/:id/invoke` | invoke; returns 402 when proof is absent | -| `GET /api/receipts`, `GET /api/events` | operator audit | -| `/mcp` | MCP Streamable HTTP when enabled | -| `/.well-known/agent-card.json`, `/a2a` | A2A discovery and JSON-RPC when enabled | -| `/.well-known/acp.json`, `/acp/checkout_sessions…` | ACP discovery and checkout when enabled | - -The invoke route uses the selected rail's headers: - -| Rail | Proof | Challenge on 402 | Refused proof or spent authorization (402) | Refused settlement (402) | Delivered response | Backend error after settlement | -| ---- | ---------------------------- | ------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------- | ---------------------------------------- | ------------------------------ | -| x402 | `PAYMENT-SIGNATURE` | `PAYMENT-REQUIRED` | a fresh `PAYMENT-REQUIRED` with `error` | `PAYMENT-RESPONSE` with `success: false` | `PAYMENT-RESPONSE` | `PAYMENT-RESPONSE` | -| MPP | `Authorization: Payment ...` | `WWW-Authenticate: Payment ...` | a fresh `WWW-Authenticate` | a fresh `WWW-Authenticate` and `PAYMENT-RESPONSE` with `success: false` | `Payment-Receipt` and `PAYMENT-RESPONSE` | `PAYMENT-RESPONSE` | - -The error body carries `details.challenge` when the pipeline supplies a -retry challenge. Other outcomes: - -- A replay returns 409 without a challenge if the first attempt is unfinished, - has the legacy `failed` status, or its status is unavailable. Issuing another - challenge could lead to a second payment for the same request. -- A settlement without a verdict returns 502 with `PAYMENT-RESPONSE`. Its - reason is `settlement_pending` when a transaction hash is available, or - `unexpected_settle_error` otherwise. -- A settled `PAYMENT-RESPONSE` includes `amount` in base units when the - provider supplies it. Paid deliveries use `Cache-Control: private`; fresh - 402 challenges use `no-store`. +## Protocol pages + +- [HTTP surface](protocols/http.md): routes, and the payment headers each rail + uses on the invoke route +- [MCP](protocols/mcp.md): tools, payment over MCP, and exclusions +- [A2A](protocols/a2a.md): invocation and payment, the x402 extension, results, + and exclusions +- [ACP](protocols/acp.md): headers, checkout operations, the payment boundary, + idempotency, errors, discovery, and exclusions +- [x402](protocols/x402.md): the `exact` scheme on EVM, Solana and NEAR, + several options, payment flows, and mainnet guardrails +- [MPP](protocols/mpp.md): the pinned drafts, the `charge` profile, payment + flow, problem documents, and exclusions +- [AP2](protocols/ap2.md): mandate verification before a paid resource settles ## Adding an integration diff --git a/docs/protocols/a2a.md b/docs/protocols/a2a.md new file mode 100644 index 0000000..55ce21f --- /dev/null +++ b/docs/protocols/a2a.md @@ -0,0 +1,174 @@ +# A2A + +**Experimental.** Enable with `protocols.a2a.enabled: true`. + +| Property | Value | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Binding | JSON-RPC 2.0 over HTTP(S) | +| Method | `SendMessage`, not legacy `message/send` | +| Version | `A2A-Version: 1.0` header, or an `A2A-Version` query parameter when the header is absent; patch suffixes are accepted but ignored | +| Agent Card | `GET /.well-known/agent-card.json` | +| Default mount | `/a2a` | +| Task model | synchronous; terminal except an unpaid x402 extension task | + +Each A2A resource becomes an Agent Card skill. Paid skills have a `paid` tag +and a price in their description. A2A v1.0 has no input-schema field for +skills, so each skill includes an illustrative JSON call example with +required input. The agent description explains the call shape and points to +`/api/resources` for input schemas. It also names +`input._payment` when any skill is paid. Enabling the adapter requires at +least one A2A resource. + +The card has a content-based `ETag` and `Cache-Control: public, max-age=300`. +A matching `If-None-Match` request receives `304` without a body. + +## Invocation and payment + +The adapter requires a non-empty `messageId` and exactly one structured data +part in `SendMessage`. The part has this shape: + +```json +{ + "data": { + "resource": "market_report", + "input": { "symbol": "ETH" } + }, + "mediaType": "application/json" +} +``` + +A paid retry adds the same reserved field used by MCP: + +```json +{ + "data": { + "resource": "market_report", + "input": { + "symbol": "ETH", + "_payment": "" + } + }, + "mediaType": "application/json" +} +``` + +`_payment` is this gateway's carrier, not part of A2A or of either rail. MPP +defines no A2A binding, so `_payment` is the only way to pay an MPP skill. + +For a new purchase, the adapter refuses text, file, inline-byte and URL +parts, multipart messages, non-user roles, and task or context continuation. +The text and file refusals name the expected part shape. A payment message can +resume a pending x402 extension task. + +## x402 extension + +A client activates the extension with +`A2A-Extensions: https://github.com/google-a2a/a2a-x402/v0.1` and can then +pay without `_payment`. The gateway uses x402 v2 documents from the +[x402 A2A transport](https://github.com/x402-foundation/x402/blob/main/specs/transports-v2/a2a.md). +Its examples use lowercase task states and roles, `kind` on parts, and the +`X-A2A-Extensions` header. This gateway uses the A2A 1.0 forms: +`TASK_STATE_INPUT_REQUIRED`, `ROLE_AGENT`, parts without `kind`, and +`A2A-Extensions`. The Agent Card advertises the extension as optional, with +`params: { "x402Version": 2 }`, when x402 is a skill's selected payment rail. + +A response carries the `A2A-Extensions` header when its task status message +lists the extension: the input-required task, and every task answered to a +payment message, even one sent without the header. Other responses, such as an +MPP skill's task or a JSON-RPC error, omit it. An extension status message +carries `contextId`, `taskId`, `extensions: []` and the +`x402.payment.*` metadata. + +1. An x402 paid call without a proof returns `TASK_STATE_INPUT_REQUIRED`. The status + message carries `x402.payment.status: payment-required` and the v2 + `PaymentRequired` in `x402.payment.required`. +2. The client sends a message with that `taskId` and sets + `x402.payment.status` to `payment-submitted` in the message metadata. It + puts the `PaymentPayload` in `x402.payment.payload`. The gateway reruns the + stored purchase through input validation, payment verification, replay + reservation and settlement. +3. A successful purchase returns `TASK_STATE_COMPLETED`, + `payment-completed` and a settlement entry in `x402.payment.receipts`. A + payment failure returns `TASK_STATE_FAILED`, `payment-failed`, an + `x402.payment.error` code and a failed receipt. If the backend fails after + settlement, the task fails but its status message reports + `payment-completed` with the settlement entry. A client can also send + `payment-rejected` to end the task without paying. + +The gateway holds unpaid tasks in process memory until the payment +requirement expires, for no longer than one hour. If the requirement has no +expiry, the task waits ten minutes. While 256 tasks are pending, a new +purchase fails with `GATEWAY_BUSY`; no waiting task is evicted to make room. +A restart or a payment routed to another instance loses the task; +the client receives `TaskNotFoundError` and must start a new purchase. +`GetTask` and `CancelTask` also answer `TaskNotFoundError` for a pending task, +which stays payable. + +These follow-ups return `-32602` and keep the task pending: a malformed payment +message, a `contextId` that differs from the task's, a role other than +`ROLE_USER`, and a malformed `_authorization`. Clients published for this +extension URI send x402 v1 payloads over A2A 0.x. The 0.x role gets `-32602`, +and an x402 v1 payload fails the task with +`x402.payment.error: invalid_x402_version`. A failed receipt always names a +`network`, the requirement's when the error carries none. + +If the first message carried no AP2 mandate, the payment message may carry one +in a data part as `{"data": {"input": {"_authorization": {...}}}}`. It is +verified against the stored purchase, and the rest of that part is ignored. A +mandate from the first message takes precedence. + +The extension applies only to x402. An MPP resource still returns a terminal +task. + +## Results + +These outcomes return a terminal task with one artifact: + +| Outcome | State | Artifact data | +| --------------------------------- | ---------------------- | -------------------------------------------------------- | +| Delivered | `TASK_STATE_COMPLETED` | Merchant response and `agent-commerce/delivery` metadata | +| Payment required on terminal path | `TASK_STATE_FAILED` | Shared payment-required envelope | +| Commerce failure | `TASK_STATE_FAILED` | Shared error envelope | + +Every `TASK_STATE_FAILED` task has a `ROLE_AGENT` status message whose text is +the envelope's `message`; after an extension payment, the text summarizes the +payment outcome instead. Only extension status messages carry `metadata` and +`extensions`. + +With the x402 extension, an unpaid task is `TASK_STATE_INPUT_REQUIRED`; its +payment terms are in the status message metadata. If the client declines, +the task ends with no artifact. + +A non-object merchant body is wrapped as `{ "value": ... }`. Without the x402 +extension, payment required is terminal; the buyer sends a new message with +the proof. + +The adapter returns JSON-RPC errors for invalid requests and A2A features it +cannot serve. Alongside `-32700`, `-32600`, `-32601`, `-32602` and `-32603`, it +uses these A2A codes: + +| Code | A2A error | Returned for | +| -------- | ----------------------------------- | ----------------------------------------------------------------------------------------------- | +| `-32001` | `TaskNotFoundError` | an unknown, expired or completed `taskId`; `GetTask` and `CancelTask` for any id | +| `-32003` | `PushNotificationNotSupportedError` | the four push notification configuration methods | +| `-32004` | `UnsupportedOperationError` | other unsupported methods and message features listed below | +| `-32005` | `ContentTypeNotSupportedError` | a text, file, inline-byte or URL part, or a non-JSON `mediaType` | +| `-32009` | `VersionNotSupportedError` | an unsupported version; a missing header and parameter, or an empty header, is treated as `0.3` | + +Multipart messages, a `contextId` and non-empty `referenceTaskIds` return +`-32004`. Missing `messageId` or a non-user role returns `-32602`. A request +without an `id` returns `-32600` without running. Each A2A error carries a +`google.rpc.ErrorInfo` in `error.data`, with a reason such as +`VERSION_NOT_SUPPORTED` and the domain `a2a-protocol.org`. + +## A2A exclusions + +Unsupported methods are `SendStreamingMessage`, `GetTask`, `ListTasks`, +`CancelTask`, `SubscribeToTask`, the four task-push-notification-config +methods and `GetExtendedAgentCard`. The adapter also excludes REST and gRPC +bindings, SSE, persistent tasks, task continuation beyond the pending x402 +payment, push delivery, multi-turn continuation, authenticated +extended cards, A2A authentication and other artifact types. + +The A2A adapter accepts `_payment` but does not verify or settle payments +or call merchant backends. `@a2a-js/sdk` is a test-only dependency. diff --git a/docs/protocols/acp.md b/docs/protocols/acp.md new file mode 100644 index 0000000..028ccde --- /dev/null +++ b/docs/protocols/acp.md @@ -0,0 +1,229 @@ +# ACP + +**Experimental.** Enable with `protocols.acp.enabled: true`. + +| Property | Value | +| ----------------------------------- | ------------------------------------------------------------------------ | +| Binding | REST over HTTP(S) | +| Snapshot and accepted `API-Version` | `2026-04-17` only | +| Vendored schema | `src/protocols/acp/spec/2026-04-17/` | +| Discovery | `GET /.well-known/acp.json` | +| Default mount | `/acp` | +| Service | `checkout` only | +| Authentication | bearer token on every checkout route | +| Idempotency | durable key on every POST, completed rows retained for at least 24 hours | + +The schema is vendored and never fetched. Runtime code imports only the dated +snapshot; it reads nothing from `spec/unreleased`. The adapter validates its +discovery document at startup and fails with `CONFIG_INVALID` if it does not +match. A version upgrade adds a new snapshot rather than editing this one. + +## Required headers + +| Header | Scope | Rule | +| ----------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `Authorization` | every checkout route | `Bearer `, constant-time comparison; a refusal is 401 with `WWW-Authenticate: Bearer` | +| `API-Version` | every checkout route | exactly `2026-04-17`; missing and unsupported are distinct errors | +| `Content-Type` | every POST | if present, `application/json` or any media type ending in `+json`; an empty body may omit it, but a supplied non-JSON type still gets 415 | +| `Idempotency-Key` | every POST | 1-255 printable ASCII characters | +| `Request-Id` | optional | bounded and filtered; echoed on every checkout response, refusals included; separate from the gateway request id | + +An absent, older, newer or malformed API version is not mapped to the supported +snapshot. + +## Headers sent to the merchant + +The gateway forwards `Accept-Language`, `User-Agent`, `Request-Id`, +`API-Version`, `Signature` and `Timestamp` to the merchant when present, +printable ASCII and at most 2048 characters long. It never forwards the +agent's `Authorization`, the caller's `Idempotency-Key`, which the derived key +replaces, or any other caller header. A `backend.headers` entry of the same name +wins, so an operator who wants a fixed `User-Agent` at the merchant must +configure one. + +`Signature` and `Timestamp` pass through unverified. The gateway re-serializes +the JSON body, so a merchant that verifies the raw request bytes fails; only one +that verifies a canonical form of the parsed JSON can succeed. No signing agent +has been tested against this path. + +## Checkout operations + +| Operation | Route | Success | Canonical input | +| ------------------------- | ---------------------------------------------- | ------- | ----------------------------------- | +| `createCheckoutSession` | `POST {mount}/checkout_sessions` | `201` | `{ body }` | +| `updateCheckoutSession` | `POST {mount}/checkout_sessions/{id}` | `200` | `{ path, body }` | +| `getCheckoutSession` | `GET {mount}/checkout_sessions/{id}` | `200` | `{ path }` | +| `completeCheckoutSession` | `POST {mount}/checkout_sessions/{id}/complete` | `200` | `{ path, body }` | +| `cancelCheckoutSession` | `POST {mount}/checkout_sessions/{id}/cancel` | `200` | `{ path }`, plus `body` if supplied | + +Each operation maps to a resource in `protocols.acp.checkout.operations`. +All five mappings are required, and each operation must use a different resource. +Each resource must include `expose: [acp]`, be free at the Agent Commerce invocation +layer and have an empty payments list. + +Request bodies are validated against the pinned schema before pipeline +execution; an invalid request returns `400 invalid_request_body`. Successful +merchant responses are validated before return. An invalid merchant document or +success status outside the ACP contract becomes a safe `processing_error`; the +merchant body is not relayed or cached. + +Validation accepts unknown values in the `supported` and `required` lists of +`capabilities.interventions`, in both directions, and passes them through, +because the capability negotiation RFC (4.6.2) says to ignore unknown +capability values. It also accepts an unknown value of an enum whose schema +description calls it extensible. Unknown fields, and unknown values of every +other enum, are still refused. + +Completion accepts the schema's two outcomes: a completed session with an +order, or an ordinary non-completed session without one. + +See [examples/acp-checkout](../../examples/acp-checkout). + +## ACP payment boundary + +ACP `payment_data` belongs to the merchant checkout flow. It remains ordinary +backend input and is not converted into an Agent Commerce `_payment`, x402 or +MPP proof. + +Config rejects paid checkout resources. If the pipeline nevertheless returns a +payment challenge, the adapter logs the broken deployment and returns a safe +500 because ACP has no Agent Commerce challenge representation. + +The ACP adapter contains no payment logic and never calls a merchant backend +directly; it sends one canonical request through the pipeline. Its bearer token +is used only for constant-time request authentication and is not written to the +idempotency database, logs or responses. + +## Idempotency + +The adapter validates `Idempotency-Key` before reading a POST body. After body +parsing, it claims the key before calling the merchant. The scope is +`(deployment public base URL, concrete endpoint path, caller key)`; bearer +credentials are not part of it. Two clients using the same caller key on the +same endpoint therefore share one claim. + +The body fingerprint uses parsed JSON. Object key order and numeric spelling +such as `1` versus `1.0` normalize; array order, type, and null versus absence +remain distinct. + +| Situation | Response | +| --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| first request | atomically claimed before merchant execution | +| same key and body, still running | `409 idempotency_in_flight` with `Retry-After` | +| same key and body, completed | stored response with `Idempotent-Replayed: true`; no merchant call | +| same key, different body | `422 idempotency_conflict`; no merchant call | +| ACP response below 500 after the claim, including pipeline `INPUT_INVALID` | cached | +| ACP response 500 or higher after the merchant may have run | `409 idempotency_unresolved` on retry by default; with `merchantIdempotent`, the claim is released for a retry | +| ACP response 500 or higher when the merchant was not called or answered 429 | claim released; a clean retry may run | + +The ACP response status controls claim finalization. The gateway treats a +merchant 429 as unprocessed, releases the claim and returns ACP 503. Merchants +must send 429 before side effects. A merchant 503 also maps to ACP 503 but may +follow partial work, so its claim stays unresolved unless +`merchantIdempotent` is set. Merchant 401 and 403 map to ACP 502 and stay +unresolved. A post-claim pipeline `INPUT_INVALID` maps to ACP 400 and is cached. + +Request guards run before the claim, so their failures are never stored. These +include routing, authentication, header, body, JSON, schema and content-type +errors. The response echoes a filtered `Request-Id`; a POST also echoes a +well-formed `Idempotency-Key`, even when a guard fails before checking it. A +malformed key is never echoed, and routing 405 includes `Allow`. + +ACP requires a fresh attempt after a 5xx. By default, the gateway keeps the +claim when the merchant may have acted, to avoid a duplicate order. Set +`protocols.acp.idempotency.merchantIdempotent: true` only if the merchant +deduplicates by the derived key. The gateway then releases the claim after a +5xx and frees claims left in flight when it next starts. + +An in-flight row becomes completed, released or unresolved when the request +finishes. A crash or a SQLite error while finalizing can leave it in-flight; +the next gateway start marks it unresolved by default or frees it when +`merchantIdempotent` is enabled. Startup logs the count. This assumes one +gateway process per idempotency database file. + +Only completed records expire, after at least 24 hours. Unresolved records +remain for operator reconciliation. This avoids rerunning an unknown remote +side effect but is not an exactly-once transaction across HTTP and SQLite. +Expired completed rows are purged lazily when a later request claims a key; +there is no background sweep. + +The schema-v1-to-v2 migration rebuilds the idempotency table empty because its +bearer-token-scoped keys cannot be translated. If it drops non-completed rows, +startup logs a warning that tells the operator to reconcile them with the +merchant before trusting a retry. + +For side-effecting ACP operations, the gateway sends the merchant a derived +`Idempotency-Key`: SHA-256 over deployment, endpoint and caller key. It is +stable across retries, restarts and bearer-token rotation; differs across +endpoints and deployments; and reveals no caller key. A static +`Idempotency-Key` in backend headers is replaced by this derived value. + +The merchant should store and replay results under the derived key. In the +default mode, the gateway holds uncertain claims to block duplicate checkout +calls. With `merchantIdempotent` enabled, the merchant must prevent duplicates +on retry. A proxy retry outside the gateway can also repeat a side effect if +the merchant does not deduplicate. + +## ACP errors + +| Cause | Response | +| --------------------------------------------------------- | -------------------------------------------------------------- | +| pipeline `INPUT_INVALID` | `400 invalid_request_body` | +| merchant 404 | `404 checkout_session_not_found` | +| merchant 405 on cancel | `405 checkout_session_not_cancelable` | +| merchant 405 on another operation | `405 method_not_allowed` | +| merchant 400 or 422 | the same status, `invalid_request_body` | +| valid merchant ACP error on a relayed status | that status, with its `type`, safe `code` and optional `param` | +| merchant 409 | `409 checkout_session_conflict` | +| merchant 429 or 503 | `503 service_unavailable` with `Retry-After` | +| merchant 401, 403, another 5xx or another unmapped status | `502 processing_error` | +| backend timeout | `504 service_unavailable` | +| load shedding | `503 service_unavailable` | +| mapping, storage or unexpected payment challenge | `500 processing_error` | + +ACP errors contain `type`, `code`, `message`, a safe `param` when available, +and `supported_versions` for API-version errors. For a valid merchant ACP error +on a relayed status, the gateway uses its `type` and a snake_case `code` of at +most 64 characters. A code starting with `idempotency_` is not relayed, +because only the gateway issues those codes. It also includes a bounded, +printable `param` when present. The gateway supplies its own `message`; no +other merchant body fields are sent to the agent. Merchant 401/403 is not +presented as a failure of the agent's ACP bearer token. + +`Retry-After` on a 503 for a merchant 429 or 503 is the merchant's value when +it is whole seconds, clamped to 1-300, and 5 otherwise. + +Session ids such as `gid://shop/Checkout/1` are accepted when encoded as one +URL segment. Empty ids and ASCII control characters are refused. Merchant +markdown containing an HTML tag or comment is refused as `processing_error` +before it reaches the agent. + +## ACP discovery + +```json +{ + "protocol": { + "name": "acp", + "version": "2026-04-17", + "supported_versions": ["2026-04-17"] + }, + "api_base_url": "https://merchant.example.com/acp", + "transports": ["rest"], + "capabilities": { "services": ["checkout"] } +} +``` + +Discovery is public while ACP is enabled and uses +`Cache-Control: public, max-age=3600`. Optional documentation, currency, +locale and intervention metadata appears only when configured. Tokens, backend +URLs, resource ids and database paths are excluded. + +## ACP exclusions + +The adapter excludes carts, feed and standalone orders; delegated payment and +authentication; the ACP MCP binding; webhooks and outbound delivery; the client +role; `Signature` and `Timestamp` verification; discounts and the general +extension framework; seller-backed payment handlers; and other ACP versions. +A `Signature` header does not replace bearer authentication. + +No ACP SDK ships. Conformance tests use the vendored official schema and examples. diff --git a/docs/ap2.md b/docs/protocols/ap2.md similarity index 96% rename from docs/ap2.md rename to docs/protocols/ap2.md index eb78314..d10a686 100644 --- a/docs/ap2.md +++ b/docs/protocols/ap2.md @@ -203,6 +203,13 @@ strings. It cannot compare its inputs with the gateway's eventual payment requirement. Take price data from the merchant catalog and settlement coordinates from the payment challenge. +For a challenge with several x402 offers, choose one before creating the JWT. +Pass its `payTo`, `network` and `asset` as `destination`, `network` and +`asset`. The mandate must match the proof's selected offer: Base USDC approval +does not authorize Solana or NEAR USDC. Copy Solana and NEAR addresses exactly; +only EVM addresses compare case-insensitively. Keep `amount` and `currency` +as the resource's price, even when payment records use the settled token. + The buyer's agent or credential provider creates the mandate around this JWT. This package does not mint mandates. @@ -323,11 +330,11 @@ adapters preserve `payload` byte for byte. Reserved fields are removed before resource validation, hashing and backend execution. With the A2A x402 extension, a payment message can carry the mandate when the -first message did not; see [x402 extension](protocols.md#x402-extension). +first message did not; see [x402 extension](a2a.md#x402-extension). ## Configuration and lifecycle -See [configuration.md](configuration.md#authorizationap2) for the YAML schema +See [configuration.md](../configuration.md#authorizationap2) for the YAML schema and validation rules. A free resource cannot require AP2 because authorization gates settlement. @@ -372,6 +379,6 @@ Open mandates are refused because their spending constraints are not evaluated. | ------------------------------------------------ | -------------------------------------------------- | | `src/authorization/ap2/` | verifier, trust, purchase binding and replay store | | `src/core/domain/authorization.ts` | generic authorization contract | -| `src/core/execution/pipeline.ts` | verification and reservation ordering | +| `src/core/execution/pipeline/admission.ts` | verification and reservation ordering | | `tests/integration/ap2-x402-conformance.test.ts` | end-to-end refusal cases | | `tests/e2e/authorization/` | on-chain gated settlement | diff --git a/docs/protocols/http.md b/docs/protocols/http.md new file mode 100644 index 0000000..828d222 --- /dev/null +++ b/docs/protocols/http.md @@ -0,0 +1,44 @@ +# HTTP surface + +| Route | Purpose | +| -------------------------------------------------- | ---------------------------------------------------------- | +| `GET /health` | liveness | +| `GET /ready` | readiness for the store, adapters and configured providers | +| `GET /.well-known/agent-commerce` | merchant, protocol and provider discovery | +| `GET /api/resources` | canonical resource list | +| `POST /api/resources/:id/invoke` | invoke; returns 402 when proof is absent | +| `GET /api/receipts`, `GET /api/events` | operator audit | +| `/mcp` | MCP Streamable HTTP when enabled | +| `/.well-known/agent-card.json`, `/a2a` | A2A discovery and JSON-RPC when enabled | +| `/.well-known/acp.json`, `/acp/checkout_sessions…` | ACP discovery and checkout when enabled | + +The invoke route uses the selected rail's headers: + +| Rail | Proof | Challenge on 402 | Refused proof or spent authorization (402) | Refused settlement (402) | Delivered response | Backend error after settlement | +| ---- | ---------------------------- | ------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------- | ---------------------------------------- | ------------------------------ | +| x402 | `PAYMENT-SIGNATURE` | `PAYMENT-REQUIRED` | a fresh `PAYMENT-REQUIRED` with `error` | `PAYMENT-RESPONSE` with `success: false` | `PAYMENT-RESPONSE` | `PAYMENT-RESPONSE` | +| MPP | `Authorization: Payment ...` | `WWW-Authenticate: Payment ...` | a fresh `WWW-Authenticate` | a fresh `WWW-Authenticate` and `PAYMENT-RESPONSE` with `success: false` | `Payment-Receipt` and `PAYMENT-RESPONSE` | `PAYMENT-RESPONSE` | + +The error body carries `details.challenge` when the pipeline supplies a +retry challenge. Other outcomes: + +- A replay returns 409 without a challenge if the first attempt is unfinished, + has the legacy `failed` status, or its status is unavailable. Issuing another + challenge could lead to a second payment for the same request. +- Replay `details.network` identifies the paid offer's network. A fresh x402 + challenge on 402 uses that family's replay `error`: + `invalid_exact_evm_nonce_already_used` on EVM, + `invalid_exact_near_payload_delegate_action_nonce_already_used` on NEAR, and + `duplicate_settlement` on Solana, whose SDK has no replay code of its own. +- A missing Permit2 token allowance (`permit2_allowance_required`) returns + 412 Precondition Failed with a fresh `PAYMENT-REQUIRED`, per the `exact` + EVM scheme. The buyer approves Permit2 and retries. + `x402HTTPClient.parsePaymentResult` decodes that header only on 402; + for 412, call `getPaymentRequiredResponse` or read `details.challenge` + in the response body. +- A settlement without a verdict returns 502 with `PAYMENT-RESPONSE`. Its + reason is `settlement_pending` when a transaction hash is available, or + `unexpected_settle_error` otherwise. +- A settled `PAYMENT-RESPONSE` includes `amount` in base units when the + provider supplies it. Paid deliveries use `Cache-Control: private`; fresh + 402 challenges use `no-store`. diff --git a/docs/protocols/mcp.md b/docs/protocols/mcp.md new file mode 100644 index 0000000..2b6eb4d --- /dev/null +++ b/docs/protocols/mcp.md @@ -0,0 +1,112 @@ +# MCP + +The MCP endpoint supports `2026-07-28`. The SDK's stateless fallback also +accepts 2025-era clients that use `initialize`. `server/discover` advertises +only `2026-07-28`. + +Resources with `expose: [mcp]` become MCP tools: + +- tool name: resource id; +- description: resource description, plus price and proof instructions when + paid, and authorization instructions when the resource requires it; +- input schema: canonical resource schema plus an optional `_payment` string + for a paid resource and an optional `_authorization` object + (`{ method, payload }`) for a resource that requires authorization. + +Calling an unknown tool returns JSON-RPC error `-32602`. For requests +without modern MCP headers, malformed `tools/call` params such as a non-string +`name` or non-object `arguments` return the same code with a short message. +The SDK handles requests carrying `Mcp-Method` or a `MCP-Protocol-Version` of +`2026-07-28` or later. + +Only `2025-03-26` has JSON-RPC batches. A batch whose `MCP-Protocol-Version` +names another revision gets HTTP 400 and `-32600`; the SDK serves a batch sent +without that header. `tools/list` is not paginated, so a `cursor` gets +`-32602`. Under `2026-07-28` the list carries `ttlMs: 60000` and +`cacheScope: "public"`. The adapter's own transport errors use `-32600` for a +405 or an unreadable body and `-32603` for a 500 or 503. + +## Payment over MCP + +A paid tool accepts a proof in the gateway's `_payment` argument or the +selected rail's MCP `_meta` carrier. The latter lets x402 and MPP clients +use their native payment format: + +| Rail | Challenge | Proof | Delivered result | +| ---- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------- | +| x402 | v2 `PaymentRequired` in `structuredContent`, `content[0].text` and `_meta["x402/error"]` | `_meta["x402/payment"]`, a `PaymentPayload` object or its base64 JSON string | `_meta["x402/payment-response"]` | +| MPP | `_meta["org.paymentauth/payment-required"]` with the challenges | `_meta["org.paymentauth/credential"]`, a credential object | `_meta["org.paymentauth/receipt"]` | + +The x402 MCP transport defines an object proof. The gateway also emits challenges in +`_meta["x402/error"]` and accepts base64 string proofs used by the `cloudflare/agents` +client; that client has not been tested here. An undecodable string gets `PAYMENT_INVALID` +and a fresh challenge. The MPP credential and receipt carriers follow +`draft-payment-transport-mcp-00`. + +If both carriers are present, the rail's `_meta` proof wins. A placeholder +in `_payment` therefore cannot break a client wrapper's paid retry. Tool +descriptions and payment-required messages name both carriers. + +The MPP draft uses JSON-RPC errors `-32042` for challenges and `-32043` for +refusals. This gateway puts both in tool-result `_meta`, which `mppx` accepts +but the draft does not define. MCP `2026-07-28` reserves `-32042` for protocol +use. The gateway reads payment `_meta` only inside `params` and does not +advertise payment support in its capabilities. + +Without a proof, the tool returns `isError: true` and the gateway's payment-required +envelope in `structuredContent`. For x402, the v2 `PaymentRequired` fields appear +alongside that envelope so x402 clients can read them: + +```jsonc +{ + "x402Version": 2, + "resource": { /* v2 resource descriptor */ }, + "accepts": [ /* x402 payment requirements */ ], + "status": "payment-required", + "code": "PAYMENT_REQUIRED", + "requestId": "...", + "resourceId": "market_report", + "message": "Payment of 0.01 USDC is required for resource \"market_report\". …", + "payment": { + "provider": "x402", + "version": "2", + "amount": "0.01", + "currency": "USDC", + "destination": "0x…", + "network": "eip155:84532", + "asset": "0x…", + "expiresAt": "…", + "accepts": [ /* provider-native requirements */ ], + "envelope": { /* provider-native challenge document */ } + } +} +``` + +For x402, `envelope` is the complete v2 `PaymentRequired` document and +`accepts` is its requirements list. A `_payment` retry carries the base64 proof. + +For MPP, `envelope.wwwAuthenticate` is the serialized `WWW-Authenticate` value +and `envelope.challenges` holds the challenge objects. A `_payment` retry +carries the complete `Authorization: Payment ...` credential. + +When the pipeline supplies a retry challenge, a 402 refusal includes it in +the rail's format. The x402 challenge carries the reason in `error`, in both +`structuredContent` and `_meta["x402/error"]`. MPP adds a `problem` with its +problem type, a `detail` sentence and the reason token in `reason`. An x402 +settlement failure or a backend failure after settlement also includes +`_meta["x402/payment-response"]`. Every failure retains the gateway's error +envelope in `structuredContent`. + +For payment-required and error results, `content[0].text` is a JSON copy of +`structuredContent`; `content[1].text` is a sentence for readers. Unexpected +exception messages and stack traces are omitted. + +## MCP exclusions + +The adapter does not implement MCP resources, prompts, sampling, completions, +elicitation, roots, logging, tasks or tool-list-change notifications. It has +no MCP authorization, sessions, subscriptions, pagination or HTTP+SSE transport. +The gateway, rather than the MCP adapter, enforces Host and Origin checks. + +The MCP adapter maps payment carriers and results. It does not verify or +settle payments or call merchant backends. diff --git a/docs/protocols/mpp.md b/docs/protocols/mpp.md new file mode 100644 index 0000000..d717cc4 --- /dev/null +++ b/docs/protocols/mpp.md @@ -0,0 +1,64 @@ +# MPP + +**Experimental.** Enable with `payments.mpp.enabled: true`. + +The gateway targets `draft-httpauth-payment-01`, +`draft-payment-intent-charge-00`, and `draft-evm-charge-00` at +`tempoxyz/mpp-specs@806fdb8`. It pins the pre-1.0 `mppx@0.13.1` as an +optional peer. For EIP-3009, the gateway follows `mppx`: it hashes +`JSON.stringify([id, realm])` with keccak256 to derive the nonce. The EVM +draft instead hashes `abi.encodePacked(id, realm)`. Credentials using the +draft nonce, including those produced by `mppx` 0.12 or earlier, fail +verification with `wrong_nonce`. + +Later drafts or `mppx` releases may change the wire format again, so MPP +remains experimental. + +The implemented profile is `charge` intent, `evm` method and EIP-3009 +`authorization` credential, using USDC on Base Sepolia or Base. The challenge +is HMAC-bound to the resource and terms. The credential is verified locally, +then checked by the x402 settlement provider. Both checks run in `verify`, +before replay reservation; settlement broadcasts later. + +For an HTTP request with a body, the challenge includes an RFC 9530 +`sha-256` digest of the original body bytes. The paid retry must send the same +bytes: even equivalent JSON with different spacing returns +`body_digest_mismatch`. MCP and A2A challenges omit the digest because their +payment proof travels in the request body. + +MPP and x402 derive the same replay identity for the same EIP-3009 +authorization, so reuse across rails collides in one receipt store. + +`payments.mpp.paymentFlow` takes the [x402 flow values](x402.md), but defaults to +`upfront`: the payment settles before the backend runs. The `charge` intent +forbids partial access before payment verification, which for the EVM method +includes the transfer, and counts a backend call as partial access. Choosing +`authorization` departs from that rule: the backend runs first, and a refused +or unconfirmed settlement withholds its response but leaves its work unpaid. +Under `authorization`, backend time also counts toward the challenge lifetime, +`challengeTtlSeconds` (default 300), because `mppx` signs `validBefore` equal +to the challenge's `expires`. + +Over HTTP, the unpaid 402 and MPP verification, replay, settlement, and +provider errors use `application/problem+json` and the core draft's problem +types; the unpaid one is `payment-required`. Envelope members such as `code`, +`message`, and `details` stay as extension members, and `status` is the numeric +HTTP status. `detail` is a sentence; the reason token stays in `message` and +`details.reason`. A successful `Payment-Receipt` includes `challengeId` and +`chainId`. + +A credential whose challenge already paid for a request is `invalid-challenge` +with reason `challenge_already_used`. The gateway learns this from the +facilitator's reason or the token's `authorizationState`. When neither shows +it, including when that read fails, the facilitator's reason is reported +instead, under `verification-failed`. + +Terminate TLS in front of deployed MPP endpoints. The core draft forbids +issuing challenges over plain HTTP. + +Base mainnet deployments must satisfy the +[mainnet guardrails](../configuration.md#mainnet-guardrails). + +Unsupported variants are listed in `src/payments/mpp/descriptor.ts`: non-EVM +methods, Permit2/transaction/hash credentials, splits, subscriptions, EVM +sessions and the discovery extension. diff --git a/docs/protocols/x402.md b/docs/protocols/x402.md new file mode 100644 index 0000000..4dce0c1 --- /dev/null +++ b/docs/protocols/x402.md @@ -0,0 +1,137 @@ +# x402 + +The x402 provider supports v2 `exact` on EVM through EIP-3009 +`transferWithAuthorization` or, per option, Permit2, on Solana and on NEAR. +Both EVM methods are supported on built-in EVM networks, with settlement +demonstrated on Base Sepolia and Base. Solana has settled on devnet and +mainnet. NEAR remains partial: testnet and mainnet payments used the test +suite's SDK facilitator, with no hosted-facilitator settlement demonstrated. +EVM requirements name the method in `extra.assetTransferMethod`; EIP-3009 +also includes the token's EIP-712 domain name and version. + +Verification checks the scheme, signature, recipient, amount, validity window, +payer balance, network and asset. Local refusals use x402 error codes where +available, such as `invalid_network`. The provider derives a replay key from +the authorization, which the pipeline reserves before settlement. A successful +settlement records the transaction hash, or the signature on Solana. A success +that names another network, or no transaction in its family's form, unlocks +nothing and leaves the payment's outcome unknown. + +A Permit2 proof must name the x402 exact Permit2 proxy as spender and match +the offer's token, amount and `payTo` witness recipient. The gateway checks +these before the facilitator checks the signature, deadline, balance and +Permit2 allowance. Other spenders could redirect funds and are refused as +`invalid_permit2_spender`. A proof using the wrong method or both +authorizations is `unsupported_scheme`: the SDK facilitator chooses Permit2 +whenever that authorization is present. Since an owner's Permit2 nonce spans +all tokens, the replay key uses the chain id, Permit2 address, owner and nonce. + +On Solana, the buyer signs a token transfer with the facilitator's fee payer, +named in `extra.feePayer` from `/supported`. The facilitator checks the fee +payer, signatures, instruction layout, mint, recipient token account and +amount, then simulates the transaction. The gateway matches the echoed offer's +addresses case-sensitively. Its replay key uses the network and SHA-256 of the +transaction message, excluding signatures, so changing signature bytes, +including the fee payer's, still collides. Unconfirmed broadcasts, duplicate +settlements in flight, and confirmed transactions whose transfers are not yet +visible leave the outcome unknown. The payer recorded is the one verification +reported: the Solana scheme's settlement example names the fee payer as `payer`, +where x402 v2 defines the payer's wallet. + +On NEAR, the buyer signs a NEP-366 delegate containing one `ft_transfer` of +the offered amount to `payTo`, with one attached yoctoNEAR. The facilitator's +relayer submits it and pays gas. NEAR adds no scheme-specific `extra`; an +`upfront` offer still carries `extra.paymentFlow`. Before calling +the facilitator, the gateway rejects mismatched tokens, recipients, amounts, +methods or deposits, extra actions, and non-canonical base64 Borsh. The +facilitator checks the signature, full-access key, nonce, delegate block +window and chain state. The replay key uses the network, sender, public key +including its curve, and nonce. Different delegate bytes under the same key +and nonce still collide. The signature names no chain: network binding relies +on the offer and facilitator's RPC. Settlement requires a successful inner +`ft_transfer` receipt. `settlement_failed` without a hash leaves the outcome +unknown because submission may have preceded the SDK exception; with a hash, +it reports a failed inner transfer. Chain-read failures also leave no verdict; +during settlement the SDK reports them before submission, releasing the attempt. +The NEAR specification leaves failure reasons to the facilitator, with an +empty `transaction` even after a submission, so any other failure is a +rejection only when its reason is one the SDK returns before submitting: +`invalid_exact_near_*`, `insufficient_funds` or a version, scheme or network +refusal. Any other reason leaves the outcome unknown. + +The signature covers neither the flow nor the resource, so the proof's +`accepted` must match the offer: amount, asset, `payTo` (case-insensitive on +EVM, case-sensitive on Solana and NEAR), and every `extra` key the gateway +sent. An absent `extra.paymentFlow` means `authorization`. A proof's `resource.url` +must equal the challenge's when present. A mismatch is `invalid_payment_requirements`, +refused before the facilitator is called. `maxTimeoutSeconds` and `extra` keys +added by the client are not compared. The facilitator receives the payload +without `resource`, since its `resource://` URL is the gateway's own identifier, +and without `extensions`: the gateway advertises none, and the SDK facilitator +would act on a Permit2 gas-sponsoring extension. + +With [several options](../configuration.md#several-options), the challenge uses +`resource://agent-commerce/resources/`. The comparison above selects the +proof's offer; zero or multiple matches yield `invalid_payment_requirements`. +A resource with [its own offers](../configuration.md#a-resources-own-offers) +accepts only proofs matching that list; resources may share offers. + +x402 v1 is not accepted. A payload declaring an `x402Version` other than 2 is +refused as `invalid_x402_version` on every carrier. A request carrying only the +v1 `X-PAYMENT` header gets the 402 challenge with +`error: "invalid_x402_version"` in `PAYMENT-REQUIRED`. + +`payments.x402.paymentFlow` chooses when an x402 payment settles, and +`payments.mpp.paymentFlow` does the same for MPP. A resource's `paymentFlow` +overrides the setting of whichever rail serves it: + +| Flow | Order | Backend fails | Settlement refused after backend | +| ------------------------------ | ------------------------------------- | ------------------------------------------------------ | -------------------------------- | +| `authorization` (x402 default) | verify, reserve, call backend, settle | no settlement; attempt recorded as rejected | response withheld; 402 | +| `upfront` (MPP default) | verify, reserve, settle, call backend | payment stays settled; receipt records failed delivery | not applicable | + +The gateway omits `extra.paymentFlow` for the `authorization` flow. +It sets `extra.paymentFlow: "upfront"` for the other flow, as required by +[x402 v2](https://github.com/x402-foundation/x402/blob/main/specs/x402-specification-v2.md#61-asset-transfer-methods-and-payment-flow-models). +The gateway verifies the proof before calling the backend in either flow. +For `upfront`, it adds a read-only check before the x402 v2 +`settle → resource` sequence. Under `authorization`, the backend may finish +work before settlement. A refusal leaves that work unpaid; an uncertain +settlement leaves its payment status unknown. In both cases, the gateway +withholds the backend response. Use `upfront` when backend side effects must +follow payment. Backend time counts toward `maxTimeoutSeconds`: the payment +authorization must remain valid at settlement. + +After a backend failure under `authorization`, a refused settlement or an +unsubmitted NEAR settlement, the client can retry the unspent payload and +reclaim the rejected attempt. On-chain replay checks prevent double settlement. +MPP credentials are single-use per challenge, so a retry returns 402 +`PAYMENT_REPLAYED` with a fresh challenge. + +Unsupported: the Permit2 gas-sponsoring extensions +(`eip2612GasSponsoring`, `erc20ApprovalGasSponsoring`), the `upto`, +`batch-settlement` and `auth-capture` schemes, multi-asset routing and +dynamic pricing. + +Facilitator authentication supports `none`, `bearer` and `cdp`; config rejects +other types. Local mode requires Anvil on `eip155:31337`; all public networks +require remote mode. Mainnet also requires the guardrails in +[configuration.md](../configuration.md#mainnet-guardrails). + +## Support matrix + +Local settlement runs in the offline E2E suite against real contracts. +Public-network runs come from the [smoke tests](../smoke-tests.md), and the +README lists their transactions. + +| Route | Support | Local settlement | Public testnet | Mainnet | Limits | +| -------------------------------------------------------------------------- | --------- | -------------------------------------------------- | ----------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| EIP-3009 on Base Sepolia and Base | supported | Anvil | Base Sepolia, x402.org | Base, PayAI | | +| EIP-3009 on Ethereum, Polygon, Arbitrum One and Sepolia, Avalanche C-Chain | supported | two custom-id Anvil chains, remote facilitator | not run | not run | needs a facilitator that lists the network; PayAI did not list Ethereum on 2026-10-10 | +| Permit2 on the built-in EVM networks | supported | Anvil with the deployed Permit2 and proxy bytecode | Base Sepolia, x402.org | Base, PayAI | the buyer approves Permit2 once, paying gas; no gas-sponsoring extensions | +| EVM networks declared in config | partial | custom-id Anvil chains | | | the operator supplies the chain and all four asset fields | +| Solana | supported | `solana-test-validator`, SDK facilitator | devnet, x402.org | mainnet, PayAI | the merchant's token account must exist; a signed transfer expires with its blockhash | +| NEAR | partial | NEAR sandbox, SDK facilitator | testnet, SDK facilitator run by the suite | mainnet, SDK facilitator run by the suite | not run through a hosted facilitator; FastNEAR's requires an API key. The merchant needs a storage deposit, the buyer a full-access key | + +Every public-network x402 payment was signed by the x402 SDK's own client for +its family. Other wallets and clients have not been tested. diff --git a/docs/security.md b/docs/security.md index 3f04631..35f11f4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -51,7 +51,7 @@ such as URLs, so keep secrets in fields designated for credentials. 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](ap2.md#trust). +restarting the process revokes it. See [AP2 trust and rotation](protocols/ap2.md#trust). 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; @@ -119,16 +119,25 @@ Covered in detail in [payment-flow.md](payment-flow.md). The invariants: 1. Paid resources fail closed: payment or authorization failures do not deliver the resource. 2. `verify` has no fund-moving side effects; only `settle` does. -3. Replay is defended twice: on-chain via EIP-3009 `authorizationState`, 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. +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 @@ -175,7 +184,7 @@ 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](protocols.md#headers-sent-to-the-merchant). It +[headers sent to the merchant](protocols/acp.md#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. @@ -245,24 +254,30 @@ configured and direct MPP construction both run these checks. ## Mainnet -Base mainnet (`eip155:8453`) requires `allowMainnet: true`, a remote HTTPS -facilitator, a non-development recipient, and canonical USDC address and EIP-712 -domain values. A facilitator with `auth.type: none` also requires -`allowUnauthenticatedFacilitator: true`. Shared deployment checks run during -config validation and provider construction; the provider additionally checks -RPC and local-key safety. +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. It 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 +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 is forbidden on mainnet because it puts a funded gas -key in the gateway process. On Base Sepolia, config accepts any non-empty local -facilitator key. Provider construction requires a 32-byte hex private key and -rejects a well-known Anvil key against a public RPC. The local health check also -requires Anvil, so public Base Sepolia needs remote mode to become ready. +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 @@ -282,35 +297,78 @@ real funds. | 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/base.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 after settlement or refusal** | 402 with a fresh challenge; facilitator reason if available, otherwise `invalid_exact_evm_nonce_already_used` | `tests/unit/gateway/server.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 expires by the requirement or within an hour; evicted tasks cannot be paid | `tests/unit/protocols-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` | -| **remote facilitator 401 / 5xx during verification** | transport failure, not a buyer rejection | `tests/integration/adversarial-payment.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 64 characters or outside `[A-Za-z0-9_.-]`** | replaced with `invalid_payment` or `settlement_failed` | `tests/unit/payments-x402/provider-sdk-mocked.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` | -| **backend failure under the x402 `authorization` flow** | nothing settles; the attempt is `rejected`, so the same proof gets 402 `invalid_exact_evm_nonce_already_used` and a retry needs a fresh authorization | `tests/unit/core/execution/pipeline-settlement-order.test.ts`, `tests/e2e/payment/x402-settlement.e2e.test.ts` | +| **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` | @@ -392,7 +450,7 @@ real funds. | **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 after the backend ran** | `settlement-uncertain`; the response is withheld; the recorded transaction lands once mined | `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` | @@ -414,17 +472,24 @@ 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` -unless the error message matches a known EIP-3009 failure such as a used nonce, -with a valid transaction hash only when a mined transaction reverted. Without a -hash 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. +(`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 64-character, `[A-Za-z0-9_.-]` check. Other values become the fixed token +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 is available only in debug logs. +the raw value, with any URL cut to its origin, is available only in debug logs. ## OpenAPI import diff --git a/docs/smoke-tests.md b/docs/smoke-tests.md new file mode 100644 index 0000000..034c7b4 --- /dev/null +++ b/docs/smoke-tests.md @@ -0,0 +1,119 @@ +# Public-network smoke tests + +`npm run test:testnet` and `npm run test:mainnet` spend funds on public x402 +payments and verify settlement on-chain. Run them manually on the wallet +holder's machine, never in CI, where funded keys would enter repository +secrets. Without settings, both skip and list each target's requirements +(with `--reporter=verbose`). + +The x402 harness lives in `tests/fixtures/x402/smoke/`. Separate MPP suites +list their settings in file headers. + +## Targets + +| Target | Network | Default facilitator | Runs unnamed | +| ------------------------ | ----------------------------------------- | ------------------------------------------- | ------------ | +| `base-sepolia` | `eip155:84532`, EIP-3009 | `https://x402.org/facilitator` | yes | +| `base-sepolia-permit2` | `eip155:84532`, Permit2 | `https://x402.org/facilitator` | no | +| `arbitrum-sepolia` | `eip155:421614`, EIP-3009 | `https://facilitator.payai.network` | no | +| `solana-devnet` | `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | `https://x402.org/facilitator` | yes | +| `near-testnet` | `near:testnet` | the SDK's, started by the suite | yes | +| `base` (mainnet) | `eip155:8453`, EIP-3009 | none: `X402_FACILITATOR_URL` | no | +| `base-permit2` (mainnet) | `eip155:8453`, Permit2 | none: `X402_FACILITATOR_URL` | no | +| `solana` (mainnet) | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | none: `X402_SOLANA_MAINNET_FACILITATOR_URL` | no | +| `near` (mainnet) | `near:mainnet` | the SDK's, started by the suite | no | + +Every target settles one payment in its network's default USDC, priced in `USD`. + +Set `X402_SMOKE_TARGETS` to a comma-separated list for a release check: +unknown targets or missing settings fail before signing, so the check cannot +pass by skipping. Without it, `test:testnet` runs configured targets marked +"runs unnamed"; `test:mainnet` runs nothing. Mainnet also requires +`ALLOW_X402_MAINNET=true`. + +## Settings + +| Target | Required | Optional | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| EVM testnets | `X402_TESTNET_BUYER_PRIVATE_KEY`, `X402_TESTNET_MERCHANT_ADDRESS` | `X402_TESTNET_RPC_URL`, `X402_TESTNET_FACILITATOR_URL` (Base Sepolia); `X402_ARBITRUM_SEPOLIA_RPC_URL`, `X402_ARBITRUM_SEPOLIA_FACILITATOR_URL` | +| `solana-devnet` | `X402_SOLANA_DEVNET_BUYER_SECRET_KEY`, `X402_SOLANA_DEVNET_MERCHANT_ADDRESS` | `X402_SOLANA_DEVNET_RPC_URL`, `X402_SOLANA_DEVNET_FACILITATOR_URL` | +| `near-testnet` | `X402_NEAR_TESTNET_BUYER_ACCOUNT`, `X402_NEAR_TESTNET_BUYER_PRIVATE_KEY`, `X402_NEAR_TESTNET_MERCHANT_ACCOUNT`, and a relayer (below) | `X402_NEAR_TESTNET_RPC_URL` | +| `base`, `base-permit2` | `ALLOW_X402_MAINNET`, `X402_MAINNET_BUYER_PRIVATE_KEY`, `X402_MAINNET_MERCHANT_ADDRESS`, `X402_FACILITATOR_URL` | `CDP_API_KEY_ID` with `CDP_API_KEY_SECRET`, or `X402_FACILITATOR_TOKEN`; `X402_MAINNET_RPC_URL` | +| `solana` | `ALLOW_X402_MAINNET`, `X402_SOLANA_MAINNET_BUYER_SECRET_KEY`, `X402_SOLANA_MAINNET_MERCHANT_ADDRESS`, `X402_SOLANA_MAINNET_FACILITATOR_URL` | `X402_SOLANA_MAINNET_FACILITATOR_TOKEN`, `X402_SOLANA_MAINNET_RPC_URL` | +| `near` | `ALLOW_X402_MAINNET`, `X402_NEAR_MAINNET_BUYER_ACCOUNT`, `X402_NEAR_MAINNET_BUYER_PRIVATE_KEY`, `X402_NEAR_MAINNET_MERCHANT_ACCOUNT`, and a relayer (below) | `X402_NEAR_MAINNET_RPC_URL`; `X402_NEAR_MAINNET_FACILITATOR_TOKEN` with an external facilitator | + +A Solana secret key is the 64-byte key, as the JSON array `solana-keygen` +writes or the base58 string a wallet exports. A NEAR key is `ed25519:...`. + +Without a facilitator URL, `near-testnet` and `near` run the SDK facilitator +on 127.0.0.1. Set `X402_NEAR_TESTNET_RELAYER_ACCOUNT` and +`X402_NEAR_TESTNET_RELAYER_PRIVATE_KEY`, or their `X402_NEAR_MAINNET_` +equivalents; the relayer pays gas in NEAR. Mainnet requires HTTPS, so the +suite uses the `openssl` CLI to create a certificate for the run. + +`X402_NEAR_TESTNET_FACILITATOR_URL` or `X402_NEAR_MAINNET_FACILITATOR_URL` +replaces it with an external facilitator, whose `/supported` must name its +relayer accounts. FastNEAR's serves `near:mainnet` at +`https://x402.mikedotexe.com` and requires an API key, sent from +`X402_NEAR_MAINNET_FACILITATOR_TOKEN` as a bearer token. + +The suite checks these prerequisites without performing them: + +- **Permit2**: the buyer approves Permit2 + (`0x000000000022D473030F116dDEE9F6B43aC78BA3`) for the USDC once, which + costs gas. The gateway never sends approvals. +- **Solana**: the merchant's USDC token account must exist; send it any amount + once. The SDK client does not create it. +- **NEAR**: the merchant holds a storage deposit on the USDC token (NEP-145), + which Circle's faucet makes when it sends USDC, and the buyer signs with a + full-access key. An implicit account (64 hex characters) exists only once it + has received NEAR, so a mainnet buyer needs a little. + +## Spend + +Each target pays `X402_SMOKE_AMOUNT` USDC once per run, `0.01` by default +(`X402_TESTNET_AMOUNT` and `X402_MAINNET_AMOUNT` are read when it is unset). +Before signing, the suite rejects payments above `1.00` on testnet or `0.10` +on mainnet, and totals above `X402_SMOKE_MAX_TOTAL` (the same defaults). The SDK client's default spend controls +also cap each payment at $1 in a default asset. + +## What a run proves + +For each target, in order: + +1. On mainnet, the same config without `allowMainnet` is refused. +2. Discovery reports the deployment as testnet or mainnet on the target's + network, without the facilitator's URL, and the provider's health check + passes: chain, token and facilitator support. +3. The buyer holds enough USDC, and the target's preparation is in place. +4. The SDK client pays the challenge once; the gateway answers 200 with a + successful `PAYMENT-RESPONSE` on the target's network. +5. The chain shows the transfer the settlement reported: + - EVM: a successful receipt with the token's `Transfer` of the exact amount + from the buyer to the payee. + - Solana: a confirmed transaction without error whose token balances move + the amount of the mint from the buyer to the payee. + - NEAR: a final outer transaction carrying the buyer's `ft_transfer` of the + amount to the payee, and the token's successful receipt for it. +6. Exactly one gateway receipt names the transaction, delivered with status 200. +7. The same payment presented again gets 402 or 409, and the backend is not + called again. +8. Nothing the gateway logged contains a key or a credential. + +Standalone balance changes cannot prove settlement; an unrelated transfer +could explain them. Each settled target prints `[testnet] evidence {...}` +or `[mainnet] evidence {...}` with target, network, method, asset, amount, +payer, payee, facilitator origin, transaction, explorer link, receipt id and +time, never keys. `X402_SMOKE_EVIDENCE_FILE` appends these records as JSON +lines. + +Offline E2E tests exercise these evidence checks on Anvil, a Solana validator +and a NEAR sandbox; each check must reject an incorrect amount. + +## Status + +| Route | Status | +| -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| x402 on Base Sepolia and Base, EIP-3009 and Permit2; Solana devnet and mainnet; NEAR testnet and mainnet | settled; the transactions are in the [README](../README.md) | +| `arbitrum-sepolia` | smoke suite not yet run | +| Other built-in mainnets | no smoke target | diff --git a/examples/acp-checkout/README.md b/examples/acp-checkout/README.md index 031c740..fa8c6b9 100644 --- a/examples/acp-checkout/README.md +++ b/examples/acp-checkout/README.md @@ -4,13 +4,13 @@ This experimental ACP checkout adapter uses the `2026-04-17` snapshot. Each of its five operations maps to a resource backed by the merchant's checkout API: -| ACP operation | Route | Resource | -| --- | --- | --- | -| `createCheckoutSession` | `POST /acp/checkout_sessions` | `acp_checkout_create` | -| `updateCheckoutSession` | `POST /acp/checkout_sessions/{id}` | `acp_checkout_update` | -| `getCheckoutSession` | `GET /acp/checkout_sessions/{id}` | `acp_checkout_get` | +| ACP operation | Route | Resource | +| ------------------------- | ------------------------------------------- | ----------------------- | +| `createCheckoutSession` | `POST /acp/checkout_sessions` | `acp_checkout_create` | +| `updateCheckoutSession` | `POST /acp/checkout_sessions/{id}` | `acp_checkout_update` | +| `getCheckoutSession` | `GET /acp/checkout_sessions/{id}` | `acp_checkout_get` | | `completeCheckoutSession` | `POST /acp/checkout_sessions/{id}/complete` | `acp_checkout_complete` | -| `cancelCheckoutSession` | `POST /acp/checkout_sessions/{id}/cancel` | `acp_checkout_cancel` | +| `cancelCheckoutSession` | `POST /acp/checkout_sessions/{id}/cancel` | `acp_checkout_cancel` | `config.yaml` validates without environment variables. @@ -70,4 +70,4 @@ Carts, feed, standalone orders, delegated payment, delegated authentication, webhooks, the ACP MCP transport binding, request `Signature` verification, and any ACP version other than `2026-04-17`. `/.well-known/acp.json` advertises `services: ["checkout"]` and nothing else, and `agent-commerce doctor` prints -the full unsupported list. See [docs/protocols.md](../../docs/protocols.md#acp). +the full unsupported list. See [docs/protocols/acp.md](../../docs/protocols/acp.md). diff --git a/examples/arbitrum-sepolia/README.md b/examples/arbitrum-sepolia/README.md new file mode 100644 index 0000000..d760d90 --- /dev/null +++ b/examples/arbitrum-sepolia/README.md @@ -0,0 +1,43 @@ +# Example: arbitrum-sepolia + +The [base-sepolia](../base-sepolia/README.md) example on another EVM testnet, +`eip155:421614`. The config leaves out the asset fields, so payments settle in +Arbitrum Sepolia's USDC with the EIP-712 domain and decimals from the +built-in profile. + +The facilitator must list `eip155:421614` in its `/supported` response. On +2026-10-07 PayAI's facilitator listed it, and x402.org's listed only Base +Sepolia among EVM networks. Check before use: + +```bash +node -e "fetch('https://facilitator.payai.network/supported').then(r=>r.json()) + .then(d=>console.log(d.kinds.filter(k=>k.network==='eip155:421614')))" +``` + +## What you need + +| | | +| ------------------ | --------------------------------------------------------------------------------------- | +| A merchant address | any address you control. `MERCHANT_WALLET`. Address only, no key. | +| A funded buyer | Arbitrum Sepolia USDC from https://faucet.circle.com. No ETH: the facilitator pays gas. | + +```bash +MERCHANT_WALLET=0xYourMerchantAddress \ + npm run agent-commerce -- validate --config examples/arbitrum-sepolia/config.yaml +``` + +Run it as in the base-sepolia example, with +`AGENT_COMMERCE_CONFIG=examples/arbitrum-sepolia/config.yaml`. + +## Proving it settles + +```bash +X402_SMOKE_TARGETS=arbitrum-sepolia \ +X402_TESTNET_BUYER_PRIVATE_KEY=0x... \ +X402_TESTNET_MERCHANT_ADDRESS=0xYourMerchantAddress \ + npm run test:testnet +``` + +The run verifies the receipt's USDC `Transfer`; see +[docs/smoke-tests.md](../../docs/smoke-tests.md). It has not run on Arbitrum +Sepolia. diff --git a/examples/arbitrum-sepolia/config.yaml b/examples/arbitrum-sepolia/config.yaml new file mode 100644 index 0000000..5f8d0a7 --- /dev/null +++ b/examples/arbitrum-sepolia/config.yaml @@ -0,0 +1,76 @@ +# --------------------------------------------------------------------------- +# Example: arbitrum-sepolia +# +# The base-sepolia example on another EVM testnet. Leaving out the asset +# fields settles in the network's USDC, with its EIP-712 domain and decimals +# taken from the built-in profile. +# +# The gateway holds no signing key: the facilitator broadcasts and pays the gas. +# +# MERCHANT_WALLET=0x... \ +# npm run agent-commerce -- validate --config examples/arbitrum-sepolia/config.yaml +# --------------------------------------------------------------------------- + +version: 1 + +merchant: + id: arbitrum-sepolia-example + name: Arbitrum Sepolia Example + publicBaseUrl: ${GATEWAY_PUBLIC_BASE_URL:-http://localhost:8080} + +server: + port: ${GATEWAY_PORT:-8080} + host: 0.0.0.0 + # Operator routes (receipts, events) 404 without this. Uncomment and set it + # before exposing the gateway anywhere. + # adminToken: ${ADMIN_TOKEN} + +storage: + receipts: + driver: sqlite + path: ${RECEIPT_STORE_PATH:-./data/receipts.sqlite} + +protocols: + http: + enabled: true + mcp: + enabled: true + mountPath: /mcp + +resources: + premium_report: + name: Premium Report + description: One paid endpoint, settled in USDC on Arbitrum Sepolia. + input: + type: object + properties: {} + additionalProperties: false + backend: + type: http + method: GET + url: ${MERCHANT_API_BASE_URL:-http://localhost:3000}/api/report + timeoutMs: 10000 + pricing: + type: fixed + amount: "0.01" + currency: USDC + expose: [http, mcp] + payments: [x402] + +payments: + x402: + enabled: true + network: eip155:421614 + # No asset fields: Circle's USDC on Arbitrum Sepolia, from the built-in profile + payTo: ${MERCHANT_WALLET} + networks: + # Only for health checks; the facilitator does the chain work + eip155:421614: + rpcUrl: ${X402_RPC_URL:-https://arbitrum-sepolia-rpc.publicnode.com} + maxTimeoutSeconds: 300 + facilitator: + mode: remote + # Listed eip155:421614 in /supported on 2026-10-07; x402.org's did not + url: ${X402_FACILITATOR_URL:-https://facilitator.payai.network} + auth: + type: none diff --git a/examples/base-mainnet-payai/README.md b/examples/base-mainnet-payai/README.md index 88e05cd..d7b5a07 100644 --- a/examples/base-mainnet-payai/README.md +++ b/examples/base-mainnet-payai/README.md @@ -52,6 +52,7 @@ To actually settle: ```bash export ALLOW_X402_MAINNET=true +export X402_SMOKE_TARGETS=base export X402_MAINNET_BUYER_PRIVATE_KEY=0x... # funded with USDC on Base export X402_MAINNET_MERCHANT_ADDRESS=0x... export X402_FACILITATOR_URL=https://facilitator.payai.network @@ -61,5 +62,5 @@ npm run test:mainnet **Each successful x402 smoke run spends real USDC** (default 0.01). Setting `ALLOW_MPP_MAINNET=true` also enables the separate, paid MPP smoke suite. -The smoke test checks the buyer and merchant balance changes and reads the -transaction receipt from Base to confirm settlement. +The smoke test reads the USDC `Transfer` from the transaction receipt on Base +to confirm settlement; see [smoke tests](../../docs/smoke-tests.md). diff --git a/examples/base-mainnet/README.md b/examples/base-mainnet/README.md index 0a72cb8..9a816b1 100644 --- a/examples/base-mainnet/README.md +++ b/examples/base-mainnet/README.md @@ -16,14 +16,16 @@ Two things are structurally different from the local and testnet examples: ## What you need -| | | -| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `MERCHANT_WALLET` | your wallet. Address only: the gateway never takes a merchant key. | -| `ALLOW_X402_MAINNET=true` | the explicit opt-in. Never a default. | -| `X402_FACILITATOR_URL` | https, and authenticated unless you set `allowUnauthenticatedFacilitator` (see [base-mainnet-payai](../base-mainnet-payai/)). | -| CDP credentials, or a bearer token | `bearer` needs nothing installed; `cdp` pulls `@coinbase/x402`. | -| `ADMIN_TOKEN` | required here: it guards the receipt routes, which carry your ledger. | -| A Base RPC | health checks only. Use a dedicated endpoint: the public one's outages become your readiness failures. | +| | | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MERCHANT_WALLET` | your wallet. Address only: the gateway never takes a merchant key. | +| `ALLOW_X402_MAINNET=true` | the explicit opt-in. Never a default. | +| CDP credentials, or a bearer token | `cdp` requires the optional `@coinbase/x402` peer and CDP's URL, already configured. `bearer` needs no extra package; set its facilitator's https `url`. | +| `ADMIN_TOKEN` | required here: it guards the receipt routes, which carry your ledger. | +| A Base RPC | health checks only. Use a dedicated endpoint: the public one's outages become your readiness failures. | + +To run without a facilitator credential, see [base-mainnet-payai](../base-mainnet-payai/), +which sets `allowUnauthenticatedFacilitator`. ## Validate before anything else @@ -33,7 +35,6 @@ MERCHANT_WALLET=0xYourWallet \ GATEWAY_PUBLIC_BASE_URL=https://your.gateway \ ADMIN_TOKEN=... \ MERCHANT_API_BASE_URL=http://localhost:3000 \ -X402_FACILITATOR_URL=https://... \ CDP_API_KEY_ID=... CDP_API_KEY_SECRET=... \ npm run agent-commerce -- validate --config examples/base-mainnet/config.yaml ``` @@ -52,6 +53,7 @@ With every value set, `doctor` reports the deployment as ```bash export ALLOW_X402_MAINNET=true +export X402_SMOKE_TARGETS=base export X402_MAINNET_BUYER_PRIVATE_KEY=0x... # funded with USDC on Base export X402_MAINNET_MERCHANT_ADDRESS=0x... export X402_FACILITATOR_URL=https://... @@ -59,9 +61,10 @@ export CDP_API_KEY_ID=... CDP_API_KEY_SECRET=... # optional; or X402_FACILITA npm run test:mainnet ``` -**Each successful x402 smoke run spends `X402_MAINNET_AMOUNT` (default +**Each successful x402 smoke run spends `X402_SMOKE_AMOUNT` (default `0.01`) of real USDC.** The x402 suite skips itself, naming missing settings, -unless the opt-in, buyer key, merchant address and facilitator URL are set. +unless `X402_SMOKE_TARGETS` names `base`; a named target then fails without the +opt-in, buyer key, merchant address or facilitator URL. The facilitator credential is optional. Setting `ALLOW_MPP_MAINNET=true` also enables the separate, paid MPP smoke suite. @@ -69,7 +72,8 @@ It proves, in order: the guard refuses a config that has not opted in · the deployment reports itself as live mainnet · a payment settles on Base · the receipt carries the settlement reference and a 2xx backend status · the resource is delivered exactly once · the same authorization presented again is -refused with no second transfer · no credential appears in anything logged. -Balances and the transaction receipt are read back from the chain. +refused, and the backend is not called again · no credential appears in +anything logged. The USDC `Transfer` is read back from the transaction receipt. +See [smoke tests](../../docs/smoke-tests.md). It never runs in CI; see the [main README](../../README.md#base-mainnet) for why. diff --git a/examples/base-mainnet/config.yaml b/examples/base-mainnet/config.yaml index 5b75d16..4603979 100644 --- a/examples/base-mainnet/config.yaml +++ b/examples/base-mainnet/config.yaml @@ -10,7 +10,7 @@ # mode would put a funded gas signer in this process. Buyer funds move directly # to the merchant on-chain. # -# ALLOW_X402_MAINNET=true MERCHANT_WALLET=0x... X402_FACILITATOR_URL=... \ +# ALLOW_X402_MAINNET=true MERCHANT_WALLET=0x... \ # CDP_API_KEY_ID=... CDP_API_KEY_SECRET=... GATEWAY_PUBLIC_BASE_URL=... \ # ADMIN_TOKEN=... MERCHANT_API_BASE_URL=... \ # npm run agent-commerce -- validate --config examples/base-mainnet/config.yaml @@ -100,13 +100,14 @@ payments: facilitator: # required. `local` is refused on mainnet mode: remote - # required, and must be https - url: ${X402_FACILITATOR_URL} + # required, and must be https. `cdp` auth works only with this URL + url: https://api.cdp.coinbase.com/platform/v2/x402 # required to be something other than `none` on mainnet, unless # allowUnauthenticatedFacilitator is set (see examples/base-mainnet-payai) # # `cdp` needs the optional peer @coinbase/x402, which signs a fresh JWT - # per request. For any facilitator that takes a static token, use: + # per request. For any facilitator that takes a static token, set its + # url and use: # auth: # type: bearer # token: ${X402_FACILITATOR_TOKEN} diff --git a/examples/base-sepolia/README.md b/examples/base-sepolia/README.md index 57a987d..ff4f905 100644 --- a/examples/base-sepolia/README.md +++ b/examples/base-sepolia/README.md @@ -50,9 +50,7 @@ MERCHANT_WALLET=0xYourMerchantAddress \ npm run agent-commerce -- doctor --config examples/base-sepolia/config.yaml ``` -`doctor` reports the deployment as `TESTNET on Base Sepolia (eip155:84532)`, -not as local. Chain id 84532 belongs to both, so the facilitator is what -distinguishes them. +`doctor` reports the deployment as `TESTNET on Base Sepolia (eip155:84532)`. ## Proving it settles @@ -62,12 +60,14 @@ X402_TESTNET_MERCHANT_ADDRESS=0xYourMerchantAddress \ npm run test:testnet ``` -That suite asks unpaid, signs the challenge, settles through the facilitator, -and then reads the **on-chain balances and transaction receipt back from the -network**; the gateway's own report of success is not the proof. It spends -`X402_TESTNET_AMOUNT` (default `0.01`) USDC per run, and it skips itself with -an explanation when those variables are absent. The same variables also run -the MPP Base Sepolia smoke suite, which spends that amount again. +The suite requests a challenge, signs it and settles through the facilitator, +then verifies the **USDC `Transfer` in the on-chain receipt**. Each x402 run +spends `X402_SMOKE_AMOUNT` (default `0.01`) USDC and skips with an explanation +if the required variables are absent. These variables also enable the separate +MPP Base Sepolia suite, whose amount is set by `X402_TESTNET_AMOUNT` +(default `0.01`). The +[smoke tests](../../docs/smoke-tests.md) page covers the other targets, such as +Permit2 on Base Sepolia. It has been run: [`0xf3288399…`](https://sepolia.basescan.org/tx/0xf3288399e31eab683f9bced802fad2dcf44072f93e1aa51223a0f8398e7668e8) settled 0.01 USDC on 2026-08-22. diff --git a/examples/base-sepolia/config.yaml b/examples/base-sepolia/config.yaml index 69d91ea..8015656 100644 --- a/examples/base-sepolia/config.yaml +++ b/examples/base-sepolia/config.yaml @@ -66,9 +66,7 @@ payments: x402: enabled: true - # Base Sepolia. The chain id it carries (84532) is the same one this - # project's local dev chain uses, which is why `local` vs `testnet` is - # decided by the facilitator below, not by this line. + # Base Sepolia. The local dev chain is eip155:31337 instead. network: eip155:84532 # Only used for health checks here; the facilitator does the chain work. diff --git a/examples/free-and-premium/config.yaml b/examples/free-and-premium/config.yaml index 9f96948..4f4debc 100644 --- a/examples/free-and-premium/config.yaml +++ b/examples/free-and-premium/config.yaml @@ -77,7 +77,7 @@ resources: payments: x402: enabled: true - network: ${X402_NETWORK:-eip155:84532} + network: ${X402_NETWORK:-eip155:31337} rpcUrl: ${X402_RPC_URL:-http://127.0.0.1:8545} # Placeholder: replace with the deployed MockUSDC address from # `.deploy/local.json` after `npm run chain:deploy` (see README.md) diff --git a/examples/multi-network-mainnet/README.md b/examples/multi-network-mainnet/README.md new file mode 100644 index 0000000..5b2d429 --- /dev/null +++ b/examples/multi-network-mainnet/README.md @@ -0,0 +1,42 @@ +# Example: multi-network-mainnet + +> **Real funds.** A template for offering one resource on several mainnets. +> Payments have settled on Base and Solana. Polygon and Arbitrum One are +> supported but have not run with real funds. + +One resource, four offers: USDC on Base, Polygon, Arbitrum One and Solana. The +challenge lists all four, and the buyer's proof selects one. Each offer has +its own `payTo` and RPC endpoint, and all of them settle through one +facilitator. Config load requires `allowMainnet: true` and each network's own +USDC, and startup refuses an Anvil development address as `payTo`. + +## What you need + +| | | +| ------------------ | ----------------------------------------------------------------------------------------------------- | +| Merchant addresses | `MERCHANT_WALLET` for the EVM networks, `SOLANA_MERCHANT_WALLET` with an existing USDC token account. | +| A facilitator | `X402_FACILITATOR_URL` and `X402_FACILITATOR_TOKEN`, listing all four networks in `/supported`. | +| Solana support | `npm install @x402/svm@2.25.0 @solana/kit@5.5.1` | +| An admin token | `ADMIN_TOKEN`, which protects the receipts and events routes. | + +Check that the facilitator's `/supported` lists every network; otherwise +health and readiness fail. +For a facilitator that takes no credential, replace the `auth` block with +`type: none` and add `allowUnauthenticatedFacilitator: true`, as in +[base-mainnet-payai](../base-mainnet-payai/README.md). + +```bash +ALLOW_X402_MAINNET=true MERCHANT_WALLET=0x... SOLANA_MERCHANT_WALLET=... \ +X402_FACILITATOR_URL=https://... X402_FACILITATOR_TOKEN=... ADMIN_TOKEN=... \ + npm run agent-commerce -- doctor --config examples/multi-network-mainnet/config.yaml +``` + +`doctor` names each network in its mainnet safety check, and compares every +live offer with this config. + +## Proving a route + +`npm run test:mainnet` settles one small payment per named target, with +`base`, `base-permit2`, `solana` and `near` allowlisted; see +[docs/smoke-tests.md](../../docs/smoke-tests.md). +Polygon and Arbitrum One have no mainnet smoke target yet. diff --git a/examples/multi-network-mainnet/config.yaml b/examples/multi-network-mainnet/config.yaml new file mode 100644 index 0000000..94d9182 --- /dev/null +++ b/examples/multi-network-mainnet/config.yaml @@ -0,0 +1,97 @@ +# --------------------------------------------------------------------------- +# Example: multi-network-mainnet - REAL FUNDS, template +# +# One resource offered on Base, Polygon, Arbitrum One and Solana; the buyer +# pays any one of them. Every offer settles in its network's USDC through one +# facilitator, which must list each network. Payments have settled on Base +# and Solana; Polygon and Arbitrum One are supported but have not run with +# real funds. +# +# Solana offers need the optional peers @x402/svm and @solana/kit. +# +# ALLOW_X402_MAINNET=true MERCHANT_WALLET=0x... SOLANA_MERCHANT_WALLET=... \ +# X402_FACILITATOR_URL=https://... \ +# npm run agent-commerce -- validate --config examples/multi-network-mainnet/config.yaml +# --------------------------------------------------------------------------- + +version: 1 + +merchant: + id: multi-network-mainnet-example + name: Multi-network Mainnet Example + publicBaseUrl: ${GATEWAY_PUBLIC_BASE_URL:-http://localhost:8080} + +server: + port: ${GATEWAY_PORT:-8080} + host: 0.0.0.0 + # Operator routes (receipts, events) 404 without this + adminToken: ${ADMIN_TOKEN} + +storage: + receipts: + driver: sqlite + path: ${RECEIPT_STORE_PATH:-./data/receipts.sqlite} + +protocols: + http: + enabled: true + mcp: + enabled: true + mountPath: /mcp + +resources: + premium_report: + name: Premium Report + description: One paid endpoint, settled in USDC on the network the buyer picks. + input: + type: object + properties: {} + additionalProperties: false + backend: + type: http + method: GET + url: ${MERCHANT_API_BASE_URL:-http://localhost:3000}/api/report + timeoutMs: 10000 + pricing: + type: fixed + # USD names the networks' USDC at face value; receipts record USDC + amount: "0.01" + currency: USD + expose: [http, mcp] + payments: [x402] + +payments: + x402: + enabled: true + # No asset fields: each network's USDC, as config load requires on mainnet + options: + - network: eip155:8453 + payTo: ${MERCHANT_WALLET} + - network: eip155:137 + payTo: ${MERCHANT_WALLET} + - network: eip155:42161 + payTo: ${MERCHANT_WALLET} + # The wallet's USDC token account must exist before a payment arrives + - network: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp + payTo: ${SOLANA_MERCHANT_WALLET} + # One endpoint per network, for health checks; dedicated endpoints avoid + # public rate limits + networks: + eip155:8453: + rpcUrl: ${BASE_RPC_URL:-https://base.drpc.org} + eip155:137: + rpcUrl: ${POLYGON_RPC_URL:-https://polygon-bor-rpc.publicnode.com} + eip155:42161: + rpcUrl: ${ARBITRUM_RPC_URL:-https://arbitrum-one-rpc.publicnode.com} + solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp: + rpcUrl: ${SOLANA_RPC_URL:-https://api.mainnet-beta.solana.com} + maxTimeoutSeconds: 600 + # Required, and required to be `true`. Mainnet is never a default + allowMainnet: ${ALLOW_X402_MAINNET} + facilitator: + mode: remote + # Health fails unless its /supported lists every network above + url: ${X402_FACILITATOR_URL} + auth: + type: bearer + token: ${X402_FACILITATOR_TOKEN} diff --git a/examples/near-testnet/README.md b/examples/near-testnet/README.md new file mode 100644 index 0000000..f273ac9 --- /dev/null +++ b/examples/near-testnet/README.md @@ -0,0 +1,50 @@ +# Example: near-testnet + +x402 `exact` on NEAR testnet (`near:testnet`). The buyer signs one USDC +`ft_transfer` in a NEP-366 delegate. The facilitator's relayer wraps it in a +transaction and pays gas; the payment flow needs no gateway or merchant key. +Omitting asset fields selects the built-in testnet USDC profile. + +The gateway loads NEAR support only when a NEAR offer is configured: + +```bash +npm install @x402/near@2.25.0 +``` + +## What you need + +| | | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A merchant account | `NEAR_MERCHANT_ACCOUNT`, with a storage deposit on the USDC token (NEP-145 `storage_deposit`); Circle's faucet makes one when it sends USDC. Health fails without one. | +| A funded buyer | testnet USDC from https://faucet.circle.com (Near Testnet) and a full-access key: the SDK facilitator refuses a delegate signed with a function-call key. | +| A NEAR facilitator | `X402_FACILITATOR_URL`, one that serves `near:testnet`. FastNEAR's [hosted facilitator](https://github.com/fastnear/x402-facilitator) requires an API key: send it with `auth.type: bearer`. | + +A facilitator for NEAR runs the SDK's `ExactNearScheme` from +`@x402/near/exact/facilitator` with a relayer account that holds testnet NEAR +for gas. Running a production facilitator is outside this project; the smoke +suite below runs one for the length of a test. + +```bash +NEAR_MERCHANT_ACCOUNT=merchant.testnet X402_FACILITATOR_URL=https://your-facilitator \ + npm run agent-commerce -- validate --config examples/near-testnet/config.yaml +``` + +## Proving it settles + +```bash +X402_SMOKE_TARGETS=near-testnet \ +X402_NEAR_TESTNET_BUYER_ACCOUNT=buyer.testnet \ +X402_NEAR_TESTNET_BUYER_PRIVATE_KEY=ed25519:... \ +X402_NEAR_TESTNET_MERCHANT_ACCOUNT=merchant.testnet \ +X402_NEAR_TESTNET_RELAYER_ACCOUNT=relayer.testnet \ +X402_NEAR_TESTNET_RELAYER_PRIVATE_KEY=ed25519:... \ + npm run test:testnet +``` + +Without `X402_NEAR_TESTNET_FACILITATOR_URL`, the run starts the SDK's +facilitator on 127.0.0.1 with that relayer. It then reads the buyer's +`ft_transfer` and the token's successful receipt from the final transaction; +see [docs/smoke-tests.md](../../docs/smoke-tests.md). + +It has been run: [`5tdtGLv4TB…`](https://testnet.nearblocks.io/txns/5tdtGLv4TBifK7c12hEdRuvxvNNrWjcWDH4e1ApUcE1W) +settled 0.01 USDC on 2026-10-10. diff --git a/examples/near-testnet/config.yaml b/examples/near-testnet/config.yaml new file mode 100644 index 0000000..8bb62f6 --- /dev/null +++ b/examples/near-testnet/config.yaml @@ -0,0 +1,76 @@ +# --------------------------------------------------------------------------- +# Example: near-testnet +# +# x402 `exact` on NEAR testnet. The buyer signs a NEP-366 delegate action +# holding one `ft_transfer`, and the facilitator's relayer submits it and pays +# the gas, so neither the gateway nor the merchant holds a key. Needs the +# optional peer @x402/near and a facilitator serving near:testnet. +# X402_FACILITATOR_URL has no default. +# +# NEAR_MERCHANT_ACCOUNT=merchant.testnet X402_FACILITATOR_URL=https://... \ +# npm run agent-commerce -- validate --config examples/near-testnet/config.yaml +# --------------------------------------------------------------------------- + +version: 1 + +merchant: + id: near-testnet-example + name: NEAR Testnet Example + publicBaseUrl: ${GATEWAY_PUBLIC_BASE_URL:-http://localhost:8080} + +server: + port: ${GATEWAY_PORT:-8080} + host: 0.0.0.0 + # Operator routes (receipts, events) 404 without this. Uncomment and set it + # before exposing the gateway anywhere. + # adminToken: ${ADMIN_TOKEN} + +storage: + receipts: + driver: sqlite + path: ${RECEIPT_STORE_PATH:-./data/receipts.sqlite} + +protocols: + http: + enabled: true + mcp: + enabled: true + mountPath: /mcp + +resources: + premium_report: + name: Premium Report + description: One paid endpoint, settled in USDC on NEAR testnet. + input: + type: object + properties: {} + additionalProperties: false + backend: + type: http + method: GET + url: ${MERCHANT_API_BASE_URL:-http://localhost:3000}/api/report + timeoutMs: 10000 + pricing: + type: fixed + amount: "0.01" + currency: USDC + expose: [http, mcp] + payments: [x402] + +payments: + x402: + enabled: true + network: near:testnet + # No asset fields: the testnet USDC token account, from the built-in profile. + # This account must hold a storage deposit with that token (NEP-145). + payTo: ${NEAR_MERCHANT_ACCOUNT} + networks: + # Only for health checks: chain id, token code and decimals, storage + near:testnet: + rpcUrl: ${X402_RPC_URL:-https://rpc.testnet.fastnear.com} + maxTimeoutSeconds: 300 + facilitator: + mode: remote + url: ${X402_FACILITATOR_URL} + auth: + type: none diff --git a/examples/paid-mcp-tool/config.yaml b/examples/paid-mcp-tool/config.yaml index b3ca0d9..5432b14 100644 --- a/examples/paid-mcp-tool/config.yaml +++ b/examples/paid-mcp-tool/config.yaml @@ -58,7 +58,7 @@ resources: payments: x402: enabled: true - network: ${X402_NETWORK:-eip155:84532} + network: ${X402_NETWORK:-eip155:31337} rpcUrl: ${X402_RPC_URL:-http://127.0.0.1:8545} # Placeholder: replace with the deployed MockUSDC address from # `.deploy/local.json` after `npm run chain:deploy` (see README.md) diff --git a/examples/simple-paid-api/config.yaml b/examples/simple-paid-api/config.yaml index 7a445f6..8c0643c 100644 --- a/examples/simple-paid-api/config.yaml +++ b/examples/simple-paid-api/config.yaml @@ -57,7 +57,7 @@ resources: payments: x402: enabled: true - network: ${X402_NETWORK:-eip155:84532} + network: ${X402_NETWORK:-eip155:31337} rpcUrl: ${X402_RPC_URL:-http://127.0.0.1:8545} # Placeholder: replace with the deployed MockUSDC address from # `.deploy/local.json` after `npm run chain:deploy` (see README.md) diff --git a/examples/solana-devnet/README.md b/examples/solana-devnet/README.md new file mode 100644 index 0000000..6cfd2b3 --- /dev/null +++ b/examples/solana-devnet/README.md @@ -0,0 +1,47 @@ +# Example: solana-devnet + +x402 `exact` on Solana devnet (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`). The +buyer signs a USDC transfer. The facilitator adds its fee payer signature and +broadcasts it, so the gateway and the merchant hold no key. The config leaves +out the asset fields, so payments settle in Circle's devnet USDC mint. + +The gateway loads Solana support only when a Solana offer is configured: + +```bash +npm install @x402/svm@2.25.0 @solana/kit@5.5.1 +``` + +## What you need + +| | | +| -------------------- | ---------------------------------------------------------------------------------------------------- | +| A merchant wallet | `SOLANA_MERCHANT_WALLET`. Its USDC token account must exist; send it any amount of devnet USDC once. | +| A funded buyer | devnet USDC from https://faucet.circle.com. No SOL: the facilitator pays the fee. | +| A Solana facilitator | one whose `/supported` lists the devnet network. On 2026-10-07 x402.org's and PayAI's did. | + +```bash +SOLANA_MERCHANT_WALLET=YourWalletAddress \ + npm run agent-commerce -- validate --config examples/solana-devnet/config.yaml +``` + +Run it as in the [base-sepolia](../base-sepolia/README.md) example, with +`AGENT_COMMERCE_CONFIG=examples/solana-devnet/config.yaml`. A handler that can +take longer than about a minute should use `paymentFlow: upfront`: the signed +transaction's blockhash expires before an `authorization` flow settles it. + +## Proving it settles + +```bash +X402_SMOKE_TARGETS=solana-devnet \ +X402_SOLANA_DEVNET_BUYER_SECRET_KEY='[...]' \ +X402_SOLANA_DEVNET_MERCHANT_ADDRESS=YourWalletAddress \ + npm run test:testnet +``` + +The buyer key is the 64-byte secret key, as the JSON array `solana-keygen` +writes or the base58 string a wallet exports. The run reads the token balance +changes from the confirmed transaction itself; see +[docs/smoke-tests.md](../../docs/smoke-tests.md). + +It has been run: [`5289xbeyHJ…`](https://explorer.solana.com/tx/5289xbeyHJPX1vX9DsLwGGJaGJJCH45qUtArhoTyxdgmWbXqZSjVPuJ3jyyLG4VrT9mFTCkLKhcoNfb1tuJ5qheb?cluster=devnet) +settled 0.01 USDC on 2026-10-10. diff --git a/examples/solana-devnet/config.yaml b/examples/solana-devnet/config.yaml new file mode 100644 index 0000000..cfc0bc2 --- /dev/null +++ b/examples/solana-devnet/config.yaml @@ -0,0 +1,78 @@ +# --------------------------------------------------------------------------- +# Example: solana-devnet +# +# x402 `exact` on Solana devnet. The buyer signs a token transfer, and the +# facilitator adds its fee payer signature and broadcasts it, so neither the +# gateway nor the merchant holds a key. Needs the optional peers @x402/svm and +# @solana/kit. +# +# SOLANA_MERCHANT_WALLET=... \ +# npm run agent-commerce -- validate --config examples/solana-devnet/config.yaml +# --------------------------------------------------------------------------- + +version: 1 + +merchant: + id: solana-devnet-example + name: Solana Devnet Example + publicBaseUrl: ${GATEWAY_PUBLIC_BASE_URL:-http://localhost:8080} + +server: + port: ${GATEWAY_PORT:-8080} + host: 0.0.0.0 + # Operator routes (receipts, events) 404 without this. Uncomment and set it + # before exposing the gateway anywhere. + # adminToken: ${ADMIN_TOKEN} + +storage: + receipts: + driver: sqlite + path: ${RECEIPT_STORE_PATH:-./data/receipts.sqlite} + +protocols: + http: + enabled: true + mcp: + enabled: true + mountPath: /mcp + +resources: + premium_report: + name: Premium Report + description: One paid endpoint, settled in USDC on Solana devnet. + input: + type: object + properties: {} + additionalProperties: false + backend: + type: http + method: GET + url: ${MERCHANT_API_BASE_URL:-http://localhost:3000}/api/report + timeoutMs: 10000 + pricing: + type: fixed + amount: "0.01" + currency: USDC + expose: [http, mcp] + payments: [x402] + +payments: + x402: + enabled: true + network: solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 + # No asset fields: Circle's devnet USDC mint, from the built-in profile. + # The transfer goes to this wallet's USDC token account, which must exist. + payTo: ${SOLANA_MERCHANT_WALLET} + networks: + # Only for health checks: the genesis hash, the mint and its decimals + solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1: + rpcUrl: ${X402_RPC_URL:-https://api.devnet.solana.com} + # A blockhash expires in about 60 to 90 seconds; a slower backend should + # use `paymentFlow: upfront` + maxTimeoutSeconds: 300 + facilitator: + mode: remote + # Its /supported names the fee payer each challenge carries + url: ${X402_FACILITATOR_URL:-https://x402.org/facilitator} + auth: + type: none diff --git a/package-lock.json b/package-lock.json index ec68345..24fdca1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,13 @@ "@modelcontextprotocol/client": "2.3.0", "@modelcontextprotocol/sdk": "1.32.0", "@modelcontextprotocol/server": "2.3.0", + "@near-js/accounts": "2.5.1", + "@near-js/crypto": "2.5.1", + "@near-js/providers": "2.5.1", + "@near-js/signers": "2.5.1", + "@near-js/transactions": "2.5.1", "@sd-jwt/core": "0.20.1", + "@solana/kit": "5.5.1", "@types/better-sqlite3": "9.6.0", "@types/node": "24.13.3", "@types/react": "19.2.18", @@ -41,6 +47,8 @@ "@x402/core": "2.25.0", "@x402/evm": "2.25.0", "@x402/mcp": "2.25.0", + "@x402/near": "2.25.0", + "@x402/svm": "2.25.0", "canonicalize": "5.0.0", "jose": "6.2.12", "mppx": "0.13.1", @@ -62,8 +70,11 @@ "@coinbase/x402": "2.1.0", "@modelcontextprotocol/server": "2.3.0", "@sd-jwt/core": "0.20.1", + "@solana/kit": "5.5.1", "@x402/core": "2.25.0", "@x402/evm": "2.25.0", + "@x402/near": "2.25.0", + "@x402/svm": "2.25.0", "canonicalize": "5.0.0", "jose": "6.2.12", "mppx": "0.13.1", @@ -79,12 +90,21 @@ "@sd-jwt/core": { "optional": true }, + "@solana/kit": { + "optional": true + }, "@x402/core": { "optional": true }, "@x402/evm": { "optional": true }, + "@x402/near": { + "optional": true + }, + "@x402/svm": { + "optional": true + }, "canonicalize": { "optional": true }, @@ -1237,6 +1257,233 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@near-js/accounts": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@near-js/accounts/-/accounts-2.5.1.tgz", + "integrity": "sha512-L4/Z9Ujwnz8/uvOExESP6h0t/plu4/0SFrL+UJBnfO5LObVwnJH2KG6MCLONy8rKh1FZHOlT9LPhtG/6nUrvOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@near-js/crypto": "2.5.1", + "@near-js/keystores": "2.5.1", + "@near-js/providers": "2.5.1", + "@near-js/signers": "2.5.1", + "@near-js/tokens": "2.5.1", + "@near-js/transactions": "2.5.1", + "@near-js/types": "2.5.1", + "@near-js/utils": "2.5.1", + "@noble/hashes": "1.7.1", + "borsh": "1.0.0", + "depd": "2.0.0", + "is-my-json-valid": "^2.20.6", + "lru_map": "0.4.1", + "near-abi": "0.2.0" + }, + "peerDependencies": { + "@near-js/crypto": "^2.0.1", + "@near-js/keystores": "^2.0.1", + "@near-js/providers": "^2.0.1", + "@near-js/signers": "^2.0.1", + "@near-js/tokens": "^2.0.1", + "@near-js/transactions": "^2.0.1", + "@near-js/types": "^2.0.1", + "@near-js/utils": "^2.0.1" + } + }, + "node_modules/@near-js/accounts/node_modules/@noble/hashes": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", + "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@near-js/crypto": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@near-js/crypto/-/crypto-2.5.1.tgz", + "integrity": "sha512-Kb+bbnUrfvuzzed9hpLRpcIlCMOaQlw/7BxlZPCq8DggVaK1m0nKR1DHQs2cV0Q0WSKSk2UrFJho1dxeKL5alg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@near-js/types": "2.5.1", + "@near-js/utils": "2.5.1", + "@noble/curves": "1.8.1", + "@noble/hashes": "^1.7.1", + "borsh": "1.0.0", + "secp256k1": "5.0.1" + }, + "peerDependencies": { + "@near-js/types": "^2.0.1", + "@near-js/utils": "^2.0.1" + } + }, + "node_modules/@near-js/crypto/node_modules/@noble/curves": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.1.tgz", + "integrity": "sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.7.1" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@near-js/crypto/node_modules/@noble/hashes": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", + "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@near-js/keystores": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@near-js/keystores/-/keystores-2.5.1.tgz", + "integrity": "sha512-UlP3GXeDzRyUlL3qKZUkwSwbABDarhOqRRR0vcMBOOu+063GDULGio9GTWDvKDDi11nmfbhOzbiY5vXEW4jR8g==", + "dev": true, + "license": "ISC", + "dependencies": { + "@near-js/crypto": "2.5.1", + "@near-js/types": "2.5.1" + }, + "peerDependencies": { + "@near-js/crypto": "^2.0.1", + "@near-js/types": "^2.0.1" + } + }, + "node_modules/@near-js/providers": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@near-js/providers/-/providers-2.5.1.tgz", + "integrity": "sha512-4DUyAKdEJVOF/7Ud364lDS8Wk+ZUc67ozgqa3dd8z/9RqIWIPCufPiZS3/89Vxi0qL+1uKlXchPpiemZLC+UOw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@near-js/crypto": "2.5.1", + "@near-js/transactions": "2.5.1", + "@near-js/types": "2.5.1", + "@near-js/utils": "2.5.1", + "borsh": "1.0.0", + "exponential-backoff": "^3.1.2" + }, + "optionalDependencies": { + "node-fetch": "2.6.7" + }, + "peerDependencies": { + "@near-js/crypto": "^2.0.1", + "@near-js/transactions": "^2.0.1", + "@near-js/types": "^2.0.1", + "@near-js/utils": "^2.0.1" + } + }, + "node_modules/@near-js/signers": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@near-js/signers/-/signers-2.5.1.tgz", + "integrity": "sha512-5Zmf/2mnnSqEjYoJ+kuS3CQPMwrGInWfGlIxvpvRWXZxL3cv/7REuhclxrLJqq5SqqEAl2cL32V1u0S18r+rkA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@near-js/crypto": "2.5.1", + "@near-js/keystores": "2.5.1", + "@near-js/transactions": "2.5.1", + "@noble/hashes": "1.7.1", + "borsh": "1.0.0" + }, + "peerDependencies": { + "@near-js/crypto": "^2.0.1", + "@near-js/keystores": "^2.0.1", + "@near-js/transactions": "^2.0.1" + } + }, + "node_modules/@near-js/signers/node_modules/@noble/hashes": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", + "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@near-js/tokens": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@near-js/tokens/-/tokens-2.5.1.tgz", + "integrity": "sha512-doDtOaSA2FOsYzzwocg0L02z6ORNL7HFAuL455y2O38//F4C2jp+C9De9fWrItkrim2jKjoSR3UvO2AuVbwmWA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@near-js/transactions": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@near-js/transactions/-/transactions-2.5.1.tgz", + "integrity": "sha512-0svK5K6VqOciSIn9mxreyS0uQf8T1UZc71JBA6yrstpHJhltz/dvNUMY0FV+xHx3H/KAdvCMjX0VLEpyi08yWQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@near-js/crypto": "2.5.1", + "@near-js/types": "2.5.1", + "@near-js/utils": "2.5.1", + "@noble/hashes": "1.7.1", + "borsh": "1.0.0" + }, + "peerDependencies": { + "@near-js/crypto": "^2.0.1", + "@near-js/types": "^2.0.1", + "@near-js/utils": "^2.0.1" + } + }, + "node_modules/@near-js/transactions/node_modules/@noble/hashes": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", + "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@near-js/types": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@near-js/types/-/types-2.5.1.tgz", + "integrity": "sha512-lUJJmbV6qcilIwswldeP7FIjkjTWvmtgI+yQHVmiBNRVjYvPMiQ/e9vs3SXE/cYtRluAdq0JHJwYoqhFb69mwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@near-js/utils": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@near-js/utils/-/utils-2.5.1.tgz", + "integrity": "sha512-bXsw7Qkm689tV0sg6nwplAFoL6PrQ5tY72YIoiZezZ8ZGBea+2+ILmUIHWKLum7b7qJHKq1iH7uycE+m+Z4QHA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@near-js/types": "2.5.1", + "@scure/base": "^1.2.4", + "depd": "2.0.0", + "mustache": "4.0.0" + }, + "peerDependencies": { + "@near-js/types": "^2.0.1" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -2085,6 +2332,16 @@ "node": ">=20" } }, + "node_modules/@solana-program/compute-budget": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@solana-program/compute-budget/-/compute-budget-0.11.0.tgz", + "integrity": "sha512-7f1ePqB/eURkTwTOO9TNIdUXZcyrZoX3Uy2hNo7cXMfNhPFWp9AVgIyRNBc2jf15sdUa9gNpW+PfP2iV8AYAaw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@solana/kit": "^5.0" + } + }, "node_modules/@solana-program/system": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@solana-program/system/-/system-0.10.0.tgz", @@ -2105,6 +2362,17 @@ "@solana/kit": "^5.0" } }, + "node_modules/@solana-program/token-2022": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@solana-program/token-2022/-/token-2022-0.6.1.tgz", + "integrity": "sha512-Ex02cruDMGfBMvZZCrggVR45vdQQSI/unHVpt/7HPt/IwFYB4eTlXtO8otYZyqV/ce5GqZ8S6uwyRf0zy6fdbA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@solana/kit": "^5.0", + "@solana/sysvars": "^5.0" + } + }, "node_modules/@solana/accounts": { "version": "5.5.1", "resolved": "https://registry.npmjs.org/@solana/accounts/-/accounts-5.5.1.tgz", @@ -3134,6 +3402,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -3368,6 +3643,40 @@ "zod": "^3.24.2" } }, + "node_modules/@x402/near": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@x402/near/-/near-2.25.0.tgz", + "integrity": "sha512-/zVEDbRNB63bCRZkSiakjpX4pztJ/gLY9jCV8FAeSzqVtrhb1R4u5fdduDBC9LhQqPQCZSJQUoBH/j9iX6+L0g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@near-js/crypto": "^2.5.1", + "@near-js/providers": "^2.5.1", + "@near-js/signers": "^2.5.1", + "@near-js/transactions": "^2.5.1", + "@near-js/utils": "^2.5.1", + "@noble/hashes": "^1.8.0", + "@x402/core": "~2.25.0", + "borsh": "1.0.0" + } + }, + "node_modules/@x402/svm": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@x402/svm/-/svm-2.25.0.tgz", + "integrity": "sha512-Xs0GbtmVGiDiFz6RAuJXh0DFRbbpfXqlZzZQjX2k7O78G4gX2+segX6Aw76u4wWax7Xx+HGUBj68YPuyfPQBnw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@noble/hashes": "^1.8.0", + "@solana-program/compute-budget": "^0.11.0", + "@solana-program/token": "^0.9.0", + "@solana-program/token-2022": "^0.6.1", + "@x402/core": "~2.25.0" + }, + "peerDependencies": { + "@solana/kit": ">=5.1.0" + } + }, "node_modules/abitype": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.3.0.tgz", @@ -3593,6 +3902,13 @@ "node": ">=22" } }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -3632,6 +3948,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/borsh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/borsh/-/borsh-1.0.0.tgz", + "integrity": "sha512-fSVWzzemnyfF89EPwlUNsrS5swF5CrtiN4e+h0/lLf4dz2he4L3ndM20PS9wj7ICSkXJe/TQUHdaPTq15b1mNQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "dev": true, + "license": "MIT" + }, "node_modules/bs58": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", @@ -4007,6 +4337,22 @@ "dev": true, "license": "MIT" }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -4192,6 +4538,13 @@ "node": ">=12.0.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -4585,6 +4938,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/generate-object-property": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz", + "integrity": "sha512-TuOwZWgJ2VAMEGJvAyPWvpqxSANF0LDpmyHauMjFYzaACvn+QTT/AZomvPCzVBV7yDN3OmwHQ5OvHaeLKre3JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.0" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4676,6 +5049,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -4696,6 +5080,18 @@ "dev": true, "license": "MIT" }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/hono": { "version": "4.13.12", "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz", @@ -4798,6 +5194,27 @@ "dev": true, "license": "MIT" }, + "node_modules/is-my-ip-valid": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-my-ip-valid/-/is-my-ip-valid-1.0.1.tgz", + "integrity": "sha512-jxc8cBcOWbNK2i2aTkCZP6i7wkHF1bqKFrwEHuN5Jtg5BSaZHUZQ/JTOJwoV41YvHnOaRyWWh72T/KvfNz9DJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-my-json-valid": { + "version": "2.20.6", + "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.20.6.tgz", + "integrity": "sha512-1JQwulVNjx8UqkPE/bqDaxtH4PXCe/2VRh/y3p99heOV87HG4Id5/VfDswd+YiAfHcRTfDlWgISycnHuhZq1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "generate-function": "^2.0.0", + "generate-object-property": "^1.1.0", + "is-my-ip-valid": "^1.0.0", + "jsonpointer": "^5.0.0", + "xtend": "^4.0.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -4805,6 +5222,13 @@ "dev": true, "license": "MIT" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "dev": true, + "license": "MIT" + }, "node_modules/is-retry-allowed": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", @@ -4939,6 +5363,16 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -5280,6 +5714,13 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/lru_map": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz", + "integrity": "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5394,6 +5835,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "dev": true, + "license": "MIT" + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -5506,6 +5961,19 @@ "dev": true, "license": "MIT" }, + "node_modules/mustache": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.0.0.tgz", + "integrity": "sha512-FJgjyX/IVkbXBXYUwH+OYwQKqWpFPLaLVESd70yHjSDunwzV2hZOoTBvPf4KLoxesUzzyfTH6F784Uqd7Wm5yA==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + }, + "engines": { + "npm": ">=1.4.0" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -5537,6 +6005,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/near-abi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/near-abi/-/near-abi-0.2.0.tgz", + "integrity": "sha512-kCwSf/3fraPU2zENK18sh+kKG4uKbEUEQdyWQkmW8ZofmLarObIz2+zAYjA1teDZLeMvEQew3UysnPDXgjneaA==", + "dev": true, + "license": "(MIT AND Apache-2.0)", + "dependencies": { + "@types/json-schema": "^7.0.11" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -5556,6 +6034,40 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6233,6 +6745,29 @@ "dev": true, "license": "MIT" }, + "node_modules/secp256k1": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-5.0.1.tgz", + "integrity": "sha512-lDFs9AAIaWP9UCdtWrotXWWF9t8PWgQDcxqgAnpM9rMqxb3Oaq2J0thzPVSxBwdJgyQtkU/sYtFtbM1RSt/iYA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "elliptic": "^6.5.7", + "node-addon-api": "^5.0.0", + "node-gyp-build": "^4.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/secp256k1/node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "dev": true, + "license": "MIT" + }, "node_modules/secure-json-parse": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", @@ -6678,6 +7213,14 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -7585,6 +8128,26 @@ "node": ">=18" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7647,6 +8210,16 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/package.json b/package.json index ae04d5b..c270d9f 100644 --- a/package.json +++ b/package.json @@ -120,8 +120,11 @@ "@coinbase/x402": "2.1.0", "@modelcontextprotocol/server": "2.3.0", "@sd-jwt/core": "0.20.1", + "@solana/kit": "5.5.1", "@x402/core": "2.25.0", "@x402/evm": "2.25.0", + "@x402/near": "2.25.0", + "@x402/svm": "2.25.0", "canonicalize": "5.0.0", "jose": "6.2.12", "mppx": "0.13.1", @@ -137,12 +140,21 @@ "@sd-jwt/core": { "optional": true }, + "@solana/kit": { + "optional": true + }, "@x402/core": { "optional": true }, "@x402/evm": { "optional": true }, + "@x402/near": { + "optional": true + }, + "@x402/svm": { + "optional": true + }, "canonicalize": { "optional": true }, @@ -163,7 +175,13 @@ "@modelcontextprotocol/client": "2.3.0", "@modelcontextprotocol/sdk": "1.32.0", "@modelcontextprotocol/server": "2.3.0", + "@near-js/accounts": "2.5.1", + "@near-js/crypto": "2.5.1", + "@near-js/providers": "2.5.1", + "@near-js/signers": "2.5.1", + "@near-js/transactions": "2.5.1", "@sd-jwt/core": "0.20.1", + "@solana/kit": "5.5.1", "@types/better-sqlite3": "9.6.0", "@types/node": "24.13.3", "@types/react": "19.2.18", @@ -173,6 +191,8 @@ "@x402/core": "2.25.0", "@x402/evm": "2.25.0", "@x402/mcp": "2.25.0", + "@x402/near": "2.25.0", + "@x402/svm": "2.25.0", "canonicalize": "5.0.0", "jose": "6.2.12", "mppx": "0.13.1", diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts deleted file mode 100644 index 5d374fc..0000000 --- a/src/cli/commands/doctor.ts +++ /dev/null @@ -1,764 +0,0 @@ -import { accessSync, existsSync, constants as fsConstants } from 'node:fs'; -import { dirname } from 'node:path'; -import picocolors from 'picocolors'; -// Constants and a descriptor only: the AP2 provider would pull the optional -// peers `jose`, `@sd-jwt/core` and `canonicalize` into the CLI bundle -import { - AP2_CHECKOUT_MANDATE_VCT, - AP2_CHECKOUT_PROFILE, - AP2_SPEC_VERSION, -} from '../../authorization/ap2/constants'; -import { AP2_UNSUPPORTED } from '../../authorization/ap2/descriptor'; -import { type CommerceResource, isCommerceError, type ReceiptStore } from '../../core'; -import { extractPathParameterNames } from '../../core/execution'; -import { - MPP_PROFILE, - MPP_SPEC_COMMIT, - MPP_SPEC_DRAFTS, - MPPX_VERSION, -} from '../../payments/mpp/constants'; -import type { X402FacilitatorConfig } from '../../payments/x402/guardrails'; -import { - type DeploymentMode, - describeDeploymentMode, - findNetworkProfile, - resolveDeploymentMode, -} from '../../payments/x402/networks'; -// Narrow modules, not the protocol barrels: the CLI must pull in no protocol SDK -import { - A2A_AGENT_CARD_PATH, - A2A_PROTOCOL_BINDING, - A2A_PROTOCOL_VERSION, - A2A_SPEC_VERSION, -} from '../../protocols/a2a/constants'; -import { A2A_UNSUPPORTED } from '../../protocols/a2a/descriptor'; -import { - ACP_API_VERSION, - ACP_SPEC_VERSION, - ACP_WELL_KNOWN_PATH, -} from '../../protocols/acp/constants'; -import { ACP_UNSUPPORTED } from '../../protocols/acp/descriptor'; -import { createSqliteReceiptStore } from '../../storage/receipts'; -import { type ConfigLoader, type GatewayConfig, loadConfigDynamic } from '../lib/config-client'; -import { type FetchLike, fetchJson } from '../lib/http'; -import { PLACEHOLDER_ASSET_ADDRESS } from '../lib/init-config'; -import type { Io } from '../lib/io'; -import { - fillEnvFromLocalChainManifest, - LOCAL_CHAIN_MANIFEST_PATH, - MANIFEST_FILLABLE_ENV_VAR_NAMES, - type ManifestEnvFill, -} from '../lib/manifest-env'; -import { maskMiddle } from '../lib/mask'; -import { readVersionReport } from '../lib/versions'; - -export type CheckStatus = 'PASS' | 'WARN' | 'FAIL' | 'INFO'; - -export interface DoctorCheck { - readonly name: string; - readonly status: CheckStatus; - readonly detail: string; -} - -export interface DoctorReport { - readonly checks: readonly DoctorCheck[]; - readonly score: { readonly passed: number; readonly total: number }; - readonly exitCode: number; -} - -export interface DoctorOptions { - readonly configPath?: string; - readonly gatewayUrl?: string; -} - -export interface DoctorDeps { - readonly fetchImpl?: FetchLike; - readonly loadConfig?: ConfigLoader; - readonly createStore?: (path: string) => ReceiptStore; - readonly fillEnvFromManifest?: (env: NodeJS.ProcessEnv) => ManifestEnvFill; -} - -const CHECK_TIMEOUT_MS = 1500; - -function deriveGatewayUrl(explicit: string | undefined, config: GatewayConfig | undefined): string { - if (explicit !== undefined) return explicit.replace(/\/$/, ''); - if (config !== undefined) { - // `0.0.0.0` and `::` mean all interfaces, so probe loopback. An IPv6 - // literal such as `::1` must be bracketed, or the URL is invalid - // (`http://::1:8080`). - const raw = config.server.host; - const host = raw === '0.0.0.0' ? '127.0.0.1' : raw === '::' ? '::1' : raw; - const authority = host.includes(':') ? `[${host}]` : host; - return `http://${authority}:${config.server.port}`; - } - return 'http://localhost:8080'; -} - -/** - * Fills `{param}` slots with a probe value, using the runtime's own grammar - * (`extractPathParameterNames`). A looser local pattern would fill a token the - * runtime leaves literal, such as `{report id}`, and report the backend - * reachable at a URL no real request uses. Unrecognized tokens stay as they - * are, so the probe hits what the gateway would send. - */ -function substitutePathParams(url: string): string { - let filled = url; - for (const name of extractPathParameterNames(url)) { - filled = filled.replaceAll(`{${name}}`, 'demo-check'); - } - return filled; -} - -// The rail's settlement order and how many resources override it -function describePaymentFlow(rail: { - readonly paymentFlow?: string | undefined; - readonly resourcePaymentFlows?: Readonly> | undefined; -}): string { - const overrides = Object.keys(rail.resourcePaymentFlows ?? {}).length; - const overrideCount = - overrides > 0 ? ` (${overrides} resource ${overrides === 1 ? 'override' : 'overrides'})` : ''; - return `paymentFlow=${rail.paymentFlow ?? 'authorization'}${overrideCount}`; -} - -// Shared settlement fields; `payTo` holds x402 `payTo` or MPP `recipient` -interface LiveX402 { - readonly asset: string; - readonly network: string; - readonly payTo: string; -} - -// Result of reading a live x402 or MPP settlement block -type LiveX402Result = LiveX402 | 'disabled' | undefined; - -/** - * Extracts the effective settlement fields from the well-known document. - * Explicit `enabled: false` is a mismatch; absent, malformed, or incomplete - * data leaves the comparison inconclusive. - */ -function extractWellKnownX402( - body: Record | undefined, - rail: 'x402' | 'mpp' = 'x402', -): LiveX402Result { - const payments = body?.['payments']; - if (typeof payments !== 'object' || payments === null) return undefined; - const block = (payments as Record)[rail]; - if (typeof block !== 'object' || block === null) return undefined; - const rec = block as Record; - if (rec['enabled'] === false) return 'disabled'; - if (rec['enabled'] !== true) return undefined; - const { asset, network } = rec; - const payTo = rec[rail === 'mpp' ? 'recipient' : 'payTo']; - if (typeof asset !== 'string' || typeof network !== 'string' || typeof payTo !== 'string') { - return undefined; - } - return { asset, network, payTo }; -} - -// Checksum-insensitive, as config validation treats addresses -function sameAddress(a: string, b: string): boolean { - return a.toLowerCase() === b.toLowerCase(); -} - -/** - * Compares live settlement fields with local config. A mismatch names both - * values so the operator knows what to fix. - */ -function findX402Mismatch( - configured: LiveX402, - live: LiveX402, - payToField = 'payTo', -): string | undefined { - const diffs: string[] = []; - if (!sameAddress(configured.asset, live.asset)) { - diffs.push( - `asset: gateway is using ${live.asset} but local config resolves to ${configured.asset}`, - ); - } - if (configured.network !== live.network) { - diffs.push( - `network: gateway is using "${live.network}" but local config resolves to "${configured.network}"`, - ); - } - if (!sameAddress(configured.payTo, live.payTo)) { - diffs.push( - `${payToField}: gateway is using ${live.payTo} but local config resolves to ${configured.payTo}`, - ); - } - if (diffs.length === 0) return undefined; - return `${diffs.join('; ')}; the gateway may be running against an older deployment - restart it or re-run chain:deploy`; -} - -/** - * Where a rail settles and through which facilitator. Chain id 84532 is both - * the local dev chain and public Base Sepolia, so the deployment mode (network - * plus facilitator) says which one this is. Local mode names the public - * network only as sharing its chain id, never as the place of settlement. - * `parseConfig` refuses an unknown network, so an absent profile means another - * loader produced this config, and the text says so rather than guessing. - */ -function describeSettlement( - network: string, - facilitator: X402FacilitatorConfig, -): { readonly mode: DeploymentMode | undefined; readonly where: string; readonly via: string } { - const profile = findNetworkProfile(network); - const mode = profile ? resolveDeploymentMode(profile, facilitator.mode) : undefined; - const where = - profile === undefined || mode === undefined - ? `unknown network ${network}` - : mode === 'local' - ? `LOCAL dev chain (${network}, chain id shared with ${profile.displayName})` - : `${describeDeploymentMode(mode)} on ${profile.displayName} (${network})`; - const via = - facilitator.mode === 'local' ? 'local (in-process)' : `remote (auth=${facilitator.auth.type})`; - return { mode, where, via }; -} - -// Reports rather than re-checks: config load already enforced every guardrail -// (src/payments/x402/guardrails.ts, shared by x402 and MPP). An operator about -// to move real money sees which guarantees they rely on, including an -// unauthenticated facilitator the config accepted through -// `allowUnauthenticatedFacilitator`. -function mainnetSafetyCheck( - name: string, - payToField: 'payTo' | 'recipient', - facilitator: X402FacilitatorConfig, -): DoctorCheck { - const unauthenticated = facilitator.mode === 'remote' && facilitator.auth.type === 'none'; - return { - name, - status: unauthenticated ? 'WARN' : 'INFO', - detail: unauthenticated - ? `${describeDeploymentMode('mainnet')}: no credential is sent to the remote facilitator ` + - '(accepted via allowUnauthenticatedFacilitator); its anonymous-access ' + - 'limits apply. Config load also requires ' + - `allowMainnet, HTTPS, a non-development ${payToField} and the canonical asset. ` + - 'Payments fail closed.' - : `${describeDeploymentMode('mainnet')}. Enforced at config load: explicit allowMainnet ` + - `opt-in, remote facilitator over HTTPS with a credential, non-development ${payToField}, ` + - 'and the canonical asset for this network. Payments fail closed.', - }; -} - -// Issuer ids and how many keys each carries. Never a key -function describeIssuers( - label: string, - issuers: readonly { readonly issuer: string; readonly keys: readonly unknown[] }[], -): string { - const described = issuers - .map( - (entry) => `${entry.issuer} (${entry.keys.length} key${entry.keys.length === 1 ? '' : 's'})`, - ) - .join(', '); - return `${label} issuers: ${described}`; -} - -// Whether an existing file is writable, without creating one -function isWritableFile(path: string): boolean { - try { - accessSync(path, fsConstants.W_OK); - return true; - } catch { - return false; - } -} - -// A SQLite store file, diagnosed without creating it (see the Storage check in -// `runDoctor`). `suffix` is appended to every detail. -function storeFileCheck( - name: string, - path: string, - risk: { readonly inMemory: string; readonly unwritable: string }, - suffix = '', -): DoctorCheck { - if (path === ':memory:') { - return { - name, - status: 'WARN', - detail: `in-memory store: ${risk.inMemory}; use a file path in production${suffix}`, - }; - } - if (existsSync(path)) { - return isWritableFile(path) - ? { name, status: 'PASS', detail: `writable at "${path}"${suffix}` } - : { - name, - status: 'FAIL', - detail: `"${path}" exists but is not writable by this user, so ${risk.unwritable}${suffix}`, - }; - } - return { name, status: 'WARN', detail: `${missingStoreDetail(path)}${suffix}` }; -} - -// Every SQLite store opens through `openSqliteDatabase`, which creates the file -// and a missing directory, so one wording covers them all -function missingStoreDetail(path: string): string { - const directory = dirname(path); - return directory !== '.' && !existsSync(directory) - ? `no store at "${path}" and its directory does not exist. Both are created when the gateway starts, provided the path is writable` - : `no store at "${path}" yet. It is created the first time the gateway starts`; -} - -/** - * The resource mapped to each checkout operation, re-checked against the rules - * config enforces: present, acp-exposed and free to invoke. Resource ids are - * printed because an operator needs them to fix a mapping; the mapping is - * config, not a secret. - */ -function acpMappingCheck( - config: GatewayConfig, - operations: Readonly>, -): DoctorCheck { - const byId = new Map(config.resources.map((resource) => [resource.id, resource])); - const problems: string[] = []; - - for (const [operation, resourceId] of Object.entries(operations)) { - const resource = byId.get(resourceId); - if (resource === undefined) { - problems.push(`${operation} -> "${resourceId}" does not exist`); - continue; - } - if (!resource.exposedVia.includes('acp')) { - problems.push(`${operation} -> "${resourceId}" is not exposed via acp`); - } - // ACP checkout carries the merchant's own purchase payment; charging for - // the invocation as well would put two payment layers on one call - if (resource.pricing.type !== 'free' || resource.paymentMethods.length > 0) { - problems.push(`${operation} -> "${resourceId}" is not free to invoke`); - } - } - - if (problems.length > 0) { - return { name: 'ACP checkout mapping', status: 'FAIL', detail: problems.join('; ') }; - } - return { - name: 'ACP checkout mapping', - status: 'PASS', - detail: `5 operations mapped to free acp-exposed resources (${Object.values(operations).join(', ')})`, - }; -} - -/** `agent-commerce doctor [--config] [--gateway] [--json]` */ -export async function runDoctor( - options: DoctorOptions, - deps: DoctorDeps = {}, -): Promise { - const fetchImpl = deps.fetchImpl ?? globalThis.fetch; - const loadConfig = deps.loadConfig ?? loadConfigDynamic; - const createStore = deps.createStore ?? ((path: string) => createSqliteReceiptStore({ path })); - const fillEnvFromManifest = deps.fillEnvFromManifest ?? fillEnvFromLocalChainManifest; - - const checks: DoctorCheck[] = []; - let config: GatewayConfig | undefined; - - // 1. Config - const { env, filled, manifestFound } = fillEnvFromManifest(process.env); - const filledSuffix = - filled.length > 0 - ? ` (using local chain manifest ${LOCAL_CHAIN_MANIFEST_PATH} for ${filled.join(', ')})` - : ''; - try { - config = await loadConfig({ - ...(options.configPath !== undefined ? { path: options.configPath } : {}), - env, - }); - checks.push({ - name: 'Config', - status: 'PASS', - detail: `valid: ${config.resources.length} resource(s), merchant "${config.merchant.name}"${filledSuffix}`, - }); - } catch (err) { - const variable = isCommerceError(err) ? err.details?.['variable'] : undefined; - const hint = - !manifestFound && - typeof variable === 'string' && - MANIFEST_FILLABLE_ENV_VAR_NAMES.has(variable) - ? `. Run "npm run chain:deploy": it writes ${LOCAL_CHAIN_MANIFEST_PATH}, which fills local X402_*/MERCHANT_WALLET placeholders automatically` - : ''; - checks.push({ - name: 'Config', - status: 'FAIL', - detail: `${err instanceof Error ? err.message : String(err)}${hint}`, - }); - } - - const gatewayUrl = deriveGatewayUrl(options.gatewayUrl, config); - - // 2. Gateway - const health = await fetchJson(fetchImpl, `${gatewayUrl}/health`, CHECK_TIMEOUT_MS); - let gatewayUp = false; - if (!health.ok) { - checks.push({ - name: 'Gateway', - status: 'FAIL', - detail: `unreachable at ${gatewayUrl} (${health.error ?? `HTTP ${health.status}`})`, - }); - } else { - gatewayUp = true; - const ready = await fetchJson(fetchImpl, `${gatewayUrl}/ready`, CHECK_TIMEOUT_MS); - checks.push( - ready.ok - ? { name: 'Gateway', status: 'PASS', detail: `healthy and ready at ${gatewayUrl}` } - : { - name: 'Gateway', - status: 'WARN', - detail: `healthy but not ready at ${gatewayUrl} (${ready.error ?? `HTTP ${ready.status}`})`, - }, - ); - } - - // 3. Backend(s) - if (config === undefined) { - checks.push({ name: 'Backend', status: 'WARN', detail: 'skipped: config invalid' }); - } else if (config.resources.length === 0) { - checks.push({ name: 'Backend', status: 'INFO', detail: 'no resources configured' }); - } else { - const resources = config.resources as readonly CommerceResource[]; - const urls: string[] = [...new Set(resources.map((r) => substitutePathParams(r.handler.url)))]; - const results = await Promise.all( - urls.map((url) => fetchJson(fetchImpl, url, CHECK_TIMEOUT_MS)), - ); - const reachable = results.filter((r) => r.status !== 0).length; - const status: CheckStatus = - reachable === urls.length ? 'PASS' : reachable === 0 ? 'FAIL' : 'WARN'; - checks.push({ - name: 'Backend', - status, - detail: `${reachable}/${urls.length} backend host(s) reachable`, - }); - } - - // 4. Well-known document (feeds Protocols + Protocol versions) - const wellKnown = gatewayUp - ? await fetchJson>( - fetchImpl, - `${gatewayUrl}/.well-known/agent-commerce`, - CHECK_TIMEOUT_MS, - ) - : undefined; - - // 5. Protocols - if (config === undefined) { - checks.push({ name: 'Protocols', status: 'WARN', detail: 'skipped: config invalid' }); - } else if (wellKnown === undefined || !wellKnown.ok) { - checks.push({ name: 'Protocols', status: 'FAIL', detail: 'well-known document unreachable' }); - } else { - const mcpMountPath = config.protocols.mcp.enabled ? config.protocols.mcp.mountPath : undefined; - const a2aMountPath = config.protocols.a2a.enabled ? config.protocols.a2a.mountPath : undefined; - checks.push({ - name: 'Protocols', - status: 'PASS', - detail: `http=${config.protocols.http.enabled ? 'on' : 'off'} mcp=${config.protocols.mcp.enabled ? `on (${mcpMountPath})` : 'off'} a2a=${config.protocols.a2a.enabled ? `on (${a2aMountPath})` : 'off'} acp=${config.protocols.acp.enabled ? `on (${config.protocols.acp.mountPath})` : 'off'}`, - }); - } - - // 5b. A2A specifics, from the pins rather than the live gateway. The spec - // revision and the negotiation version look alike, so each is named. - if (config === undefined) { - checks.push({ name: 'A2A', status: 'WARN', detail: 'skipped: config invalid' }); - } else if (!config.protocols.a2a.enabled) { - checks.push({ name: 'A2A', status: 'INFO', detail: 'disabled' }); - } else { - checks.push({ - name: 'A2A', - status: 'PASS', - detail: `experimental · spec ${A2A_SPEC_VERSION} · protocol ${A2A_PROTOCOL_VERSION} · binding ${A2A_PROTOCOL_BINDING} · mount ${config.protocols.a2a.mountPath} · card ${A2A_AGENT_CARD_PATH}`, - }); - // Listed in full, as are ACP's and AP2's below: a count does not tell an - // operator whether the one feature their client needs is missing - checks.push({ - name: 'A2A unsupported', - status: 'INFO', - detail: A2A_UNSUPPORTED.join(', '), - }); - } - - // 5c. ACP specifics, from config and the pins: the well-known document omits - // the bearer token, the idempotency path and the operation mapping. This - // report prints the path and the mapping, never the token. - if (config === undefined) { - checks.push({ name: 'ACP', status: 'WARN', detail: 'skipped: config invalid' }); - } else if (!config.protocols.acp.enabled) { - checks.push({ name: 'ACP', status: 'INFO', detail: 'disabled' }); - } else { - const acp = config.protocols.acp; - checks.push({ - name: 'ACP', - status: 'PASS', - detail: `experimental · spec ${ACP_SPEC_VERSION} · API-Version ${ACP_API_VERSION} · service checkout · mount ${acp.mountPath} · discovery ${ACP_WELL_KNOWN_PATH}`, - }); - - // Whether a token is configured and its length, never the token. Config - // refuses an empty one. - checks.push({ - name: 'ACP auth', - status: 'PASS', - detail: `bearer token configured (${acp.auth.token.length} characters, not shown)`, - }); - - checks.push( - storeFileCheck( - 'ACP idempotency', - acp.idempotency.path, - { - inMemory: 'replay protection is lost on every restart', - unwritable: 'the adapter cannot claim idempotency keys', - }, - ` (retention ${acp.idempotency.retentionHours}h)`, - ), - ); - checks.push(acpMappingCheck(config, acp.checkout.operations)); - - checks.push({ - name: 'ACP unsupported', - status: 'INFO', - detail: ACP_UNSUPPORTED.join(', '), - }); - } - - // 5d. AP2 authorization, from config and the pins. Verification keys are - // public but still trust policy nobody asked this report to print, so only - // issuer ids and key counts appear. - const ap2 = config?.authorization?.ap2; - if (config === undefined) { - checks.push({ name: 'AP2', status: 'WARN', detail: 'skipped: config invalid' }); - } else if (ap2 === undefined || !ap2.enabled) { - checks.push({ name: 'AP2', status: 'INFO', detail: 'disabled' }); - } else { - checks.push({ - name: 'AP2', - status: 'PASS', - detail: `experimental · spec ${AP2_SPEC_VERSION} · mode ${ap2.mode} · ${AP2_CHECKOUT_MANDATE_VCT} · profile ${AP2_CHECKOUT_PROFILE} · clock skew ${ap2.clockSkewSeconds}s · mandate aud ${ap2.requireMandateAudience === true ? 'required' : 'optional'} · mandate exp ${ap2.requireMandateExpiry === true ? 'required' : 'optional'}`, - }); - - // Two lists, reported separately: signing the merchant's checkout - // documents must not read as the power to issue mandates - checks.push({ - name: 'AP2 trust', - status: 'PASS', - detail: `${describeIssuers('mandate', ap2.trust.mandateIssuers)} · ${describeIssuers('checkout', ap2.trust.checkoutIssuers)}`, - }); - - checks.push( - storeFileCheck('AP2 replay store', ap2.replay.path, { - inMemory: - 'every spent mandate is forgotten on restart, so one could authorize a second purchase', - unwritable: 'no mandate could be recorded as spent', - }), - ); - - // Resource ids, so an operator can see exactly which purchases now need a - // mandate. An empty list means AP2 is configured and gating nothing. - const gated = config.resources.filter((resource) => - resource.authorization?.required.includes('ap2'), - ); - checks.push({ - name: 'AP2 resources', - status: gated.length > 0 ? 'PASS' : 'WARN', - detail: - gated.length > 0 - ? gated.map((resource) => resource.id).join(', ') - : 'AP2 is enabled but no resource requires it, so every purchase settles without a mandate', - }); - - checks.push({ - name: 'AP2 unsupported', - status: 'INFO', - detail: AP2_UNSUPPORTED.join(', '), - }); - } - - // 6. Payments - const x402 = config?.payments.x402; - if (x402 === undefined || !x402.enabled) { - checks.push({ name: 'Payments', status: 'INFO', detail: 'x402 not configured' }); - } else { - const { mode, where, via } = describeSettlement(x402.network, x402.facilitator); - const flow = describePaymentFlow(x402); - const summary = `x402 v2 (scheme=exact) enabled - ${where}, destination=${maskMiddle(x402.payTo)}, facilitator=${via}, ${flow}`; - const live = wellKnown?.ok ? extractWellKnownX402(wellKnown.body) : undefined; - if (sameAddress(x402.asset, PLACEHOLDER_ASSET_ADDRESS)) { - // `init` writes this placeholder, and the live cross-check below would - // pass it, since the gateway echoes the same config. No token contract - // exists there, so every paid call fails (closed, with no funds at - // risk). - checks.push({ - name: 'Payments', - status: 'WARN', - detail: `${summary}, but the asset is still the init placeholder ${maskMiddle(PLACEHOLDER_ASSET_ADDRESS)}. No token contract exists there, so every paid call will fail. Set payments.x402.asset (\${X402_ASSET} is filled from .deploy/local.json, which "npm run chain:deploy" writes)`, - }); - } else if (live === 'disabled') { - // Verified, and it disagrees: the gateway runs a different configuration - checks.push({ - name: 'Payments', - status: 'FAIL', - detail: `${summary}, but the gateway at ${gatewayUrl} reports x402 disabled, so it is running a different configuration`, - }); - } else if (live === undefined) { - // Gateway unreachable, or the document does not say: a match cannot be - // judged either way, so INFO rather than FAIL - checks.push({ - name: 'Payments', - status: 'INFO', - detail: `${summary} (gateway's live settlement config could not be verified)`, - }); - } else { - const mismatch = findX402Mismatch(x402, live); - checks.push( - mismatch === undefined - ? { name: 'Payments', status: 'PASS', detail: summary } - : { name: 'Payments', status: 'FAIL', detail: mismatch }, - ); - } - if (mode === 'mainnet') { - checks.push(mainnetSafetyCheck('Mainnet safety', 'payTo', x402.facilitator)); - } - } - const mpp = config?.payments.mpp; - if (mpp === undefined || !mpp.enabled) { - checks.push({ name: 'Payments (MPP)', status: 'INFO', detail: 'MPP not configured' }); - } else { - const { mode, where, via } = describeSettlement(mpp.network, mpp.facilitator); - const summary = - `MPP ${MPP_PROFILE.intent}/${MPP_PROFILE.method}/${MPP_PROFILE.credentialType} enabled - ` + - `${where}, asset=${MPP_PROFILE.assetSymbol} ${maskMiddle(mpp.asset)}, ` + - `recipient=${maskMiddle(mpp.recipient)}, facilitator=${via}, ` + - `${describePaymentFlow(mpp)}, ` + - `spec ${MPP_SPEC_DRAFTS.core}@${MPP_SPEC_COMMIT.slice(0, 7)}, mppx ${MPPX_VERSION}`; - const configured = { asset: mpp.asset, network: mpp.network, payTo: mpp.recipient }; - const live = wellKnown?.ok ? extractWellKnownX402(wellKnown.body, 'mpp') : undefined; - if (live === 'disabled') { - checks.push({ - name: 'Payments (MPP)', - status: 'FAIL', - detail: `Local config enables MPP, but ${gatewayUrl} reports it disabled`, - }); - } else if (live === undefined) { - checks.push({ - name: 'Payments (MPP)', - status: 'INFO', - detail: `${summary} (live MPP settlement config unavailable for comparison)`, - }); - } else { - const mismatch = findX402Mismatch(configured, live, 'recipient'); - checks.push( - mismatch === undefined - ? { name: 'Payments (MPP)', status: 'PASS', detail: summary } - : { name: 'Payments (MPP)', status: 'FAIL', detail: mismatch }, - ); - } - if (mode === 'mainnet') { - checks.push(mainnetSafetyCheck('Mainnet safety (MPP)', 'recipient', mpp.facilitator)); - } - } - - // 7. Storage - if (config === undefined) { - checks.push({ name: 'Storage', status: 'WARN', detail: 'skipped: config invalid' }); - } else if ( - // Diagnose without creating: createSqliteReceiptStore creates the file, - // so opening a wrong path (a typo, or a container path read from the - // host) would leave an empty database and report it healthy, hiding the - // real one. `:memory:` never exists on disk, so it takes the open branch - // below. - config.storage.receipts.path !== ':memory:' && - !existsSync(config.storage.receipts.path) - ) { - checks.push({ - name: 'Storage', - status: 'WARN', - detail: - `${missingStoreDetail(config.storage.receipts.path)}. If the gateway is ` + - 'already running, this path does not match the one it uses.', - }); - } else { - let store: ReceiptStore | undefined; - try { - store = createStore(config.storage.receipts.path); - await store.init(); - const storeHealth = await store.health(); - let receiptCount: number | undefined; - try { - // Exact count, not a list length: listReceipts clamps every page - receiptCount = await store.countReceipts(); - } catch { - receiptCount = undefined; - } - let undeliveredCount: number | undefined; - try { - // Paid but undelivered purchases (see - // `ReceiptStore.countUndeliveredReceipts`), the rows the dashboard - // flags as "Charged but not delivered" - undeliveredCount = await store.countUndeliveredReceipts(); - } catch { - undeliveredCount = undefined; - } - const status: CheckStatus = - storeHealth.status === 'pass' ? 'PASS' : storeHealth.status === 'warn' ? 'WARN' : 'FAIL'; - const receiptsSuffix = - receiptCount !== undefined - ? `; receipts=${receiptCount}${undeliveredCount !== undefined && undeliveredCount > 0 ? ` (${undeliveredCount} undelivered)` : ''}` - : ''; - checks.push({ - name: 'Storage', - status, - detail: `${storeHealth.detail ?? 'sqlite'}${receiptsSuffix}`, - }); - } catch (err) { - checks.push({ - name: 'Storage', - status: 'FAIL', - detail: err instanceof Error ? err.message : String(err), - }); - } finally { - await store?.close(); - } - } - - // 8. Protocol versions - if (wellKnown?.ok) { - checks.push({ - name: 'Protocol versions', - status: 'PASS', - detail: 'reported by gateway /.well-known/agent-commerce', - }); - } else { - const local = readVersionReport(); - checks.push({ - name: 'Protocol versions', - status: 'INFO', - detail: `gateway unreachable; local pins: ${local.pinned.map((p) => `${p.name}@${p.version}`).join(', ')}`, - }); - } - - const scored = checks.filter((c) => c.status !== 'INFO'); - const passed = scored.filter((c) => c.status === 'PASS').length; - const exitCode = checks.some((c) => c.status === 'FAIL') ? 1 : 0; - - return { checks, score: { passed, total: scored.length }, exitCode }; -} - -function statusColor(status: CheckStatus, text: string): string { - switch (status) { - case 'PASS': - return picocolors.green(text); - case 'WARN': - return picocolors.yellow(text); - case 'FAIL': - return picocolors.red(text); - case 'INFO': - return picocolors.cyan(text); - } -} - -export function printDoctorReport(report: DoctorReport, io: Io, json: boolean): void { - if (json) { - io.stdout(JSON.stringify(report, null, 2)); - return; - } - for (const check of report.checks) { - const label = statusColor(check.status, check.status.padEnd(5)); - io.stdout(`${label} ${check.name.padEnd(20)} ${check.detail}`); - } - io.stdout(''); - io.stdout(`Score: ${report.score.passed}/${report.score.total} checks passed`); -} diff --git a/src/cli/commands/doctor/ap2.ts b/src/cli/commands/doctor/ap2.ts new file mode 100644 index 0000000..57b4beb --- /dev/null +++ b/src/cli/commands/doctor/ap2.ts @@ -0,0 +1,82 @@ +// AP2 checks: settings, trust lists, the replay store and the gated resources + +// Constants and a descriptor only: the AP2 provider would pull the optional +// peers `jose`, `@sd-jwt/core` and `canonicalize` into the CLI bundle +import { + AP2_CHECKOUT_MANDATE_VCT, + AP2_CHECKOUT_PROFILE, + AP2_SPEC_VERSION, +} from '../../../authorization/ap2/constants'; +import { AP2_UNSUPPORTED } from '../../../authorization/ap2/descriptor'; +import type { GatewayConfig } from '../../lib/config-client'; +import { storeFileCheck } from './storage'; +import type { DoctorCheck } from './types'; + +// Issuer ids and how many keys each carries. Never a key +function describeIssuers( + label: string, + issuers: readonly { readonly issuer: string; readonly keys: readonly unknown[] }[], +): string { + const described = issuers + .map( + (entry) => `${entry.issuer} (${entry.keys.length} key${entry.keys.length === 1 ? '' : 's'})`, + ) + .join(', '); + return `${label} issuers: ${described}`; +} + +export function ap2Checks(config: GatewayConfig | undefined): DoctorCheck[] { + const checks: DoctorCheck[] = []; + // 5d. AP2 authorization, from config and the pins. Verification keys are + // public but still trust policy nobody asked this report to print, so only + // issuer ids and key counts appear. + const ap2 = config?.authorization?.ap2; + if (config === undefined) { + checks.push({ name: 'AP2', status: 'WARN', detail: 'skipped: config invalid' }); + } else if (ap2 === undefined || !ap2.enabled) { + checks.push({ name: 'AP2', status: 'INFO', detail: 'disabled' }); + } else { + checks.push({ + name: 'AP2', + status: 'PASS', + detail: `experimental · spec ${AP2_SPEC_VERSION} · mode ${ap2.mode} · ${AP2_CHECKOUT_MANDATE_VCT} · profile ${AP2_CHECKOUT_PROFILE} · clock skew ${ap2.clockSkewSeconds}s · mandate aud ${ap2.requireMandateAudience === true ? 'required' : 'optional'} · mandate exp ${ap2.requireMandateExpiry === true ? 'required' : 'optional'}`, + }); + + // Two lists, reported separately: signing the merchant's checkout + // documents must not read as the power to issue mandates + checks.push({ + name: 'AP2 trust', + status: 'PASS', + detail: `${describeIssuers('mandate', ap2.trust.mandateIssuers)} · ${describeIssuers('checkout', ap2.trust.checkoutIssuers)}`, + }); + + checks.push( + storeFileCheck('AP2 replay store', ap2.replay.path, { + inMemory: + 'every spent mandate is forgotten on restart, so one could authorize a second purchase', + unwritable: 'no mandate could be recorded as spent', + }), + ); + + // Resource ids, so an operator can see exactly which purchases now need a + // mandate. An empty list means AP2 is configured and gating nothing. + const gated = config.resources.filter((resource) => + resource.authorization?.required.includes('ap2'), + ); + checks.push({ + name: 'AP2 resources', + status: gated.length > 0 ? 'PASS' : 'WARN', + detail: + gated.length > 0 + ? gated.map((resource) => resource.id).join(', ') + : 'AP2 is enabled but no resource requires it, so every purchase settles without a mandate', + }); + + checks.push({ + name: 'AP2 unsupported', + status: 'INFO', + detail: AP2_UNSUPPORTED.join(', '), + }); + } + return checks; +} diff --git a/src/cli/commands/doctor/index.ts b/src/cli/commands/doctor/index.ts new file mode 100644 index 0000000..35449ab --- /dev/null +++ b/src/cli/commands/doctor/index.ts @@ -0,0 +1,179 @@ +// `agent-commerce doctor`: config, the running gateway, protocols, payments and storage +import { type CommerceResource, isCommerceError } from '../../../core'; +import { extractPathParameterNames } from '../../../core/execution'; +import { createSqliteReceiptStore } from '../../../storage/receipts'; +import { type GatewayConfig, loadConfigDynamic } from '../../lib/config-client'; +import { fetchJson } from '../../lib/http'; +import { + fillEnvFromLocalChainManifest, + LOCAL_CHAIN_MANIFEST_PATH, + MANIFEST_FILLABLE_ENV_VAR_NAMES, +} from '../../lib/manifest-env'; +import { readVersionReport } from '../../lib/versions'; +import { ap2Checks } from './ap2'; +import { paymentChecks } from './payments'; +import { protocolChecks } from './protocols'; +import { storageChecks } from './storage'; +import type { CheckStatus, DoctorCheck, DoctorDeps, DoctorOptions, DoctorReport } from './types'; + +const CHECK_TIMEOUT_MS = 1500; + +function deriveGatewayUrl(explicit: string | undefined, config: GatewayConfig | undefined): string { + if (explicit !== undefined) return explicit.replace(/\/$/, ''); + if (config !== undefined) { + // `0.0.0.0` and `::` mean all interfaces, so probe loopback. An IPv6 + // literal such as `::1` must be bracketed, or the URL is invalid + // (`http://::1:8080`). + const raw = config.server.host; + const host = raw === '0.0.0.0' ? '127.0.0.1' : raw === '::' ? '::1' : raw; + const authority = host.includes(':') ? `[${host}]` : host; + return `http://${authority}:${config.server.port}`; + } + return 'http://localhost:8080'; +} + +/** + * Fills `{param}` slots with a probe value, using the runtime's own grammar + * (`extractPathParameterNames`). A looser local pattern would fill a token the + * runtime leaves literal, such as `{report id}`, and report the backend + * reachable at a URL no real request uses. Unrecognized tokens stay as they + * are, so the probe hits what the gateway would send. + */ +function substitutePathParams(url: string): string { + let filled = url; + for (const name of extractPathParameterNames(url)) { + filled = filled.replaceAll(`{${name}}`, 'demo-check'); + } + return filled; +} + +/** `agent-commerce doctor [--config] [--gateway] [--json]` */ +export async function runDoctor( + options: DoctorOptions, + deps: DoctorDeps = {}, +): Promise { + const fetchImpl = deps.fetchImpl ?? globalThis.fetch; + const loadConfig = deps.loadConfig ?? loadConfigDynamic; + const createStore = deps.createStore ?? ((path: string) => createSqliteReceiptStore({ path })); + const fillEnvFromManifest = deps.fillEnvFromManifest ?? fillEnvFromLocalChainManifest; + + const checks: DoctorCheck[] = []; + let config: GatewayConfig | undefined; + + // 1. Config + const { env, filled, manifestFound } = fillEnvFromManifest(process.env); + const filledSuffix = + filled.length > 0 + ? ` (using local chain manifest ${LOCAL_CHAIN_MANIFEST_PATH} for ${filled.join(', ')})` + : ''; + try { + config = await loadConfig({ + ...(options.configPath !== undefined ? { path: options.configPath } : {}), + env, + }); + checks.push({ + name: 'Config', + status: 'PASS', + detail: `valid: ${config.resources.length} resource(s), merchant "${config.merchant.name}"${filledSuffix}`, + }); + } catch (err) { + const variable = isCommerceError(err) ? err.details?.['variable'] : undefined; + const hint = + !manifestFound && + typeof variable === 'string' && + MANIFEST_FILLABLE_ENV_VAR_NAMES.has(variable) + ? `. Run "npm run chain:deploy": it writes ${LOCAL_CHAIN_MANIFEST_PATH}, which fills local X402_*/MERCHANT_WALLET placeholders automatically` + : ''; + checks.push({ + name: 'Config', + status: 'FAIL', + detail: `${err instanceof Error ? err.message : String(err)}${hint}`, + }); + } + + const gatewayUrl = deriveGatewayUrl(options.gatewayUrl, config); + + // 2. Gateway + const health = await fetchJson(fetchImpl, `${gatewayUrl}/health`, CHECK_TIMEOUT_MS); + let gatewayUp = false; + if (!health.ok) { + checks.push({ + name: 'Gateway', + status: 'FAIL', + detail: `unreachable at ${gatewayUrl} (${health.error ?? `HTTP ${health.status}`})`, + }); + } else { + gatewayUp = true; + const ready = await fetchJson(fetchImpl, `${gatewayUrl}/ready`, CHECK_TIMEOUT_MS); + checks.push( + ready.ok + ? { name: 'Gateway', status: 'PASS', detail: `healthy and ready at ${gatewayUrl}` } + : { + name: 'Gateway', + status: 'WARN', + detail: `healthy but not ready at ${gatewayUrl} (${ready.error ?? `HTTP ${ready.status}`})`, + }, + ); + } + + // 3. Backend(s) + if (config === undefined) { + checks.push({ name: 'Backend', status: 'WARN', detail: 'skipped: config invalid' }); + } else if (config.resources.length === 0) { + checks.push({ name: 'Backend', status: 'INFO', detail: 'no resources configured' }); + } else { + const resources = config.resources as readonly CommerceResource[]; + const urls: string[] = [...new Set(resources.map((r) => substitutePathParams(r.handler.url)))]; + const results = await Promise.all( + urls.map((url) => fetchJson(fetchImpl, url, CHECK_TIMEOUT_MS)), + ); + const reachable = results.filter((r) => r.status !== 0).length; + const status: CheckStatus = + reachable === urls.length ? 'PASS' : reachable === 0 ? 'FAIL' : 'WARN'; + checks.push({ + name: 'Backend', + status, + detail: `${reachable}/${urls.length} backend host(s) reachable`, + }); + } + + // 4. Well-known document (feeds Protocols + Protocol versions) + const wellKnown = gatewayUp + ? await fetchJson>( + fetchImpl, + `${gatewayUrl}/.well-known/agent-commerce`, + CHECK_TIMEOUT_MS, + ) + : undefined; + + // 5-7. Protocols, AP2, payments and storage + checks.push(...protocolChecks(config, wellKnown)); + checks.push(...ap2Checks(config)); + checks.push(...paymentChecks(config, wellKnown, gatewayUrl)); + checks.push(...(await storageChecks(config, createStore))); + + // 8. Protocol versions + if (wellKnown?.ok) { + checks.push({ + name: 'Protocol versions', + status: 'PASS', + detail: 'reported by gateway /.well-known/agent-commerce', + }); + } else { + const local = readVersionReport(); + checks.push({ + name: 'Protocol versions', + status: 'INFO', + detail: `gateway unreachable; local pins: ${local.pinned.map((p) => `${p.name}@${p.version}`).join(', ')}`, + }); + } + + const scored = checks.filter((c) => c.status !== 'INFO'); + const passed = scored.filter((c) => c.status === 'PASS').length; + const exitCode = checks.some((c) => c.status === 'FAIL') ? 1 : 0; + + return { checks, score: { passed, total: scored.length }, exitCode }; +} + +export { printDoctorReport } from './report'; +export type { CheckStatus, DoctorCheck, DoctorDeps, DoctorOptions, DoctorReport } from './types'; diff --git a/src/cli/commands/doctor/payments.ts b/src/cli/commands/doctor/payments.ts new file mode 100644 index 0000000..6194269 --- /dev/null +++ b/src/cli/commands/doctor/payments.ts @@ -0,0 +1,471 @@ +// Payment checks: where each rail settles, and whether the running gateway agrees + +import { isRecord } from '../../../core/is-record'; +import { + MPP_DEFAULT_PAYMENT_FLOW, + MPP_PROFILE, + MPP_SPEC_COMMIT, + MPP_SPEC_DRAFTS, + MPPX_VERSION, +} from '../../../payments/mpp/constants'; +import { sameAddress } from '../../../payments/x402/addresses'; +import type { X402FacilitatorConfig } from '../../../payments/x402/guardrails'; +import { + type CustomNetworks, + type DeploymentMode, + describeDeploymentMode, + findX402Network, + resolveDeploymentMode, +} from '../../../payments/x402/networks'; +import { X402_DEFAULT_PAYMENT_FLOW } from '../../../payments/x402/payment-flow'; +import type { GatewayConfig } from '../../lib/config-client'; +import { PLACEHOLDER_ASSET_ADDRESS } from '../../lib/init-config'; +import { maskMiddle } from '../../lib/mask'; +import type { DoctorCheck, WellKnownResult } from './types'; + +// The rail's settlement order and how many resources override it +function describePaymentFlow( + rail: { + readonly paymentFlow?: string | undefined; + readonly resourcePaymentFlows?: Readonly> | undefined; + }, + defaultFlow: string, +): string { + const overrides = Object.keys(rail.resourcePaymentFlows ?? {}).length; + const overrideCount = + overrides > 0 ? ` (${overrides} resource ${overrides === 1 ? 'override' : 'overrides'})` : ''; + return `paymentFlow=${rail.paymentFlow ?? defaultFlow}${overrideCount}`; +} + +// One offer's public settlement terms; `payTo` holds x402 `payTo` or MPP `recipient` +interface RailOffer { + readonly network: string; + readonly asset: string; + readonly payTo: string; + readonly assetDecimals?: number; + /** Absent for EIP-3009 and for every non-EVM offer */ + readonly assetTransferMethod?: string; + /** The live offer's health status, when the gateway publishes it */ + readonly status?: string; +} + +// A rail's terms, as configured or as the running gateway publishes them +interface RailTerms { + readonly offers: readonly RailOffer[]; + /** Resources with their own offers, by id */ + readonly resourceOffers?: Readonly>; + /** Absent from a gateway that does not publish its flows */ + readonly paymentFlow?: string; + readonly resourcePaymentFlows?: Readonly>; +} + +// Result of reading a live x402 or MPP block +type LiveRailResult = RailTerms | 'disabled' | undefined; + +function readOffer(rec: Record, rail: 'x402' | 'mpp'): RailOffer | undefined { + const { asset, network, assetDecimals, assetTransferMethod, status } = rec; + const payTo = rec[rail === 'mpp' ? 'recipient' : 'payTo']; + if (typeof asset !== 'string' || typeof network !== 'string' || typeof payTo !== 'string') { + return undefined; + } + return { + network, + asset, + payTo, + ...(typeof assetDecimals === 'number' ? { assetDecimals } : {}), + ...(typeof assetTransferMethod === 'string' ? { assetTransferMethod } : {}), + ...(typeof status === 'string' ? { status } : {}), + }; +} + +// A list of offers, or undefined when any entry is malformed +function readOffers(value: unknown, rail: 'x402' | 'mpp'): RailOffer[] | undefined { + if (!Array.isArray(value)) return undefined; + const read = value.map((entry) => (isRecord(entry) ? readOffer(entry, rail) : undefined)); + return read.some((offer) => offer === undefined) ? undefined : (read as RailOffer[]); +} + +/** + * Reads offers and flows from discovery. Explicit `enabled: false` signals + * a mismatch; missing or invalid offer data leaves comparison inconclusive. + */ +function extractLiveRail( + body: Record | undefined, + rail: 'x402' | 'mpp' = 'x402', +): LiveRailResult { + const block = isRecord(body?.['payments']) ? body['payments'][rail] : undefined; + if (!isRecord(block)) return undefined; + if (block['enabled'] === false) return 'disabled'; + if (block['enabled'] !== true) return undefined; + const first = readOffer(block, rail); + if (first === undefined) return undefined; + // The block's own fields repeat the first offer; `options` lists them all + let offers: readonly RailOffer[] = [first]; + if (block['options'] !== undefined) { + const listed = readOffers(block['options'], rail); + if (listed === undefined) return undefined; + offers = listed; + } + let resourceOffers: Record | undefined; + const ownLists = block['resourceOptions']; + if (ownLists !== undefined) { + if (!isRecord(ownLists)) return undefined; + resourceOffers = {}; + for (const [id, list] of Object.entries(ownLists)) { + const read = readOffers(list, rail); + if (read === undefined) return undefined; + resourceOffers[id] = read; + } + } + const { paymentFlow, resourcePaymentFlows } = block; + return { + offers, + ...(resourceOffers !== undefined ? { resourceOffers } : {}), + ...(typeof paymentFlow === 'string' ? { paymentFlow } : {}), + ...(isRecord(resourcePaymentFlows) + ? { + resourcePaymentFlows: Object.fromEntries( + Object.entries(resourcePaymentFlows).map(([id, flow]) => [id, String(flow)]), + ), + } + : {}), + }; +} + +function describeOverrides(flows: Readonly> | undefined): string { + const entries = Object.entries(flows ?? {}).sort(([a], [b]) => a.localeCompare(b)); + return entries.length > 0 ? entries.map(([id, flow]) => `${id}=${flow}`).join(', ') : 'none'; +} + +/** + * Compares offers in challenge order, then published flows, with local config. + * Mismatches name both values for the operator. + */ +function findRailMismatch( + configured: RailTerms, + live: RailTerms, + payToField = 'payTo', + customNetworks?: CustomNetworks, +): string | undefined { + const diffs: string[] = []; + const differ = (field: string, liveValue: unknown, localValue: unknown) => + diffs.push( + `${field}: gateway is using ${liveValue} but local config resolves to ${localValue}`, + ); + const compareOffers = ( + label: string, + localOffers: readonly RailOffer[], + liveOffers: readonly RailOffer[], + ) => { + if (liveOffers.length !== localOffers.length) { + differ(label, `${liveOffers.length} offer(s)`, localOffers.length); + return; + } + localOffers.forEach((local, index) => { + const remote = liveOffers[index] as RailOffer; + const prefix = + label !== 'options' + ? `${label}[${index}] ` + : localOffers.length > 1 + ? `option ${index} ` + : ''; + // EVM addresses compare without case; Solana's and NEAR's compare exactly + const profile = findX402Network(local.network, customNetworks); + const same = (a: string, b: string) => (profile ? sameAddress(profile, a, b) : a === b); + if (local.network !== remote.network) { + differ(`${prefix}network`, `"${remote.network}"`, `"${local.network}"`); + } + if (!same(local.asset, remote.asset)) differ(`${prefix}asset`, remote.asset, local.asset); + if (!same(local.payTo, remote.payTo)) { + differ(`${prefix}${payToField}`, remote.payTo, local.payTo); + } + if ( + local.assetDecimals !== undefined && + remote.assetDecimals !== undefined && + local.assetDecimals !== remote.assetDecimals + ) { + differ(`${prefix}assetDecimals`, remote.assetDecimals, local.assetDecimals); + } + if (local.assetTransferMethod !== remote.assetTransferMethod) { + differ( + `${prefix}assetTransferMethod`, + remote.assetTransferMethod ?? 'eip3009', + local.assetTransferMethod ?? 'eip3009', + ); + } + }); + }; + compareOffers('options', configured.offers, live.offers); + const localLists = configured.resourceOffers ?? {}; + const liveLists = live.resourceOffers ?? {}; + for (const id of [...new Set([...Object.keys(localLists), ...Object.keys(liveLists)])].sort()) { + compareOffers(`resources.${id}.x402.accepts`, localLists[id] ?? [], liveLists[id] ?? []); + } + // A gateway that publishes no flows cannot be compared on them + if (live.paymentFlow !== undefined) { + if (live.paymentFlow !== configured.paymentFlow) { + differ('paymentFlow', live.paymentFlow, configured.paymentFlow); + } + const liveOverrides = describeOverrides(live.resourcePaymentFlows); + const localOverrides = describeOverrides(configured.resourcePaymentFlows); + if (liveOverrides !== localOverrides) + differ('resourcePaymentFlows', liveOverrides, localOverrides); + } + if (diffs.length === 0) return undefined; + return `${diffs.join('; ')}; the gateway may be running against an older deployment - restart it or re-run chain:deploy`; +} + +/** + * Where a rail settles and through which facilitator. `parseConfig` refuses an + * unknown network, so an absent profile means another loader produced this + * config, and the text says so rather than guessing. + */ +function describeSettlement( + network: string, + facilitator: X402FacilitatorConfig, + customNetworks?: CustomNetworks, +): { readonly mode: DeploymentMode | undefined; readonly where: string; readonly via: string } { + const profile = findX402Network(network, customNetworks); + const mode = profile ? resolveDeploymentMode(profile) : undefined; + const where = + profile === undefined || mode === undefined + ? `unknown network ${network}` + : mode === 'local' + ? `LOCAL dev chain (${network})` + : `${describeDeploymentMode(mode)} on ${profile.displayName} (${network})`; + const via = + facilitator.mode === 'local' ? 'local (in-process)' : `remote (auth=${facilitator.auth.type})`; + return { mode, where, via }; +} + +// Reports rather than re-checks: config load already enforced every guardrail +// (src/payments/x402/guardrails.ts, shared by x402 and MPP). An operator about +// to move real money sees which guarantees they rely on, including an +// unauthenticated facilitator the config accepted through +// `allowUnauthenticatedFacilitator`. +function mainnetSafetyCheck( + name: string, + payToField: 'payTo' | 'recipient', + facilitator: X402FacilitatorConfig, + networks: readonly string[], +): DoctorCheck { + const unauthenticated = facilitator.mode === 'remote' && facilitator.auth.type === 'none'; + const where = `${describeDeploymentMode('mainnet')} on ${networks.join(', ')}`; + return { + name, + status: unauthenticated ? 'WARN' : 'INFO', + detail: unauthenticated + ? `${where}: no credential is sent to the remote facilitator ` + + '(accepted via allowUnauthenticatedFacilitator); its anonymous-access ' + + 'limits apply. Config load also requires ' + + `allowMainnet, HTTPS, a non-development ${payToField} and the canonical asset. ` + + 'Payments fail closed.' + : `${where}. Enforced at config load: explicit allowMainnet ` + + `opt-in, remote facilitator over HTTPS with a credential, non-development ${payToField}, ` + + 'and the canonical asset of each network. Payments fail closed.', + }; +} + +function railOffer(option: { + readonly network: string; + readonly asset: string; + readonly payTo: string; + readonly assetDecimals: number; + readonly assetTransferMethod?: string; +}): RailOffer { + const { network, asset, payTo, assetDecimals, assetTransferMethod } = option; + return { + network, + asset, + payTo, + assetDecimals, + ...(assetTransferMethod !== undefined ? { assetTransferMethod } : {}), + }; +} + +// Discovery exposes offer statuses; reasons stay in gateway debug logs +function offerHealthCheck( + live: RailTerms, + named: (network: string) => string, +): DoctorCheck | undefined { + const all = [...live.offers, ...Object.values(live.resourceOffers ?? {}).flat()]; + const offers = [ + ...new Map( + all.map((offer) => [`${offer.network} ${offer.asset} ${offer.payTo}`, offer]), + ).values(), + ]; + const reported = offers.filter((offer) => offer.status !== undefined); + if (reported.length === 0) return undefined; + const failing = reported.filter((offer) => offer.status === 'fail'); + const degraded = reported.filter((offer) => offer.status === 'warn'); + const describe = (list: readonly RailOffer[]) => + list + .map( + (offer) => + `${named(offer.network)} ${maskMiddle(offer.asset)} to ${maskMiddle(offer.payTo)}`, + ) + .join('; '); + if (failing.length === 0 && degraded.length === 0) { + return { + name: 'Payments (offers)', + status: 'PASS', + detail: `${reported.length} offer(s) pass their health checks`, + }; + } + const problems = [ + ...(failing.length > 0 ? [`failing: ${describe(failing)}`] : []), + ...(degraded.length > 0 ? [`degraded: ${describe(degraded)}`] : []), + ]; + return { + name: 'Payments (offers)', + status: failing.length > 0 ? 'FAIL' : 'WARN', + detail: `${problems.join('; ')}. The gateway logs each reason at debug level`, + }; +} + +// x402 and MPP, each compared with what the running gateway reports +export function paymentChecks( + config: GatewayConfig | undefined, + wellKnown: WellKnownResult | undefined, + gatewayUrl: string, +): DoctorCheck[] { + const checks: DoctorCheck[] = []; + // 6. Payments + const x402 = config?.payments.x402; + if (x402 === undefined || !x402.enabled) { + checks.push({ name: 'Payments', status: 'INFO', detail: 'x402 not configured' }); + } else { + const options = x402.options ?? [x402]; + const resourceOptions = x402.resourceOptions ?? {}; + // Every configured offer, the rail's and each resource's + const everyOption = [...options, ...Object.values(resourceOptions).flat()]; + const { mode, where, via } = describeSettlement(x402.network, x402.facilitator, x402.networks); + const flow = describePaymentFlow(x402, X402_DEFAULT_PAYMENT_FLOW); + const named = (network: string) => + `${findX402Network(network, x402.networks)?.displayName ?? 'unknown network'} (${network})`; + const terms = + options.length === 1 + ? `${where}, destination=${maskMiddle(x402.payTo)}` + : `${mode !== undefined ? describeDeploymentMode(mode) : 'unknown mode'}, ${options.length} options: ` + + options + .map((option) => `${named(option.network)} to ${maskMiddle(option.payTo)}`) + .join('; '); + const ownCount = Object.keys(resourceOptions).length; + const own = + ownCount > 0 + ? `, ${ownCount} resource${ownCount === 1 ? '' : 's'} with ${ownCount === 1 ? 'its' : 'their'} own offers` + : ''; + const summary = `x402 v2 (scheme=exact) enabled - ${terms}${own}, facilitator=${via}, ${flow}`; + const live = wellKnown?.ok ? extractLiveRail(wellKnown.body) : undefined; + const placeholder = PLACEHOLDER_ASSET_ADDRESS.toLowerCase(); + if (everyOption.some((option) => option.asset.toLowerCase() === placeholder)) { + // The gateway echoes init's placeholder, so matching configs alone + // cannot detect the missing token contract. Payment fails closed. + checks.push({ + name: 'Payments', + status: 'WARN', + detail: `${summary}, but the asset is still the init placeholder ${maskMiddle(PLACEHOLDER_ASSET_ADDRESS)}. No token contract exists there, so every paid call will fail. Set payments.x402.asset (\${X402_ASSET} is filled from .deploy/local.json, which "npm run chain:deploy" writes)`, + }); + } else if (live === 'disabled') { + // Verified, and it disagrees: the gateway runs a different configuration + checks.push({ + name: 'Payments', + status: 'FAIL', + detail: `${summary}, but the gateway at ${gatewayUrl} reports x402 disabled, so it is running a different configuration`, + }); + } else if (live === undefined) { + // Gateway unreachable, or the document does not say: a match cannot be + // judged either way, so INFO rather than FAIL + checks.push({ + name: 'Payments', + status: 'INFO', + detail: `${summary} (gateway's live settlement config could not be verified)`, + }); + } else { + const configured: RailTerms = { + offers: options.map(railOffer), + ...(ownCount > 0 + ? { + resourceOffers: Object.fromEntries( + Object.entries(resourceOptions).map(([id, list]) => [id, list.map(railOffer)]), + ), + } + : {}), + paymentFlow: x402.paymentFlow ?? X402_DEFAULT_PAYMENT_FLOW, + ...(x402.resourcePaymentFlows !== undefined + ? { resourcePaymentFlows: x402.resourcePaymentFlows } + : {}), + }; + const mismatch = findRailMismatch(configured, live, 'payTo', x402.networks); + checks.push( + mismatch === undefined + ? { name: 'Payments', status: 'PASS', detail: summary } + : { name: 'Payments', status: 'FAIL', detail: mismatch }, + ); + const offerHealth = offerHealthCheck(live, named); + if (offerHealth !== undefined) checks.push(offerHealth); + } + if (mode === 'mainnet') { + checks.push( + mainnetSafetyCheck('Mainnet safety', 'payTo', x402.facilitator, [ + ...new Set(everyOption.map((option) => named(option.network))), + ]), + ); + } + } + const mpp = config?.payments.mpp; + if (mpp === undefined || !mpp.enabled) { + checks.push({ name: 'Payments (MPP)', status: 'INFO', detail: 'MPP not configured' }); + } else { + const { mode, where, via } = describeSettlement(mpp.network, mpp.facilitator); + const summary = + `MPP ${MPP_PROFILE.intent}/${MPP_PROFILE.method}/${MPP_PROFILE.credentialType} enabled - ` + + `${where}, asset=${MPP_PROFILE.assetSymbol} ${maskMiddle(mpp.asset)}, ` + + `recipient=${maskMiddle(mpp.recipient)}, facilitator=${via}, ` + + `${describePaymentFlow(mpp, MPP_DEFAULT_PAYMENT_FLOW)}, ` + + `spec ${MPP_SPEC_DRAFTS.core}@${MPP_SPEC_COMMIT.slice(0, 7)}, mppx ${MPPX_VERSION}`; + const configured: RailTerms = { + offers: [ + { + network: mpp.network, + asset: mpp.asset, + payTo: mpp.recipient, + assetDecimals: MPP_PROFILE.assetDecimals, + }, + ], + paymentFlow: mpp.paymentFlow ?? MPP_DEFAULT_PAYMENT_FLOW, + ...(mpp.resourcePaymentFlows !== undefined + ? { resourcePaymentFlows: mpp.resourcePaymentFlows } + : {}), + }; + const live = wellKnown?.ok ? extractLiveRail(wellKnown.body, 'mpp') : undefined; + if (live === 'disabled') { + checks.push({ + name: 'Payments (MPP)', + status: 'FAIL', + detail: `Local config enables MPP, but ${gatewayUrl} reports it disabled`, + }); + } else if (live === undefined) { + checks.push({ + name: 'Payments (MPP)', + status: 'INFO', + detail: `${summary} (live MPP settlement config unavailable for comparison)`, + }); + } else { + const mismatch = findRailMismatch(configured, live, 'recipient'); + checks.push( + mismatch === undefined + ? { name: 'Payments (MPP)', status: 'PASS', detail: summary } + : { name: 'Payments (MPP)', status: 'FAIL', detail: mismatch }, + ); + } + if (mode === 'mainnet') { + checks.push( + mainnetSafetyCheck('Mainnet safety (MPP)', 'recipient', mpp.facilitator, [ + `${findX402Network(mpp.network)?.displayName ?? 'unknown network'} (${mpp.network})`, + ]), + ); + } + } + return checks; +} diff --git a/src/cli/commands/doctor/protocols.ts b/src/cli/commands/doctor/protocols.ts new file mode 100644 index 0000000..0d3783a --- /dev/null +++ b/src/cli/commands/doctor/protocols.ts @@ -0,0 +1,145 @@ +// Protocol checks: what is enabled, and the A2A and ACP specifics + +// Narrow modules, not the protocol barrels: the CLI must pull in no protocol SDK +import { + A2A_AGENT_CARD_PATH, + A2A_PROTOCOL_BINDING, + A2A_PROTOCOL_VERSION, + A2A_SPEC_VERSION, +} from '../../../protocols/a2a/constants'; +import { A2A_UNSUPPORTED } from '../../../protocols/a2a/descriptor'; +import { + ACP_API_VERSION, + ACP_SPEC_VERSION, + ACP_WELL_KNOWN_PATH, +} from '../../../protocols/acp/constants'; +import { ACP_UNSUPPORTED } from '../../../protocols/acp/descriptor'; +import type { GatewayConfig } from '../../lib/config-client'; +import { storeFileCheck } from './storage'; +import type { DoctorCheck, WellKnownResult } from './types'; + +/** + * The resource mapped to each checkout operation, re-checked against the rules + * config enforces: present, acp-exposed and free to invoke. Resource ids are + * printed because an operator needs them to fix a mapping; the mapping is + * config, not a secret. + */ +function acpMappingCheck( + config: GatewayConfig, + operations: Readonly>, +): DoctorCheck { + const byId = new Map(config.resources.map((resource) => [resource.id, resource])); + const problems: string[] = []; + + for (const [operation, resourceId] of Object.entries(operations)) { + const resource = byId.get(resourceId); + if (resource === undefined) { + problems.push(`${operation} -> "${resourceId}" does not exist`); + continue; + } + if (!resource.exposedVia.includes('acp')) { + problems.push(`${operation} -> "${resourceId}" is not exposed via acp`); + } + // ACP checkout carries the merchant's own purchase payment; charging for + // the invocation as well would put two payment layers on one call + if (resource.pricing.type !== 'free' || resource.paymentMethods.length > 0) { + problems.push(`${operation} -> "${resourceId}" is not free to invoke`); + } + } + + if (problems.length > 0) { + return { name: 'ACP checkout mapping', status: 'FAIL', detail: problems.join('; ') }; + } + return { + name: 'ACP checkout mapping', + status: 'PASS', + detail: `5 operations mapped to free acp-exposed resources (${Object.values(operations).join(', ')})`, + }; +} + +// Protocols, then the A2A and ACP specifics +export function protocolChecks( + config: GatewayConfig | undefined, + wellKnown: WellKnownResult | undefined, +): DoctorCheck[] { + const checks: DoctorCheck[] = []; + // 5. Protocols + if (config === undefined) { + checks.push({ name: 'Protocols', status: 'WARN', detail: 'skipped: config invalid' }); + } else if (wellKnown === undefined || !wellKnown.ok) { + checks.push({ name: 'Protocols', status: 'FAIL', detail: 'well-known document unreachable' }); + } else { + const mcpMountPath = config.protocols.mcp.enabled ? config.protocols.mcp.mountPath : undefined; + const a2aMountPath = config.protocols.a2a.enabled ? config.protocols.a2a.mountPath : undefined; + checks.push({ + name: 'Protocols', + status: 'PASS', + detail: `http=${config.protocols.http.enabled ? 'on' : 'off'} mcp=${config.protocols.mcp.enabled ? `on (${mcpMountPath})` : 'off'} a2a=${config.protocols.a2a.enabled ? `on (${a2aMountPath})` : 'off'} acp=${config.protocols.acp.enabled ? `on (${config.protocols.acp.mountPath})` : 'off'}`, + }); + } + + // 5b. A2A specifics, from the pins rather than the live gateway. The spec + // revision and the negotiation version look alike, so each is named. + if (config === undefined) { + checks.push({ name: 'A2A', status: 'WARN', detail: 'skipped: config invalid' }); + } else if (!config.protocols.a2a.enabled) { + checks.push({ name: 'A2A', status: 'INFO', detail: 'disabled' }); + } else { + checks.push({ + name: 'A2A', + status: 'PASS', + detail: `experimental · spec ${A2A_SPEC_VERSION} · protocol ${A2A_PROTOCOL_VERSION} · binding ${A2A_PROTOCOL_BINDING} · mount ${config.protocols.a2a.mountPath} · card ${A2A_AGENT_CARD_PATH}`, + }); + // Listed in full, as are ACP's and AP2's below: a count does not tell an + // operator whether the one feature their client needs is missing + checks.push({ + name: 'A2A unsupported', + status: 'INFO', + detail: A2A_UNSUPPORTED.join(', '), + }); + } + + // 5c. ACP specifics, from config and the pins: the well-known document omits + // the bearer token, the idempotency path and the operation mapping. This + // report prints the path and the mapping, never the token. + if (config === undefined) { + checks.push({ name: 'ACP', status: 'WARN', detail: 'skipped: config invalid' }); + } else if (!config.protocols.acp.enabled) { + checks.push({ name: 'ACP', status: 'INFO', detail: 'disabled' }); + } else { + const acp = config.protocols.acp; + checks.push({ + name: 'ACP', + status: 'PASS', + detail: `experimental · spec ${ACP_SPEC_VERSION} · API-Version ${ACP_API_VERSION} · service checkout · mount ${acp.mountPath} · discovery ${ACP_WELL_KNOWN_PATH}`, + }); + + // Whether a token is configured and its length, never the token. Config + // refuses an empty one. + checks.push({ + name: 'ACP auth', + status: 'PASS', + detail: `bearer token configured (${acp.auth.token.length} characters, not shown)`, + }); + + checks.push( + storeFileCheck( + 'ACP idempotency', + acp.idempotency.path, + { + inMemory: 'replay protection is lost on every restart', + unwritable: 'the adapter cannot claim idempotency keys', + }, + ` (retention ${acp.idempotency.retentionHours}h)`, + ), + ); + checks.push(acpMappingCheck(config, acp.checkout.operations)); + + checks.push({ + name: 'ACP unsupported', + status: 'INFO', + detail: ACP_UNSUPPORTED.join(', '), + }); + } + return checks; +} diff --git a/src/cli/commands/doctor/report.ts b/src/cli/commands/doctor/report.ts new file mode 100644 index 0000000..f192026 --- /dev/null +++ b/src/cli/commands/doctor/report.ts @@ -0,0 +1,30 @@ +// Prints a doctor report as text or JSON +import picocolors from 'picocolors'; +import type { Io } from '../../lib/io'; +import type { CheckStatus, DoctorReport } from './types'; + +function statusColor(status: CheckStatus, text: string): string { + switch (status) { + case 'PASS': + return picocolors.green(text); + case 'WARN': + return picocolors.yellow(text); + case 'FAIL': + return picocolors.red(text); + case 'INFO': + return picocolors.cyan(text); + } +} + +export function printDoctorReport(report: DoctorReport, io: Io, json: boolean): void { + if (json) { + io.stdout(JSON.stringify(report, null, 2)); + return; + } + for (const check of report.checks) { + const label = statusColor(check.status, check.status.padEnd(5)); + io.stdout(`${label} ${check.name.padEnd(20)} ${check.detail}`); + } + io.stdout(''); + io.stdout(`Score: ${report.score.passed}/${report.score.total} checks passed`); +} diff --git a/src/cli/commands/doctor/storage.ts b/src/cli/commands/doctor/storage.ts new file mode 100644 index 0000000..c77375f --- /dev/null +++ b/src/cli/commands/doctor/storage.ts @@ -0,0 +1,122 @@ +// Storage checks: the receipt store, and the SQLite files other features keep +import { accessSync, existsSync, constants as fsConstants } from 'node:fs'; +import { dirname } from 'node:path'; +import type { ReceiptStore } from '../../../core'; +import type { GatewayConfig } from '../../lib/config-client'; +import type { CheckStatus, DoctorCheck } from './types'; + +// Whether an existing file is writable, without creating one +function isWritableFile(path: string): boolean { + try { + accessSync(path, fsConstants.W_OK); + return true; + } catch { + return false; + } +} + +// A SQLite store file, diagnosed without creating it (see the Storage check in +// `runDoctor`). `suffix` is appended to every detail. +export function storeFileCheck( + name: string, + path: string, + risk: { readonly inMemory: string; readonly unwritable: string }, + suffix = '', +): DoctorCheck { + if (path === ':memory:') { + return { + name, + status: 'WARN', + detail: `in-memory store: ${risk.inMemory}; use a file path in production${suffix}`, + }; + } + if (existsSync(path)) { + return isWritableFile(path) + ? { name, status: 'PASS', detail: `writable at "${path}"${suffix}` } + : { + name, + status: 'FAIL', + detail: `"${path}" exists but is not writable by this user, so ${risk.unwritable}${suffix}`, + }; + } + return { name, status: 'WARN', detail: `${missingStoreDetail(path)}${suffix}` }; +} + +// Every SQLite store opens through `openSqliteDatabase`, which creates the file +// and a missing directory, so one wording covers them all +function missingStoreDetail(path: string): string { + const directory = dirname(path); + return directory !== '.' && !existsSync(directory) + ? `no store at "${path}" and its directory does not exist. Both are created when the gateway starts, provided the path is writable` + : `no store at "${path}" yet. It is created the first time the gateway starts`; +} + +export async function storageChecks( + config: GatewayConfig | undefined, + createStore: (path: string) => ReceiptStore, +): Promise { + const checks: DoctorCheck[] = []; + // 7. Storage + if (config === undefined) { + checks.push({ name: 'Storage', status: 'WARN', detail: 'skipped: config invalid' }); + } else if ( + // Diagnose without creating: createSqliteReceiptStore creates the file, + // so opening a wrong path (a typo, or a container path read from the + // host) would leave an empty database and report it healthy, hiding the + // real one. `:memory:` never exists on disk, so it takes the open branch + // below. + config.storage.receipts.path !== ':memory:' && + !existsSync(config.storage.receipts.path) + ) { + checks.push({ + name: 'Storage', + status: 'WARN', + detail: + `${missingStoreDetail(config.storage.receipts.path)}. If the gateway is ` + + 'already running, this path does not match the one it uses.', + }); + } else { + let store: ReceiptStore | undefined; + try { + store = createStore(config.storage.receipts.path); + await store.init(); + const storeHealth = await store.health(); + let receiptCount: number | undefined; + try { + // Exact count, not a list length: listReceipts clamps every page + receiptCount = await store.countReceipts(); + } catch { + receiptCount = undefined; + } + let undeliveredCount: number | undefined; + try { + // Paid but undelivered purchases (see + // `ReceiptStore.countUndeliveredReceipts`), the rows the dashboard + // flags as "Charged but not delivered" + undeliveredCount = await store.countUndeliveredReceipts(); + } catch { + undeliveredCount = undefined; + } + const status: CheckStatus = + storeHealth.status === 'pass' ? 'PASS' : storeHealth.status === 'warn' ? 'WARN' : 'FAIL'; + const receiptsSuffix = + receiptCount !== undefined + ? `; receipts=${receiptCount}${undeliveredCount !== undefined && undeliveredCount > 0 ? ` (${undeliveredCount} undelivered)` : ''}` + : ''; + checks.push({ + name: 'Storage', + status, + detail: `${storeHealth.detail ?? 'sqlite'}${receiptsSuffix}`, + }); + } catch (err) { + checks.push({ + name: 'Storage', + status: 'FAIL', + detail: err instanceof Error ? err.message : String(err), + }); + } finally { + await store?.close(); + } + } + return checks; +} diff --git a/src/cli/commands/doctor/types.ts b/src/cli/commands/doctor/types.ts new file mode 100644 index 0000000..8b9d8a4 --- /dev/null +++ b/src/cli/commands/doctor/types.ts @@ -0,0 +1,33 @@ +// The report doctor builds, and what a caller can inject +import type { ReceiptStore } from '../../../core'; +import type { ConfigLoader } from '../../lib/config-client'; +import type { FetchJsonResult, FetchLike } from '../../lib/http'; +import type { ManifestEnvFill } from '../../lib/manifest-env'; + +export type CheckStatus = 'PASS' | 'WARN' | 'FAIL' | 'INFO'; + +export interface DoctorCheck { + readonly name: string; + readonly status: CheckStatus; + readonly detail: string; +} + +export interface DoctorReport { + readonly checks: readonly DoctorCheck[]; + readonly score: { readonly passed: number; readonly total: number }; + readonly exitCode: number; +} + +export interface DoctorOptions { + readonly configPath?: string; + readonly gatewayUrl?: string; +} + +export interface DoctorDeps { + readonly fetchImpl?: FetchLike; + readonly loadConfig?: ConfigLoader; + readonly createStore?: (path: string) => ReceiptStore; + readonly fillEnvFromManifest?: (env: NodeJS.ProcessEnv) => ManifestEnvFill; +} + +export type WellKnownResult = FetchJsonResult>; diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 5de2756..7ccb64a 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -3,6 +3,7 @@ import { writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import * as clack from '@clack/prompts'; import { DEFAULT_CONFIG_FILENAME } from '../../config/filename'; +import { isEvmAddress } from '../../payments/x402/addresses'; import { type ConfigLoader, type ConfigParser, @@ -106,7 +107,7 @@ export async function collectAnswersInteractive(): Promise origin !== '*', { - message: - 'wildcard "*" is not supported - allowedOrigins entries are matched literally against the browser\'s Origin header, so "*" would match nothing. List each scheme://host[:port] explicitly.', - }) - .refine((origin) => !origin.endsWith('/'), { - message: - 'must not end with "/" - a browser Origin header never has a trailing slash, so this entry would match nothing. Use e.g. "http://localhost:5173".', - }), - ) - .optional(), - }) - .strict(); - -const StorageSchema = z - .object({ - receipts: z - .object({ - driver: z.literal('sqlite'), - path: z.string().min(1), - }) - .strict(), - }) - .strict(); - -// Every path the gateway registers (`src/gateway/routes.ts`), plus the fixed -// discovery paths adapters own. A mount equal to one duplicates that route; a -// mount that is a prefix of one swallows it through its `${mountPath}/*` wildcard. -const RESERVED_GATEWAY_PATHS = [ - '/health', - '/ready', - '/.well-known/agent-commerce', - // Fixed by the A2A specification and served by the adapter - '/.well-known/agent-card.json', - // Fixed by the ACP specification - ACP_WELL_KNOWN_PATH, - '/api/resources', - '/api/resources/:id/invoke', - '/api/receipts', - '/api/events', -] as const; - -// A bad `mountPath` would throw inside Fastify route registration at -// `server.ready()`, failing the whole gateway with an opaque `FST_ERR_*`. -// Checked here it is a CONFIG_INVALID naming the value. Pattern syntax (`:`, -// `*`) is refused because the mount registers its own wildcard. -const MountPathSchema = z - .string() - .min(1) - .refine((value) => value.startsWith('/'), { - message: 'must start with "/" - it is an absolute gateway path, e.g. "/mcp".', - }) - .refine((value) => !/[:*?\s]/.test(value), { - message: - 'must not contain ":", "*", "?" or whitespace - the mount is a literal path prefix, not a Fastify route pattern, and registers its own wildcard.', - }) - .refine( - (value) => { - const base = value.replace(/\/+$/, ''); - return !RESERVED_GATEWAY_PATHS.some( - (reserved) => reserved === base || reserved.startsWith(`${base}/`), - ); - }, - { - message: `must not collide with a route the gateway already serves (${RESERVED_GATEWAY_PATHS.join(', ')}); pick a dedicated prefix such as "/mcp".`, - }, - ); - -// Bearer is the only ACP auth scheme; there is no `none` -const AcpAuthSchema = z.object({ type: z.literal('bearer'), token: z.string().min(1) }).strict(); - -const AcpIdempotencySchema = z - .object({ - path: z.string().min(1), - retentionHours: NumberOrString.optional(), - merchantIdempotent: z.boolean().optional(), - }) - .strict(); - -// Operation names are checked against `ACP_CHECKOUT_OPERATIONS` in the -// business pass, which names a missing or misspelled operation -const AcpCheckoutSchema = z.object({ operations: z.record(z.string().min(1)) }).strict(); - -// Optional discovery metadata, omitted when not configured -const AcpDiscoverySchema = z - .object({ - documentationUrl: z.string().min(1).optional(), - supportedCurrencies: z.array(z.string().min(1)).optional(), - supportedLocales: z.array(z.string().min(1)).optional(), - interventionTypes: z.array(z.string().min(1)).optional(), - }) - .strict(); - -const ProtocolsSchema = z - .object({ - http: z.object({ enabled: BooleanOrString }).strict(), - mcp: z - .object({ - enabled: BooleanOrString, - mountPath: MountPathSchema, - }) - .strict(), - // Optional; A2A is off unless configured - a2a: z - .object({ - enabled: BooleanOrString, - mountPath: MountPathSchema.optional(), - }) - .strict() - .optional(), - // Optional and off by default. The business pass requires the sub-blocks - // only when ACP is enabled, so a disabled placeholder stays valid and an - // enabled block gets a message naming what it lacks. - acp: z - .object({ - enabled: BooleanOrString, - mountPath: MountPathSchema.optional(), - auth: AcpAuthSchema.optional(), - idempotency: AcpIdempotencySchema.optional(), - checkout: AcpCheckoutSchema.optional(), - discovery: AcpDiscoverySchema.optional(), - }) - .strict() - .optional(), - }) - .strict(); - -// Applied when `protocols.a2a` is absent or names no mount -const DEFAULT_A2A_MOUNT_PATH = '/a2a'; - -// Applied when `protocols.acp` is absent or names no mount -const DEFAULT_ACP_MOUNT_PATH = '/acp'; - -// Idempotency records must outlive ACP's 24-hour retry window, or a replayed -// key could pass an expired record and run a checkout side effect twice. It is -// both the default and the floor. -const ACP_RETENTION_HOURS = 24; - -const BackendMethodSchema = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); - -// Strict, so a typo like `bodyy` fails at load instead of silently meaning -// "no body binding" -const BackendInputBindingsSchema = z - .object({ - path: z.string().min(1).optional(), - query: z.string().min(1).optional(), - body: z.string().min(1).optional(), - }) - .strict(); - -const BackendHandlerSchema = z - .object({ - type: z.literal('http'), - method: BackendMethodSchema, - url: z.string().min(1), - headers: z.record(z.string(), z.string()).optional(), - timeoutMs: NumberOrString.optional(), - inputBindings: BackendInputBindingsSchema.optional(), - }) - .strict(); - -const PricingSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('free') }).strict(), - z - .object({ - type: z.literal('fixed'), - amount: z.string().min(1), - currency: z.string().min(1), - }) - .strict(), - z - .object({ - type: z.literal('dynamic'), - resolver: z.string().min(1), - }) - .strict(), -]); - -const JsonSchemaValueSchema: ZodTypeAny = z.record(z.string(), z.unknown()); - -const ResourceEntrySchema = z - .object({ - name: z.string().min(1), - description: z.string().optional(), - input: JsonSchemaValueSchema.optional(), - output: JsonSchemaValueSchema.optional(), - backend: BackendHandlerSchema, - pricing: PricingSchema, - expose: z.array(z.string().min(1)).min(1), - payments: z.array(z.string().min(1)).optional(), - paymentFlow: z.enum(X402_PAYMENT_FLOWS).optional(), - authorization: z - .object({ required: z.array(z.string().min(1)).min(1) }) - .strict() - .optional(), - }) - .strict(); - -const ResourcesMapSchema = z.record(z.string().min(1), ResourceEntrySchema); - -// Facilitator credentials. `none` and `bearer` fit any facilitator; `cdp` is -// the one vendor-specific type. Any other type is refused at load. -const FacilitatorAuthSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('none') }).strict(), - z.object({ type: z.literal('bearer'), token: z.string().min(1) }).strict(), - // Coinbase Developer Platform. Needs the optional peer `@coinbase/x402`, - // which signs a fresh JWT per request. - z - .object({ - type: z.literal('cdp'), - apiKeyId: z.string().min(1), - apiKeySecret: z.string().min(1), - }) - .strict(), -]); - -const FacilitatorSchema = z.discriminatedUnion('mode', [ - z - .object({ - mode: z.literal('local'), - signerPrivateKey: z.string().min(1), - }) - .strict(), - z - .object({ - mode: z.literal('remote'), - url: z.string().min(1), - // Absent auth sends no credential and normalizes to `{ type: 'none' }`, - // which on mainnet requires `allowUnauthenticatedFacilitator` - auth: FacilitatorAuthSchema.optional(), - }) - .strict(), -]); - -const X402Schema = z - .object({ - enabled: BooleanOrString, - network: z.string().min(1), - rpcUrl: z.string().min(1), - asset: z.string().min(1), - assetName: z.string().min(1), - assetVersion: z.string().min(1), - assetDecimals: NumberOrString, - payTo: z.string().min(1), - maxTimeoutSeconds: NumberOrString, - // `authorization` (the x402 default) settles after the backend call - paymentFlow: z.enum(X402_PAYMENT_FLOWS).optional(), - facilitator: FacilitatorSchema, - // Real funds. Never defaulted; see src/payments/x402/guardrails.ts - allowMainnet: BooleanOrString.optional(), - // Accepts credential-free facilitator access on mainnet. Never defaulted - allowUnauthenticatedFacilitator: BooleanOrString.optional(), - }) - .strict(); - -const MppSchema = z - .object({ - enabled: BooleanOrString, - network: z.string().min(1).optional(), - rpcUrl: z.string().min(1), - asset: z.string().min(1), - assetName: z.string().min(1), - assetVersion: z.string().min(1), - recipient: z.string().min(1), - realm: z.string().min(1), - challengeSecret: z.string().min(1), - challengeTtlSeconds: NumberOrString.optional(), - // Same values and default as `payments.x402.paymentFlow` - paymentFlow: z.enum(X402_PAYMENT_FLOWS).optional(), - facilitator: FacilitatorSchema, - // Real funds. Never defaulted; see src/payments/x402/guardrails.ts - allowMainnet: BooleanOrString.optional(), - // Accepts credential-free facilitator access on mainnet. Never defaulted - allowUnauthenticatedFacilitator: BooleanOrString.optional(), - }) - .strict(); - -const PaymentsSchema = z - .object({ - x402: X402Schema.optional(), - mpp: MppSchema.optional(), - }) - .strict(); - -// A public verification key, inline only: no `jwksUri`, `jku` or discovery -// URL. AP2 leaves secure key distribution open, and fetching keys would mean -// fetching from a location a mandate can influence. The business pass checks -// the JWK's members against `AP2_JWK_MEMBERS`, which also keeps out `x5u`. -const Ap2KeySchema = z - .object({ - kid: z.string().min(1), - jwk: z.record(z.string(), z.unknown()), - }) - .strict(); - -const Ap2IssuerSchema = z - .object({ - issuer: z.string().min(1), - // Required, not defaulted: without it a mandate minted for another - // merchant would verify here - audience: z.string().min(1), - keys: z.array(Ap2KeySchema).min(1), - }) - .strict(); - -const Ap2Schema = z - .object({ - enabled: BooleanOrString, - specVersion: z.string().min(1).optional(), - mode: z.string().min(1).optional(), - trust: z - .object({ - mandateIssuers: z.array(Ap2IssuerSchema).optional(), - checkoutIssuers: z.array(Ap2IssuerSchema).optional(), - }) - .strict() - .optional(), - clockSkewSeconds: NumberOrString.optional(), - requireMandateAudience: BooleanOrString.optional(), - requireMandateExpiry: BooleanOrString.optional(), - replay: z - .object({ path: z.string().min(1) }) - .strict() - .optional(), - }) - .strict(); - -// Optional and off by default. The business pass requires the sub-blocks only -// when AP2 is enabled, and names the missing piece. -const AuthorizationSchema = z.object({ ap2: Ap2Schema.optional() }).strict(); - -const RawConfigSchema = z - .object({ - version: z.literal(SUPPORTED_CONFIG_VERSION), - merchant: MerchantSchema, - server: ServerSchema, - storage: StorageSchema, - protocols: ProtocolsSchema, - resources: ResourcesMapSchema, - payments: PaymentsSchema, - authorization: AuthorizationSchema.optional(), - }) - .strict(); - -type RawConfig = z.infer; -type RawResourceEntry = z.infer; - -// --------------------------------------------------------------------------- -// Public shape (docs/contracts.md) -// --------------------------------------------------------------------------- - -/** Optional ACP discovery metadata. Absent fields are omitted from the document */ -export interface AcpDiscoveryConfig { - readonly documentationUrl?: string; - readonly supportedCurrencies?: readonly string[]; - readonly supportedLocales?: readonly string[]; - readonly interventionTypes?: readonly string[]; -} - -/** - * Discriminated on `enabled`, so an enabled ACP config carries everything the - * adapter needs; a half-configured checkout is rejected at load - */ -export type AcpProtocolConfig = - | { readonly enabled: false; readonly mountPath: string } - | { - readonly enabled: true; - readonly mountPath: string; - readonly auth: { readonly type: 'bearer'; readonly token: string }; - readonly idempotency: { - readonly path: string; - readonly retentionHours: number; - readonly merchantIdempotent: boolean; - }; - readonly checkout: { - readonly operations: Readonly>; - }; - readonly discovery?: AcpDiscoveryConfig; - }; - -export interface GatewayConfig { - readonly version: 1; - readonly merchant: { readonly id: string; readonly name: string; readonly publicBaseUrl: string }; - readonly server: { - readonly port: number; - readonly host: string; - readonly adminToken?: string; - readonly allowedOrigins: readonly string[]; - }; - readonly storage: { readonly receipts: { readonly driver: 'sqlite'; readonly path: string } }; - readonly protocols: { - readonly http: { readonly enabled: boolean }; - readonly mcp: { readonly enabled: boolean; readonly mountPath: string }; - readonly a2a: { readonly enabled: boolean; readonly mountPath: string }; - readonly acp: AcpProtocolConfig; - }; - /** Canonical resources, already normalized */ - readonly resources: readonly CommerceResource[]; - readonly payments: { - readonly x402?: { - readonly enabled: boolean; - readonly network: string; - readonly rpcUrl: string; - readonly asset: string; - readonly assetName: string; - readonly assetVersion: string; - readonly assetDecimals: number; - readonly payTo: string; - readonly maxTimeoutSeconds: number; - /** Defaults to `authorization`, the x402 default */ - readonly paymentFlow?: X402PaymentFlow; - /** Resources whose `paymentFlow` overrides the rail's, by resource id */ - readonly resourcePaymentFlows?: Readonly>; - readonly facilitator: X402FacilitatorConfig; - readonly allowMainnet?: boolean; - readonly allowUnauthenticatedFacilitator?: boolean; - }; - readonly mpp?: { - readonly enabled: boolean; - readonly network: MppNetwork; - readonly rpcUrl: string; - readonly asset: string; - readonly assetName: string; - readonly assetVersion: string; - readonly recipient: string; - readonly realm: string; - readonly challengeSecret: string; - readonly challengeTtlSeconds?: number; - /** Defaults to `authorization`: settle after a successful backend call */ - readonly paymentFlow?: X402PaymentFlow; - /** Resources whose `paymentFlow` overrides the rail's, by resource id */ - readonly resourcePaymentFlows?: Readonly>; - readonly facilitator: X402FacilitatorConfig; - readonly allowMainnet?: boolean; - readonly allowUnauthenticatedFacilitator?: boolean; - }; - }; - /** Absent when no `authorization:` block is configured */ - readonly authorization?: { readonly ap2: Ap2AuthorizationConfig }; -} - -// --------------------------------------------------------------------------- -// Entry point -// --------------------------------------------------------------------------- - -export function parseConfig(raw: unknown, env: NodeJS.ProcessEnv): GatewayConfig { - if (!isRecord(raw)) { - throw new CommerceError( - 'CONFIG_INVALID', - 'Configuration root must be a mapping (object) at "$"', - { - details: { path: '$' }, - }, - ); - } - - checkVersion(raw); - - const substituted = substituteEnv(raw, env); - const parsed = parseWithZod(RawConfigSchema, substituted); - return normalize(parsed); -} - -function checkVersion(raw: Record): void { - if (!('version' in raw)) { - throw new CommerceError('CONFIG_INVALID', 'Configuration is missing required field "version"', { - details: { path: 'version' }, - }); - } - const version = raw['version']; - if (version !== SUPPORTED_CONFIG_VERSION) { - const hint = typeof version === 'string' ? ', written as a number without quotes' : ''; - throw new CommerceError( - 'CONFIG_INVALID', - `Unsupported config version "${String(version)}": this gateway only supports version ${SUPPORTED_CONFIG_VERSION}${hint}`, - { details: { path: 'version', supported: SUPPORTED_CONFIG_VERSION } }, - ); - } -} - -function parseWithZod(schema: z.ZodType, value: unknown): T { - const result = schema.safeParse(value); - if (!result.success) { - throw zodErrorToConfigError(result.error); - } - return result.data; -} - -function zodErrorToConfigError(error: ZodError): CommerceError { - const issues = error.issues.map(describeIssue); - const first = issues[0]; - const message = first - ? `Configuration invalid at "${first.path}": ${first.message}` - : 'Configuration invalid'; - return new CommerceError('CONFIG_INVALID', message, { details: { issues } }); -} - -function describeIssue(issue: ZodIssue): { path: string; message: string; code: string } { - const path = issue.path.length > 0 ? issue.path.join('.') : '$'; - return { path, message: issue.message, code: issue.code }; -} - -// --------------------------------------------------------------------------- -// Numeric and boolean coercion (plain TypeScript, not Zod; see file header) -// --------------------------------------------------------------------------- - -function toNumber( - value: number | string, - path: string, - bounds: { min?: number; max?: number } = {}, -): number { - // Plain decimal digits only. `Number('')` is 0, which `server.port` accepts - // ("let the OS pick"), so an empty `${PORT:-}` would bind a random port. - // `Number` also accepts `"0x50"` and `"1e3"` for fields documented as decimal. - if (typeof value === 'string' && !/^\s*\d+\s*$/.test(value)) { - throw new CommerceError( - 'CONFIG_INVALID', - `Configuration value at "${path}" must be an integer written in decimal digits (got ${value === '' ? 'an empty string' : `"${value}"`})`, - { details: { path } }, - ); - } - const n = typeof value === 'number' ? value : Number(value); - if (!Number.isFinite(n) || !Number.isInteger(n)) { - throw new CommerceError( - 'CONFIG_INVALID', - `Configuration value at "${path}" must be an integer`, - { - details: { path }, - }, - ); - } - if (bounds.min !== undefined && n < bounds.min) { - throw new CommerceError( - 'CONFIG_INVALID', - `Configuration value at "${path}" must be >= ${bounds.min}`, - { - details: { path }, - }, - ); - } - if (bounds.max !== undefined && n > bounds.max) { - throw new CommerceError( - 'CONFIG_INVALID', - `Configuration value at "${path}" must be <= ${bounds.max}`, - { - details: { path }, - }, - ); - } - return n; -} - -function toBoolean(value: boolean | string, path: string): boolean { - if (typeof value === 'boolean') return value; - const normalized = value.trim().toLowerCase(); - if (normalized === 'true') return true; - if (normalized === 'false') return false; - throw new CommerceError( - 'CONFIG_INVALID', - `Configuration value at "${path}" must be a boolean ("true" or "false")`, - { details: { path } }, - ); -} - -// --------------------------------------------------------------------------- -// Business rules and normalization into the canonical shape -// --------------------------------------------------------------------------- - -const SUPPORTED_PROTOCOLS: ReadonlySet = new Set(PROTOCOL_NAMES); -const SUPPORTED_PAYMENT_METHODS: ReadonlySet = new Set(PAYMENT_METHOD_NAMES); -const SUPPORTED_AUTHORIZATION_METHODS: ReadonlySet = new Set(['ap2']); - -function normalize(raw: RawConfig): GatewayConfig { - const protocols = { - http: { enabled: toBoolean(raw.protocols.http.enabled, 'protocols.http.enabled') }, - mcp: { - enabled: toBoolean(raw.protocols.mcp.enabled, 'protocols.mcp.enabled'), - mountPath: raw.protocols.mcp.mountPath, - }, - a2a: { - enabled: toBoolean(raw.protocols.a2a?.enabled ?? false, 'protocols.a2a.enabled'), - mountPath: raw.protocols.a2a?.mountPath ?? DEFAULT_A2A_MOUNT_PATH, - }, - acp: normalizeAcp(raw.protocols.acp), - }; - validateMountPaths(protocols); - - const x402Raw = raw.payments.x402; - const x402 = - x402Raw !== undefined - ? { - enabled: toBoolean(x402Raw.enabled, 'payments.x402.enabled'), - network: x402Raw.network, - rpcUrl: x402Raw.rpcUrl, - asset: x402Raw.asset, - assetName: x402Raw.assetName, - assetVersion: x402Raw.assetVersion, - assetDecimals: toNumber(x402Raw.assetDecimals, 'payments.x402.assetDecimals', { - min: 0, - max: 36, - }), - payTo: x402Raw.payTo, - maxTimeoutSeconds: toNumber( - x402Raw.maxTimeoutSeconds, - 'payments.x402.maxTimeoutSeconds', - { - min: 1, - }, - ), - paymentFlow: x402Raw.paymentFlow ?? X402_DEFAULT_PAYMENT_FLOW, - facilitator: normalizeFacilitator(x402Raw.facilitator), - ...(x402Raw.allowMainnet !== undefined - ? { allowMainnet: toBoolean(x402Raw.allowMainnet, 'payments.x402.allowMainnet') } - : {}), - ...(x402Raw.allowUnauthenticatedFacilitator !== undefined - ? { - allowUnauthenticatedFacilitator: toBoolean( - x402Raw.allowUnauthenticatedFacilitator, - 'payments.x402.allowUnauthenticatedFacilitator', - ), - } - : {}), - } - : undefined; - - if (x402) { - validateAddress('payments.x402.payTo', x402.payTo); - validateAddress('payments.x402.asset', x402.asset); - // The provider makes the same call at construction; running it here lets - // `agent-commerce validate` report an unsafe deployment before boot - resolveX402Deployment({ - network: x402.network, - payTo: x402.payTo, - asset: x402.asset, - assetName: x402.assetName, - assetVersion: x402.assetVersion, - facilitator: x402.facilitator, - ...(x402.allowMainnet !== undefined ? { allowMainnet: x402.allowMainnet } : {}), - ...(x402.allowUnauthenticatedFacilitator !== undefined - ? { allowUnauthenticatedFacilitator: x402.allowUnauthenticatedFacilitator } - : {}), - }); - } - - const mpp = normalizeMpp(raw.payments.mpp); - - const ap2 = normalizeAp2(raw.authorization?.ap2); - if (ap2?.enabled) { - validateReplayStoreIsolated(ap2.replay.path, raw.storage.receipts.path, protocols.acp); - } - - const resources = Object.entries(raw.resources).map(([id, entry]) => - normalizeResource(id, entry, protocols, x402, mpp, ap2), - ); - // CommerceResource has no payment-flow field, so the overrides travel with - // each rail, and the one selected for a request applies them - const resourcePaymentFlows = Object.fromEntries( - Object.entries(raw.resources).flatMap(([id, entry]) => - entry.paymentFlow !== undefined ? [[id, entry.paymentFlow]] : [], - ), - ); - const hasFlowOverrides = Object.keys(resourcePaymentFlows).length > 0; - const x402WithFlows = - x402 !== undefined && hasFlowOverrides ? { ...x402, resourcePaymentFlows } : x402; - const mppWithFlows = - mpp !== undefined && hasFlowOverrides ? { ...mpp, resourcePaymentFlows } : mpp; - if (protocols.acp.enabled) validateAcpCheckoutMapping(protocols.acp, resources); - // Require an exposed resource so the enabled adapter publishes a skill - if (protocols.a2a.enabled && !resources.some((r) => r.exposedVia.includes('a2a'))) { - throw new CommerceError( - 'CONFIG_INVALID', - 'protocols.a2a.enabled is true but no resource lists "a2a" in its expose', - { details: { path: 'protocols.a2a.enabled' } }, - ); - } - - return { - version: SUPPORTED_CONFIG_VERSION, - merchant: { - id: raw.merchant.id, - name: raw.merchant.name, - publicBaseUrl: raw.merchant.publicBaseUrl, - }, - // Port 0 lets the OS pick a free port, for tests and throwaway instances - server: { - port: toNumber(raw.server.port, 'server.port', { min: 0, max: 65535 }), - host: raw.server.host, - ...(raw.server.adminToken !== undefined ? { adminToken: raw.server.adminToken } : {}), - allowedOrigins: raw.server.allowedOrigins ?? [], - }, - storage: { receipts: { driver: 'sqlite', path: raw.storage.receipts.path } }, - protocols, - resources, - payments: { - ...(x402WithFlows !== undefined ? { x402: x402WithFlows } : {}), - ...(mppWithFlows !== undefined ? { mpp: mppWithFlows } : {}), - }, - ...(ap2 !== undefined ? { authorization: { ap2 } } : {}), - }; -} - -interface NormalizedProtocols { - readonly http: { readonly enabled: boolean }; - readonly mcp: { readonly enabled: boolean; readonly mountPath: string }; - readonly a2a: { readonly enabled: boolean; readonly mountPath: string }; - readonly acp: AcpProtocolConfig; -} - -// Two enabled mounts may not overlap: each registers a `${mountPath}/*` -// wildcard, so a shared prefix lets one adapter answer for the other. Disabled -// protocols mount nothing and are not compared. -function validateMountPaths(protocols: NormalizedProtocols): void { - const mounts = ( - [ - ['mcp', protocols.mcp], - ['a2a', protocols.a2a], - ['acp', protocols.acp], - ] as const - ).filter(([, p]) => p.enabled); - - for (const [index, [nameA, a]] of mounts.entries()) { - for (const [nameB, b] of mounts.slice(index + 1)) { - const baseA = a.mountPath.replace(/\/+$/, ''); - const baseB = b.mountPath.replace(/\/+$/, ''); - if (baseA === baseB || baseA.startsWith(`${baseB}/`) || baseB.startsWith(`${baseA}/`)) { - throw new CommerceError( - 'CONFIG_INVALID', - `protocols.${nameA}.mountPath ("${a.mountPath}") collides with protocols.${nameB}.mountPath ("${b.mountPath}") - each mount registers a wildcard, so overlapping prefixes cannot both be served`, - { details: { path: `protocols.${nameA}.mountPath` } }, - ); - } - } - } -} - -// --------------------------------------------------------------------------- -// AP2 authorization (experimental) -// --------------------------------------------------------------------------- - -type RawAp2 = NonNullable['ap2']>; - -function ap2Invalid( - path: string, - message: string, - extra: Record = {}, -): CommerceError { - return new CommerceError('CONFIG_INVALID', message, { details: { path, ...extra } }); -} - -function normalizeAp2(raw: RawAp2 | undefined): Ap2AuthorizationConfig | undefined { - if (raw === undefined) return undefined; - if (!toBoolean(raw.enabled, 'authorization.ap2.enabled')) return { enabled: false }; - - const specVersion = raw.specVersion ?? AP2_SPEC_VERSION; - if (specVersion !== AP2_SPEC_VERSION) { - throw ap2Invalid( - 'authorization.ap2.specVersion', - `authorization.ap2.specVersion "${specVersion}" is not supported - this gateway verifies against the tagged AP2 release ${AP2_SPEC_VERSION} only`, - ); - } - - const mode = raw.mode ?? AP2_MODES[0]; - if (!(AP2_MODES as readonly string[]).includes(mode)) { - throw ap2Invalid( - 'authorization.ap2.mode', - `authorization.ap2.mode "${mode}" is not supported. Supported: ${AP2_MODES.join(', ')}. Autonomous mode needs open mandates, agent key binding and constraint evaluation, none of which this release implements.`, - ); - } - - if (raw.replay === undefined) { - throw ap2Invalid( - 'authorization.ap2.replay', - 'authorization.ap2.replay is required when AP2 is enabled - it names the SQLite file recording which mandates have been spent, and without it a verified mandate could authorize a second settlement', - ); - } - - const mandateIssuers = normalizeIssuers( - raw.trust?.mandateIssuers, - 'authorization.ap2.trust.mandateIssuers', - ); - const checkoutIssuers = normalizeIssuers( - raw.trust?.checkoutIssuers, - 'authorization.ap2.trust.checkoutIssuers', - ); - - return { - enabled: true, - specVersion: AP2_SPEC_VERSION, - mode: mode as Ap2Mode, - trust: { mandateIssuers, checkoutIssuers }, - clockSkewSeconds: toNumber( - raw.clockSkewSeconds ?? AP2_DEFAULT_CLOCK_SKEW_SECONDS, - 'authorization.ap2.clockSkewSeconds', - { min: 0, max: AP2_MAX_CLOCK_SKEW_SECONDS }, - ), - requireMandateAudience: toBoolean( - raw.requireMandateAudience ?? false, - 'authorization.ap2.requireMandateAudience', - ), - requireMandateExpiry: toBoolean( - raw.requireMandateExpiry ?? false, - 'authorization.ap2.requireMandateExpiry', - ), - replay: { path: raw.replay.path }, - }; -} - -// Both issuer lists must be non-empty when AP2 is on: verification checks the -// issuer's signature over the Checkout Mandate and the merchant's over the -// checkout JWT it binds. An empty list would fail every mandate at request -// time instead of at load. -function normalizeIssuers( - raw: readonly z.infer[] | undefined, - path: string, -): readonly Ap2TrustedIssuer[] { - if (raw === undefined || raw.length === 0) { - throw ap2Invalid( - path, - `${path} is required when AP2 is enabled and must name at least one issuer - trust is operator-configured and static, so an empty list means no mandate can ever verify`, - ); - } - - const seen = new Set(); - return raw.map((entry, index) => { - const entryPath = `${path}.${index}`; - if (seen.has(entry.issuer)) { - throw ap2Invalid( - `${entryPath}.issuer`, - `${entryPath}.issuer "${entry.issuer}" is listed twice - key lookup resolves by issuer, so a second entry for the same one is silently unreachable. Merge the keys into one entry instead (which is also how a rotation overlaps an old and a new key).`, - { issuer: entry.issuer }, - ); - } - seen.add(entry.issuer); - - const kids = new Set(); - const keys = entry.keys.map((key, keyIndex) => { - const keyPath = `${entryPath}.keys.${keyIndex}`; - if (kids.has(key.kid)) { - throw ap2Invalid( - `${keyPath}.kid`, - `${keyPath}.kid "${key.kid}" is listed twice for issuer "${entry.issuer}" - a kid selects exactly one key, so which of the two verifies a signature would be undefined`, - { issuer: entry.issuer, kid: key.kid }, - ); - } - kids.add(key.kid); - return { kid: key.kid, jwk: validateJwk(key.jwk, key.kid, keyPath) }; - }); - - return { issuer: entry.issuer, audience: entry.audience, keys }; - }); -} - -// Accepts only a public P-256 key. Every member is checked at load rather than -// by the JOSE library at the first purchase, so a bad key fails the deploy -// instead of a buyer's valid mandate. -function validateJwk( - jwk: Record, - kid: string, - path: string, -): Readonly> { - const fail = (detail: string): never => { - throw ap2Invalid(`${path}.jwk`, `${path}.jwk (kid "${kid}") ${detail}`, { kid }); - }; - - for (const member of Object.keys(jwk)) { - if ((AP2_JWK_MEMBERS as readonly string[]).includes(member)) continue; - // `d` (an EC private scalar) or `k` (a symmetric key) means signing - // material was pasted where only verification material belongs, which the - // operator needs to hear about specifically - if (member === 'd' || member === 'k') { - fail( - `carries private key material ("${member}"). The gateway verifies signatures and never produces them; publish only the public half. Treat the pasted key as compromised and rotate it.`, - ); - } - fail( - `has unsupported member "${member}". Allowed: ${AP2_JWK_MEMBERS.join(', ')}. Members naming a URL are refused on purpose - keys are configured inline and never fetched.`, - ); - } - - const value = (member: string): string => { - const raw = jwk[member]; - if (typeof raw !== 'string' || raw.length === 0) { - fail(`must give "${member}" as a non-empty string`); - } - return raw as string; - }; - - if (value('kty') !== AP2_KEY_TYPE) { - fail( - `must have kty "${AP2_KEY_TYPE}" (got "${value('kty')}") - ${AP2_SIGNING_ALGORITHM} is the only accepted algorithm`, - ); - } - if (value('crv') !== AP2_JWK_CURVE) { - fail( - `must be on curve "${AP2_JWK_CURVE}" (got "${value('crv')}") - ${AP2_SIGNING_ALGORITHM} is the only accepted algorithm`, - ); - } - for (const coordinate of ['x', 'y'] as const) { - const encoded = value(coordinate); - if (!/^[A-Za-z0-9_-]+$/.test(encoded)) { - fail(`coordinate "${coordinate}" is not base64url (no padding, no "+" or "/")`); - } - if (Buffer.from(encoded, 'base64url').length !== AP2_JWK_COORDINATE_BYTES) { - fail( - `coordinate "${coordinate}" decodes to ${Buffer.from(encoded, 'base64url').length} bytes; a ${AP2_JWK_CURVE} coordinate is ${AP2_JWK_COORDINATE_BYTES}`, - ); - } - } - if (jwk['alg'] !== undefined && value('alg') !== AP2_SIGNING_ALGORITHM) { - fail(`declares alg "${value('alg')}"; only ${AP2_SIGNING_ALGORITHM} is accepted`); - } - if (jwk['use'] !== undefined && value('use') !== 'sig') { - fail(`declares use "${value('use')}"; a verification key must be "sig"`); - } - if (jwk['kid'] !== undefined && value('kid') !== kid) { - fail(`declares kid "${value('kid')}", which disagrees with the configured kid "${kid}"`); - } - - const normalized: Record = {}; - for (const member of AP2_JWK_MEMBERS) { - if (jwk[member] !== undefined) normalized[member] = value(member); - } - return normalized; -} - -// The AP2 replay store gets its own SQLite file. Reserved mandates, payment -// attempts and ACP idempotency records have different schemas and retention -// rules, and a shared file means a migration conflict at startup or a shared -// write lock on the settlement path. -function validateReplayStoreIsolated( - replayPath: string, - receiptsPath: string, - acp: AcpProtocolConfig, -): void { - // Every `:memory:` handle is its own private database, so equal strings do - // not collide - if (replayPath === ':memory:') return; - - const others: [string, string][] = [['storage.receipts.path', receiptsPath]]; - if (acp.enabled) others.push(['protocols.acp.idempotency.path', acp.idempotency.path]); - - for (const [otherPath, other] of others) { - if (replayPath !== other) continue; - throw ap2Invalid( - 'authorization.ap2.replay.path', - `authorization.ap2.replay.path is the same file as ${otherPath} ("${replayPath}") - the AP2 replay store keeps its own schema and must not share a database with another store`, - ); - } -} - -// Resolves a resource's `authorization.required` list against the configured -// provider. A requirement that cannot be enforced is refused: the resource -// would look protected in config and settle unprotected. -function normalizeResourceAuthorization( - id: string, - entry: RawResourceEntry, - pricing: Pricing, - ap2: Ap2AuthorizationConfig | undefined, -): CommerceResource['authorization'] | undefined { - const required = entry.authorization?.required; - if (required === undefined) return undefined; - - const path = `resources.${id}.authorization.required`; - const methods: AuthorizationMethodName[] = []; - for (const method of required) { - if (!SUPPORTED_AUTHORIZATION_METHODS.has(method)) { - throw ap2Invalid( - path, - `Resource "${id}" requires unsupported authorization method "${method}". Supported: ${[...SUPPORTED_AUTHORIZATION_METHODS].join(', ')}.`, - { resourceId: id, method }, - ); - } - if (methods.includes(method as AuthorizationMethodName)) { - throw ap2Invalid(path, `Resource "${id}" lists authorization method "${method}" twice`, { - resourceId: id, - method, - }); - } - if (method === 'ap2' && (ap2 === undefined || !ap2.enabled)) { - throw ap2Invalid( - path, - `Resource "${id}" requires authorization method "ap2", which is not configured or not enabled under authorization.ap2`, - { resourceId: id, method }, - ); - } - methods.push(method as AuthorizationMethodName); - } - - // A mandate binds an exact amount and currency and never unlocks a resource - // by itself, so it only means something on a fixed-price resource - if (pricing.type !== 'fixed') { - throw ap2Invalid( - path, - `Resource "${id}" requires authorization but its pricing is "${pricing.type}" - authorization proves a purchase was approved and never replaces payment, so it applies to fixed-price paid resources only in this release`, - { resourceId: id }, - ); - } - - return { required: methods }; -} - -// --------------------------------------------------------------------------- -// ACP (experimental) -// --------------------------------------------------------------------------- - -type RawAcp = NonNullable; -type EnabledAcpConfig = Extract; - -function acpInvalid( - path: string, - message: string, - extra: Record = {}, -): CommerceError { - return new CommerceError('CONFIG_INVALID', message, { details: { path, ...extra } }); -} - -function normalizeAcp(raw: RawAcp | undefined): AcpProtocolConfig { - const mountPath = raw?.mountPath ?? DEFAULT_ACP_MOUNT_PATH; - if (raw === undefined || !toBoolean(raw.enabled, 'protocols.acp.enabled')) { - return { enabled: false, mountPath }; - } - - if (raw.auth === undefined) { - throw acpInvalid( - 'protocols.acp.auth', - 'protocols.acp.auth is required when ACP is enabled - every ACP checkout endpoint is authenticated, and "bearer" is the only scheme supported in this release', - ); - } - if (raw.idempotency === undefined) { - throw acpInvalid( - 'protocols.acp.idempotency', - 'protocols.acp.idempotency is required when ACP is enabled - it names the SQLite file holding checkout idempotency records, and ACP makes the Idempotency-Key mandatory on every checkout POST', - ); - } - - const discovery = raw.discovery; - - return { - enabled: true, - mountPath, - auth: raw.auth, - idempotency: { - path: raw.idempotency.path, - retentionHours: toNumber( - raw.idempotency.retentionHours ?? ACP_RETENTION_HOURS, - 'protocols.acp.idempotency.retentionHours', - { min: ACP_RETENTION_HOURS }, - ), - merchantIdempotent: raw.idempotency.merchantIdempotent ?? false, - }, - checkout: { operations: normalizeAcpOperations(raw.checkout) }, - // Built key by key so absent metadata stays absent rather than `undefined` - ...(discovery !== undefined - ? { - discovery: { - ...(discovery.documentationUrl !== undefined - ? { documentationUrl: discovery.documentationUrl } - : {}), - ...(discovery.supportedCurrencies !== undefined - ? { supportedCurrencies: discovery.supportedCurrencies } - : {}), - ...(discovery.supportedLocales !== undefined - ? { supportedLocales: discovery.supportedLocales } - : {}), - ...(discovery.interventionTypes !== undefined - ? { interventionTypes: discovery.interventionTypes } - : {}), - }, - } - : {}), - }; -} - -// All five operations, each on its own resource. ACP discovery advertises -// `checkout` as one service, so a partial mapping is refused. Operations differ -// in method, input and success status, so one resource cannot serve two. -function normalizeAcpOperations( - checkout: RawAcp['checkout'], -): Readonly> { - const configured: Record = checkout?.operations ?? {}; - - for (const key of Object.keys(configured)) { - if (!(ACP_CHECKOUT_OPERATIONS as readonly string[]).includes(key)) { - throw acpInvalid( - `protocols.acp.checkout.operations.${key}`, - `protocols.acp.checkout.operations names unknown ACP checkout operation "${key}". Supported: ${ACP_CHECKOUT_OPERATIONS.join(', ')}.`, - ); - } - } - - const operations: Record = {}; - const claimedBy = new Map(); - for (const operation of ACP_CHECKOUT_OPERATIONS) { - const path = `protocols.acp.checkout.operations.${operation}`; - const resourceId = configured[operation]; - if (resourceId === undefined) { - throw acpInvalid( - path, - `${path} is required when ACP is enabled - all five checkout operations must be mapped, since ACP discovery advertises the checkout service as a whole`, - ); - } - const claimed = claimedBy.get(resourceId); - if (claimed !== undefined) { - throw acpInvalid( - path, - `${path} maps resource "${resourceId}", which is already mapped to "${claimed}" - each ACP checkout operation needs its own resource`, - { resourceId }, - ); - } - claimedBy.set(resourceId, operation); - operations[operation] = resourceId; - } - return operations as Record; -} - -function validateAcpCheckoutMapping( - acp: EnabledAcpConfig, - resources: readonly CommerceResource[], -): void { - const byId = new Map(resources.map((resource) => [resource.id, resource])); - - for (const operation of ACP_CHECKOUT_OPERATIONS) { - const resourceId = acp.checkout.operations[operation]; - const path = `protocols.acp.checkout.operations.${operation}`; - const resource = byId.get(resourceId); - if (resource === undefined) { - throw acpInvalid( - path, - `${path} maps resource "${resourceId}", which is not defined under "resources"`, - { resourceId }, - ); - } - if (!resource.exposedVia.includes('acp')) { - throw acpInvalid( - path, - `Resource "${resourceId}" implements ACP operation "${operation}" but does not list "acp" in its expose - the mapping says which resource serves the operation, expose says ACP may invoke it, and both are required`, - { resourceId }, - ); - } - // ACP checkout carries the merchant's own purchase payment (`payment_data` - // on completion), and ACP has no wire form for our payment-required - // outcome, so the invocation itself must be free - if (resource.pricing.type !== 'free' || resource.paymentMethods.length > 0) { - throw acpInvalid( - path, - `Resource "${resourceId}" implements ACP operation "${operation}" and must use pricing.type "free" with no "payments" - ACP checkout carries the merchant's own purchase payment, so the gateway does not also charge for the invocation`, - { resourceId }, - ); - } - validateAcpOperationInput(path, operation, resource); - } -} - -// A schema that forbids a key the adapter sends, or requires one it may not -// send, fails requests with INPUT_INVALID. Refused at load instead. An optional -// key counts as sent: a closed cancel schema without `body` would pass a bare -// cancel and refuse every one that carries `intent_trace`. Normalization has -// already closed every object node that omits additionalProperties, so the -// check also looks inside `path` and `body`: a bare `body: { type: object }` -// arrives here accepting no key at all. -function validateAcpOperationInput( - path: string, - operation: AcpCheckoutOperation, - resource: CommerceResource, -): void { - const schema = resource.inputSchema; - if (schema === undefined) return; - const keys: readonly string[] = ACP_OPERATION_INPUT_KEYS[operation]; - const optionalKeys: readonly string[] = ACP_OPERATION_OPTIONAL_INPUT_KEYS[operation] ?? []; - const sent = [...keys, ...optionalKeys]; - - const topLevel = closedObjectProperties(schema); - if (topLevel !== undefined) { - for (const key of sent) { - if (!Object.hasOwn(topLevel, key)) { - const when = keys.includes(key) ? 'always sends' : 'sends when the caller supplies it'; - throw acpInvalid( - path, - `Resource "${resource.id}" implements ACP operation "${operation}" but its input schema sets additionalProperties: false without declaring "${key}", which the adapter ${when} for this operation`, - { resourceId: resource.id }, - ); - } - } - } - - const declared = isRecord(schema['properties']) ? schema['properties'] : {}; - const pathKeys = closedObjectProperties(declared['path']); - if ( - sent.includes('path') && - pathKeys !== undefined && - !Object.hasOwn(pathKeys, ACP_SESSION_ID_INPUT_KEY) - ) { - throw acpInvalid( - path, - `Resource "${resource.id}" implements ACP operation "${operation}" but its "path" schema sets additionalProperties: false without declaring "${ACP_SESSION_ID_INPUT_KEY}", which the adapter always sends inside "path"`, - { resourceId: resource.id }, - ); - } - const bodyKeys = closedObjectProperties(declared['body']); - if (sent.includes('body') && bodyKeys !== undefined && Object.keys(bodyKeys).length === 0) { - throw acpInvalid( - path, - `Resource "${resource.id}" implements ACP operation "${operation}" but its "body" schema accepts no keys, so it refuses every ACP document. Config load closes an object schema that omits additionalProperties: declare body as { type: object, additionalProperties: true }`, - { resourceId: resource.id }, - ); - } - - const required = schema['required']; - if (Array.isArray(required)) { - for (const entry of required) { - if (typeof entry === 'string' && !keys.includes(entry)) { - throw acpInvalid( - path, - `Resource "${resource.id}" implements ACP operation "${operation}" but its input schema requires "${entry}", which that operation does not always supply (it always sends: ${keys.join(', ')})`, - { resourceId: resource.id }, - ); - } - } - } -} - -// The declared properties of a closed object schema, or undefined when the -// schema accepts keys it does not declare -function closedObjectProperties(schema: unknown): Record | undefined { - if (!isRecord(schema) || schema['additionalProperties'] !== false) return undefined; - return isRecord(schema['properties']) ? schema['properties'] : {}; -} - -interface NormalizedX402 { - readonly enabled: boolean; - readonly assetDecimals: number; -} - -function normalizeFacilitator(raw: z.infer): X402FacilitatorConfig { - return raw.mode === 'local' - ? { mode: 'local', signerPrivateKey: raw.signerPrivateKey } - : { mode: 'remote', url: raw.url, auth: raw.auth ?? { type: 'none' } }; -} - -type NormalizedMpp = NonNullable; - -function normalizeMpp(raw: RawConfig['payments']['mpp']): NormalizedMpp | undefined { - if (raw === undefined) return undefined; - const network = raw.network ?? MPP_DEFAULT_NETWORK; - if (!isMppNetwork(network)) { - throw new CommerceError( - 'CONFIG_INVALID', - `payments.mpp.network must be one of ${MPP_NETWORKS.join(', ')}`, - { details: { path: 'payments.mpp.network' } }, - ); - } - const mpp: NormalizedMpp = { - enabled: toBoolean(raw.enabled, 'payments.mpp.enabled'), - network, - rpcUrl: raw.rpcUrl, - asset: raw.asset, - assetName: raw.assetName, - assetVersion: raw.assetVersion, - recipient: raw.recipient, - realm: raw.realm, - challengeSecret: raw.challengeSecret, - ...(raw.challengeTtlSeconds !== undefined - ? { - challengeTtlSeconds: toNumber( - raw.challengeTtlSeconds, - 'payments.mpp.challengeTtlSeconds', - { min: 1 }, - ), - } - : {}), - paymentFlow: raw.paymentFlow ?? X402_DEFAULT_PAYMENT_FLOW, - facilitator: normalizeFacilitator(raw.facilitator), - ...(raw.allowMainnet !== undefined - ? { allowMainnet: toBoolean(raw.allowMainnet, 'payments.mpp.allowMainnet') } - : {}), - ...(raw.allowUnauthenticatedFacilitator !== undefined - ? { - allowUnauthenticatedFacilitator: toBoolean( - raw.allowUnauthenticatedFacilitator, - 'payments.mpp.allowUnauthenticatedFacilitator', - ), - } - : {}), - }; - validateAddress('payments.mpp.recipient', mpp.recipient); - validateAddress('payments.mpp.asset', mpp.asset); - // mppx refuses CR or LF in a quoted challenge parameter - if (/[\r\n]/.test(mpp.realm)) { - throw new CommerceError('CONFIG_INVALID', 'payments.mpp.realm must be a single line', { - details: { path: 'payments.mpp.realm' }, - }); - } - // Length only: the secret itself never appears in an error - if (mpp.challengeSecret.length < MPP_MIN_CHALLENGE_SECRET_LENGTH) { - throw new CommerceError( - 'CONFIG_INVALID', - `payments.mpp.challengeSecret must have length at least ${MPP_MIN_CHALLENGE_SECRET_LENGTH}`, - { details: { path: 'payments.mpp.challengeSecret' } }, - ); - } - // The shared x402 address, facilitator and mainnet guards, reported under the - // MPP config path - resolveX402Deployment({ - network: mpp.network, - payTo: mpp.recipient, - asset: mpp.asset, - assetName: mpp.assetName, - assetVersion: mpp.assetVersion, - facilitator: mpp.facilitator, - ...(mpp.allowMainnet !== undefined ? { allowMainnet: mpp.allowMainnet } : {}), - ...(mpp.allowUnauthenticatedFacilitator !== undefined - ? { allowUnauthenticatedFacilitator: mpp.allowUnauthenticatedFacilitator } - : {}), - configPath: 'payments.mpp', - payToField: 'recipient', - }); - return mpp; -} - -function normalizeResource( - id: string, - entry: RawResourceEntry, - protocols: NormalizedProtocols, - x402: NormalizedX402 | undefined, - mpp: NormalizedMpp | undefined, - ap2: Ap2AuthorizationConfig | undefined, -): CommerceResource { - if (entry.input !== undefined) validateResourceSchemaKeywords(id, 'input', entry.input); - - const inputProperties = entry.input?.['properties']; - if (inputProperties && typeof inputProperties === 'object') { - for (const reserved of RESERVED_INPUT_FIELDS) { - if (!(reserved in inputProperties)) continue; - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" declares an input property "${reserved}", which is reserved by the gateway`, - { - details: { - path: `resources.${id}.input.properties.${reserved}`, - resourceId: id, - }, - }, - ); - } - } - - for (const protocol of entry.expose) { - if (!SUPPORTED_PROTOCOLS.has(protocol)) { - const hint = protocol === 'ucp' ? ' (UCP is planned, not supported in this release)' : ''; - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" exposes unsupported protocol "${protocol}"${hint}. Supported: ${PROTOCOL_NAMES.join(', ')}.`, - { details: { path: `resources.${id}.expose`, resourceId: id, protocol } }, - ); - } - } - if (entry.expose.includes('mcp')) { - if (!protocols.mcp.enabled) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" is exposed via "mcp" but protocols.mcp.enabled is false`, - { details: { path: `resources.${id}.expose`, resourceId: id } }, - ); - } - if (!MCP_TOOL_NAME_PATTERN.test(id)) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" is exposed via "mcp" but its id is not a legal MCP tool name (allowed: A-Z, a-z, 0-9, "_", "-", ".", 1-128 chars)`, - { details: { path: `resources.${id}`, resourceId: id } }, - ); - } - } - if (entry.expose.includes('a2a') && !protocols.a2a.enabled) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" is exposed via "a2a" but protocols.a2a.enabled is false`, - { details: { path: `resources.${id}.expose`, resourceId: id } }, - ); - } - if (entry.expose.includes('acp') && !protocols.acp.enabled) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" is exposed via "acp" but protocols.acp.enabled is false`, - { details: { path: `resources.${id}.expose`, resourceId: id } }, - ); - } - if (entry.expose.includes('http') && !protocols.http.enabled) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" is exposed via "http" but protocols.http.enabled is false`, - { details: { path: `resources.${id}.expose`, resourceId: id } }, - ); - } - - validateBackendUrl(id, entry.backend.url); - validateBackendHeaders(id, entry.backend.headers); - const pathScope = validateInputBindings(id, entry.backend, entry.input); - validatePathParametersDeclared(id, entry.backend.url, pathScope.schema, pathScope.where); - - if (entry.pricing.type === 'dynamic') { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" uses pricing.type "dynamic", which is not supported in this release`, - { details: { path: `resources.${id}.pricing.type`, resourceId: id } }, - ); - } - - if (entry.pricing.type === 'fixed') { - validatePricingAmount(id, entry.pricing.amount, x402?.assetDecimals); - } - - const paymentMethods = entry.payments ?? []; - - if (entry.pricing.type === 'fixed') { - if (paymentMethods.length === 0) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" has fixed pricing but declares no "payments" (a paid resource must name at least one payment method)`, - { details: { path: `resources.${id}.payments`, resourceId: id } }, - ); - } - for (const method of paymentMethods) { - if (!SUPPORTED_PAYMENT_METHODS.has(method)) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" names unsupported payment method "${method}". Supported: ${PAYMENT_METHOD_NAMES.join(', ')}.`, - { details: { path: `resources.${id}.payments`, resourceId: id, method } }, - ); - } - } - // At least one named rail must be enabled, not every one. `main.ts` checks - // again at composition as defense in depth. - const enabledRails = [...(x402?.enabled ? ['x402'] : []), ...(mpp?.enabled ? ['mpp'] : [])]; - if (!paymentMethods.some((method) => enabledRails.includes(method))) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" names payment method(s) "${paymentMethods.join(', ')}", none of which is configured and enabled under "payments"`, - { details: { path: `resources.${id}.payments`, resourceId: id, paymentMethods } }, - ); - } - // MPP fixes the currency label and decimal precision for every resource it serves - if (mpp?.enabled && paymentMethods.includes('mpp')) { - if (entry.pricing.currency !== MPP_PROFILE.assetSymbol) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" is priced in ${entry.pricing.currency}, but MPP charges ${MPP_PROFILE.assetSymbol}`, - { details: { path: `resources.${id}.pricing.currency`, resourceId: id } }, - ); - } - validatePricingAmount(id, entry.pricing.amount, MPP_PROFILE.assetDecimals); - } - } - - if ( - entry.paymentFlow !== undefined && - (entry.pricing.type !== 'fixed' || - !paymentMethods.some((method) => method === 'x402' || method === 'mpp')) - ) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" sets paymentFlow, which requires fixed pricing and "x402" or "mpp" in payments`, - { details: { path: `resources.${id}.paymentFlow`, resourceId: id } }, - ); - } - - const pricing: Pricing = - entry.pricing.type === 'free' - ? { type: 'free' } - : { type: 'fixed', amount: entry.pricing.amount, currency: entry.pricing.currency }; - - const authorization = normalizeResourceAuthorization(id, entry, pricing, ap2); - - return { - id, - name: entry.name, - ...(entry.description !== undefined ? { description: entry.description } : {}), - inputSchema: - entry.input !== undefined - ? defaultClosedObjectSchema(entry.input) - : EMPTY_CLOSED_OBJECT_SCHEMA, - ...(entry.output !== undefined ? { outputSchema: entry.output } : {}), - handler: { - type: 'http', - method: entry.backend.method, - url: entry.backend.url, - ...(entry.backend.headers !== undefined ? { headers: entry.backend.headers } : {}), - ...(entry.backend.timeoutMs !== undefined - ? { - timeoutMs: toNumber(entry.backend.timeoutMs, `resources.${id}.backend.timeoutMs`, { - min: 1, - }), - } - : {}), - ...(entry.backend.inputBindings !== undefined - ? { inputBindings: pickDefined(entry.backend.inputBindings) } - : {}), - }, - pricing, - exposedVia: entry.expose as CommerceResource['exposedVia'], - paymentMethods: paymentMethods as CommerceResource['paymentMethods'], - ...(authorization !== undefined ? { authorization } : {}), - }; -} - -/** - * Closes every object schema at every depth: a node without - * `additionalProperties` gets `false`, so unknown keys never reach the - * merchant's API. An explicit value, including `true`, is kept. - * - * JSON Schema itself defaults to open, and core's validator enforces - * `additionalProperties` only where a node states it, so this recurses into - * `properties`, `items` and schema-valued `additionalProperties`, the same - * places the validator descends. Object-ness comes from `isObjectSchemaNode`, - * the validator's own definition. - */ -function defaultClosedObjectSchema(schema: Record): Record { - const isObjectSchema = isObjectSchemaNode(schema); - const result: Record = { ...schema }; - - if (isObjectSchema) { - const properties = schema['properties']; - if (isRecord(properties)) { - result['properties'] = Object.fromEntries( - Object.entries(properties).map(([key, sub]) => [ - key, - isRecord(sub) ? defaultClosedObjectSchema(sub) : sub, - ]), - ); - } - if (!Object.hasOwn(schema, 'additionalProperties')) { - result['additionalProperties'] = false; - } - } - - const items = schema['items']; - if (isRecord(items)) { - result['items'] = defaultClosedObjectSchema(items); - } - - // `additionalProperties: {schema}` (a map of typed objects) is closed too. - // Not conditional on `isObjectSchema`: the validator applies the subschema - // wherever it finds one. - const additional = schema['additionalProperties']; - if (isRecord(additional)) { - result['additionalProperties'] = defaultClosedObjectSchema(additional); - } - - return result; -} - -// The input schema of a resource that declares no `input:`. Declaring nothing -// accepts nothing: without this, `compileJsonSchema(undefined)` would accept -// any input and forward every key to the backend. A no-argument resource -// omitting `input:` is a legitimate shape, so it is not refused. -const EMPTY_CLOSED_OBJECT_SCHEMA: Record = { - type: 'object', - properties: {}, - additionalProperties: false, -}; - -// Keywords core's validator ignores (see `execution/validation.ts` for the -// enforced subset). Config warns at load, since an operator who writes -// `pattern` cannot otherwise tell that it is not enforced. -const UNSUPPORTED_SCHEMA_KEYWORDS = [ - 'pattern', - 'minLength', - 'maxLength', - 'format', - 'minimum', - 'maximum', - 'exclusiveMinimum', - 'exclusiveMaximum', - 'multipleOf', - 'oneOf', - 'anyOf', - 'allOf', - 'not', - '$ref', - 'const', - 'patternProperties', - 'minItems', - 'maxItems', - 'uniqueItems', -]; - -// `type` set to something that rules out "object". No `type` at all does not -// exclude it: the validator treats `properties`/`required` alone as an object -// schema. -function excludesObjectType(schema: Record): boolean { - const type = schema['type']; - if (type === undefined || type === 'object') return false; - if (Array.isArray(type)) return !type.includes('object'); - return true; -} - -/** - * Walks a resource's input schema once. Warns about unenforced keywords and - * tuple `items`. Rejects a node whose `type` rules out "object" while it - * declares `properties` or `required`, which then go unchecked (often a - * copy-pasted sibling whose `type` was never updated), and a closed node - * requiring a property it never declares, which no input can satisfy. - */ -function validateResourceSchemaKeywords( - id: string, - path: string, - schema: Record, -): void { - for (const keyword of UNSUPPORTED_SCHEMA_KEYWORDS) { - if (Object.hasOwn(schema, keyword)) { - console.warn( - `[agent-commerce] resource "${id}" ${path} uses JSON Schema keyword "${keyword}", which this gateway does not enforce (see src/core/execution/validation.ts for the supported subset). Remove it or treat it as documentation only.`, - ); - } - } - if ( - (Object.hasOwn(schema, 'properties') || Object.hasOwn(schema, 'required')) && - excludesObjectType(schema) - ) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" ${path} declares "properties" and/or "required" but its "type" (${JSON.stringify(schema['type'])}) does not include "object" - the validator will never route a value through either check under that type, so this schema claims to constrain input it does not actually enforce. Remove "properties"/"required", or include "object" in "type".`, - { details: { path: `resources.${id}.${path}`, resourceId: id } }, - ); - } - // A closed node that requires a property it never declares rejects that - // property as unknown, so no input can satisfy it and every call would 400. - // Only when the node is closed: an explicit `additionalProperties` - // other than `false` leaves the name reachable. - const requiredRaw = schema['required']; - const closed = !Object.hasOwn(schema, 'additionalProperties') - ? true - : schema['additionalProperties'] === false; - if (Array.isArray(requiredRaw) && closed && isObjectSchemaNode(schema)) { - const declared = isRecord(schema['properties']) ? schema['properties'] : {}; - const undeclared = requiredRaw.filter( - (name): name is string => typeof name === 'string' && !Object.hasOwn(declared, name), - ); - if (undeclared.length > 0) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" ${path} lists ${undeclared.map((n) => `"${n}"`).join(', ')} in "required" but does not declare ${undeclared.length === 1 ? 'it' : 'them'} in "properties". The schema is closed (additionalProperties: false), so ${undeclared.length === 1 ? 'that name is' : 'those names are'} rejected as an unknown key and no input can ever satisfy this schema. Declare ${undeclared.length === 1 ? 'it' : 'them'} in "properties".`, - { details: { path: `resources.${id}.${path}`, resourceId: id, undeclared } }, - ); - } - } - - const properties = schema['properties']; - if (isRecord(properties)) { - for (const [key, sub] of Object.entries(properties)) { - if (isRecord(sub)) validateResourceSchemaKeywords(id, `${path}.properties.${key}`, sub); - } - } - const additional = schema['additionalProperties']; - if (isRecord(additional)) { - validateResourceSchemaKeywords(id, `${path}.additionalProperties`, additional); - } - const items = schema['items']; - if (isRecord(items)) { - validateResourceSchemaKeywords(id, `${path}.items`, items); - } else if (Array.isArray(items)) { - // Tuple-form `items` is valid JSON Schema, but the validator supports only - // one schema for every element, so a tuple enforces nothing - console.warn( - `[agent-commerce] resource "${id}" ${path}.items is a tuple (an array of schemas), which this gateway does not enforce - only a single schema applied to every array element is supported (see src/core/execution/validation.ts). Each position's schema is unenforced; treat it as documentation only.`, - ); - } -} - -// A plain decimal: no currency symbol, thousands separator, sign or exponent. -// "0,01", "$0.01", "1e-2" and "-1" would otherwise load and then fail every -// purchase. -const PRICING_AMOUNT_PATTERN = /^\d+(?:\.\d+)?$/; -const ZERO_AMOUNT_PATTERN = /^0(?:\.0+)?$/; - -function validatePricingAmount(id: string, amount: string, decimals: number | undefined): void { - const path = `resources.${id}.pricing.amount`; - if (!PRICING_AMOUNT_PATTERN.test(amount)) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" has pricing.amount "${amount}", which is not a plain positive decimal (no currency symbol, no thousands separator, no exponent - e.g. "0.01")`, - { details: { path, resourceId: id } }, - ); - } - if (ZERO_AMOUNT_PATTERN.test(amount)) { - // A zero-priced paid resource would settle a zero-value transfer - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" has pricing.amount "0" - a paid resource cannot cost zero; use "pricing: { type: free }" instead`, - { details: { path, resourceId: id } }, - ); - } - if (decimals !== undefined) { - const fractionalDigits = amount.includes('.') ? (amount.split('.')[1]?.length ?? 0) : 0; - if (fractionalDigits > decimals) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" has pricing.amount "${amount}" with more precision (${fractionalDigits} fractional digits) than the configured asset supports (${decimals} decimals)`, - { details: { path, resourceId: id } }, - ); - } - } -} - -/** - * Checks `backend.inputBindings` against the input schema at load. Schemas are - * closed by default at every depth, so a binding naming an undeclared property - * could never be supplied, and on a paid resource the backend would get an - * incomplete request after payment. - * - * Returns the schema node `{param}` names must be declared in: the path group - * with bindings, the whole input without. - */ -function validateInputBindings( - id: string, - backend: RawResourceEntry['backend'], - input: Record | undefined, -): { readonly schema: Record | undefined; readonly where: string } { - const legacy = { schema: input, where: 'its input schema' } as const; - const bindings = backend.inputBindings; - const templated = extractPathParameterNames(backend.url).length > 0; - if (bindings === undefined) return legacy; - - const path = `resources.${id}.backend.inputBindings`; - const fail = (message: string, details: Record = {}): never => { - throw new CommerceError('CONFIG_INVALID', `Resource "${id}" ${message}`, { - details: { path, resourceId: id, ...details }, - }); - }; - - const entries = Object.entries(bindings).filter( - (entry): entry is [string, string] => entry[1] !== undefined, - ); - if (entries.length === 0) { - fail('has an empty backend.inputBindings - remove the block to use the default mapping'); - } - if (backend.method === 'GET' || backend.method === 'DELETE') { - if (bindings.body !== undefined) { - fail( - `binds a request body on a ${backend.method}, which sends none - the value would be silently dropped`, - ); - } - } - if (templated && bindings.path === undefined) { - fail( - 'has backend.url path parameters but no "path" binding - in explicit binding mode nothing else supplies them, so every call would fail to reach the backend', - ); - } - - const seen = new Map(); - const properties = isRecord(input?.['properties']) ? input['properties'] : {}; - const required = new Set( - Array.isArray(input?.['required']) - ? input['required'].filter((value): value is string => typeof value === 'string') - : [], - ); - - for (const [location, property] of entries) { - if (RESERVED_INPUT_FIELDS.includes(property)) { - fail(`binds "${location}" to "${property}", which is reserved by the gateway`, { location }); - } - const other = seen.get(property); - if (other !== undefined) { - fail(`binds both "${other}" and "${location}" to the input property "${property}"`, { - location, - property, - }); - } - seen.set(property, location); - - if (!Object.hasOwn(properties, property)) { - fail( - `binds "${location}" to input property "${property}", which the input schema does not declare - the schema is closed, so a caller could never supply it`, - { location, property }, - ); - } - // `body` may be any JSON value; only the path and query groups are read as - // name/value pairs - const declared = properties[property]; - if (location !== 'body' && isRecord(declared) && !isObjectSchemaNode(declared)) { - fail( - `binds "${location}" to input property "${property}", which is not an object schema - ${location} parameters are read as an object of name/value pairs`, - { location, property }, - ); - } - } - - if (templated && bindings.path !== undefined && !required.has(bindings.path)) { - fail( - `binds path parameters to input property "${bindings.path}" without listing it in the input schema's "required" - a caller that omits it cannot supply any path parameter, so the request could never be built`, - { property: bindings.path }, - ); - } - - if (bindings.path === undefined) return { schema: undefined, where: 'its input schema' }; - const group = properties[bindings.path]; - return { - schema: isRecord(group) ? group : undefined, - where: `input.properties.${bindings.path}`, - }; -} - -// Strips absent optional keys so the result satisfies `exactOptionalPropertyTypes` -function pickDefined>( - value: T, -): { [K in keyof T]?: string } { - return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as { - [K in keyof T]?: string; - }; -} - -/** - * Every `{param}` in `backend.url` must be declared in `properties` and listed - * in `required`. Otherwise a call can arrive without it, and core's - * `validateBackendRequestShape` rejects the request, which for a schema that - * never declares the parameter means every call. Checked at load so - * `validate` and `doctor` report it; the runtime check covers a hand-built - * `CommerceResource`. - */ -function validatePathParametersDeclared( - id: string, - url: string, - input: Record | undefined, - where: string, -): void { - // A brace token the grammar does not recognize is neither extracted nor - // substituted, so it would reach the backend as a percent-encoded literal - const stray = findUnparsedBraceToken(url); - if (stray !== undefined) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" has backend.url containing "${stray}", which is not a valid path parameter (allowed characters: A-Z a-z 0-9 _ . -). It would reach the backend as literal text, so a paid resource would take payment for a request the backend cannot serve`, - { details: { path: `resources.${id}.backend.url`, resourceId: id, token: stray } }, - ); - } - - const params = extractPathParameterNames(url); - if (params.length === 0) return; - - // Everything before the first `{` must be a complete origin followed by the - // path's leading `/`. In the host, every other `{param}` defense fails open: - // `http://{host}/api` parses, the runtime prefix check skips a prefix that - // is not a URL, and `encodeURIComponent` keeps dots. Caller input would pick - // the host the gateway calls, such as a metadata service or an internal - // address, and `http://{region}.api.internal/...` looks ordinary enough to - // be written by mistake. - const firstBrace = url.indexOf('{'); - if (firstBrace !== -1) { - const prefix = url.slice(0, firstBrace); - let origin: string | undefined; - try { - const parsedPrefix = new URL(prefix); - origin = parsedPrefix.hostname === '' ? undefined : parsedPrefix.origin; - } catch { - // Not a URL: the parameter starts before the authority is complete - } - if (origin === undefined || !prefix.startsWith(`${origin}/`)) { - // The URL is not quoted: it may carry a key substituted from the environment - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" has a backend.url parameter before the end of the host. A caller-supplied value would choose which host the gateway calls, which is request forgery with the gateway's own network position. Parameters are supported inside the path and query only`, - { details: { path: `resources.${id}.backend.url`, resourceId: id } }, - ); - } - } - - const propertiesRaw = input?.['properties']; - const properties = isRecord(propertiesRaw) ? propertiesRaw : {}; - const requiredRaw = input?.['required']; - const required = new Set( - Array.isArray(requiredRaw) - ? requiredRaw.filter((entry): entry is string => typeof entry === 'string') - : [], - ); - - for (const param of params) { - const path = `resources.${id}.backend.url`; - if (!Object.hasOwn(properties, param)) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" has backend.url path parameter "{${param}}" which is not declared in ${where} - the caller has no way to supply it, so every call would be refused`, - { details: { path, resourceId: id, param } }, - ); - } - if (!required.has(param)) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" has backend.url path parameter "{${param}}" declared in ${where} but not listed in its "required" - a call that omits it would be refused`, - { details: { path, resourceId: id, param } }, - ); - } - } -} - -// `{param}` templates parse as URLs (the WHATWG parser percent-encodes the -// braces), so this refuses only malformed URLs and non-http(s) schemes -function validateBackendUrl(id: string, url: string): void { - let parsed: URL; - try { - parsed = new URL(url); - } catch { - // The URL is not quoted: it may carry a key substituted from the environment - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" has an invalid backend.url: must be an absolute http:// or https:// URL`, - { details: { path: `resources.${id}.backend.url`, resourceId: id } }, - ); - } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" has a backend.url with scheme "${parsed.protocol}": must be http:// or https://`, - { details: { path: `resources.${id}.backend.url`, resourceId: id } }, - ); - } -} - -// Checked with fetch's own `Headers` rules. Otherwise an illegal name, or a -// substituted value with a line break inside it, would load cleanly and then -// refuse every call. The error names the key, never the value, which may be a -// credential. -function validateBackendHeaders(id: string, headers: Record | undefined): void { - for (const [name, value] of Object.entries(headers ?? {})) { - try { - new Headers([[name, value]]); - } catch { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" has backend header "${name}" with an illegal name or value (a name must be an HTTP token; a value must not contain NUL, a character above U+00FF, or a line break except at either end, which fetch trims)`, - { details: { path: `resources.${id}.backend.headers.${name}`, resourceId: id } }, - ); - } - } -} - -function validateAddress(path: string, value: string): void { - if (!ADDRESS_PATTERN.test(value)) { - throw new CommerceError( - 'CONFIG_INVALID', - `Configuration value at "${path}" is not a plausible address (expected "0x" followed by 40 hex characters)`, - { details: { path } }, - ); - } - if (ZERO_ADDRESS_PATTERN.test(value)) { - throw new CommerceError( - 'CONFIG_INVALID', - `Configuration value at "${path}" must not be the zero address`, - { - details: { path }, - }, - ); - } -} diff --git a/src/config/schema/acp.ts b/src/config/schema/acp.ts new file mode 100644 index 0000000..709231c --- /dev/null +++ b/src/config/schema/acp.ts @@ -0,0 +1,245 @@ +// The ACP checkout adapter's settings and its operation-to-resource mapping +import { CommerceError, type CommerceResource } from '../../core'; +import { isRecord } from '../../core/is-record'; +import { + ACP_CHECKOUT_OPERATIONS, + ACP_OPERATION_INPUT_KEYS, + ACP_OPERATION_OPTIONAL_INPUT_KEYS, + ACP_SESSION_ID_INPUT_KEY, + type AcpCheckoutOperation, +} from '../../protocols/acp/constants'; +import type { RawConfig } from './raw'; +import type { AcpProtocolConfig } from './types'; +import { toBoolean, toNumber } from './values'; + +// Applied when `protocols.acp` is absent or names no mount +const DEFAULT_ACP_MOUNT_PATH = '/acp'; + +// Idempotency records must outlive ACP's 24-hour retry window, or a replayed +// key could pass an expired record and run a checkout side effect twice. It is +// both the default and the floor. +const ACP_RETENTION_HOURS = 24; + +type RawAcp = NonNullable; + +type EnabledAcpConfig = Extract; + +function acpInvalid( + path: string, + message: string, + extra: Record = {}, +): CommerceError { + return new CommerceError('CONFIG_INVALID', message, { details: { path, ...extra } }); +} + +export function normalizeAcp(raw: RawAcp | undefined): AcpProtocolConfig { + const mountPath = raw?.mountPath ?? DEFAULT_ACP_MOUNT_PATH; + if (raw === undefined || !toBoolean(raw.enabled, 'protocols.acp.enabled')) { + return { enabled: false, mountPath }; + } + + if (raw.auth === undefined) { + throw acpInvalid( + 'protocols.acp.auth', + 'protocols.acp.auth is required when ACP is enabled - every ACP checkout endpoint is authenticated, and "bearer" is the only scheme supported in this release', + ); + } + if (raw.idempotency === undefined) { + throw acpInvalid( + 'protocols.acp.idempotency', + 'protocols.acp.idempotency is required when ACP is enabled - it names the SQLite file holding checkout idempotency records, and ACP makes the Idempotency-Key mandatory on every checkout POST', + ); + } + + const discovery = raw.discovery; + + return { + enabled: true, + mountPath, + auth: raw.auth, + idempotency: { + path: raw.idempotency.path, + retentionHours: toNumber( + raw.idempotency.retentionHours ?? ACP_RETENTION_HOURS, + 'protocols.acp.idempotency.retentionHours', + { min: ACP_RETENTION_HOURS }, + ), + merchantIdempotent: raw.idempotency.merchantIdempotent ?? false, + }, + checkout: { operations: normalizeAcpOperations(raw.checkout) }, + // Built key by key so absent metadata stays absent rather than `undefined` + ...(discovery !== undefined + ? { + discovery: { + ...(discovery.documentationUrl !== undefined + ? { documentationUrl: discovery.documentationUrl } + : {}), + ...(discovery.supportedCurrencies !== undefined + ? { supportedCurrencies: discovery.supportedCurrencies } + : {}), + ...(discovery.supportedLocales !== undefined + ? { supportedLocales: discovery.supportedLocales } + : {}), + ...(discovery.interventionTypes !== undefined + ? { interventionTypes: discovery.interventionTypes } + : {}), + }, + } + : {}), + }; +} + +// All five operations, each on its own resource. ACP discovery advertises +// `checkout` as one service, so a partial mapping is refused. Operations differ +// in method, input and success status, so one resource cannot serve two. +function normalizeAcpOperations( + checkout: RawAcp['checkout'], +): Readonly> { + const configured: Record = checkout?.operations ?? {}; + + for (const key of Object.keys(configured)) { + if (!(ACP_CHECKOUT_OPERATIONS as readonly string[]).includes(key)) { + throw acpInvalid( + `protocols.acp.checkout.operations.${key}`, + `protocols.acp.checkout.operations names unknown ACP checkout operation "${key}". Supported: ${ACP_CHECKOUT_OPERATIONS.join(', ')}.`, + ); + } + } + + const operations: Record = {}; + const claimedBy = new Map(); + for (const operation of ACP_CHECKOUT_OPERATIONS) { + const path = `protocols.acp.checkout.operations.${operation}`; + const resourceId = configured[operation]; + if (resourceId === undefined) { + throw acpInvalid( + path, + `${path} is required when ACP is enabled - all five checkout operations must be mapped, since ACP discovery advertises the checkout service as a whole`, + ); + } + const claimed = claimedBy.get(resourceId); + if (claimed !== undefined) { + throw acpInvalid( + path, + `${path} maps resource "${resourceId}", which is already mapped to "${claimed}" - each ACP checkout operation needs its own resource`, + { resourceId }, + ); + } + claimedBy.set(resourceId, operation); + operations[operation] = resourceId; + } + return operations as Record; +} + +export function validateAcpCheckoutMapping( + acp: EnabledAcpConfig, + resources: readonly CommerceResource[], +): void { + const byId = new Map(resources.map((resource) => [resource.id, resource])); + + for (const operation of ACP_CHECKOUT_OPERATIONS) { + const resourceId = acp.checkout.operations[operation]; + const path = `protocols.acp.checkout.operations.${operation}`; + const resource = byId.get(resourceId); + if (resource === undefined) { + throw acpInvalid( + path, + `${path} maps resource "${resourceId}", which is not defined under "resources"`, + { resourceId }, + ); + } + if (!resource.exposedVia.includes('acp')) { + throw acpInvalid( + path, + `Resource "${resourceId}" implements ACP operation "${operation}" but does not list "acp" in its expose - the mapping says which resource serves the operation, expose says ACP may invoke it, and both are required`, + { resourceId }, + ); + } + // ACP checkout carries the merchant's own purchase payment (`payment_data` + // on completion), and ACP has no wire form for our payment-required + // outcome, so the invocation itself must be free + if (resource.pricing.type !== 'free' || resource.paymentMethods.length > 0) { + throw acpInvalid( + path, + `Resource "${resourceId}" implements ACP operation "${operation}" and must use pricing.type "free" with no "payments" - ACP checkout carries the merchant's own purchase payment, so the gateway does not also charge for the invocation`, + { resourceId }, + ); + } + validateAcpOperationInput(path, operation, resource); + } +} + +// A schema that forbids a key the adapter sends, or requires one it may not +// send, fails requests with INPUT_INVALID. Refused at load instead. An optional +// key counts as sent: a closed cancel schema without `body` would pass a bare +// cancel and refuse every one that carries `intent_trace`. Normalization has +// already closed every object node that omits additionalProperties, so the +// check also looks inside `path` and `body`: a bare `body: { type: object }` +// arrives here accepting no key at all. +function validateAcpOperationInput( + path: string, + operation: AcpCheckoutOperation, + resource: CommerceResource, +): void { + const schema = resource.inputSchema; + if (schema === undefined) return; + const keys: readonly string[] = ACP_OPERATION_INPUT_KEYS[operation]; + const optionalKeys: readonly string[] = ACP_OPERATION_OPTIONAL_INPUT_KEYS[operation] ?? []; + const sent = [...keys, ...optionalKeys]; + + const topLevel = closedObjectProperties(schema); + if (topLevel !== undefined) { + for (const key of sent) { + if (!Object.hasOwn(topLevel, key)) { + const when = keys.includes(key) ? 'always sends' : 'sends when the caller supplies it'; + throw acpInvalid( + path, + `Resource "${resource.id}" implements ACP operation "${operation}" but its input schema sets additionalProperties: false without declaring "${key}", which the adapter ${when} for this operation`, + { resourceId: resource.id }, + ); + } + } + } + + const declared = isRecord(schema['properties']) ? schema['properties'] : {}; + const pathKeys = closedObjectProperties(declared['path']); + if ( + sent.includes('path') && + pathKeys !== undefined && + !Object.hasOwn(pathKeys, ACP_SESSION_ID_INPUT_KEY) + ) { + throw acpInvalid( + path, + `Resource "${resource.id}" implements ACP operation "${operation}" but its "path" schema sets additionalProperties: false without declaring "${ACP_SESSION_ID_INPUT_KEY}", which the adapter always sends inside "path"`, + { resourceId: resource.id }, + ); + } + const bodyKeys = closedObjectProperties(declared['body']); + if (sent.includes('body') && bodyKeys !== undefined && Object.keys(bodyKeys).length === 0) { + throw acpInvalid( + path, + `Resource "${resource.id}" implements ACP operation "${operation}" but its "body" schema accepts no keys, so it refuses every ACP document. Config load closes an object schema that omits additionalProperties: declare body as { type: object, additionalProperties: true }`, + { resourceId: resource.id }, + ); + } + + const required = schema['required']; + if (Array.isArray(required)) { + for (const entry of required) { + if (typeof entry === 'string' && !keys.includes(entry)) { + throw acpInvalid( + path, + `Resource "${resource.id}" implements ACP operation "${operation}" but its input schema requires "${entry}", which that operation does not always supply (it always sends: ${keys.join(', ')})`, + { resourceId: resource.id }, + ); + } + } + } +} + +// The declared properties of a closed object schema, or undefined when the +// schema accepts keys it does not declare +function closedObjectProperties(schema: unknown): Record | undefined { + if (!isRecord(schema) || schema['additionalProperties'] !== false) return undefined; + return isRecord(schema['properties']) ? schema['properties'] : {}; +} diff --git a/src/config/schema/ap2.ts b/src/config/schema/ap2.ts new file mode 100644 index 0000000..a62fd4e --- /dev/null +++ b/src/config/schema/ap2.ts @@ -0,0 +1,291 @@ +// AP2 mandate verification settings and each resource's authorization requirement +import type { z } from 'zod'; +import { + AP2_DEFAULT_CLOCK_SKEW_SECONDS, + AP2_JWK_COORDINATE_BYTES, + AP2_JWK_CURVE, + AP2_JWK_MEMBERS, + AP2_KEY_TYPE, + AP2_MAX_CLOCK_SKEW_SECONDS, + AP2_MODES, + AP2_SIGNING_ALGORITHM, + AP2_SPEC_VERSION, +} from '../../authorization/ap2/constants'; +import type { + Ap2AuthorizationConfig, + Ap2Mode, + Ap2TrustedIssuer, +} from '../../authorization/ap2/types'; +import { + type AuthorizationMethodName, + CommerceError, + type CommerceResource, + type Pricing, +} from '../../core'; +import type { Ap2IssuerSchema, RawConfig, RawResourceEntry } from './raw'; +import type { AcpProtocolConfig } from './types'; +import { toBoolean, toNumber } from './values'; + +const SUPPORTED_AUTHORIZATION_METHODS: ReadonlySet = new Set(['ap2']); + +type RawAp2 = NonNullable['ap2']>; + +function ap2Invalid( + path: string, + message: string, + extra: Record = {}, +): CommerceError { + return new CommerceError('CONFIG_INVALID', message, { details: { path, ...extra } }); +} + +export function normalizeAp2(raw: RawAp2 | undefined): Ap2AuthorizationConfig | undefined { + if (raw === undefined) return undefined; + if (!toBoolean(raw.enabled, 'authorization.ap2.enabled')) return { enabled: false }; + + const specVersion = raw.specVersion ?? AP2_SPEC_VERSION; + if (specVersion !== AP2_SPEC_VERSION) { + throw ap2Invalid( + 'authorization.ap2.specVersion', + `authorization.ap2.specVersion "${specVersion}" is not supported - this gateway verifies against the tagged AP2 release ${AP2_SPEC_VERSION} only`, + ); + } + + const mode = raw.mode ?? AP2_MODES[0]; + if (!(AP2_MODES as readonly string[]).includes(mode)) { + throw ap2Invalid( + 'authorization.ap2.mode', + `authorization.ap2.mode "${mode}" is not supported. Supported: ${AP2_MODES.join(', ')}. Autonomous mode needs open mandates, agent key binding and constraint evaluation, none of which this release implements.`, + ); + } + + if (raw.replay === undefined) { + throw ap2Invalid( + 'authorization.ap2.replay', + 'authorization.ap2.replay is required when AP2 is enabled - it names the SQLite file recording which mandates have been spent, and without it a verified mandate could authorize a second settlement', + ); + } + + const mandateIssuers = normalizeIssuers( + raw.trust?.mandateIssuers, + 'authorization.ap2.trust.mandateIssuers', + ); + const checkoutIssuers = normalizeIssuers( + raw.trust?.checkoutIssuers, + 'authorization.ap2.trust.checkoutIssuers', + ); + + return { + enabled: true, + specVersion: AP2_SPEC_VERSION, + mode: mode as Ap2Mode, + trust: { mandateIssuers, checkoutIssuers }, + clockSkewSeconds: toNumber( + raw.clockSkewSeconds ?? AP2_DEFAULT_CLOCK_SKEW_SECONDS, + 'authorization.ap2.clockSkewSeconds', + { min: 0, max: AP2_MAX_CLOCK_SKEW_SECONDS }, + ), + requireMandateAudience: toBoolean( + raw.requireMandateAudience ?? false, + 'authorization.ap2.requireMandateAudience', + ), + requireMandateExpiry: toBoolean( + raw.requireMandateExpiry ?? false, + 'authorization.ap2.requireMandateExpiry', + ), + replay: { path: raw.replay.path }, + }; +} + +// Both issuer lists must be non-empty when AP2 is on: verification checks the +// issuer's signature over the Checkout Mandate and the merchant's over the +// checkout JWT it binds. An empty list would fail every mandate at request +// time instead of at load. +function normalizeIssuers( + raw: readonly z.infer[] | undefined, + path: string, +): readonly Ap2TrustedIssuer[] { + if (raw === undefined || raw.length === 0) { + throw ap2Invalid( + path, + `${path} is required when AP2 is enabled and must name at least one issuer - trust is operator-configured and static, so an empty list means no mandate can ever verify`, + ); + } + + const seen = new Set(); + return raw.map((entry, index) => { + const entryPath = `${path}.${index}`; + if (seen.has(entry.issuer)) { + throw ap2Invalid( + `${entryPath}.issuer`, + `${entryPath}.issuer "${entry.issuer}" is listed twice - key lookup resolves by issuer, so a second entry for the same one is silently unreachable. Merge the keys into one entry instead (which is also how a rotation overlaps an old and a new key).`, + { issuer: entry.issuer }, + ); + } + seen.add(entry.issuer); + + const kids = new Set(); + const keys = entry.keys.map((key, keyIndex) => { + const keyPath = `${entryPath}.keys.${keyIndex}`; + if (kids.has(key.kid)) { + throw ap2Invalid( + `${keyPath}.kid`, + `${keyPath}.kid "${key.kid}" is listed twice for issuer "${entry.issuer}" - a kid selects exactly one key, so which of the two verifies a signature would be undefined`, + { issuer: entry.issuer, kid: key.kid }, + ); + } + kids.add(key.kid); + return { kid: key.kid, jwk: validateJwk(key.jwk, key.kid, keyPath) }; + }); + + return { issuer: entry.issuer, audience: entry.audience, keys }; + }); +} + +// Accepts only a public P-256 key. Every member is checked at load rather than +// by the JOSE library at the first purchase, so a bad key fails the deploy +// instead of a buyer's valid mandate. +function validateJwk( + jwk: Record, + kid: string, + path: string, +): Readonly> { + const fail = (detail: string): never => { + throw ap2Invalid(`${path}.jwk`, `${path}.jwk (kid "${kid}") ${detail}`, { kid }); + }; + + for (const member of Object.keys(jwk)) { + if ((AP2_JWK_MEMBERS as readonly string[]).includes(member)) continue; + // `d` (an EC private scalar) or `k` (a symmetric key) means signing + // material was pasted where only verification material belongs, which the + // operator needs to hear about specifically + if (member === 'd' || member === 'k') { + fail( + `carries private key material ("${member}"). The gateway verifies signatures and never produces them; publish only the public half. Treat the pasted key as compromised and rotate it.`, + ); + } + fail( + `has unsupported member "${member}". Allowed: ${AP2_JWK_MEMBERS.join(', ')}. Members naming a URL are refused on purpose - keys are configured inline and never fetched.`, + ); + } + + const value = (member: string): string => { + const raw = jwk[member]; + if (typeof raw !== 'string' || raw.length === 0) { + fail(`must give "${member}" as a non-empty string`); + } + return raw as string; + }; + + if (value('kty') !== AP2_KEY_TYPE) { + fail( + `must have kty "${AP2_KEY_TYPE}" (got "${value('kty')}") - ${AP2_SIGNING_ALGORITHM} is the only accepted algorithm`, + ); + } + if (value('crv') !== AP2_JWK_CURVE) { + fail( + `must be on curve "${AP2_JWK_CURVE}" (got "${value('crv')}") - ${AP2_SIGNING_ALGORITHM} is the only accepted algorithm`, + ); + } + for (const coordinate of ['x', 'y'] as const) { + const encoded = value(coordinate); + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) { + fail(`coordinate "${coordinate}" is not base64url (no padding, no "+" or "/")`); + } + if (Buffer.from(encoded, 'base64url').length !== AP2_JWK_COORDINATE_BYTES) { + fail( + `coordinate "${coordinate}" decodes to ${Buffer.from(encoded, 'base64url').length} bytes; a ${AP2_JWK_CURVE} coordinate is ${AP2_JWK_COORDINATE_BYTES}`, + ); + } + } + if (jwk['alg'] !== undefined && value('alg') !== AP2_SIGNING_ALGORITHM) { + fail(`declares alg "${value('alg')}"; only ${AP2_SIGNING_ALGORITHM} is accepted`); + } + if (jwk['use'] !== undefined && value('use') !== 'sig') { + fail(`declares use "${value('use')}"; a verification key must be "sig"`); + } + if (jwk['kid'] !== undefined && value('kid') !== kid) { + fail(`declares kid "${value('kid')}", which disagrees with the configured kid "${kid}"`); + } + + const normalized: Record = {}; + for (const member of AP2_JWK_MEMBERS) { + if (jwk[member] !== undefined) normalized[member] = value(member); + } + return normalized; +} + +// The AP2 replay store gets its own SQLite file. Reserved mandates, payment +// attempts and ACP idempotency records have different schemas and retention +// rules, and a shared file means a migration conflict at startup or a shared +// write lock on the settlement path. +export function validateReplayStoreIsolated( + replayPath: string, + receiptsPath: string, + acp: AcpProtocolConfig, +): void { + // Every `:memory:` handle is its own private database, so equal strings do + // not collide + if (replayPath === ':memory:') return; + + const others: [string, string][] = [['storage.receipts.path', receiptsPath]]; + if (acp.enabled) others.push(['protocols.acp.idempotency.path', acp.idempotency.path]); + + for (const [otherPath, other] of others) { + if (replayPath !== other) continue; + throw ap2Invalid( + 'authorization.ap2.replay.path', + `authorization.ap2.replay.path is the same file as ${otherPath} ("${replayPath}") - the AP2 replay store keeps its own schema and must not share a database with another store`, + ); + } +} + +// Resolves a resource's `authorization.required` list against the configured +// provider. A requirement that cannot be enforced is refused: the resource +// would look protected in config and settle unprotected. +export function normalizeResourceAuthorization( + id: string, + entry: RawResourceEntry, + pricing: Pricing, + ap2: Ap2AuthorizationConfig | undefined, +): CommerceResource['authorization'] | undefined { + const required = entry.authorization?.required; + if (required === undefined) return undefined; + + const path = `resources.${id}.authorization.required`; + const methods: AuthorizationMethodName[] = []; + for (const method of required) { + if (!SUPPORTED_AUTHORIZATION_METHODS.has(method)) { + throw ap2Invalid( + path, + `Resource "${id}" requires unsupported authorization method "${method}". Supported: ${[...SUPPORTED_AUTHORIZATION_METHODS].join(', ')}.`, + { resourceId: id, method }, + ); + } + if (methods.includes(method as AuthorizationMethodName)) { + throw ap2Invalid(path, `Resource "${id}" lists authorization method "${method}" twice`, { + resourceId: id, + method, + }); + } + if (method === 'ap2' && (ap2 === undefined || !ap2.enabled)) { + throw ap2Invalid( + path, + `Resource "${id}" requires authorization method "ap2", which is not configured or not enabled under authorization.ap2`, + { resourceId: id, method }, + ); + } + methods.push(method as AuthorizationMethodName); + } + + // A mandate binds an exact amount and currency and never unlocks a resource + // by itself, so it only means something on a fixed-price resource + if (pricing.type !== 'fixed') { + throw ap2Invalid( + path, + `Resource "${id}" requires authorization but its pricing is "${pricing.type}" - authorization proves a purchase was approved and never replaces payment, so it applies to fixed-price paid resources only in this release`, + { resourceId: id }, + ); + } + + return { required: methods }; +} diff --git a/src/config/schema/backend.ts b/src/config/schema/backend.ts new file mode 100644 index 0000000..1053430 --- /dev/null +++ b/src/config/schema/backend.ts @@ -0,0 +1,234 @@ +// A resource's backend request: input bindings, path parameters, URL and headers +import { CommerceError, RESERVED_INPUT_FIELDS } from '../../core'; +import { + extractPathParameterNames, + findUnparsedBraceToken, + isObjectSchemaNode, +} from '../../core/execution'; +import { isRecord } from '../../core/is-record'; +import type { RawResourceEntry } from './raw'; + +/** + * Checks `backend.inputBindings` against the input schema at load. Schemas are + * closed by default at every depth, so a binding naming an undeclared property + * could never be supplied, and on a paid resource the backend would get an + * incomplete request after payment. + * + * Returns the schema node `{param}` names must be declared in: the path group + * with bindings, the whole input without. + */ +export function validateInputBindings( + id: string, + backend: RawResourceEntry['backend'], + input: Record | undefined, +): { readonly schema: Record | undefined; readonly where: string } { + const legacy = { schema: input, where: 'its input schema' } as const; + const bindings = backend.inputBindings; + const templated = extractPathParameterNames(backend.url).length > 0; + if (bindings === undefined) return legacy; + + const path = `resources.${id}.backend.inputBindings`; + const fail = (message: string, details: Record = {}): never => { + throw new CommerceError('CONFIG_INVALID', `Resource "${id}" ${message}`, { + details: { path, resourceId: id, ...details }, + }); + }; + + const entries = Object.entries(bindings).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ); + if (entries.length === 0) { + fail('has an empty backend.inputBindings - remove the block to use the default mapping'); + } + if (backend.method === 'GET' || backend.method === 'DELETE') { + if (bindings.body !== undefined) { + fail( + `binds a request body on a ${backend.method}, which sends none - the value would be silently dropped`, + ); + } + } + if (templated && bindings.path === undefined) { + fail( + 'has backend.url path parameters but no "path" binding - in explicit binding mode nothing else supplies them, so every call would fail to reach the backend', + ); + } + + const seen = new Map(); + const properties = isRecord(input?.['properties']) ? input['properties'] : {}; + const required = new Set( + Array.isArray(input?.['required']) + ? input['required'].filter((value): value is string => typeof value === 'string') + : [], + ); + + for (const [location, property] of entries) { + if (RESERVED_INPUT_FIELDS.includes(property)) { + fail(`binds "${location}" to "${property}", which is reserved by the gateway`, { location }); + } + const other = seen.get(property); + if (other !== undefined) { + fail(`binds both "${other}" and "${location}" to the input property "${property}"`, { + location, + property, + }); + } + seen.set(property, location); + + if (!Object.hasOwn(properties, property)) { + fail( + `binds "${location}" to input property "${property}", which the input schema does not declare - the schema is closed, so a caller could never supply it`, + { location, property }, + ); + } + // `body` may be any JSON value; only the path and query groups are read as + // name/value pairs + const declared = properties[property]; + if (location !== 'body' && isRecord(declared) && !isObjectSchemaNode(declared)) { + fail( + `binds "${location}" to input property "${property}", which is not an object schema - ${location} parameters are read as an object of name/value pairs`, + { location, property }, + ); + } + } + + if (templated && bindings.path !== undefined && !required.has(bindings.path)) { + fail( + `binds path parameters to input property "${bindings.path}" without listing it in the input schema's "required" - a caller that omits it cannot supply any path parameter, so the request could never be built`, + { property: bindings.path }, + ); + } + + if (bindings.path === undefined) return { schema: undefined, where: 'its input schema' }; + const group = properties[bindings.path]; + return { + schema: isRecord(group) ? group : undefined, + where: `input.properties.${bindings.path}`, + }; +} + +/** + * Every `{param}` in `backend.url` must be declared in `properties` and listed + * in `required`. Otherwise a call can arrive without it, and core's + * `validateBackendRequestShape` rejects the request, which for a schema that + * never declares the parameter means every call. Checked at load so + * `validate` and `doctor` report it; the runtime check covers a hand-built + * `CommerceResource`. + */ +export function validatePathParametersDeclared( + id: string, + url: string, + input: Record | undefined, + where: string, +): void { + // A brace token the grammar does not recognize is neither extracted nor + // substituted, so it would reach the backend as a percent-encoded literal + const stray = findUnparsedBraceToken(url); + if (stray !== undefined) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" has backend.url containing "${stray}", which is not a valid path parameter (allowed characters: A-Z a-z 0-9 _ . -). It would reach the backend as literal text, so a paid resource would take payment for a request the backend cannot serve`, + { details: { path: `resources.${id}.backend.url`, resourceId: id, token: stray } }, + ); + } + + const params = extractPathParameterNames(url); + if (params.length === 0) return; + + // Everything before the first `{` must be a complete origin followed by the + // path's leading `/`. In the host, every other `{param}` defense fails open: + // `http://{host}/api` parses, the runtime prefix check skips a prefix that + // is not a URL, and `encodeURIComponent` keeps dots. Caller input would pick + // the host the gateway calls, such as a metadata service or an internal + // address, and `http://{region}.api.internal/...` looks ordinary enough to + // be written by mistake. + const firstBrace = url.indexOf('{'); + if (firstBrace !== -1) { + const prefix = url.slice(0, firstBrace); + let origin: string | undefined; + try { + const parsedPrefix = new URL(prefix); + origin = parsedPrefix.hostname === '' ? undefined : parsedPrefix.origin; + } catch { + // Not a URL: the parameter starts before the authority is complete + } + if (origin === undefined || !prefix.startsWith(`${origin}/`)) { + // The URL is not quoted: it may carry a key substituted from the environment + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" has a backend.url parameter before the end of the host. A caller-supplied value would choose which host the gateway calls, which is request forgery with the gateway's own network position. Parameters are supported inside the path and query only`, + { details: { path: `resources.${id}.backend.url`, resourceId: id } }, + ); + } + } + + const propertiesRaw = input?.['properties']; + const properties = isRecord(propertiesRaw) ? propertiesRaw : {}; + const requiredRaw = input?.['required']; + const required = new Set( + Array.isArray(requiredRaw) + ? requiredRaw.filter((entry): entry is string => typeof entry === 'string') + : [], + ); + + for (const param of params) { + const path = `resources.${id}.backend.url`; + if (!Object.hasOwn(properties, param)) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" has backend.url path parameter "{${param}}" which is not declared in ${where} - the caller has no way to supply it, so every call would be refused`, + { details: { path, resourceId: id, param } }, + ); + } + if (!required.has(param)) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" has backend.url path parameter "{${param}}" declared in ${where} but not listed in its "required" - a call that omits it would be refused`, + { details: { path, resourceId: id, param } }, + ); + } + } +} + +// `{param}` templates parse as URLs (the WHATWG parser percent-encodes the +// braces), so this refuses only malformed URLs and non-http(s) schemes +export function validateBackendUrl(id: string, url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + // The URL is not quoted: it may carry a key substituted from the environment + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" has an invalid backend.url: must be an absolute http:// or https:// URL`, + { details: { path: `resources.${id}.backend.url`, resourceId: id } }, + ); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" has a backend.url with scheme "${parsed.protocol}": must be http:// or https://`, + { details: { path: `resources.${id}.backend.url`, resourceId: id } }, + ); + } +} + +// Checked with fetch's own `Headers` rules. Otherwise an illegal name, or a +// substituted value with a line break inside it, would load cleanly and then +// refuse every call. The error names the key, never the value, which may be a +// credential. +export function validateBackendHeaders( + id: string, + headers: Record | undefined, +): void { + for (const [name, value] of Object.entries(headers ?? {})) { + try { + new Headers([[name, value]]); + } catch { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" has backend header "${name}" with an illegal name or value (a name must be an HTTP token; a value must not contain NUL, a character above U+00FF, or a line break except at either end, which fetch trims)`, + { details: { path: `resources.${id}.backend.headers.${name}`, resourceId: id } }, + ); + } + } +} diff --git a/src/config/schema/index.ts b/src/config/schema/index.ts new file mode 100644 index 0000000..ca12e41 --- /dev/null +++ b/src/config/schema/index.ts @@ -0,0 +1,89 @@ +/** + * `config.yaml` schema, validation and normalization into the canonical + * `GatewayConfig` (docs/contracts.md). + * + * 1. Zod validates shape: types, required fields, unknown keys. Numeric and + * boolean leaves accept their native type or a string, because env + * substitution produces strings. Zod transforms interact badly with + * `.strict()` inference, so coercion happens in plain TypeScript in step 2. + * 2. A business-rule pass coerces those leaves and checks what needs a + * specific message: cross-references, disabled protocols and payment + * methods, dynamic pricing, addresses, duplicate trust entries. + * + * Env substitution (`${VAR}` / `${VAR:-default}`) runs over the raw value + * before both steps, so numeric and boolean fields can be templated. The one + * exception is `version:`, checked before substitution: it decides whether this + * parser understands the document at all, so it cannot depend on the + * environment. + */ +import type { ZodError, ZodIssue, z } from 'zod'; +import { CommerceError } from '../../core'; +import { isRecord } from '../../core/is-record'; +import { substituteEnv } from '../env'; +import { normalize } from './normalize'; +import { RawConfigSchema, SUPPORTED_CONFIG_VERSION } from './raw'; +import type { GatewayConfig } from './types'; + +export function parseConfig(raw: unknown, env: NodeJS.ProcessEnv): GatewayConfig { + if (!isRecord(raw)) { + throw new CommerceError( + 'CONFIG_INVALID', + 'Configuration root must be a mapping (object) at "$"', + { + details: { path: '$' }, + }, + ); + } + + checkVersion(raw); + + const substituted = substituteEnv(raw, env); + const parsed = parseWithZod(RawConfigSchema, substituted); + return normalize(parsed); +} + +function checkVersion(raw: Record): void { + if (!('version' in raw)) { + throw new CommerceError('CONFIG_INVALID', 'Configuration is missing required field "version"', { + details: { path: 'version' }, + }); + } + const version = raw['version']; + if (version !== SUPPORTED_CONFIG_VERSION) { + const hint = typeof version === 'string' ? ', written as a number without quotes' : ''; + throw new CommerceError( + 'CONFIG_INVALID', + `Unsupported config version "${String(version)}": this gateway only supports version ${SUPPORTED_CONFIG_VERSION}${hint}`, + { details: { path: 'version', supported: SUPPORTED_CONFIG_VERSION } }, + ); + } +} + +function parseWithZod(schema: z.ZodType, value: unknown): T { + const result = schema.safeParse(value); + if (!result.success) { + throw zodErrorToConfigError(result.error); + } + return result.data; +} + +function zodErrorToConfigError(error: ZodError): CommerceError { + const issues = error.issues.map(describeIssue); + const first = issues[0]; + const message = first + ? `Configuration invalid at "${first.path}": ${first.message}` + : 'Configuration invalid'; + return new CommerceError('CONFIG_INVALID', message, { details: { issues } }); +} + +function describeIssue(issue: ZodIssue): { path: string; message: string; code: string } { + const path = issue.path.length > 0 ? issue.path.join('.') : '$'; + return { path, message: issue.message, code: issue.code }; +} + +export type { + AcpDiscoveryConfig, + AcpProtocolConfig, + GatewayConfig, + X402OptionConfig, +} from './types'; diff --git a/src/config/schema/mpp.ts b/src/config/schema/mpp.ts new file mode 100644 index 0000000..005ff68 --- /dev/null +++ b/src/config/schema/mpp.ts @@ -0,0 +1,94 @@ +// The MPP rail's settings +import { CommerceError } from '../../core'; +import { + isMppNetwork, + MPP_DEFAULT_NETWORK, + MPP_DEFAULT_PAYMENT_FLOW, + MPP_MIN_CHALLENGE_SECRET_LENGTH, + MPP_NETWORKS, +} from '../../payments/mpp/constants'; +import { resolveX402Deployment } from '../../payments/x402/guardrails'; +import type { RawConfig } from './raw'; +import type { GatewayConfig } from './types'; +import { toBoolean, toNumber, validateAddress } from './values'; +import { normalizeFacilitator } from './x402'; + +export type NormalizedMpp = NonNullable; + +export function normalizeMpp(raw: RawConfig['payments']['mpp']): NormalizedMpp | undefined { + if (raw === undefined) return undefined; + const network = raw.network ?? MPP_DEFAULT_NETWORK; + if (!isMppNetwork(network)) { + throw new CommerceError( + 'CONFIG_INVALID', + `payments.mpp.network must be one of ${MPP_NETWORKS.join(', ')}`, + { details: { path: 'payments.mpp.network' } }, + ); + } + const mpp: NormalizedMpp = { + enabled: toBoolean(raw.enabled, 'payments.mpp.enabled'), + network, + rpcUrl: raw.rpcUrl, + asset: raw.asset, + assetName: raw.assetName, + assetVersion: raw.assetVersion, + recipient: raw.recipient, + realm: raw.realm, + challengeSecret: raw.challengeSecret, + ...(raw.challengeTtlSeconds !== undefined + ? { + challengeTtlSeconds: toNumber( + raw.challengeTtlSeconds, + 'payments.mpp.challengeTtlSeconds', + { min: 1 }, + ), + } + : {}), + paymentFlow: raw.paymentFlow ?? MPP_DEFAULT_PAYMENT_FLOW, + facilitator: normalizeFacilitator(raw.facilitator), + ...(raw.allowMainnet !== undefined + ? { allowMainnet: toBoolean(raw.allowMainnet, 'payments.mpp.allowMainnet') } + : {}), + ...(raw.allowUnauthenticatedFacilitator !== undefined + ? { + allowUnauthenticatedFacilitator: toBoolean( + raw.allowUnauthenticatedFacilitator, + 'payments.mpp.allowUnauthenticatedFacilitator', + ), + } + : {}), + }; + validateAddress('payments.mpp.recipient', mpp.recipient); + validateAddress('payments.mpp.asset', mpp.asset); + // mppx refuses CR or LF in a quoted challenge parameter + if (/[\r\n]/.test(mpp.realm)) { + throw new CommerceError('CONFIG_INVALID', 'payments.mpp.realm must be a single line', { + details: { path: 'payments.mpp.realm' }, + }); + } + // Length only: the secret itself never appears in an error + if (mpp.challengeSecret.length < MPP_MIN_CHALLENGE_SECRET_LENGTH) { + throw new CommerceError( + 'CONFIG_INVALID', + `payments.mpp.challengeSecret must have length at least ${MPP_MIN_CHALLENGE_SECRET_LENGTH}`, + { details: { path: 'payments.mpp.challengeSecret' } }, + ); + } + // The shared x402 address, facilitator and mainnet guards, reported under the + // MPP config path + resolveX402Deployment({ + network: mpp.network, + payTo: mpp.recipient, + asset: mpp.asset, + assetName: mpp.assetName, + assetVersion: mpp.assetVersion, + facilitator: mpp.facilitator, + ...(mpp.allowMainnet !== undefined ? { allowMainnet: mpp.allowMainnet } : {}), + ...(mpp.allowUnauthenticatedFacilitator !== undefined + ? { allowUnauthenticatedFacilitator: mpp.allowUnauthenticatedFacilitator } + : {}), + configPath: 'payments.mpp', + payToField: 'recipient', + }); + return mpp; +} diff --git a/src/config/schema/normalize.ts b/src/config/schema/normalize.ts new file mode 100644 index 0000000..fbc0286 --- /dev/null +++ b/src/config/schema/normalize.ts @@ -0,0 +1,119 @@ +// The business-rule pass: coerces leaves and checks cross-references across sections +import { CommerceError } from '../../core'; +import { normalizeAcp, validateAcpCheckoutMapping } from './acp'; +import { normalizeAp2, validateReplayStoreIsolated } from './ap2'; +import { normalizeMpp } from './mpp'; +import { type RawConfig, SUPPORTED_CONFIG_VERSION } from './raw'; +import { normalizeResource } from './resources'; +import type { GatewayConfig, NormalizedProtocols } from './types'; +import { toBoolean, toNumber } from './values'; +import { normalizeX402 } from './x402'; + +// Applied when `protocols.a2a` is absent or names no mount +const DEFAULT_A2A_MOUNT_PATH = '/a2a'; + +export function normalize(raw: RawConfig): GatewayConfig { + const protocols = { + http: { enabled: toBoolean(raw.protocols.http.enabled, 'protocols.http.enabled') }, + mcp: { + enabled: toBoolean(raw.protocols.mcp.enabled, 'protocols.mcp.enabled'), + mountPath: raw.protocols.mcp.mountPath, + }, + a2a: { + enabled: toBoolean(raw.protocols.a2a?.enabled ?? false, 'protocols.a2a.enabled'), + mountPath: raw.protocols.a2a?.mountPath ?? DEFAULT_A2A_MOUNT_PATH, + }, + acp: normalizeAcp(raw.protocols.acp), + }; + validateMountPaths(protocols); + + const x402Settings = + raw.payments.x402 !== undefined ? normalizeX402(raw.payments.x402, raw.resources) : undefined; + const x402 = x402Settings?.config; + + const mpp = normalizeMpp(raw.payments.mpp); + + const ap2 = normalizeAp2(raw.authorization?.ap2); + if (ap2?.enabled) { + validateReplayStoreIsolated(ap2.replay.path, raw.storage.receipts.path, protocols.acp); + } + + const resources = Object.entries(raw.resources).map(([id, entry]) => + normalizeResource(id, entry, protocols, x402Settings, mpp, ap2), + ); + // CommerceResource has no payment-flow field. Each listed rail carries the + // overrides; the selected rail applies them. + const flowOverrides = (rail: 'x402' | 'mpp') => { + const flows = Object.fromEntries( + resources.flatMap((resource) => { + const flow = raw.resources[resource.id]?.paymentFlow; + return flow !== undefined && resource.paymentMethods.includes(rail) + ? [[resource.id, flow]] + : []; + }), + ); + return Object.keys(flows).length > 0 ? { resourcePaymentFlows: flows } : {}; + }; + const x402WithFlows = x402 !== undefined ? { ...x402, ...flowOverrides('x402') } : x402; + const mppWithFlows = mpp !== undefined ? { ...mpp, ...flowOverrides('mpp') } : mpp; + if (protocols.acp.enabled) validateAcpCheckoutMapping(protocols.acp, resources); + // Require an exposed resource so the enabled adapter publishes a skill + if (protocols.a2a.enabled && !resources.some((r) => r.exposedVia.includes('a2a'))) { + throw new CommerceError( + 'CONFIG_INVALID', + 'protocols.a2a.enabled is true but no resource lists "a2a" in its expose', + { details: { path: 'protocols.a2a.enabled' } }, + ); + } + + return { + version: SUPPORTED_CONFIG_VERSION, + merchant: { + id: raw.merchant.id, + name: raw.merchant.name, + publicBaseUrl: raw.merchant.publicBaseUrl, + }, + // Port 0 lets the OS pick a free port, for tests and throwaway instances + server: { + port: toNumber(raw.server.port, 'server.port', { min: 0, max: 65535 }), + host: raw.server.host, + ...(raw.server.adminToken !== undefined ? { adminToken: raw.server.adminToken } : {}), + allowedOrigins: raw.server.allowedOrigins ?? [], + }, + storage: { receipts: { driver: 'sqlite', path: raw.storage.receipts.path } }, + protocols, + resources, + payments: { + ...(x402WithFlows !== undefined ? { x402: x402WithFlows } : {}), + ...(mppWithFlows !== undefined ? { mpp: mppWithFlows } : {}), + }, + ...(ap2 !== undefined ? { authorization: { ap2 } } : {}), + }; +} + +// Two enabled mounts may not overlap: each registers a `${mountPath}/*` +// wildcard, so a shared prefix lets one adapter answer for the other. Disabled +// protocols mount nothing and are not compared. +function validateMountPaths(protocols: NormalizedProtocols): void { + const mounts = ( + [ + ['mcp', protocols.mcp], + ['a2a', protocols.a2a], + ['acp', protocols.acp], + ] as const + ).filter(([, p]) => p.enabled); + + for (const [index, [nameA, a]] of mounts.entries()) { + for (const [nameB, b] of mounts.slice(index + 1)) { + const baseA = a.mountPath.replace(/\/+$/, ''); + const baseB = b.mountPath.replace(/\/+$/, ''); + if (baseA === baseB || baseA.startsWith(`${baseB}/`) || baseB.startsWith(`${baseA}/`)) { + throw new CommerceError( + 'CONFIG_INVALID', + `protocols.${nameA}.mountPath ("${a.mountPath}") collides with protocols.${nameB}.mountPath ("${b.mountPath}") - each mount registers a wildcard, so overlapping prefixes cannot both be served`, + { details: { path: `protocols.${nameA}.mountPath` } }, + ); + } + } + } +} diff --git a/src/config/schema/raw.ts b/src/config/schema/raw.ts new file mode 100644 index 0000000..dd23b84 --- /dev/null +++ b/src/config/schema/raw.ts @@ -0,0 +1,423 @@ +// The Zod schemas for `config.yaml`: shape only, before any business rule +import { type ZodTypeAny, z } from 'zod'; +import { X402_PAYMENT_FLOWS } from '../../payments/x402/payment-flow'; +import { ACP_WELL_KNOWN_PATH } from '../../protocols/acp/constants'; + +export const SUPPORTED_CONFIG_VERSION = 1; + +// A number, or a numeric string from env substitution +const NumberOrString = z.union([z.number(), z.string()]); + +// A boolean, or a "true"/"false" string from env substitution +const BooleanOrString = z.union([z.boolean(), z.string()]); + +// An RPC endpoint. The message never echoes the value, which can carry an API key +const RpcUrl = z + .string() + .min(1) + .refine(isHttpUrl, { message: 'must be an http:// or https:// URL' }); + +function isHttpUrl(value: string): boolean { + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +} + +const MerchantSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + publicBaseUrl: z.string().min(1), + }) + .strict(); + +const ServerSchema = z + .object({ + port: NumberOrString, + host: z.string().min(1), + // Shared secret gating /api/receipts and /api/events. Without it both + // routes 404. + adminToken: z.string().min(1).optional(), + // Browser origins allowed to call the gateway; none by default. A request + // with any other `Origin` gets 403 on every route. Entries match the + // `Origin` header exactly (ignoring case), so `"*"` and a trailing slash + // would match nothing: a silent lockout that an operator might "fix" by + // disabling the check. Both are refused with a pointer. + allowedOrigins: z + .array( + z + .string() + .min(1) + .refine((origin) => origin !== '*', { + message: + 'wildcard "*" is not supported - allowedOrigins entries are matched literally against the browser\'s Origin header, so "*" would match nothing. List each scheme://host[:port] explicitly.', + }) + .refine((origin) => !origin.endsWith('/'), { + message: + 'must not end with "/" - a browser Origin header never has a trailing slash, so this entry would match nothing. Use e.g. "http://localhost:5173".', + }), + ) + .optional(), + }) + .strict(); + +const StorageSchema = z + .object({ + receipts: z + .object({ + driver: z.literal('sqlite'), + path: z.string().min(1), + }) + .strict(), + }) + .strict(); + +// Every path the gateway registers (`src/gateway/routes.ts`), plus the fixed +// discovery paths adapters own. A mount equal to one duplicates that route; a +// mount that is a prefix of one swallows it through its `${mountPath}/*` wildcard. +const RESERVED_GATEWAY_PATHS = [ + '/health', + '/ready', + '/.well-known/agent-commerce', + // Fixed by the A2A specification and served by the adapter + '/.well-known/agent-card.json', + // Fixed by the ACP specification + ACP_WELL_KNOWN_PATH, + '/api/resources', + '/api/resources/:id/invoke', + '/api/receipts', + '/api/events', +] as const; + +// A bad `mountPath` would throw inside Fastify route registration at +// `server.ready()`, failing the whole gateway with an opaque `FST_ERR_*`. +// Checked here it is a CONFIG_INVALID naming the value. Pattern syntax (`:`, +// `*`) is refused because the mount registers its own wildcard. +const MountPathSchema = z + .string() + .min(1) + .refine((value) => value.startsWith('/'), { + message: 'must start with "/" - it is an absolute gateway path, e.g. "/mcp".', + }) + .refine((value) => !/[:*?\s]/.test(value), { + message: + 'must not contain ":", "*", "?" or whitespace - the mount is a literal path prefix, not a Fastify route pattern, and registers its own wildcard.', + }) + .refine( + (value) => { + const base = value.replace(/\/+$/, ''); + return !RESERVED_GATEWAY_PATHS.some( + (reserved) => reserved === base || reserved.startsWith(`${base}/`), + ); + }, + { + message: `must not collide with a route the gateway already serves (${RESERVED_GATEWAY_PATHS.join(', ')}); pick a dedicated prefix such as "/mcp".`, + }, + ); + +// Bearer is the only ACP auth scheme; there is no `none` +const AcpAuthSchema = z.object({ type: z.literal('bearer'), token: z.string().min(1) }).strict(); + +const AcpIdempotencySchema = z + .object({ + path: z.string().min(1), + retentionHours: NumberOrString.optional(), + merchantIdempotent: z.boolean().optional(), + }) + .strict(); + +// Operation names are checked against `ACP_CHECKOUT_OPERATIONS` in the +// business pass, which names a missing or misspelled operation +const AcpCheckoutSchema = z.object({ operations: z.record(z.string().min(1)) }).strict(); + +// Optional discovery metadata, omitted when not configured +const AcpDiscoverySchema = z + .object({ + documentationUrl: z.string().min(1).optional(), + supportedCurrencies: z.array(z.string().min(1)).optional(), + supportedLocales: z.array(z.string().min(1)).optional(), + interventionTypes: z.array(z.string().min(1)).optional(), + }) + .strict(); + +const ProtocolsSchema = z + .object({ + http: z.object({ enabled: BooleanOrString }).strict(), + mcp: z + .object({ + enabled: BooleanOrString, + mountPath: MountPathSchema, + }) + .strict(), + // Optional; A2A is off unless configured + a2a: z + .object({ + enabled: BooleanOrString, + mountPath: MountPathSchema.optional(), + }) + .strict() + .optional(), + // Optional and off by default. The business pass requires the sub-blocks + // only when ACP is enabled, so a disabled placeholder stays valid and an + // enabled block gets a message naming what it lacks. + acp: z + .object({ + enabled: BooleanOrString, + mountPath: MountPathSchema.optional(), + auth: AcpAuthSchema.optional(), + idempotency: AcpIdempotencySchema.optional(), + checkout: AcpCheckoutSchema.optional(), + discovery: AcpDiscoverySchema.optional(), + }) + .strict() + .optional(), + }) + .strict(); + +const BackendMethodSchema = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); + +// Strict, so a typo like `bodyy` fails at load instead of silently meaning +// "no body binding" +const BackendInputBindingsSchema = z + .object({ + path: z.string().min(1).optional(), + query: z.string().min(1).optional(), + body: z.string().min(1).optional(), + }) + .strict(); + +const BackendHandlerSchema = z + .object({ + type: z.literal('http'), + method: BackendMethodSchema, + url: z.string().min(1), + headers: z.record(z.string(), z.string()).optional(), + timeoutMs: NumberOrString.optional(), + inputBindings: BackendInputBindingsSchema.optional(), + }) + .strict(); + +const PricingSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('free') }).strict(), + z + .object({ + type: z.literal('fixed'), + amount: z.string().min(1), + currency: z.string().min(1), + }) + .strict(), + z + .object({ + type: z.literal('dynamic'), + resolver: z.string().min(1), + }) + .strict(), +]); + +const JsonSchemaValueSchema: ZodTypeAny = z.record(z.string(), z.unknown()); + +// Settlement network, payee and token, resolved by x402/options.ts. Network +// and payee fields follow the SDK's `PaymentOption`; asset fields override defaults +const X402OptionSchema = z + .object({ + scheme: z.string().min(1).optional(), + network: z.string().min(1), + payTo: z.string().min(1), + asset: z.string().min(1).optional(), + assetName: z.string().min(1).optional(), + assetVersion: z.string().min(1).optional(), + assetDecimals: NumberOrString.optional(), + assetTransferMethod: z.string().min(1).optional(), + }) + .strict(); + +const ResourceEntrySchema = z + .object({ + name: z.string().min(1), + description: z.string().optional(), + input: JsonSchemaValueSchema.optional(), + output: JsonSchemaValueSchema.optional(), + backend: BackendHandlerSchema, + pricing: PricingSchema, + expose: z.array(z.string().min(1)).min(1), + payments: z.array(z.string().min(1)).optional(), + paymentFlow: z.enum(X402_PAYMENT_FLOWS).optional(), + // This resource's own x402 offers, in place of `payments.x402` options + x402: z + .object({ accepts: z.array(X402OptionSchema) }) + .strict() + .optional(), + authorization: z + .object({ required: z.array(z.string().min(1)).min(1) }) + .strict() + .optional(), + }) + .strict(); + +const ResourcesMapSchema = z.record(z.string().min(1), ResourceEntrySchema); + +// Facilitator credentials. `none` and `bearer` fit any facilitator; `cdp` is +// the one vendor-specific type. Any other type is refused at load. +const FacilitatorAuthSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('none') }).strict(), + z.object({ type: z.literal('bearer'), token: z.string().min(1) }).strict(), + // Coinbase Developer Platform. Needs the optional peer `@coinbase/x402`, + // which signs a fresh JWT per request. + z + .object({ + type: z.literal('cdp'), + apiKeyId: z.string().min(1), + apiKeySecret: z.string().min(1), + }) + .strict(), +]); + +export const FacilitatorSchema = z.discriminatedUnion('mode', [ + z + .object({ + mode: z.literal('local'), + signerPrivateKey: z.string().min(1), + }) + .strict(), + z + .object({ + mode: z.literal('remote'), + url: z.string().min(1), + // Absent auth sends no credential and normalizes to `{ type: 'none' }`, + // which on mainnet requires `allowUnauthenticatedFacilitator` + auth: FacilitatorAuthSchema.optional(), + }) + .strict(), +]); + +// Per-network settings: `kind` and `displayName` declare a network outside +// the registry; `rpcUrl` is that network's endpoint, built in or declared +const X402NetworkSettingsSchema = z + .object({ + kind: z.enum(['testnet', 'mainnet']).optional(), + displayName: z.string().min(1).optional(), + rpcUrl: RpcUrl.optional(), + }) + .strict(); + +const X402Schema = z + .object({ + enabled: BooleanOrString, + // Accept direct fields or an `options` array, never both + network: z.string().min(1).optional(), + payTo: z.string().min(1).optional(), + asset: z.string().min(1).optional(), + assetName: z.string().min(1).optional(), + assetVersion: z.string().min(1).optional(), + assetDecimals: NumberOrString.optional(), + assetTransferMethod: z.string().min(1).optional(), + options: z.array(X402OptionSchema).optional(), + networks: z.record(z.string().min(1), X402NetworkSettingsSchema).optional(), + // The RPC for every network without its own `networks..rpcUrl` + rpcUrl: RpcUrl.optional(), + maxTimeoutSeconds: NumberOrString, + // `authorization` (the x402 default) settles after the backend call + paymentFlow: z.enum(X402_PAYMENT_FLOWS).optional(), + facilitator: FacilitatorSchema, + // Real funds. Never defaulted; see src/payments/x402/guardrails.ts + allowMainnet: BooleanOrString.optional(), + // Accepts credential-free facilitator access on mainnet. Never defaulted + allowUnauthenticatedFacilitator: BooleanOrString.optional(), + }) + .strict(); + +const MppSchema = z + .object({ + enabled: BooleanOrString, + network: z.string().min(1).optional(), + rpcUrl: RpcUrl, + asset: z.string().min(1), + assetName: z.string().min(1), + assetVersion: z.string().min(1), + recipient: z.string().min(1), + realm: z.string().min(1), + challengeSecret: z.string().min(1), + challengeTtlSeconds: NumberOrString.optional(), + // Same values as `payments.x402.paymentFlow`; the default is `upfront` + paymentFlow: z.enum(X402_PAYMENT_FLOWS).optional(), + facilitator: FacilitatorSchema, + // Real funds. Never defaulted; see src/payments/x402/guardrails.ts + allowMainnet: BooleanOrString.optional(), + // Accepts credential-free facilitator access on mainnet. Never defaulted + allowUnauthenticatedFacilitator: BooleanOrString.optional(), + }) + .strict(); + +const PaymentsSchema = z + .object({ + x402: X402Schema.optional(), + mpp: MppSchema.optional(), + }) + .strict(); + +// A public verification key, inline only: no `jwksUri`, `jku` or discovery +// URL. AP2 leaves secure key distribution open, and fetching keys would mean +// fetching from a location a mandate can influence. The business pass checks +// the JWK's members against `AP2_JWK_MEMBERS`, which also keeps out `x5u`. +const Ap2KeySchema = z + .object({ + kid: z.string().min(1), + jwk: z.record(z.string(), z.unknown()), + }) + .strict(); + +export const Ap2IssuerSchema = z + .object({ + issuer: z.string().min(1), + // Required, not defaulted: without it a mandate minted for another + // merchant would verify here + audience: z.string().min(1), + keys: z.array(Ap2KeySchema).min(1), + }) + .strict(); + +const Ap2Schema = z + .object({ + enabled: BooleanOrString, + specVersion: z.string().min(1).optional(), + mode: z.string().min(1).optional(), + trust: z + .object({ + mandateIssuers: z.array(Ap2IssuerSchema).optional(), + checkoutIssuers: z.array(Ap2IssuerSchema).optional(), + }) + .strict() + .optional(), + clockSkewSeconds: NumberOrString.optional(), + requireMandateAudience: BooleanOrString.optional(), + requireMandateExpiry: BooleanOrString.optional(), + replay: z + .object({ path: z.string().min(1) }) + .strict() + .optional(), + }) + .strict(); + +// Optional and off by default. The business pass requires the sub-blocks only +// when AP2 is enabled, and names the missing piece. +const AuthorizationSchema = z.object({ ap2: Ap2Schema.optional() }).strict(); + +export const RawConfigSchema = z + .object({ + version: z.literal(SUPPORTED_CONFIG_VERSION), + merchant: MerchantSchema, + server: ServerSchema, + storage: StorageSchema, + protocols: ProtocolsSchema, + resources: ResourcesMapSchema, + payments: PaymentsSchema, + authorization: AuthorizationSchema.optional(), + }) + .strict(); + +export type RawConfig = z.infer; + +export type RawResourceEntry = z.infer; diff --git a/src/config/schema/resource-schema.ts b/src/config/schema/resource-schema.ts new file mode 100644 index 0000000..9fb5701 --- /dev/null +++ b/src/config/schema/resource-schema.ts @@ -0,0 +1,170 @@ +// A resource's input schema: the default closed object and the keywords the validator supports +import { CommerceError } from '../../core'; +import { isObjectSchemaNode } from '../../core/execution'; +import { isRecord } from '../../core/is-record'; + +/** + * Closes every object schema at every depth: a node without + * `additionalProperties` gets `false`, so unknown keys never reach the + * merchant's API. An explicit value, including `true`, is kept. + * + * JSON Schema itself defaults to open, and core's validator enforces + * `additionalProperties` only where a node states it, so this recurses into + * `properties`, `items` and schema-valued `additionalProperties`, the same + * places the validator descends. Object-ness comes from `isObjectSchemaNode`, + * the validator's own definition. + */ +export function defaultClosedObjectSchema( + schema: Record, +): Record { + const isObjectSchema = isObjectSchemaNode(schema); + const result: Record = { ...schema }; + + if (isObjectSchema) { + const properties = schema['properties']; + if (isRecord(properties)) { + result['properties'] = Object.fromEntries( + Object.entries(properties).map(([key, sub]) => [ + key, + isRecord(sub) ? defaultClosedObjectSchema(sub) : sub, + ]), + ); + } + if (!Object.hasOwn(schema, 'additionalProperties')) { + result['additionalProperties'] = false; + } + } + + const items = schema['items']; + if (isRecord(items)) { + result['items'] = defaultClosedObjectSchema(items); + } + + // `additionalProperties: {schema}` (a map of typed objects) is closed too. + // Not conditional on `isObjectSchema`: the validator applies the subschema + // wherever it finds one. + const additional = schema['additionalProperties']; + if (isRecord(additional)) { + result['additionalProperties'] = defaultClosedObjectSchema(additional); + } + + return result; +} + +// The input schema of a resource that declares no `input:`. Declaring nothing +// accepts nothing: without this, `compileJsonSchema(undefined)` would accept +// any input and forward every key to the backend. A no-argument resource +// omitting `input:` is a legitimate shape, so it is not refused. +export const EMPTY_CLOSED_OBJECT_SCHEMA: Record = { + type: 'object', + properties: {}, + additionalProperties: false, +}; + +// Keywords core's validator ignores (see `execution/validation.ts` for the +// enforced subset). Config warns at load, since an operator who writes +// `pattern` cannot otherwise tell that it is not enforced. +const UNSUPPORTED_SCHEMA_KEYWORDS = [ + 'pattern', + 'minLength', + 'maxLength', + 'format', + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + 'multipleOf', + 'oneOf', + 'anyOf', + 'allOf', + 'not', + '$ref', + 'const', + 'patternProperties', + 'minItems', + 'maxItems', + 'uniqueItems', +]; + +// `type` set to something that rules out "object". No `type` at all does not +// exclude it: the validator treats `properties`/`required` alone as an object +// schema. +function excludesObjectType(schema: Record): boolean { + const type = schema['type']; + if (type === undefined || type === 'object') return false; + if (Array.isArray(type)) return !type.includes('object'); + return true; +} + +/** + * Walks a resource's input schema once. Warns about unenforced keywords and + * tuple `items`. Rejects a node whose `type` rules out "object" while it + * declares `properties` or `required`, which then go unchecked (often a + * copy-pasted sibling whose `type` was never updated), and a closed node + * requiring a property it never declares, which no input can satisfy. + */ +export function validateResourceSchemaKeywords( + id: string, + path: string, + schema: Record, +): void { + for (const keyword of UNSUPPORTED_SCHEMA_KEYWORDS) { + if (Object.hasOwn(schema, keyword)) { + console.warn( + `[agent-commerce] resource "${id}" ${path} uses JSON Schema keyword "${keyword}", which this gateway does not enforce (see src/core/execution/validation.ts for the supported subset). Remove it or treat it as documentation only.`, + ); + } + } + if ( + (Object.hasOwn(schema, 'properties') || Object.hasOwn(schema, 'required')) && + excludesObjectType(schema) + ) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" ${path} declares "properties" and/or "required" but its "type" (${JSON.stringify(schema['type'])}) does not include "object" - the validator will never route a value through either check under that type, so this schema claims to constrain input it does not actually enforce. Remove "properties"/"required", or include "object" in "type".`, + { details: { path: `resources.${id}.${path}`, resourceId: id } }, + ); + } + // A closed node that requires a property it never declares rejects that + // property as unknown, so no input can satisfy it and every call would 400. + // Only when the node is closed: an explicit `additionalProperties` + // other than `false` leaves the name reachable. + const requiredRaw = schema['required']; + const closed = !Object.hasOwn(schema, 'additionalProperties') + ? true + : schema['additionalProperties'] === false; + if (Array.isArray(requiredRaw) && closed && isObjectSchemaNode(schema)) { + const declared = isRecord(schema['properties']) ? schema['properties'] : {}; + const undeclared = requiredRaw.filter( + (name): name is string => typeof name === 'string' && !Object.hasOwn(declared, name), + ); + if (undeclared.length > 0) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" ${path} lists ${undeclared.map((n) => `"${n}"`).join(', ')} in "required" but does not declare ${undeclared.length === 1 ? 'it' : 'them'} in "properties". The schema is closed (additionalProperties: false), so ${undeclared.length === 1 ? 'that name is' : 'those names are'} rejected as an unknown key and no input can ever satisfy this schema. Declare ${undeclared.length === 1 ? 'it' : 'them'} in "properties".`, + { details: { path: `resources.${id}.${path}`, resourceId: id, undeclared } }, + ); + } + } + + const properties = schema['properties']; + if (isRecord(properties)) { + for (const [key, sub] of Object.entries(properties)) { + if (isRecord(sub)) validateResourceSchemaKeywords(id, `${path}.properties.${key}`, sub); + } + } + const additional = schema['additionalProperties']; + if (isRecord(additional)) { + validateResourceSchemaKeywords(id, `${path}.additionalProperties`, additional); + } + const items = schema['items']; + if (isRecord(items)) { + validateResourceSchemaKeywords(id, `${path}.items`, items); + } else if (Array.isArray(items)) { + // Tuple-form `items` is valid JSON Schema, but the validator supports only + // one schema for every element, so a tuple enforces nothing + console.warn( + `[agent-commerce] resource "${id}" ${path}.items is a tuple (an array of schemas), which this gateway does not enforce - only a single schema applied to every array element is supported (see src/core/execution/validation.ts). Each position's schema is unenforced; treat it as documentation only.`, + ); + } +} diff --git a/src/config/schema/resources.ts b/src/config/schema/resources.ts new file mode 100644 index 0000000..68ab631 --- /dev/null +++ b/src/config/schema/resources.ts @@ -0,0 +1,309 @@ +// Resources: exposure, pricing, payment rails and backend handler +import type { Ap2AuthorizationConfig } from '../../authorization/ap2/types'; +import { + CommerceError, + type CommerceResource, + PAYMENT_METHOD_NAMES, + PROTOCOL_NAMES, + type Pricing, + RESERVED_INPUT_FIELDS, +} from '../../core'; +import { MPP_PROFILE } from '../../payments/mpp/constants'; +import { MCP_TOOL_NAME_PATTERN } from '../../protocols/mcp/constants'; +import { normalizeResourceAuthorization } from './ap2'; +import { + validateBackendHeaders, + validateBackendUrl, + validateInputBindings, + validatePathParametersDeclared, +} from './backend'; +import type { NormalizedMpp } from './mpp'; +import type { RawResourceEntry } from './raw'; +import { + defaultClosedObjectSchema, + EMPTY_CLOSED_OBJECT_SCHEMA, + validateResourceSchemaKeywords, +} from './resource-schema'; +import type { NormalizedProtocols } from './types'; +import { toNumber } from './values'; +import type { NormalizedX402 } from './x402'; + +const SUPPORTED_PROTOCOLS: ReadonlySet = new Set(PROTOCOL_NAMES); + +const SUPPORTED_PAYMENT_METHODS: ReadonlySet = new Set(PAYMENT_METHOD_NAMES); + +export function normalizeResource( + id: string, + entry: RawResourceEntry, + protocols: NormalizedProtocols, + x402: NormalizedX402 | undefined, + mpp: NormalizedMpp | undefined, + ap2: Ap2AuthorizationConfig | undefined, +): CommerceResource { + if (entry.input !== undefined) validateResourceSchemaKeywords(id, 'input', entry.input); + + const inputProperties = entry.input?.['properties']; + if (inputProperties && typeof inputProperties === 'object') { + for (const reserved of RESERVED_INPUT_FIELDS) { + if (!(reserved in inputProperties)) continue; + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" declares an input property "${reserved}", which is reserved by the gateway`, + { + details: { + path: `resources.${id}.input.properties.${reserved}`, + resourceId: id, + }, + }, + ); + } + } + + for (const protocol of entry.expose) { + if (!SUPPORTED_PROTOCOLS.has(protocol)) { + const hint = protocol === 'ucp' ? ' (UCP is planned, not supported in this release)' : ''; + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" exposes unsupported protocol "${protocol}"${hint}. Supported: ${PROTOCOL_NAMES.join(', ')}.`, + { details: { path: `resources.${id}.expose`, resourceId: id, protocol } }, + ); + } + } + if (entry.expose.includes('mcp')) { + if (!protocols.mcp.enabled) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" is exposed via "mcp" but protocols.mcp.enabled is false`, + { details: { path: `resources.${id}.expose`, resourceId: id } }, + ); + } + if (!MCP_TOOL_NAME_PATTERN.test(id)) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" is exposed via "mcp" but its id is not a legal MCP tool name (allowed: A-Z, a-z, 0-9, "_", "-", ".", 1-128 chars)`, + { details: { path: `resources.${id}`, resourceId: id } }, + ); + } + } + if (entry.expose.includes('a2a') && !protocols.a2a.enabled) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" is exposed via "a2a" but protocols.a2a.enabled is false`, + { details: { path: `resources.${id}.expose`, resourceId: id } }, + ); + } + if (entry.expose.includes('acp') && !protocols.acp.enabled) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" is exposed via "acp" but protocols.acp.enabled is false`, + { details: { path: `resources.${id}.expose`, resourceId: id } }, + ); + } + if (entry.expose.includes('http') && !protocols.http.enabled) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" is exposed via "http" but protocols.http.enabled is false`, + { details: { path: `resources.${id}.expose`, resourceId: id } }, + ); + } + + validateBackendUrl(id, entry.backend.url); + validateBackendHeaders(id, entry.backend.headers); + const pathScope = validateInputBindings(id, entry.backend, entry.input); + validatePathParametersDeclared(id, entry.backend.url, pathScope.schema, pathScope.where); + + if (entry.pricing.type === 'dynamic') { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" uses pricing.type "dynamic", which is not supported in this release`, + { details: { path: `resources.${id}.pricing.type`, resourceId: id } }, + ); + } + + const paymentMethods = entry.payments ?? []; + + if (entry.x402 !== undefined) { + const at = `resources.${id}.x402`; + if (x402 === undefined) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" sets x402.accepts, but payments.x402 is not configured`, + { details: { path: at, resourceId: id } }, + ); + } + if (entry.pricing.type !== 'fixed' || !paymentMethods.includes('x402')) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" sets x402.accepts, which requires fixed pricing and "x402" in payments`, + { details: { path: at, resourceId: id } }, + ); + } + } + // The resource's own offers, or the rail's + const offers = x402 !== undefined ? (x402.resources[id] ?? x402) : undefined; + + if (entry.pricing.type === 'fixed') { + // An x402 price is charged in every option's token, so the coarsest one + // bounds it. MPP checks its own token's decimals below. + const decimals = paymentMethods.includes('x402') + ? offers?.options.map((option) => option.assetDecimals) + : undefined; + validatePricingAmount(id, entry.pricing.amount, decimals && Math.min(...decimals)); + } + + if (entry.pricing.type === 'fixed') { + if (paymentMethods.length === 0) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" has fixed pricing but declares no "payments" (a paid resource must name at least one payment method)`, + { details: { path: `resources.${id}.payments`, resourceId: id } }, + ); + } + for (const method of paymentMethods) { + if (!SUPPORTED_PAYMENT_METHODS.has(method)) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" names unsupported payment method "${method}". Supported: ${PAYMENT_METHOD_NAMES.join(', ')}.`, + { details: { path: `resources.${id}.payments`, resourceId: id, method } }, + ); + } + } + // At least one named rail must be enabled, not every one. `main.ts` checks + // again at composition as defense in depth. + const enabledRails = [ + ...(x402?.config.enabled ? ['x402'] : []), + ...(mpp?.enabled ? ['mpp'] : []), + ]; + if (!paymentMethods.some((method) => enabledRails.includes(method))) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" names payment method(s) "${paymentMethods.join(', ')}", none of which is configured and enabled under "payments"`, + { details: { path: `resources.${id}.payments`, resourceId: id, paymentMethods } }, + ); + } + // An offer in the network's default stablecoin, named or not, charges the + // price in that token, so the price must name that token or USD + const symbols = offers?.defaultAssetSymbols ?? []; + const currency = entry.pricing.currency; + if ( + x402?.config.enabled && + symbols.length > 0 && + paymentMethods.includes('x402') && + currency !== 'USD' && + !(symbols.length === 1 && currency === symbols[0]) + ) { + const tokens = symbols.join(' and '); + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" is priced in ${currency}, but x402 settles in ${tokens}, the default asset of its network. Price it in ${symbols.length === 1 ? `${tokens} or ` : ''}USD.`, + { details: { path: `resources.${id}.pricing.currency`, resourceId: id } }, + ); + } + // MPP fixes the currency label and decimal precision for every resource it serves + if (mpp?.enabled && paymentMethods.includes('mpp')) { + if (entry.pricing.currency !== MPP_PROFILE.assetSymbol) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" is priced in ${entry.pricing.currency}, but MPP charges ${MPP_PROFILE.assetSymbol}`, + { details: { path: `resources.${id}.pricing.currency`, resourceId: id } }, + ); + } + validatePricingAmount(id, entry.pricing.amount, MPP_PROFILE.assetDecimals); + } + } + + if ( + entry.paymentFlow !== undefined && + (entry.pricing.type !== 'fixed' || + !paymentMethods.some((method) => method === 'x402' || method === 'mpp')) + ) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" sets paymentFlow, which requires fixed pricing and "x402" or "mpp" in payments`, + { details: { path: `resources.${id}.paymentFlow`, resourceId: id } }, + ); + } + + const pricing: Pricing = + entry.pricing.type === 'free' + ? { type: 'free' } + : { type: 'fixed', amount: entry.pricing.amount, currency: entry.pricing.currency }; + + const authorization = normalizeResourceAuthorization(id, entry, pricing, ap2); + + return { + id, + name: entry.name, + ...(entry.description !== undefined ? { description: entry.description } : {}), + inputSchema: + entry.input !== undefined + ? defaultClosedObjectSchema(entry.input) + : EMPTY_CLOSED_OBJECT_SCHEMA, + ...(entry.output !== undefined ? { outputSchema: entry.output } : {}), + handler: { + type: 'http', + method: entry.backend.method, + url: entry.backend.url, + ...(entry.backend.headers !== undefined ? { headers: entry.backend.headers } : {}), + ...(entry.backend.timeoutMs !== undefined + ? { + timeoutMs: toNumber(entry.backend.timeoutMs, `resources.${id}.backend.timeoutMs`, { + min: 1, + }), + } + : {}), + ...(entry.backend.inputBindings !== undefined + ? { inputBindings: pickDefined(entry.backend.inputBindings) } + : {}), + }, + pricing, + exposedVia: entry.expose as CommerceResource['exposedVia'], + paymentMethods: paymentMethods as CommerceResource['paymentMethods'], + ...(authorization !== undefined ? { authorization } : {}), + }; +} + +// A plain decimal: no currency symbol, thousands separator, sign or exponent. +// "0,01", "$0.01", "1e-2" and "-1" would otherwise load and then fail every +// purchase. +const PRICING_AMOUNT_PATTERN = /^\d+(?:\.\d+)?$/; + +const ZERO_AMOUNT_PATTERN = /^0(?:\.0+)?$/; + +function validatePricingAmount(id: string, amount: string, decimals: number | undefined): void { + const path = `resources.${id}.pricing.amount`; + if (!PRICING_AMOUNT_PATTERN.test(amount)) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" has pricing.amount "${amount}", which is not a plain positive decimal (no currency symbol, no thousands separator, no exponent - e.g. "0.01")`, + { details: { path, resourceId: id } }, + ); + } + if (ZERO_AMOUNT_PATTERN.test(amount)) { + // A zero-priced paid resource would settle a zero-value transfer + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" has pricing.amount "0" - a paid resource cannot cost zero; use "pricing: { type: free }" instead`, + { details: { path, resourceId: id } }, + ); + } + if (decimals !== undefined) { + const fractionalDigits = amount.includes('.') ? (amount.split('.')[1]?.length ?? 0) : 0; + if (fractionalDigits > decimals) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" has pricing.amount "${amount}" with more precision (${fractionalDigits} fractional digits) than the configured asset supports (${decimals} decimals)`, + { details: { path, resourceId: id } }, + ); + } + } +} + +// Strips absent optional keys so the result satisfies `exactOptionalPropertyTypes` +function pickDefined>( + value: T, +): { [K in keyof T]?: string } { + return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as { + [K in keyof T]?: string; + }; +} diff --git a/src/config/schema/types.ts b/src/config/schema/types.ts new file mode 100644 index 0000000..5d82d9e --- /dev/null +++ b/src/config/schema/types.ts @@ -0,0 +1,135 @@ +// The normalized configuration the rest of the gateway reads +import type { Ap2AuthorizationConfig } from '../../authorization/ap2/types'; +import type { CommerceResource } from '../../core'; +import type { MppNetwork } from '../../payments/mpp/constants'; +import type { X402FacilitatorConfig } from '../../payments/x402/guardrails'; +import type { CustomNetworks } from '../../payments/x402/networks'; +import type { X402PaymentFlow } from '../../payments/x402/payment-flow'; +import type { AcpCheckoutOperation } from '../../protocols/acp/constants'; + +/** Optional ACP discovery metadata. Absent fields are omitted from the document */ +export interface AcpDiscoveryConfig { + readonly documentationUrl?: string; + readonly supportedCurrencies?: readonly string[]; + readonly supportedLocales?: readonly string[]; + readonly interventionTypes?: readonly string[]; +} + +/** + * Discriminated on `enabled`, so an enabled ACP config carries everything the + * adapter needs; a half-configured checkout is rejected at load + */ +export type AcpProtocolConfig = + | { readonly enabled: false; readonly mountPath: string } + | { + readonly enabled: true; + readonly mountPath: string; + readonly auth: { readonly type: 'bearer'; readonly token: string }; + readonly idempotency: { + readonly path: string; + readonly retentionHours: number; + readonly merchantIdempotent: boolean; + }; + readonly checkout: { + readonly operations: Readonly>; + }; + readonly discovery?: AcpDiscoveryConfig; + }; + +/** One resolved x402 option: where a payment settles and in which token */ +export interface X402OptionConfig { + readonly network: string; + /** This network's RPC: `payments.x402.networks..rpcUrl`, else `payments.x402.rpcUrl` */ + readonly rpcUrl: string; + readonly asset: string; + /** EIP-712 domain of the token; always present for EIP-3009 */ + readonly assetName?: string; + readonly assetVersion?: string; + readonly assetDecimals: number; + readonly payTo: string; + /** Absent for EIP-3009, the default */ + readonly assetTransferMethod?: 'permit2'; +} + +export interface GatewayConfig { + readonly version: 1; + readonly merchant: { readonly id: string; readonly name: string; readonly publicBaseUrl: string }; + readonly server: { + readonly port: number; + readonly host: string; + readonly adminToken?: string; + readonly allowedOrigins: readonly string[]; + }; + readonly storage: { readonly receipts: { readonly driver: 'sqlite'; readonly path: string } }; + readonly protocols: { + readonly http: { readonly enabled: boolean }; + readonly mcp: { readonly enabled: boolean; readonly mountPath: string }; + readonly a2a: { readonly enabled: boolean; readonly mountPath: string }; + readonly acp: AcpProtocolConfig; + }; + /** Canonical resources, already normalized */ + readonly resources: readonly CommerceResource[]; + readonly payments: { + readonly x402?: { + readonly enabled: boolean; + readonly network: string; + readonly rpcUrl: string; + readonly asset: string; + /** Always present for EIP-3009; a Permit2 option may have none */ + readonly assetName?: string; + readonly assetVersion?: string; + readonly assetDecimals: number; + readonly payTo: string; + /** Absent for EIP-3009, the default */ + readonly assetTransferMethod?: 'permit2'; + readonly maxTimeoutSeconds: number; + /** Defaults to `authorization`, the x402 default */ + readonly paymentFlow?: X402PaymentFlow; + /** Resources whose `paymentFlow` overrides the rail's, by resource id */ + readonly resourcePaymentFlows?: Readonly>; + readonly facilitator: X402FacilitatorConfig; + readonly allowMainnet?: boolean; + readonly allowUnauthenticatedFacilitator?: boolean; + /** EVM networks declared beside the built-in ones, by CAIP-2 id */ + readonly networks?: CustomNetworks; + /** + * Resolved options in challenge order; fields above mirror the first. + * Omit for manually built single-option configs that skip `parseConfig` + */ + readonly options?: readonly X402OptionConfig[]; + /** + * Resource `x402.accepts` overrides by id, replacing `options`. + * Absent when no resource overrides its offers. + */ + readonly resourceOptions?: Readonly>; + }; + readonly mpp?: { + readonly enabled: boolean; + readonly network: MppNetwork; + readonly rpcUrl: string; + readonly asset: string; + readonly assetName: string; + readonly assetVersion: string; + readonly recipient: string; + readonly realm: string; + readonly challengeSecret: string; + readonly challengeTtlSeconds?: number; + /** Defaults to `upfront`: settle before the backend call */ + readonly paymentFlow?: X402PaymentFlow; + /** Resources whose `paymentFlow` overrides the rail's, by resource id */ + readonly resourcePaymentFlows?: Readonly>; + readonly facilitator: X402FacilitatorConfig; + readonly allowMainnet?: boolean; + readonly allowUnauthenticatedFacilitator?: boolean; + }; + }; + /** Absent when no `authorization:` block is configured */ + readonly authorization?: { readonly ap2: Ap2AuthorizationConfig }; +} + +export interface NormalizedProtocols { + readonly http: { readonly enabled: boolean }; + readonly mcp: { readonly enabled: boolean; readonly mountPath: string }; + readonly a2a: { readonly enabled: boolean; readonly mountPath: string }; + readonly acp: AcpProtocolConfig; +} diff --git a/src/config/schema/values.ts b/src/config/schema/values.ts new file mode 100644 index 0000000..c097426 --- /dev/null +++ b/src/config/schema/values.ts @@ -0,0 +1,80 @@ +// Readers for leaves that env substitution may have turned into strings, and addresses +import { CommerceError } from '../../core'; +import { isEvmAddress, isEvmZeroAddress } from '../../payments/x402/addresses'; + +export function toNumber( + value: number | string, + path: string, + bounds: { min?: number; max?: number } = {}, +): number { + // Plain decimal digits only. `Number('')` is 0, which `server.port` accepts + // ("let the OS pick"), so an empty `${PORT:-}` would bind a random port. + // `Number` also accepts `"0x50"` and `"1e3"` for fields documented as decimal. + if (typeof value === 'string' && !/^\s*\d+\s*$/.test(value)) { + throw new CommerceError( + 'CONFIG_INVALID', + `Configuration value at "${path}" must be an integer written in decimal digits (got ${value === '' ? 'an empty string' : `"${value}"`})`, + { details: { path } }, + ); + } + const n = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(n) || !Number.isInteger(n)) { + throw new CommerceError( + 'CONFIG_INVALID', + `Configuration value at "${path}" must be an integer`, + { + details: { path }, + }, + ); + } + if (bounds.min !== undefined && n < bounds.min) { + throw new CommerceError( + 'CONFIG_INVALID', + `Configuration value at "${path}" must be >= ${bounds.min}`, + { + details: { path }, + }, + ); + } + if (bounds.max !== undefined && n > bounds.max) { + throw new CommerceError( + 'CONFIG_INVALID', + `Configuration value at "${path}" must be <= ${bounds.max}`, + { + details: { path }, + }, + ); + } + return n; +} + +export function toBoolean(value: boolean | string, path: string): boolean { + if (typeof value === 'boolean') return value; + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + throw new CommerceError( + 'CONFIG_INVALID', + `Configuration value at "${path}" must be a boolean ("true" or "false")`, + { details: { path } }, + ); +} + +export function validateAddress(path: string, value: string): void { + if (!isEvmAddress(value)) { + throw new CommerceError( + 'CONFIG_INVALID', + `Configuration value at "${path}" is not a plausible address (expected "0x" followed by 40 hex characters)`, + { details: { path } }, + ); + } + if (isEvmZeroAddress(value)) { + throw new CommerceError( + 'CONFIG_INVALID', + `Configuration value at "${path}" must not be the zero address`, + { + details: { path }, + }, + ); + } +} diff --git a/src/config/schema/x402.ts b/src/config/schema/x402.ts new file mode 100644 index 0000000..e52c7eb --- /dev/null +++ b/src/config/schema/x402.ts @@ -0,0 +1,274 @@ +// The x402 rail: direct or listed options, declared networks and the facilitator +import type { z } from 'zod'; +import { CommerceError } from '../../core'; +import { resolveX402Deployment, type X402FacilitatorConfig } from '../../payments/x402/guardrails'; +import { + type CustomNetworkDeclaration, + findX402Network, + validateCustomNetworks, +} from '../../payments/x402/networks'; +import { resolveX402Option, type X402OptionInput } from '../../payments/x402/options'; +import { X402_DEFAULT_PAYMENT_FLOW } from '../../payments/x402/payment-flow'; +import type { FacilitatorSchema, RawConfig } from './raw'; +import type { GatewayConfig, X402OptionConfig } from './types'; +import { toBoolean, toNumber } from './values'; + +type X402Config = NonNullable; + +// One offer list: the rail's options or a resource's own accepts +export interface X402OfferTerms { + /** Every option, resolved, in order */ + readonly options: readonly X402OptionConfig[]; + /** + * Default symbols for options omitting `asset`; prices must use USD or + * the shared symbol + */ + readonly defaultAssetSymbols: readonly string[]; +} + +export interface NormalizedX402 extends X402OfferTerms { + readonly config: X402Config; + /** Resources with their own `x402.accepts`, by id; each replaces the rail's options */ + readonly resources: Readonly>; +} + +function x402Invalid(message: string, path: string): CommerceError { + return new CommerceError('CONFIG_INVALID', message, { details: { path } }); +} + +// The fields of one option, as they appear directly under `payments.x402` +const DIRECT_OPTION_FIELDS = [ + 'network', + 'payTo', + 'asset', + 'assetName', + 'assetVersion', + 'assetDecimals', + 'assetTransferMethod', +] as const; + +type RawX402 = NonNullable; + +// An entry of `options`, or `payments.x402` itself in the direct form +interface RawX402Option { + readonly scheme?: string | undefined; + readonly network?: string | undefined; + readonly payTo?: string | undefined; + readonly asset?: string | undefined; + readonly assetName?: string | undefined; + readonly assetVersion?: string | undefined; + readonly assetDecimals?: number | string | undefined; + readonly assetTransferMethod?: string | undefined; +} + +function x402OptionInput(raw: RawX402Option, path: string): X402OptionInput { + return { + ...(raw.scheme !== undefined ? { scheme: raw.scheme } : {}), + network: raw.network as string, + payTo: raw.payTo as string, + ...(raw.asset !== undefined ? { asset: raw.asset } : {}), + ...(raw.assetName !== undefined ? { assetName: raw.assetName } : {}), + ...(raw.assetVersion !== undefined ? { assetVersion: raw.assetVersion } : {}), + ...(raw.assetTransferMethod !== undefined + ? { assetTransferMethod: raw.assetTransferMethod } + : {}), + ...(raw.assetDecimals !== undefined + ? { + assetDecimals: toNumber(raw.assetDecimals, `${path}.assetDecimals`, { min: 0, max: 36 }), + } + : {}), + }; +} + +/** + * Resolves direct `payments.x402` fields like a one-entry `options` array, + * preserving config form. Resource `x402.accepts` uses the same resolution + * and rail-level networks, RPC endpoints, facilitator and mainnet settings. + */ +export function normalizeX402(raw: RawX402, resources: RawConfig['resources']): NormalizedX402 { + const at = 'payments.x402'; + const direct = DIRECT_OPTION_FIELDS.filter((field) => raw[field] !== undefined); + if (raw.options !== undefined && direct.length > 0) { + throw x402Invalid( + `${at}: give the option either under ${at}.options or directly (${direct.join(', ')}), not both`, + `${at}.options`, + ); + } + if (raw.options?.length === 0) { + throw x402Invalid(`${at}.options is empty; list at least one option`, `${at}.options`); + } + if (raw.options === undefined) { + for (const field of ['network', 'payTo'] as const) { + if (raw[field] === undefined) { + throw x402Invalid( + `${at}.${field} is required, unless the options are listed under ${at}.options`, + `${at}.${field}`, + ); + } + } + } + + // `kind` and `displayName` declare a network; `rpcUrl` alone sets the + // endpoint of a built-in or declared one + const settings = raw.networks ?? {}; + const declared: Record = {}; + for (const [id, entry] of Object.entries(settings)) { + if (entry.kind === undefined && entry.displayName === undefined) continue; + if (entry.kind === undefined || entry.displayName === undefined) { + const path = `${at}.networks.${id}`; + throw x402Invalid(`${path}: declaring a network needs both kind and displayName`, path); + } + declared[id] = { kind: entry.kind, displayName: entry.displayName }; + } + const networks = Object.keys(declared).length > 0 ? declared : undefined; + if (networks !== undefined) validateCustomNetworks(networks, `${at}.networks`); + for (const id of Object.keys(settings)) { + if (!findX402Network(id, networks)) { + const path = `${at}.networks.${id}`; + throw x402Invalid( + `${path}: "${id}" is not a known network; declare it with kind and displayName`, + path, + ); + } + } + + const facilitator = normalizeFacilitator(raw.facilitator); + const allowMainnet = + raw.allowMainnet !== undefined ? toBoolean(raw.allowMainnet, `${at}.allowMainnet`) : undefined; + const allowUnauthenticatedFacilitator = + raw.allowUnauthenticatedFacilitator !== undefined + ? toBoolean(raw.allowUnauthenticatedFacilitator, `${at}.allowUnauthenticatedFacilitator`) + : undefined; + + // Shared by every list, so the rail and its resources use one RPC endpoint + // per network and one deployment mode + const rpcOwners = new Map(); + let firstMode: string | undefined; + const resolveList = (entries: readonly (readonly [string, RawX402Option])[]): X402OfferTerms => { + const resolved = entries.map(([path, entry]) => + resolveX402Option(x402OptionInput(entry, path), path, networks), + ); + const seen = new Set(); + const options = resolved.map((option, i): X402OptionConfig => { + const path = entries[i]?.[0] as string; + const network = option.network.id; + // Require distinct RPC endpoints so each network's health check reads its chain + const rpcUrl = settings[network]?.rpcUrl ?? raw.rpcUrl; + if (rpcUrl === undefined) { + throw x402Invalid( + `${path}.network: no RPC endpoint for ${network}; set ${at}.rpcUrl or ${at}.networks.${network}.rpcUrl`, + `${at}.rpcUrl`, + ); + } + // Parsed, so `https://rpc.example` and `https://rpc.example/` are one endpoint + const endpoint = new URL(rpcUrl).href; + const owner = rpcOwners.get(endpoint); + if (owner !== undefined && owner !== network) { + throw x402Invalid( + `${at}: ${owner} and ${network} would use the same RPC endpoint; give each network its own ${at}.networks..rpcUrl`, + `${at}.networks`, + ); + } + rpcOwners.set(endpoint, network); + + // EVM addresses compare case-insensitively; Solana and NEAR exactly + const comparable = (value: string) => + option.network.family === 'evm' ? value.toLowerCase() : value; + const key = `${network}|${comparable(option.asset)}|${comparable(option.payTo)}`; + if (seen.has(key)) { + throw x402Invalid( + `${path}: repeats an earlier option, the same asset to the same payTo on ${network}`, + path, + ); + } + seen.add(key); + + // The provider makes the same call at construction; running it here lets + // `agent-commerce validate` report an unsafe deployment before boot + const { mode } = resolveX402Deployment({ + network, + payTo: option.payTo, + asset: option.asset, + ...(option.assetName !== undefined ? { assetName: option.assetName } : {}), + ...(option.assetVersion !== undefined ? { assetVersion: option.assetVersion } : {}), + facilitator, + ...(allowMainnet !== undefined ? { allowMainnet } : {}), + ...(allowUnauthenticatedFacilitator !== undefined + ? { allowUnauthenticatedFacilitator } + : {}), + ...(networks !== undefined ? { customNetworks: networks } : {}), + }); + // Keep all offers in one deployment mode: local, testnet or mainnet + if (firstMode !== undefined && mode !== firstMode) { + throw x402Invalid( + `${path}.network: ${network} is a ${mode} network, but the first option is on a ${firstMode} one; every option must be on the same kind of network`, + `${path}.network`, + ); + } + firstMode = mode; + + return { + network, + rpcUrl, + asset: option.asset, + ...(option.assetName !== undefined ? { assetName: option.assetName } : {}), + ...(option.assetVersion !== undefined ? { assetVersion: option.assetVersion } : {}), + assetDecimals: option.assetDecimals, + payTo: option.payTo, + ...(option.assetTransferMethod === 'permit2' + ? { assetTransferMethod: 'permit2' as const } + : {}), + }; + }); + return { + options, + defaultAssetSymbols: [ + ...new Set(resolved.flatMap((option) => option.defaultAssetSymbol ?? [])), + ], + }; + }; + + const rail = resolveList( + raw.options !== undefined + ? raw.options.map((option, i) => [`${at}.options[${i}]`, option] as const) + : [[at, raw]], + ); + const resourceTerms: Record = {}; + for (const [id, entry] of Object.entries(resources)) { + const accepts = entry.x402?.accepts; + if (accepts === undefined) continue; + const path = `resources.${id}.x402.accepts`; + if (accepts.length === 0) { + throw x402Invalid(`${path} is empty; list at least one option`, path); + } + resourceTerms[id] = resolveList(accepts.map((option, i) => [`${path}[${i}]`, option] as const)); + } + const options = rail.options; + const first = options[0] as X402OptionConfig; + const resourceOptions = Object.fromEntries( + Object.entries(resourceTerms).map(([id, terms]) => [id, terms.options]), + ); + + const config: X402Config = { + enabled: toBoolean(raw.enabled, `${at}.enabled`), + ...first, + maxTimeoutSeconds: toNumber(raw.maxTimeoutSeconds, `${at}.maxTimeoutSeconds`, { min: 1 }), + paymentFlow: raw.paymentFlow ?? X402_DEFAULT_PAYMENT_FLOW, + facilitator, + ...(allowMainnet !== undefined ? { allowMainnet } : {}), + ...(allowUnauthenticatedFacilitator !== undefined ? { allowUnauthenticatedFacilitator } : {}), + ...(networks !== undefined ? { networks } : {}), + options, + ...(Object.keys(resourceOptions).length > 0 ? { resourceOptions } : {}), + }; + + return { config, ...rail, resources: resourceTerms }; +} + +export function normalizeFacilitator( + raw: z.infer, +): X402FacilitatorConfig { + return raw.mode === 'local' + ? { mode: 'local', signerPrivateKey: raw.signerPrivateKey } + : { mode: 'remote', url: raw.url, auth: raw.auth ?? { type: 'none' } }; +} diff --git a/src/core/domain/common.ts b/src/core/domain/common.ts index 14469e4..995feba 100644 --- a/src/core/domain/common.ts +++ b/src/core/domain/common.ts @@ -67,6 +67,17 @@ export interface AdapterHealth { readonly detail?: string; readonly checkedAt: IsoTimestamp; readonly durationMs?: number; + /** + * Results per checked part, such as each x402 offer. Names and statuses + * may be published; exclude diagnostic detail. + */ + readonly components?: readonly AdapterHealthComponent[]; +} + +/** One part of an {@link AdapterHealth} result */ +export interface AdapterHealthComponent { + readonly name: string; + readonly status: 'pass' | 'warn' | 'fail'; } /** Describes an adapter's supported surface for diagnostics */ diff --git a/src/core/domain/payment.ts b/src/core/domain/payment.ts index e50d3c4..ef6ca38 100644 --- a/src/core/domain/payment.ts +++ b/src/core/domain/payment.ts @@ -48,8 +48,12 @@ export interface PaymentRequirement { readonly provider: PaymentMethodName; readonly amount: DecimalAmount; readonly currency: string; - /** Merchant-controlled settlement destination, never a gateway-owned wallet */ - readonly destination: string; + /** + * Merchant-controlled settlement destination, never a gateway-owned wallet. + * Multi-offer challenges omit this, `network` and `asset`; selection + * supplies the chosen offer's terms. + */ + readonly destination?: string; readonly network?: string; readonly asset?: string; readonly expiresAt?: IsoTimestamp; @@ -132,6 +136,15 @@ export interface PaymentProvider { */ createRequirement(context: PaymentContext): Promise; + /** + * Selects a proof's offer before verification, returning its destination, + * network, asset and metadata while preserving `id` and the full `challenge`. + * Verification, authorization, replay reservation and settlement use the result. + * This hook must not verify, reserve or settle. If no unique offer matches, + * return the original requirement for `verify` to reject. + */ + selectRequirement?(context: PaymentVerificationContext): Promise; + /** * Validate a submitted proof against the requirement. Must return `rejected` * or throw a `CommerceError` rather than an untyped error, and must never diff --git a/src/core/domain/wire.ts b/src/core/domain/wire.ts index 49a9889..841d08e 100644 --- a/src/core/domain/wire.ts +++ b/src/core/domain/wire.ts @@ -103,8 +103,14 @@ export interface PaymentRequiredEnvelope { readonly version: string; readonly amount: DecimalAmount; readonly currency: string; - readonly destination: string; + /** + * @deprecated Read each offer's `payTo` in `accepts`. Absent when the + * challenge offers several destinations. + */ + readonly destination?: string; + /** @deprecated Read each offer's `network` in `accepts` */ readonly network?: string; + /** @deprecated Read each offer's `asset` in `accepts` */ readonly asset?: string; readonly expiresAt?: IsoTimestamp; /** Provider-native requirement objects, passed through verbatim */ @@ -198,7 +204,7 @@ export function toPaymentRequiredEnvelope( version: r.challenge.version, amount: r.amount, currency: r.currency, - destination: r.destination, + ...(r.destination !== undefined ? { destination: r.destination } : {}), ...(r.network !== undefined ? { network: r.network } : {}), ...(r.asset !== undefined ? { asset: r.asset } : {}), ...(r.expiresAt !== undefined ? { expiresAt: r.expiresAt } : {}), diff --git a/src/core/execution/backend-http.ts b/src/core/execution/backend-http.ts index c9bb274..ddd54d1 100644 --- a/src/core/execution/backend-http.ts +++ b/src/core/execution/backend-http.ts @@ -6,30 +6,27 @@ * (SSRF hardening). * - `{param}` segments in `handler.url` are filled from validated input and * URL-encoded; the rest is mapped as `BackendHandler.inputBindings` - * describes. + * describes (see `backend-request.ts`). */ -import { - type BackendHandler, - type BackendMethod, - DEFAULT_BACKEND_TIMEOUT_MS, -} from '../domain/resource'; +import { type BackendHandler, DEFAULT_BACKEND_TIMEOUT_MS } from '../domain/resource'; import { CommerceError, isCommerceError } from '../errors'; import type { BackendExecutor, BackendRequest, BackendResponse } from '../interfaces/backend'; import { type Logger, NOOP_LOGGER } from '../interfaces/logger'; import { isRecord } from '../is-record'; +import { + buildBackendRequestParts, + buildHeaders, + checkQueryCollision, + setHeader, + stringifyPrimitive, +} from './backend-request'; const MAX_BODY_SNIPPET_LENGTH = 512; + // How a merchant recognizes a repeat of an operation. The de facto standard // name, and the one ACP requires inbound. const IDEMPOTENCY_KEY_HEADER = 'idempotency-key'; -// Never forward credentials, transport headers or fields set by the executor -const NEVER_FORWARDED_HEADER = - /^(authorization|cookie|set-cookie|host|content-length|content-type|connection|keep-alive|te|trailer|transfer-encoding|upgrade|idempotency-key|proxy-.*)$/; -// The one `{param}` grammar, shared by substitution, config, `doctor` and the -// OpenAPI importer. It admits `-` and `.` because -// `/report/{report-id}` is ordinary REST. Config refuses any other brace -// (findUnparsedBraceToken). -const PATH_PARAM_PATTERN = /\{([a-zA-Z0-9_.-]+)\}/g; + // The timeout bounds a response by time, not bytes; this stops a hostile or // broken backend from filling memory const MAX_RESPONSE_BODY_BYTES = 1024 * 1024; @@ -217,245 +214,6 @@ export class HttpBackendExecutor implements BackendExecutor { } } -/** - * `{param}` names in a `backend.url` template, in declaration order. Config, - * `doctor` and the OpenAPI importer use it so they read the same grammar as - * substitution. - */ -export function extractPathParameterNames(url: string): string[] { - return [...url.matchAll(PATH_PARAM_PATTERN)].map((match) => match[1] as string); -} - -/** - * A brace left over after every legal `{param}` is removed, or `undefined`. - * - * `{report id}`, `{a/b}`, `{}` and an unbalanced `{` match no parameter and - * would reach the backend as literals, which on a paid resource means payment - * without delivery. Config refuses any such residue, and the OpenAPI importer - * skips the operation. - */ -export function findUnparsedBraceToken(url: string): string | undefined { - const residue = url.replace(PATH_PARAM_PATTERN, ''); - const index = residue.search(/[{}]/); - if (index === -1) return undefined; - // Report the offending run, not just the character, so the error is fixable - const token = /\{[^{}]*\}?|\}/.exec(residue.slice(index)); - return token?.[0] ?? residue[index]; -} - -/** - * Check path values, query collisions and headers before pricing. `call()` - * repeats these checks. Invalid input or forwarded headers raise - * `INPUT_INVALID`; invalid configured headers raise `BACKEND_ERROR`. - * An unparseable URL remains a call-time error. Does no I/O. - */ -export function validateBackendRequestShape( - handler: BackendHandler, - input: unknown, - context: ShapeContext, - backendHeaders?: Readonly>, -): void { - const inputRecord = isRecord(input) ? input : {}; - - // Every shape error (missing or invalid path parameter, a bound group that is - // not an object) throws INPUT_INVALID here - const parts = buildBackendRequestParts(handler, inputRecord, context); - // Config refuses a bad header at load; this covers a hand-built resource - buildHeaders(handler, context, backendHeaders); - - let target: URL; - try { - target = new URL(parts.url); - } catch { - return; // call() raises BACKEND_ERROR for an unparseable URL - } - checkQueryCollision(target, parts.query, context); -} - -type ShapeContext = { readonly requestId: string; readonly resourceId: string }; - -// The path-templated URL plus the query and body values a request carries -interface BackendRequestParts { - readonly url: string; - readonly query: Record; - // Present when a JSON body should be sent; `value` is what gets encoded - readonly body?: { readonly value: unknown }; -} - -function acceptsBody(method: BackendMethod): boolean { - return method !== 'GET' && method !== 'DELETE'; -} - -/** - * Split validated input into URL, query and body according to - * `handler.inputBindings`. `call()` and `validateBackendRequestShape()` both - * use it, so they cannot disagree about the request an input describes. Throws - * only `CommerceError('INPUT_INVALID')`. - */ -function buildBackendRequestParts( - handler: BackendHandler, - input: Record, - context: ShapeContext, -): BackendRequestParts { - const bindings = handler.inputBindings; - if (bindings === undefined) { - const { url, remaining } = applyPathTemplate(handler.url, input, context); - return acceptsBody(handler.method) - ? { url, query: {}, body: { value: remaining } } - : { url, query: remaining }; - } - - const pathValues = - bindings.path === undefined ? {} : resolveBoundGroup(input, bindings.path, 'path', context); - const { url } = applyPathTemplate(handler.url, pathValues, context); - const query = - bindings.query === undefined ? {} : resolveBoundGroup(input, bindings.query, 'query', context); - - // An absent body value sends no body rather than `null`. A required body is - // enforced earlier, by `required` in the resource's input schema. - const bodyValue = bindings.body === undefined ? undefined : input[bindings.body]; - if (bodyValue === undefined || !acceptsBody(handler.method)) return { url, query }; - return { url, query, body: { value: bodyValue } }; -} - -function resolveBoundGroup( - input: Record, - key: string, - kind: 'path' | 'query', - context: ShapeContext, -): Record { - const value = input[key]; - if (value === undefined) return {}; - if (!isRecord(value)) { - throw new CommerceError( - 'INPUT_INVALID', - `Input "${key}" must be an object of ${kind} parameters`, - { ...context, details: { field: key } }, - ); - } - return value; -} - -function checkQueryCollision( - target: URL, - remaining: Record, - context: { readonly requestId: string; readonly resourceId: string }, -): void { - const templateKeys = new Set(target.searchParams.keys()); - for (const key of Object.keys(remaining)) { - if (templateKeys.has(key)) { - throw new CommerceError( - 'INPUT_INVALID', - `Input key "${key}" collides with a query parameter already set on backend.url`, - { requestId: context.requestId, resourceId: context.resourceId, details: { field: key } }, - ); - } - } -} - -// encodeURIComponent leaves "." alone, and new URL() then resolves a "." or -// ".." segment, stepping outside the template's directory. Slashes are -// escaped, so only these exact values (and "", an empty segment) need refusing. -const TRAVERSAL_PATH_VALUES = new Set(['', '.', '..']); - -function applyPathTemplate( - template: string, - input: Record, - context: ShapeContext, -): { url: string; remaining: Record } { - const remaining: Record = { ...input }; - let missing: string | undefined; - let invalid: string | undefined; - const url = template.replace(PATH_PARAM_PATTERN, (_match, key: string) => { - // Object.hasOwn, not `in`: a parameter named "constructor" or "toString" - // would otherwise resolve to the inherited Object.prototype member - if (!Object.hasOwn(remaining, key)) { - missing = key; - return ''; - } - const value = remaining[key]; - delete remaining[key]; - const raw = stringifyPrimitive(value); - if (TRAVERSAL_PATH_VALUES.has(raw)) { - invalid = key; - return ''; - } - return encodeURIComponent(raw); - }); - if (missing !== undefined) { - // Config refuses a template parameter the input schema cannot supply. - // This covers a hand-built `CommerceResource` that skipped config: through - // validateBackendRequestShape it fails before payment, instead of calling - // the backend with an empty path segment. - throw new CommerceError('INPUT_INVALID', `Path parameter "${missing}" was not supplied`, { - ...context, - details: { field: missing }, - }); - } - if (invalid !== undefined) { - throw new CommerceError('INPUT_INVALID', `Path parameter "${invalid}" is not a valid value`, { - ...context, - details: { field: invalid }, - }); - } - return { url, remaining }; -} - -function stringifyPrimitive(value: unknown): string { - if (value === undefined || value === null) return ''; - if (typeof value === 'string') return value; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -// `Headers` throws a TypeError on an illegal name or value. The error is -// rethrown typed and without `cause`: the TypeError quotes the value, which may -// be a credential. -function buildHeaders( - handler: BackendHandler, - context: ShapeContext, - forwarded: Readonly> = {}, -): Headers { - let headers: Headers; - try { - headers = new Headers(handler.headers); - } catch { - throw invalidHeaderError(context); - } - // Caller headers cannot replace operator-configured backend credentials - for (const [name, value] of Object.entries(forwarded)) { - if (NEVER_FORWARDED_HEADER.test(name.toLowerCase())) continue; - try { - if (!headers.has(name)) headers.set(name, value); - } catch { - throw new CommerceError('INPUT_INVALID', 'A forwarded request header is invalid', { - ...context, - details: { reason: 'invalid-forwarded-header' }, - }); - } - } - return headers; -} - -function setHeader(headers: Headers, name: string, value: string, context: ShapeContext): void { - try { - headers.set(name, value); - } catch { - throw invalidHeaderError(context); - } -} - -function invalidHeaderError(context: ShapeContext): CommerceError { - return new CommerceError('BACKEND_ERROR', 'Backend request headers are invalid', { - ...context, - details: { reason: 'invalid-header' }, - }); -} - // `handler.url` never changes, so its literal prefix is parsed once per handler. // `null` records a prefix that is not a URL, as when a parameter sits in the // host (which config refuses); the containment check is then skipped. diff --git a/src/core/execution/backend-request.ts b/src/core/execution/backend-request.ts new file mode 100644 index 0000000..d9c997b --- /dev/null +++ b/src/core/execution/backend-request.ts @@ -0,0 +1,262 @@ +/** + * Shapes a backend request from validated input: the path template, query, + * body and forwarded headers. The pipeline checks the shape before pricing, so + * a request the backend cannot receive fails before any payment. + */ +import type { BackendHandler, BackendMethod } from '../domain/resource'; +import { CommerceError } from '../errors'; +import { isRecord } from '../is-record'; + +// Never forward credentials, transport headers or fields set by the executor +const NEVER_FORWARDED_HEADER = + /^(authorization|cookie|set-cookie|host|content-length|content-type|connection|keep-alive|te|trailer|transfer-encoding|upgrade|idempotency-key|proxy-.*)$/; + +// The one `{param}` grammar, shared by substitution, config, `doctor` and the +// OpenAPI importer. It admits `-` and `.` because +// `/report/{report-id}` is ordinary REST. Config refuses any other brace +// (findUnparsedBraceToken). +const PATH_PARAM_PATTERN = /\{([a-zA-Z0-9_.-]+)\}/g; + +/** + * `{param}` names in a `backend.url` template, in declaration order. Config, + * `doctor` and the OpenAPI importer use it so they read the same grammar as + * substitution. + */ +export function extractPathParameterNames(url: string): string[] { + return [...url.matchAll(PATH_PARAM_PATTERN)].map((match) => match[1] as string); +} + +/** + * A brace left over after every legal `{param}` is removed, or `undefined`. + * + * `{report id}`, `{a/b}`, `{}` and an unbalanced `{` match no parameter and + * would reach the backend as literals, which on a paid resource means payment + * without delivery. Config refuses any such residue, and the OpenAPI importer + * skips the operation. + */ +export function findUnparsedBraceToken(url: string): string | undefined { + const residue = url.replace(PATH_PARAM_PATTERN, ''); + const index = residue.search(/[{}]/); + if (index === -1) return undefined; + // Report the offending run, not just the character, so the error is fixable + const token = /\{[^{}]*\}?|\}/.exec(residue.slice(index)); + return token?.[0] ?? residue[index]; +} + +/** + * Check path values, query collisions and headers before pricing. `call()` + * repeats these checks. Invalid input or forwarded headers raise + * `INPUT_INVALID`; invalid configured headers raise `BACKEND_ERROR`. + * An unparseable URL remains a call-time error. Does no I/O. + */ +export function validateBackendRequestShape( + handler: BackendHandler, + input: unknown, + context: ShapeContext, + backendHeaders?: Readonly>, +): void { + const inputRecord = isRecord(input) ? input : {}; + + // Every shape error (missing or invalid path parameter, a bound group that is + // not an object) throws INPUT_INVALID here + const parts = buildBackendRequestParts(handler, inputRecord, context); + // Config refuses a bad header at load; this covers a hand-built resource + buildHeaders(handler, context, backendHeaders); + + let target: URL; + try { + target = new URL(parts.url); + } catch { + return; // call() raises BACKEND_ERROR for an unparseable URL + } + checkQueryCollision(target, parts.query, context); +} + +type ShapeContext = { readonly requestId: string; readonly resourceId: string }; + +// The path-templated URL plus the query and body values a request carries +interface BackendRequestParts { + readonly url: string; + readonly query: Record; + // Present when a JSON body should be sent; `value` is what gets encoded + readonly body?: { readonly value: unknown }; +} + +function acceptsBody(method: BackendMethod): boolean { + return method !== 'GET' && method !== 'DELETE'; +} + +/** + * Split validated input into URL, query and body according to + * `handler.inputBindings`. `call()` and `validateBackendRequestShape()` both + * use it, so they cannot disagree about the request an input describes. Throws + * only `CommerceError('INPUT_INVALID')`. + */ +export function buildBackendRequestParts( + handler: BackendHandler, + input: Record, + context: ShapeContext, +): BackendRequestParts { + const bindings = handler.inputBindings; + if (bindings === undefined) { + const { url, remaining } = applyPathTemplate(handler.url, input, context); + return acceptsBody(handler.method) + ? { url, query: {}, body: { value: remaining } } + : { url, query: remaining }; + } + + const pathValues = + bindings.path === undefined ? {} : resolveBoundGroup(input, bindings.path, 'path', context); + const { url } = applyPathTemplate(handler.url, pathValues, context); + const query = + bindings.query === undefined ? {} : resolveBoundGroup(input, bindings.query, 'query', context); + + // An absent body value sends no body rather than `null`. A required body is + // enforced earlier, by `required` in the resource's input schema. + const bodyValue = bindings.body === undefined ? undefined : input[bindings.body]; + if (bodyValue === undefined || !acceptsBody(handler.method)) return { url, query }; + return { url, query, body: { value: bodyValue } }; +} + +function resolveBoundGroup( + input: Record, + key: string, + kind: 'path' | 'query', + context: ShapeContext, +): Record { + const value = input[key]; + if (value === undefined) return {}; + if (!isRecord(value)) { + throw new CommerceError( + 'INPUT_INVALID', + `Input "${key}" must be an object of ${kind} parameters`, + { ...context, details: { field: key } }, + ); + } + return value; +} + +export function checkQueryCollision( + target: URL, + remaining: Record, + context: { readonly requestId: string; readonly resourceId: string }, +): void { + const templateKeys = new Set(target.searchParams.keys()); + for (const key of Object.keys(remaining)) { + if (templateKeys.has(key)) { + throw new CommerceError( + 'INPUT_INVALID', + `Input key "${key}" collides with a query parameter already set on backend.url`, + { requestId: context.requestId, resourceId: context.resourceId, details: { field: key } }, + ); + } + } +} + +// encodeURIComponent leaves "." alone, and new URL() then resolves a "." or +// ".." segment, stepping outside the template's directory. Slashes are +// escaped, so only these exact values (and "", an empty segment) need refusing. +const TRAVERSAL_PATH_VALUES = new Set(['', '.', '..']); + +function applyPathTemplate( + template: string, + input: Record, + context: ShapeContext, +): { url: string; remaining: Record } { + const remaining: Record = { ...input }; + let missing: string | undefined; + let invalid: string | undefined; + const url = template.replace(PATH_PARAM_PATTERN, (_match, key: string) => { + // Object.hasOwn, not `in`: a parameter named "constructor" or "toString" + // would otherwise resolve to the inherited Object.prototype member + if (!Object.hasOwn(remaining, key)) { + missing = key; + return ''; + } + const value = remaining[key]; + delete remaining[key]; + const raw = stringifyPrimitive(value); + if (TRAVERSAL_PATH_VALUES.has(raw)) { + invalid = key; + return ''; + } + return encodeURIComponent(raw); + }); + if (missing !== undefined) { + // Config refuses a template parameter the input schema cannot supply. + // This covers a hand-built `CommerceResource` that skipped config: through + // validateBackendRequestShape it fails before payment, instead of calling + // the backend with an empty path segment. + throw new CommerceError('INPUT_INVALID', `Path parameter "${missing}" was not supplied`, { + ...context, + details: { field: missing }, + }); + } + if (invalid !== undefined) { + throw new CommerceError('INPUT_INVALID', `Path parameter "${invalid}" is not a valid value`, { + ...context, + details: { field: invalid }, + }); + } + return { url, remaining }; +} + +export function stringifyPrimitive(value: unknown): string { + if (value === undefined || value === null) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +// `Headers` throws a TypeError on an illegal name or value. The error is +// rethrown typed and without `cause`: the TypeError quotes the value, which may +// be a credential. +export function buildHeaders( + handler: BackendHandler, + context: ShapeContext, + forwarded: Readonly> = {}, +): Headers { + let headers: Headers; + try { + headers = new Headers(handler.headers); + } catch { + throw invalidHeaderError(context); + } + // Caller headers cannot replace operator-configured backend credentials + for (const [name, value] of Object.entries(forwarded)) { + if (NEVER_FORWARDED_HEADER.test(name.toLowerCase())) continue; + try { + if (!headers.has(name)) headers.set(name, value); + } catch { + throw new CommerceError('INPUT_INVALID', 'A forwarded request header is invalid', { + ...context, + details: { reason: 'invalid-forwarded-header' }, + }); + } + } + return headers; +} + +export function setHeader( + headers: Headers, + name: string, + value: string, + context: ShapeContext, +): void { + try { + headers.set(name, value); + } catch { + throw invalidHeaderError(context); + } +} + +function invalidHeaderError(context: ShapeContext): CommerceError { + return new CommerceError('BACKEND_ERROR', 'Backend request headers are invalid', { + ...context, + details: { reason: 'invalid-header' }, + }); +} diff --git a/src/core/execution/index.ts b/src/core/execution/index.ts index 52a501e..2939762 100644 --- a/src/core/execution/index.ts +++ b/src/core/execution/index.ts @@ -6,12 +6,12 @@ */ export type { HttpBackendExecutorOptions } from './backend-http'; +export { HttpBackendExecutor } from './backend-http'; export { extractPathParameterNames, findUnparsedBraceToken, - HttpBackendExecutor, validateBackendRequestShape, -} from './backend-http'; +} from './backend-request'; export type { CreateExecutionPipelineOptions } from './pipeline'; export { createExecutionPipeline } from './pipeline'; export { createResourceRegistry } from './registry'; diff --git a/src/core/execution/pipeline.ts b/src/core/execution/pipeline.ts deleted file mode 100644 index dd82eb6..0000000 --- a/src/core/execution/pipeline.ts +++ /dev/null @@ -1,940 +0,0 @@ -/** - * The single execution path every protocol adapter converges on. The order is a - * security boundary; do not reorder: - * - * 1. resolve resource (RESOURCE_NOT_FOUND) - * 2. strip reserved input fields, validate input and the backend request shape - * (INPUT_INVALID, or BACKEND_ERROR for an illegal configured header) - * 3. resolve price - * 4. free: straight to the backend - * 5. paid: pick provider and createRequirement, then return a challenge or - * verify the proof and reserve authorization and replay identities - * 6. settle before calling the backend, or call the backend first and settle - * only if it succeeds. Finalize authorization according to the outcome - * (BACKEND_TIMEOUT / BACKEND_ERROR) - * 7. persist receipt, emit events, return outcome - */ - -import type { - AuthorizationProvider, - AuthorizationRecord, - AuthorizationVerification, -} from '../domain/authorization'; -import type { AuthorizationMethodName, PaymentMethodName } from '../domain/common'; -import type { CommerceEvent, EventSink } from '../domain/event'; -import type { - PaymentProvider, - PaymentRequirement, - PaymentResult, - PaymentSubmission, -} from '../domain/payment'; -import type { CommerceReceipt } from '../domain/receipt'; -import type { CanonicalRequest, ExecutionOutcome, ExecutionPipeline } from '../domain/request'; -import type { CommerceResource, ResourceRegistry } from '../domain/resource'; -import { RESERVED_INPUT_FIELDS } from '../domain/wire'; -import { CommerceError, describeError, isCommerceError, toCommerceError } from '../errors'; -import type { BackendExecutor, BackendResponse } from '../interfaces/backend'; -import type { Logger } from '../interfaces/logger'; -import type { Clock, IdGenerator } from '../interfaces/runtime'; -import type { ReceiptStore } from '../interfaces/store'; -import { isRecord } from '../is-record'; -import { validateBackendRequestShape } from './backend-http'; -import { SETTLE_AFTER_BACKEND_METADATA_KEY } from './settlement-order'; -import { compileJsonSchema, type Validator } from './validation'; - -export interface CreateExecutionPipelineOptions { - readonly resources: ResourceRegistry; - readonly paymentProviders: readonly PaymentProvider[]; - /** Defaults to none */ - readonly authorizationProviders?: readonly AuthorizationProvider[]; - readonly store: ReceiptStore; - readonly backend: BackendExecutor; - readonly events: EventSink; - readonly logger: Logger; - readonly clock: Clock; - readonly ids: IdGenerator; -} - -// A reservation held across settlement, plus the summary the receipt keeps. -// `finalize` ends it. -interface AuthorizationHold { - readonly record: AuthorizationRecord; - finalize(action: 'consume' | 'release' | 'markUncertain'): Promise; -} - -export function createExecutionPipeline( - options: CreateExecutionPipelineOptions, -): ExecutionPipeline { - const validators = new Map(); - - function getValidator(resource: CommerceResource): Validator { - const cached = validators.get(resource.id); - if (cached) return cached; - const compiled = compileJsonSchema(resource.inputSchema); - validators.set(resource.id, compiled); - return compiled; - } - - async function safeEmit(event: CommerceEvent): Promise { - try { - await options.events.emit(event); - } catch (error) { - options.logger.error( - { err: describeError(error), eventType: event.type, requestId: event.requestId }, - 'Event sink failed; commerce flow continues', - ); - } - } - - async function safePersist( - op: () => Promise, - label: string, - requestId: string, - ): Promise { - try { - await op(); - } catch (error) { - options.logger.error( - { err: describeError(error), requestId }, - `Persistence failed: ${label}`, - ); - } - } - - function buildEvent(partial: Omit): CommerceEvent { - return { id: options.ids.next('evt'), at: options.clock.nowIso(), ...partial }; - } - - // Verify and reserve what the resource requires, or undefined if it requires - // none. Runs after payment verification, which has no side effect, so a bad - // payment proof cannot burn a reservation. - async function authorizeAndReserve( - request: CanonicalRequest, - resource: CommerceResource, - providers: readonly AuthorizationProvider[], - requirement: PaymentRequirement, - input: unknown, - ): Promise { - if (providers.length === 0) return undefined; - - const submission = request.authorization; - const provider = providers.find((candidate) => candidate.name === submission?.method); - // A request carries one proof, so a resource requiring two methods is - // refused here rather than half-checked - const unmet = providers.filter((candidate) => candidate !== provider).map((c) => c.name); - - if (submission === undefined || provider === undefined || unmet.length > 0) { - await safeEmit( - buildEvent({ - type: 'authorization.rejected', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - status: 'error', - data: { reason: 'AUTHORIZATION_REQUIRED', methods: unmet }, - }), - ); - throw new CommerceError( - 'AUTHORIZATION_REQUIRED', - `Resource "${resource.id}" requires authorization: ${unmet.join(', ')}`, - { - requestId: request.requestId, - resourceId: resource.id, - details: { required: providers.map((candidate) => candidate.name), missing: unmet }, - }, - ); - } - - let verification: AuthorizationVerification; - try { - verification = await provider.verifyAndReserve({ - requestId: request.requestId, - resourceId: resource.id, - input, - submission, - requirement, - }); - } catch (error) { - // An untyped throw says nothing about whose fault it was. Unavailable is - // honest and retryable; invalid would blame the buyer for our outage. - const mapped = isCommerceError(error) - ? error - : new CommerceError( - 'AUTHORIZATION_PROVIDER_UNAVAILABLE', - 'Authorization provider is unavailable', - { - requestId: request.requestId, - resourceId: resource.id, - cause: error, - }, - ); - await safeEmit( - buildEvent({ - type: 'authorization.rejected', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - status: 'error', - data: { reason: mapped.code, method: provider.name }, - }), - ); - throw mapped; - } - - await safeEmit( - buildEvent({ - type: 'authorization.verified', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - status: 'ok', - data: { method: verification.method, reference: verification.reference }, - }), - ); - - const context = { requestId: request.requestId, resourceId: resource.id }; - return { - record: { - method: verification.method, - reference: verification.reference, - ...(verification.metadata !== undefined ? { metadata: verification.metadata } : {}), - }, - async finalize(action) { - try { - await provider[action](verification.reservationId, context); - } catch (error) { - // A failure leaves the reservation reserved, which is still - // unspendable, so the caller's outcome stands - options.logger.error( - { err: describeError(error), requestId: request.requestId, action }, - 'Authorization finalization failed; the reservation stays reserved', - ); - } - }, - }; - } - - interface SettleArgs { - readonly request: CanonicalRequest; - readonly resource: CommerceResource; - readonly provider: PaymentProvider; - readonly requirement: PaymentRequirement; - readonly payment: PaymentSubmission; - readonly verification: PaymentResult; - readonly replayKey: string; - readonly hold: AuthorizationHold | undefined; - } - - // Settle a verified, reserved payment and finalize its authorization. - // Rejected or uncertain settlement throws before any response is delivered. - async function settle(args: SettleArgs): Promise { - const { request, resource, provider, requirement, payment, verification, replayKey, hold } = - args; - let settlement: PaymentResult; - try { - settlement = await provider.settle({ - requestId: request.requestId, - resource, - requirement, - submission: payment, - verification, - }); - } catch (error) { - // A throw gives no settlement verdict. The facilitator may have - // broadcast before losing its response, even without a transaction - // hash. Keep the authorization uncertain so it cannot be spent again. - const txHash = settlementTxHash(error); - await hold?.finalize('markUncertain'); - await safePersist( - () => - options.store.updatePaymentAttempt({ - replayKey, - status: 'settlement-uncertain', - ...(txHash !== undefined ? { externalReference: txHash } : {}), - ...(error instanceof Error ? { rejectionReason: error.message } : {}), - }), - 'updatePaymentAttempt(settlement-uncertain)', - request.requestId, - ); - await safeEmit( - buildEvent({ - type: 'payment.rejected', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - paymentProvider: provider.name, - status: 'error', - data: { - reason: 'settlement-uncertain', - ...(txHash !== undefined ? { transactionHash: txHash } : {}), - }, - }), - ); - // The replay key remains reserved, so report an uncertain, non-retryable - // outcome. Expose only the transaction hash from the underlying error. - throw new CommerceError('PAYMENT_SETTLEMENT_FAILED', 'Settlement could not be confirmed', { - requestId: request.requestId, - resourceId: resource.id, - cause: error, - details: { - settlementUncertain: true, - ...(txHash !== undefined ? { transactionHash: txHash } : {}), - ...settlementParties(requirement, verification), - }, - }); - } - - if (settlement.status !== 'settled') { - await hold?.finalize('release'); - await safePersist( - () => - options.store.updatePaymentAttempt({ - replayKey, - status: 'rejected', - ...(settlement.rejectionReason !== undefined - ? { rejectionReason: settlement.rejectionReason } - : {}), - }), - 'updatePaymentAttempt(rejected)', - request.requestId, - ); - await safeEmit( - buildEvent({ - type: 'payment.rejected', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - paymentProvider: provider.name, - status: 'error', - data: settlement.rejectionReason ? { reason: settlement.rejectionReason } : {}, - }), - ); - // A returned rejection confirms no settlement. Both rails return 402. - const rejectionDetails = retryChallenge(requirement, settlement.rejectionReason).details; - throw new CommerceError( - 'PAYMENT_SETTLEMENT_FAILED', - settlement.rejectionReason ?? 'Settlement was rejected', - { - requestId: request.requestId, - resourceId: resource.id, - httpStatus: 402, - details: { - ...rejectionDetails, - ...settlementParties(requirement, settlement, verification), - }, - }, - ); - } - - await hold?.finalize('consume'); - await safePersist( - () => - options.store.updatePaymentAttempt({ - replayKey, - status: 'settled', - ...(settlement.externalReference !== undefined - ? { externalReference: settlement.externalReference } - : {}), - }), - 'updatePaymentAttempt(settled)', - request.requestId, - ); - await safeEmit( - buildEvent({ - type: 'payment.settled', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - paymentProvider: provider.name, - status: 'ok', - }), - ); - - return settlement; - } - - // The backend failed before settlement. Keep the payment replay key, - // record rejection, and release the separate authorization hold. - async function abandonSettlement(args: SettleArgs, backendErrorCode: string): Promise { - const { request, resource, provider, replayKey, hold } = args; - await hold?.finalize('release'); - await safePersist( - () => - options.store.updatePaymentAttempt({ - replayKey, - status: 'rejected', - rejectionReason: 'backend_failed', - }), - 'updatePaymentAttempt(backend-failed)', - request.requestId, - ); - await safeEmit( - buildEvent({ - type: 'payment.rejected', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - paymentProvider: provider.name, - status: 'error', - data: { reason: 'backend-failed', backendErrorCode }, - }), - ); - } - - async function execute(request: CanonicalRequest): Promise { - const pipelineStart = options.clock.monotonicMs(); - - // 1. resolve resource - const resource = options.resources.get(request.resourceId); - // Same code and message as an unknown id: a caller on a protocol the - // resource is not exposed via must not learn that it exists - if (!resource?.exposedVia.includes(request.protocol)) { - throw new CommerceError( - 'RESOURCE_NOT_FOUND', - `Resource "${request.resourceId}" was not found`, - { - requestId: request.requestId, - resourceId: request.resourceId, - }, - ); - } - - await safeEmit( - buildEvent({ - type: 'resource.requested', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - }), - ); - - // 2. strip reserved gateway fields, then validate input - const strippedInput = stripReservedFields(request.input); - const validation = getValidator(resource)(strippedInput); - if (!validation.valid) { - throw new CommerceError( - 'INPUT_INVALID', - `Input for resource "${resource.id}" failed validation`, - { - requestId: request.requestId, - resourceId: resource.id, - details: { errors: validation.errors }, - }, - ); - } - const validInput = validation.value; - - // Before pricing, so a request the backend cannot receive fails before - // payment (see validateBackendRequestShape) - validateBackendRequestShape( - resource.handler, - validInput, - { requestId: request.requestId, resourceId: resource.id }, - request.backendHeaders, - ); - - // 3. resolve price - if (resource.pricing.type === 'dynamic') { - // Config validation rejects this at load; this is defense in depth - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${resource.id}" uses unsupported dynamic pricing`, - { - requestId: request.requestId, - resourceId: resource.id, - }, - ); - } - - // Authorization gates settlement, so on a free resource nothing would read - // the proof. Config refuses this at load; this is defense in depth. - if (resource.pricing.type !== 'fixed' && (resource.authorization?.required.length ?? 0) > 0) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${resource.id}" requires authorization but is not a paid resource`, - { - requestId: request.requestId, - resourceId: resource.id, - }, - ); - } - - let paymentResult: PaymentResult | undefined; - let authorization: AuthorizationRecord | undefined; - let deferredSettlement: SettleArgs | undefined; - - if (resource.pricing.type === 'fixed') { - const pricing = resource.pricing; - const provider = pickProvider(options.paymentProviders, resource.paymentMethods); - if (!provider) { - throw new CommerceError( - 'PAYMENT_PROVIDER_UNAVAILABLE', - `No enabled payment provider for resource "${resource.id}"`, - { - requestId: request.requestId, - resourceId: resource.id, - details: { paymentMethods: resource.paymentMethods }, - }, - ); - } - - // Resolved before the challenge so the 402 can name what the retry must - // also carry, and so an uncheckable method fails before payment starts - const authProviders = resolveAuthorizationProviders( - options.authorizationProviders ?? [], - resource, - request.requestId, - ); - - let requirement: PaymentRequirement; - try { - requirement = await provider.createRequirement({ - requestId: request.requestId, - resource, - amount: pricing.amount, - currency: pricing.currency, - requestedAt: options.clock.nowIso(), - // Transport facts a rail may bind into its challenge, such as a body digest - ...(request.metadata !== undefined ? { metadata: request.metadata } : {}), - }); - } catch (error) { - throw isCommerceError(error) - ? error - : new CommerceError('PAYMENT_PROVIDER_UNAVAILABLE', 'Payment provider is unavailable', { - requestId: request.requestId, - resourceId: resource.id, - cause: error, - }); - } - - if (!request.payment) { - await safeEmit( - buildEvent({ - type: 'payment.required', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - paymentProvider: provider.name, - }), - ); - return { - kind: 'payment-required', - requestId: request.requestId, - resourceId: resource.id, - requirement, - ...(authProviders.length > 0 - ? { authorization: authProviders.map((p) => p.requirement) } - : {}), - }; - } - - const payment = request.payment; - - let verification: PaymentResult; - try { - verification = await provider.verify({ - requestId: request.requestId, - resource, - requirement, - submission: payment, - }); - } catch (error) { - // A typed CommerceError is rethrown as is, keeping its code and its - // possibly retryable status. Only an untyped throw is mapped, with a - // fixed client-facing message. - const mapped = isCommerceError(error) - ? error - : new CommerceError('PAYMENT_INVALID', 'Payment verification failed', { - requestId: request.requestId, - resourceId: resource.id, - cause: error, - ...retryChallenge(requirement), - }); - await safeEmit( - buildEvent({ - type: 'payment.rejected', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - paymentProvider: provider.name, - status: 'error', - data: { reason: mapped.code }, - }), - ); - throw mapped; - } - - if (verification.status !== 'verified') { - await safeEmit( - buildEvent({ - type: 'payment.rejected', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - paymentProvider: provider.name, - status: 'error', - data: verification.rejectionReason ? { reason: verification.rejectionReason } : {}, - }), - ); - throw new CommerceError( - 'PAYMENT_INVALID', - verification.rejectionReason ?? 'Payment was rejected', - { - requestId: request.requestId, - resourceId: resource.id, - ...retryChallenge(requirement, verification.rejectionReason), - }, - ); - } - - if (!verification.replayKey) { - await safeEmit( - buildEvent({ - type: 'payment.rejected', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - paymentProvider: provider.name, - status: 'error', - data: { reason: 'missing-replay-key' }, - }), - ); - throw new CommerceError('PAYMENT_INVALID', 'Payment provider did not return a replay key', { - requestId: request.requestId, - resourceId: resource.id, - }); - } - const replayKey = verification.replayKey; - - const hold = await authorizeAndReserve( - request, - resource, - authProviders, - requirement, - validInput, - ); - authorization = hold?.record; - - try { - await options.store.reservePaymentAttempt({ - requestId: request.requestId, - resourceId: resource.id, - provider: provider.name, - replayKey, - amount: verification.amount, - currency: verification.currency, - ...(verification.payer !== undefined ? { payer: verification.payer } : {}), - ...(verification.payee !== undefined ? { payee: verification.payee } : {}), - }); - } catch (error) { - // A duplicate replayKey arrives as PAYMENT_REPLAYED and is kept. Any - // other failure (a full disk, a dropped connection) is STORAGE_ERROR, - // never a replay. - const mapped = toCommerceError( - error, - 'STORAGE_ERROR', - 'Payment attempt could not be recorded', - ); - await hold?.finalize('release'); - await safeEmit( - buildEvent({ - type: 'payment.rejected', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - paymentProvider: provider.name, - status: 'error', - data: { reason: mapped.code }, - }), - ); - throw finishedReplay(mapped, requirement); - } - - await safeEmit( - buildEvent({ - type: 'payment.verified', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - paymentProvider: provider.name, - status: 'ok', - }), - ); - - const settleArgs: SettleArgs = { - request, - resource, - provider, - requirement, - payment, - verification, - replayKey, - hold, - }; - if (requirement.metadata?.[SETTLE_AFTER_BACKEND_METADATA_KEY] === true) { - deferredSettlement = settleArgs; - } else { - paymentResult = await settle(settleArgs); - } - } - - // 6. call backend - const backendStart = options.clock.monotonicMs(); - let backendResponse: BackendResponse; - try { - backendResponse = await options.backend.call(resource.handler, { - requestId: request.requestId, - resourceId: resource.id, - input: validInput, - ...(request.idempotencyKey !== undefined ? { idempotencyKey: request.idempotencyKey } : {}), - ...(request.backendHeaders !== undefined ? { backendHeaders: request.backendHeaders } : {}), - }); - } catch (error) { - const commerceError = toCommerceError(error, 'BACKEND_ERROR', 'Backend call failed'); - await safeEmit( - buildEvent({ - type: 'backend.failed', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - status: 'error', - durationMs: Math.round(options.clock.monotonicMs() - backendStart), - data: { code: commerceError.code }, - }), - ); - - if (deferredSettlement !== undefined) { - await abandonSettlement(deferredSettlement, commerceError.code); - throw commerceError; - } - - if (paymentResult !== undefined) { - // The payment settled and the backend then failed. Record an - // undelivered receipt for the merchant and tell the buyer what they - // paid, including the settlement reference. - const failedReceipt: CommerceReceipt = { - id: options.ids.next('receipt'), - requestId: request.requestId, - resourceId: resource.id, - deliveredAt: options.clock.nowIso(), - backendStatus: backendErrorStatus(commerceError), - protocol: request.protocol, - payment: paymentResult, - ...(authorization !== undefined ? { authorization } : {}), - metadata: { delivered: false, backendErrorCode: commerceError.code }, - }; - await safePersist( - () => options.store.saveReceipt(failedReceipt), - 'saveReceipt(backend-failed-after-settlement)', - request.requestId, - ); - - throw new CommerceError( - 'BACKEND_ERROR', - 'Payment settled but the backend call failed; the payment was not refunded automatically', - { - requestId: request.requestId, - resourceId: resource.id, - cause: commerceError, - details: { - payment: { - status: paymentResult.status, - provider: paymentResult.provider, - amount: paymentResult.amount, - currency: paymentResult.currency, - ...(paymentResult.network !== undefined ? { network: paymentResult.network } : {}), - ...(paymentResult.externalReference !== undefined - ? { externalReference: paymentResult.externalReference } - : {}), - ...(paymentResult.payer !== undefined ? { payer: paymentResult.payer } : {}), - ...(typeof paymentResult.metadata?.['amountBaseUnits'] === 'string' - ? { amountBaseUnits: paymentResult.metadata['amountBaseUnits'] } - : {}), - ...(typeof paymentResult.metadata?.['receipt'] === 'string' - ? { receipt: paymentResult.metadata['receipt'] } - : {}), - }, - }, - }, - ); - } - - throw commerceError; - } - - await safeEmit( - buildEvent({ - type: 'backend.called', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - status: 'ok', - durationMs: backendResponse.durationMs, - data: { status: backendResponse.status }, - }), - ); - - // Hold the backend response until settlement succeeds. A refusal or - // uncertain outcome throws before delivery. - if (deferredSettlement !== undefined) { - paymentResult = await settle(deferredSettlement); - } - - // 7. persist receipt, emit final event, return outcome - const receipt: CommerceReceipt = { - id: options.ids.next('receipt'), - requestId: request.requestId, - resourceId: resource.id, - deliveredAt: options.clock.nowIso(), - backendStatus: backendResponse.status, - durationMs: backendResponse.durationMs, - protocol: request.protocol, - ...(paymentResult !== undefined ? { payment: paymentResult } : {}), - ...(authorization !== undefined ? { authorization } : {}), - }; - - await safePersist(() => options.store.saveReceipt(receipt), 'saveReceipt', request.requestId); - - const totalDurationMs = Math.round(options.clock.monotonicMs() - pipelineStart); - - await safeEmit( - buildEvent({ - type: 'resource.delivered', - requestId: request.requestId, - resourceId: resource.id, - adapter: request.protocol, - status: 'ok', - durationMs: totalDurationMs, - }), - ); - - return { - kind: 'delivered', - requestId: request.requestId, - resourceId: resource.id, - backendStatus: backendResponse.status, - body: backendResponse.body, - headers: backendResponse.headers, - ...(paymentResult !== undefined ? { payment: paymentResult } : {}), - receipt, - durationMs: totalDurationMs, - }; - } - - return { execute }; -} - -// The status the backend returned, which backend-http.ts puts on -// `details.status` for a non-2xx or redirect response. 0 means no status is -// known, as after a timeout, a transport failure or an unreadable or oversized -// body. -function backendErrorStatus(error: CommerceError): number { - const status = error.details?.['status']; - return typeof status === 'number' ? status : 0; -} - -// The broadcast transaction hash a provider attached to a settlement throw, -// when it knows one -function settlementTxHash(error: unknown): string | undefined { - const hash = isCommerceError(error) ? error.details?.['transactionHash'] : undefined; - return typeof hash === 'string' ? hash : undefined; -} - -// The provider for each method a resource requires, in the resource's order. -// Config refuses a method that is not enabled at load; it is checked again -// because missing it here would serve the resource with no authorization. -function resolveAuthorizationProviders( - providers: readonly AuthorizationProvider[], - resource: CommerceResource, - requestId: string, -): readonly AuthorizationProvider[] { - const required = resource.authorization?.required ?? []; - return required.map((method: AuthorizationMethodName) => { - const found = providers.find((provider) => provider.name === method); - if (!found) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${resource.id}" requires authorization method "${method}", which is not enabled`, - { requestId, resourceId: resource.id }, - ); - } - return found; - }); -} - -// A refusal leaves this request's challenge unused. Return it with the -// provider's reason so the client can retry without fetching another challenge -function retryChallenge( - requirement: PaymentRequirement, - reason?: string, -): { details?: Record } { - const envelope = requirement.challenge.envelope; - if (envelope === undefined && reason === undefined) return {}; - return { - details: { - ...(envelope !== undefined ? { challenge: envelope } : {}), - ...(reason !== undefined ? { reason } : {}), - }, - }; -} - -// Attempt statuses the pipeline treats as finished when handling a replay. The -// legacy `failed` is not one: it recorded any throw from settle(), so it may -// hide a transfer, like `settlement-uncertain`. -const FINISHED_ATTEMPT_STATUSES: ReadonlySet = new Set(['settled', 'rejected']); - -// Treat a finished attempt as eligible for a 402 with a new challenge. Keep -// 409 when the first attempt is unfinished or its status is unavailable; -// another challenge could lead to a second payment -function finishedReplay(error: CommerceError, requirement: PaymentRequirement): CommerceError { - const status = error.details?.['attemptStatus']; - if (error.code !== 'PAYMENT_REPLAYED' || typeof status !== 'string') return error; - if (!FINISHED_ATTEMPT_STATUSES.has(status)) return error; - return new CommerceError('PAYMENT_REPLAYED', error.message, { - ...(error.requestId !== undefined ? { requestId: error.requestId } : {}), - ...(error.resourceId !== undefined ? { resourceId: error.resourceId } : {}), - httpStatus: 402, - details: { ...error.details, ...retryChallenge(requirement).details }, - }); -} - -// Prefer the network and payer reported by settlement or verification, then -// fall back to the requirement's network -function settlementParties( - requirement: PaymentRequirement, - ...results: readonly PaymentResult[] -): { network?: string; payer?: string } { - const network = results.find((r) => r.network !== undefined)?.network ?? requirement.network; - const payer = results.find((r) => r.payer !== undefined)?.payer; - return { - ...(network !== undefined ? { network } : {}), - ...(payer !== undefined ? { payer } : {}), - }; -} - -function pickProvider( - providers: readonly PaymentProvider[], - methods: readonly PaymentMethodName[], -): PaymentProvider | undefined { - for (const method of methods) { - const found = providers.find((provider) => provider.name === method); - if (found) return found; - } - return undefined; -} - -const RESERVED_INPUT_KEYS: readonly string[] = [...RESERVED_INPUT_FIELDS, '__proto__']; - -function stripReservedFields(input: unknown): unknown { - if (!isRecord(input)) return input; - // Object.hasOwn, not `in`, which also matches inherited names - if (!RESERVED_INPUT_KEYS.some((key) => Object.hasOwn(input, key))) return input; - const rest: Record = {}; - for (const key of Object.keys(input)) { - // "__proto__" is dropped too: assigning it would call the inherited setter - // and replace `rest`'s prototype instead of creating a data property. - // validation.ts's own-property lookups are the primary control. - if (!RESERVED_INPUT_KEYS.includes(key)) rest[key] = input[key]; - } - return rest; -} diff --git a/src/core/execution/pipeline/admission.ts b/src/core/execution/pipeline/admission.ts new file mode 100644 index 0000000..67bfe2e --- /dev/null +++ b/src/core/execution/pipeline/admission.ts @@ -0,0 +1,271 @@ +// The payment phase: challenge, offer selection, verification, and the authorization and replay reservations + +import type { PaymentMethodName } from '../../domain/common'; +import type { PaymentProvider, PaymentRequirement, PaymentResult } from '../../domain/payment'; +import type { CanonicalRequest, PaymentRequiredOutcome } from '../../domain/request'; +import type { CommerceResource, Pricing } from '../../domain/resource'; +import { CommerceError, isCommerceError, toCommerceError } from '../../errors'; +import { REUSE_REJECTED_AUTHORIZATION_METADATA_KEY } from '../settlement-order'; +import { type AuthorizeAndReserve, resolveAuthorizationProviders } from './authorization'; +import { replayError, retryChallenge } from './challenge'; +import type { PipelineContext, SettleArgs } from './context'; + +/** A verified payment whose authorization and replay identity are reserved */ +export interface AdmittedPayment { + readonly kind: 'admitted'; + readonly settleArgs: SettleArgs; +} + +export type AdmitPayment = ( + request: CanonicalRequest, + resource: CommerceResource, + pricing: Extract, + validInput: unknown, +) => Promise; + +/** + * Builds the payment phase. It returns the challenge when the request carries + * no proof, and otherwise verifies the proof and reserves the authorization + * and replay identity, or throws. + */ +export function createPaymentAdmission( + ctx: PipelineContext, + authorizeAndReserve: AuthorizeAndReserve, +): AdmitPayment { + const { options, safeEmit, buildEvent } = ctx; + + return async function admitPayment(request, resource, pricing, validInput) { + const provider = pickProvider(options.paymentProviders, resource.paymentMethods); + if (!provider) { + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + `No enabled payment provider for resource "${resource.id}"`, + { + requestId: request.requestId, + resourceId: resource.id, + details: { paymentMethods: resource.paymentMethods }, + }, + ); + } + + // Resolved before the challenge so the 402 can name what the retry must + // also carry, and so an uncheckable method fails before payment starts + const authProviders = resolveAuthorizationProviders( + options.authorizationProviders ?? [], + resource, + request.requestId, + ); + + let requirement: PaymentRequirement; + try { + requirement = await provider.createRequirement({ + requestId: request.requestId, + resource, + amount: pricing.amount, + currency: pricing.currency, + requestedAt: options.clock.nowIso(), + // Transport facts a rail may bind into its challenge, such as a body digest + ...(request.metadata !== undefined ? { metadata: request.metadata } : {}), + }); + } catch (error) { + throw isCommerceError(error) + ? error + : new CommerceError('PAYMENT_PROVIDER_UNAVAILABLE', 'Payment provider is unavailable', { + requestId: request.requestId, + resourceId: resource.id, + cause: error, + }); + } + + if (!request.payment) { + await safeEmit( + buildEvent({ + type: 'payment.required', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + paymentProvider: provider.name, + }), + ); + return { + kind: 'payment-required', + requestId: request.requestId, + resourceId: resource.id, + requirement, + ...(authProviders.length > 0 + ? { authorization: authProviders.map((p) => p.requirement) } + : {}), + }; + } + + const payment = request.payment; + + let verification: PaymentResult; + try { + // Later steps, including authorization and settlement order, use the + // selected requirement with its full retry challenge + if (provider.selectRequirement) { + requirement = await provider.selectRequirement({ + requestId: request.requestId, + resource, + requirement, + submission: payment, + }); + } + verification = await provider.verify({ + requestId: request.requestId, + resource, + requirement, + submission: payment, + }); + } catch (error) { + // A typed CommerceError is rethrown as is, keeping its code and its + // possibly retryable status. Only an untyped throw is mapped, with a + // fixed client-facing message. + const mapped = isCommerceError(error) + ? error + : new CommerceError('PAYMENT_INVALID', 'Payment verification failed', { + requestId: request.requestId, + resourceId: resource.id, + cause: error, + ...retryChallenge(requirement), + }); + await safeEmit( + buildEvent({ + type: 'payment.rejected', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + paymentProvider: provider.name, + status: 'error', + data: { reason: mapped.code }, + }), + ); + throw mapped; + } + + if (verification.status !== 'verified') { + await safeEmit( + buildEvent({ + type: 'payment.rejected', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + paymentProvider: provider.name, + status: 'error', + data: verification.rejectionReason ? { reason: verification.rejectionReason } : {}, + }), + ); + throw new CommerceError( + 'PAYMENT_INVALID', + verification.rejectionReason ?? 'Payment was rejected', + { + requestId: request.requestId, + resourceId: resource.id, + ...retryChallenge(requirement, verification.rejectionReason), + }, + ); + } + + if (!verification.replayKey) { + await safeEmit( + buildEvent({ + type: 'payment.rejected', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + paymentProvider: provider.name, + status: 'error', + data: { reason: 'missing-replay-key' }, + }), + ); + throw new CommerceError('PAYMENT_INVALID', 'Payment provider did not return a replay key', { + requestId: request.requestId, + resourceId: resource.id, + }); + } + const replayKey = verification.replayKey; + + const hold = await authorizeAndReserve( + request, + resource, + authProviders, + requirement, + validInput, + ); + + try { + await options.store.reservePaymentAttempt({ + requestId: request.requestId, + resourceId: resource.id, + provider: provider.name, + replayKey, + amount: verification.amount, + currency: verification.currency, + ...(verification.payer !== undefined ? { payer: verification.payer } : {}), + ...(verification.payee !== undefined ? { payee: verification.payee } : {}), + ...(requirement.metadata?.[REUSE_REJECTED_AUTHORIZATION_METADATA_KEY] === true + ? { reclaimRejected: true } + : {}), + }); + } catch (error) { + // A duplicate replayKey arrives as PAYMENT_REPLAYED and is kept. Any + // other failure (a full disk, a dropped connection) is STORAGE_ERROR, + // never a replay. + const mapped = toCommerceError( + error, + 'STORAGE_ERROR', + 'Payment attempt could not be recorded', + ); + await hold?.finalize('release'); + await safeEmit( + buildEvent({ + type: 'payment.rejected', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + paymentProvider: provider.name, + status: 'error', + data: { reason: mapped.code }, + }), + ); + throw replayError(mapped, requirement); + } + + await safeEmit( + buildEvent({ + type: 'payment.verified', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + paymentProvider: provider.name, + status: 'ok', + }), + ); + + return { + kind: 'admitted', + settleArgs: { + request, + resource, + provider, + requirement, + payment, + verification, + replayKey, + hold, + }, + }; + }; +} + +function pickProvider( + providers: readonly PaymentProvider[], + methods: readonly PaymentMethodName[], +): PaymentProvider | undefined { + for (const method of methods) { + const found = providers.find((provider) => provider.name === method); + if (found) return found; + } + return undefined; +} diff --git a/src/core/execution/pipeline/authorization.ts b/src/core/execution/pipeline/authorization.ts new file mode 100644 index 0000000..46723e1 --- /dev/null +++ b/src/core/execution/pipeline/authorization.ts @@ -0,0 +1,153 @@ +// Verifies and reserves the authorization a resource requires, before its payment is reserved +import type { AuthorizationProvider, AuthorizationVerification } from '../../domain/authorization'; +import type { AuthorizationMethodName } from '../../domain/common'; +import type { PaymentRequirement } from '../../domain/payment'; +import type { CanonicalRequest } from '../../domain/request'; +import type { CommerceResource } from '../../domain/resource'; +import { CommerceError, describeError, isCommerceError } from '../../errors'; +import type { AuthorizationHold, PipelineContext } from './context'; + +// The provider for each method a resource requires, in the resource's order. +// Config refuses a method that is not enabled at load; it is checked again +// because missing it here would serve the resource with no authorization. +export function resolveAuthorizationProviders( + providers: readonly AuthorizationProvider[], + resource: CommerceResource, + requestId: string, +): readonly AuthorizationProvider[] { + const required = resource.authorization?.required ?? []; + return required.map((method: AuthorizationMethodName) => { + const found = providers.find((provider) => provider.name === method); + if (!found) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${resource.id}" requires authorization method "${method}", which is not enabled`, + { requestId, resourceId: resource.id }, + ); + } + return found; + }); +} + +export type AuthorizeAndReserve = ( + request: CanonicalRequest, + resource: CommerceResource, + providers: readonly AuthorizationProvider[], + requirement: PaymentRequirement, + input: unknown, +) => Promise; + +export function createAuthorizeAndReserve(ctx: PipelineContext): AuthorizeAndReserve { + const { options, safeEmit, buildEvent } = ctx; + + // Verify and reserve what the resource requires, or undefined if it requires + // none. Runs after payment verification, which has no side effect, so a bad + // payment proof cannot burn a reservation. + async function authorizeAndReserve( + request: CanonicalRequest, + resource: CommerceResource, + providers: readonly AuthorizationProvider[], + requirement: PaymentRequirement, + input: unknown, + ): Promise { + if (providers.length === 0) return undefined; + + const submission = request.authorization; + const provider = providers.find((candidate) => candidate.name === submission?.method); + // A request carries one proof, so a resource requiring two methods is + // refused here rather than half-checked + const unmet = providers.filter((candidate) => candidate !== provider).map((c) => c.name); + + if (submission === undefined || provider === undefined || unmet.length > 0) { + await safeEmit( + buildEvent({ + type: 'authorization.rejected', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + status: 'error', + data: { reason: 'AUTHORIZATION_REQUIRED', methods: unmet }, + }), + ); + throw new CommerceError( + 'AUTHORIZATION_REQUIRED', + `Resource "${resource.id}" requires authorization: ${unmet.join(', ')}`, + { + requestId: request.requestId, + resourceId: resource.id, + details: { required: providers.map((candidate) => candidate.name), missing: unmet }, + }, + ); + } + + let verification: AuthorizationVerification; + try { + verification = await provider.verifyAndReserve({ + requestId: request.requestId, + resourceId: resource.id, + input, + submission, + requirement, + }); + } catch (error) { + // An untyped throw says nothing about whose fault it was. Unavailable is + // honest and retryable; invalid would blame the buyer for our outage. + const mapped = isCommerceError(error) + ? error + : new CommerceError( + 'AUTHORIZATION_PROVIDER_UNAVAILABLE', + 'Authorization provider is unavailable', + { + requestId: request.requestId, + resourceId: resource.id, + cause: error, + }, + ); + await safeEmit( + buildEvent({ + type: 'authorization.rejected', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + status: 'error', + data: { reason: mapped.code, method: provider.name }, + }), + ); + throw mapped; + } + + await safeEmit( + buildEvent({ + type: 'authorization.verified', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + status: 'ok', + data: { method: verification.method, reference: verification.reference }, + }), + ); + + const context = { requestId: request.requestId, resourceId: resource.id }; + return { + record: { + method: verification.method, + reference: verification.reference, + ...(verification.metadata !== undefined ? { metadata: verification.metadata } : {}), + }, + async finalize(action) { + try { + await provider[action](verification.reservationId, context); + } catch (error) { + // A failure leaves the reservation reserved, which is still + // unspendable, so the caller's outcome stands + options.logger.error( + { err: describeError(error), requestId: request.requestId, action }, + 'Authorization finalization failed; the reservation stays reserved', + ); + } + }, + }; + } + + return authorizeAndReserve; +} diff --git a/src/core/execution/pipeline/challenge.ts b/src/core/execution/pipeline/challenge.ts new file mode 100644 index 0000000..9315e47 --- /dev/null +++ b/src/core/execution/pipeline/challenge.ts @@ -0,0 +1,45 @@ +// What a refused or replayed payment answers with: the unused challenge, when one may be retried +import type { PaymentRequirement } from '../../domain/payment'; +import { CommerceError } from '../../errors'; + +// A refusal leaves this request's challenge unused. Return it with the +// provider's reason so the client can retry without fetching another challenge +export function retryChallenge( + requirement: PaymentRequirement, + reason?: string, +): { details?: Record } { + const envelope = requirement.challenge.envelope; + if (envelope === undefined && reason === undefined) return {}; + return { + details: { + ...(envelope !== undefined ? { challenge: envelope } : {}), + ...(reason !== undefined ? { reason } : {}), + }, + }; +} + +// Attempt statuses the pipeline treats as finished when handling a replay. The +// legacy `failed` is not one: it recorded any throw from settle(), so it may +// hide a transfer, like `settlement-uncertain`. +const FINISHED_ATTEMPT_STATUSES: ReadonlySet = new Set(['settled', 'rejected']); + +// Include the paid offer's network for the rail's replay code. Finished +// attempts get 402 with a fresh challenge; unfinished or unreadable attempts +// keep 409 to avoid prompting a second payment. +export function replayError(error: CommerceError, requirement: PaymentRequirement): CommerceError { + if (error.code !== 'PAYMENT_REPLAYED') return error; + const status = error.details?.['attemptStatus']; + const finished = typeof status === 'string' && FINISHED_ATTEMPT_STATUSES.has(status); + const network = requirement.network; + if (!finished && network === undefined) return error; + return new CommerceError('PAYMENT_REPLAYED', error.message, { + ...(error.requestId !== undefined ? { requestId: error.requestId } : {}), + ...(error.resourceId !== undefined ? { resourceId: error.resourceId } : {}), + ...(finished ? { httpStatus: 402 } : {}), + details: { + ...error.details, + ...(network !== undefined ? { network } : {}), + ...(finished ? retryChallenge(requirement).details : {}), + }, + }); +} diff --git a/src/core/execution/pipeline/context.ts b/src/core/execution/pipeline/context.ts new file mode 100644 index 0000000..5f3995f --- /dev/null +++ b/src/core/execution/pipeline/context.ts @@ -0,0 +1,97 @@ +// What the pipeline's stages share: its options, and event and persistence writes that never fail a flow +import type { AuthorizationProvider, AuthorizationRecord } from '../../domain/authorization'; +import type { CommerceEvent, EventSink } from '../../domain/event'; +import type { + PaymentProvider, + PaymentRequirement, + PaymentResult, + PaymentSubmission, +} from '../../domain/payment'; +import type { CanonicalRequest } from '../../domain/request'; +import type { CommerceResource, ResourceRegistry } from '../../domain/resource'; +import { describeError } from '../../errors'; +import type { BackendExecutor } from '../../interfaces/backend'; +import type { Logger } from '../../interfaces/logger'; +import type { Clock, IdGenerator } from '../../interfaces/runtime'; +import type { ReceiptStore } from '../../interfaces/store'; + +export interface CreateExecutionPipelineOptions { + readonly resources: ResourceRegistry; + readonly paymentProviders: readonly PaymentProvider[]; + /** Defaults to none */ + readonly authorizationProviders?: readonly AuthorizationProvider[]; + readonly store: ReceiptStore; + readonly backend: BackendExecutor; + readonly events: EventSink; + readonly logger: Logger; + readonly clock: Clock; + readonly ids: IdGenerator; +} + +/** + * A reservation held across settlement, plus the summary the receipt keeps. + * `finalize` ends it. + */ +export interface AuthorizationHold { + readonly record: AuthorizationRecord; + finalize(action: 'consume' | 'release' | 'markUncertain'): Promise; +} + +/** A verified, reserved payment and everything its settlement needs */ +export interface SettleArgs { + readonly request: CanonicalRequest; + readonly resource: CommerceResource; + readonly provider: PaymentProvider; + readonly requirement: PaymentRequirement; + readonly payment: PaymentSubmission; + readonly verification: PaymentResult; + readonly replayKey: string; + readonly hold: AuthorizationHold | undefined; +} + +export interface PipelineContext { + readonly options: CreateExecutionPipelineOptions; + /** Emits an event; a failing sink is logged, never thrown */ + readonly safeEmit: (event: CommerceEvent) => Promise; + /** Runs a store write; a failure is logged, never thrown */ + readonly safePersist: ( + op: () => Promise, + label: string, + requestId: string, + ) => Promise; + readonly buildEvent: (partial: Omit) => CommerceEvent; +} + +export function createPipelineContext(options: CreateExecutionPipelineOptions): PipelineContext { + async function safeEmit(event: CommerceEvent): Promise { + try { + await options.events.emit(event); + } catch (error) { + options.logger.error( + { err: describeError(error), eventType: event.type, requestId: event.requestId }, + 'Event sink failed; commerce flow continues', + ); + } + } + + async function safePersist( + op: () => Promise, + label: string, + requestId: string, + ): Promise { + try { + await op(); + } catch (error) { + options.logger.error( + { err: describeError(error), requestId }, + `Persistence failed: ${label}`, + ); + } + } + + function buildEvent(partial: Omit): CommerceEvent { + return { id: options.ids.next('evt'), at: options.clock.nowIso(), ...partial }; + } + + return { options, safeEmit, safePersist, buildEvent }; +} diff --git a/src/core/execution/pipeline/index.ts b/src/core/execution/pipeline/index.ts new file mode 100644 index 0000000..b545fe6 --- /dev/null +++ b/src/core/execution/pipeline/index.ts @@ -0,0 +1,323 @@ +/** + * The single execution path every protocol adapter converges on. The order is a + * security boundary; do not reorder: + * + * 1. resolve resource (RESOURCE_NOT_FOUND) + * 2. strip reserved input fields, validate input and the backend request shape + * (INPUT_INVALID, or BACKEND_ERROR for an illegal configured header) + * 3. resolve price + * 4. free: straight to the backend + * 5. paid: pick provider and createRequirement, then return a challenge, or + * select the offer the proof pays, verify it, and reserve authorization and + * replay identities + * 6. settle before calling the backend, or call the backend first and settle + * only if it succeeds. Finalize authorization according to the outcome + * (BACKEND_TIMEOUT / BACKEND_ERROR) + * 7. persist receipt, emit events, return outcome + */ + +import type { AuthorizationRecord } from '../../domain/authorization'; +import type { PaymentResult } from '../../domain/payment'; +import type { CommerceReceipt } from '../../domain/receipt'; +import type { CanonicalRequest, ExecutionOutcome, ExecutionPipeline } from '../../domain/request'; +import type { CommerceResource } from '../../domain/resource'; +import { RESERVED_INPUT_FIELDS } from '../../domain/wire'; +import { CommerceError, toCommerceError } from '../../errors'; +import type { BackendResponse } from '../../interfaces/backend'; +import { isRecord } from '../../is-record'; +import { validateBackendRequestShape } from '../backend-request'; +import { SETTLE_AFTER_BACKEND_METADATA_KEY } from '../settlement-order'; +import { compileJsonSchema, type Validator } from '../validation'; +import { createPaymentAdmission } from './admission'; +import { createAuthorizeAndReserve } from './authorization'; +import { + type CreateExecutionPipelineOptions, + createPipelineContext, + type SettleArgs, +} from './context'; +import { createSettlement } from './settlement'; + +export type { CreateExecutionPipelineOptions } from './context'; + +export function createExecutionPipeline( + options: CreateExecutionPipelineOptions, +): ExecutionPipeline { + const validators = new Map(); + + function getValidator(resource: CommerceResource): Validator { + const cached = validators.get(resource.id); + if (cached) return cached; + const compiled = compileJsonSchema(resource.inputSchema); + validators.set(resource.id, compiled); + return compiled; + } + + const ctx = createPipelineContext(options); + const { safeEmit, safePersist, buildEvent } = ctx; + const { settle, abandonSettlement } = createSettlement(ctx); + const admitPayment = createPaymentAdmission(ctx, createAuthorizeAndReserve(ctx)); + + async function execute(request: CanonicalRequest): Promise { + const pipelineStart = options.clock.monotonicMs(); + + // 1. resolve resource + const resource = options.resources.get(request.resourceId); + // Same code and message as an unknown id: a caller on a protocol the + // resource is not exposed via must not learn that it exists + if (!resource?.exposedVia.includes(request.protocol)) { + throw new CommerceError( + 'RESOURCE_NOT_FOUND', + `Resource "${request.resourceId}" was not found`, + { + requestId: request.requestId, + resourceId: request.resourceId, + }, + ); + } + + await safeEmit( + buildEvent({ + type: 'resource.requested', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + }), + ); + + // 2. strip reserved gateway fields, then validate input + const strippedInput = stripReservedFields(request.input); + const validation = getValidator(resource)(strippedInput); + if (!validation.valid) { + throw new CommerceError( + 'INPUT_INVALID', + `Input for resource "${resource.id}" failed validation`, + { + requestId: request.requestId, + resourceId: resource.id, + details: { errors: validation.errors }, + }, + ); + } + const validInput = validation.value; + + // Before pricing, so a request the backend cannot receive fails before + // payment (see validateBackendRequestShape) + validateBackendRequestShape( + resource.handler, + validInput, + { requestId: request.requestId, resourceId: resource.id }, + request.backendHeaders, + ); + + // 3. resolve price + if (resource.pricing.type === 'dynamic') { + // Config validation rejects this at load; this is defense in depth + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${resource.id}" uses unsupported dynamic pricing`, + { + requestId: request.requestId, + resourceId: resource.id, + }, + ); + } + + // Authorization gates settlement, so on a free resource nothing would read + // the proof. Config refuses this at load; this is defense in depth. + if (resource.pricing.type !== 'fixed' && (resource.authorization?.required.length ?? 0) > 0) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${resource.id}" requires authorization but is not a paid resource`, + { + requestId: request.requestId, + resourceId: resource.id, + }, + ); + } + + let paymentResult: PaymentResult | undefined; + let authorization: AuthorizationRecord | undefined; + let deferredSettlement: SettleArgs | undefined; + + if (resource.pricing.type === 'fixed') { + const admission = await admitPayment(request, resource, resource.pricing, validInput); + if (admission.kind === 'payment-required') return admission; + const { settleArgs } = admission; + authorization = settleArgs.hold?.record; + if (settleArgs.requirement.metadata?.[SETTLE_AFTER_BACKEND_METADATA_KEY] === true) { + deferredSettlement = settleArgs; + } else { + paymentResult = await settle(settleArgs); + } + } + + // 6. call backend + const backendStart = options.clock.monotonicMs(); + let backendResponse: BackendResponse; + try { + backendResponse = await options.backend.call(resource.handler, { + requestId: request.requestId, + resourceId: resource.id, + input: validInput, + ...(request.idempotencyKey !== undefined ? { idempotencyKey: request.idempotencyKey } : {}), + ...(request.backendHeaders !== undefined ? { backendHeaders: request.backendHeaders } : {}), + }); + } catch (error) { + const commerceError = toCommerceError(error, 'BACKEND_ERROR', 'Backend call failed'); + await safeEmit( + buildEvent({ + type: 'backend.failed', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + status: 'error', + durationMs: Math.round(options.clock.monotonicMs() - backendStart), + data: { code: commerceError.code }, + }), + ); + + if (deferredSettlement !== undefined) { + await abandonSettlement(deferredSettlement, commerceError.code); + throw commerceError; + } + + if (paymentResult !== undefined) { + // The payment settled and the backend then failed. Record an + // undelivered receipt for the merchant and tell the buyer what they + // paid, including the settlement reference. + const failedReceipt: CommerceReceipt = { + id: options.ids.next('receipt'), + requestId: request.requestId, + resourceId: resource.id, + deliveredAt: options.clock.nowIso(), + backendStatus: backendErrorStatus(commerceError), + protocol: request.protocol, + payment: paymentResult, + ...(authorization !== undefined ? { authorization } : {}), + metadata: { delivered: false, backendErrorCode: commerceError.code }, + }; + await safePersist( + () => options.store.saveReceipt(failedReceipt), + 'saveReceipt(backend-failed-after-settlement)', + request.requestId, + ); + + throw new CommerceError( + 'BACKEND_ERROR', + 'Payment settled but the backend call failed; the payment was not refunded automatically', + { + requestId: request.requestId, + resourceId: resource.id, + cause: commerceError, + details: { + payment: { + status: paymentResult.status, + provider: paymentResult.provider, + amount: paymentResult.amount, + currency: paymentResult.currency, + ...(paymentResult.network !== undefined ? { network: paymentResult.network } : {}), + ...(paymentResult.externalReference !== undefined + ? { externalReference: paymentResult.externalReference } + : {}), + ...(paymentResult.payer !== undefined ? { payer: paymentResult.payer } : {}), + ...(typeof paymentResult.metadata?.['amountBaseUnits'] === 'string' + ? { amountBaseUnits: paymentResult.metadata['amountBaseUnits'] } + : {}), + ...(typeof paymentResult.metadata?.['receipt'] === 'string' + ? { receipt: paymentResult.metadata['receipt'] } + : {}), + }, + }, + }, + ); + } + + throw commerceError; + } + + await safeEmit( + buildEvent({ + type: 'backend.called', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + status: 'ok', + durationMs: backendResponse.durationMs, + data: { status: backendResponse.status }, + }), + ); + + // Hold the backend response until settlement succeeds. A refusal or + // uncertain outcome throws before delivery. + if (deferredSettlement !== undefined) { + paymentResult = await settle(deferredSettlement); + } + + // 7. persist receipt, emit final event, return outcome + const receipt: CommerceReceipt = { + id: options.ids.next('receipt'), + requestId: request.requestId, + resourceId: resource.id, + deliveredAt: options.clock.nowIso(), + backendStatus: backendResponse.status, + durationMs: backendResponse.durationMs, + protocol: request.protocol, + ...(paymentResult !== undefined ? { payment: paymentResult } : {}), + ...(authorization !== undefined ? { authorization } : {}), + }; + + await safePersist(() => options.store.saveReceipt(receipt), 'saveReceipt', request.requestId); + + const totalDurationMs = Math.round(options.clock.monotonicMs() - pipelineStart); + + await safeEmit( + buildEvent({ + type: 'resource.delivered', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + status: 'ok', + durationMs: totalDurationMs, + }), + ); + + return { + kind: 'delivered', + requestId: request.requestId, + resourceId: resource.id, + backendStatus: backendResponse.status, + body: backendResponse.body, + headers: backendResponse.headers, + ...(paymentResult !== undefined ? { payment: paymentResult } : {}), + receipt, + durationMs: totalDurationMs, + }; + } + + return { execute }; +} + +// The status the backend returned, which backend-http.ts puts on +// `details.status` for a non-2xx or redirect response. 0 means no status is +// known, as after a timeout, a transport failure or an unreadable or oversized +// body. +function backendErrorStatus(error: CommerceError): number { + const status = error.details?.['status']; + return typeof status === 'number' ? status : 0; +} + +const RESERVED_INPUT_KEYS: readonly string[] = [...RESERVED_INPUT_FIELDS, '__proto__']; + +function stripReservedFields(input: unknown): unknown { + if (!isRecord(input)) return input; + // Object.hasOwn, not `in`, which also matches inherited names + if (!RESERVED_INPUT_KEYS.some((key) => Object.hasOwn(input, key))) return input; + const rest: Record = {}; + for (const key of Object.keys(input)) { + // "__proto__" is dropped too: assigning it would call the inherited setter + // and replace `rest`'s prototype instead of creating a data property. + // validation.ts's own-property lookups are the primary control. + if (!RESERVED_INPUT_KEYS.includes(key)) rest[key] = input[key]; + } + return rest; +} diff --git a/src/core/execution/pipeline/settlement.ts b/src/core/execution/pipeline/settlement.ts new file mode 100644 index 0000000..b7734f0 --- /dev/null +++ b/src/core/execution/pipeline/settlement.ts @@ -0,0 +1,240 @@ +// Settles a verified, reserved payment, or records that the backend failed before it could +import type { PaymentRequirement, PaymentResult } from '../../domain/payment'; +import { CommerceError, isCommerceError } from '../../errors'; +import { retryChallenge } from './challenge'; +import type { PipelineContext, SettleArgs } from './context'; + +export interface Settlement { + /** Settles and finalizes the authorization; a refusal or unknown outcome throws */ + readonly settle: (args: SettleArgs) => Promise; + /** Records a backend failure before settlement and releases the authorization */ + readonly abandonSettlement: (args: SettleArgs, backendErrorCode: string) => Promise; +} + +export function createSettlement(ctx: PipelineContext): Settlement { + const { options, safeEmit, safePersist, buildEvent } = ctx; + + // Settle a verified, reserved payment and finalize its authorization. + // Rejected or uncertain settlement throws before any response is delivered. + async function settle(args: SettleArgs): Promise { + const { request, resource, provider, requirement, payment, verification, replayKey, hold } = + args; + let settlement: PaymentResult; + try { + settlement = await provider.settle({ + requestId: request.requestId, + resource, + requirement, + submission: payment, + verification, + }); + } catch (error) { + // Confirmed non-submission leaves the authorization spendable. Release + // it for retry and report the provider failure. + const notSubmitted = notSubmittedReason(error); + if (notSubmitted !== undefined) { + await hold?.finalize('release'); + await safePersist( + () => + options.store.updatePaymentAttempt({ + replayKey, + status: 'rejected', + rejectionReason: notSubmitted, + }), + 'updatePaymentAttempt(not-submitted)', + request.requestId, + ); + await safeEmit( + buildEvent({ + type: 'payment.rejected', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + paymentProvider: provider.name, + status: 'error', + data: { reason: 'settlement-not-submitted', providerReason: notSubmitted }, + }), + ); + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + 'The payment provider could not settle the payment; nothing was charged', + { requestId: request.requestId, resourceId: resource.id, cause: error }, + ); + } + // A throw gives no settlement verdict. The facilitator may have + // broadcast before losing its response, even without a transaction + // hash. Keep the authorization uncertain so it cannot be spent again. + const txHash = settlementTxHash(error); + await hold?.finalize('markUncertain'); + await safePersist( + () => + options.store.updatePaymentAttempt({ + replayKey, + status: 'settlement-uncertain', + ...(txHash !== undefined ? { externalReference: txHash } : {}), + ...(error instanceof Error ? { rejectionReason: error.message } : {}), + }), + 'updatePaymentAttempt(settlement-uncertain)', + request.requestId, + ); + await safeEmit( + buildEvent({ + type: 'payment.rejected', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + paymentProvider: provider.name, + status: 'error', + data: { + reason: 'settlement-uncertain', + ...(txHash !== undefined ? { transactionHash: txHash } : {}), + }, + }), + ); + // The replay key remains reserved, so report an uncertain, non-retryable + // outcome. Expose only the transaction hash from the underlying error. + throw new CommerceError('PAYMENT_SETTLEMENT_FAILED', 'Settlement could not be confirmed', { + requestId: request.requestId, + resourceId: resource.id, + cause: error, + details: { + settlementUncertain: true, + ...(txHash !== undefined ? { transactionHash: txHash } : {}), + ...settlementParties(requirement, verification), + }, + }); + } + + if (settlement.status !== 'settled') { + await hold?.finalize('release'); + await safePersist( + () => + options.store.updatePaymentAttempt({ + replayKey, + status: 'rejected', + ...(settlement.rejectionReason !== undefined + ? { rejectionReason: settlement.rejectionReason } + : {}), + ...(settlement.externalReference !== undefined + ? { externalReference: settlement.externalReference } + : {}), + }), + 'updatePaymentAttempt(rejected)', + request.requestId, + ); + await safeEmit( + buildEvent({ + type: 'payment.rejected', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + paymentProvider: provider.name, + status: 'error', + data: settlement.rejectionReason ? { reason: settlement.rejectionReason } : {}, + }), + ); + // A returned rejection confirms no settlement. Both rails return 402. + const rejectionDetails = retryChallenge(requirement, settlement.rejectionReason).details; + throw new CommerceError( + 'PAYMENT_SETTLEMENT_FAILED', + settlement.rejectionReason ?? 'Settlement was rejected', + { + requestId: request.requestId, + resourceId: resource.id, + httpStatus: 402, + details: { + ...rejectionDetails, + ...settlementParties(requirement, settlement, verification), + }, + }, + ); + } + + await hold?.finalize('consume'); + await safePersist( + () => + options.store.updatePaymentAttempt({ + replayKey, + status: 'settled', + ...(settlement.externalReference !== undefined + ? { externalReference: settlement.externalReference } + : {}), + }), + 'updatePaymentAttempt(settled)', + request.requestId, + ); + await safeEmit( + buildEvent({ + type: 'payment.settled', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + paymentProvider: provider.name, + status: 'ok', + }), + ); + + return settlement; + } + + // Record the pre-settlement backend failure and release the authorization + // hold. Rails with reuse metadata can reserve the unspent replay key again, + // as after any rejection. + async function abandonSettlement(args: SettleArgs, backendErrorCode: string): Promise { + const { request, resource, provider, replayKey, hold } = args; + await hold?.finalize('release'); + await safePersist( + () => + options.store.updatePaymentAttempt({ + replayKey, + status: 'rejected', + rejectionReason: 'backend_failed', + }), + 'updatePaymentAttempt(backend-failed)', + request.requestId, + ); + await safeEmit( + buildEvent({ + type: 'payment.rejected', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + paymentProvider: provider.name, + status: 'error', + data: { reason: 'backend-failed', backendErrorCode }, + }), + ); + } + + return { settle, abandonSettlement }; +} + +// The reason a provider gave when it threw before submitting anything +function notSubmittedReason(error: unknown): string | undefined { + if (!isCommerceError(error) || error.details?.['settlementNotSubmitted'] !== true) { + return undefined; + } + const reason = error.details['reason']; + return typeof reason === 'string' ? reason : 'settlement_not_submitted'; +} + +// The broadcast transaction hash a provider attached to a settlement throw, +// when it knows one +function settlementTxHash(error: unknown): string | undefined { + const hash = isCommerceError(error) ? error.details?.['transactionHash'] : undefined; + return typeof hash === 'string' ? hash : undefined; +} + +// Prefer the network and payer reported by settlement or verification, then +// fall back to the requirement's network +function settlementParties( + requirement: PaymentRequirement, + ...results: readonly PaymentResult[] +): { network?: string; payer?: string } { + const network = results.find((r) => r.network !== undefined)?.network ?? requirement.network; + const payer = results.find((r) => r.payer !== undefined)?.payer; + return { + ...(network !== undefined ? { network } : {}), + ...(payer !== undefined ? { payer } : {}), + }; +} diff --git a/src/core/execution/settlement-order.ts b/src/core/execution/settlement-order.ts index bbb96fa..84ec33c 100644 --- a/src/core/execution/settlement-order.ts +++ b/src/core/execution/settlement-order.ts @@ -3,3 +3,11 @@ * The pipeline settles first unless this metadata value is `true`. */ export const SETTLE_AFTER_BACKEND_METADATA_KEY = 'settleAfterBackend'; + +/** + * Set to `true` when the rail permits presenting an authorization again after + * its attempt was rejected, which settled nothing; the chain still spends each + * nonce once. The pipeline lets retries reclaim the rejected attempt; + * otherwise, it refuses them as replays. + */ +export const REUSE_REJECTED_AUTHORIZATION_METADATA_KEY = 'reuseRejectedAuthorization'; diff --git a/src/core/interfaces/store.ts b/src/core/interfaces/store.ts index 5ce4ef3..715090c 100644 --- a/src/core/interfaces/store.ts +++ b/src/core/interfaces/store.ts @@ -17,6 +17,12 @@ export interface PaymentAttemptReservation { readonly currency: string; readonly payer?: string; readonly payee?: string; + /** + * Reclaim this `replayKey` if the same `provider` rejected it, which + * settled nothing. Set only for rails whose authorizations remain reusable + * until settled. + */ + readonly reclaimRejected?: boolean; } export interface PaymentAttemptUpdate { @@ -42,6 +48,10 @@ export interface ReceiptStore { * `replayKey` is already reserved, throws `CommerceError('PAYMENT_REPLAYED')` * with the existing attempt's `PaymentAttempt.status` in * `details.attemptStatus` when available. + * + * With `reclaimRejected`, replace the same provider's `rejected` attempt, + * which settled nothing. Only one concurrent claim can replace it with the + * new reservation. */ reservePaymentAttempt(reservation: PaymentAttemptReservation): Promise; diff --git a/src/core/public-types.ts b/src/core/public-types.ts index 8e50789..417b81f 100644 --- a/src/core/public-types.ts +++ b/src/core/public-types.ts @@ -22,6 +22,7 @@ export type { export type { AdapterDescriptor, AdapterHealth, + AdapterHealthComponent, AuthorizationMethodName, DecimalAmount, IsoTimestamp, diff --git a/src/gateway/main.ts b/src/gateway/main.ts index fb9dd75..a77a27e 100644 --- a/src/gateway/main.ts +++ b/src/gateway/main.ts @@ -21,6 +21,7 @@ import type { Ap2AuthorizationProvider } from '../authorization/ap2'; import { createAp2AuthorizationProvider } from '../authorization/ap2'; import { loadConfig } from '../config'; +import type { X402OptionConfig } from '../config/schema'; import type { ProtocolAdapter, ReceiptStore } from '../core'; import { CommerceError, isCommerceError } from '../core'; import { createA2aAdapter } from '../protocols/a2a'; @@ -137,11 +138,22 @@ async function main(): Promise { if (x402?.enabled) { console.log(''); console.log(' x402 settlement'); - // Origin only: Alchemy, Infura and QuickNode put the API key in the URL, - // and REDACT_PATHS covers pino's structured fields, not console.log - console.log(` network ${x402.network} via ${rpcOrigin(x402.rpcUrl)}`); - console.log(` asset ${x402.asset} (${x402.assetName} v${x402.assetVersion})`); - console.log(` pays to ${x402.payTo} <- merchant-controlled, not the gateway`); + // Print each offer's RPC origin only: URLs may contain API keys, and + // pino's REDACT_PATHS does not protect console.log + const printOffer = (option: X402OptionConfig, indent = '') => { + console.log(`${indent} network ${option.network} via ${rpcOrigin(option.rpcUrl)}`); + const domain = option.assetName ? ` (${option.assetName} v${option.assetVersion})` : ''; + const method = option.assetTransferMethod === 'permit2' ? ' via Permit2' : ''; + console.log(`${indent} asset ${option.asset}${domain}${method}`); + console.log( + `${indent} pays to ${option.payTo} <- merchant-controlled, not the gateway`, + ); + }; + for (const option of x402.options ?? [x402]) printOffer(option); + for (const [id, list] of Object.entries(x402.resourceOptions ?? {})) { + console.log(` resource ${id} (its own offers)`); + for (const option of list) printOffer(option, ' '); + } console.log(` facilitator ${x402.facilitator.mode}`); console.log(''); } diff --git a/src/gateway/payment-providers.ts b/src/gateway/payment-providers.ts index f09634d..7fd7bb3 100644 --- a/src/gateway/payment-providers.ts +++ b/src/gateway/payment-providers.ts @@ -4,9 +4,25 @@ * dependencies, so the peer-free main entry and CLI must not import it. */ import type { GatewayConfig } from '../config'; +import type { X402OptionConfig } from '../config/schema'; import type { Logger, PaymentProvider } from '../core'; import { createMppPaymentProvider } from '../payments/mpp/provider'; -import { createX402PaymentProvider } from '../payments/x402'; +import { createX402PaymentProvider, type X402OfferOptions } from '../payments/x402'; + +function offerOptions(option: X402OptionConfig): X402OfferOptions { + return { + network: option.network, + rpcUrl: option.rpcUrl, + asset: option.asset, + assetDecimals: option.assetDecimals, + payTo: option.payTo, + ...(option.assetName !== undefined ? { assetName: option.assetName } : {}), + ...(option.assetVersion !== undefined ? { assetVersion: option.assetVersion } : {}), + ...(option.assetTransferMethod !== undefined + ? { assetTransferMethod: option.assetTransferMethod } + : {}), + }; +} export function createConfiguredPaymentProviders( payments: GatewayConfig['payments'], @@ -17,13 +33,18 @@ export function createConfiguredPaymentProviders( if (x402?.enabled) { providers.push( createX402PaymentProvider({ - network: x402.network, - rpcUrl: x402.rpcUrl, - asset: x402.asset as `0x${string}`, - assetName: x402.assetName, - assetVersion: x402.assetVersion, - assetDecimals: x402.assetDecimals, - payTo: x402.payTo as `0x${string}`, + // Support manually built single-option configs without `options` + offers: (x402.options ?? [x402]).map(offerOptions), + ...(x402.resourceOptions !== undefined + ? { + resourceOffers: Object.fromEntries( + Object.entries(x402.resourceOptions).map(([id, list]) => [ + id, + list.map(offerOptions), + ]), + ), + } + : {}), maxTimeoutSeconds: x402.maxTimeoutSeconds, ...(x402.paymentFlow !== undefined ? { paymentFlow: x402.paymentFlow } : {}), ...(x402.resourcePaymentFlows !== undefined @@ -34,6 +55,7 @@ export function createConfiguredPaymentProviders( ...(x402.allowUnauthenticatedFacilitator !== undefined ? { allowUnauthenticatedFacilitator: x402.allowUnauthenticatedFacilitator } : {}), + ...(x402.networks !== undefined ? { customNetworks: x402.networks } : {}), logger, }), ); diff --git a/src/gateway/readiness.ts b/src/gateway/readiness.ts index 23e21c8..fcedfa1 100644 --- a/src/gateway/readiness.ts +++ b/src/gateway/readiness.ts @@ -132,6 +132,13 @@ async function probe( interface Evaluation { readonly result: ReadinessResult; readonly adapters: readonly ProbedAdapter[]; + readonly paymentProviders: readonly ProbedProvider[]; +} + +/** A payment provider's health, detail and components included */ +export interface ProbedProvider { + readonly name: string; + readonly health: AdapterHealth; } async function evaluate(options: CheckReadinessOptions): Promise { @@ -183,6 +190,10 @@ async function evaluate(options: CheckReadinessOptions): Promise { descriptor: runtime.adapter.descriptor, health: probed.health, })), + paymentProviders: paymentProviders.map((probed, index) => ({ + name: (options.paymentProviders[index] as PaymentProvider).name, + health: probed.health, + })), }; } @@ -191,8 +202,10 @@ const READINESS_TTL_MS = 2_000; export interface ReadinessProbe { check(): Promise; - /** Every adapter's health from the same memoized evaluation, detail included */ + /** All adapter health results, including detail, from the cached evaluation */ adapterHealth(): Promise; + /** All payment provider health results from that evaluation */ + paymentHealth(): Promise; } export function createReadinessProbe( @@ -221,5 +234,6 @@ export function createReadinessProbe( return { check: async () => (await current()).result, adapterHealth: async () => (await current()).adapters, + paymentHealth: async () => (await current()).paymentProviders, }; } diff --git a/src/gateway/routes.ts b/src/gateway/routes.ts index 809ad7f..f883063 100644 --- a/src/gateway/routes.ts +++ b/src/gateway/routes.ts @@ -34,6 +34,7 @@ import { settlementResponse, settlementResponseFromDetails, x402ErrorCode, + x402RefusalStatus, } from '../payments/x402/transport'; import { buildOperatorTokenHook } from './access-control'; import type { AdapterRuntime } from './adapters'; @@ -86,6 +87,7 @@ export function registerRoutes(options: RegisterRoutesOptions): void { authorizationProviders: options.authorizationProviders, store: options.store, adapters: await readinessProbe.adapterHealth(), + paymentHealth: await readinessProbe.paymentHealth(), }), ); @@ -267,6 +269,14 @@ async function handleInvoke( } } +// A provider outage is no verdict on the payment, so the client may retry the +// same proof after a short pause +const PROVIDER_UNAVAILABLE_CODES: ReadonlySet = new Set([ + 'PAYMENT_PROVIDER_UNAVAILABLE', + 'AUTHORIZATION_PROVIDER_UNAVAILABLE', +]); +const PROVIDER_RETRY_AFTER_SECONDS = 5; + // Codes whose MPP response body is a Problem Details document const MPP_PROBLEM_CODES: ReadonlySet = new Set([ 'PAYMENT_INVALID', @@ -281,6 +291,9 @@ function sendInvokeError( paymentMethod: PaymentMethodName | undefined, ): void { const details = error.details; + if (PROVIDER_UNAVAILABLE_CODES.has(error.code)) { + reply.header('retry-after', String(PROVIDER_RETRY_AFTER_SECONDS)); + } // Report a settled payment even if backend delivery fails. MPP forbids a // `Payment-Receipt` header on error responses, so send only this summary const settled = settlementResponseFromDetails(details); @@ -302,7 +315,7 @@ function sendInvokeError( PAYMENT_REQUIRED_HEADER, encodeHeaderDocument({ ...challenge, - error: x402ErrorCode(error.code, details?.['reason']), + error: x402ErrorCode(error.code, details?.['reason'], details?.['network']), }), ); } @@ -327,7 +340,12 @@ function sendInvokeError( }); return; } - reply.status(error.httpStatus).send(envelope); + // Preserve the challenge when a missing Permit2 allowance changes 402 to 412 + const status = + paymentMethod === 'x402' + ? x402RefusalStatus(error.code, error.httpStatus, details?.['reason']) + : error.httpStatus; + reply.status(status).send(envelope); } // Each rail has its own proof header. MPP uses HTTP authentication, and an diff --git a/src/gateway/well-known.ts b/src/gateway/well-known.ts index 7841a74..8324ba1 100644 --- a/src/gateway/well-known.ts +++ b/src/gateway/well-known.ts @@ -12,6 +12,7 @@ */ import type { GatewayConfig } from '../config'; +import type { X402OptionConfig } from '../config/schema'; import type { AdapterDescriptor, AdapterHealth, @@ -19,14 +20,16 @@ import type { PaymentProvider, ReceiptStore, } from '../core'; -import { MPP_PROFILE } from '../payments/mpp/constants'; +import { MPP_DEFAULT_PAYMENT_FLOW, MPP_PROFILE } from '../payments/mpp/constants'; import { type DeploymentMode, - requireNetworkProfile, + requireX402Network, resolveDeploymentMode, } from '../payments/x402/networks'; +import { x402OfferName } from '../payments/x402/options'; +import { X402_DEFAULT_PAYMENT_FLOW, type X402PaymentFlow } from '../payments/x402/payment-flow'; import { PACKAGE_VERSION } from '../version'; -import type { ProbedAdapter } from './readiness'; +import type { ProbedAdapter, ProbedProvider } from './readiness'; // Not derived from `PACKAGE_VERSION`: it names the wire contract a client // negotiates against, which changes only when that contract does @@ -66,14 +69,25 @@ export interface WellKnownDocument { readonly enabled: boolean; readonly network: string; readonly asset: string; - readonly assetName: string; - readonly assetVersion: string; + /** Absent for a Permit2 token that has no EIP-712 domain */ + readonly assetName?: string; + readonly assetVersion?: string; readonly assetDecimals: number; readonly payTo: string; + /** Absent for EIP-3009, the default */ + readonly assetTransferMethod?: 'permit2'; readonly maxTimeoutSeconds: number; readonly facilitator: { readonly mode: 'local' | 'remote' }; - /** `local`, `testnet` or `mainnet`, from the network and facilitator together */ + /** `local`, `testnet` or `mainnet`, from the network */ readonly mode: DeploymentMode; + /** `authorization` settles after the backend; `upfront` before it */ + readonly paymentFlow: X402PaymentFlow; + /** Resources that set their own flow, by id; absent when none does */ + readonly resourcePaymentFlows?: Readonly>; + /** All rail offers when multiple exist; top-level fields mirror the first */ + readonly options?: readonly WellKnownX402Offer[]; + /** Resource offer overrides by id, replacing the rail's offers */ + readonly resourceOptions?: Readonly>; }; /** Public settlement fields; excludes the challenge secret and facilitator credentials */ readonly mpp?: { @@ -86,17 +100,36 @@ export interface WellKnownDocument { readonly recipient: string; readonly facilitator: { readonly mode: 'local' | 'remote' }; readonly mode: DeploymentMode; + /** `upfront` settles before the backend; `authorization` after it */ + readonly paymentFlow: X402PaymentFlow; + /** Resources that set their own flow, by id; absent when none does */ + readonly resourcePaymentFlows?: Readonly>; }; }; } +/** One x402 offer's public terms */ +export interface WellKnownX402Offer { + readonly network: string; + readonly asset: string; + readonly assetName?: string; + readonly assetVersion?: string; + readonly assetDecimals: number; + readonly payTo: string; + readonly assetTransferMethod?: 'permit2'; + /** Last reported health status, excluding diagnostic detail */ + readonly status?: AdapterHealth['status']; +} + export interface BuildWellKnownOptions { readonly config: GatewayConfig; readonly paymentProviders: readonly PaymentProvider[]; readonly authorizationProviders: readonly AuthorizationProvider[]; readonly store: ReceiptStore; - /** From the memoized readiness probe, so this route shares `/ready`'s `health()` calls */ + /** From the readiness cache, sharing `/ready`'s `health()` calls */ readonly adapters: readonly ProbedAdapter[]; + /** From the same probe; the x402 provider's components give each offer's status */ + readonly paymentHealth?: readonly ProbedProvider[]; } /** @@ -120,6 +153,35 @@ function publicProtocols(protocols: GatewayConfig['protocols']): WellKnownProtoc }; } +// Allowlist public offer fields: RPC URLs can contain API keys +function publicOffer( + option: X402OptionConfig, + statuses: ReadonlyMap, +): WellKnownX402Offer { + const { network, asset, assetName, assetVersion, assetDecimals, payTo, assetTransferMethod } = + option; + const status = statuses.get(x402OfferName(option)); + return { + network, + asset, + ...(assetName !== undefined ? { assetName } : {}), + ...(assetVersion !== undefined ? { assetVersion } : {}), + assetDecimals, + payTo, + ...(assetTransferMethod !== undefined ? { assetTransferMethod } : {}), + ...(status !== undefined ? { status } : {}), + }; +} + +// A rail's per-resource flows, or nothing when no resource overrides the rail +function flowOverrides(flows: Readonly> | undefined): { + readonly resourcePaymentFlows?: Readonly>; +} { + return flows !== undefined && Object.keys(flows).length > 0 + ? { resourcePaymentFlows: flows } + : {}; +} + export function buildWellKnownDocument(options: BuildWellKnownOptions): WellKnownDocument { const adapters = options.adapters.map(({ descriptor, health }) => ({ ...descriptor, @@ -127,6 +189,10 @@ export function buildWellKnownDocument(options: BuildWellKnownOptions): WellKnow })); const { x402, mpp } = options.config.payments; + const x402Health = options.paymentHealth?.find((probed) => probed.name === 'x402')?.health; + const offerStatuses = new Map( + (x402Health?.components ?? []).map((component) => [component.name, component.status]), + ); return { gateway: { @@ -146,20 +212,35 @@ export function buildWellKnownDocument(options: BuildWellKnownOptions): WellKnow enabled: x402.enabled, network: x402.network, asset: x402.asset, - assetName: x402.assetName, - assetVersion: x402.assetVersion, + ...(x402.assetName !== undefined ? { assetName: x402.assetName } : {}), + ...(x402.assetVersion !== undefined ? { assetVersion: x402.assetVersion } : {}), assetDecimals: x402.assetDecimals, payTo: x402.payTo, + ...(x402.assetTransferMethod !== undefined + ? { assetTransferMethod: x402.assetTransferMethod } + : {}), maxTimeoutSeconds: x402.maxTimeoutSeconds, // Only local or remote is public: the facilitator URL can carry // a tenant path or an API key, and the signer key is secret facilitator: { mode: x402.facilitator.mode }, - // Chain id 84532 is both the local dev chain and public Base - // Sepolia, so the network id alone cannot say which one this is mode: resolveDeploymentMode( - requireNetworkProfile(x402.network, 'payments.x402.network'), - x402.facilitator.mode, + requireX402Network(x402.network, 'payments.x402.network', x402.networks), ), + paymentFlow: x402.paymentFlow ?? X402_DEFAULT_PAYMENT_FLOW, + ...flowOverrides(x402.resourcePaymentFlows), + ...(x402.options !== undefined && x402.options.length > 1 + ? { options: x402.options.map((option) => publicOffer(option, offerStatuses)) } + : {}), + ...(x402.resourceOptions !== undefined + ? { + resourceOptions: Object.fromEntries( + Object.entries(x402.resourceOptions).map(([id, list]) => [ + id, + list.map((option) => publicOffer(option, offerStatuses)), + ]), + ), + } + : {}), }, } : {}), @@ -174,10 +255,9 @@ export function buildWellKnownDocument(options: BuildWellKnownOptions): WellKnow assetDecimals: MPP_PROFILE.assetDecimals, recipient: mpp.recipient, facilitator: { mode: mpp.facilitator.mode }, - mode: resolveDeploymentMode( - requireNetworkProfile(mpp.network, 'payments.mpp.network'), - mpp.facilitator.mode, - ), + mode: resolveDeploymentMode(requireX402Network(mpp.network, 'payments.mpp.network')), + paymentFlow: mpp.paymentFlow ?? MPP_DEFAULT_PAYMENT_FLOW, + ...flowOverrides(mpp.resourcePaymentFlows), }, } : {}), diff --git a/src/payments/mpp/constants.ts b/src/payments/mpp/constants.ts index c82625c..8b7dd86 100644 --- a/src/payments/mpp/constants.ts +++ b/src/payments/mpp/constants.ts @@ -7,6 +7,7 @@ * * Importing this metadata does not load `mppx`. */ +import type { X402PaymentFlow } from '../x402/payment-flow'; /** Upstream specification repository the pinned drafts were read from */ export const MPP_SPEC_REPOSITORY = 'https://github.com/tempoxyz/mpp-specs'; @@ -52,13 +53,20 @@ export const MPP_PROFILE = { } as const; /** Supported CAIP-2 networks; gateway config applies the x402 mainnet guardrails */ -export const MPP_NETWORKS = ['eip155:84532', 'eip155:8453'] as const; +export const MPP_NETWORKS = ['eip155:31337', 'eip155:84532', 'eip155:8453'] as const; export type MppNetwork = (typeof MPP_NETWORKS)[number]; -/** Default network ID, shared by Base Sepolia and the local development chain */ +/** Default network: Base Sepolia. The local Anvil chain is `eip155:31337` */ export const MPP_DEFAULT_NETWORK: MppNetwork = 'eip155:84532'; +/** + * Default payment flow: `upfront` settles before the backend runs. The + * `charge` intent counts a backend call made before verification completes as + * partial access, and EVM charge verification includes the transfer itself. + */ +export const MPP_DEFAULT_PAYMENT_FLOW: X402PaymentFlow = 'upfront'; + export function isMppNetwork(value: string): value is MppNetwork { return (MPP_NETWORKS as readonly string[]).includes(value); } diff --git a/src/payments/mpp/credential.ts b/src/payments/mpp/credential.ts new file mode 100644 index 0000000..1cd024a --- /dev/null +++ b/src/payments/mpp/credential.ts @@ -0,0 +1,108 @@ +// Reading an MPP credential: its decoding, its binding to the challenge, and the x402 proof it carries +import { type Challenge, Credential, Errors, PaymentRequest } from 'mppx'; +import type { Types } from 'mppx/evm'; +import { getAddress, isAddress } from 'viem'; +import type { PaymentRequirement, PaymentSubmission } from '../../core'; + +// mppx reports a verification failure only as message text. The pinned +// release's wording maps to stable reasons, and the tests cover every entry; +// unmapped text becomes `authorization_invalid`. +const VERIFICATION_REASONS: readonly (readonly [string, string])[] = [ + ['EVM authorization challenge hash mismatch', 'wrong_nonce'], + ['EVM authorization signature mismatch', 'invalid_signature'], + ['EVM authorization is not valid yet', 'authorization_not_yet_valid'], + ['EVM authorization has expired', 'authorization_expired'], + ['EVM authorization source mismatch', 'source_mismatch'], + ['EVM authorization amount mismatch', 'wrong_amount'], + ['EVM authorization recipient mismatch', 'wrong_recipient'], +]; + +export function verificationReason(error: unknown): string { + if (error instanceof Errors.PaymentExpiredError) return 'challenge_expired'; + if (error instanceof Errors.InvalidPayloadError) return 'invalid_payload'; + if (error instanceof Errors.InvalidChallengeError) return 'invalid_challenge'; + if (error instanceof Errors.VerificationFailedError) { + const match = VERIFICATION_REASONS.find(([text]) => error.message.includes(text)); + if (match) return match[1]; + } + return 'authorization_invalid'; +} + +// Accept the HTTP `Authorization: Payment ...` value or a JSON credential +// from MCP `_meta`. Serialize the object for mppx deserialization +export function decodeCredential(payload: string): Credential.Credential { + const serialized = payload.trimStart().startsWith('{') + ? Credential.serialize(JSON.parse(payload) as Credential.Credential) + : payload; + return Credential.deserialize(serialized); +} + +// Compares the echoed challenge with the one issued for this request. Both +// carry a valid HMAC by now, so any difference means the credential answers a +// challenge issued for other terms. The final comparison catches a difference +// in any request field the named checks do not cover. +export function bindingMismatch( + echoed: Challenge.Challenge, + issued: Challenge.Challenge, + resourceId: string, +): string | undefined { + if (echoed.realm !== issued.realm) return 'wrong_realm'; + if (echoed.method !== issued.method || echoed.intent !== issued.intent) { + return 'unsupported_method'; + } + // Only the encoded `opaque` survives the credential round trip, not `meta`, + // so compare it with the encoding of this resource's binding + if (echoed.opaque !== PaymentRequest.serialize({ resource: resourceId })) { + return 'wrong_resource'; + } + // `issued` was built for the request carrying this credential, so a + // different body, or none, fails here. A challenge without a digest binds + // no body, as the core draft reads it. + if (echoed.digest !== undefined && echoed.digest !== issued.digest) { + return 'body_digest_mismatch'; + } + const got = echoed.request as ChargeRequest; + const want = issued.request as ChargeRequest; + if (got.amount !== want.amount) return 'wrong_amount'; + if (!sameAddress(got.recipient, want.recipient)) return 'wrong_recipient'; + if (got.methodDetails?.chainId !== want.methodDetails?.chainId) return 'wrong_network'; + if (!sameAddress(got.currency, want.currency)) return 'wrong_asset'; + if (canonical(got.methodDetails) !== canonical(want.methodDetails)) { + return 'unsupported_credential'; + } + if (canonical(got) !== canonical(want)) return 'wrong_terms'; + return undefined; +} + +// Rewrap the MPP authorization as the x402 `exact` payload expected by settlement +export function toX402Submission( + x402Requirement: PaymentRequirement, + authorization: Types.AuthorizationPayload, +): PaymentSubmission { + const { signature, from, to, value, validAfter, validBefore, nonce } = authorization; + const payment = { + x402Version: 2, + accepted: x402Requirement.challenge.accepts[0], + payload: { signature, authorization: { from, to, value, validAfter, validBefore, nonce } }, + }; + return { method: 'x402', payload: Buffer.from(JSON.stringify(payment)).toString('base64') }; +} + +interface ChargeRequest { + readonly amount?: string; + readonly currency?: string; + readonly recipient?: string; + readonly methodDetails?: { readonly chainId?: number }; +} + +// The encoding mppx binds into the challenge HMAC, so key order cannot +// produce a false mismatch +function canonical(value: object | undefined): string { + return value === undefined ? '' : PaymentRequest.serialize(value as PaymentRequest.Request); +} + +export function sameAddress(a: string | undefined, b: string | undefined): boolean { + return a !== undefined && b !== undefined && isAddress(a) && isAddress(b) + ? getAddress(a) === getAddress(b) + : false; +} diff --git a/src/payments/mpp/options.ts b/src/payments/mpp/options.ts new file mode 100644 index 0000000..965606e --- /dev/null +++ b/src/payments/mpp/options.ts @@ -0,0 +1,86 @@ +// The MPP provider's options, checked once at construction +import { isAddress } from 'viem'; +import { type Clock, CommerceError, type IdGenerator, type Logger } from '../../core'; +import type { X402FacilitatorConfig } from '../x402/guardrails'; +import type { X402PaymentFlow } from '../x402/payment-flow'; +import { isMppNetwork, MPP_MIN_CHALLENGE_SECRET_LENGTH, MPP_NETWORKS } from './constants'; + +/** Whether the token contract has recorded `nonce` as used by `payer` */ +export type NonceUsedReader = (payer: `0x${string}`, nonce: `0x${string}`) => Promise; + +export interface MppProviderOptions { + /** Merchant-controlled settlement destination. Never gateway-owned */ + readonly recipient: `0x${string}`; + /** EIP-3009 token the charge is paid in */ + readonly asset: `0x${string}`; + /** EIP-712 domain name of the asset, e.g. 'USDC' or 'MockUSDC' */ + readonly assetName: string; + /** EIP-712 domain version of the asset, e.g. '2' */ + readonly assetVersion: string; + /** Value of each challenge's `realm`, such as the gateway's host */ + readonly realm: string; + /** Key that binds each challenge to this gateway. Never logged or published */ + readonly challengeSecret: string; + /** RPC endpoint of the settlement chain */ + readonly rpcUrl: string; + /** The x402 facilitator that verifies and broadcasts the authorization */ + readonly facilitator: X402FacilitatorConfig; + /** Must be `true` before anything settles on a mainnet. Never a default */ + readonly allowMainnet?: boolean; + /** Required on mainnet when facilitator auth is `none` */ + readonly allowUnauthenticatedFacilitator?: boolean; + readonly logger?: Logger; + /** + * Seconds a challenge stays valid, 300 by default. The mppx client signs its + * authorization to expire at the same moment. + */ + readonly challengeTtlSeconds?: number; + /** + * CAIP-2 network: `eip155:31337` (local Anvil), `eip155:84532` (Base + * Sepolia, the default) or `eip155:8453` (Base) + */ + readonly network?: string; + /** + * Same flow values as x402. The default, `upfront`, settles before the + * backend; `authorization` settles after it + */ + readonly paymentFlow?: X402PaymentFlow; + /** Per-resource overrides of `paymentFlow`, keyed by resource id */ + readonly resourcePaymentFlows?: Readonly>; + readonly clock?: Clock; + readonly ids?: IdGenerator; +} + +export function configInvalid(message: string): CommerceError { + return new CommerceError('CONFIG_INVALID', message); +} + +export type MppChallengeOptions = Omit< + MppProviderOptions, + 'rpcUrl' | 'facilitator' | 'allowMainnet' | 'allowUnauthenticatedFacilitator' | 'logger' +>; + +export function validateOptions(options: MppChallengeOptions): void { + if (!isAddress(options.recipient)) throw configInvalid('MPP recipient is not an EVM address'); + if (!isAddress(options.asset)) throw configInvalid('MPP asset is not an EVM address'); + if (options.assetName.length === 0 || options.assetVersion.length === 0) { + throw configInvalid('MPP asset needs its EIP-712 domain name and version'); + } + // mppx refuses CR or LF in the quoted realm parameter, so checking here fails + // at construction instead of on every challenge + if (options.realm.length === 0 || /[\r\n]/.test(options.realm)) { + throw configInvalid('MPP realm must be a non-empty single-line string'); + } + if (options.challengeSecret.length < MPP_MIN_CHALLENGE_SECRET_LENGTH) { + throw configInvalid( + `MPP challenge secret must have length at least ${MPP_MIN_CHALLENGE_SECRET_LENGTH}`, + ); + } + const ttl = options.challengeTtlSeconds; + if (ttl !== undefined && (!Number.isInteger(ttl) || ttl <= 0)) { + throw configInvalid('MPP challenge TTL must be a positive whole number of seconds'); + } + if (options.network !== undefined && !isMppNetwork(options.network)) { + throw configInvalid(`MPP network must be one of ${MPP_NETWORKS.join(', ')}`); + } +} diff --git a/src/payments/mpp/provider.ts b/src/payments/mpp/provider.ts index 435ea9e..65d7465 100644 --- a/src/payments/mpp/provider.ts +++ b/src/payments/mpp/provider.ts @@ -7,44 +7,50 @@ * the same signed authorization to that facilitator through an internal x402 * provider built from these options. The MPP layer adds no signing key. * - * Settlement follows a successful backend call unless the payment flow is - * `upfront`. The drafts require a confirmed transaction before a - * `Payment-Receipt`, not before the backend runs. + * Under the default `upfront` flow settlement precedes the backend call; under + * `authorization` it follows a successful one. The drafts require a confirmed + * transaction before a `Payment-Receipt`. */ -import { Challenge, Credential, Errors, Method, PaymentRequest, Receipt } from 'mppx'; +import { Challenge, type Credential, Method, Receipt } from 'mppx'; import { Methods, type Types } from 'mppx/evm'; import { charge } from 'mppx/evm/server'; -import { createPublicClient, getAddress, http, isAddress, parseAbi } from 'viem'; +import { createPublicClient, getAddress, http, parseAbi } from 'viem'; import { type AdapterHealth, - type Clock, CommerceError, - type IdGenerator, - type Logger, type PaymentContext, type PaymentProvider, type PaymentRequirement, type PaymentResult, type PaymentSettlementContext, - type PaymentSubmission, type PaymentVerificationContext, systemClock, } from '../../core'; import { SETTLE_AFTER_BACKEND_METADATA_KEY } from '../../core/execution/settlement-order'; import { parseCanonicalAmount } from '../x402/amount'; -import type { X402FacilitatorConfig } from '../x402/guardrails'; -import { X402_DEFAULT_PAYMENT_FLOW, type X402PaymentFlow } from '../x402/payment-flow'; import { createX402PaymentProvider, DEFAULT_IDS } from '../x402/provider'; import { computeReplayKey } from '../x402/replay-key'; import { - isMppNetwork, MPP_DEFAULT_NETWORK, - MPP_MIN_CHALLENGE_SECRET_LENGTH, - MPP_NETWORKS, + MPP_DEFAULT_PAYMENT_FLOW, MPP_PROFILE, MPP_SPEC_DRAFTS, } from './constants'; +import { + bindingMismatch, + decodeCredential, + sameAddress, + toX402Submission, + verificationReason, +} from './credential'; import { MPP_DESCRIPTOR } from './descriptor'; +import { + configInvalid, + type MppChallengeOptions, + type MppProviderOptions, + type NonceUsedReader, + validateOptions, +} from './options'; import { CONTENT_DIGEST_METADATA_KEY } from './transport'; const DEFAULT_CHALLENGE_TTL_SECONDS = 300; @@ -55,105 +61,8 @@ const NONCE_ALREADY_USED = 'invalid_exact_evm_nonce_already_used'; const AUTHORIZATION_STATE_ABI = parseAbi([ 'function authorizationState(address authorizer, bytes32 nonce) view returns (bool)', ]); -const NONCE_READ_TIMEOUT_MS = 5_000; - -/** Whether the token contract has recorded `nonce` as used by `payer` */ -export type NonceUsedReader = (payer: `0x${string}`, nonce: `0x${string}`) => Promise; - -export interface MppProviderOptions { - /** Merchant-controlled settlement destination. Never gateway-owned */ - readonly recipient: `0x${string}`; - /** EIP-3009 token the charge is paid in */ - readonly asset: `0x${string}`; - /** EIP-712 domain name of the asset, e.g. 'USDC' or 'MockUSDC' */ - readonly assetName: string; - /** EIP-712 domain version of the asset, e.g. '2' */ - readonly assetVersion: string; - /** Value of each challenge's `realm`, such as the gateway's host */ - readonly realm: string; - /** Key that binds each challenge to this gateway. Never logged or published */ - readonly challengeSecret: string; - /** RPC endpoint of the settlement chain */ - readonly rpcUrl: string; - /** The x402 facilitator that verifies and broadcasts the authorization */ - readonly facilitator: X402FacilitatorConfig; - /** Must be `true` before anything settles on a mainnet. Never a default */ - readonly allowMainnet?: boolean; - /** Required on mainnet when facilitator auth is `none` */ - readonly allowUnauthenticatedFacilitator?: boolean; - readonly logger?: Logger; - /** - * Seconds a challenge stays valid, 300 by default. The mppx client signs its - * authorization to expire at the same moment. - */ - readonly challengeTtlSeconds?: number; - /** CAIP-2 network; accepts `eip155:84532` or `eip155:8453` and defaults to the former */ - readonly network?: string; - /** Same flow values as x402; `authorization` settles after the backend */ - readonly paymentFlow?: X402PaymentFlow; - /** Per-resource overrides of `paymentFlow`, keyed by resource id */ - readonly resourcePaymentFlows?: Readonly>; - readonly clock?: Clock; - readonly ids?: IdGenerator; -} - -// mppx reports a verification failure only as message text. The pinned -// release's wording maps to stable reasons, and the tests cover every entry; -// unmapped text becomes `authorization_invalid`. -const VERIFICATION_REASONS: readonly (readonly [string, string])[] = [ - ['EVM authorization challenge hash mismatch', 'wrong_nonce'], - ['EVM authorization signature mismatch', 'invalid_signature'], - ['EVM authorization is not valid yet', 'authorization_not_yet_valid'], - ['EVM authorization has expired', 'authorization_expired'], - ['EVM authorization source mismatch', 'source_mismatch'], - ['EVM authorization amount mismatch', 'wrong_amount'], - ['EVM authorization recipient mismatch', 'wrong_recipient'], -]; - -function configInvalid(message: string): CommerceError { - return new CommerceError('CONFIG_INVALID', message); -} - -type MppChallengeOptions = Omit< - MppProviderOptions, - 'rpcUrl' | 'facilitator' | 'allowMainnet' | 'allowUnauthenticatedFacilitator' | 'logger' ->; - -function validateOptions(options: MppChallengeOptions): void { - if (!isAddress(options.recipient)) throw configInvalid('MPP recipient is not an EVM address'); - if (!isAddress(options.asset)) throw configInvalid('MPP asset is not an EVM address'); - if (options.assetName.length === 0 || options.assetVersion.length === 0) { - throw configInvalid('MPP asset needs its EIP-712 domain name and version'); - } - // mppx refuses CR or LF in the quoted realm parameter, so checking here fails - // at construction instead of on every challenge - if (options.realm.length === 0 || /[\r\n]/.test(options.realm)) { - throw configInvalid('MPP realm must be a non-empty single-line string'); - } - if (options.challengeSecret.length < MPP_MIN_CHALLENGE_SECRET_LENGTH) { - throw configInvalid( - `MPP challenge secret must have length at least ${MPP_MIN_CHALLENGE_SECRET_LENGTH}`, - ); - } - const ttl = options.challengeTtlSeconds; - if (ttl !== undefined && (!Number.isInteger(ttl) || ttl <= 0)) { - throw configInvalid('MPP challenge TTL must be a positive whole number of seconds'); - } - if (options.network !== undefined && !isMppNetwork(options.network)) { - throw configInvalid(`MPP network must be one of ${MPP_NETWORKS.join(', ')}`); - } -} -function verificationReason(error: unknown): string { - if (error instanceof Errors.PaymentExpiredError) return 'challenge_expired'; - if (error instanceof Errors.InvalidPayloadError) return 'invalid_payload'; - if (error instanceof Errors.InvalidChallengeError) return 'invalid_challenge'; - if (error instanceof Errors.VerificationFailedError) { - const match = VERIFICATION_REASONS.find(([text]) => error.message.includes(text)); - if (match) return match[1]; - } - return 'authorization_invalid'; -} +const NONCE_READ_TIMEOUT_MS = 5_000; export function createMppPaymentProvider(options: MppProviderOptions): PaymentProvider { const { rpcUrl, facilitator, allowMainnet, allowUnauthenticatedFacilitator, logger, ...rest } = @@ -260,7 +169,7 @@ export function createMppProviderWithSettlement( const paymentFlow = options.resourcePaymentFlows?.[context.resource.id] ?? options.paymentFlow ?? - X402_DEFAULT_PAYMENT_FLOW; + MPP_DEFAULT_PAYMENT_FLOW; // mppx signs `validBefore` at this expiry. Under `authorization`, the // backend must finish before then for settlement to proceed. const expires = new Date(clock.now().getTime() + ttlSeconds * 1000); @@ -476,81 +385,4 @@ export function createMppProviderWithSettlement( return { name: 'mpp', descriptor: MPP_DESCRIPTOR, createRequirement, verify, settle, health }; } -// Accept the HTTP `Authorization: Payment ...` value or a JSON credential -// from MCP `_meta`. Serialize the object for mppx deserialization -function decodeCredential(payload: string): Credential.Credential { - const serialized = payload.trimStart().startsWith('{') - ? Credential.serialize(JSON.parse(payload) as Credential.Credential) - : payload; - return Credential.deserialize(serialized); -} - -// Compares the echoed challenge with the one issued for this request. Both -// carry a valid HMAC by now, so any difference means the credential answers a -// challenge issued for other terms. The final comparison catches a difference -// in any request field the named checks do not cover. -function bindingMismatch( - echoed: Challenge.Challenge, - issued: Challenge.Challenge, - resourceId: string, -): string | undefined { - if (echoed.realm !== issued.realm) return 'wrong_realm'; - if (echoed.method !== issued.method || echoed.intent !== issued.intent) { - return 'unsupported_method'; - } - // Only the encoded `opaque` survives the credential round trip, not `meta`, - // so compare it with the encoding of this resource's binding - if (echoed.opaque !== PaymentRequest.serialize({ resource: resourceId })) { - return 'wrong_resource'; - } - // `issued` was built for the request carrying this credential, so a - // different body, or none, fails here. A challenge without a digest binds - // no body, as the core draft reads it. - if (echoed.digest !== undefined && echoed.digest !== issued.digest) { - return 'body_digest_mismatch'; - } - const got = echoed.request as ChargeRequest; - const want = issued.request as ChargeRequest; - if (got.amount !== want.amount) return 'wrong_amount'; - if (!sameAddress(got.recipient, want.recipient)) return 'wrong_recipient'; - if (got.methodDetails?.chainId !== want.methodDetails?.chainId) return 'wrong_network'; - if (!sameAddress(got.currency, want.currency)) return 'wrong_asset'; - if (canonical(got.methodDetails) !== canonical(want.methodDetails)) { - return 'unsupported_credential'; - } - if (canonical(got) !== canonical(want)) return 'wrong_terms'; - return undefined; -} - -// Rewrap the MPP authorization as the x402 `exact` payload expected by settlement -function toX402Submission( - x402Requirement: PaymentRequirement, - authorization: Types.AuthorizationPayload, -): PaymentSubmission { - const { signature, from, to, value, validAfter, validBefore, nonce } = authorization; - const payment = { - x402Version: 2, - accepted: x402Requirement.challenge.accepts[0], - payload: { signature, authorization: { from, to, value, validAfter, validBefore, nonce } }, - }; - return { method: 'x402', payload: Buffer.from(JSON.stringify(payment)).toString('base64') }; -} - -interface ChargeRequest { - readonly amount?: string; - readonly currency?: string; - readonly recipient?: string; - readonly methodDetails?: { readonly chainId?: number }; -} - -// The encoding mppx binds into the challenge HMAC, so key order cannot -// produce a false mismatch -function canonical(value: object | undefined): string { - return value === undefined ? '' : PaymentRequest.serialize(value as PaymentRequest.Request); -} - -function sameAddress(a: string | undefined, b: string | undefined): boolean { - return a !== undefined && b !== undefined && isAddress(a) && isAddress(b) - ? getAddress(a) === getAddress(b) - : false; -} +export type { MppProviderOptions, NonceUsedReader } from './options'; diff --git a/src/payments/x402/addresses.ts b/src/payments/x402/addresses.ts new file mode 100644 index 0000000..d64dc38 --- /dev/null +++ b/src/payments/x402/addresses.ts @@ -0,0 +1,93 @@ +/** + * SDK-free address checks for config load and the CLI. EVM addresses compare + * without case; Solana keys and NEAR account IDs compare exactly. + */ +import type { X402Network } from './networks'; + +// `0x` + 40 hex characters. Config load cannot check the EIP-55 checksum, +// which needs keccak-256; the x402 and MPP providers refuse a mixed-case +// address whose checksum is wrong when they start. +const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/; +const EVM_ZERO = /^0x0{40}$/i; + +/** `0x` followed by 40 hex characters, in any case */ +export const isEvmAddress = (value: unknown): value is `0x${string}` => + typeof value === 'string' && EVM_ADDRESS.test(value); + +/** The EVM zero address, in any case */ +export const isEvmZeroAddress = (value: string): boolean => EVM_ZERO.test(value); + +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; +// A 32-byte key is 32 to 44 base58 characters +const SOLANA_ADDRESS = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; +// The all-zero key, which is the System Program and owns nothing recoverable +const SOLANA_ZERO = '11111111111111111111111111111111'; +const EVM_TRANSACTION_HASH = /^0x[0-9a-fA-F]{64}$/; +// A 64-byte Ed25519 signature is at most 88 base58 characters +const SOLANA_SIGNATURE = /^[1-9A-HJ-NP-Za-km-z]{64,88}$/; + +// nearcore's account ID rule: 2 to 64 characters, lowercase alphanumeric runs +// joined by single "-", "_" or "."; implicit accounts are 64 hex characters +const NEAR_ACCOUNT_ID = /^(([a-z\d]+[-_])*[a-z\d]+\.)*([a-z\d]+[-_])*[a-z\d]+$/; +// A transaction hash: 32 bytes in base58 +const NEAR_TRANSACTION_HASH = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; + +/** + * Returns the family's wire address or `undefined`. NEAR uses named or + * implicit account IDs; a `.near` suffix does not identify the network. + */ +export function normalizeAddress(network: X402Network, value: string): string | undefined { + switch (network.family) { + case 'evm': + return isEvmAddress(value) ? value : undefined; + case 'svm': + return SOLANA_ADDRESS.test(value) && base58ByteLength(value) === 32 ? value : undefined; + case 'near': + return value.length >= 2 && value.length <= 64 && NEAR_ACCOUNT_ID.test(value) + ? value + : undefined; + } +} + +/** + * An address funds cannot be recovered from: the zero address of its family. + * NEAR has no zero account. + */ +export function isBurnAddress(network: X402Network, wireAddress: string): boolean { + switch (network.family) { + case 'evm': + return isEvmZeroAddress(wireAddress); + case 'svm': + return wireAddress === SOLANA_ZERO; + case 'near': + return false; + } +} + +/** Compare EVM addresses case-insensitively, Solana keys and NEAR account IDs exactly */ +export function sameAddress(network: X402Network, a: string, b: string): boolean { + return network.family === 'evm' ? a.toLowerCase() === b.toLowerCase() : a === b; +} + +/** + * A settlement reference in its family's form: an EVM transaction hash, a + * Solana transaction signature or a NEAR transaction hash + */ +export function isTransactionReference(network: X402Network, value: string): boolean { + switch (network.family) { + case 'evm': + return EVM_TRANSACTION_HASH.test(value); + case 'svm': + return SOLANA_SIGNATURE.test(value) && base58ByteLength(value) === 64; + case 'near': + return NEAR_TRANSACTION_HASH.test(value) && base58ByteLength(value) === 32; + } +} + +function base58ByteLength(value: string): number { + let n = 0n; + for (const char of value) n = n * 58n + BigInt(BASE58_ALPHABET.indexOf(char)); + // Each leading '1' encodes one leading zero byte + const leadingZeros = value.length - value.replace(/^1+/, '').length; + return leadingZeros + (n === 0n ? 0 : Math.ceil(n.toString(16).length / 2)); +} diff --git a/src/payments/x402/chain.ts b/src/payments/x402/chain.ts index 729e7b6..8d297c8 100644 --- a/src/payments/x402/chain.ts +++ b/src/payments/x402/chain.ts @@ -3,12 +3,10 @@ * SDK uses the client it is handed, so the provider's chain calls reach no * other RPC. * - * Despite the `Local` names, `createLocalFacilitatorClient` sits on the - * settlement path and `createLocalPublicClient` backs `health()`. The health - * client is built for whichever network is configured. The facilitator client - * always uses chain id 84532: an in-process facilitator is refused on a - * mainnet, and Base Sepolia is the registry's only testnet. Registering a - * second testnet means building this client for the configured chain. + * `createLocalPublicClient` backs health checks on the configured network. + * `createLocalFacilitatorClient` handles settlement and defaults to the local + * chain ID, the only network the gateway's in-process facilitator serves. + * Deployment tools and test facilitators can pass another chain ID. */ import { type Account, @@ -50,10 +48,7 @@ export function chainIdFromCaip2(network: string): number | undefined { return Number.isSafeInteger(chainId) && chainId > 0 ? chainId : undefined; } -/** - * `chainId` defaults to the local dev chain, the only chain the facilitator - * client is built for. The health client passes the configured network's id. - */ +// `chainId` defaults to the local dev chain; callers on another chain pass its id function buildLocalChain(rpcUrl: string, chainId: number = LOCAL_CHAIN_ID): Chain { return { id: chainId, @@ -95,8 +90,9 @@ export function createLocalPublicClient( export function createLocalFacilitatorClient( rpcUrl: string, signerPrivateKey: `0x${string}`, + chainId?: number, ): LocalFacilitatorClient { - const chain = buildLocalChain(rpcUrl); + const chain = buildLocalChain(rpcUrl, chainId); const account = privateKeyToAccount(signerPrivateKey); return createWalletClient({ account, diff --git a/src/payments/x402/client.ts b/src/payments/x402/client.ts index 5c2d1df..b2a088c 100644 --- a/src/payments/x402/client.ts +++ b/src/payments/x402/client.ts @@ -48,6 +48,13 @@ const TRANSFER_WITH_AUTHORIZATION_TYPES = { /** Returns the base64 `PAYMENT-SIGNATURE` value to send back to the gateway */ export async function createPaymentProof(options: CreatePaymentProofOptions): Promise { const requirements = PaymentRequirementsV2Schema.parse(options.accepts); + const method = requirements.extra?.['assetTransferMethod']; + if (method !== undefined && method !== 'eip3009') { + throw new CommerceError( + 'INPUT_INVALID', + `createPaymentProof: signs EIP-3009 only, and this offer uses "${String(method)}"; sign it with the SDK's x402Client`, + ); + } if ( !requirements.extra || typeof requirements.extra['name'] !== 'string' || diff --git a/src/payments/x402/facilitator.ts b/src/payments/x402/facilitator.ts index 2ea9c03..cbd6043 100644 --- a/src/payments/x402/facilitator.ts +++ b/src/payments/x402/facilitator.ts @@ -1,15 +1,12 @@ /** - * The facilitator verifies an authorization and broadcasts the transfer. Two - * kinds sit behind one interface, both driving the SDK's own code: + * Shared facilitator interface and remote HTTP binding. The in-process EVM + * binding lives in `local-facilitator.ts` to keep EVM peers out of this module. + * `@x402/core` loads statically; `@coinbase/x402` loads only for `auth.type: cdp`. + * Both bindings use the SDK to verify authorizations and broadcast transfers. * - * local the SDK's in-process `x402Facilitator`, signing against a dev node - * remote the SDK's HTTP facilitator client, with or without a credential - * - * Each verify or settle call opens its own session so that `transportFailed()` - * covers one request only: concurrent payments must not see each other's - * transport failures. + * Each verify or settle call gets a session with its own `transportFailed()` + * flag, isolating concurrent payments' transport failures. */ -import { x402Facilitator } from '@x402/core/facilitator'; import { HTTPFacilitatorClient } from '@x402/core/http'; import { type Network, @@ -17,13 +14,11 @@ import { type PaymentRequirements, SettleError, type SettleResponse, + type SupportedResponse, VerifyError, type VerifyResponse, } from '@x402/core/types'; -import { toFacilitatorEvmSigner } from '@x402/evm'; -import { registerExactEvmScheme } from '@x402/evm/exact/facilitator'; import { CommerceError } from '../../core'; -import type { LocalFacilitatorClient } from './chain'; import type { FacilitatorAuth } from './guardrails'; /** One verify or one settle, with its own transport-failure flag */ @@ -43,12 +38,6 @@ export interface FacilitatorSession { transportFailed(): boolean; } -export interface SupportedKind { - readonly x402Version: number; - readonly scheme: string; - readonly network: string; -} - interface BindingBase { /** Human-readable, never a credential */ readonly describe: string; @@ -59,77 +48,21 @@ export type FacilitatorBinding = | (BindingBase & { readonly kind: 'local' }) | (BindingBase & { readonly kind: 'remote'; - /** What the facilitator says it can settle. Used by health(), not by the payment path */ - supported(): Promise; + /** + * SDK-parsed `/supported` response, including kinds and their `extra` + * (such as Solana's fee payer), extensions and signers. health() reads + * it, and so does a Solana challenge, which names the fee payer + */ + supported(): Promise; }); -// Bounds each receipt wait of the in-process facilitator, and each request to a -// remote one -const SETTLEMENT_CONFIRMATION_TIMEOUT_MS = 60_000; - +/** Bounds each receipt wait of the in-process facilitator, and each verify or settle request to a remote one */ +export const SETTLEMENT_CONFIRMATION_TIMEOUT_MS = 60_000; /** - * An in-process `x402Facilitator` with the `exact`/EVM scheme registered for - * one network rather than an `eip155:*` wildcard, so a payload naming another - * chain finds no scheme at all + * Bounds a remote `/supported` read, retries included. Readiness and every + * Solana challenge wait on it, and the SDK retries a 429 for up to 30 s a time. */ -export function createLocalFacilitatorBinding( - client: LocalFacilitatorClient, - network: string, - isTransportError: (err: unknown) => boolean, -): FacilitatorBinding { - const build = (): { facilitator: x402Facilitator; failed: () => boolean } => { - let failed = false; - const watch = (promise: Promise): Promise => - promise.catch((err: unknown) => { - if (isTransportError(err)) failed = true; - throw err; - }); - - // Exactly the `FacilitatorEvmSigner` surface, so every call the SDK makes is - // watched. `toFacilitatorEvmSigner` wants a flat `address`, which viem - // keeps on `client.account`. - const signer = { - address: client.account.address, - readContract: (args: Parameters[0]) => - watch(client.readContract(args)), - verifyTypedData: (args: Parameters[0]) => - watch(client.verifyTypedData(args)), - writeContract: (args: Parameters[0]) => - watch(client.writeContract(args)), - sendTransaction: (args: Parameters[0]) => - watch(client.sendTransaction(args)), - waitForTransactionReceipt: ( - args: Parameters[0], - ) => watch(client.waitForTransactionReceipt(args)), - getCode: (args: Parameters[0]) => - watch(client.getCode(args)), - } as unknown as Parameters[0]; - - // `confirmationTimeoutMs` turns a lost receipt wait into `settlement_pending` - // with the broadcast hash, instead of a discarded broadcast - const facilitator = registerExactEvmScheme(new x402Facilitator(), { - signer: toFacilitatorEvmSigner(signer, { - confirmationTimeoutMs: SETTLEMENT_CONFIRMATION_TIMEOUT_MS, - }), - networks: network as Network, - }); - - return { facilitator, failed: () => failed }; - }; - - return { - kind: 'local', - describe: 'local (in-process)', - open(): FacilitatorSession { - const { facilitator, failed } = build(); - return { - verify: (payload, requirements) => facilitator.verify(payload, requirements), - settle: (payload, requirements) => facilitator.settle(payload, requirements), - transportFailed: failed, - }; - }, - }; -} +export const SUPPORTED_TIMEOUT_MS = 10_000; export interface RemoteFacilitatorOptions { readonly url: string; @@ -249,6 +182,11 @@ export function createRemoteFacilitatorBinding( timeoutMs: SETTLEMENT_CONFIRMATION_TIMEOUT_MS, ...(createAuthHeaders ? { createAuthHeaders } : {}), }); + const supportedClient = new HTTPFacilitatorClient({ + url: options.url, + timeoutMs: SUPPORTED_TIMEOUT_MS, + ...(createAuthHeaders ? { createAuthHeaders } : {}), + }); return { kind: 'remote', @@ -278,9 +216,26 @@ export function createRemoteFacilitatorBinding( transportFailed: () => failed, }; }, - async supported(): Promise { - const response = await client.getSupported(); - return response.kinds as readonly SupportedKind[]; - }, + supported: () => withDeadline(supportedClient.getSupported(), SUPPORTED_TIMEOUT_MS), }; } + +// The SDK bounds each attempt but not its retry waits. `Promise.race` keeps +// a handler on the abandoned call, so its later rejection is not unhandled. +function withDeadline(call: Promise, ms: number): Promise { + let timer: NodeJS.Timeout | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + `x402 provider: facilitator /supported did not answer within ${ms / 1000} s`, + ), + ), + ms, + ); + timer.unref(); + }); + return Promise.race([call, deadline]).finally(() => clearTimeout(timer)); +} diff --git a/src/payments/x402/guardrails.ts b/src/payments/x402/guardrails.ts index 4d82df5..21d9a3e 100644 --- a/src/payments/x402/guardrails.ts +++ b/src/payments/x402/guardrails.ts @@ -11,12 +11,15 @@ * Imports no peer (no viem, no `@x402/*`), so the CLI can load it. */ import { CommerceError } from '../../core'; +import { isBurnAddress, sameAddress } from './addresses'; import { isLikelyLocalOrPrivateHost, isWellKnownDevAddress } from './dev-key-guard'; import { + type CustomNetworks, type DeploymentMode, - type NetworkProfile, - requireNetworkProfile, + LOCAL_CHAIN_ID, + requireX402Network, resolveDeploymentMode, + type X402Network, } from './networks'; /** @@ -59,13 +62,17 @@ export interface X402DeploymentInput { readonly configPath?: string; /** Settlement-recipient field named in errors, `payTo` by default */ readonly payToField?: string; + /** EVM networks declared under `payments.x402.networks` */ + readonly customNetworks?: CustomNetworks; } export interface X402Deployment { - readonly profile: NetworkProfile; + readonly profile: X402Network; readonly mode: DeploymentMode; } +const BASE_SEPOLIA = 'eip155:84532'; + function invalid(message: string, path: string): CommerceError { return new CommerceError('CONFIG_INVALID', message, { details: { path } }); } @@ -78,8 +85,8 @@ function invalid(message: string, path: string): CommerceError { export function resolveX402Deployment(input: X402DeploymentInput): X402Deployment { const at = input.configPath ?? 'payments.x402'; const payTo = input.payToField ?? 'payTo'; - const profile = requireNetworkProfile(input.network, `${at}.network`); - const mode = resolveDeploymentMode(profile, input.facilitator.mode); + const profile = requireX402Network(input.network, `${at}.network`, input.customNetworks); + const mode = resolveDeploymentMode(profile); // Local mode keeps a gas-paying key in the gateway process. A compromise // could expose that key, so mainnet requires a remote facilitator. @@ -89,6 +96,16 @@ export function resolveX402Deployment(input: X402DeploymentInput): X402Deploymen `${at}.facilitator.mode`, ); } + // The in-process facilitator signs with a client built for the Anvil chain + // id, so on any other chain every settlement would fail + if (input.facilitator.mode === 'local' && mode !== 'local') { + throw invalid( + profile.id === BASE_SEPOLIA + ? `${at}: the local Anvil chain is eip155:${LOCAL_CHAIN_ID}, Anvil's default chain id; "${BASE_SEPOLIA}" is public Base Sepolia. Set ${at}.network to eip155:${LOCAL_CHAIN_ID}, start Anvil with its default chain id and redeploy the local contracts (npm run chain:start, npm run chain:deploy).` + : `${at}: facilitator.mode "local" serves the local Anvil chain (eip155:${LOCAL_CHAIN_ID}) only, not "${profile.id}" (${profile.displayName}). Use a remote facilitator.`, + `${at}.facilitator.mode`, + ); + } if (mode === 'mainnet' && input.allowMainnet !== true) { throw invalid( @@ -99,6 +116,14 @@ export function resolveX402Deployment(input: X402DeploymentInput): X402Deploymen if (input.facilitator.mode === 'remote') { assertFacilitatorUrlIsSafe(input.facilitator.url, mode, at); + // `@coinbase/x402` signs each request for CDP's own host and route, + // whatever URL is configured, so no other facilitator can accept the credential + if (input.facilitator.auth.type === 'cdp' && !isCdpFacilitatorUrl(input.facilitator.url)) { + throw invalid( + `${at}: facilitator.auth.type "cdp" signs requests for ${CDP_FACILITATOR_URL} only, so facilitator ${describeOrigin(input.facilitator.url)} cannot accept its credential. Set facilitator.url to ${CDP_FACILITATOR_URL}, or use auth.type "bearer" or "none".`, + `${at}.facilitator.url`, + ); + } // The authorization fixes recipient, amount and chain, so a facilitator // cannot redirect funds. A credential adds an account: rate limits, terms // and support. On mainnet, going without one is an explicit choice. @@ -127,7 +152,7 @@ export function resolveX402Deployment(input: X402DeploymentInput): X402Deploymen // The config loader's address check refuses this too; repeated here for a // library consumer who calls `createX402PaymentProvider` without it - if (/^0x0{40}$/i.test(input.payTo)) { + if (isBurnAddress(profile, input.payTo)) { throw invalid( `${at}: "${payTo}" is the zero address. Every payment settled there is destroyed.`, `${at}.${payTo}`, @@ -144,27 +169,29 @@ export function resolveX402Deployment(input: X402DeploymentInput): X402Deploymen ); } - // Mainnet only: a testnet is where pointing at a mock token is normal + // A public built-in network settles only in its USDC. The local chain and + // declared networks have no default asset and name their token. const canonical = profile.canonicalAsset; - if (mode === 'mainnet' && canonical) { - if (!sameAddress(input.asset, canonical.address)) { + if (canonical) { + if (!sameAddress(profile, input.asset, canonical.address)) { throw invalid( - `${at}: asset ${input.asset} is not ${canonical.symbol} on ${profile.displayName} (expected ${canonical.address}). Settling a mainnet payment in an unintended token is refused.`, + `${at}: asset ${input.asset} is not ${canonical.symbol} on ${profile.displayName} (expected ${canonical.address}). A built-in network settles only in its ${canonical.symbol}.`, `${at}.asset`, ); } // The buyer signs the EIP-712 domain and the scheme checks it. A wrong one // refuses every payment with `invalid_exact_evm_token_name_mismatch` after // the buyer signed, so it is caught here, before the gateway starts. - if (input.assetName !== undefined && input.assetName !== canonical.name) { + const domain = 'name' in canonical ? canonical : undefined; + if (domain && input.assetName !== undefined && input.assetName !== domain.name) { throw invalid( - `${at}: assetName "${input.assetName}" is not the EIP-712 domain name ${canonical.symbol} reports on ${profile.displayName} (expected "${canonical.name}"). Every payment would be refused after the buyer signed.`, + `${at}: assetName "${input.assetName}" is not the EIP-712 domain name ${canonical.symbol} reports on ${profile.displayName} (expected "${domain.name}"). Every payment would be refused after the buyer signed.`, `${at}.assetName`, ); } - if (input.assetVersion !== undefined && input.assetVersion !== canonical.version) { + if (domain && input.assetVersion !== undefined && input.assetVersion !== domain.version) { throw invalid( - `${at}: assetVersion "${input.assetVersion}" is not the EIP-712 domain version ${canonical.symbol} reports on ${profile.displayName} (expected "${canonical.version}").`, + `${at}: assetVersion "${input.assetVersion}" is not the EIP-712 domain version ${canonical.symbol} reports on ${profile.displayName} (expected "${domain.version}").`, `${at}.assetVersion`, ); } @@ -207,6 +234,24 @@ function assertFacilitatorUrlIsSafe(url: string, mode: DeploymentMode, at: strin ); } +/** The facilitator URL `@coinbase/x402` signs its JWTs for */ +export const CDP_FACILITATOR_URL = 'https://api.cdp.coinbase.com/platform/v2/x402'; + +// The SDK client strips trailing slashes before appending its route +function isCdpFacilitatorUrl(url: string): boolean { + try { + const parsed = new URL(url); + const expected = new URL(CDP_FACILITATOR_URL); + return ( + parsed.origin === expected.origin && + parsed.pathname.replace(/\/+$/, '') === expected.pathname && + parsed.search === '' + ); + } catch { + return false; + } +} + // Origin only: a facilitator URL can carry a tenant path or an API key function describeOrigin(url: string): string { try { @@ -230,7 +275,3 @@ function credentialFields(auth: FacilitatorAuth): readonly (readonly [string, st return []; } } - -function sameAddress(a: string, b: string): boolean { - return a.toLowerCase() === b.toLowerCase(); -} diff --git a/src/payments/x402/index.ts b/src/payments/x402/index.ts index 872b415..380bc9a 100644 --- a/src/payments/x402/index.ts +++ b/src/payments/x402/index.ts @@ -10,5 +10,11 @@ export { type NetworkProfile, SUPPORTED_NETWORK_IDS, } from './networks'; +export type { X402AssetTransferMethod } from './options'; export type { X402PaymentFlow } from './payment-flow'; -export { createX402PaymentProvider, type X402ProviderOptions } from './provider'; +export { + createX402PaymentProvider, + type X402MultiOfferProviderOptions, + type X402OfferOptions, + type X402ProviderOptions, +} from './provider'; diff --git a/src/payments/x402/local-chain/anvil.ts b/src/payments/x402/local-chain/anvil.ts index 4890107..216e6f7 100644 --- a/src/payments/x402/local-chain/anvil.ts +++ b/src/payments/x402/local-chain/anvil.ts @@ -1,7 +1,7 @@ /** - * Starts the local Anvil chain for the demo and the payment test suites. The - * chain id defaults to 84532 because the gateway advertises `eip155:84532` and - * the buyer's signature is bound to that id. + * Starts the local Anvil chain for the demo and the payment test suites, on + * `LOCAL_CHAIN_ID` (31337, Anvil's own default). The gateway advertises + * `eip155:31337` and the buyer's signature is bound to that id. * * `--block-time` is omitted on purpose: Anvil then mines a block for each * transaction and none in between, and some Anvil builds reject diff --git a/src/payments/x402/local-chain/deploy-engine.ts b/src/payments/x402/local-chain/deploy-engine.ts index d7d5e67..fc843c6 100644 --- a/src/payments/x402/local-chain/deploy-engine.ts +++ b/src/payments/x402/local-chain/deploy-engine.ts @@ -30,6 +30,11 @@ export interface DeployLocalChainOptions { readonly rpcUrl: string; /** Canonical decimal display amount, e.g. "100.00" */ readonly buyerInitialBalance: string; + /** + * Expected RPC chain ID; defaults to the local chain. Tests on another + * Anvil chain override it. + */ + readonly chainId?: number; readonly log?: (message: string) => void; } @@ -55,18 +60,20 @@ export async function deployLocalChain( const log = options.log ?? (() => {}); const publicClient = createLocalPublicClient(options.rpcUrl); + const expected = options.chainId ?? LOCAL_CHAIN_ID; const chainId = await publicClient.getChainId(); - if (chainId !== LOCAL_CHAIN_ID) { + if (chainId !== expected) { throw new CommerceError( 'CONFIG_INVALID', - `Local chain at ${options.rpcUrl} reports chain id ${chainId}, expected ${LOCAL_CHAIN_ID}. ` + - 'Start it with "npm run chain:start" (anvil --chain-id 84532 ...).', + `Local chain at ${options.rpcUrl} reports chain id ${chainId}, expected ${expected}. ` + + `Start it with "npm run chain:start" (anvil --chain-id ${expected} ...).`, ); } const facilitatorClient = createLocalFacilitatorClient( options.rpcUrl, LOCAL_FACILITATOR_ACCOUNT.privateKey, + expected, ); let asset = existingAsset; diff --git a/src/payments/x402/local-facilitator.ts b/src/payments/x402/local-facilitator.ts new file mode 100644 index 0000000..af66274 --- /dev/null +++ b/src/payments/x402/local-facilitator.ts @@ -0,0 +1,76 @@ +/** + * In-process `x402Facilitator` using viem through `@x402/evm`. + * EVM imports stay here so the remote binding can load without them. + */ +import { x402Facilitator } from '@x402/core/facilitator'; +import type { Network } from '@x402/core/types'; +import { toFacilitatorEvmSigner } from '@x402/evm'; +import { registerExactEvmScheme } from '@x402/evm/exact/facilitator'; +import type { LocalFacilitatorClient } from './chain'; +import { + type FacilitatorBinding, + type FacilitatorSession, + SETTLEMENT_CONFIRMATION_TIMEOUT_MS, +} from './facilitator'; + +/** + * Registers `exact`/EVM for one network, so a payload naming another chain + * finds no matching scheme + */ +export function createLocalFacilitatorBinding( + client: LocalFacilitatorClient, + network: string, + isTransportError: (err: unknown) => boolean, +): FacilitatorBinding { + const build = (): { facilitator: x402Facilitator; failed: () => boolean } => { + let failed = false; + const watch = (promise: Promise): Promise => + promise.catch((err: unknown) => { + if (isTransportError(err)) failed = true; + throw err; + }); + + // Watch the `FacilitatorEvmSigner` methods for transport failures and expose + // `client.account.address` as the flat `address` the SDK expects + const signer = { + address: client.account.address, + readContract: (args: Parameters[0]) => + watch(client.readContract(args)), + verifyTypedData: (args: Parameters[0]) => + watch(client.verifyTypedData(args)), + writeContract: (args: Parameters[0]) => + watch(client.writeContract(args)), + sendTransaction: (args: Parameters[0]) => + watch(client.sendTransaction(args)), + waitForTransactionReceipt: ( + args: Parameters[0], + ) => watch(client.waitForTransactionReceipt(args)), + getCode: (args: Parameters[0]) => + watch(client.getCode(args)), + } as unknown as Parameters[0]; + + // Bound receipt waits so a timeout returns `settlement_pending` with the + // broadcast hash + const facilitator = registerExactEvmScheme(new x402Facilitator(), { + signer: toFacilitatorEvmSigner(signer, { + confirmationTimeoutMs: SETTLEMENT_CONFIRMATION_TIMEOUT_MS, + }), + networks: network as Network, + }); + + return { facilitator, failed: () => failed }; + }; + + return { + kind: 'local', + describe: 'local (in-process)', + open(): FacilitatorSession { + const { facilitator, failed } = build(); + return { + verify: (payload, requirements) => facilitator.verify(payload, requirements), + settle: (payload, requirements) => facilitator.settle(payload, requirements), + transportFailed: failed, + }; + }, + }; +} diff --git a/src/payments/x402/near.ts b/src/payments/x402/near.ts new file mode 100644 index 0000000..47ad50f --- /dev/null +++ b/src/payments/x402/near.ts @@ -0,0 +1,89 @@ +/** + * NEAR `exact` payload inspection: bounded decoding of the NEP-366 signed + * delegate action, the transfer it authorizes and its replay identity. The + * provider loads this module only for a NEAR offer, so other deployments never + * import `@x402/near`. + * + * The facilitator checks the signature, the access key and its nonce, the + * delegate window and chain state. The gateway checks the transfer the buyer + * signed against the offer, so a proof for other terms never reaches it. + */ +import { decodeSignedDelegateB64, parseFtTransferArgs } from '@x402/near'; +import { isRecord } from '../../core/is-record'; +import { computeNearReplayKey } from './replay-key'; + +// One `ft_transfer` delegate is a few hundred bytes; this bounds the decoder +const MAX_SIGNED_DELEGATE_BASE64 = 4096; +// NEP-141 requires exactly one yoctoNEAR on `ft_transfer` +const ONE_YOCTO = 1n; + +export interface NearTransferTerms { + readonly network: string; + /** The NEP-141 token contract the delegate must call */ + readonly asset: string; + readonly payTo: string; + /** Atomic units, as the offer states them */ + readonly amount: string; +} + +/** + * Returns the payer and replay key if the NEAR `exact` payload matches the + * offer, or a refusal reason using the SDK facilitator's codes. + */ +export function inspectNearPayload( + payload: unknown, + terms: NearTransferTerms, +): { readonly payer: string; readonly replayKey: string } | string { + const encoded = isRecord(payload) ? payload['signedDelegateAction'] : undefined; + if (typeof encoded !== 'string' || encoded.length > MAX_SIGNED_DELEGATE_BASE64) { + return 'invalid_exact_near_payload_shape'; + } + let delegate: ReturnType['delegate']; + try { + delegate = decodeSignedDelegateB64(encoded).delegate; + } catch { + return 'invalid_exact_near_payload_signed_delegate_action'; + } + if (!isCanonical(encoded)) return 'invalid_exact_near_payload_signed_delegate_action'; + if (delegate.actionCount !== 1) return 'invalid_exact_near_payload_action_count'; + const call = delegate.functionCall; + if (!call) return 'invalid_exact_near_payload_action_kind'; + if (call.methodName !== 'ft_transfer') return 'invalid_exact_near_payload_method_name'; + if (delegate.receiverId !== terms.asset) { + return 'invalid_exact_near_payload_token_contract_mismatch'; + } + let args: ReturnType; + try { + args = parseFtTransferArgs(call.args); + } catch { + return 'invalid_exact_near_payload_ft_transfer_args'; + } + if (args.receiver_id !== terms.payTo) return 'invalid_exact_near_payload_recipient_mismatch'; + if (args.amount !== terms.amount) return 'invalid_exact_near_payload_amount_mismatch'; + if (call.deposit !== ONE_YOCTO) return 'invalid_exact_near_payload_attached_deposit'; + return { + payer: delegate.senderId, + replayKey: computeNearReplayKey({ + network: terms.network, + senderId: delegate.senderId, + publicKey: delegate.publicKey, + nonce: delegate.nonce, + }), + }; +} + +/** + * One delegate, one encoding: canonical base64, and no bytes after the signed + * delegate. The SDK decoder ignores such bytes, so the check decodes the + * payload without its last byte: a canonical encoding then runs short. + */ +function isCanonical(encoded: string): boolean { + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.toString('base64') !== encoded) return false; + try { + decodeSignedDelegateB64(bytes.subarray(0, bytes.length - 1).toString('base64')); + return false; + } catch { + return true; + } +} diff --git a/src/payments/x402/networks.ts b/src/payments/x402/networks.ts index b6a4dfb..05fa3ef 100644 --- a/src/payments/x402/networks.ts +++ b/src/payments/x402/networks.ts @@ -1,106 +1,274 @@ /** - * The networks this gateway settles on, as data: the chain id to sign against, - * a display name, and whether real money is involved. + * Network profiles: family, display name, environment, EVM chain ID and + * default stablecoin. Config load rejects unknown IDs; parsing an EVM chain + * ID cannot establish whether it is mainnet. Merchants must declare custom + * EVM networks under `payments.x402.networks`; built-ins cannot be redeclared. * - * A registry rather than "parse any `eip155:*`": the chain id parses from any - * such string, but whether it is a mainnet does not, and the guardrails key off - * that. An unknown id is refused at startup instead of passing as a testnet. + * Profiles copy SDK asset defaults to keep config load and the CLI SDK-free. + * Tests compare each profile with the pinned SDK tables. * - * The local dev chain runs under Base Sepolia's `eip155:84532`, so nothing may - * infer "public network" from the id alone. `resolveDeploymentMode` derives - * the mode from the facilitator too, and the provider's health check confirms - * that a local facilitator's node is Anvil. + * The local Anvil chain has a profile of its own, `eip155:31337` (Anvil's + * default chain id), so the network alone tells a local deployment from a + * public one. Local health checks still require an Anvil response, because an + * RPC endpoint can report any chain id. */ import { CommerceError } from '../../core'; -/** Chain id of the local dev chain, which Base Sepolia uses too */ -export const LOCAL_CHAIN_ID = 84532; +/** Chain id of the local Anvil dev chain, Anvil's default */ +export const LOCAL_CHAIN_ID = 31337; +export type X402NetworkFamily = 'evm' | 'svm' | 'near'; + +export type NetworkKind = 'testnet' | 'mainnet'; + +/** An EVM network the x402 provider can settle on */ export interface NetworkProfile { /** CAIP-2 identifier, e.g. `eip155:84532` */ readonly id: string; /** Signed into the buyer's EIP-712 domain */ readonly chainId: number; readonly displayName: string; - readonly kind: 'testnet' | 'mainnet'; + readonly kind: NetworkKind; + /** The local Anvil dev chain, where the in-process facilitator runs */ + readonly local?: boolean; /** - * The USDC deployment this network settles in, with the EIP-712 domain the - * token reports. Enforced on mainnet only, since a testnet may use a mock - * token. + * Default USDC deployment and EIP-712 domain. A public network's offers + * settle only in it; the local chain has none and names its mock token. * - * `name` is the signed EIP-712 domain name, not the symbol, and the two - * deployments differ: `"USDC"` on Base Sepolia, `"USD Coin"` on Base. The - * values were read back from the contracts. + * `name` is the signed EIP-712 domain name, not the symbol, and deployments + * differ: `"USDC"` on Base Sepolia, `"USD Coin"` on Base. */ readonly canonicalAsset?: { readonly symbol: string; readonly address: string; readonly name: string; readonly version: string; + readonly decimals: number; + }; +} + +interface NonEvmNetwork { + readonly family: F; + readonly id: string; + readonly displayName: string; + readonly kind: NetworkKind; + /** Solana mint or NEAR NEP-141 contract account, with no EIP-712 domain */ + readonly canonicalAsset: { + readonly symbol: string; + readonly address: string; + readonly decimals: number; }; } -const PROFILES: readonly NetworkProfile[] = [ +export type X402Network = + | (NetworkProfile & { readonly family: 'evm' }) + | NonEvmNetwork<'svm'> + | NonEvmNetwork<'near'>; + +/** A merchant-declared EVM network, from `payments.x402.networks` */ +export interface CustomNetworkDeclaration { + readonly kind: NetworkKind; + readonly displayName: string; +} + +export type CustomNetworks = Readonly>; + +const usdc = (address: string, name: string) => + ({ symbol: 'USDC', address, name, version: '2', decimals: 6 }) as const; + +const PROFILES: readonly X402Network[] = [ + { + family: 'evm', + id: `eip155:${LOCAL_CHAIN_ID}`, + chainId: LOCAL_CHAIN_ID, + displayName: 'Local Anvil', + kind: 'testnet', + local: true, + }, { + family: 'evm', id: 'eip155:84532', chainId: 84532, displayName: 'Base Sepolia', kind: 'testnet', - canonicalAsset: { - symbol: 'USDC', - address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', - name: 'USDC', - version: '2', - }, + canonicalAsset: usdc('0x036CbD53842c5426634e7929541eC2318f3dCF7e', 'USDC'), }, { + family: 'evm', id: 'eip155:8453', chainId: 8453, displayName: 'Base', kind: 'mainnet', + canonicalAsset: usdc('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', 'USD Coin'), + }, + { + family: 'evm', + id: 'eip155:1', + chainId: 1, + displayName: 'Ethereum', + kind: 'mainnet', + canonicalAsset: usdc('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', 'USD Coin'), + }, + { + family: 'evm', + id: 'eip155:137', + chainId: 137, + displayName: 'Polygon', + kind: 'mainnet', + canonicalAsset: usdc('0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', 'USD Coin'), + }, + { + family: 'evm', + id: 'eip155:42161', + chainId: 42161, + displayName: 'Arbitrum One', + kind: 'mainnet', + canonicalAsset: usdc('0xaf88d065e77c8cC2239327C5EDb3A432268e5831', 'USD Coin'), + }, + { + family: 'evm', + id: 'eip155:421614', + chainId: 421614, + displayName: 'Arbitrum Sepolia', + kind: 'testnet', + canonicalAsset: usdc('0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', 'USD Coin'), + }, + { + family: 'evm', + id: 'eip155:43114', + chainId: 43114, + displayName: 'Avalanche C-Chain', + kind: 'mainnet', + canonicalAsset: usdc('0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E', 'USD Coin'), + }, + { + family: 'svm', + id: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + displayName: 'Solana', + kind: 'mainnet', + canonicalAsset: { + symbol: 'USDC', + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + decimals: 6, + }, + }, + { + family: 'svm', + id: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + displayName: 'Solana Devnet', + kind: 'testnet', + canonicalAsset: { + symbol: 'USDC', + address: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + decimals: 6, + }, + }, + // NEAR's USDC contracts are implicit accounts: 64 lowercase hex characters + { + family: 'near', + id: 'near:mainnet', + displayName: 'NEAR', + kind: 'mainnet', + canonicalAsset: { + symbol: 'USDC', + address: '17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1', + decimals: 6, + }, + }, + { + family: 'near', + id: 'near:testnet', + displayName: 'NEAR Testnet', + kind: 'testnet', canonicalAsset: { symbol: 'USDC', - address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', - name: 'USD Coin', - version: '2', + address: '3e2210e1184b45b64c8a434c0a7e7b23cc04ea7eb7a6c3c32520d03d4afcb8af', + decimals: 6, }, }, ]; const BY_ID = new Map(PROFILES.map((profile) => [profile.id, profile])); -export const SUPPORTED_NETWORK_IDS: readonly string[] = PROFILES.map((p) => p.id); +/** Built-in networks of every family, for tests and diagnostics */ +export const BUILT_IN_NETWORKS: readonly X402Network[] = PROFILES; -export function findNetworkProfile(id: string): NetworkProfile | undefined { - return BY_ID.get(id); +/** The built-in EVM networks the x402 provider settles on without a declaration */ +export const SUPPORTED_NETWORK_IDS: readonly string[] = PROFILES.filter( + (p) => p.family === 'evm', +).map((p) => p.id); + +// A chain id viem and EIP-712 can carry as a JS number +const EVM_NETWORK_ID = /^eip155:([1-9]\d{0,15})$/; + +function invalid(message: string, path: string): CommerceError { + return new CommerceError('CONFIG_INVALID', message, { details: { path } }); } -export function requireNetworkProfile(id: string, path: string): NetworkProfile { +/** Built-in EVM profile for callers using the original network interface */ +export function findNetworkProfile(id: string): NetworkProfile | undefined { const profile = BY_ID.get(id); - if (!profile) { + return profile?.family === 'evm' ? profile : undefined; +} + +/** A built-in network of any family, or a declared custom EVM network */ +export function findX402Network(id: string, custom?: CustomNetworks): X402Network | undefined { + const builtIn = BY_ID.get(id); + if (builtIn) return builtIn; + const declared = custom?.[id]; + const chainId = Number(EVM_NETWORK_ID.exec(id)?.[1]); + if (!declared || !Number.isSafeInteger(chainId)) return undefined; + return { + family: 'evm', + id, + chainId, + displayName: declared.displayName, + kind: declared.kind, + }; +} + +export function requireX402Network(id: string, path: string, custom?: CustomNetworks): X402Network { + const network = findX402Network(id, custom); + if (!network) { throw new CommerceError( 'CONFIG_INVALID', - `${path}: "${id}" is not a supported network. Supported CAIP-2 identifiers: ${SUPPORTED_NETWORK_IDS.join(', ')}`, + `${path}: "${id}" is not a known network. Built-in CAIP-2 identifiers: ${PROFILES.map((p) => p.id).join(', ')}. Declare another EVM network under payments.x402.networks with its kind and displayName.`, { details: { path, network: id } }, ); } - return profile; + return network; } /** - * What a deployment is, as opposed to the network it names. `local` comes from - * the facilitator: the in-process facilitator makes a deployment local whatever - * CAIP-2 id it is configured with, which lets diagnostics show `eip155:84532` - * without claiming public Base Sepolia. + * Allows only new `eip155` networks in `payments.x402.networks`. Redeclaring + * built-ins could relabel mainnet as testnet; Solana and NEAR IDs are SDK-fixed. */ +export function validateCustomNetworks(custom: CustomNetworks, path: string): void { + for (const id of Object.keys(custom)) { + const at = `${path}.${id}`; + if (BY_ID.has(id)) { + throw invalid( + `${at}: "${id}" is a built-in network and cannot be redeclared; remove this entry`, + at, + ); + } + if (!findX402Network(id, custom)) { + throw invalid( + `${at}: "${id}" is not an EVM CAIP-2 identifier ("eip155:" followed by a positive chain id below 2^53). Only EVM networks can be declared.`, + at, + ); + } + } +} + +/** What a deployment is: the local dev chain, or a public testnet or mainnet */ export type DeploymentMode = 'local' | 'testnet' | 'mainnet'; -export function resolveDeploymentMode( - profile: NetworkProfile, - facilitatorMode: 'local' | 'remote', -): DeploymentMode { - if (facilitatorMode === 'local') return 'local'; - return profile.kind; +export function resolveDeploymentMode(profile: { + readonly kind: NetworkKind; + readonly local?: boolean; +}): DeploymentMode { + return profile.local ? 'local' : profile.kind; } // The banner an operator must not miss. Printed for mainnet only diff --git a/src/payments/x402/options.ts b/src/payments/x402/options.ts new file mode 100644 index 0000000..c2cb13d --- /dev/null +++ b/src/payments/x402/options.ts @@ -0,0 +1,196 @@ +/** + * Resolves an x402 option into offer terms at config load, so `validate` + * reports invalid options before startup. This module uses no SDK. + * + * Omitted assets use `canonicalAsset`. Overrides for the default token must + * match its metadata; another token needs its own, including the EIP-712 domain + * on EVM. + */ +import { CommerceError } from '../../core'; +import { isBurnAddress, normalizeAddress, sameAddress } from './addresses'; +import { type CustomNetworks, requireX402Network, type X402Network } from './networks'; + +export interface X402OptionInput { + readonly scheme?: string; + readonly network: string; + readonly payTo: string; + readonly asset?: string; + readonly assetName?: string; + readonly assetVersion?: string; + readonly assetDecimals?: number; + /** `eip3009` (the default) or `permit2`; EVM only */ + readonly assetTransferMethod?: string; +} + +export interface ResolvedX402Option { + readonly scheme: 'exact'; + readonly network: X402Network; + readonly payTo: string; + readonly asset: string; + /** EIP-712 domain; EVM only */ + readonly assetName?: string; + readonly assetVersion?: string; + readonly assetDecimals: number; + /** + * Default token symbol when `asset` is omitted; resource prices must use + * this symbol or USD + */ + readonly defaultAssetSymbol?: string; + /** How the buyer authorizes the transfer; always `eip3009` off EVM */ + readonly assetTransferMethod: X402AssetTransferMethod; +} + +/** + * Names health components by network, asset and payee for discovery matching. + * Lowercase EVM addresses so case variants identify the same offer. + */ +export function x402OfferName(offer: { + readonly network: string; + readonly asset: string; + readonly payTo: string; +}): string { + const evm = offer.network.startsWith('eip155:'); + const address = (value: string) => (evm ? value.toLowerCase() : value); + return `${offer.network} ${address(offer.asset)} ${address(offer.payTo)}`; +} + +/** EIP-3009 `transferWithAuthorization`, or Permit2 with a prior token allowance */ +export type X402AssetTransferMethod = 'eip3009' | 'permit2'; + +// Incorrect EIP-712 domain values invalidate the buyer's signature +const FIELD_MEANINGS = { + assetName: 'EIP-712 domain name', + assetVersion: 'EIP-712 domain version', + assetDecimals: 'decimals', +} as const; + +const ADDRESS_SHAPES = { + evm: '"0x" followed by 40 hex characters', + svm: 'a base58 public key of 32 bytes', + near: 'a NEAR account ID: 2 to 64 lowercase characters, digits and single "-", "_" or "." separators', +} as const; + +function invalid(message: string, path: string): CommerceError { + return new CommerceError('CONFIG_INVALID', message, { details: { path } }); +} + +export function resolveX402Option( + input: X402OptionInput, + path: string, + custom?: CustomNetworks, +): ResolvedX402Option { + if (input.scheme !== undefined && input.scheme !== 'exact') { + throw invalid( + `${path}.scheme: "${input.scheme}" is not supported; the only x402 scheme is "exact"`, + `${path}.scheme`, + ); + } + const network = requireX402Network(input.network, `${path}.network`, custom); + const address = (field: 'payTo' | 'asset', value: string): string => { + const wire = normalizeAddress(network, value); + if (wire === undefined) { + throw invalid( + // The value is not echoed: a swapped variable could put a key here + `${path}.${field} is not a plausible address on ${network.displayName} (expected ${ADDRESS_SHAPES[network.family]})`, + `${path}.${field}`, + ); + } + return wire; + }; + + const payTo = address('payTo', input.payTo); + if (isBurnAddress(network, payTo)) { + throw invalid( + `${path}.payTo is the zero address of ${network.displayName}. Every payment settled there is destroyed.`, + `${path}.payTo`, + ); + } + + const method = input.assetTransferMethod ?? 'eip3009'; + if (method !== 'eip3009' && method !== 'permit2') { + throw invalid( + `${path}.assetTransferMethod: "${method}" is not supported; use "eip3009" or "permit2"`, + `${path}.assetTransferMethod`, + ); + } + if (network.family !== 'evm' && input.assetTransferMethod !== undefined) { + throw invalid( + `${path}.assetTransferMethod applies to EVM networks only, not to ${network.displayName}`, + `${path}.assetTransferMethod`, + ); + } + + if (network.family !== 'evm') { + for (const field of ['assetName', 'assetVersion'] as const) { + if (input[field] !== undefined) { + throw invalid( + `${path}.${field}: an EIP-712 domain applies to EVM tokens only, not to ${network.displayName}`, + `${path}.${field}`, + ); + } + } + } + + const canonical = network.canonicalAsset; + const domain = canonical && 'name' in canonical ? canonical : undefined; + + const asset = input.asset !== undefined ? address('asset', input.asset) : undefined; + if (asset !== undefined && isBurnAddress(network, asset)) { + throw invalid(`${path}.asset must not be the zero address`, `${path}.asset`); + } + + // The default asset applies when the option names none or names it exactly + if (canonical && (asset === undefined || sameAddress(network, asset, canonical.address))) { + const expected = { + assetName: domain?.name, + assetVersion: domain?.version, + assetDecimals: canonical.decimals, + }; + for (const field of ['assetName', 'assetVersion', 'assetDecimals'] as const) { + const given = input[field]; + if (given !== undefined && given !== expected[field]) { + throw invalid( + `${path}.${field} does not match ${canonical.symbol} on ${network.displayName}, which reports ${FIELD_MEANINGS[field]} ${JSON.stringify(expected[field])}. Remove ${field}, or set asset to the token it describes.`, + `${path}.${field}`, + ); + } + } + return { + scheme: 'exact', + network, + payTo, + asset: asset ?? canonical.address, + ...(domain ? { assetName: domain.name, assetVersion: domain.version } : {}), + assetDecimals: canonical.decimals, + defaultAssetSymbol: canonical.symbol, + assetTransferMethod: method, + }; + } + + // Another token: nothing about it can be borrowed from the default. The + // token's EIP-712 domain matters to EIP-3009 only: a Permit2 buyer signs + // Permit2's own domain. + const required = + network.family === 'evm' && method === 'eip3009' + ? (['asset', 'assetName', 'assetVersion', 'assetDecimals'] as const) + : (['asset', 'assetDecimals'] as const); + const missing = required.filter((field) => input[field] === undefined); + if (asset === undefined || missing.length > 0) { + throw invalid( + canonical + ? `${path}: a token other than ${canonical.symbol} on ${network.displayName} needs ${missing.join(', ')}` + : `${path}: ${network.displayName} has no default asset, so the option needs ${missing.join(', ')}`, + `${path}.${missing[0] ?? 'asset'}`, + ); + } + return { + scheme: 'exact', + network, + payTo, + asset, + ...(input.assetName !== undefined ? { assetName: input.assetName } : {}), + ...(input.assetVersion !== undefined ? { assetVersion: input.assetVersion } : {}), + assetDecimals: input.assetDecimals as number, + assetTransferMethod: method, + }; +} diff --git a/src/payments/x402/payload.ts b/src/payments/x402/payload.ts index d7e3ce7..dc0ea0c 100644 --- a/src/payments/x402/payload.ts +++ b/src/payments/x402/payload.ts @@ -8,7 +8,9 @@ */ import { PaymentPayloadV2Schema } from '@x402/core/schemas'; import type { PaymentPayload } from '@x402/core/types'; -import { type ExactEIP3009Payload, isEIP3009Payload } from '@x402/evm'; +import type { ExactEIP3009Payload, ExactPermit2Payload } from '@x402/evm'; +import { isRecord } from '../../core/is-record'; +import { isEvmAddress } from './addresses'; export function decodePaymentSubmission(payloadBase64: string): PaymentPayload | undefined { const json = decodeJson(payloadBase64); @@ -29,10 +31,14 @@ export function declaredX402Version(payloadBase64: string): unknown { * The payload as sent to a facilitator. `resource` is optional in v2 and * verify() has already matched it against the offer; hosted facilitators have * settled payloads without it, and none has been shown to accept this - * gateway's `resource://` URL + * gateway's `resource://` URL. `extensions` is dropped because the gateway + * advertises none, and the SDK facilitator would act on a gas-sponsoring + * extension in a Permit2 payload. */ -export function withoutResource(payload: T): Omit { - const { resource: _resource, ...rest } = payload; +export function forFacilitator( + payload: T, +): Omit { + const { resource: _resource, extensions: _extensions, ...rest } = payload; return rest; } @@ -45,22 +51,75 @@ function decodeJson(payloadBase64: string): unknown { } /** - * Narrows a decoded payload to the EIP-3009 `exact`/EVM shape this provider - * settles. + * Narrows a decoded payload to the EIP-3009 `exact`/EVM shape. * - * The `exact` scheme on EVM also has a Permit2 method. The challenge names - * EIP-3009 (`extra.assetTransferMethod`), and a Permit2 payload is rejected - * here rather than handed to the SDK. On the local chain, which has no Permit2 - * proxy, it would fail as an opaque settlement revert. + * The SDK facilitator settles any payload carrying `permit2Authorization` + * through Permit2, so a payload with both shapes is refused: the gateway would + * check one authorization and the facilitator would settle the other. */ export function isExactEvmPayload( payload: PaymentPayload, ): payload is PaymentPayload & { payload: ExactEIP3009Payload } { + const inner = payload.payload; return ( payload.accepted.scheme === 'exact' && - typeof payload.payload === 'object' && - payload.payload !== null && - isEIP3009Payload(payload.payload as ExactEIP3009Payload) && - typeof (payload.payload as ExactEIP3009Payload).signature === 'string' + isRecord(inner) && + isRecord(inner['authorization']) && + !('permit2Authorization' in inner) && + typeof inner['signature'] === 'string' + ); +} + +// A uint256 in decimal, the form the SDK client writes +const UINT256 = /^(0|[1-9]\d{0,77})$/; +const UINT256_MAX = 2n ** 256n - 1n; +// An EIP-3009 nonce: 32 bytes in hex +const BYTES32 = /^0x[0-9a-fA-F]{64}$/; + +const isUint256 = (value: unknown): value is string => + typeof value === 'string' && UINT256.test(value) && BigInt(value) <= UINT256_MAX; + +/** + * Checks an EIP-3009 authorization's fields in the forms the SDK client and + * mppx write: addresses, decimal uint256 strings and a 32-byte hex nonce. A + * value such as `"0x2710"` would otherwise reach the receipt as written. + */ +export function hasEip3009Fields(authorization: Readonly>): boolean { + return ( + isEvmAddress(authorization['from']) && + isEvmAddress(authorization['to']) && + isUint256(authorization['value']) && + isUint256(authorization['validAfter']) && + isUint256(authorization['validBefore']) && + typeof authorization['nonce'] === 'string' && + BYTES32.test(authorization['nonce']) + ); +} + +/** + * Checks the Permit2 `exact`/EVM fields used to match the offer and derive + * the replay key. The facilitator checks the signature and on-chain state. + */ +export function isExactPermit2Payload( + payload: PaymentPayload, +): payload is PaymentPayload & { payload: ExactPermit2Payload } { + const inner = payload.payload; + if (payload.accepted.scheme !== 'exact' || !isRecord(inner)) return false; + const auth = inner['permit2Authorization']; + if (!isRecord(auth) || 'authorization' in inner || typeof inner['signature'] !== 'string') { + return false; + } + const { permitted, witness } = auth; + return ( + isRecord(permitted) && + isRecord(witness) && + isEvmAddress(auth['from']) && + isEvmAddress(auth['spender']) && + isEvmAddress(permitted['token']) && + isEvmAddress(witness['to']) && + isUint256(permitted['amount']) && + isUint256(auth['nonce']) && + isUint256(auth['deadline']) && + isUint256(witness['validAfter']) ); } diff --git a/src/payments/x402/provider.ts b/src/payments/x402/provider.ts deleted file mode 100644 index aa1fd5b..0000000 --- a/src/payments/x402/provider.ts +++ /dev/null @@ -1,826 +0,0 @@ -/** - * x402 payment provider: protocol v2, `exact` scheme, EVM, EIP-3009 - * `transferWithAuthorization`. - * - * `verify()` never moves funds. `settle()` does, and throws unless it is handed - * a successful verification. Both run through a facilitator (see - * `facilitator.ts`): the SDK's own `x402Facilitator` in this process, or a - * remote HTTP facilitator. - */ - -import type { - Network, - PaymentRequired, - PaymentRequirements, - SettleResponse, - VerifyResponse, -} from '@x402/core/types'; -import { - BaseError, - ContractFunctionRevertedError, - formatUnits, - getAddress, - HttpRequestError, - isAddress, - TimeoutError, - WaitForTransactionReceiptTimeoutError, -} from 'viem'; -import { - type AdapterDescriptor, - type AdapterHealth, - type Clock, - CommerceError, - type IdGenerator, - type Logger, - NOOP_LOGGER, - type PaymentContext, - type PaymentProvider, - type PaymentRequirement, - type PaymentResult, - type PaymentSettlementContext, - type PaymentVerificationContext, - systemClock, -} from '../../core'; -import { redactedErrorText } from '../../core/errors'; -import { SETTLE_AFTER_BACKEND_METADATA_KEY } from '../../core/execution/settlement-order'; -import { PACKAGE_VERSION } from '../../version'; -import { parseCanonicalAmount } from './amount'; -import { - createLocalFacilitatorClient, - createLocalPublicClient, - type LocalFacilitatorClient, -} from './chain'; -import { assertDevKeyIsLocalOnly, assertPayToIsNotDevAddress, describeRpc } from './dev-key-guard'; -import { - createLocalFacilitatorBinding, - createRemoteFacilitatorBinding, - type FacilitatorBinding, -} from './facilitator'; -import { resolveX402Deployment, type X402FacilitatorConfig } from './guardrails'; -import { describeDeploymentMode } from './networks'; -import { - declaredX402Version, - decodePaymentSubmission, - isExactEvmPayload, - withoutResource, -} from './payload'; -import { X402_DEFAULT_PAYMENT_FLOW, type X402PaymentFlow } from './payment-flow'; -import { computeReplayKey } from './replay-key'; - -const X402_VERSION = 2; -const X402_PROTOCOL_VERSION = String(X402_VERSION); -const DEFAULT_MAX_TIMEOUT_SECONDS = 60; -const DEFAULT_MIME_TYPE = 'application/json'; -const HEALTH_TIMEOUT_MS = 4_000; -// `SettleResponse.errorReason` the SDK uses for "broadcast, not confirmed" -const SETTLEMENT_PENDING_REASON = 'settlement_pending'; -/** - * The SDK's catch-all for a throw from the broadcast call, and its reason for - * a mined transaction that reverted. Only the second carries a hash. A revert - * the SDK recognizes during gas estimation gets its own reason instead. - */ -const TRANSACTION_FAILED_REASON = 'invalid_exact_evm_transaction_failed'; -/** - * `invalidReason`/`errorReason` are `z.string()` in the SDK schema, with no - * length or charset bound. They reach the buyer's error, the persisted events - * and the merchant's ledger. A remote facilitator controls these values, so - * they are treated as untrusted input rather than diagnostic text. - */ -const MAX_REASON_LENGTH = 64; -const REASON_SHAPE = /^[a-z0-9_.-]+$/i; - -function sanitizeReason(reason: string | undefined, fallback: string): string { - if (reason === undefined) return fallback; - const trimmed = reason.trim(); - if (trimmed.length === 0 || trimmed.length > MAX_REASON_LENGTH) return fallback; - return REASON_SHAPE.test(trimmed) ? trimmed : fallback; -} - -export interface X402ProviderOptions { - /** - * CAIP-2 network identifier, e.g. `eip155:84532`. The chain id it carries is - * part of the EIP-712 domain the buyer signs, so it is read from this value - * rather than defaulted. - */ - readonly network: string; - /** RPC endpoint. Local chain: http://127.0.0.1:8545 */ - readonly rpcUrl: string; - /** ERC-20 (EIP-3009) asset address used for settlement */ - readonly asset: `0x${string}`; - /** EIP-712 domain name of the asset, e.g. 'MockUSDC' */ - readonly assetName: string; - /** EIP-712 domain version of the asset, e.g. '2' */ - readonly assetVersion: string; - readonly assetDecimals: number; - /** Merchant-controlled settlement destination. Never a gateway-owned wallet */ - readonly payTo: `0x${string}`; - readonly maxTimeoutSeconds?: number; - /** - * When the payment settles relative to the backend call. Defaults to - * `authorization`, the x402 default: settle only after the backend succeeds. - * `upfront` settles before the backend call. - */ - readonly paymentFlow?: X402PaymentFlow; - /** Per-resource overrides of `paymentFlow`, keyed by resource id */ - readonly resourcePaymentFlows?: Readonly>; - /** - * Which facilitator verifies and broadcasts. `local` runs one in this process - * against a dev chain, and its `signerPrivateKey` pays gas there, usually - * an Anvil well-known key (LOCAL DEVELOPMENT ONLY - DO NOT FUND). `remote` - * calls an HTTP facilitator, and the gateway then holds no signing key. - */ - readonly facilitator: X402FacilitatorConfig; - /** Must be `true` before anything settles on a mainnet. Never a default */ - readonly allowMainnet?: boolean; - /** Required on mainnet when facilitator auth is `none` */ - readonly allowUnauthenticatedFacilitator?: boolean; - readonly logger?: Logger; - readonly clock?: Clock; - readonly ids?: IdGenerator; -} - -/** Prefixed random UUIDs, shared with the MPP rail */ -export const DEFAULT_IDS: IdGenerator = { - next: (prefix?: string) => `${prefix ? `${prefix}_` : ''}${crypto.randomUUID()}`, -}; - -export function createX402PaymentProvider(options: X402ProviderOptions): PaymentProvider { - if (!isAddress(options.asset)) { - throw new CommerceError( - 'CONFIG_INVALID', - `x402 provider: "asset" is not a valid EVM address: ${options.asset}`, - ); - } - if (!isAddress(options.payTo)) { - throw new CommerceError( - 'CONFIG_INVALID', - `x402 provider: "payTo" is not a valid EVM address: ${options.payTo}`, - ); - } - // In every facilitator mode. `resolveX402Deployment` below also refuses a dev - // payTo on any non-local deployment, whatever the RPC host. - assertPayToIsNotDevAddress(options.rpcUrl, options.payTo); - if (!Number.isInteger(options.assetDecimals) || options.assetDecimals < 0) { - throw new CommerceError( - 'CONFIG_INVALID', - `x402 provider: "assetDecimals" must be a non-negative integer`, - ); - } - // The chain id is signed into the buyer's EIP-712 domain, so a network it - // cannot be resolved from fails here, never at request time - const { profile, mode } = resolveX402Deployment({ - network: options.network, - payTo: options.payTo, - asset: options.asset, - assetName: options.assetName, - assetVersion: options.assetVersion, - facilitator: options.facilitator, - ...(options.allowMainnet !== undefined ? { allowMainnet: options.allowMainnet } : {}), - ...(options.allowUnauthenticatedFacilitator !== undefined - ? { allowUnauthenticatedFacilitator: options.allowUnauthenticatedFacilitator } - : {}), - }); - const network = options.network as Network; - // Built once, here, rather than in settle(): an unusable key fails the - // provider at startup, not on the first paid request after that buyer's - // replay key is already reserved - let binding: FacilitatorBinding; - if (options.facilitator.mode === 'local') { - assertDevKeyIsLocalOnly(options.rpcUrl, options.facilitator.signerPrivateKey); - let client: LocalFacilitatorClient; - try { - client = createLocalFacilitatorClient( - options.rpcUrl, - options.facilitator.signerPrivateKey as `0x${string}`, - ); - } catch (cause) { - throw new CommerceError( - 'CONFIG_INVALID', - 'x402 provider: could not construct a local facilitator signer from ' + - 'facilitator.signerPrivateKey. It must be a valid 32-byte hex private key.', - { cause }, - ); - } - binding = createLocalFacilitatorBinding(client, network, isProviderUnavailableError); - } else { - binding = createRemoteFacilitatorBinding({ - url: options.facilitator.url, - auth: options.facilitator.auth, - }); - } - - // health() reads the chain through its own client, whose timeout aborts the - // request. Verification and settlement go through the facilitator instead. - // Health details can reach logs, so they name the RPC by origin only. - const rpcOrigin = describeRpc(options.rpcUrl); - const healthPublicClient = createLocalPublicClient( - options.rpcUrl, - HEALTH_TIMEOUT_MS, - profile.chainId, - ); - - const clock = options.clock ?? systemClock; - const ids = options.ids ?? DEFAULT_IDS; - const logger = options.logger ?? NOOP_LOGGER; - const maxTimeoutSeconds = options.maxTimeoutSeconds ?? DEFAULT_MAX_TIMEOUT_SECONDS; - - const descriptor: AdapterDescriptor = { - name: 'x402', - kind: 'payment', - implementationVersion: PACKAGE_VERSION, - supportedSpec: `x402/v${X402_VERSION} scheme=exact family=eip155`, - capabilities: [ - 'exact-evm', - `${binding.kind}-facilitator`, - 'eip-3009', - 'caip-2', - `mode=${mode}`, - 'authorization-flow', - 'upfront-flow', - ], - status: 'stable', - unsupported: [ - 'svm', - 'permit2', - 'upto scheme', - 'batch-settlement scheme', - 'auth-capture scheme', - ], - }; - - async function createRequirement(context: PaymentContext): Promise { - const amount = parseCanonicalAmount(context.amount, options.assetDecimals).toString(); - const paymentFlow = - options.resourcePaymentFlows?.[context.resource.id] ?? - options.paymentFlow ?? - X402_DEFAULT_PAYMENT_FLOW; - - const requirements: PaymentRequirements = { - scheme: 'exact', - network, - asset: options.asset, - amount, - payTo: options.payTo, - maxTimeoutSeconds, - // `name`/`version` let the buyer and the facilitator build the same - // EIP-712 domain without calling `version()` on the token. - // `assetTransferMethod` names the one method this provider settles, so a - // conforming client never picks the `exact` scheme's Permit2 path. - // x402 requires an explicit non-default flow. Omit the default - // `authorization` flow here. - extra: { - name: options.assetName, - version: options.assetVersion, - assetTransferMethod: 'eip3009', - ...(paymentFlow === 'upfront' ? { paymentFlow } : {}), - }, - }; - - // The v2 challenge document as sent on the wire. The HTTP adapter - // base64-encodes it into `PAYMENT-REQUIRED` and MCP passes it through, so - // both surfaces offer the same challenge. - // - // `resource.url` is outside the EIP-3009 signature. Include public - // network and payee terms so the buyer's echo can identify this offer. - const envelope: PaymentRequired = { - x402Version: X402_VERSION, - resource: { - url: `resource://agent-commerce/${encodeURIComponent(options.network)}/${options.payTo}/resources/${encodeURIComponent(context.resource.id)}`, - description: context.resource.description ?? context.resource.name, - mimeType: DEFAULT_MIME_TYPE, - }, - accepts: [requirements], - }; - - const expiresAt = new Date(clock.now().getTime() + maxTimeoutSeconds * 1000).toISOString(); - - return { - id: ids.next('payreq'), - requestId: context.requestId, - resourceId: context.resource.id, - provider: 'x402', - amount: context.amount, - currency: context.currency, - destination: options.payTo, - network: options.network, - asset: options.asset, - expiresAt, - challenge: { - provider: 'x402', - version: X402_PROTOCOL_VERSION, - accepts: [requirements as unknown as Record], - envelope: envelope as unknown as Record, - }, - metadata: { [SETTLE_AFTER_BACKEND_METADATA_KEY]: paymentFlow === 'authorization' }, - }; - } - - /** - * Checks a submission against the requirement this provider built, never - * against `payload.accepted`, the copy the client echoes back. That copy - * must itself match the offer, or the proof is refused. - * - * The `exact` scheme requires the authorized amount to match exactly, - * including when the buyer offers more. Local refusals use x402 error codes - * where available. - */ - async function verify(context: PaymentVerificationContext): Promise { - const { requirement, submission } = context; - - const rejected = (reason: string): PaymentResult => ({ - status: 'rejected', - provider: 'x402', - amount: requirement.amount, - currency: requirement.currency, - ...(requirement.network !== undefined ? { network: requirement.network } : {}), - ...(requirement.asset !== undefined ? { asset: requirement.asset } : {}), - rejectionReason: reason, - }); - - if (requirement.provider !== 'x402' || requirement.challenge.provider !== 'x402') { - return rejected('wrong_provider'); - } - - const requirements = requirement.challenge.accepts[0] as PaymentRequirements | undefined; - if (!requirements) { - return rejected('missing_payment_requirements'); - } - - // Defense in depth only: the pipeline builds a fresh requirement for every - // request, so this rarely fires. The real expiry is EIP-3009's - // `validBefore`, which the facilitator checks. - if ( - requirement.expiresAt !== undefined && - Date.parse(requirement.expiresAt) < clock.now().getTime() - ) { - return rejected('requirement_expired'); - } - - // This provider built the requirement, so the asset always matches in - // normal operation. A corrupted one must still never authorize a transfer - // of a different asset. - if ( - !isAddress(requirements.asset) || - getAddress(requirements.asset) !== getAddress(options.asset) - ) { - return rejected('wrong_asset'); - } - - const payload = decodePaymentSubmission(submission.payload); - if (!payload) { - const version = declaredX402Version(submission.payload); - return rejected( - typeof version === 'number' && version !== X402_VERSION - ? 'invalid_x402_version' - : 'invalid_payload', - ); - } - if (payload.x402Version !== X402_VERSION) { - return rejected('invalid_x402_version'); - } - if (!isExactEvmPayload(payload)) { - return rejected('unsupported_scheme'); - } - - const { authorization } = payload.payload; - - if (!isAddress(authorization.from) || !isAddress(authorization.to)) { - return rejected('invalid_payload'); - } - // `accepted.network` names the chain the buyer signed for, so a mismatch - // means a signature bound to a chain we do not settle on - if (payload.accepted.network !== options.network) { - return rejected('invalid_network'); - } - // The signature omits flow and resource. Check the echoed offer; - // `resource` is optional, but must match when present. - const offeredUrl = (requirement.challenge.envelope as PaymentRequired | undefined)?.resource - ?.url; - if ( - !acceptedMatchesOffer(requirements, payload.accepted) || - (payload.resource !== undefined && payload.resource.url !== offeredUrl) - ) { - return rejected('invalid_payment_requirements'); - } - if (getAddress(authorization.to) !== getAddress(options.payTo)) { - return rejected('invalid_exact_evm_payload_recipient_mismatch'); - } - let authorizedValue: bigint; - let required: bigint; - try { - authorizedValue = BigInt(authorization.value); - required = BigInt(requirements.amount); - } catch { - return rejected('invalid_payload'); - } - if (authorizedValue !== required) { - return rejected('invalid_exact_evm_payload_authorization_value_mismatch'); - } - - const scope = binding.open(); - let sdkResult: VerifyResponse; - try { - sdkResult = await scope.verify(withoutResource(payload), requirements); - } catch (err) { - // A throw means no verdict was obtained, so it is never held against the - // buyer. Only an unclassified one is logged, as it may be an SDK fault. - if (!isProviderUnavailableError(err) && !scope.transportFailed()) { - logger.warn({ err: redactedErrorText(err) }, 'x402 verify(): unexpected SDK error'); - } - throw new CommerceError( - 'PAYMENT_PROVIDER_UNAVAILABLE', - `x402 provider: ${binding.kind} facilitator returned no verdict during verify()`, - { cause: err }, - ); - } - - if (!sdkResult.isValid) { - // An RPC that never answered is not a payment that failed a check - if (scope.transportFailed()) { - if (sdkResult.invalidReason !== undefined) { - logger.debug( - { reportedReason: sdkResult.invalidReason }, - 'x402 verify(): local facilitator transport failure reason', - ); - } - throw new CommerceError( - 'PAYMENT_PROVIDER_UNAVAILABLE', - `x402 provider: ${binding.kind} facilitator unreachable during verify()`, - ); - } - // The raw reason is logged at debug level. Only what the buyer sees and - // the ledger records is constrained. - if (sdkResult.invalidReason !== undefined) { - logger.debug( - { reportedReason: sdkResult.invalidReason }, - 'x402 verify(): facilitator rejection reason', - ); - } - return rejected(sanitizeReason(sdkResult.invalidReason, 'invalid_payment')); - } - - // `accepted.network` equals the configured network (checked above), so the - // profile's chain id is the one the authorization is bound to - const replayKey = computeReplayKey({ - chainId: profile.chainId, - asset: getAddress(requirements.asset), - from: getAddress(authorization.from), - nonce: authorization.nonce, - }); - - return { - status: 'verified', - provider: 'x402', - payer: getAddress(authorization.from), - payee: getAddress(authorization.to), - amount: formatUnits(authorizedValue, options.assetDecimals), - currency: requirement.currency, - network: payload.accepted.network, - asset: getAddress(requirements.asset), - replayKey, - }; - } - - async function settle(context: PaymentSettlementContext): Promise { - const { requirement, submission, verification } = context; - - if (verification.status !== 'verified') { - throw new CommerceError( - 'PAYMENT_INVALID', - 'x402 provider: settle() called without a successful verify()', - ); - } - - const requirements = requirement.challenge.accepts[0] as PaymentRequirements | undefined; - if (!requirements) { - throw new CommerceError( - 'PAYMENT_INVALID', - 'x402 provider: settle() found no payment requirements to settle against', - ); - } - - const payload = decodePaymentSubmission(submission.payload); - if (!payload || !isExactEvmPayload(payload)) { - throw new CommerceError( - 'PAYMENT_INVALID', - 'x402 provider: settle() received an undecodable payment payload', - ); - } - - const rejectedSettlement = (rejectionReason: string): PaymentResult => ({ - status: 'rejected', - provider: 'x402', - amount: verification.amount, - currency: requirement.currency, - ...(verification.network !== undefined ? { network: verification.network } : {}), - ...(verification.asset !== undefined ? { asset: verification.asset } : {}), - ...(verification.replayKey !== undefined ? { replayKey: verification.replayKey } : {}), - rejectionReason, - }); - - const scope = binding.open(); - let sdkResult: SettleResponse; - try { - sdkResult = await scope.settle(withoutResource(payload), requirements); - } catch (err) { - if (isProviderUnavailableError(err) || scope.transportFailed()) { - throw new CommerceError( - 'PAYMENT_PROVIDER_UNAVAILABLE', - `x402 provider: ${binding.kind} facilitator unreachable during settle()`, - { cause: err }, - ); - } - // The pinned SDK returns a result for every failure from gas estimation - // on, so a throw comes from its checks before anything is sent. A revert - // there is a real rejection, which releases any mandate the pipeline - // holds for the purchase. Any other throw goes back as an unavailable - // provider, which the pipeline records as `settlement-uncertain`, never - // as a rejection blamed on the buyer. - if (!isOnChainRevertError(err)) { - logger.warn( - { err: redactedErrorText(err) }, - 'x402 settle(): settlement failed with an unclassified error; outcome unknown', - ); - throw new CommerceError( - 'PAYMENT_PROVIDER_UNAVAILABLE', - 'x402 provider: settlement failed with an unclassified error; outcome unknown', - { cause: err }, - ); - } - logger.warn( - { err: redactedErrorText(err), rejectionReason: 'transaction_reverted' }, - 'x402 settle(): settlement transaction reverted on chain', - ); - return rejectedSettlement('transaction_reverted'); - } - - if (!sdkResult.success) { - // "Broadcast, never confirmed" is not "did not happen": the transfer may - // be on-chain, so it throws as an unavailable provider carrying the hash, - // and the pipeline records the attempt `settlement-uncertain`, not - // `rejected`. The SDK's catch-all without a hash is treated the same way, - // because its throw may have followed a broadcast and viem reports an - // RPC error on the send as a revert. Everything else is a real rejection. - if ( - sdkResult.errorReason === TRANSACTION_FAILED_REASON && - !/^0x[0-9a-f]{64}$/i.test(sdkResult.transaction) - ) { - logger.warn( - { reportedReason: sdkResult.errorReason }, - 'x402 settle(): the broadcast failed without a transaction hash; outcome unknown', - ); - throw new CommerceError( - 'PAYMENT_PROVIDER_UNAVAILABLE', - 'x402 provider: settlement failed with an unclassified error; outcome unknown', - ); - } - if (sdkResult.errorReason === SETTLEMENT_PENDING_REASON || scope.transportFailed()) { - throw new CommerceError( - 'PAYMENT_PROVIDER_UNAVAILABLE', - 'x402 provider: settlement was broadcast but could not be confirmed', - { - ...(sdkResult.transaction - ? { details: { transactionHash: sdkResult.transaction } } - : {}), - }, - ); - } - if (sdkResult.errorReason !== undefined) { - logger.debug( - { reportedReason: sdkResult.errorReason }, - 'x402 settle(): facilitator failure reason', - ); - } - return { - ...rejectedSettlement(sanitizeReason(sdkResult.errorReason, 'settlement_failed')), - network: sdkResult.network, - ...(sdkResult.payer !== undefined ? { payer: sdkResult.payer } : {}), - }; - } - - return { - status: 'settled', - provider: 'x402', - externalReference: sdkResult.transaction, - ...(sdkResult.payer !== undefined ? { payer: sdkResult.payer } : {}), - payee: requirements.payTo, - amount: verification.amount, - currency: requirement.currency, - network: sdkResult.network, - ...(verification.asset !== undefined ? { asset: verification.asset } : {}), - ...(verification.replayKey !== undefined ? { replayKey: verification.replayKey } : {}), - settledAt: clock.nowIso(), - // `amount` is the display value; x402's settlement response reports base units - metadata: { amountBaseUnits: payload.payload.authorization.value }, - }; - } - - async function computeHealth(): Promise { - const startedAt = clock.monotonicMs(); - const checkedAt = clock.nowIso(); - - try { - const chainId = await healthPublicClient.getChainId(); - if (chainId !== profile.chainId) { - return { - status: 'fail', - detail: `RPC at ${rpcOrigin} reports chain id ${chainId}, expected ${profile.chainId} (${profile.displayName})`, - checkedAt, - durationMs: clock.monotonicMs() - startedAt, - }; - } - - const code = await healthPublicClient.getCode({ address: options.asset }); - if (!code || code === '0x') { - return { - status: 'fail', - detail: `No contract code found at configured asset address ${options.asset}`, - checkedAt, - durationMs: clock.monotonicMs() - startedAt, - }; - } - - if (binding.kind === 'remote') { - // Reachable is not enough: a facilitator that does not carry our - // scheme on our network fails every payment after the buyer has signed - const kinds = await binding.supported(); - const supportsUs = kinds.some( - (kind) => - kind.x402Version === X402_VERSION && - kind.scheme === 'exact' && - kind.network === options.network, - ); - if (!supportsUs) { - return { - status: 'fail', - detail: - `Facilitator ${binding.describe} does not advertise x402 v${X402_VERSION} scheme=exact ` + - `on ${options.network} (${profile.displayName}); no payment on this deployment can settle`, - checkedAt, - durationMs: clock.monotonicMs() - startedAt, - }; - } - return { - status: 'pass', - detail: - `${describeDeploymentMode(mode)}: RPC ${rpcOrigin} reachable, chain id ` + - `${profile.chainId} (${profile.displayName}), asset ${options.asset} has code, ` + - `facilitator ${binding.describe} supports exact/${options.network}`, - checkedAt, - durationMs: clock.monotonicMs() - startedAt, - }; - } - - // Chain id alone cannot prove this is a dev chain, because Base Sepolia - // reports the same 84532 as the local chain. An Anvil-only RPC method - // must answer before a local facilitator counts as healthy. - const isAnvil = await probeIsAnvilNode(healthPublicClient); - if (!isAnvil) { - return { - status: 'fail', - detail: - `RPC at ${rpcOrigin} reports chain id ${profile.chainId} but does not answer ` + - '"anvil_nodeInfo", so it does not look like a local Anvil dev node. Refusing to treat it ' + - 'as safe for a local-facilitator dev key.', - checkedAt, - durationMs: clock.monotonicMs() - startedAt, - }; - } - - return { - status: 'pass', - detail: - `${describeDeploymentMode(mode)}: RPC ${rpcOrigin} reachable, chain id ` + - `${profile.chainId}, asset ${options.asset} has code, confirmed Anvil dev node`, - checkedAt, - durationMs: clock.monotonicMs() - startedAt, - }; - } catch (err) { - return { - status: 'fail', - detail: `x402 health check failed: ${redactedErrorText(err)}`, - checkedAt, - durationMs: clock.monotonicMs() - startedAt, - }; - } - } - - // A probe makes several upstream calls, and `/ready` is unauthenticated. - // A short TTL plus one shared in-flight probe keeps upstream volume independent - // of request volume, for library callers as well as the gateway, whose - // readiness probe adds its own cache on top. Pass and fail share the TTL: a - // shorter one for fail would bring the load back during an RPC outage, - // exactly when each call is slowest. - const HEALTH_CACHE_MS = 5_000; - let cachedHealth: { at: number; value: AdapterHealth } | undefined; - let inFlightHealth: Promise | undefined; - - async function health(): Promise { - const now = clock.monotonicMs(); - if (cachedHealth && now - cachedHealth.at < HEALTH_CACHE_MS) { - return cachedHealth.value; - } - if (inFlightHealth) return inFlightHealth; - - inFlightHealth = computeHealth() - .then((value) => { - cachedHealth = { at: clock.monotonicMs(), value }; - return value; - }) - .finally(() => { - inFlightHealth = undefined; - }); - return inFlightHealth; - } - - return { - name: 'x402', - descriptor, - createRequirement, - verify, - settle, - health, - }; -} - -/** - * Match echoed terms against the offer. Extra client keys and - * `maxTimeoutSeconds` do not matter; addresses ignore case. Compare effective - * payment flows so an upfront proof cannot pay a default authorization offer. - */ -function acceptedMatchesOffer( - offered: PaymentRequirements, - accepted: PaymentRequirements, -): boolean { - const sameAddress = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); - const offeredExtra = offered.extra ?? {}; - const acceptedExtra = accepted.extra ?? {}; - const flow = (extra: Record) => - extra['paymentFlow'] ?? X402_DEFAULT_PAYMENT_FLOW; - return ( - accepted.scheme === offered.scheme && - accepted.network === offered.network && - accepted.amount === offered.amount && - sameAddress(accepted.asset, offered.asset) && - sameAddress(accepted.payTo, offered.payTo) && - Object.entries(offeredExtra).every(([key, value]) => acceptedExtra[key] === value) && - flow(acceptedExtra) === flow(offeredExtra) - ); -} - -// viem wraps every failed contract call in ContractFunctionExecutionError, so -// only the cause chain can show a revert. A revert alone does not prove nothing -// moved, because viem also reports an RPC error on a send as one; settle() -// relies on the SDK throwing only before the broadcast. -function isOnChainRevertError(err: unknown): boolean { - return ( - err instanceof BaseError && err.walk((e) => e instanceof ContractFunctionRevertedError) !== null - ); -} - -/** - * The `instanceof` checks cover what the pinned viem HTTP transport throws - * (`HttpRequestError` for every fetch failure, including connection refused, - * or `TimeoutError`) and what `waitForTransactionReceipt` throws when it stops - * polling after a broadcast. Message matching is only a fallback for errors - * outside those classes, such as a raw undici error, because message text can - * change with any viem or undici upgrade. - */ -function isProviderUnavailableError(err: unknown): boolean { - if ( - err instanceof HttpRequestError || - err instanceof TimeoutError || - err instanceof WaitForTransactionReceiptTimeoutError - ) { - return true; - } - const message = redactedErrorText(err).toLowerCase(); - return ( - message.includes('econnrefused') || - message.includes('fetch failed') || - message.includes('enotfound') || - message.includes('etimedout') || - message.includes('timed out') || - message.includes('network error') - ); -} - -/** - * True only when the RPC answers Anvil's own `anvil_nodeInfo` method. A public - * node, Base Sepolia included, answers "method not found". Never throws. - */ -async function probeIsAnvilNode( - client: ReturnType, -): Promise { - // Anvil-only method, absent from viem's typed PublicRpcSchema, hence the cast - const request = client.request as unknown as (args: { - method: string; - params: unknown[]; - }) => Promise; - try { - await request({ method: 'anvil_nodeInfo', params: [] }); - return true; - } catch { - return false; - } -} diff --git a/src/payments/x402/provider/constants.ts b/src/payments/x402/provider/constants.ts new file mode 100644 index 0000000..a17967d --- /dev/null +++ b/src/payments/x402/provider/constants.ts @@ -0,0 +1,13 @@ +// Protocol constants shared by the x402 provider's modules +import type { IdGenerator } from '../../../core'; + +export const X402_VERSION = 2; +export const X402_PROTOCOL_VERSION = String(X402_VERSION); +export const DEFAULT_MAX_TIMEOUT_SECONDS = 60; +export const DEFAULT_MIME_TYPE = 'application/json'; +export const HEALTH_TIMEOUT_MS = 4_000; + +/** Prefixed random UUIDs, shared with the MPP rail */ +export const DEFAULT_IDS: IdGenerator = { + next: (prefix?: string) => `${prefix ? `${prefix}_` : ''}${crypto.randomUUID()}`, +}; diff --git a/src/payments/x402/provider/fee-payer.ts b/src/payments/x402/provider/fee-payer.ts new file mode 100644 index 0000000..43bb481 --- /dev/null +++ b/src/payments/x402/provider/fee-payer.ts @@ -0,0 +1,76 @@ +// The Solana fee payer an offer names, read from the facilitator's `/supported` +import type { SupportedResponse } from '@x402/core/types'; +import { CommerceError } from '../../../core'; +import { normalizeAddress } from '../addresses'; +import type { FacilitatorBinding } from '../facilitator'; +import { X402_VERSION } from './constants'; +import type { ProviderContext, SvmOffer } from './types'; + +// How long a `/supported` snapshot names the Solana fee payer of new offers +const SUPPORTED_TTL_MS = 60_000; + +/** Returns the fee payer lookup, with its `/supported` snapshot, for one provider */ +export function createSvmFeePayerSource( + ctx: ProviderContext, +): (offer: SvmOffer) => Promise { + const { clock, remoteBinding } = ctx; + + // A Solana offer carries the fee payer the facilitator names in `/supported`. + // A snapshot lasts SUPPORTED_TTL_MS, and a refresh keeps the previous fee + // payer while the facilitator still lists it, so a challenge and the retry + // that pays it agree. + let supportedSnapshot: + | { readonly at: number; readonly response: Promise } + | undefined; + const feePayers = new Map(); + + function currentSupported(): Promise { + const now = clock.monotonicMs(); + if (supportedSnapshot && now - supportedSnapshot.at < SUPPORTED_TTL_MS) { + return supportedSnapshot.response; + } + const remote = remoteBinding as Extract; + const response = remote.supported(); + const snapshot = { at: now, response }; + supportedSnapshot = snapshot; + // A failed fetch is not kept; the next challenge asks again + response.catch(() => { + if (supportedSnapshot === snapshot) supportedSnapshot = undefined; + }); + return response; + } + + async function svmFeePayer(offer: SvmOffer): Promise { + let supported: SupportedResponse; + try { + supported = await currentSupported(); + } catch (cause) { + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + `x402 provider: facilitator ${offer.binding.describe} did not answer /supported, which names the Solana fee payer`, + { cause }, + ); + } + const listed = supported.kinds.find( + (kind) => + kind.x402Version === X402_VERSION && + kind.scheme === 'exact' && + kind.network === offer.network, + )?.extra?.['feePayer']; + const previous = feePayers.get(offer.network); + const stillSigns = + previous !== undefined && + (previous === listed || Object.values(supported.signers).some((s) => s.includes(previous))); + const feePayer = stillSigns ? previous : listed; + if (typeof feePayer !== 'string' || normalizeAddress(offer.profile, feePayer) === undefined) { + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + `x402 provider: facilitator ${offer.binding.describe} names no Solana fee payer for ${offer.network}`, + ); + } + feePayers.set(offer.network, feePayer); + return feePayer; + } + + return svmFeePayer; +} diff --git a/src/payments/x402/provider/health.ts b/src/payments/x402/provider/health.ts new file mode 100644 index 0000000..156b675 --- /dev/null +++ b/src/payments/x402/provider/health.ts @@ -0,0 +1,422 @@ +// Readiness: each offer's chain, asset and facilitator support, cached briefly + +import type { SupportedKind } from '@x402/core/types'; +import { PERMIT2_ADDRESS, x402ExactPermit2ProxyAddress } from '@x402/evm'; +import { type AdapterHealth, CommerceError } from '../../../core'; +import { redactedErrorText } from '../../../core/errors'; +import { isRecord } from '../../../core/is-record'; +import { normalizeAddress } from '../addresses'; +import type { createLocalPublicClient } from '../chain'; +import type { FacilitatorBinding } from '../facilitator'; +import { describeDeploymentMode } from '../networks'; +import { x402OfferName } from '../options'; +import { HEALTH_TIMEOUT_MS, X402_VERSION } from './constants'; +import type { NearOffer, Offer, ProviderContext, SvmOffer } from './types'; + +/** Builds the provider's `health`, which probes every offer and caches the result */ +export function createHealthCheck(ctx: ProviderContext): () => Promise { + const { offers, clock, deploymentMode, remoteBinding, svm, near } = ctx; + + // One offer's chain, asset and facilitator support. `kinds` fetches the + // remote facilitator's `/supported` once per probe, shared by every offer. + async function offerHealth( + offer: Offer, + kinds: () => Promise, + ): Promise<{ readonly status: 'pass' | 'fail'; readonly detail: string }> { + if (offer.family === 'svm') return svmOfferHealth(offer, kinds); + if (offer.family === 'near') return nearOfferHealth(offer, kinds); + const { profile, rpcOrigin, healthClient, binding } = offer; + const chainId = await healthClient.getChainId(); + if (chainId !== profile.chainId) { + return { + status: 'fail', + detail: `RPC at ${rpcOrigin} reports chain id ${chainId}, expected ${profile.chainId} (${profile.displayName})`, + }; + } + + const code = await healthClient.getCode({ address: offer.asset }); + if (!code || code === '0x') { + return { + status: 'fail', + detail: `No contract code found at configured asset address ${offer.asset}`, + }; + } + // Permit2 offers settle through two contracts the merchant does not deploy + if (offer.method === 'permit2') { + for (const [name, address] of PERMIT2_CONTRACTS) { + const deployed = await healthClient.getCode({ address }); + if (!deployed || deployed === '0x') { + return { + status: 'fail', + detail: `No ${name} contract code found at ${address} on ${profile.displayName}`, + }; + } + } + } + const contracts = `asset ${offer.asset}${offer.method === 'permit2' ? ', Permit2 and its x402 proxy' : ''}`; + + if (binding.kind === 'remote') { + // Reachable is not enough: a facilitator that does not carry our + // scheme on our network fails every payment after the buyer has signed + const supportsUs = (await kinds()).some( + (kind) => + kind.x402Version === X402_VERSION && + kind.scheme === 'exact' && + kind.network === offer.network, + ); + if (!supportsUs) { + return { + status: 'fail', + detail: + `Facilitator ${binding.describe} does not advertise x402 v${X402_VERSION} scheme=exact ` + + `on ${offer.network} (${profile.displayName}); no payment on this deployment can settle`, + }; + } + return { + status: 'pass', + detail: + `${describeDeploymentMode(deploymentMode)}: RPC ${rpcOrigin} reachable, chain id ` + + `${profile.chainId} (${profile.displayName}), ${contracts} have code, ` + + `facilitator ${binding.describe} supports exact/${offer.network}`, + }; + } + + // An RPC reports whatever chain id it was started with, so the id cannot + // prove this is a dev chain. An Anvil-only RPC method must answer before + // a local facilitator counts as healthy. + const isAnvil = await probeIsAnvilNode(healthClient); + if (!isAnvil) { + return { + status: 'fail', + detail: + `RPC at ${rpcOrigin} reports chain id ${profile.chainId} but does not answer ` + + '"anvil_nodeInfo", so it does not look like a local Anvil dev node. Refusing to treat it ' + + 'as safe for a local-facilitator dev key.', + }; + } + + return { + status: 'pass', + detail: + `${describeDeploymentMode(deploymentMode)}: RPC ${rpcOrigin} reachable, chain id ` + + `${profile.chainId}, ${contracts} have code, confirmed Anvil dev node`, + }; + } + + // A Solana offer's cluster, mint and fee payer. The CAIP-2 reference is the + // start of the genesis hash, so the RPC proves which cluster it serves. + async function svmOfferHealth( + offer: SvmOffer, + kinds: () => Promise, + ): Promise<{ readonly status: 'pass' | 'fail'; readonly detail: string }> { + const { profile, rpcOrigin, binding } = offer; + // A missing Solana peer fails readiness, not the first paid request + const svmModule = await svm(); + const genesis = await jsonRpc(offer.rpcUrl, 'getGenesisHash', []); + const cluster = typeof genesis === 'string' ? `solana:${genesis.slice(0, 32)}` : undefined; + if (cluster !== offer.network) { + return { + status: 'fail', + detail: `RPC at ${rpcOrigin} serves ${cluster ?? 'an unknown cluster'}, expected ${offer.network} (${profile.displayName})`, + }; + } + const mint = await jsonRpc(offer.rpcUrl, 'getAccountInfo', [ + offer.asset, + { encoding: 'base64' }, + ]); + const mintInfo = readMint(mint); + if (mintInfo === undefined) { + return { + status: 'fail', + detail: `No initialized token mint found at configured asset address ${offer.asset}`, + }; + } + const { decimals, tokenProgram } = mintInfo; + if (decimals !== offer.assetDecimals) { + return { + status: 'fail', + detail: `Mint ${offer.asset} has ${decimals} decimals, configured ${offer.assetDecimals}`, + }; + } + // Without the payee's token account every payment fails the facilitator's + // simulation, after the buyer signed + const payeeAccount = await svmModule.associatedTokenAccount( + offer.payTo, + offer.asset, + tokenProgram, + ); + const account = await jsonRpc(offer.rpcUrl, 'getAccountInfo', [ + payeeAccount, + { encoding: 'base64' }, + ]); + const accountValue = isRecord(account) ? account['value'] : undefined; + if (!isRecord(accountValue) || accountValue['owner'] !== tokenProgram) { + return { + status: 'fail', + detail: `payTo ${offer.payTo} has no token account for mint ${offer.asset}; create its associated token account (${payeeAccount}) before taking payments`, + }; + } + const feePayer = (await kinds()).find( + (kind) => + kind.x402Version === X402_VERSION && + kind.scheme === 'exact' && + kind.network === offer.network, + )?.extra?.['feePayer']; + if (typeof feePayer !== 'string' || normalizeAddress(profile, feePayer) === undefined) { + return { + status: 'fail', + detail: + `Facilitator ${binding.describe} does not advertise x402 v${X402_VERSION} scheme=exact ` + + `on ${offer.network} (${profile.displayName}) with a fee payer; no payment on this deployment can settle`, + }; + } + return { + status: 'pass', + detail: + `${describeDeploymentMode(deploymentMode)}: RPC ${rpcOrigin} serves ${profile.displayName}, ` + + `mint ${offer.asset} has ${decimals} decimals and a token account for ${offer.payTo}, ` + + `facilitator ${binding.describe} supports ` + + `exact/${offer.network} with fee payer ${feePayer}`, + }; + } + + // A NEAR offer's chain, token and merchant registration. The RPC's `status` + // names the chain it serves; an account suffix such as `.testnet` proves nothing. + async function nearOfferHealth( + offer: NearOffer, + kinds: () => Promise, + ): Promise<{ readonly status: 'pass' | 'fail'; readonly detail: string }> { + const { profile, rpcOrigin, binding, rpcUrl } = offer; + // A missing NEAR peer fails readiness, not the first paid request + await near(); + const status = await jsonRpc(rpcUrl, 'status', []); + const chainId = isRecord(status) ? status['chain_id'] : undefined; + const expected = offer.network.slice('near:'.length); + if (chainId !== expected) { + return { + status: 'fail', + detail: `RPC at ${rpcOrigin} serves chain ${String(chainId ?? 'unknown')}, expected ${expected} (${profile.displayName})`, + }; + } + const token = await jsonRpc(rpcUrl, 'query', { + request_type: 'view_account', + finality: 'final', + account_id: offer.asset, + }); + if (!isRecord(token) || token['code_hash'] === NEAR_EMPTY_CODE_HASH) { + return { + status: 'fail', + detail: `No contract code found at configured asset account ${offer.asset}`, + }; + } + const metadata = await nearView(rpcUrl, offer.asset, 'ft_metadata', {}); + const decimals = isRecord(metadata) ? metadata['decimals'] : undefined; + if (decimals !== offer.assetDecimals) { + return { + status: 'fail', + detail: `Token ${offer.asset} has ${String(decimals)} decimals, configured ${offer.assetDecimals}`, + }; + } + // An unregistered recipient makes every transfer fail after the relayer + // paid for it. A token without NEP-145 storage management needs none. + let registered = true; + try { + registered = + (await nearView(rpcUrl, offer.asset, 'storage_balance_of', { + account_id: offer.payTo, + })) !== null; + } catch (err) { + if (!/MethodNotFound|MethodResolveError/i.test(redactedErrorText(err))) throw err; + } + if (!registered) { + return { + status: 'fail', + detail: `payTo ${offer.payTo} has no storage registered with token ${offer.asset}; register it (NEP-145 storage_deposit) before taking payments`, + }; + } + const listed = (await kinds()).some( + (kind) => + kind.x402Version === X402_VERSION && + kind.scheme === 'exact' && + kind.network === offer.network, + ); + if (!listed) { + return { + status: 'fail', + detail: + `Facilitator ${binding.describe} does not advertise x402 v${X402_VERSION} scheme=exact ` + + `on ${offer.network} (${profile.displayName}); no payment on this deployment can settle`, + }; + } + return { + status: 'pass', + detail: + `${describeDeploymentMode(deploymentMode)}: RPC ${rpcOrigin} serves ${expected}, ` + + `token ${offer.asset} has ${decimals} decimals and storage for ${offer.payTo}, ` + + `facilitator ${binding.describe} supports exact/${offer.network}`, + }; + } + + // Check all offers and report each status; any failure fails readiness. + // Challenges retain the complete offer list. + async function computeHealth(): Promise { + const startedAt = clock.monotonicMs(); + const checkedAt = clock.nowIso(); + let supported: Promise | undefined; + const kinds = () => { + supported ??= (remoteBinding as Extract) + .supported() + .then((response) => response.kinds); + return supported; + }; + + const results = await Promise.all( + offers.map((offer) => + offerHealth(offer, kinds).catch((err: unknown) => ({ + status: 'fail' as const, + detail: `x402 health check failed: ${redactedErrorText(err)}`, + })), + ), + ); + const failed = results.some((result) => result.status === 'fail'); + const details = offers.flatMap((offer, index) => { + const result = results[index] as (typeof results)[number]; + if (failed && result.status !== 'fail') return []; + return [offers.length === 1 ? result.detail : `${x402OfferName(offer)}: ${result.detail}`]; + }); + return { + status: failed ? 'fail' : 'pass', + detail: details.join('; '), + components: offers.map((offer, index) => ({ + name: x402OfferName(offer), + status: (results[index] as (typeof results)[number]).status, + })), + checkedAt, + durationMs: clock.monotonicMs() - startedAt, + }; + } + + // A probe makes several upstream calls, and `/ready` is unauthenticated. + // A short TTL plus one shared in-flight probe keeps upstream volume independent + // of request volume, for library callers as well as the gateway, whose + // readiness probe adds its own cache on top. Pass and fail share the TTL: a + // shorter one for fail would bring the load back during an RPC outage, + // exactly when each call is slowest. + const HEALTH_CACHE_MS = 5_000; + let cachedHealth: { at: number; value: AdapterHealth } | undefined; + let inFlightHealth: Promise | undefined; + + async function health(): Promise { + const now = clock.monotonicMs(); + if (cachedHealth && now - cachedHealth.at < HEALTH_CACHE_MS) { + return cachedHealth.value; + } + if (inFlightHealth) return inFlightHealth; + + inFlightHealth = computeHealth() + .then((value) => { + cachedHealth = { at: clock.monotonicMs(), value }; + return value; + }) + .finally(() => { + inFlightHealth = undefined; + }); + return inFlightHealth; + } + + return health; +} + +const PERMIT2_CONTRACTS = [ + ['Permit2', PERMIT2_ADDRESS], + ['x402 exact Permit2 proxy', x402ExactPermit2ProxyAddress], +] as const; + +const TOKEN_PROGRAMS: ReadonlySet = new Set([ + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', +]); + +// The decimals and owning token program of an initialized SPL Token or +// Token-2022 mint, from a base64 `getAccountInfo` result: byte 44 holds the +// decimals, byte 45 the initialized flag +function readMint( + result: unknown, +): { readonly decimals: number; readonly tokenProgram: string } | undefined { + const value = isRecord(result) ? result['value'] : undefined; + if (!isRecord(value)) return undefined; + const owner = value['owner']; + if (typeof owner !== 'string' || !TOKEN_PROGRAMS.has(owner)) return undefined; + const data = value['data']; + if (!Array.isArray(data) || typeof data[0] !== 'string') return undefined; + const bytes = Buffer.from(data[0], 'base64'); + const decimals = bytes[44]; + return bytes.length >= 82 && bytes[45] === 1 && decimals !== undefined + ? { decimals, tokenProgram: owner } + : undefined; +} + +// One JSON-RPC call for health(), bounded like the EVM client's +async function jsonRpc(rpcUrl: string, method: string, params: unknown): Promise { + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS), + }); + const body = (await response.json()) as { result?: unknown; error?: unknown }; + if (body.error !== undefined) { + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + `RPC ${method} failed: ${JSON.stringify(body.error)}`, + ); + } + return body.result; +} + +// A NEAR contract view call at final finality, its result parsed as JSON +async function nearView( + rpcUrl: string, + accountId: string, + method: string, + args: Record, +): Promise { + const result = await jsonRpc(rpcUrl, 'query', { + request_type: 'call_function', + finality: 'final', + account_id: accountId, + method_name: method, + args_base64: Buffer.from(JSON.stringify(args)).toString('base64'), + }); + const bytes = isRecord(result) ? result['result'] : undefined; + if (!Array.isArray(bytes)) { + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + `NEAR view ${method} returned no result`, + ); + } + return JSON.parse(Buffer.from(bytes as number[]).toString('utf8')); +} + +// The code hash of an account without a contract +const NEAR_EMPTY_CODE_HASH = '11111111111111111111111111111111'; + +/** + * True only when the RPC answers Anvil's own `anvil_nodeInfo` method. A public + * node, Base Sepolia included, answers "method not found". Never throws. + */ +async function probeIsAnvilNode( + client: ReturnType, +): Promise { + // Anvil-only method, absent from viem's typed PublicRpcSchema, hence the cast + const request = client.request as unknown as (args: { + method: string; + params: unknown[]; + }) => Promise; + try { + await request({ method: 'anvil_nodeInfo', params: [] }); + return true; + } catch { + return false; + } +} diff --git a/src/payments/x402/provider/index.ts b/src/payments/x402/provider/index.ts new file mode 100644 index 0000000..c0a54e3 --- /dev/null +++ b/src/payments/x402/provider/index.ts @@ -0,0 +1,165 @@ +/** + * x402 payment provider: protocol v2, `exact` scheme. On EVM the buyer signs an + * EIP-3009 `transferWithAuthorization` or a Permit2 transfer through the x402 + * exact proxy; on Solana, a token transfer whose fee the facilitator pays; on + * NEAR, a delegated NEP-141 `ft_transfer` that the facilitator's relayer + * submits. + * + * `verify()` never moves funds. `settle()` does, and throws unless it is handed + * a successful verification. Both run through a facilitator (see + * `../facilitator.ts`): the SDK's own `x402Facilitator` in this process, or a + * remote HTTP facilitator. + */ +import type { AdapterDescriptor, PaymentProvider } from '../../../core'; +import { CommerceError, NOOP_LOGGER, systemClock } from '../../../core'; +import { PACKAGE_VERSION } from '../../../version'; +import { DEFAULT_IDS, DEFAULT_MAX_TIMEOUT_SECONDS, X402_VERSION } from './constants'; +import { createSvmFeePayerSource } from './fee-payer'; +import { createHealthCheck } from './health'; +import { resolveOffers } from './offers'; +import { createRequirementBuilders } from './requirement'; +import { createSettle } from './settle'; +import type { + EvmOffer, + ProviderContext, + X402MultiOfferProviderOptions, + X402OfferOptions, + X402ProviderOptions, +} from './types'; +import { createVerify } from './verify'; + +export { DEFAULT_IDS } from './constants'; +export type { X402MultiOfferProviderOptions, X402OfferOptions, X402ProviderOptions } from './types'; + +export function createX402PaymentProvider( + options: X402ProviderOptions | X402MultiOfferProviderOptions, +): PaymentProvider { + const inputs: readonly X402OfferOptions[] = 'offers' in options ? options.offers : [options]; + if (inputs.length === 0) { + throw new CommerceError('CONFIG_INVALID', 'x402 provider: "offers" is empty'); + } + // The NEAR facilitator refuses every payment under any other value + if ( + options.maxTimeoutSeconds !== undefined && + (!Number.isInteger(options.maxTimeoutSeconds) || options.maxTimeoutSeconds <= 0) + ) { + throw new CommerceError( + 'CONFIG_INVALID', + `x402 provider: maxTimeoutSeconds must be a positive integer (got ${options.maxTimeoutSeconds})`, + ); + } + const { + offers: defaultOffers, + resourceOffers, + pool: offers, + deploymentMode, + remoteBinding, + } = resolveOffers(inputs, options); + const offersFor = (resourceId: string) => resourceOffers.get(resourceId) ?? defaultOffers; + const severalOffers = [defaultOffers, ...resourceOffers.values()].some((list) => list.length > 1); + + const clock = options.clock ?? systemClock; + const ids = options.ids ?? DEFAULT_IDS; + const logger = options.logger ?? NOOP_LOGGER; + const maxTimeoutSeconds = options.maxTimeoutSeconds ?? DEFAULT_MAX_TIMEOUT_SECONDS; + + const evmOffers = offers.filter((offer): offer is EvmOffer => offer.family === 'evm'); + const hasSvm = offers.some((offer) => offer.family === 'svm'); + const hasNear = offers.some((offer) => offer.family === 'near'); + const descriptor: AdapterDescriptor = { + name: 'x402', + kind: 'payment', + implementationVersion: PACKAGE_VERSION, + supportedSpec: `x402/v${X402_VERSION} scheme=exact family=${[ + ...(evmOffers.length > 0 ? ['eip155'] : []), + ...(hasSvm ? ['solana'] : []), + ...(hasNear ? ['near'] : []), + ].join(',')}`, + capabilities: [ + ...(evmOffers.length > 0 ? ['exact-evm'] : []), + ...(hasSvm ? ['exact-svm'] : []), + ...(hasNear ? ['exact-near'] : []), + `${options.facilitator.mode}-facilitator`, + ...(evmOffers.some((offer) => offer.method === 'eip3009') ? ['eip-3009'] : []), + ...(evmOffers.some((offer) => offer.method === 'permit2') ? ['permit2'] : []), + 'caip-2', + `mode=${deploymentMode}`, + 'authorization-flow', + 'upfront-flow', + ...(severalOffers ? ['multiple-offers'] : []), + ], + status: 'stable', + unsupported: [ + 'permit2 gas sponsoring extensions', + 'upto scheme', + 'batch-settlement scheme', + 'auth-capture scheme', + ], + }; + + // Loaded only with an offer of their family, so no deployment needs the + // peers of a family it does not offer + const svmModule = hasSvm + ? loadOptionalModule(() => import('../svm'), 'Solana offers need @x402/svm and @solana/kit') + : undefined; + const nearModule = hasNear + ? loadOptionalModule(() => import('../near'), 'NEAR offers need @x402/near') + : undefined; + const svm = async () => { + if (!svmModule) + throw new CommerceError('INTERNAL_ERROR', 'x402 provider: no Solana offer is configured'); + return svmModule; + }; + const near = async () => { + if (!nearModule) + throw new CommerceError('INTERNAL_ERROR', 'x402 provider: no NEAR offer is configured'); + return nearModule; + }; + + const ctx: ProviderContext = { + options, + offers, + offersFor, + deploymentMode, + remoteBinding, + clock, + ids, + logger, + maxTimeoutSeconds, + svm, + near, + }; + const { createRequirement, selectRequirement } = createRequirementBuilders( + ctx, + createSvmFeePayerSource(ctx), + ); + const verify = createVerify(ctx); + const settle = createSettle(ctx); + const health = createHealthCheck(ctx); + + return { + name: 'x402', + descriptor, + createRequirement, + // A single offer needs no selection + ...(severalOffers ? { selectRequirement } : {}), + verify, + settle, + health, + }; +} + +/** + * Loads a family's inspection module, refusing with a clear error when its + * optional peers are missing + */ +function loadOptionalModule(load: () => Promise, missing: string): Promise { + const loading = load().catch((cause: unknown) => { + throw new CommerceError('PAYMENT_PROVIDER_UNAVAILABLE', `x402 provider: ${missing}`, { + cause, + }); + }); + // verify() and health() report the failure; nothing else awaits it + loading.catch(() => {}); + return loading; +} diff --git a/src/payments/x402/provider/offers.ts b/src/payments/x402/provider/offers.ts new file mode 100644 index 0000000..1dc62c7 --- /dev/null +++ b/src/payments/x402/provider/offers.ts @@ -0,0 +1,244 @@ +// Builds each offer from its options: validation, deployment guardrails and the facilitator binding +import type { Network } from '@x402/core/types'; +import { isAddress } from 'viem'; +import { CommerceError } from '../../../core'; +import { normalizeAddress, sameAddress } from '../addresses'; +import { + createLocalFacilitatorClient, + createLocalPublicClient, + type LocalFacilitatorClient, +} from '../chain'; +import { assertDevKeyIsLocalOnly, assertPayToIsNotDevAddress, describeRpc } from '../dev-key-guard'; +import { createRemoteFacilitatorBinding, type FacilitatorBinding } from '../facilitator'; +import { resolveX402Deployment } from '../guardrails'; +import { createLocalFacilitatorBinding } from '../local-facilitator'; +import { type DeploymentMode, findX402Network } from '../networks'; +import { HEALTH_TIMEOUT_MS } from './constants'; +import { isProviderUnavailableError } from './reasons'; +import type { + Offer, + X402MultiOfferProviderOptions, + X402OfferOptions, + X402ProviderOptions, +} from './types'; + +/** + * Resolves provider and resource offers at construction, rejecting + * misconfiguration before paid requests. + */ +export function resolveOffers( + inputs: readonly X402OfferOptions[], + options: X402ProviderOptions | X402MultiOfferProviderOptions, +): { + readonly offers: readonly Offer[]; + readonly resourceOffers: ReadonlyMap; + /** Every distinct offer, the provider's first */ + readonly pool: readonly Offer[]; + readonly deploymentMode: DeploymentMode; + readonly remoteBinding: FacilitatorBinding | undefined; +} { + // Share one remote binding, created after the first offer passes validation + let remoteBinding: FacilitatorBinding | undefined; + + let mode: DeploymentMode | undefined; + // An offer that several lists repeat is built, and health-checked, once + const built = new Map(); + const resolveList = ( + list: readonly X402OfferOptions[], + field: (index: number, name: string) => string, + ): readonly Offer[] => { + const seen = new Set(); + return list.map((input, index): Offer => { + const at = (name: string) => field(index, name); + // The network decides how the other fields are read. An unknown one + // fails in `resolveX402Deployment` below. + const known = findX402Network(input.network, options.customNetworks); + if (known !== undefined && known.family !== 'evm') { + const shape = + known.family === 'svm' ? 'a Solana address (base58, 32 bytes)' : 'a NEAR account ID'; + for (const name of ['asset', 'payTo'] as const) { + if (normalizeAddress(known, input[name]) === undefined) { + throw new CommerceError('CONFIG_INVALID', `x402 provider: ${at(name)} is not ${shape}`); + } + } + if (input.assetTransferMethod !== undefined) { + throw new CommerceError( + 'CONFIG_INVALID', + `x402 provider: ${at('assetTransferMethod')} applies to EVM networks only`, + ); + } + } else { + // Neither address is echoed: a swapped variable could contain a key. + // Check mixed-case EIP-55 checksums here; config load checks only shape. + if (!isAddress(input.asset)) { + throw new CommerceError( + 'CONFIG_INVALID', + `x402 provider: ${at('asset')} is not a valid EVM address (0x and 40 hex characters; a mixed-case address needs a valid EIP-55 checksum)`, + ); + } + if (!isAddress(input.payTo)) { + throw new CommerceError( + 'CONFIG_INVALID', + `x402 provider: ${at('payTo')} is not a valid EVM address (0x and 40 hex characters; a mixed-case address needs a valid EIP-55 checksum)`, + ); + } + // In every facilitator mode. `resolveX402Deployment` below also refuses + // a dev payTo on any non-local deployment, whatever the RPC host. + assertPayToIsNotDevAddress(input.rpcUrl, input.payTo); + // The buyer signs the token's EIP-712 domain under EIP-3009, so the + // requirement must name it. Permit2 has a domain of its own. + if ( + (input.assetTransferMethod ?? 'eip3009') === 'eip3009' && + (!input.assetName || !input.assetVersion) + ) { + throw new CommerceError( + 'CONFIG_INVALID', + `x402 provider: an EIP-3009 offer needs ${at('assetName')} and ${at('assetVersion')}, the token's EIP-712 domain`, + ); + } + } + if (!Number.isInteger(input.assetDecimals) || input.assetDecimals < 0) { + throw new CommerceError( + 'CONFIG_INVALID', + `x402 provider: ${at('assetDecimals')} must be a non-negative integer`, + ); + } + // The chain id is signed into the buyer's EIP-712 domain, so a network it + // cannot be resolved from fails here, never at request time + const deployment = resolveX402Deployment({ + network: input.network, + ...(options.customNetworks !== undefined ? { customNetworks: options.customNetworks } : {}), + payTo: input.payTo, + asset: input.asset, + ...(input.assetName !== undefined ? { assetName: input.assetName } : {}), + ...(input.assetVersion !== undefined ? { assetVersion: input.assetVersion } : {}), + facilitator: options.facilitator, + ...(options.allowMainnet !== undefined ? { allowMainnet: options.allowMainnet } : {}), + ...(options.allowUnauthenticatedFacilitator !== undefined + ? { allowUnauthenticatedFacilitator: options.allowUnauthenticatedFacilitator } + : {}), + }); + const profile = deployment.profile; + // Refuse offers mixing local, testnet and mainnet deployment modes + if (mode !== undefined && deployment.mode !== mode) { + throw new CommerceError( + 'CONFIG_INVALID', + `x402 provider: offers mix ${mode} and ${deployment.mode} networks; every offer must be on a ${mode} network`, + ); + } + mode = deployment.mode; + // EVM addresses compare case-insensitively; Solana and NEAR exactly + const comparable = (value: string) => + profile.family === 'evm' ? value.toLowerCase() : value; + const key = `${input.network}|${comparable(input.asset)}|${comparable(input.payTo)}`; + if (seen.has(key)) { + throw new CommerceError( + 'CONFIG_INVALID', + `x402 provider: ${at('network')} repeats an earlier offer of the same asset to the same payTo on ${input.network}`, + ); + } + seen.add(key); + const identity = [ + key, + input.assetTransferMethod ?? 'eip3009', + input.assetName, + input.assetVersion, + input.assetDecimals, + input.rpcUrl, + ].join('|'); + const existing = built.get(identity); + if (existing !== undefined) return existing; + + // Construct the signer before requests can reserve replay keys, so an + // unusable key fails provider construction. + let binding: FacilitatorBinding; + if (options.facilitator.mode === 'local') { + assertDevKeyIsLocalOnly(input.rpcUrl, options.facilitator.signerPrivateKey); + let client: LocalFacilitatorClient; + try { + client = createLocalFacilitatorClient( + input.rpcUrl, + options.facilitator.signerPrivateKey as `0x${string}`, + ); + } catch (cause) { + throw new CommerceError( + 'CONFIG_INVALID', + 'x402 provider: could not construct a local facilitator signer from ' + + 'facilitator.signerPrivateKey. It must be a valid 32-byte hex private key.', + { cause }, + ); + } + binding = createLocalFacilitatorBinding(client, input.network, isProviderUnavailableError); + } else { + remoteBinding ??= createRemoteFacilitatorBinding({ + url: options.facilitator.url, + auth: options.facilitator.auth, + }); + binding = remoteBinding; + } + + // health() reads the chain itself, with a timeout. Verification and + // settlement go through the facilitator instead. Health details can reach + // logs, so they name the RPC by origin. + const base = { + network: input.network as Network, + assetDecimals: input.assetDecimals, + rpcUrl: input.rpcUrl, + rpcOrigin: describeRpc(input.rpcUrl), + binding, + }; + const offer: Offer = + profile.family === 'svm' + ? { ...base, family: 'svm', profile, asset: input.asset, payTo: input.payTo } + : profile.family === 'near' + ? { ...base, family: 'near', profile, asset: input.asset, payTo: input.payTo } + : { + ...base, + family: 'evm', + profile, + method: input.assetTransferMethod ?? 'eip3009', + asset: input.asset as `0x${string}`, + assetName: input.assetName, + assetVersion: input.assetVersion, + payTo: input.payTo as `0x${string}`, + healthClient: createLocalPublicClient( + input.rpcUrl, + HEALTH_TIMEOUT_MS, + profile.chainId, + ), + }; + built.set(identity, offer); + return offer; + }); + }; + + const offers = resolveList(inputs, (index, name) => + inputs.length === 1 ? `"${name}"` : `"offers[${index}].${name}"`, + ); + const resourceOffers = new Map( + Object.entries(options.resourceOffers ?? {}).map(([id, list]) => { + if (list.length === 0) { + throw new CommerceError('CONFIG_INVALID', `x402 provider: "resourceOffers.${id}" is empty`); + } + return [id, resolveList(list, (index, name) => `"resourceOffers.${id}[${index}].${name}"`)]; + }), + ); + return { + offers, + resourceOffers, + pool: [...built.values()], + deploymentMode: mode as DeploymentMode, + remoteBinding, + }; +} + +/** + * Uses the network's default token symbol when paying that asset; otherwise + * uses the resource's currency, the gateway's only label for another token. + */ +export function offerCurrency(offer: Offer, quoted: string): string { + const canonical = offer.profile.canonicalAsset; + return canonical !== undefined && sameAddress(offer.profile, offer.asset, canonical.address) + ? canonical.symbol + : quoted; +} diff --git a/src/payments/x402/provider/reasons.ts b/src/payments/x402/provider/reasons.ts new file mode 100644 index 0000000..d52b72f --- /dev/null +++ b/src/payments/x402/provider/reasons.ts @@ -0,0 +1,144 @@ +/** + * How the provider reads a facilitator's verify and settle results: which + * reasons are verdicts, which leave the outcome unknown, and which errors mean + * no verdict was obtained + */ +import { + BaseError, + ContractFunctionRevertedError, + HttpRequestError, + TimeoutError, + WaitForTransactionReceiptTimeoutError, +} from 'viem'; +import { redactedErrorText } from '../../../core/errors'; + +// `SettleResponse.errorReason` the SDK uses for "broadcast, not confirmed" +export const SETTLEMENT_PENDING_REASON = 'settlement_pending'; +/** + * Each family's catch-all for a throw from the broadcast call, and its reason + * for a transaction that failed on chain. Only the second carries a + * transaction reference. A failure the SDK recognizes before the broadcast gets + * its own reason instead. The EVM Permit2 path appends the thrown message as + * `: `. On NEAR the second is a failed inner `ft_transfer` receipt. + */ +const TRANSACTION_FAILED_REASONS = [ + 'invalid_exact_evm_transaction_failed', + 'invalid_exact_svm_transaction_failed', + 'settlement_failed', +]; +export const isTransactionFailedReason = (reason: string | undefined): boolean => + reason !== undefined && + TRANSACTION_FAILED_REASONS.some((known) => reason === known || reason.startsWith(`${known}:`)); +/** + * The one failure reason per family that names a transaction and proves + * nothing moved: a reverted EVM receipt, a Solana transaction that failed on + * chain, or a failed inner NEAR `ft_transfer` receipt. Any other failure that + * names a transaction may have moved funds. The EVM SDK, for one, reports a + * mined transfer whose `Transfer` log it cannot match as a failure with the hash. + */ +export const ON_CHAIN_FAILURE_REASONS: Readonly> = { + evm: 'invalid_exact_evm_transaction_failed', + svm: 'invalid_exact_svm_transaction_failed', + near: 'settlement_failed', +}; +/** + * Solana outcomes that leave the payment unknown: the facilitator is already + * settling the same transaction for another request, or it confirmed the + * transaction but could not yet see the transfer + */ +export const UNKNOWN_OUTCOME_REASONS: ReadonlySet = new Set([ + 'duplicate_settlement', + 'post_settlement_transfer_not_confirmed', +]); +/** + * Facilitator configuration and chain-read failures yield no payment verdict, + * so they count as provider failures rather than buyer refusals. The NEAR SDK + * returns its reasons before submission. The Solana ones compare the offer the + * gateway sent with the facilitator's own fee payers, or with the buyer's + * echo, which the gateway already matched. + */ +export const NO_VERDICT_REASONS: ReadonlySet = new Set([ + 'unexpected_verify_error', + 'invalid_exact_near_no_relayer_configured', + 'invalid_exact_near_current_block_height_unavailable', + 'invalid_exact_near_access_key_lookup_failed', + 'invalid_exact_near_account_lookup_failed', + 'invalid_exact_near_token_account_lookup_failed', + 'invalid_exact_near_balance_check_failed', + 'invalid_exact_near_storage_check_failed', + 'invalid_exact_svm_payload_missing_fee_payer', + 'invalid_exact_svm_fee_payer_not_managed_by_facilitator', + 'invalid_exact_svm_unsupported_scheme', + 'invalid_exact_svm_network_mismatch', +]); +/** + * NEAR settle failures that are verdicts. The NEAR specification leaves the + * reason implementation-specific and the transaction empty, even after a + * submission, so only the SDK's verification reasons, returned before it + * submits, and a failed inner `ft_transfer` with its hash prove nothing moved. + */ +const NEAR_PRE_SUBMISSION_REASONS: ReadonlySet = new Set([ + 'invalid_x402_version', + 'unsupported_scheme', + 'invalid_network', + 'insufficient_funds', + 'verification_failed', +]); +export const isNearVerdict = (reason: string | undefined): boolean => + reason !== undefined && + (reason === 'settlement_failed' || + reason.startsWith('invalid_exact_near_') || + NEAR_PRE_SUBMISSION_REASONS.has(reason)); +/** + * `invalidReason`/`errorReason` are `z.string()` in the SDK schema, with no + * length or charset bound. They reach the buyer's error, the persisted events + * and the merchant's ledger. A remote facilitator controls these values, so + * they are treated as untrusted input rather than diagnostic text. + */ +// The SDKs' longest reason, a Solana compute-budget refusal, has 85 characters +const MAX_REASON_LENGTH = 128; +const REASON_SHAPE = /^[a-z0-9_.-]+$/i; + +export function sanitizeReason(reason: string | undefined, fallback: string): string { + if (reason === undefined) return fallback; + const trimmed = reason.trim(); + if (trimmed.length === 0 || trimmed.length > MAX_REASON_LENGTH) return fallback; + return REASON_SHAPE.test(trimmed) ? trimmed : fallback; +} + +// viem wraps every failed contract call in ContractFunctionExecutionError, so +// only the cause chain can show a revert. A revert alone does not prove nothing +// moved, because viem also reports an RPC error on a send as one; settle() +// relies on the SDK throwing only before the broadcast. +export function isOnChainRevertError(err: unknown): boolean { + return ( + err instanceof BaseError && err.walk((e) => e instanceof ContractFunctionRevertedError) !== null + ); +} + +/** + * The `instanceof` checks cover what the pinned viem HTTP transport throws + * (`HttpRequestError` for every fetch failure, including connection refused, + * or `TimeoutError`) and what `waitForTransactionReceipt` throws when it stops + * polling after a broadcast. Message matching is only a fallback for errors + * outside those classes, such as a raw undici error, because message text can + * change with any viem or undici upgrade. + */ +export function isProviderUnavailableError(err: unknown): boolean { + if ( + err instanceof HttpRequestError || + err instanceof TimeoutError || + err instanceof WaitForTransactionReceiptTimeoutError + ) { + return true; + } + const message = redactedErrorText(err).toLowerCase(); + return ( + message.includes('econnrefused') || + message.includes('fetch failed') || + message.includes('enotfound') || + message.includes('etimedout') || + message.includes('timed out') || + message.includes('network error') + ); +} diff --git a/src/payments/x402/provider/requirement.ts b/src/payments/x402/provider/requirement.ts new file mode 100644 index 0000000..2c0beb8 --- /dev/null +++ b/src/payments/x402/provider/requirement.ts @@ -0,0 +1,182 @@ +// The challenge a resource answers with, and the offer a proof selects from it +import type { PaymentRequired, PaymentRequirements } from '@x402/core/types'; +import type { PaymentContext, PaymentRequirement, PaymentVerificationContext } from '../../../core'; +import { + REUSE_REJECTED_AUTHORIZATION_METADATA_KEY, + SETTLE_AFTER_BACKEND_METADATA_KEY, +} from '../../../core/execution/settlement-order'; +import { sameAddress } from '../addresses'; +import { parseCanonicalAmount } from '../amount'; +import type { X402Network } from '../networks'; +import { decodePaymentSubmission } from '../payload'; +import { X402_DEFAULT_PAYMENT_FLOW } from '../payment-flow'; +import { DEFAULT_MIME_TYPE, X402_PROTOCOL_VERSION, X402_VERSION } from './constants'; +import type { ProviderContext, SvmOffer } from './types'; + +// Requirement metadata naming the offer `selectRequirement` chose, by index +export const SELECTED_OFFER_KEY = 'x402.offer'; + +// Use the selected or sole offer; multi-offer challenges require selection +export function offerIndexOf(requirement: PaymentRequirement): number | undefined { + const selected = requirement.metadata?.[SELECTED_OFFER_KEY]; + if (typeof selected === 'number') return selected; + // An empty list falls through to 'missing_payment_requirements' + return requirement.challenge.accepts.length <= 1 ? 0 : undefined; +} + +/** + * Match echoed terms against the offer. Extra client keys and + * `maxTimeoutSeconds` do not matter; EVM addresses ignore case, Solana's do + * not. Compare effective payment flows so an upfront proof cannot pay a default + * authorization offer. + */ +export function acceptedMatchesOffer( + offered: PaymentRequirements, + accepted: PaymentRequirements, + network: X402Network | undefined, +): boolean { + if (!network) return false; + const same = (a: string, b: string) => sameAddress(network, a, b); + const offeredExtra = offered.extra ?? {}; + const acceptedExtra = accepted.extra ?? {}; + const flow = (extra: Record) => + extra['paymentFlow'] ?? X402_DEFAULT_PAYMENT_FLOW; + return ( + accepted.scheme === offered.scheme && + accepted.network === offered.network && + accepted.amount === offered.amount && + same(accepted.asset, offered.asset) && + same(accepted.payTo, offered.payTo) && + Object.entries(offeredExtra).every(([key, value]) => acceptedExtra[key] === value) && + flow(acceptedExtra) === flow(offeredExtra) + ); +} + +/** Builds the provider's `createRequirement` and `selectRequirement` */ +export function createRequirementBuilders( + ctx: ProviderContext, + svmFeePayer: (offer: SvmOffer) => Promise, +): { + readonly createRequirement: (context: PaymentContext) => Promise; + readonly selectRequirement: (context: PaymentVerificationContext) => Promise; +} { + const { options, offersFor, clock, ids, maxTimeoutSeconds } = ctx; + + async function createRequirement(context: PaymentContext): Promise { + const offers = offersFor(context.resource.id); + const paymentFlow = + options.resourcePaymentFlows?.[context.resource.id] ?? + options.paymentFlow ?? + X402_DEFAULT_PAYMENT_FLOW; + + // x402 requires an explicit non-default flow. Omit the default + // `authorization` flow here. + const flow = paymentFlow === 'upfront' ? { paymentFlow } : {}; + const accepts: PaymentRequirements[] = await Promise.all( + offers.map(async (offer) => ({ + scheme: 'exact', + network: offer.network, + asset: offer.asset, + amount: parseCanonicalAmount(context.amount, offer.assetDecimals).toString(), + payTo: offer.payTo, + maxTimeoutSeconds, + // EIP-3009 needs `name`/`version` for a shared EIP-712 domain without + // a token `version()` call; Permit2 uses its own domain. EVM clients + // sign the named `assetTransferMethod`. Solana needs the facilitator's + // fee payer; NEAR needs no scheme-specific extra because the + // facilitator chooses its relayer. + extra: + offer.family === 'svm' + ? { feePayer: await svmFeePayer(offer), ...flow } + : offer.family === 'near' + ? { ...flow } + : { + ...(offer.assetName !== undefined ? { name: offer.assetName } : {}), + ...(offer.assetVersion !== undefined ? { version: offer.assetVersion } : {}), + assetTransferMethod: offer.method, + ...flow, + }, + })), + ); + const single = offers.length === 1 ? offers[0] : undefined; + + // The v2 challenge document as sent on the wire. The HTTP adapter + // base64-encodes it into `PAYMENT-REQUIRED` and MCP passes it through, so + // both surfaces offer the same challenge. + // + // `resource.url` is outside the EIP-3009 signature. A single offer puts its + // network and payee in it, so the buyer's echo identifies the offer. With + // several, the URL stays the same whichever one the buyer pays. + const resourceId = encodeURIComponent(context.resource.id); + const envelope: PaymentRequired = { + x402Version: X402_VERSION, + resource: { + url: single + ? `resource://agent-commerce/${encodeURIComponent(single.network)}/${single.payTo}/resources/${resourceId}` + : `resource://agent-commerce/resources/${resourceId}`, + description: context.resource.description ?? context.resource.name, + mimeType: DEFAULT_MIME_TYPE, + }, + accepts, + }; + + const expiresAt = new Date(clock.now().getTime() + maxTimeoutSeconds * 1000).toISOString(); + + return { + id: ids.next('payreq'), + requestId: context.requestId, + resourceId: context.resource.id, + provider: 'x402', + amount: context.amount, + currency: context.currency, + // Several offers name several destinations; selection fills these in + ...(single + ? { destination: single.payTo, network: single.network, asset: single.asset } + : {}), + expiresAt, + challenge: { + provider: 'x402', + version: X402_PROTOCOL_VERSION, + accepts: accepts as unknown as Record[], + envelope: envelope as unknown as Record, + }, + metadata: { + [SETTLE_AFTER_BACKEND_METADATA_KEY]: paymentFlow === 'authorization', + // The SDK server keeps no record of a payload whose handler or + // settlement failed and verifies it again; the chain spends it once + [REUSE_REJECTED_AUTHORIZATION_METADATA_KEY]: true, + }, + }; + } + + /** + * Selects the unique offer matching `accepted`, leaving zero or multiple + * matches unchanged for `verify()` to reject. Selection identifies the + * claimed offer; verification still validates the proof. + */ + async function selectRequirement( + context: PaymentVerificationContext, + ): Promise { + const { requirement, submission } = context; + const offered = requirement.challenge.accepts as unknown as PaymentRequirements[]; + if (offered.length < 2) return requirement; + const offers = offersFor(requirement.resourceId); + const payload = decodePaymentSubmission(submission.payload); + if (!payload) return requirement; + const matches = offered.flatMap((offer, index) => + acceptedMatchesOffer(offer, payload.accepted, offers[index]?.profile) ? [index] : [], + ); + const index = matches.length === 1 ? matches[0] : undefined; + const offer = index === undefined ? undefined : offers[index]; + if (index === undefined || !offer) return requirement; + return { + ...requirement, + destination: offer.payTo, + network: offer.network, + asset: offer.asset, + metadata: { ...requirement.metadata, [SELECTED_OFFER_KEY]: index }, + }; + } + + return { createRequirement, selectRequirement }; +} diff --git a/src/payments/x402/provider/settle.ts b/src/payments/x402/provider/settle.ts new file mode 100644 index 0000000..ed474c0 --- /dev/null +++ b/src/payments/x402/provider/settle.ts @@ -0,0 +1,249 @@ +// Settles a verified proof and classifies the facilitator's answer +import type { PaymentRequirements, SettleResponse } from '@x402/core/types'; +import type { PaymentResult, PaymentSettlementContext } from '../../../core'; +import { CommerceError } from '../../../core'; +import { redactedErrorText } from '../../../core/errors'; +import { isTransactionReference } from '../addresses'; +import { decodePaymentSubmission, forFacilitator } from '../payload'; +import { + isNearVerdict, + isOnChainRevertError, + isProviderUnavailableError, + isTransactionFailedReason, + NO_VERDICT_REASONS, + ON_CHAIN_FAILURE_REASONS, + SETTLEMENT_PENDING_REASON, + sanitizeReason, + UNKNOWN_OUTCOME_REASONS, +} from './reasons'; +import { offerIndexOf } from './requirement'; +import { readTransfer } from './transfer'; +import type { ProviderContext } from './types'; + +/** Builds the provider's `settle`, which throws unless handed a successful verification */ +export function createSettle( + ctx: ProviderContext, +): (context: PaymentSettlementContext) => Promise { + const { offersFor, clock, logger } = ctx; + + async function settle(context: PaymentSettlementContext): Promise { + const { requirement, submission, verification } = context; + + if (verification.status !== 'verified') { + throw new CommerceError( + 'PAYMENT_INVALID', + 'x402 provider: settle() called without a successful verify()', + ); + } + + const index = offerIndexOf(requirement); + const offer = index === undefined ? undefined : offersFor(requirement.resourceId)[index]; + const requirements = + index === undefined + ? undefined + : (requirement.challenge.accepts[index] as PaymentRequirements | undefined); + if (!offer || !requirements) { + throw new CommerceError( + 'PAYMENT_INVALID', + 'x402 provider: settle() found no payment requirements to settle against', + ); + } + + const payload = decodePaymentSubmission(submission.payload); + const transfer = payload && offer.family === 'evm' ? readTransfer(payload, offer) : undefined; + if (!payload || typeof transfer === 'string') { + throw new CommerceError( + 'PAYMENT_INVALID', + 'x402 provider: settle() received an undecodable payment payload', + ); + } + const reference = (value: string) => isTransactionReference(offer.profile, value); + + const rejectedSettlement = (rejectionReason: string): PaymentResult => ({ + status: 'rejected', + provider: 'x402', + amount: verification.amount, + currency: verification.currency, + ...(verification.network !== undefined ? { network: verification.network } : {}), + ...(verification.asset !== undefined ? { asset: verification.asset } : {}), + ...(verification.replayKey !== undefined ? { replayKey: verification.replayKey } : {}), + rejectionReason, + }); + + const scope = offer.binding.open(); + let sdkResult: SettleResponse; + try { + sdkResult = await scope.settle(forFacilitator(payload), requirements); + } catch (err) { + if (isProviderUnavailableError(err) || scope.transportFailed()) { + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + `x402 provider: ${offer.binding.kind} facilitator unreachable during settle()`, + { cause: err }, + ); + } + // The pinned SDK returns a result for every failure from gas estimation + // on, so a throw comes from its checks before anything is sent. A revert + // there is a real rejection, which releases any mandate the pipeline + // holds for the purchase. Any other throw goes back as an unavailable + // provider, which the pipeline records as `settlement-uncertain`, never + // as a rejection blamed on the buyer. + if (!isOnChainRevertError(err)) { + logger.warn( + { err: redactedErrorText(err) }, + 'x402 settle(): settlement failed with an unclassified error; outcome unknown', + ); + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + 'x402 provider: settlement failed with an unclassified error; outcome unknown', + { cause: err }, + ); + } + logger.warn( + { err: redactedErrorText(err), rejectionReason: 'transaction_reverted' }, + 'x402 settle(): settlement transaction reverted on chain', + ); + return rejectedSettlement('transaction_reverted'); + } + + // verify() established the payer. The Solana scheme's settlement example + // names the fee payer there, while x402 v2 defines the payer's wallet. + const payer = verification.payer ?? sdkResult.payer; + + if (!sdkResult.success) { + // "Broadcast, never confirmed" is not "did not happen": the transfer may + // be on-chain, so it throws as an unavailable provider carrying the hash, + // and the pipeline records the attempt `settlement-uncertain`, not + // `rejected`. The SDK's catch-all without a hash is treated the same way, + // because its throw may have followed a broadcast and viem reports an + // RPC error on the send as a revert. + if (isTransactionFailedReason(sdkResult.errorReason) && !reference(sdkResult.transaction)) { + // The appended message can carry an RPC URL + logger.warn( + { reportedReason: redactedErrorText(sdkResult.errorReason) }, + 'x402 settle(): the broadcast failed without a transaction hash; outcome unknown', + ); + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + 'x402 provider: settlement failed with an unclassified error; outcome unknown', + ); + } + // These NEAR SDK checks fail before submission. Release the attempt so + // a retry using the same access-key nonce can reserve it again. + if ( + offer.family === 'near' && + NO_VERDICT_REASONS.has(sdkResult.errorReason ?? '') && + !sdkResult.transaction + ) { + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + 'x402 provider: the facilitator could not check the payment before submitting it', + { + details: { + settlementNotSubmitted: true, + reason: sanitizeReason(sdkResult.errorReason, 'facilitator_unavailable'), + }, + }, + ); + } + // A broadcast the SDK could not confirm nor record for a retry comes + // back as its catch-all with the transaction, and a message starting + // `settlement_pending` + if ( + sdkResult.errorReason === SETTLEMENT_PENDING_REASON || + sdkResult.errorMessage?.startsWith(SETTLEMENT_PENDING_REASON) === true || + UNKNOWN_OUTCOME_REASONS.has(sdkResult.errorReason ?? '') || + NO_VERDICT_REASONS.has(sdkResult.errorReason ?? '') || + scope.transportFailed() + ) { + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + 'x402 provider: settlement was broadcast but could not be confirmed', + { + ...(sdkResult.transaction + ? { details: { transactionHash: sdkResult.transaction } } + : {}), + }, + ); + } + // Only the family's on-chain failure proves a named transaction moved + // nothing. Any other reason may follow a transfer that landed. + if ( + sdkResult.transaction && + sdkResult.errorReason !== ON_CHAIN_FAILURE_REASONS[offer.family] + ) { + logger.warn( + { reportedReason: redactedErrorText(sdkResult.errorReason ?? '') }, + 'x402 settle(): the facilitator reported a failure with a transaction; outcome unknown', + ); + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + 'x402 provider: the facilitator reported a failed settlement with a transaction; outcome unknown', + { details: { transactionHash: sdkResult.transaction } }, + ); + } + // A `settlement_failed` that reaches here carries its hash + if (offer.family === 'near' && !isNearVerdict(sdkResult.errorReason)) { + logger.warn( + { reportedReason: redactedErrorText(sdkResult.errorReason ?? '') }, + 'x402 settle(): NEAR facilitator failure reason is not a verdict; outcome unknown', + ); + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + 'x402 provider: settlement failed with an unclassified error; outcome unknown', + ); + } + if (sdkResult.errorReason !== undefined) { + logger.debug( + { reportedReason: redactedErrorText(sdkResult.errorReason) }, + 'x402 settle(): facilitator failure reason', + ); + } + return { + ...rejectedSettlement(sanitizeReason(sdkResult.errorReason, 'settlement_failed')), + network: sdkResult.network, + ...(payer !== undefined ? { payer } : {}), + // The failed transaction, which the facilitator paid for + ...(sdkResult.transaction ? { externalReference: sdkResult.transaction } : {}), + }; + } + + // A success names the offer's network and a transaction in its family's + // form; anything else unlocks nothing, and the outcome stays unknown + if (!reference(sdkResult.transaction) || sdkResult.network !== offer.network) { + logger.warn( + { network: sdkResult.network }, + 'x402 settle(): the facilitator reported a success it did not identify; outcome unknown', + ); + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + 'x402 provider: the facilitator reported a settlement without a valid transaction; outcome unknown', + ); + } + + return { + status: 'settled', + provider: 'x402', + externalReference: sdkResult.transaction, + ...(payer !== undefined ? { payer } : {}), + payee: requirements.payTo, + amount: verification.amount, + currency: verification.currency, + network: sdkResult.network, + ...(verification.asset !== undefined ? { asset: verification.asset } : {}), + ...(verification.replayKey !== undefined ? { replayKey: verification.replayKey } : {}), + settledAt: clock.nowIso(), + // `amount` is the display value; x402's settlement response reports base + // units. verify() matched an EVM authorization's value to the offer. + metadata: { + amountBaseUnits: transfer?.value ?? requirements.amount, + // The resource's own price, when it names a currency other than the token + ...(verification.currency !== requirement.currency + ? { quote: { amount: requirement.amount, currency: requirement.currency } } + : {}), + }, + }; + } + + return settle; +} diff --git a/src/payments/x402/provider/transfer.ts b/src/payments/x402/provider/transfer.ts new file mode 100644 index 0000000..26411db --- /dev/null +++ b/src/payments/x402/provider/transfer.ts @@ -0,0 +1,66 @@ +// What an EVM payload authorizes: payer, payee, amount and replay key + +import type { PaymentPayload } from '@x402/core/types'; +import { x402ExactPermit2ProxyAddress } from '@x402/evm'; +import { getAddress, isAddress } from 'viem'; +import { hasEip3009Fields, isExactEvmPayload, isExactPermit2Payload } from '../payload'; +import { computePermit2ReplayKey, computeReplayKey } from '../replay-key'; +import type { EvmOffer } from './types'; + +// What a payload authorizes, read the way the offer's method settles it +export interface Transfer { + readonly from: `0x${string}`; + readonly to: `0x${string}`; + readonly value: string; + readonly replayKey: string; + readonly recipientMismatch: string; + readonly amountMismatch: string; +} + +/** + * Reads the payer, payee, amount and replay key, or returns a refusal reason. + * Enforces the offer's method because the SDK facilitator chooses the method + * from the payload. + */ +export function readTransfer(payload: PaymentPayload, offer: EvmOffer): Transfer | string { + // verify() refuses an `accepted.network` other than the offer's, so this + // chain id is the one the authorization is bound to + const { chainId } = offer.profile; + if (offer.method === 'eip3009') { + if (!isExactEvmPayload(payload)) return 'unsupported_scheme'; + const { authorization } = payload.payload; + const { from, to, value, nonce } = authorization; + if (!hasEip3009Fields(authorization) || !isAddress(from) || !isAddress(to)) { + return 'invalid_payload'; + } + return { + from: getAddress(from), + to: getAddress(to), + value, + replayKey: computeReplayKey({ + chainId, + asset: getAddress(offer.asset), + from: getAddress(from), + nonce, + }), + recipientMismatch: 'invalid_exact_evm_payload_recipient_mismatch', + amountMismatch: 'invalid_exact_evm_payload_authorization_value_mismatch', + }; + } + if (!isExactPermit2Payload(payload)) return 'unsupported_scheme'; + const { from, spender, permitted, witness, nonce } = payload.payload.permit2Authorization; + // Permit2 lets the spender move the permitted tokens anywhere; only the x402 + // proxy binds the transfer to the signed witness recipient + if (getAddress(spender) !== getAddress(x402ExactPermit2ProxyAddress)) { + return 'invalid_permit2_spender'; + } + if (getAddress(permitted.token) !== getAddress(offer.asset)) return 'permit2_token_mismatch'; + return { + from: getAddress(from), + to: getAddress(witness.to), + value: permitted.amount, + replayKey: computePermit2ReplayKey({ chainId, owner: getAddress(from), nonce }), + recipientMismatch: 'invalid_permit2_recipient_mismatch', + amountMismatch: 'permit2_amount_mismatch', + }; +} diff --git a/src/payments/x402/provider/types.ts b/src/payments/x402/provider/types.ts new file mode 100644 index 0000000..d67654c --- /dev/null +++ b/src/payments/x402/provider/types.ts @@ -0,0 +1,143 @@ +// Provider options, the offers resolved from them, and the state the modules share +import type { Network } from '@x402/core/types'; +import type { Clock, IdGenerator, Logger } from '../../../core'; +import type { createLocalPublicClient } from '../chain'; +import type { FacilitatorBinding } from '../facilitator'; +import type { X402FacilitatorConfig } from '../guardrails'; +import type { CustomNetworks, DeploymentMode, X402Network } from '../networks'; +import type { X402AssetTransferMethod } from '../options'; +import type { X402PaymentFlow } from '../payment-flow'; + +/** A single offer and the settings every offer shares */ +export interface X402ProviderOptions extends X402OfferOptions { + readonly maxTimeoutSeconds?: number; + /** + * When the payment settles relative to the backend call. Defaults to + * `authorization`, the x402 default: settle only after the backend succeeds. + * `upfront` settles before the backend call. + */ + readonly paymentFlow?: X402PaymentFlow; + /** Per-resource overrides of `paymentFlow`, keyed by resource id */ + readonly resourcePaymentFlows?: Readonly>; + /** + * Which facilitator verifies and broadcasts. `local` runs one in this process + * against a dev chain, and its `signerPrivateKey` pays gas there, usually + * an Anvil well-known key (LOCAL DEVELOPMENT ONLY - DO NOT FUND). `remote` + * calls an HTTP facilitator, and the gateway then holds no signing key. + */ + readonly facilitator: X402FacilitatorConfig; + /** Must be `true` before anything settles on a mainnet. Never a default */ + readonly allowMainnet?: boolean; + /** Required on mainnet when facilitator auth is `none` */ + readonly allowUnauthenticatedFacilitator?: boolean; + /** + * Offer overrides by resource id, sharing the provider's facilitator and + * settings. Each list replaces the provider's offers for that resource. + */ + readonly resourceOffers?: Readonly>; + /** + * Custom EVM networks keyed by CAIP-2 ID; `network` may select one, and + * its `kind` determines mainnet guardrails + */ + readonly customNetworks?: CustomNetworks; + readonly logger?: Logger; + readonly clock?: Clock; + readonly ids?: IdGenerator; +} + +/** Settlement network, token and merchant payee for one offer */ +export interface X402OfferOptions { + /** + * CAIP-2 network identifier, e.g. `eip155:8453`. An EVM chain id it carries + * is part of the EIP-712 domain the buyer signs, so it is read from this + * value rather than defaulted. + */ + readonly network: string; + /** RPC endpoint of that network, for health checks and a local facilitator */ + readonly rpcUrl: string; + /** ERC-20 contract or Solana mint used for settlement */ + readonly asset: string; + /** EIP-712 domain name of the asset, e.g. 'USDC'; required for EIP-3009 */ + readonly assetName?: string; + /** EIP-712 domain version of the asset, e.g. '2'; required for EIP-3009 */ + readonly assetVersion?: string; + readonly assetDecimals: number; + /** + * Merchant-controlled settlement destination, never a gateway-owned wallet. + * On Solana it is the wallet; the transfer goes to its token account. + */ + readonly payTo: string; + /** + * EVM only: `eip3009` (the default) or `permit2`. A Permit2 buyer approves + * the Permit2 contract for the token beforehand; the gateway never sends + * approvals. + */ + readonly assetTransferMethod?: X402AssetTransferMethod; +} + +/** + * Offers for each paid resource, using one facilitator. The proof selects + * the offer the buyer pays. + */ +export interface X402MultiOfferProviderOptions + extends Omit { + readonly offers: readonly X402OfferOptions[]; +} + +/** Everything one offer needs at request time, resolved and checked at startup */ +export interface OfferBase { + readonly network: Network; + readonly assetDecimals: number; + readonly rpcUrl: string; + readonly rpcOrigin: string; + readonly binding: FacilitatorBinding; +} + +export interface EvmOffer extends OfferBase { + readonly family: 'evm'; + readonly profile: Extract; + readonly method: X402AssetTransferMethod; + readonly asset: `0x${string}`; + readonly assetName: string | undefined; + readonly assetVersion: string | undefined; + readonly payTo: `0x${string}`; + readonly healthClient: ReturnType; +} + +/** Always a remote facilitator: the in-process one serves only the Anvil chain */ +export interface SvmOffer extends OfferBase { + readonly family: 'svm'; + readonly profile: Extract; + readonly asset: string; + readonly payTo: string; +} + +/** Always a remote facilitator, like Solana */ +export interface NearOffer extends OfferBase { + readonly family: 'near'; + readonly profile: Extract; + readonly asset: string; + readonly payTo: string; +} + +export type Offer = EvmOffer | SvmOffer | NearOffer; + +/** What the request-time modules share, resolved once when the provider is built */ +export interface ProviderContext { + readonly options: X402ProviderOptions | X402MultiOfferProviderOptions; + /** Every distinct offer, the provider's and its resources', for health and the descriptor */ + readonly offers: readonly Offer[]; + /** The offers a resource's challenge lists, in order */ + readonly offersFor: (resourceId: string) => readonly Offer[]; + readonly deploymentMode: DeploymentMode; + /** The shared remote binding, when the facilitator is remote */ + readonly remoteBinding: FacilitatorBinding | undefined; + readonly clock: Clock; + readonly ids: IdGenerator; + readonly logger: Logger; + readonly maxTimeoutSeconds: number; + /** The Solana inspection module; rejects when no Solana offer is configured */ + readonly svm: () => Promise; + /** The NEAR inspection module; rejects when no NEAR offer is configured */ + readonly near: () => Promise; +} diff --git a/src/payments/x402/provider/verify.ts b/src/payments/x402/provider/verify.ts new file mode 100644 index 0000000..e319896 --- /dev/null +++ b/src/payments/x402/provider/verify.ts @@ -0,0 +1,213 @@ +// Checks a proof against the offer it pays, then asks the facilitator for a verdict +import type { PaymentRequired, PaymentRequirements, VerifyResponse } from '@x402/core/types'; +import { formatUnits, getAddress } from 'viem'; +import type { PaymentResult, PaymentVerificationContext } from '../../../core'; +import { CommerceError } from '../../../core'; +import { redactedErrorText } from '../../../core/errors'; +import { normalizeAddress, sameAddress } from '../addresses'; +import { declaredX402Version, decodePaymentSubmission, forFacilitator } from '../payload'; +import { X402_VERSION } from './constants'; +import { offerCurrency } from './offers'; +import { isProviderUnavailableError, NO_VERDICT_REASONS, sanitizeReason } from './reasons'; +import { acceptedMatchesOffer, offerIndexOf } from './requirement'; +import { readTransfer, type Transfer } from './transfer'; +import type { ProviderContext } from './types'; + +/** Builds the provider's `verify`, which never moves funds */ +export function createVerify( + ctx: ProviderContext, +): (context: PaymentVerificationContext) => Promise { + const { offersFor, clock, logger, svm, near } = ctx; + + /** + * Checks a submission against the requirement this provider built, never + * against `payload.accepted`, the copy the client echoes back. That copy + * must itself match the offer, or the proof is refused. + * + * The `exact` scheme requires the authorized amount to match exactly, + * including when the buyer offers more. Local refusals use x402 error codes + * where available. + */ + async function verify(context: PaymentVerificationContext): Promise { + const { requirement, submission } = context; + + const rejected = (reason: string): PaymentResult => ({ + status: 'rejected', + provider: 'x402', + amount: requirement.amount, + currency: requirement.currency, + ...(requirement.network !== undefined ? { network: requirement.network } : {}), + ...(requirement.asset !== undefined ? { asset: requirement.asset } : {}), + rejectionReason: reason, + }); + + if (requirement.provider !== 'x402' || requirement.challenge.provider !== 'x402') { + return rejected('wrong_provider'); + } + + // With several offers and none selected, the proof matched no single offer + const index = offerIndexOf(requirement); + if (index === undefined) { + return rejected('invalid_payment_requirements'); + } + const offer = offersFor(requirement.resourceId)[index]; + const requirements = requirement.challenge.accepts[index] as PaymentRequirements | undefined; + if (!offer || !requirements) { + return rejected('missing_payment_requirements'); + } + + // Defense in depth only: the pipeline builds a fresh requirement for every + // request, so this rarely fires. The real expiry is the authorization's + // own, which the facilitator checks. + if ( + requirement.expiresAt !== undefined && + Date.parse(requirement.expiresAt) < clock.now().getTime() + ) { + return rejected('requirement_expired'); + } + + // This provider built the requirement, so the asset always matches in + // normal operation. A corrupted one must still never authorize a transfer + // of a different asset. + if (!sameAddress(offer.profile, requirements.asset, offer.asset)) { + return rejected('wrong_asset'); + } + + const payload = decodePaymentSubmission(submission.payload); + if (!payload) { + const version = declaredX402Version(submission.payload); + return rejected( + typeof version === 'number' && version !== X402_VERSION + ? 'invalid_x402_version' + : 'invalid_payload', + ); + } + if (payload.x402Version !== X402_VERSION) { + return rejected('invalid_x402_version'); + } + // EVM: the authorization names payer, payee and amount. Solana: the + // facilitator reads the transaction, and the gateway takes its identity. + // NEAR: the delegate names the transfer, checked here against the offer. + let transfer: Transfer | undefined; + let replayKey: string; + let derivedPayer: string | undefined; + if (offer.family === 'svm') { + const identity = (await svm()).svmReplayKey(payload.payload, offer.network); + if (typeof identity === 'string') return rejected(identity); + replayKey = identity.replayKey; + } else if (offer.family === 'near') { + const inspected = (await near()).inspectNearPayload(payload.payload, { + network: offer.network, + asset: offer.asset, + payTo: offer.payTo, + amount: requirements.amount, + }); + if (typeof inspected === 'string') return rejected(inspected); + replayKey = inspected.replayKey; + derivedPayer = inspected.payer; + } else { + const read = readTransfer(payload, offer); + if (typeof read === 'string') return rejected(read); + transfer = read; + replayKey = read.replayKey; + derivedPayer = read.from; + } + // Reject a different network before calling the facilitator. NEAR's + // delegate signature itself does not bind the chain. + if (payload.accepted.network !== offer.network) { + return rejected('invalid_network'); + } + // The signature omits flow and resource. Check the echoed offer; + // `resource` is optional, but must match when present. + const offeredUrl = (requirement.challenge.envelope as PaymentRequired | undefined)?.resource + ?.url; + if ( + !acceptedMatchesOffer(requirements, payload.accepted, offer.profile) || + (payload.resource !== undefined && payload.resource.url !== offeredUrl) + ) { + return rejected('invalid_payment_requirements'); + } + let required: bigint; + try { + required = BigInt(requirements.amount); + } catch { + return rejected('invalid_payload'); + } + if (transfer) { + if (transfer.to !== getAddress(offer.payTo)) { + return rejected(transfer.recipientMismatch); + } + let authorizedValue: bigint; + try { + authorizedValue = BigInt(transfer.value); + } catch { + return rejected('invalid_payload'); + } + if (authorizedValue !== required) { + return rejected(transfer.amountMismatch); + } + } + + const scope = offer.binding.open(); + let sdkResult: VerifyResponse; + try { + sdkResult = await scope.verify(forFacilitator(payload), requirements); + } catch (err) { + // A throw means no verdict was obtained, so it is never held against the + // buyer. Only an unclassified one is logged, as it may be an SDK fault. + if (!isProviderUnavailableError(err) && !scope.transportFailed()) { + logger.warn({ err: redactedErrorText(err) }, 'x402 verify(): unexpected SDK error'); + } + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + `x402 provider: ${offer.binding.kind} facilitator returned no verdict during verify()`, + { cause: err }, + ); + } + + if (!sdkResult.isValid) { + // An RPC that never answered is not a payment that failed a check + if (scope.transportFailed() || NO_VERDICT_REASONS.has(sdkResult.invalidReason ?? '')) { + if (sdkResult.invalidReason !== undefined) { + logger.debug( + { reportedReason: redactedErrorText(sdkResult.invalidReason) }, + 'x402 verify(): local facilitator transport failure reason', + ); + } + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + `x402 provider: ${offer.binding.kind} facilitator unreachable during verify()`, + ); + } + // The raw reason is logged at debug level. Only what the buyer sees and + // the ledger records is constrained. + if (sdkResult.invalidReason !== undefined) { + logger.debug( + { reportedReason: redactedErrorText(sdkResult.invalidReason) }, + 'x402 verify(): facilitator rejection reason', + ); + } + return rejected(sanitizeReason(sdkResult.invalidReason, 'invalid_payment')); + } + + // The Solana payer is the facilitator's reading of the transaction + const payer = + derivedPayer ?? + (sdkResult.payer !== undefined && normalizeAddress(offer.profile, sdkResult.payer) + ? sdkResult.payer + : undefined); + return { + status: 'verified', + provider: 'x402', + ...(payer !== undefined ? { payer } : {}), + payee: transfer?.to ?? offer.payTo, + amount: formatUnits(required, offer.assetDecimals), + currency: offerCurrency(offer, requirement.currency), + network: payload.accepted.network, + asset: offer.family === 'evm' ? getAddress(requirements.asset) : offer.asset, + replayKey, + }; + } + + return verify; +} diff --git a/src/payments/x402/replay-key.ts b/src/payments/x402/replay-key.ts index 295d02c..5ee3756 100644 --- a/src/payments/x402/replay-key.ts +++ b/src/payments/x402/replay-key.ts @@ -1,9 +1,11 @@ /** - * `PaymentResult.replayKey` is derived from the payment authorization alone - * (chain id, asset, payer, nonce), never from the request id, so the same - * authorization replayed on another request collides in the receipt store's - * reservation. The MPP provider uses the same derivation. + * `PaymentResult.replayKey` is derived from the payment authorization alone, + * never from the request id, so the same authorization replayed on another + * request collides in the receipt store's reservation. The MPP provider uses + * the EIP-3009 derivation. */ +import { createHash } from 'node:crypto'; +import { PERMIT2_ADDRESS } from '@x402/evm'; import { encodeAbiParameters, keccak256 } from 'viem'; export interface ReplayKeyInput { @@ -20,3 +22,71 @@ export function computeReplayKey(input: ReplayKeyInput): string { ); return keccak256(encoded); } + +/** + * Identifies a Permit2 authorization by chain, Permit2 contract, owner and + * nonce. Nonces span all tokens and spenders, so asset and resource are excluded. + * The tag separates these keys from EIP-3009 in the receipt store's unique column. + */ +export function computePermit2ReplayKey(input: { + readonly chainId: number; + readonly owner: `0x${string}`; + readonly nonce: string; +}): string { + return keccak256( + encodeAbiParameters( + [ + { type: 'string' }, + { type: 'uint256' }, + { type: 'address' }, + { type: 'address' }, + { type: 'uint256' }, + ], + [ + 'x402/exact/permit2/v1', + BigInt(input.chainId), + PERMIT2_ADDRESS, + input.owner, + BigInt(input.nonce), + ], + ), + ); +} + +/** + * Identifies a Solana `exact` payment by network and SHA-256 of the signed + * message bytes. Excludes signatures, including the facilitator's fee payer + * slot, so altered signatures keep the same key. `messageHash` is the SDK's + * base64 `transactionMessageHash`. + */ +export function computeSvmReplayKey(input: { + readonly network: string; + readonly messageHash: string; +}): string { + const digest = createHash('sha256') + .update('x402/exact/svm/v1\0') + .update(input.network) + .update('\0') + .update(Buffer.from(input.messageHash, 'base64')) + .digest('hex'); + return `0x${digest}`; +} + +/** + * Identifies a NEAR `exact` payment by network, sender, curve-prefixed public + * key (`ed25519:...`) and delegate nonce. Nonces are scoped per access key, + * excluding token, recipient, relayer and signature; another key of the same + * sender has an independent nonce scope. + */ +export function computeNearReplayKey(input: { + readonly network: string; + readonly senderId: string; + readonly publicKey: string; + readonly nonce: bigint; +}): string { + const digest = createHash('sha256') + .update('x402/exact/near/v1\0') + .update(`${input.network}\0${input.senderId}\0${input.publicKey}\0${input.nonce.toString(10)}`) + .digest('hex'); + return `0x${digest}`; +} diff --git a/src/payments/x402/svm.ts b/src/payments/x402/svm.ts new file mode 100644 index 0000000..f1befbc --- /dev/null +++ b/src/payments/x402/svm.ts @@ -0,0 +1,55 @@ +/** + * Solana `exact` payload inspection: bounded decoding and the replay identity. + * The provider loads this module only for a Solana offer, so an EVM-only + * deployment never imports `@x402/svm` or `@solana/kit`. + * + * The facilitator checks the transaction itself: the fee payer, signatures, + * instruction layout, mint, recipient token account and amount, and a + * simulation. Its smart-wallet path accepts layouts the gateway could not + * parse, so the gateway does not read the transfer. + */ +import { address, getAddressEncoder, getProgramDerivedAddress } from '@solana/kit'; +import { decodeTransactionFromPayload, transactionMessageHash } from '@x402/svm'; +import { isRecord } from '../../core/is-record'; +import { computeSvmReplayKey } from './replay-key'; + +// A Solana transaction fits a 1232-byte packet, which is 1644 base64 characters +const MAX_TRANSACTION_BASE64 = 1644; + +const ASSOCIATED_TOKEN_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'; + +/** + * The associated token account of `owner` for `mint`, where the SDK client + * sends a Solana `exact` payment. Its transaction cannot create the account. + */ +export async function associatedTokenAccount( + owner: string, + mint: string, + tokenProgram: string, +): Promise { + const encoder = getAddressEncoder(); + const [account] = await getProgramDerivedAddress({ + programAddress: address(ASSOCIATED_TOKEN_PROGRAM), + seeds: [owner, tokenProgram, mint].map((key) => encoder.encode(address(key))), + }); + return account; +} + +/** + * Returns the Solana `exact` replay key, or an SDK refusal reason for a + * missing, oversized or undecodable transaction. + */ +export function svmReplayKey(payload: unknown, network: string): { replayKey: string } | string { + if (!isRecord(payload)) return 'invalid_exact_svm_payload_transaction'; + const transaction = payload['transaction']; + if (typeof transaction !== 'string' || transaction.length > MAX_TRANSACTION_BASE64) { + return 'invalid_exact_svm_payload_transaction'; + } + let messageHash: string; + try { + messageHash = transactionMessageHash(decodeTransactionFromPayload({ transaction })); + } catch { + return 'invalid_exact_svm_payload_transaction_could_not_be_decoded'; + } + return { replayKey: computeSvmReplayKey({ network, messageHash }) }; +} diff --git a/src/payments/x402/transport.ts b/src/payments/x402/transport.ts index 4ec9275..2545109 100644 --- a/src/payments/x402/transport.ts +++ b/src/payments/x402/transport.ts @@ -26,15 +26,37 @@ export function x402McpPaymentSubmission(value: unknown): string | undefined { /** MCP `_meta` key of a tool result's settlement response (x402 MCP transport) */ export const X402_MCP_PAYMENT_RESPONSE_META_KEY = 'x402/payment-response'; +// The code an SDK facilitator gives a spent authorization, by CAIP-2 namespace. +// The Solana scheme has none; its specification refuses a payload being +// settled again as `duplicate_settlement`. +const REPLAY_CODES: Readonly> = { + near: 'invalid_exact_near_payload_delegate_action_nonce_already_used', + solana: 'duplicate_settlement', +}; + /** - * Return the provider's x402 refusal reason when available. For a replay, - * use the spent-nonce code returned by x402 facilitators. + * Returns the provider's refusal reason, or the paid network family's replay + * code. Unknown networks use EVM's replay code. */ -export function x402ErrorCode(code: string, reason: unknown): string { - if (code === 'PAYMENT_REPLAYED') return 'invalid_exact_evm_nonce_already_used'; +export function x402ErrorCode(code: string, reason: unknown, network?: unknown): string { + if (code === 'PAYMENT_REPLAYED') { + const namespace = typeof network === 'string' ? network.split(':')[0] : undefined; + return ( + (namespace !== undefined ? REPLAY_CODES[namespace] : undefined) ?? + 'invalid_exact_evm_nonce_already_used' + ); + } return typeof reason === 'string' ? reason : 'unexpected_verify_error'; } +/** + * Returns 412 Precondition Failed for a missing Permit2 allowance: the buyer + * must approve Permit2 before retrying. Other refusals keep their status. + */ +export function x402RefusalStatus(code: string, httpStatus: number, reason: unknown): number { + return code === 'PAYMENT_INVALID' && reason === 'permit2_allowance_required' ? 412 : httpStatus; +} + interface SettledPayment { readonly status: string; readonly provider: string; diff --git a/src/protocols/a2a/adapter.ts b/src/protocols/a2a/adapter.ts index 2d55891..0c3d6c4 100644 --- a/src/protocols/a2a/adapter.ts +++ b/src/protocols/a2a/adapter.ts @@ -7,8 +7,7 @@ * * Only the synchronous `SendMessage` path is served; every other A2A method * is listed in `descriptor.unsupported`. This adapter maps reserved payment - * fields but leaves verification, settlement and merchant calls to the - * pipeline. + * fields but leaves verification, settlement and merchant calls to the pipeline. */ import { createHash } from 'node:crypto'; import type { IncomingMessage, ServerResponse } from 'node:http'; @@ -40,7 +39,6 @@ import { A2A_PROTOCOL_VERSION, A2A_PUSH_CONFIG_METHODS, A2A_TASK_LOOKUP_METHODS, - A2A_TASK_STATE_FAILED, A2A_UNSUPPORTED_METHODS, A2A_VERSION_HEADER, } from './constants'; @@ -57,33 +55,24 @@ import { JSONRPC_PARSE_ERROR, type JsonRpcId, jsonRpcError, - jsonRpcResult, parseJsonRpcRequest, } from './jsonrpc'; -import { type A2aInvocation, parseInvocation, userRoleProblem } from './message-mapping'; +import { type A2aInvocation, parseInvocation } from './message-mapping'; +import { resumePayment, taskResult } from './payment-resume'; import { completedTask, failedTask, inputRequired, paymentRequiredTask, type TaskIdentity, - withStatusMessage, } from './task-mapping'; import type { A2aAgentCard, A2aTask } from './types'; import { A2A_X402_EXTENSION_URI, createPendingPayments, - type PendingPayment, type PendingPayments, - paymentCompletedMetadata, - paymentFailureMetadata, - paymentRejectedMetadata, paymentRequiredMetadata, - readFollowUpAuthorization, - readPaymentSubmission, requestsX402Extension, - X402_PAYMENT_PAYLOAD_KEY, - X402_PAYMENT_STATUS_KEY, } from './x402-extension'; // A second line behind the gateway mount's cap, bounding what this adapter @@ -336,7 +325,7 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { return jsonRpcError(id, JSONRPC_METHOD_NOT_FOUND, `Unknown method "${method}".`); } - const resumed = await this.resumePayment(id, params); + const resumed = await resumePayment(this.context, this.pendingPayments, id, params); if (resumed !== undefined) return resumed; let invocation: ReturnType; @@ -375,7 +364,7 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { // A commerce outcome, so a failed task rather than a JSON-RPC error. It // reads the same whether the resource is missing or scoped to another // protocol, so a caller cannot probe what A2A does not expose. - return this.taskResult( + return taskResult( id, failedTask( new CommerceError( @@ -409,16 +398,16 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { }; const outcome: ExecutionOutcome = await context.pipeline.execute(request); if (outcome.kind !== 'payment-required') { - return this.taskResult(id, completedTask(outcome, identity)); + return taskResult(id, completedTask(outcome, identity)); } const task = paymentRequiredTask(outcome, identity); const envelope = outcome.requirement.challenge.envelope; // The extension carries x402 only; any other rail keeps the terminal task if (!x402Extension || outcome.requirement.provider !== 'x402' || !isRecord(envelope)) { - return this.taskResult(id, task); + return taskResult(id, task); } const network = outcome.requirement.network; - this.pendingPayments?.put( + const held = this.pendingPayments?.put( identity.taskId, { contextId: identity.contextId, @@ -429,7 +418,19 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { }, outcome.requirement.expiresAt, ); - return this.taskResult( + if (held === false) { + return taskResult( + id, + failedTask( + new CommerceError( + 'GATEWAY_BUSY', + 'Too many unpaid purchases are waiting for payment; retry shortly.', + ), + identity, + ), + ); + } + return taskResult( id, inputRequired(task, paymentRequiredMetadata(envelope), identity.statusMessageId), ); @@ -442,158 +443,10 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { { resourceId: invocation.resourceId, requestId, err: toLogInfo(error) }, 'a2a adapter: execution failed', ); - return this.taskResult(id, failedTask(error, identity)); + return taskResult(id, failedTask(error, identity)); } } - /** - * A message on a task waiting for an x402 extension payment, recognized by - * its `taskId` whether or not the header activated the extension again. Any - * other task id falls through to `parseInvocation`, which answers - * TaskNotFoundError. - */ - private async resumePayment( - id: JsonRpcId, - params: unknown, - ): Promise | undefined> { - const context = this.context; - const message = isRecord(params) ? params['message'] : undefined; - const taskId = isRecord(message) ? message['taskId'] : undefined; - if (context === undefined || typeof taskId !== 'string') return undefined; - const pending = this.pendingPayments?.get(taskId); - if (pending === undefined) return undefined; - - const messageId = isRecord(message) ? message['messageId'] : undefined; - if (typeof messageId !== 'string' || messageId.length === 0) { - return jsonRpcError( - id, - JSONRPC_INVALID_PARAMS, - 'Message must carry a non-empty "messageId".', - ); - } - // Apply the same role check as a new purchase - const roleProblem = userRoleProblem(isRecord(message) ? message['role'] : undefined); - if (roleProblem !== undefined) return jsonRpcError(id, JSONRPC_INVALID_PARAMS, roleProblem); - // Reject a mismatched context without consuming the pending task. - const contextId = isRecord(message) ? message['contextId'] : undefined; - if (contextId !== undefined && contextId !== pending.contextId) { - return jsonRpcError( - id, - JSONRPC_INVALID_PARAMS, - 'Message "contextId" does not match the task.', - ); - } - const submission = readPaymentSubmission(message); - if (submission === undefined) { - // The task stays pending, so a corrected message can still pay it - return jsonRpcError( - id, - JSONRPC_INVALID_PARAMS, - `For a pending task, set "${X402_PAYMENT_STATUS_KEY}" to "payment-submitted" and provide a "${X402_PAYMENT_PAYLOAD_KEY}" object, or set it to "payment-rejected".`, - ); - } - // A mandate stored with the purchase wins. One sent here is verified - // against the stored resource and input, like the payment. - let authorization = pending.authorization; - if (authorization === undefined && submission.kind === 'submitted') { - try { - authorization = readFollowUpAuthorization(message); - } catch (err) { - return jsonRpcError(id, JSONRPC_INVALID_PARAMS, toCommerceError(err).message); - } - } - // Taken before execution, so a second payment message for the task finds - // nothing and cannot run the purchase twice - this.pendingPayments?.delete(taskId); - const statusMessageId = context.ids.next('a2a-msg'); - const identity: TaskIdentity = { - taskId, - contextId: pending.contextId, - artifactId: context.ids.next('a2a-artifact'), - statusMessageId, - timestamp: context.clock.nowIso(), - }; - - if (submission.kind === 'rejected') { - const declined: A2aTask = { - id: taskId, - contextId: pending.contextId, - status: { state: A2A_TASK_STATE_FAILED, timestamp: identity.timestamp }, - artifacts: [], - }; - return this.taskResult( - id, - withStatusMessage( - declined, - 'Payment was declined.', - statusMessageId, - paymentRejectedMetadata(), - ), - ); - } - return this.taskResult( - id, - await this.payPending( - context, - { ...pending, ...(authorization !== undefined ? { authorization } : {}) }, - submission.payload, - identity, - ), - ); - } - - // The pending purchase, now with the x402 `PaymentPayload` as its proof - private async payPending( - context: ProtocolAdapterContext, - pending: PendingPayment, - payload: Record, - identity: TaskIdentity, - ): Promise { - const requestId = context.ids.next('a2a'); - const request: CanonicalRequest = { - requestId, - resourceId: pending.resourceId, - input: pending.input, - protocol: 'a2a', - receivedAt: context.clock.nowIso(), - // The same encoding as the HTTP header's base64 PaymentPayload JSON - payment: { - method: 'x402', - payload: Buffer.from(JSON.stringify(payload), 'utf8').toString('base64'), - }, - ...(pending.authorization !== undefined ? { authorization: pending.authorization } : {}), - }; - let error: CommerceError; - try { - const outcome = await context.pipeline.execute(request); - if (outcome.kind === 'delivered' && outcome.payment !== undefined) { - return withStatusMessage( - completedTask(outcome, identity), - 'Payment settled.', - identity.statusMessageId, - paymentCompletedMetadata(outcome.payment), - ); - } - // A proof was sent, so neither a free delivery nor a new challenge is a - // payment this task can report - error = new CommerceError('PAYMENT_INVALID', 'The payment was not accepted.', { requestId }); - } catch (err) { - error = toCommerceError(err); - context.logger.warn( - { resourceId: pending.resourceId, requestId, err: toLogInfo(error) }, - 'a2a adapter: x402 extension payment failed', - ); - } - const metadata = paymentFailureMetadata(error, pending.network); - let text = 'Payment failed.'; - if (metadata[X402_PAYMENT_STATUS_KEY] === 'payment-completed') { - text = 'Payment settled, but the resource could not be delivered.'; - } else if (!error.code.startsWith('PAYMENT_')) { - text = 'The resource could not be delivered.'; - } - return withStatusMessage(failedTask(error, identity), text, identity.statusMessageId, metadata); - } - private taskIdentity(context: ProtocolAdapterContext, requestId: string): TaskIdentity { return { taskId: requestId, @@ -604,11 +457,6 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { }; } - // A2A's JSON-RPC result wraps the terminal task - private taskResult(id: JsonRpcId, task: A2aTask): Record { - return jsonRpcResult(id, { task }); - } - async health(): Promise { const checkedAt = this.context?.clock.nowIso() ?? new Date().toISOString(); if (!this.started || this.card === undefined) { diff --git a/src/protocols/a2a/payment-resume.ts b/src/protocols/a2a/payment-resume.ts new file mode 100644 index 0000000..f26df19 --- /dev/null +++ b/src/protocols/a2a/payment-resume.ts @@ -0,0 +1,166 @@ +// Completing a purchase that waits for an x402 extension payment, from the follow-up message that pays or declines it +import type { CanonicalRequest, ProtocolAdapterContext } from '../../core'; +import { CommerceError, toCommerceError } from '../../core'; +import { toLogInfo } from '../../core/errors'; +import { isRecord } from '../../core/is-record'; +import { A2A_TASK_STATE_FAILED } from './constants'; +import { JSONRPC_INVALID_PARAMS, type JsonRpcId, jsonRpcError, jsonRpcResult } from './jsonrpc'; +import { userRoleProblem } from './message-mapping'; +import { completedTask, failedTask, type TaskIdentity, withStatusMessage } from './task-mapping'; +import type { A2aTask } from './types'; +import { + type PendingPayment, + type PendingPayments, + paymentCompletedMetadata, + paymentFailureMetadata, + paymentRejectedMetadata, + readFollowUpAuthorization, + readPaymentSubmission, + X402_PAYMENT_PAYLOAD_KEY, + X402_PAYMENT_STATUS_KEY, +} from './x402-extension'; + +/** A2A's JSON-RPC result wraps the terminal task */ +export function taskResult(id: JsonRpcId, task: A2aTask): Record { + return jsonRpcResult(id, { task }); +} + +/** + * A message on a task waiting for an x402 extension payment, recognized by + * its `taskId` whether or not the header activated the extension again. Any + * other task id falls through to `parseInvocation`, which answers TaskNotFoundError. + */ +export async function resumePayment( + context: ProtocolAdapterContext | undefined, + pendingPayments: PendingPayments | undefined, + id: JsonRpcId, + params: unknown, +): Promise | undefined> { + const message = isRecord(params) ? params['message'] : undefined; + const taskId = isRecord(message) ? message['taskId'] : undefined; + if (context === undefined || typeof taskId !== 'string') return undefined; + const pending = pendingPayments?.get(taskId); + if (pending === undefined) return undefined; + + const messageId = isRecord(message) ? message['messageId'] : undefined; + if (typeof messageId !== 'string' || messageId.length === 0) { + return jsonRpcError(id, JSONRPC_INVALID_PARAMS, 'Message must carry a non-empty "messageId".'); + } + // Apply the same role check as a new purchase + const roleProblem = userRoleProblem(isRecord(message) ? message['role'] : undefined); + if (roleProblem !== undefined) return jsonRpcError(id, JSONRPC_INVALID_PARAMS, roleProblem); + // Reject a mismatched context without consuming the pending task. + const contextId = isRecord(message) ? message['contextId'] : undefined; + if (contextId !== undefined && contextId !== pending.contextId) { + return jsonRpcError(id, JSONRPC_INVALID_PARAMS, 'Message "contextId" does not match the task.'); + } + const submission = readPaymentSubmission(message); + if (submission === undefined) { + // The task stays pending, so a corrected message can still pay it + return jsonRpcError( + id, + JSONRPC_INVALID_PARAMS, + `For a pending task, set "${X402_PAYMENT_STATUS_KEY}" to "payment-submitted" and provide a "${X402_PAYMENT_PAYLOAD_KEY}" object, or set it to "payment-rejected".`, + ); + } + // A mandate stored with the purchase wins. One sent here is verified + // against the stored resource and input, like the payment. + let authorization = pending.authorization; + if (authorization === undefined && submission.kind === 'submitted') { + try { + authorization = readFollowUpAuthorization(message); + } catch (err) { + return jsonRpcError(id, JSONRPC_INVALID_PARAMS, toCommerceError(err).message); + } + } + // Taken before execution, so a second payment message for the task finds + // nothing and cannot run the purchase twice + pendingPayments?.delete(taskId); + const statusMessageId = context.ids.next('a2a-msg'); + const identity: TaskIdentity = { + taskId, + contextId: pending.contextId, + artifactId: context.ids.next('a2a-artifact'), + statusMessageId, + timestamp: context.clock.nowIso(), + }; + + if (submission.kind === 'rejected') { + const declined: A2aTask = { + id: taskId, + contextId: pending.contextId, + status: { state: A2A_TASK_STATE_FAILED, timestamp: identity.timestamp }, + artifacts: [], + }; + return taskResult( + id, + withStatusMessage( + declined, + 'Payment was declined.', + statusMessageId, + paymentRejectedMetadata(), + ), + ); + } + return taskResult( + id, + await payPending( + context, + { ...pending, ...(authorization !== undefined ? { authorization } : {}) }, + submission.payload, + identity, + ), + ); +} + +// The pending purchase, now with the x402 `PaymentPayload` as its proof +async function payPending( + context: ProtocolAdapterContext, + pending: PendingPayment, + payload: Record, + identity: TaskIdentity, +): Promise { + const requestId = context.ids.next('a2a'); + const request: CanonicalRequest = { + requestId, + resourceId: pending.resourceId, + input: pending.input, + protocol: 'a2a', + receivedAt: context.clock.nowIso(), + // The same encoding as the HTTP header's base64 PaymentPayload JSON + payment: { + method: 'x402', + payload: Buffer.from(JSON.stringify(payload), 'utf8').toString('base64'), + }, + ...(pending.authorization !== undefined ? { authorization: pending.authorization } : {}), + }; + let error: CommerceError; + try { + const outcome = await context.pipeline.execute(request); + if (outcome.kind === 'delivered' && outcome.payment !== undefined) { + return withStatusMessage( + completedTask(outcome, identity), + 'Payment settled.', + identity.statusMessageId, + paymentCompletedMetadata(outcome.payment), + ); + } + // A proof was sent, so neither a free delivery nor a new challenge is a + // payment this task can report + error = new CommerceError('PAYMENT_INVALID', 'The payment was not accepted.', { requestId }); + } catch (err) { + error = toCommerceError(err); + context.logger.warn( + { resourceId: pending.resourceId, requestId, err: toLogInfo(error) }, + 'a2a adapter: x402 extension payment failed', + ); + } + const metadata = paymentFailureMetadata(error, pending.network); + let text = 'Payment failed.'; + if (metadata[X402_PAYMENT_STATUS_KEY] === 'payment-completed') { + text = 'Payment settled, but the resource could not be delivered.'; + } else if (!error.code.startsWith('PAYMENT_')) { + text = 'The resource could not be delivered.'; + } + return withStatusMessage(failedTask(error, identity), text, identity.statusMessageId, metadata); +} diff --git a/src/protocols/a2a/x402-extension.ts b/src/protocols/a2a/x402-extension.ts index 064c160..716be06 100644 --- a/src/protocols/a2a/x402-extension.ts +++ b/src/protocols/a2a/x402-extension.ts @@ -38,8 +38,8 @@ const DEFAULT_PENDING_TTL_MS = 10 * 60 * 1000; const MAX_PENDING_TTL_MS = 60 * 60 * 1000; // Bound memory used by unpaid tasks from unauthenticated callers. Each task -// holds one request's input; deployments can raise this cap or use a shared -// store if valid tasks are evicted before payment. +// holds one request's input. A full store refuses new purchases rather than +// evicting a live one, whose buyer may already have signed. const MAX_PENDING_PAYMENTS = 256; /** `A2A-Extensions` is a comma-separated URI list, possibly repeated */ @@ -79,17 +79,15 @@ export function createPendingPayments(clock: Clock, maxEntries = MAX_PENDING_PAY } return { - /** Holds a purchase until `expiresAt`, evicting the oldest one when full */ - put(taskId: string, pending: Omit, expiresAt?: string): void { + /** Holds a purchase until `expiresAt`; returns false, holding nothing, when full */ + put(taskId: string, pending: Omit, expiresAt?: string): boolean { purgeExpired(); - if (tasks.size >= maxEntries) { - const [oldest] = tasks.keys(); - if (oldest !== undefined) tasks.delete(oldest); - } + if (tasks.size >= maxEntries) return false; const requested = expiresAt !== undefined ? Date.parse(expiresAt) : Number.NaN; const ttl = Number.isFinite(requested) ? requested - now() : DEFAULT_PENDING_TTL_MS; const expiresAtMs = now() + Math.min(Math.max(ttl, 0), MAX_PENDING_TTL_MS); tasks.set(taskId, { ...pending, expiresAtMs }); + return true; }, get(taskId: string): PendingPayment | undefined { @@ -186,7 +184,7 @@ export function paymentFailureMetadata( } // A backend failure before settlement keeps its own commerce error code. const code = error.code.startsWith('PAYMENT_') - ? x402ErrorCode(error.code, error.details?.['reason']) + ? x402ErrorCode(error.code, error.details?.['reason'], error.details?.['network']) : error.code.toLowerCase(); const failure = settlementFailure(error.details); const receipt = diff --git a/src/protocols/mcp/result-mapping.ts b/src/protocols/mcp/result-mapping.ts index fe9cd83..ab256b6 100644 --- a/src/protocols/mcp/result-mapping.ts +++ b/src/protocols/mcp/result-mapping.ts @@ -98,7 +98,8 @@ function paymentRequiredResult(outcome: PaymentRequiredOutcome): CallToolResult else if (mppRequired !== undefined) meta = { [MPP_MCP_PAYMENT_REQUIRED_META_KEY]: mppRequired }; return structuredError( structured, - `Payment required: ${p.amount} ${p.currency} to ${p.destination} for resource "${outcome.resourceId}". Retry with an ${p.provider} payment proof in ${proofCarriers(p.provider)}.`, + // A challenge with several offers names no single destination + `Payment required: ${p.amount} ${p.currency}${p.destination !== undefined ? ` to ${p.destination}` : ''} for resource "${outcome.resourceId}". Retry with an ${p.provider} payment proof in ${proofCarriers(p.provider)}.`, meta, ); } @@ -112,7 +113,10 @@ export function errorResult(error: CommerceError, rail?: PaymentMethodName): Cal const refused = error.httpStatus === 402 && isRecord(challenge); const x402Required = rail === 'x402' && refused - ? { ...challenge, error: x402ErrorCode(error.code, details?.['reason']) } + ? { + ...challenge, + error: x402ErrorCode(error.code, details?.['reason'], details?.['network']), + } : undefined; const structured = x402Required !== undefined diff --git a/src/storage/receipts/schema.ts b/src/storage/receipts/schema.ts index d6de23b..dd13e3e 100644 --- a/src/storage/receipts/schema.ts +++ b/src/storage/receipts/schema.ts @@ -75,6 +75,36 @@ const MIGRATIONS: readonly Migration[] = [ db.exec(`ALTER TABLE receipts ADD COLUMN authorization_json TEXT;`); }, }, + { + version: 3, + up(db) { + // Archive rejected attempts before reclaim overwrites them. replay_key + // is not unique here: one authorization can be rejected and reclaimed + // repeatedly. + db.exec(` + CREATE TABLE IF NOT EXISTS payment_attempt_history ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL, + request_id TEXT NOT NULL, + resource_id TEXT NOT NULL, + provider TEXT NOT NULL, + replay_key TEXT NOT NULL, + status TEXT NOT NULL, + amount TEXT NOT NULL, + currency TEXT NOT NULL, + payer TEXT, + payee TEXT, + external_reference TEXT, + rejection_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + superseded_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_payment_attempt_history_replay_key ON payment_attempt_history(replay_key); + CREATE INDEX IF NOT EXISTS idx_payment_attempt_history_request_id ON payment_attempt_history(request_id); + `); + }, + }, ]; /** Target schema version: the version of the last migration */ diff --git a/src/storage/receipts/store.ts b/src/storage/receipts/store.ts index 5242c58..0a5d556 100644 --- a/src/storage/receipts/store.ts +++ b/src/storage/receipts/store.ts @@ -122,6 +122,21 @@ export function createSqliteReceiptStore(options: SqliteReceiptStoreOptions): Re VALUES (@id, @request_id, @resource_id, @provider, @replay_key, @status, @amount, @currency, @payer, @payee, @external_reference, @rejection_reason, @created_at, @updated_at) RETURNING *`, ); + // Keeps the rejected row a reclaim is about to overwrite + const archiveRejectedAttemptStmt = db.prepare( + `INSERT INTO payment_attempt_history (id, request_id, resource_id, provider, replay_key, status, amount, currency, payer, payee, external_reference, rejection_reason, created_at, updated_at, superseded_at) + SELECT id, request_id, resource_id, provider, replay_key, status, amount, currency, payer, payee, external_reference, rejection_reason, created_at, updated_at, @created_at + FROM payment_attempts + WHERE replay_key = @replay_key AND provider = @provider AND status = 'rejected'`, + ); + const reclaimPaymentAttemptStmt = db.prepare, PaymentAttemptRow>( + `UPDATE payment_attempts SET id = @id, request_id = @request_id, resource_id = @resource_id, + status = 'reserved', amount = @amount, currency = @currency, + payer = @payer, payee = @payee, external_reference = NULL, rejection_reason = NULL, + created_at = @created_at, updated_at = @updated_at + WHERE replay_key = @replay_key AND provider = @provider AND status = 'rejected' + RETURNING *`, + ); // COALESCE: an omitted field (bound as NULL by updatePaymentAttempt) keeps // its stored value. A status-only update must not erase external_reference, // which holds the tx hash of a settlement-uncertain attempt: the record that @@ -143,6 +158,15 @@ export function createSqliteReceiptStore(options: SqliteReceiptStoreOptions): Re const paymentAttemptStatusStmt = db.prepare<[string], Pick>( 'SELECT status FROM payment_attempts WHERE replay_key = ?', ); + // IMMEDIATE takes the write lock before the read, so of two retries on one + // file only one archives and reclaims. The other finds no rejected row, and + // its insert hits the unique replay key. + const reclaimRejectedAttempt = db.transaction( + (params: Record): PaymentAttemptRow | undefined => { + archiveRejectedAttemptStmt.run(params); + return reclaimPaymentAttemptStmt.get(params); + }, + ); let closed = false; @@ -185,26 +209,35 @@ export function createSqliteReceiptStore(options: SqliteReceiptStoreOptions): Re async reservePaymentAttempt(reservation: PaymentAttemptReservation): Promise { const now = clock.nowIso(); - const id = ids.next('attempt'); + const params = { + id: ids.next('attempt'), + request_id: reservation.requestId, + resource_id: reservation.resourceId, + provider: reservation.provider, + replay_key: reservation.replayKey, + amount: reservation.amount, + currency: reservation.currency, + payer: reservation.payer ?? null, + payee: reservation.payee ?? null, + created_at: now, + updated_at: now, + }; let row: PaymentAttemptRow; try { - // RETURNING on a successful insert always yields the inserted row - row = insertPaymentAttemptStmt.get({ - id, - request_id: reservation.requestId, - resource_id: reservation.resourceId, - provider: reservation.provider, - replay_key: reservation.replayKey, - status: 'reserved' satisfies PaymentAttempt['status'], - amount: reservation.amount, - currency: reservation.currency, - payer: reservation.payer ?? null, - payee: reservation.payee ?? null, - external_reference: null, - rejection_reason: null, - created_at: now, - updated_at: now, - }) as PaymentAttemptRow; + // Reclaim only if the rail permits reusing an authorization whose + // attempt was rejected. NEAR client retries reuse the access key's + // next nonce and therefore the replay key. + row = + (reservation.reclaimRejected === true + ? reclaimRejectedAttempt.immediate(params) + : undefined) ?? + // RETURNING on a successful insert always yields the inserted row + (insertPaymentAttemptStmt.get({ + ...params, + status: 'reserved' satisfies PaymentAttempt['status'], + external_reference: null, + rejection_reason: null, + }) as PaymentAttemptRow); } catch (err) { if (isUniqueConstraintOn(err, 'payment_attempts.replay_key')) { // Report the existing status so the pipeline can choose a replay response diff --git a/src/x402.ts b/src/x402.ts index b8d698a..0973921 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -1,12 +1,15 @@ /** * `@devlab.group/agent-commerce/x402`: the x402 payment provider (`exact` - * scheme, EVM). A separate entry point because the rail brings the EVM signing - * and RPC stack, which a consumer serving free resources should not install. + * scheme on EVM, Solana and NEAR). A separate entry point because the rail + * brings the EVM signing and RPC stack, which a consumer serving free resources + * should not install. The EVM peers are needed whatever the offers' networks. * * npm install @devlab.group/agent-commerce @x402/core @x402/evm viem * import { x402 } from '@devlab.group/agent-commerce/x402'; * - * A remote facilitator with `auth.type: cdp` also needs `@coinbase/x402`. + * Solana offers also need `@x402/svm` and `@solana/kit`, NEAR offers + * `@x402/near`, and a remote facilitator with `auth.type: cdp` needs + * `@coinbase/x402`. Each loads only when used. * * The peers are pinned exactly: x402's zod schemas and EIP-712 domain * construction cross this boundary, so version skew is a correctness problem. @@ -25,7 +28,10 @@ export { type FacilitatorAuth, type NetworkProfile, SUPPORTED_NETWORK_IDS, + type X402AssetTransferMethod, type X402FacilitatorConfig, + type X402MultiOfferProviderOptions, + type X402OfferOptions, type X402PaymentFlow, type X402ProviderOptions, } from './payments/x402'; diff --git a/tests/conformance/a2a/support/gateway.ts b/tests/conformance/a2a/support/gateway.ts index d69d2c6..dfa3419 100644 --- a/tests/conformance/a2a/support/gateway.ts +++ b/tests/conformance/a2a/support/gateway.ts @@ -144,7 +144,7 @@ function fakeProvider(): PaymentProvider & { settleCalls: () => number } { amount: ctx.amount, currency: ctx.currency, destination: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', - network: 'eip155:84532', + network: 'eip155:31337', challenge: { provider: 'x402', version: '2', accepts: [{ scheme: 'exact' }] }, }), verify: async (ctx: PaymentVerificationContext): Promise => { diff --git a/tests/conformance/mcp/mcp-conformance.test.ts b/tests/conformance/mcp/mcp-conformance.test.ts index 187b11c..983491a 100644 --- a/tests/conformance/mcp/mcp-conformance.test.ts +++ b/tests/conformance/mcp/mcp-conformance.test.ts @@ -303,7 +303,7 @@ describe('mcp adapter: invocation', () => { challenge: { provider: 'x402', version: '1', - accepts: [{ scheme: 'exact', network: 'eip155:84532', payTo: '0xMerchantWallet' }], + accepts: [{ scheme: 'exact', network: 'eip155:31337', payTo: '0xMerchantWallet' }], }, }, }); @@ -324,7 +324,7 @@ describe('mcp adapter: invocation', () => { expect(Array.isArray(payment.accepts)).toBe(true); expect((payment.accepts as unknown[])[0]).toEqual({ scheme: 'exact', - network: 'eip155:84532', + network: 'eip155:31337', payTo: '0xMerchantWallet', }); @@ -382,7 +382,7 @@ describe('mcp adapter: invocation', () => { provider: 'x402', amount: '0.05', currency: 'USDC', - network: 'eip155:84532', + network: 'eip155:31337', externalReference: '0xsettlementtxhash', }, receipt: { @@ -419,7 +419,7 @@ describe('mcp adapter: invocation', () => { amount: '0.05', currency: 'USDC', externalReference: '0xsettlementtxhash', - network: 'eip155:84532', + network: 'eip155:31337', }, }); }); diff --git a/tests/e2e/authorization/ap2-mpp.e2e.test.ts b/tests/e2e/authorization/ap2-mpp.e2e.test.ts index 1120df2..ea8590b 100644 --- a/tests/e2e/authorization/ap2-mpp.e2e.test.ts +++ b/tests/e2e/authorization/ap2-mpp.e2e.test.ts @@ -43,7 +43,7 @@ const RESOURCE_ID = 'market_report'; const INPUT = { city: 'Berlin' }; const AMOUNT = '1.00'; const CURRENCY = 'USDC'; -const NETWORK = 'eip155:84532'; +const NETWORK = 'eip155:31337'; type EvmChallenge = Parameters['createCredential']>[0]['challenge']; @@ -94,6 +94,7 @@ function rawConfig(): Record { payments: { mpp: { enabled: true, + network: 'eip155:31337', rpcUrl: anvil.rpcUrl, asset: deployment.asset, assetName: deployment.assetName, diff --git a/tests/e2e/authorization/ap2-x402.e2e.test.ts b/tests/e2e/authorization/ap2-x402.e2e.test.ts index 6799d83..61e22d0 100644 --- a/tests/e2e/authorization/ap2-x402.e2e.test.ts +++ b/tests/e2e/authorization/ap2-x402.e2e.test.ts @@ -40,7 +40,7 @@ const RESOURCE_ID = 'market_report'; const INPUT = { city: 'Berlin' }; const AMOUNT = '1.00'; const CURRENCY = 'USD'; -const NETWORK = 'eip155:84532'; +const NETWORK = 'eip155:31337'; let anvil: AnvilHandle; let deployment: Awaited>; diff --git a/tests/e2e/payment/mpp-settlement.e2e.test.ts b/tests/e2e/payment/mpp-settlement.e2e.test.ts index ff90522..846d703 100644 --- a/tests/e2e/payment/mpp-settlement.e2e.test.ts +++ b/tests/e2e/payment/mpp-settlement.e2e.test.ts @@ -96,7 +96,7 @@ function rawConfig(options: GatewayOptions): Record { ? { x402: { enabled: true, - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: anvil.rpcUrl, asset: deployment.asset, assetName: deployment.assetName, @@ -110,6 +110,7 @@ function rawConfig(options: GatewayOptions): Record { : {}), mpp: { enabled: true, + network: 'eip155:31337', rpcUrl: anvil.rpcUrl, asset: deployment.asset, assetName: deployment.assetName, @@ -454,8 +455,8 @@ describe('MPP settlement - real local chain', () => { expect(uncertain.body['code']).toBe('PAYMENT_SETTLEMENT_FAILED'); const details = uncertain.body['details'] as Record; expect(details['settlementUncertain']).toBe(true); - // The default flow runs the backend first; its response is not delivered - expect(backendCalls).toBe(callsBefore + 1); + // The default upfront flow settles first, so the backend never ran + expect(backendCalls).toBe(callsBefore); expect(uncertain.body).not.toHaveProperty('report'); expect(uncertain.headers['payment-receipt']).toBeUndefined(); // Not "failed": the broadcast was pending, with nothing moved yet @@ -487,7 +488,7 @@ describe('MPP settlement - real local chain', () => { expect(uncertain.body['code']).toBe('PAYMENT_SETTLEMENT_FAILED'); const details = uncertain.body['details'] as Record; expect(details['settlementUncertain']).toBe(true); - expect(backendCalls).toBe(callsBefore + 1); + expect(backendCalls).toBe(callsBefore); expect(uncertain.body).not.toHaveProperty('report'); const [attempt] = await store.listPaymentAttempts(); expect(attempt?.status).toBe('settlement-uncertain'); @@ -501,7 +502,10 @@ describe('MPP settlement - real local chain', () => { }); it('9. backend failure under authorization leaves funds untouched and bars replay', async () => { - const { gateway, store } = await startGateway({ backend: failingBackend }); + const { gateway, store } = await startGateway({ + mpp: { paymentFlow: 'authorization' }, + backend: failingBackend, + }); const credential = await mppCredential(gateway); const before = await balances(); const callsBefore = backendCalls; diff --git a/tests/e2e/payment/x402-multichain.e2e.test.ts b/tests/e2e/payment/x402-multichain.e2e.test.ts new file mode 100644 index 0000000..4662d25 --- /dev/null +++ b/tests/e2e/payment/x402-multichain.e2e.test.ts @@ -0,0 +1,302 @@ +/** + * EIP-3009 settlement through one gateway on Anvil chains 31338 and 31339, + * each with MockUSDC. A loopback facilitator serves both declared testnets, + * exercising the remote path used for non-local EVM networks. A second + * resource offers only the second chain, through its own `x402.accepts`. + */ +import { x402Client, x402HTTPClient } from '@x402/core/client'; +import type { PaymentRequired } from '@x402/core/types'; +import { registerExactEvmScheme } from '@x402/evm/exact/client'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { parseConfig } from '../../../src/config'; +import { type BackendExecutor, NOOP_LOGGER, type PaymentProvider } from '../../../src/core'; +import { createGateway, type GatewayInstance } from '../../../src/gateway'; +import { createConfiguredPaymentProviders } from '../../../src/gateway/payment-providers'; +import { createPaymentProof } from '../../../src/payments/x402'; +import { type AnvilHandle, deployLocalChain, startAnvil } from '../../../src/payments/x402/testing'; +import { createSqliteReceiptStore } from '../../../src/storage/receipts'; +import { + type LoopbackFacilitator, + startLoopbackFacilitator, +} from '../../fixtures/x402/loopback-facilitator'; +import { expectRealSettlement, readBalances } from '../../fixtures/x402/settlement'; +import { assertTransferOnChain } from '../../fixtures/x402/smoke/evidence'; + +const RESOURCE_ID = 'market_report'; +// Offers only the second chain +const OWN_RESOURCE_ID = 'chain_b_report'; +const CHAINS = [ + { network: 'eip155:31338', chainId: 31338, port: 18781 }, + { network: 'eip155:31339', chainId: 31339, port: 18782 }, +] as const; +// Not an Anvil account: a well-known address is refused on a testnet deployment +const MERCHANT = privateKeyToAccount(generatePrivateKey()).address; + +let anvils: AnvilHandle[] = []; +let deployments: Awaited>[] = []; +let facilitator: LoopbackFacilitator; +let gateway: GatewayInstance; +let provider: PaymentProvider; +let store: ReturnType; + +const backend: BackendExecutor = { + async call() { + return { status: 200, body: { report: 'ok' }, headers: {}, durationMs: 1 }; + }, +}; + +const deploymentOf = (index: number) => deployments[index] as (typeof deployments)[number]; +const anvilOf = (index: number) => anvils[index] as AnvilHandle; + +function balancesOn(index: number) { + const deployment = deploymentOf(index); + return readBalances({ + rpcUrl: anvilOf(index).rpcUrl, + asset: deployment.asset, + buyer: deployment.buyer.address, + merchant: MERCHANT, + }); +} + +async function invoke(headers: Record = {}, resourceId = RESOURCE_ID) { + return gateway.server.inject({ + method: 'POST', + url: `/api/resources/${resourceId}/invoke`, + headers: { 'content-type': 'application/json', ...headers }, + payload: {}, + }); +} + +async function challenge(resourceId = RESOURCE_ID): Promise { + const unpaid = await invoke({}, resourceId); + expect(unpaid.statusCode).toBe(402); + return new x402HTTPClient(new x402Client()).getPaymentRequiredResponse( + (name) => unpaid.headers[name.toLowerCase()] as string | undefined, + ); +} + +const header = (payload: unknown) => ({ + 'payment-signature': Buffer.from(JSON.stringify(payload)).toString('base64'), +}); + +beforeAll(async () => { + anvils = await Promise.all( + CHAINS.map(({ chainId, port }) => startAnvil({ chainId, port, silent: true })), + ); + deployments = await Promise.all( + CHAINS.map(({ chainId }, index) => + deployLocalChain({ rpcUrl: anvilOf(index).rpcUrl, buyerInitialBalance: '10.00', chainId }), + ), + ); + facilitator = await startLoopbackFacilitator({ + evm: { + chains: CHAINS.map(({ network }, index) => ({ network, rpcUrl: anvilOf(index).rpcUrl })), + signerPrivateKey: deploymentOf(0).facilitator.privateKey, + }, + }); + + const optionFor = (index: number) => { + const deployment = deploymentOf(index); + return { + network: CHAINS[index]?.network, + payTo: MERCHANT, + asset: deployment.asset, + assetName: deployment.assetName, + assetVersion: deployment.assetVersion, + assetDecimals: deployment.assetDecimals, + }; + }; + const report = { + backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: '1.00', currency: 'USDC' }, + expose: ['http'], + payments: ['x402'], + }; + const config = parseConfig( + { + version: 1, + merchant: { id: 'multichain', name: 'Multichain', publicBaseUrl: 'http://127.0.0.1:8080' }, + server: { port: 8080, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } }, + resources: { + [RESOURCE_ID]: { name: 'Market report', ...report }, + [OWN_RESOURCE_ID]: { + name: 'Chain B report', + ...report, + x402: { accepts: [optionFor(1)] }, + }, + }, + payments: { + x402: { + enabled: true, + networks: Object.fromEntries( + CHAINS.map(({ network, chainId }, index) => [ + network, + { kind: 'testnet', displayName: `Anvil ${chainId}`, rpcUrl: anvilOf(index).rpcUrl }, + ]), + ), + options: CHAINS.map((_chain, index) => optionFor(index)), + maxTimeoutSeconds: 120, + facilitator: { mode: 'remote', url: facilitator.url, auth: { type: 'none' } }, + }, + }, + }, + {}, + ); + store = createSqliteReceiptStore({ path: ':memory:' }); + await store.init(); + const providers = createConfiguredPaymentProviders(config.payments, NOOP_LOGGER); + provider = providers[0] as PaymentProvider; + gateway = await createGateway({ + config, + store, + paymentProviders: providers, + protocolAdapters: [], + backend, + }); +}, 120_000); + +afterAll(async () => { + await gateway?.close(); + await store?.close(); + await facilitator?.close(); + await Promise.all(anvils.map((anvil) => anvil.stop())); +}); + +describe('x402 settlement on two EVM chains', () => { + it.each([1, 0])('settles on chain %i and moves nothing on the other', async (pick) => { + const other = 1 - pick; + const offered = await challenge(); + const client = new x402Client((_version, accepts) => accepts[pick] as never); + registerExactEvmScheme(client, { + signer: privateKeyToAccount(deploymentOf(pick).buyer.privateKey), + networks: CHAINS.map((c) => c.network), + }); + client.setSpendControls(false); + const payload = await client.createPaymentPayload(offered); + + const before = await balancesOn(pick); + const otherBefore = await balancesOn(other); + const paid = await invoke(header(payload)); + expect(paid.statusCode).toBe(200); + + const settlement = new x402HTTPClient(new x402Client()).getPaymentSettleResponse( + (name) => paid.headers[name.toLowerCase()] as string | undefined, + ); + expect(settlement.network).toBe(CHAINS[pick]?.network); + await expectRealSettlement({ + rpcUrl: anvilOf(pick).rpcUrl, + asset: deploymentOf(pick).asset, + buyer: deploymentOf(pick).buyer.address, + merchant: MERCHANT, + before, + after: await balancesOn(pick), + amountBaseUnits: 1_000_000n, + txHash: settlement.transaction, + }); + expect(await balancesOn(other)).toEqual(otherBefore); + + // The public-network smoke harness reads the same evidence from the receipt's logs + const transfer = { + asset: deploymentOf(pick).asset, + from: deploymentOf(pick).buyer.address, + to: MERCHANT, + amount: 1_000_000n, + reference: settlement.transaction, + }; + await assertTransferOnChain('evm', anvilOf(pick).rpcUrl, transfer); + await expect( + assertTransferOnChain('evm', anvilOf(pick).rpcUrl, { ...transfer, amount: 999_999n }), + ).rejects.toThrow(/no .* Transfer of 999999/); + + const [receipt] = await store.listReceipts({ limit: 1 }); + expect(receipt?.payment?.network).toBe(CHAINS[pick]?.network); + }); + + it("refuses an authorization signed for one chain and presented as the other chain's offer", async () => { + const offered = await challenge(); + const signed = await createPaymentProof({ + buyerPrivateKey: deploymentOf(0).buyer.privateKey, + accepts: offered.accepts[0] as unknown as Record, + }); + // Same signature, now claiming the second chain's offer + const decoded = JSON.parse(Buffer.from(signed, 'base64').toString('utf8')); + decoded.accepted = offered.accepts[1]; + + const before = [await balancesOn(0), await balancesOn(1)]; + const refused = await invoke(header(decoded)); + expect(refused.statusCode).toBe(402); + expect(refused.json().code).toBe('PAYMENT_INVALID'); + // Chain 31339's EIP-712 domain cannot validate the signature made for 31338 + expect(refused.json().message).toBe('invalid_exact_evm_signature'); + expect([await balancesOn(0), await balancesOn(1)]).toEqual(before); + }); + + it('keeps replay keys apart for the same payer, token address and nonce on two chains', async () => { + const offered = await challenge(); + const nonce = `0x${'7'.repeat(64)}` as const; + for (const pick of [0, 1]) { + const proof = await createPaymentProof({ + buyerPrivateKey: deploymentOf(pick).buyer.privateKey, + accepts: offered.accepts[pick] as unknown as Record, + overrides: { nonce }, + }); + const paid = await invoke({ 'payment-signature': proof }); + expect(paid.statusCode).toBe(200); + } + }); + + it('serves a resource its own offer only, and settles it on that chain', async () => { + const offered = await challenge(OWN_RESOURCE_ID); + expect(offered.accepts.map((accepts) => accepts.network)).toEqual([CHAINS[1].network]); + const client = new x402Client((_version, accepts) => accepts[0] as never); + registerExactEvmScheme(client, { + signer: privateKeyToAccount(deploymentOf(1).buyer.privateKey), + networks: CHAINS.map((c) => c.network), + }); + client.setSpendControls(false); + const payload = await client.createPaymentPayload(offered); + + const before = await balancesOn(1); + const otherBefore = await balancesOn(0); + const paid = await invoke(header(payload), OWN_RESOURCE_ID); + expect(paid.statusCode).toBe(200); + const settlement = new x402HTTPClient(new x402Client()).getPaymentSettleResponse( + (name) => paid.headers[name.toLowerCase()] as string | undefined, + ); + await expectRealSettlement({ + rpcUrl: anvilOf(1).rpcUrl, + asset: deploymentOf(1).asset, + buyer: deploymentOf(1).buyer.address, + merchant: MERCHANT, + before, + after: await balancesOn(1), + amountBaseUnits: 1_000_000n, + txHash: settlement.transaction, + }); + expect(await balancesOn(0)).toEqual(otherBefore); + + // This resource does not accept the first chain's offer + const proof = await createPaymentProof({ + buyerPrivateKey: deploymentOf(0).buyer.privateKey, + accepts: (await challenge()).accepts[0] as unknown as Record, + }); + const unchanged = await balancesOn(0); + const refused = await invoke({ 'payment-signature': proof }, OWN_RESOURCE_ID); + expect(refused.statusCode).toBe(402); + expect(refused.json().code).toBe('PAYMENT_INVALID'); + // The offer is on another network, which verify checks before the facilitator + expect(refused.json().message).toBe('invalid_network'); + expect(await balancesOn(0)).toEqual(unchanged); + }); + + it('checks the health of both chains, each offer once', async () => { + const health = await provider.health(); + expect(health.status).toBe('pass'); + for (const { network } of CHAINS) expect(health.detail).toContain(network); + // The shared offer is health-checked once + expect(health.components?.map((component) => component.status)).toEqual(['pass', 'pass']); + }); +}); diff --git a/tests/e2e/payment/x402-near.e2e.test.ts b/tests/e2e/payment/x402-near.e2e.test.ts new file mode 100644 index 0000000..4b1aef5 --- /dev/null +++ b/tests/e2e/payment/x402-near.e2e.test.ts @@ -0,0 +1,524 @@ +/** + * NEAR `exact` settlement on a local sandbox through the gateway's remote-only + * NEAR path. The SDK client signs delegates; its HTTP facilitator uses a + * sandbox relayer. A NEP-141 contract occupies the testnet USDC account ID, + * and the sandbox uses `near:testnet`, the SDK's only testnet ID. Balances and + * receipts come from the sandbox. + */ +import { KeyPair } from '@near-js/crypto'; +import { x402Client, x402HTTPClient } from '@x402/core/client'; +import type { PaymentRequired } from '@x402/core/types'; +import { createClientNearSigner, decodeSignedDelegateB64 } from '@x402/near'; +import { ExactNearScheme } from '@x402/near/exact/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { type GatewayConfig, parseConfig } from '../../../src/config'; +import { type BackendExecutor, isCommerceError, NOOP_LOGGER } from '../../../src/core'; +import { createGateway, type GatewayInstance } from '../../../src/gateway'; +import { createConfiguredPaymentProviders } from '../../../src/gateway/payment-providers'; +import { createX402PaymentProvider } from '../../../src/payments/x402'; +import { createSqliteReceiptStore } from '../../../src/storage/receipts'; +import { + type LoopbackFacilitator, + startLoopbackFacilitator, +} from '../../fixtures/x402/loopback-facilitator'; +import { startLossyRpc } from '../../fixtures/x402/lossy-rpc'; +import { buildSignedDelegate } from '../../fixtures/x402/near-delegate'; +import { + createNearAccount, + deployUsdc, + ftBalance, + isNearSandboxAvailable, + NEAR_TESTNET_USDC, + type NearSandbox, + type NearTestAccount, + nearRpc, + startNearSandbox, + waitForFinal, +} from '../../fixtures/x402/near-sandbox'; +import { assertTransferOnChain } from '../../fixtures/x402/smoke/evidence'; + +const NETWORK = 'near:testnet'; +const RPC_PORT = 19030; +const RESOURCE_ID = 'market_report'; +const PRICE = 1_000_000n; + +const HAS_SANDBOX = isNearSandboxAvailable(); +if (!HAS_SANDBOX && process.env['CI']) { + throw new Error( + 'near-sandbox is not on PATH and NEAR_SANDBOX_BIN is unset, but CI is set. This suite ' + + 'settles on a local NEAR sandbox and must not be skipped in CI.', + ); +} +const describeWithSandbox = HAS_SANDBOX ? describe : describe.skip; + +let sandbox: NearSandbox; +let facilitator: LoopbackFacilitator; +let gateway: GatewayInstance; +let config: GatewayConfig; +let store: ReturnType; +let buyer: NearTestAccount; +let merchant: NearTestAccount; +let relayer: NearTestAccount; +let backendCalls = 0; +// Holds a request between reservation and settlement, which follows the backend +let backendDelayMs = 0; +let backendFails = false; +// Runs inside the backend call, after reservation and before settlement +let duringBackend: (() => Promise) | undefined; + +const backend: BackendExecutor = { + async call() { + backendCalls += 1; + await new Promise((resolve) => setTimeout(resolve, backendDelayMs)); + await duringBackend?.(); + if (backendFails) throw new Error('backend down'); + return { status: 200, body: { report: 'ok' }, headers: {}, durationMs: 1 }; + }, +}; + +async function balances() { + const [buyerBalance, merchantBalance] = await Promise.all([ + ftBalance(sandbox.rpcUrl, NEAR_TESTNET_USDC, buyer.accountId), + ftBalance(sandbox.rpcUrl, NEAR_TESTNET_USDC, merchant.accountId), + ]); + return { buyer: buyerBalance, merchant: merchantBalance }; +} + +async function expectBalances(expected: { buyer: bigint; merchant: bigint }) { + await waitForFinal(async () => { + const current = await balances(); + return current.buyer === expected.buyer && current.merchant === expected.merchant; + }); +} + +async function invoke(headers: Record = {}) { + return gateway.server.inject({ + method: 'POST', + url: `/api/resources/${RESOURCE_ID}/invoke`, + headers: { 'content-type': 'application/json', ...headers }, + payload: {}, + }); +} + +type NearProof = { accepted: Record; payload: { signedDelegateAction: string } }; + +async function sign(offered: PaymentRequired): Promise { + const client = new x402Client(); + client.register( + NETWORK, + new ExactNearScheme( + createClientNearSigner({ + accountId: buyer.accountId, + secretKey: buyer.secretKey, + rpcUrls: { [NETWORK]: sandbox.rpcUrl }, + }), + ), + ); + client.setSpendControls(false); + return (await client.createPaymentPayload(offered)) as unknown as NearProof; +} + +async function challenge(): Promise { + const unpaid = await invoke(); + expect(unpaid.statusCode).toBe(402); + return new x402HTTPClient(new x402Client()).getPaymentRequiredResponse( + (name) => unpaid.headers[name.toLowerCase()] as string | undefined, + ); +} + +const nonceOf = (proof: NearProof) => + decodeSignedDelegateB64(proof.payload.signedDelegateAction).delegate.nonce; + +const header = (proof: unknown) => ({ + 'payment-signature': Buffer.from(JSON.stringify(proof)).toString('base64'), +}); + +// The facilitator's next settlement needs final state that includes the last one +async function finalNonceOf(accountId: string, publicKey: string): Promise { + const key = await nearRpc<{ nonce: number }>(sandbox.rpcUrl, 'query', { + request_type: 'view_access_key', + finality: 'final', + account_id: accountId, + public_key: publicKey, + }); + return BigInt(key.nonce); +} + +beforeAll(async () => { + if (!HAS_SANDBOX) return; + sandbox = await startNearSandbox({ rpcPort: RPC_PORT }); + buyer = await createNearAccount(sandbox, 'buyer'); + merchant = await createNearAccount(sandbox, 'merchant'); + relayer = await createNearAccount(sandbox, 'relayer'); + await deployUsdc(sandbox, buyer.accountId, 100_000_000n, [merchant.accountId]); + facilitator = await startLoopbackFacilitator({ + near: { + network: NETWORK, + rpcUrl: sandbox.rpcUrl, + relayer: { accountId: relayer.accountId, secretKey: relayer.secretKey }, + }, + }); + config = parseConfig( + { + version: 1, + merchant: { id: 'near', name: 'NEAR', publicBaseUrl: 'http://127.0.0.1:8080' }, + server: { port: 8080, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } }, + resources: { + [RESOURCE_ID]: { + name: 'Market report', + backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: '1.00', currency: 'USDC' }, + expose: ['http'], + payments: ['x402'], + }, + }, + payments: { + x402: { + enabled: true, + // The testnet USDC by default; the sandbox holds a token at its id + network: NETWORK, + rpcUrl: sandbox.rpcUrl, + payTo: merchant.accountId, + maxTimeoutSeconds: 60, + facilitator: { mode: 'remote', url: facilitator.url, auth: { type: 'none' } }, + }, + }, + }, + {}, + ); + store = createSqliteReceiptStore({ path: ':memory:' }); + await store.init(); + gateway = await createGateway({ + config, + store, + paymentProviders: createConfiguredPaymentProviders(config.payments, NOOP_LOGGER), + protocolAdapters: [], + backend, + }); +}, 180_000); + +afterAll(async () => { + await gateway?.close(); + await store?.close(); + await facilitator?.close(); + await sandbox?.stop(); +}); + +describeWithSandbox('x402 NEAR settlement on a local sandbox', () => { + it('settles an SDK-signed delegate and its inner ft_transfer on chain', async () => { + const offered = await challenge(); + expect(offered.accepts[0]?.extra).toEqual({}); + const before = await balances(); + + const paid = await invoke(header(await sign(offered))); + expect(paid.statusCode).toBe(200); + const settlement = new x402HTTPClient(new x402Client()).getPaymentSettleResponse( + (name) => paid.headers[name.toLowerCase()] as string | undefined, + ); + expect(settlement).toMatchObject({ success: true, network: NETWORK, payer: buyer.accountId }); + + // The relayer's outer transaction, and the token's own receipt within it + const outcome = await nearRpc<{ + receipts_outcome: { outcome: { executor_id: string; status: Record } }[]; + }>(sandbox.rpcUrl, 'tx', { + tx_hash: settlement.transaction, + sender_account_id: relayer.accountId, + wait_until: 'FINAL', + }); + const tokenReceipts = outcome.receipts_outcome.filter( + (receipt) => receipt.outcome.executor_id === NEAR_TESTNET_USDC, + ); + expect(tokenReceipts.length).toBeGreaterThan(0); + for (const receipt of tokenReceipts) + expect(receipt.outcome.status).toHaveProperty('SuccessValue'); + await expectBalances({ buyer: before.buyer - PRICE, merchant: before.merchant + PRICE }); + + // The public-network smoke harness reads the ft_transfer from the transaction's receipts + const transfer = { + asset: NEAR_TESTNET_USDC, + from: buyer.accountId, + to: merchant.accountId, + amount: PRICE, + reference: settlement.transaction, + }; + await assertTransferOnChain('near', sandbox.rpcUrl, transfer, [relayer.accountId]); + await expect( + assertTransferOnChain('near', sandbox.rpcUrl, { ...transfer, amount: PRICE + 1n }, [ + relayer.accountId, + ]), + ).rejects.toThrow(/no ft_transfer of/); + + const [receipt] = await store.listReceipts({ limit: 1 }); + expect(receipt?.payment).toMatchObject({ + network: NETWORK, + externalReference: settlement.transaction, + payer: buyer.accountId, + }); + }); + + it.each<[string, (offer: Record) => void, string]>([ + [ + 'another account', + (offer) => { + offer['payTo'] = 'attacker.test.near'; + }, + 'invalid_exact_near_payload_recipient_mismatch', + ], + [ + 'a smaller amount', + (offer) => { + offer['amount'] = '1'; + }, + 'invalid_exact_near_payload_amount_mismatch', + ], + ])('refuses a delegate paying %s, moving nothing', async (_case, tamper, reason) => { + const offered = await challenge(); + const original = offered.accepts[0] as unknown as Record; + // Signed for altered terms, presented as the offer the gateway made + const altered = { ...original }; + tamper(altered); + const proof = await sign({ ...offered, accepts: [altered as never] }); + proof.accepted = original; + + const before = await balances(); + const refused = await invoke(header(proof)); + expect(refused.statusCode).toBe(402); + expect(refused.json().message).toBe(reason); + expect(await balances()).toEqual(before); + }); + + it('refuses a delegate signed with a function-call access key', async () => { + // Such a key cannot attach the one yoctoNEAR an ft_transfer needs + const limited = KeyPair.fromRandom('ed25519'); + await sandbox + .account(buyer.accountId, buyer.keyPair) + .addKey(limited.getPublicKey(), NEAR_TESTNET_USDC, ['ft_transfer'], 10n ** 23n); + await waitForFinal(async () => { + await finalNonceOf(buyer.accountId, limited.getPublicKey().toString()); + return true; + }); + const offered = await challenge(); + const nonce = (await finalNonceOf(buyer.accountId, limited.getPublicKey().toString())) + 1n; + const status = await nearRpc<{ sync_info: { latest_block_height: number } }>( + sandbox.rpcUrl, + 'status', + [], + ); + const { signedDelegateAction } = await buildSignedDelegate({ + keyPair: limited, + senderId: buyer.accountId, + receiverId: NEAR_TESTNET_USDC, + ftReceiver: merchant.accountId, + amount: PRICE.toString(), + nonce, + maxBlockHeight: BigInt(status.sync_info.latest_block_height) + 30n, + }); + + const before = await balances(); + const refused = await invoke( + header({ x402Version: 2, accepted: offered.accepts[0], payload: { signedDelegateAction } }), + ); + expect(refused.statusCode).toBe(402); + expect(refused.json().message).toBe('invalid_exact_near_function_call_key_not_allowed'); + expect(await balances()).toEqual(before); + }); + + it('settles one delegate once when presented on two requests at the same time', async () => { + const proof = await sign(await challenge()); + const before = await balances(); + const calls = backendCalls; + const statuses = (await Promise.all([invoke(header(proof)), invoke(header(proof))])).map( + (response) => response.statusCode, + ); + expect(statuses.filter((status) => status === 200)).toHaveLength(1); + expect(backendCalls - calls).toBe(1); + await expectBalances({ buyer: before.buyer - PRICE, merchant: before.merchant + PRICE }); + }); + + it('reserves the access-key nonce, not the delegate bytes, while the first is settling', async () => { + const proof = await sign(await challenge()); + // Different delegate bytes and signature, same transfer, key and nonce + const first = decodeSignedDelegateB64(proof.payload.signedDelegateAction).delegate; + const { signedDelegateAction } = await buildSignedDelegate({ + keyPair: buyer.keyPair, + senderId: buyer.accountId, + receiverId: NEAR_TESTNET_USDC, + ftReceiver: merchant.accountId, + amount: PRICE.toString(), + nonce: first.nonce, + maxBlockHeight: first.maxBlockHeight, + gas: 40_000_000_000_000n, + }); + expect(signedDelegateAction).not.toBe(proof.payload.signedDelegateAction); + const sibling = { ...proof, payload: { signedDelegateAction } }; + + const before = await balances(); + backendDelayMs = 2_000; + try { + const [firstResponse, second] = await Promise.all([ + invoke(header(proof)), + new Promise((resolve) => setTimeout(resolve, 500)).then(() => invoke(header(sibling))), + ]); + expect(firstResponse.statusCode).toBe(200); + expect(second.statusCode).toBe(409); + expect(second.json().code).toBe('PAYMENT_REPLAYED'); + } finally { + backendDelayMs = 0; + } + await expectBalances({ buyer: before.buyer - PRICE, merchant: before.merchant + PRICE }); + }); + + it('refuses a settled delegate presented again, moving nothing', async () => { + const proof = await sign(await challenge()); + expect((await invoke(header(proof))).statusCode).toBe(200); + const nonce = decodeSignedDelegateB64(proof.payload.signedDelegateAction).delegate.nonce; + await waitForFinal( + async () => + (await finalNonceOf(buyer.accountId, buyer.keyPair.getPublicKey().toString())) >= nonce, + ); + const after = await balances(); + + const replayed = await invoke(header(proof)); + expect(replayed.statusCode).toBe(402); + expect(replayed.json().message).toBe( + 'invalid_exact_near_payload_delegate_action_nonce_already_used', + ); + expect(await balances()).toEqual(after); + }); + + it('pays once when the reference client retries a failed backend call with the same nonce', async () => { + const first = await sign(await challenge()); + const before = await balances(); + const calls = backendCalls; + backendFails = true; + try { + const failed = await invoke(header(first)); + expect(failed.statusCode).toBe(502); + expect(failed.json().code).toBe('BACKEND_ERROR'); + } finally { + backendFails = false; + } + expect(await balances()).toEqual(before); + + // Nothing reached the chain, so the client's next delegate repeats the nonce + const retry = await sign(await challenge()); + expect(nonceOf(retry)).toBe(nonceOf(first)); + const paid = await invoke(header(retry)); + + expect(paid.statusCode).toBe(200); + expect(backendCalls - calls).toBe(2); + await expectBalances({ buyer: before.buyer - PRICE, merchant: before.merchant + PRICE }); + }); + + it('pays once when the reference client retries a settlement refused before submission', async () => { + // A second full-access key moves the buyer's tokens without spending the + // nonce of the key that signs delegates + const spare = KeyPair.fromRandom('ed25519'); + await sandbox.account(buyer.accountId, buyer.keyPair).addFullAccessKey(spare.getPublicKey()); + await waitForFinal(async () => { + await finalNonceOf(buyer.accountId, spare.getPublicKey().toString()); + return true; + }); + const ftTransfer = (from: string, keyPair: KeyPair, to: string, amount: bigint) => + sandbox.account(from, keyPair).functionCall({ + contractId: NEAR_TESTNET_USDC, + methodName: 'ft_transfer', + args: { receiver_id: to, amount: amount.toString() }, + gas: 30_000_000_000_000n, + attachedDeposit: 1n, + }); + const first = await sign(await challenge()); + const before = await balances(); + const calls = backendCalls; + + // Verified with funds, then drained while the backend runs: the + // facilitator's check at settle refuses before submitting anything + duringBackend = async () => { + await ftTransfer(buyer.accountId, spare, merchant.accountId, before.buyer); + await waitForFinal(async () => (await balances()).buyer === 0n); + }; + try { + const refused = await invoke(header(first)); + expect(refused.statusCode).toBe(402); + } finally { + duringBackend = undefined; + } + const [attempt] = await store.listPaymentAttempts({ limit: 1 }); + expect(attempt).toMatchObject({ status: 'rejected', rejectionReason: 'insufficient_funds' }); + expect( + await finalNonceOf(buyer.accountId, buyer.keyPair.getPublicKey().toString()), + ).toBeLessThan(nonceOf(first)); + + await ftTransfer(merchant.accountId, merchant.keyPair, buyer.accountId, before.buyer); + await expectBalances(before); + // The nonce is still unused, so the client's next delegate repeats it + const retry = await sign(await challenge()); + expect(nonceOf(retry)).toBe(nonceOf(first)); + const paid = await invoke(header(retry)); + + expect(paid.statusCode).toBe(200); + expect(backendCalls - calls).toBe(2); + await expectBalances({ buyer: before.buyer - PRICE, merchant: before.merchant + PRICE }); + }); + + it('a submission whose RPC response is lost is uncertain, never a rejection', async () => { + const rpc = await startLossyRpc(sandbox.rpcUrl, 'send_tx'); + const lossyFacilitator = await startLoopbackFacilitator({ + near: { + network: NETWORK, + rpcUrl: rpc.url, + relayer: { accountId: relayer.accountId, secretKey: relayer.secretKey }, + }, + }); + try { + const provider = createX402PaymentProvider({ + network: NETWORK, + rpcUrl: sandbox.rpcUrl, + asset: NEAR_TESTNET_USDC, + assetDecimals: 6, + payTo: merchant.accountId, + facilitator: { mode: 'remote', url: lossyFacilitator.url, auth: { type: 'none' } }, + }); + const resource = config.resources[0] as (typeof config.resources)[number]; + const requirement = await provider.createRequirement({ + requestId: 'req-lossy', + resource, + amount: '1.00', + currency: 'USDC', + requestedAt: new Date().toISOString(), + }); + const proof = await sign(requirement.challenge.envelope as unknown as PaymentRequired); + const context = { + requestId: 'req-lossy', + resource, + requirement, + submission: { method: 'x402' as const, payload: header(proof)['payment-signature'] }, + }; + const before = await balances(); + const verification = await provider.verify(context); + expect(verification.status).toBe('verified'); + + // The SDK reports the lost response as `settlement_failed` without a + // hash, while the delegated transfer executes + await expect(provider.settle({ ...context, verification })).rejects.toSatisfy( + (err: unknown) => isCommerceError(err) && err.code === 'PAYMENT_PROVIDER_UNAVAILABLE', + ); + await expectBalances({ buyer: before.buyer - PRICE, merchant: before.merchant + PRICE }); + } finally { + await lossyFacilitator.close(); + await rpc.close(); + } + }); + + it('fails health: the sandbox is not the chain its offer names', async () => { + const provider = createConfiguredPaymentProviders(config.payments, NOOP_LOGGER)[0]; + const health = await provider?.health(); + expect(health?.status).toBe('fail'); + const status = await nearRpc<{ chain_id: string }>(sandbox.rpcUrl, 'status', []); + expect(health?.detail).toContain( + `serves chain ${status.chain_id}, expected testnet (NEAR Testnet)`, + ); + }); +}); diff --git a/tests/e2e/payment/x402-permit2.e2e.test.ts b/tests/e2e/payment/x402-permit2.e2e.test.ts new file mode 100644 index 0000000..45a18f3 --- /dev/null +++ b/tests/e2e/payment/x402-permit2.e2e.test.ts @@ -0,0 +1,314 @@ +/** + * Settles MockUSDC on Anvil through canonical Permit2 and x402 exact proxy + * code at their addresses, using the in-process facilitator. The buyer + * approves Permit2; the gateway sends no approval. + */ +import { x402Client, x402HTTPClient } from '@x402/core/client'; +import type { PaymentRequired } from '@x402/core/types'; +import { PERMIT2_ADDRESS } from '@x402/evm'; +import { registerExactEvmScheme } from '@x402/evm/exact/client'; +import { createWalletClient, erc20Abi, http, maxUint256, publicActions } from 'viem'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { anvil as anvilChain } from 'viem/chains'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { type GatewayConfig, parseConfig } from '../../../src/config'; +import { type BackendExecutor, isCommerceError, NOOP_LOGGER } from '../../../src/core'; +import { createGateway, type GatewayInstance } from '../../../src/gateway'; +import { createConfiguredPaymentProviders } from '../../../src/gateway/payment-providers'; +import { createX402PaymentProvider } from '../../../src/payments/x402'; +import { type AnvilHandle, deployLocalChain, startAnvil } from '../../../src/payments/x402/testing'; +import { createSqliteReceiptStore } from '../../../src/storage/receipts'; +import { startLossyRpc } from '../../fixtures/x402/lossy-rpc'; +import { installMulticall3, installPermit2 } from '../../fixtures/x402/permit2-contracts'; +import { + assertBalanceDelta, + expectRealSettlement, + readBalances, +} from '../../fixtures/x402/settlement'; + +const PORT = 18794; +const RESOURCE_ID = 'market_report'; +const MERCHANT = privateKeyToAccount(generatePrivateKey()).address; + +let anvil: AnvilHandle; +let deployment: Awaited>; +let config: GatewayConfig; +let gateway: GatewayInstance; +let store: ReturnType; +let backendCalls = 0; + +const backend: BackendExecutor = { + async call() { + backendCalls += 1; + return { status: 200, body: { report: 'ok' }, headers: {}, durationMs: 1 }; + }, +}; + +const balances = () => + readBalances({ + rpcUrl: anvil.rpcUrl, + asset: deployment.asset, + buyer: deployment.buyer.address, + merchant: MERCHANT, + }); + +// A fresh provider has no cached health result +const freshHealth = () => + ( + createConfiguredPaymentProviders(config.payments, NOOP_LOGGER)[0] as { + health(): Promise<{ status: string; detail?: string }>; + } + ).health(); + +async function invoke(headers: Record = {}) { + return gateway.server.inject({ + method: 'POST', + url: `/api/resources/${RESOURCE_ID}/invoke`, + headers: { 'content-type': 'application/json', ...headers }, + payload: {}, + }); +} + +type Permit2Proof = { + payload: { + permit2Authorization: { deadline: string; witness: { to: string } }; + }; +}; + +async function signedProof(): Promise { + const unpaid = await invoke(); + expect(unpaid.statusCode).toBe(402); + return sign( + new x402HTTPClient(new x402Client()).getPaymentRequiredResponse( + (name) => unpaid.headers[name.toLowerCase()] as string | undefined, + ), + ); +} + +async function sign(offered: PaymentRequired): Promise { + const client = new x402Client(); + registerExactEvmScheme(client, { + signer: privateKeyToAccount(deployment.buyer.privateKey), + networks: ['eip155:31337'], + }); + client.setSpendControls(false); + return (await client.createPaymentPayload(offered)) as unknown as Permit2Proof; +} + +const header = (proof: unknown) => ({ + 'payment-signature': Buffer.from(JSON.stringify(proof)).toString('base64'), +}); + +beforeAll(async () => { + anvil = await startAnvil({ port: PORT, silent: true }); + deployment = await deployLocalChain({ rpcUrl: anvil.rpcUrl, buyerInitialBalance: '10.00' }); + // Public chains have it; the SDK names a missing allowance through it + await installMulticall3(anvil.rpcUrl); + config = parseConfig( + { + version: 1, + merchant: { id: 'permit2', name: 'Permit2', publicBaseUrl: 'http://127.0.0.1:8080' }, + server: { port: 8080, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } }, + resources: { + [RESOURCE_ID]: { + name: 'Market report', + backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: '1.00', currency: 'USDC' }, + expose: ['http'], + payments: ['x402'], + }, + }, + payments: { + x402: { + enabled: true, + network: 'eip155:31337', + rpcUrl: anvil.rpcUrl, + // No token domain: the buyer signs Permit2's + asset: deployment.asset, + assetDecimals: deployment.assetDecimals, + assetTransferMethod: 'permit2', + payTo: MERCHANT, + maxTimeoutSeconds: 120, + facilitator: { mode: 'local', signerPrivateKey: deployment.facilitator.privateKey }, + }, + }, + }, + {}, + ); + store = createSqliteReceiptStore({ path: ':memory:' }); + await store.init(); + gateway = await createGateway({ + config, + store, + paymentProviders: createConfiguredPaymentProviders(config.payments, NOOP_LOGGER), + protocolAdapters: [], + backend, + }); +}, 120_000); + +afterAll(async () => { + await gateway?.close(); + await store?.close(); + await anvil?.stop(); +}); + +// The cases run in order: Permit2 is installed by the first, approved by the second +describe('x402 Permit2 settlement on Anvil', () => { + it('fails health until Permit2 and its proxy have code', async () => { + const missing = await freshHealth(); + expect(missing.status).toBe('fail'); + expect(missing.detail).toContain(`No Permit2 contract code found at ${PERMIT2_ADDRESS}`); + + await installPermit2(anvil.rpcUrl); + const installed = await freshHealth(); + expect(installed.status).toBe('pass'); + expect(installed.detail).toContain('Permit2 and its x402 proxy have code'); + }); + + it('answers a buyer that has not approved Permit2 with 412 and the challenge, moving nothing', async () => { + const proof = await signedProof(); + const before = await balances(); + const refused = await invoke(header(proof)); + expect(refused.statusCode).toBe(412); + expect(refused.json()).toMatchObject({ + code: 'PAYMENT_INVALID', + message: 'permit2_allowance_required', + }); + const challenge = JSON.parse( + Buffer.from(String(refused.headers['payment-required']), 'base64').toString('utf8'), + ); + expect(challenge.error).toBe('permit2_allowance_required'); + expect(challenge.accepts[0].extra.assetTransferMethod).toBe('permit2'); + expect(await balances()).toEqual(before); + expect(backendCalls).toBe(0); + + const buyer = createWalletClient({ + account: privateKeyToAccount(deployment.buyer.privateKey), + chain: anvilChain, + transport: http(anvil.rpcUrl), + }).extend(publicActions); + const hash = await buyer.writeContract({ + address: deployment.asset, + abi: erc20Abi, + functionName: 'approve', + args: [PERMIT2_ADDRESS, maxUint256], + }); + await buyer.waitForTransactionReceipt({ hash }); + }); + + it('settles an SDK-signed Permit2 payment on chain', async () => { + const before = await balances(); + const paid = await invoke(header(await signedProof())); + expect(paid.statusCode).toBe(200); + + const settlement = new x402HTTPClient(new x402Client()).getPaymentSettleResponse( + (name) => paid.headers[name.toLowerCase()] as string | undefined, + ); + await expectRealSettlement({ + rpcUrl: anvil.rpcUrl, + asset: deployment.asset, + buyer: deployment.buyer.address, + merchant: MERCHANT, + before, + after: await balances(), + amountBaseUnits: 1_000_000n, + txHash: settlement.transaction, + }); + const [receipt] = await store.listReceipts({ limit: 1 }); + expect(receipt?.payment?.payer).toBe(deployment.buyer.address); + }); + + it('a broadcast whose RPC response is lost is uncertain, never a rejection', async () => { + const rpc = await startLossyRpc(anvil.rpcUrl); + try { + const lossy = createX402PaymentProvider({ + network: 'eip155:31337', + rpcUrl: rpc.url, + asset: deployment.asset, + assetDecimals: deployment.assetDecimals, + assetTransferMethod: 'permit2', + payTo: MERCHANT, + facilitator: { mode: 'local', signerPrivateKey: deployment.facilitator.privateKey }, + }); + const requirement = await lossy.createRequirement({ + requestId: 'req-lossy', + resource: config.resources[0] as (typeof config.resources)[number], + amount: '1.00', + currency: 'USDC', + requestedAt: new Date().toISOString(), + }); + const proof = await sign(requirement.challenge.envelope as unknown as PaymentRequired); + const context = { + requestId: 'req-lossy', + resource: config.resources[0] as (typeof config.resources)[number], + requirement, + submission: { method: 'x402' as const, payload: header(proof)['payment-signature'] }, + }; + const before = await balances(); + const verification = await lossy.verify(context); + expect(verification.status).toBe('verified'); + + // The SDK reports the lost response as its catch-all with the message + // appended and no hash, while the transfer has landed + await expect(lossy.settle({ ...context, verification })).rejects.toSatisfy( + (err: unknown) => isCommerceError(err) && err.code === 'PAYMENT_PROVIDER_UNAVAILABLE', + ); + assertBalanceDelta(before, await balances(), 1_000_000n); + } finally { + await rpc.close(); + } + }); + + it.each<[string, string, (proof: Permit2Proof) => void]>([ + [ + 'a swapped witness recipient', + 'invalid_permit2_recipient_mismatch', + (proof) => { + proof.payload.permit2Authorization.witness.to = privateKeyToAccount( + generatePrivateKey(), + ).address; + }, + ], + [ + 'an extended deadline the buyer never signed', + 'invalid_permit2_signature', + (proof) => { + const auth = proof.payload.permit2Authorization; + auth.deadline = String(BigInt(auth.deadline) + 60n); + }, + ], + ])('refuses %s, moving nothing', async (_case, reason, tamper) => { + const proof = await signedProof(); + tamper(proof); + const before = await balances(); + const refused = await invoke(header(proof)); + expect(refused.statusCode).toBe(402); + expect(refused.json().message).toBe(reason); + expect(await balances()).toEqual(before); + }); + + it('settles one Permit2 authorization once when presented on two requests at the same time', async () => { + const proof = await signedProof(); + const before = await balances(); + const calls = backendCalls; + const statuses = (await Promise.all([invoke(header(proof)), invoke(header(proof))])).map( + (response) => response.statusCode, + ); + expect(statuses.filter((status) => status === 200)).toHaveLength(1); + expect(backendCalls - calls).toBe(1); + const after = await balances(); + expect(before.buyer - after.buyer).toBe(1_000_000n); + expect(after.merchant - before.merchant).toBe(1_000_000n); + }); + + it('publishes the transfer method with the offer', async () => { + const wellKnown = await gateway.server.inject({ + method: 'GET', + url: '/.well-known/agent-commerce', + }); + expect(wellKnown.json().payments.x402).toMatchObject({ assetTransferMethod: 'permit2' }); + expect(wellKnown.json().payments.x402.assetName).toBeUndefined(); + }); +}); diff --git a/tests/e2e/payment/x402-settlement.e2e.test.ts b/tests/e2e/payment/x402-settlement.e2e.test.ts index 9aca274..1d5ae9c 100644 --- a/tests/e2e/payment/x402-settlement.e2e.test.ts +++ b/tests/e2e/payment/x402-settlement.e2e.test.ts @@ -115,7 +115,7 @@ async function startGateway(): Promise { payments: { x402: { enabled: true, - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: anvil.rpcUrl, asset: deployment.asset, assetName: deployment.assetName, @@ -231,7 +231,7 @@ beforeAll(async () => { anvil = await startAnvil({ port: PORT, silent: true }); deployment = await deployLocalChain({ rpcUrl: anvil.rpcUrl, buyerInitialBalance: '100.00' }); provider = createX402PaymentProvider({ - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: anvil.rpcUrl, asset: deployment.asset, assetName: deployment.assetName, @@ -523,7 +523,7 @@ describe('x402 settlement - real local chain', () => { it('10. provider failure: RPC unreachable yields PAYMENT_PROVIDER_UNAVAILABLE, not a silent pass', async () => { const unavailableProvider = createX402PaymentProvider({ - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: await unreachableRpcUrl(), asset: deployment.asset, assetName: deployment.assetName, @@ -562,7 +562,7 @@ describe('x402 settlement - real local chain', () => { const rpc = await startLossyRpc(anvil.rpcUrl); try { const lossyProvider = createX402PaymentProvider({ - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: rpc.url, asset: deployment.asset, assetName: deployment.assetName, @@ -617,7 +617,7 @@ describe('x402 settlement - real local chain', () => { // This one pays with the SDK's own client, as an off-the-shelf buyer would. const buyer = privateKeyToAccount(deployment.buyer.privateKey); const client = new x402Client(); - registerExactEvmScheme(client, { signer: buyer, networks: ['eip155:84532'] }); + registerExactEvmScheme(client, { signer: buyer, networks: ['eip155:31337'] }); // The SDK's default spend controls allow only assets it recognizes, and // MockUSDC is not one, so they are off for this local-only case. A real // buyer would allowlist its asset. @@ -724,7 +724,7 @@ describe('x402 settlement - real local chain', () => { const client = new x402Client(); registerExactEvmScheme(client, { signer: privateKeyToAccount(deployment.buyer.privateKey), - networks: ['eip155:84532'], + networks: ['eip155:31337'], }); client.setSpendControls(false); // MockUSDC is not in the SDK's asset list const payload = await client.createPaymentPayload( @@ -761,7 +761,7 @@ describe('x402 settlement - real local chain', () => { }); }); - it('15. leaves funds untouched when the backend fails before settlement', async () => { + it('15. leaves funds untouched when the backend fails before settlement, and the same proof pays once on retry', async () => { const proof = await gatewayProof(); const receiptsBefore = (await store.listReceipts()).length; const before = await balances(); @@ -780,5 +780,149 @@ describe('x402 settlement - real local chain', () => { expect(after.buyer).toBe(before.buyer); expect(after.merchant).toBe(before.merchant); expect(await store.listReceipts()).toHaveLength(receiptsBefore); + + // The authorization is unspent, so presenting it again settles it once + const again = await invoke({ [PAYMENT_HEADER]: proof }); + + expect(again.statusCode).toBe(200); + const [receipt] = await store.listReceipts({ limit: 1 }); + await expectRealSettlement({ + rpcUrl: anvil.rpcUrl, + asset: deployment.asset, + buyer: deployment.buyer.address, + merchant: deployment.merchant.address, + before, + after: await balances(), + amountBaseUnits: 1_000_000n, + txHash: receipt?.payment?.externalReference as string, + }); + }); +}); + +describe('x402 settlement - several offers', () => { + it('13. the SDK client pays the second offer through the gateway, and only that token moves', async () => { + // A second, independent MockUSDC on the same chain, which funds the buyer + const second = await deployLocalChain({ rpcUrl: anvil.rpcUrl, buyerInitialBalance: '10.00' }); + const option = (token: typeof deployment) => ({ + network: 'eip155:31337', + payTo: deployment.merchant.address, + asset: token.asset, + assetName: token.assetName, + assetVersion: token.assetVersion, + assetDecimals: token.assetDecimals, + }); + const config = parseConfig( + { + version: 1, + merchant: { + id: 'x402-offers', + name: 'x402 offers', + publicBaseUrl: 'http://127.0.0.1:8080', + }, + server: { port: 8080, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } }, + resources: { + [RESOURCE_ID]: { + name: 'Market report', + backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: '1.00', currency: 'USDC' }, + expose: ['http'], + payments: ['x402'], + }, + }, + payments: { + x402: { + enabled: true, + rpcUrl: anvil.rpcUrl, + options: [option(deployment), option(second)], + maxTimeoutSeconds: 120, + facilitator: { mode: 'local', signerPrivateKey: deployment.facilitator.privateKey }, + }, + }, + }, + {}, + ); + const offersStore = createSqliteReceiptStore({ path: ':memory:' }); + await offersStore.init(); + const offers = await createGateway({ + config, + store: offersStore, + paymentProviders: createConfiguredPaymentProviders(config.payments, NOOP_LOGGER), + protocolAdapters: [], + backend, + }); + try { + const call = (headers: Record = {}) => + offers.server.inject({ + method: 'POST', + url: `/api/resources/${RESOURCE_ID}/invoke`, + headers: { 'content-type': 'application/json', ...headers }, + payload: {}, + }); + + const unpaid = await call(); + expect(unpaid.statusCode).toBe(402); + const http = new x402HTTPClient(new x402Client()); + const challenge = http.getPaymentRequiredResponse( + (name) => unpaid.headers[name.toLowerCase()] as string | undefined, + ); + expect(challenge.accepts.map((a) => a.asset)).toEqual([deployment.asset, second.asset]); + + const client = new x402Client((_version, accepts) => accepts[1] as never); + registerExactEvmScheme(client, { + signer: privateKeyToAccount(deployment.buyer.privateKey), + networks: ['eip155:31337'], + }); + client.setSpendControls(false); + const payload = await client.createPaymentPayload(challenge); + + const query = (asset: `0x${string}`) => + readBalances({ + rpcUrl: anvil.rpcUrl, + asset, + buyer: deployment.buyer.address, + merchant: deployment.merchant.address, + }); + const firstBefore = await query(deployment.asset); + const secondBefore = await query(second.asset); + + const paid = await call({ + [PAYMENT_HEADER]: Buffer.from(JSON.stringify(payload)).toString('base64'), + }); + expect(paid.statusCode).toBe(200); + + const settlement = http.getPaymentSettleResponse( + (name) => paid.headers[name.toLowerCase()] as string | undefined, + ); + await expectRealSettlement({ + rpcUrl: anvil.rpcUrl, + asset: second.asset, + buyer: deployment.buyer.address, + merchant: deployment.merchant.address, + before: secondBefore, + after: await query(second.asset), + amountBaseUnits: 1_000_000n, + txHash: settlement.transaction, + }); + expect(await query(deployment.asset)).toEqual(firstBefore); + + // The receipt names the token that actually settled + const [receipt] = await offersStore.listReceipts({ limit: 1 }); + expect(receipt?.payment?.asset?.toLowerCase()).toBe(second.asset.toLowerCase()); + + // Discovery lists both offers, without either RPC URL + const wellKnown = await offers.server.inject({ + method: 'GET', + url: '/.well-known/agent-commerce', + }); + expect(wellKnown.json().payments.x402.options.map((o: { asset: string }) => o.asset)).toEqual( + [deployment.asset, second.asset], + ); + expect(wellKnown.body).not.toContain(anvil.rpcUrl); + } finally { + await offers.close(); + await offersStore.close(); + } }); }); diff --git a/tests/e2e/payment/x402-solana.e2e.test.ts b/tests/e2e/payment/x402-solana.e2e.test.ts new file mode 100644 index 0000000..526935e --- /dev/null +++ b/tests/e2e/payment/x402-solana.e2e.test.ts @@ -0,0 +1,364 @@ +/** + * Solana `exact` settlement on `solana-test-validator`, signed by the SDK + * client and served over HTTP by its facilitator. Solana uses the gateway's + * remote path only. The validator holds a USDC-style mint at the devnet USDC + * address and uses the devnet CAIP-2 id; the SDK accepts only known cluster + * ids. Balances come from the validator's token accounts. + */ +import { generateKeyPairSigner, type KeyPairSigner } from '@solana/kit'; +import { x402Client, x402HTTPClient } from '@x402/core/client'; +import type { PaymentRequired } from '@x402/core/types'; +import { ExactSvmScheme } from '@x402/svm/exact/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { type GatewayConfig, parseConfig } from '../../../src/config'; +import { type BackendExecutor, isCommerceError, NOOP_LOGGER } from '../../../src/core'; +import { createGateway, type GatewayInstance } from '../../../src/gateway'; +import { createConfiguredPaymentProviders } from '../../../src/gateway/payment-providers'; +import { createX402PaymentProvider } from '../../../src/payments/x402'; +import { createSqliteReceiptStore } from '../../../src/storage/receipts'; +import { + type LoopbackFacilitator, + startLoopbackFacilitator, +} from '../../fixtures/x402/loopback-facilitator'; +import { startLossyRpc } from '../../fixtures/x402/lossy-rpc'; +import { assertTransferOnChain } from '../../fixtures/x402/smoke/evidence'; +import { + associatedTokenAddress, + isSolanaValidatorAvailable, + mintAccount, + type SolanaValidator, + solanaRpc, + startSolanaValidator, + tokenAccount, + tokenBalance, + walletAccount, +} from '../../fixtures/x402/solana-validator'; +import { withSignatureBytes } from '../../fixtures/x402/svm-transaction'; + +const DEVNET = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'; +const USDC = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; +const RPC_PORT = 18899; +const RESOURCE_ID = 'market_report'; +const PRICE = 1_000_000n; + +const HAS_VALIDATOR = isSolanaValidatorAvailable(); +if (!HAS_VALIDATOR && process.env['CI']) { + throw new Error( + 'solana-test-validator is not on PATH, but CI is set. This suite settles on a ' + + 'local validator and must not be skipped in CI - install the Agave CLI in the job.', + ); +} +const describeWithValidator = HAS_VALIDATOR ? describe : describe.skip; + +let validator: SolanaValidator; +let facilitator: LoopbackFacilitator; +let gateway: GatewayInstance; +let config: GatewayConfig; +let store: ReturnType; +let buyer: KeyPairSigner; +let feePayer: KeyPairSigner; +let merchant: string; +let backendCalls = 0; +// Holds a request between reservation and settlement, which follows the backend +let backendDelayMs = 0; + +const backend: BackendExecutor = { + async call() { + backendCalls += 1; + await new Promise((resolve) => setTimeout(resolve, backendDelayMs)); + return { status: 200, body: { report: 'ok' }, headers: {}, durationMs: 1 }; + }, +}; + +async function balances() { + const [buyerBalance, merchantBalance] = await Promise.all([ + tokenBalance(validator.rpcUrl, await associatedTokenAddress(buyer.address, USDC)), + tokenBalance(validator.rpcUrl, await associatedTokenAddress(merchant, USDC)), + ]); + return { buyer: buyerBalance, merchant: merchantBalance }; +} + +async function invoke(headers: Record = {}) { + return gateway.server.inject({ + method: 'POST', + url: `/api/resources/${RESOURCE_ID}/invoke`, + headers: { 'content-type': 'application/json', ...headers }, + payload: {}, + }); +} + +type SvmProof = { + accepted: Record; + payload: { transaction: string }; +}; + +async function sign(offered: PaymentRequired): Promise { + const client = new x402Client(); + client.register(DEVNET, new ExactSvmScheme(buyer, { rpcUrl: validator.rpcUrl })); + client.setSpendControls(false); + return (await client.createPaymentPayload(offered)) as unknown as SvmProof; +} + +async function challenge(): Promise { + const unpaid = await invoke(); + expect(unpaid.statusCode).toBe(402); + return new x402HTTPClient(new x402Client()).getPaymentRequiredResponse( + (name) => unpaid.headers[name.toLowerCase()] as string | undefined, + ); +} + +const header = (proof: unknown) => ({ + 'payment-signature': Buffer.from(JSON.stringify(proof)).toString('base64'), +}); + +async function waitForBalances(expected: { buyer: bigint; merchant: bigint }) { + const deadline = Date.now() + 15_000; + const settled = (b: { buyer: bigint; merchant: bigint }) => + b.buyer === expected.buyer && b.merchant === expected.merchant; + let current = await balances(); + while (Date.now() < deadline && !settled(current)) { + await new Promise((resolve) => setTimeout(resolve, 250)); + current = await balances(); + } + expect(current).toEqual(expected); +} + +beforeAll(async () => { + if (!HAS_VALIDATOR) return; + buyer = await generateKeyPairSigner(); + feePayer = await generateKeyPairSigner(); + merchant = (await generateKeyPairSigner()).address; + validator = await startSolanaValidator({ + rpcPort: RPC_PORT, + accounts: [ + mintAccount(USDC, 6, 100_000_000n), + await tokenAccount(buyer.address, USDC, 100_000_000n), + await tokenAccount(merchant, USDC, 0n), + walletAccount(feePayer.address, 10n), + ], + }); + facilitator = await startLoopbackFacilitator({ + svm: { network: DEVNET, rpcUrl: validator.rpcUrl, feePayer }, + }); + config = parseConfig( + { + version: 1, + merchant: { id: 'solana', name: 'Solana', publicBaseUrl: 'http://127.0.0.1:8080' }, + server: { port: 8080, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } }, + resources: { + [RESOURCE_ID]: { + name: 'Market report', + backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: '1.00', currency: 'USDC' }, + expose: ['http'], + payments: ['x402'], + }, + }, + payments: { + x402: { + enabled: true, + // The devnet USDC by default; the validator holds a mint at its address + network: DEVNET, + rpcUrl: validator.rpcUrl, + payTo: merchant, + maxTimeoutSeconds: 60, + facilitator: { mode: 'remote', url: facilitator.url, auth: { type: 'none' } }, + }, + }, + }, + {}, + ); + store = createSqliteReceiptStore({ path: ':memory:' }); + await store.init(); + gateway = await createGateway({ + config, + store, + paymentProviders: createConfiguredPaymentProviders(config.payments, NOOP_LOGGER), + protocolAdapters: [], + backend, + }); +}, 120_000); + +afterAll(async () => { + await gateway?.close(); + await store?.close(); + await facilitator?.close(); + await validator?.stop(); +}); + +describeWithValidator('x402 Solana settlement on a local validator', () => { + it('settles an SDK-signed transaction and moves the token on chain', async () => { + const offered = await challenge(); + expect(offered.accepts[0]?.extra?.['feePayer']).toBe(feePayer.address); + const before = await balances(); + + const paid = await invoke(header(await sign(offered))); + expect(paid.statusCode).toBe(200); + const settlement = new x402HTTPClient(new x402Client()).getPaymentSettleResponse( + (name) => paid.headers[name.toLowerCase()] as string | undefined, + ); + expect(settlement).toMatchObject({ success: true, network: DEVNET }); + + // The signature names a confirmed transaction without an error + const status = await solanaRpc<{ + value: { err: unknown; confirmationStatus: string }[]; + }>(validator.rpcUrl, 'getSignatureStatuses', [[settlement.transaction]]); + expect(status.value[0]).toMatchObject({ err: null }); + expect(['confirmed', 'finalized']).toContain(status.value[0]?.confirmationStatus); + await waitForBalances({ buyer: before.buyer - PRICE, merchant: before.merchant + PRICE }); + + // The public-network smoke harness reads the token movement from the transaction itself + const transfer = { + asset: USDC, + from: buyer.address, + to: merchant, + amount: PRICE, + reference: settlement.transaction, + }; + await assertTransferOnChain('svm', validator.rpcUrl, transfer); + await expect( + assertTransferOnChain('svm', validator.rpcUrl, { ...transfer, amount: PRICE + 1n }), + ).rejects.toThrow(/payee's .* change/); + + const [receipt] = await store.listReceipts({ limit: 1 }); + expect(receipt?.payment).toMatchObject({ + network: DEVNET, + externalReference: settlement.transaction, + payer: buyer.address, + }); + }); + + it.each<[string, (offer: Record) => void, string]>([ + [ + 'another wallet', + (offer) => { + offer['payTo'] = 'Bz6TZb9AuDDrv1qATAoG3D4sRS9GZnBdfvVKJW5mo8Fr'; + }, + 'invalid_exact_svm_payload_recipient_mismatch', + ], + [ + 'a smaller amount', + (offer) => { + offer['amount'] = '1'; + }, + 'invalid_exact_svm_payload_amount_mismatch', + ], + ])('refuses a transaction paying %s, moving nothing', async (_case, tamper, reason) => { + const offered = await challenge(); + const original = offered.accepts[0] as unknown as Record; + // Signed for altered terms, presented as the offer the gateway made + const altered = { ...original }; + tamper(altered); + const proof = await sign({ ...offered, accepts: [altered as never] }); + proof.accepted = original; + + const before = await balances(); + const refused = await invoke(header(proof)); + expect(refused.statusCode).toBe(402); + expect(refused.json().message).toBe(reason); + expect(await balances()).toEqual(before); + }); + + it('settles one transaction once when presented on two requests at the same time', async () => { + const proof = await sign(await challenge()); + const before = await balances(); + const calls = backendCalls; + const statuses = (await Promise.all([invoke(header(proof)), invoke(header(proof))])).map( + (response) => response.statusCode, + ); + expect(statuses.filter((status) => status === 200)).toHaveLength(1); + expect(backendCalls - calls).toBe(1); + await waitForBalances({ buyer: before.buyer - PRICE, merchant: before.merchant + PRICE }); + }); + + it('reserves the transaction, not its signature bytes, while the first copy is settling', async () => { + const proof = await sign(await challenge()); + const altered = structuredClone(proof); + altered.payload.transaction = withSignatureBytes(proof.payload.transaction, 0, 0xab); + const before = await balances(); + backendDelayMs = 1_000; + try { + const [first, second] = await Promise.all([ + invoke(header(proof)), + // After the first has reserved, before it settles + new Promise((resolve) => setTimeout(resolve, 200)).then(() => invoke(header(altered))), + ]); + expect(first.statusCode).toBe(200); + expect(second.statusCode).toBe(409); + expect(second.json().code).toBe('PAYMENT_REPLAYED'); + } finally { + backendDelayMs = 0; + } + await waitForBalances({ buyer: before.buyer - PRICE, merchant: before.merchant + PRICE }); + }); + + it('refuses a settled transaction presented again, moving nothing', async () => { + const proof = await sign(await challenge()); + expect((await invoke(header(proof))).statusCode).toBe(200); + const after = await balances(); + + // The facilitator's simulation finds the transaction already processed + proof.payload.transaction = withSignatureBytes(proof.payload.transaction, 0, 0xab); + const replayed = await invoke(header(proof)); + expect(replayed.statusCode).toBe(402); + expect(replayed.json().message).toBe('invalid_exact_svm_transaction_simulation_failed'); + expect(await balances()).toEqual(after); + }); + + it('a broadcast whose RPC response is lost is uncertain, never a rejection', async () => { + const rpc = await startLossyRpc(validator.rpcUrl, 'sendTransaction'); + const lossyFacilitator = await startLoopbackFacilitator({ + svm: { network: DEVNET, rpcUrl: rpc.url, feePayer }, + }); + try { + const provider = createX402PaymentProvider({ + network: DEVNET, + rpcUrl: validator.rpcUrl, + asset: USDC, + assetDecimals: 6, + payTo: merchant, + facilitator: { mode: 'remote', url: lossyFacilitator.url, auth: { type: 'none' } }, + }); + const resource = config.resources[0] as (typeof config.resources)[number]; + const requirement = await provider.createRequirement({ + requestId: 'req-lossy', + resource, + amount: '1.00', + currency: 'USDC', + requestedAt: new Date().toISOString(), + }); + const proof = await sign(requirement.challenge.envelope as unknown as PaymentRequired); + const context = { + requestId: 'req-lossy', + resource, + requirement, + submission: { method: 'x402' as const, payload: header(proof)['payment-signature'] }, + }; + const before = await balances(); + const verification = await provider.verify(context); + expect(verification.status).toBe('verified'); + + // The SDK reports the lost response as its catch-all without a + // signature, while the transfer lands + await expect(provider.settle({ ...context, verification })).rejects.toSatisfy( + (err: unknown) => isCommerceError(err) && err.code === 'PAYMENT_PROVIDER_UNAVAILABLE', + ); + await waitForBalances({ buyer: before.buyer - PRICE, merchant: before.merchant + PRICE }); + } finally { + await lossyFacilitator.close(); + await rpc.close(); + } + }); + + it('fails health: the validator is not the devnet cluster its offer names', async () => { + const provider = createConfiguredPaymentProviders(config.payments, NOOP_LOGGER)[0]; + const health = await provider?.health(); + expect(health?.status).toBe('fail'); + const genesis = await solanaRpc(validator.rpcUrl, 'getGenesisHash', []); + expect(health?.detail).toContain( + `serves solana:${genesis.slice(0, 32)}, expected ${DEVNET} (Solana Devnet)`, + ); + }); +}); diff --git a/tests/fixtures/x402/loopback-facilitator.ts b/tests/fixtures/x402/loopback-facilitator.ts new file mode 100644 index 0000000..741fc2a --- /dev/null +++ b/tests/fixtures/x402/loopback-facilitator.ts @@ -0,0 +1,160 @@ +/** + * Test-only `x402Facilitator` on 127.0.0.1 exercises the gateway's remote path: + * EVM uses the local binding on custom-id Anvil chains; Solana and NEAR use + * SDK schemes on a local validator and sandbox. Each configured Solana or NEAR + * facilitator is reused across requests to share its duplicate-settlement + * cache, as in deployment. The smoke harness also uses it as a local + * facilitator for public NEAR networks, over https. + */ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { createServer as createHttpsServer } from 'node:https'; +import type { AddressInfo } from 'node:net'; +import type { KeyPairString } from '@near-js/crypto'; +import type { KeyPairSigner } from '@solana/kit'; +import { x402Facilitator } from '@x402/core/facilitator'; +import type { Network } from '@x402/core/types'; +import { createFacilitatorNearSigner } from '@x402/near'; +import { ExactNearScheme } from '@x402/near/exact/facilitator'; +import { toFacilitatorSvmSigner } from '@x402/svm'; +import { registerExactSvmScheme } from '@x402/svm/exact/facilitator'; +import { createLocalFacilitatorClient } from '../../../src/payments/x402/chain'; +import type { FacilitatorBinding } from '../../../src/payments/x402/facilitator'; +import { createLocalFacilitatorBinding } from '../../../src/payments/x402/local-facilitator'; + +export interface LoopbackChain { + readonly network: `eip155:${number}`; + readonly rpcUrl: string; +} + +export interface LoopbackFacilitator { + readonly url: string; + close(): Promise; +} + +async function readJson(req: IncomingMessage): Promise> { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + return JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record; +} + +export interface LoopbackSolana { + readonly network: string; + readonly rpcUrl: string; + /** Pays transaction fees; LOCAL DEVELOPMENT ONLY */ + readonly feePayer: KeyPairSigner; +} + +export interface LoopbackNear { + readonly network: string; + readonly rpcUrl: string; + /** Submits delegate actions and pays their gas: a sandbox account, or a funded public-network one */ + readonly relayer: { readonly accountId: string; readonly secretKey: KeyPairString }; +} + +export async function startLoopbackFacilitator(options: { + readonly evm?: { + readonly chains: readonly LoopbackChain[]; + readonly signerPrivateKey: `0x${string}`; + }; + readonly svm?: LoopbackSolana; + readonly near?: LoopbackNear; + /** Serves https with this certificate, since a mainnet gateway refuses a plain-HTTP facilitator */ + readonly tls?: { readonly key: string; readonly cert: string }; +}): Promise { + const chains = options.evm?.chains ?? []; + const bindings = new Map( + chains.map(({ network, rpcUrl }) => { + const client = createLocalFacilitatorClient( + rpcUrl, + options.evm?.signerPrivateKey as `0x${string}`, + Number(network.split(':')[1]), + ); + return [network, createLocalFacilitatorBinding(client, network, () => false)]; + }), + ); + // The SDK's own facilitators, by the network each serves + const sdkFacilitators = new Map(); + if (options.svm) { + const { network, rpcUrl, feePayer } = options.svm; + sdkFacilitators.set( + network, + registerExactSvmScheme(new x402Facilitator(), { + signer: toFacilitatorSvmSigner(feePayer, { defaultRpcUrl: rpcUrl }), + networks: network as Network, + }), + ); + } + if (options.near) { + const { network, rpcUrl, relayer } = options.near; + const signer = createFacilitatorNearSigner({ + relayers: [relayer], + rpcUrls: { [network]: rpcUrl }, + }); + sdkFacilitators.set( + network, + new x402Facilitator().register(network as Network, new ExactNearScheme(signer)), + ); + } + + const handle = (req: IncomingMessage, res: ServerResponse) => { + const reply = (status: number, body: unknown) => { + res.statusCode = status; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(body)); + }; + if (req.method === 'GET' && req.url === '/supported') { + // As the SDK reports them: Solana kinds carry the fee payer + const sdk = [...sdkFacilitators.values()].map((facilitator) => facilitator.getSupported()); + reply(200, { + kinds: [ + ...chains.map(({ network }) => ({ x402Version: 2, scheme: 'exact', network })), + ...sdk.flatMap((supported) => supported.kinds.filter((kind) => kind.x402Version === 2)), + ], + extensions: [], + signers: Object.assign({}, ...sdk.map((supported) => supported.signers)), + }); + return; + } + const operation = + req.url === '/verify' ? 'verify' : req.url === '/settle' ? 'settle' : undefined; + if (req.method !== 'POST' || operation === undefined) { + reply(404, { error: 'not found' }); + return; + } + readJson(req) + .then(async (body) => { + const requirements = body['paymentRequirements'] as { network: string }; + const payload = body['paymentPayload'] as never; + const sdkFacilitator = sdkFacilitators.get(requirements.network); + if (sdkFacilitator) { + reply( + 200, + operation === 'verify' + ? await sdkFacilitator.verify(payload, requirements as never) + : await sdkFacilitator.settle(payload, requirements as never), + ); + return; + } + const binding = bindings.get(requirements.network); + if (!binding) { + reply(400, { isValid: false, invalidReason: 'unsupported_network' }); + return; + } + const session = binding.open(); + reply( + 200, + operation === 'verify' + ? await session.verify(payload, requirements as never) + : await session.settle(payload, requirements as never), + ); + }) + .catch((err: unknown) => reply(500, { error: String(err) })); + }; + + const server = options.tls ? createHttpsServer(options.tls, handle) : createServer(handle); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { + url: `${options.tls ? 'https' : 'http'}://127.0.0.1:${(server.address() as AddressInfo).port}`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} diff --git a/tests/fixtures/x402/lossy-rpc.ts b/tests/fixtures/x402/lossy-rpc.ts index 869bd01..acf8946 100644 --- a/tests/fixtures/x402/lossy-rpc.ts +++ b/tests/fixtures/x402/lossy-rpc.ts @@ -6,9 +6,13 @@ export interface LossyRpc { close(): Promise; } -// Forwards JSON-RPC to `upstream`, but answers `eth_sendRawTransaction` with an -// error after the node has accepted it: a broadcast whose response was lost -export async function startLossyRpc(upstream: string): Promise { +// Forwards JSON-RPC to `upstream`, but answers the broadcast method with an +// error after the node has accepted it: a broadcast whose response was lost. +// The method is EVM's by default; Solana's is `sendTransaction`. +export async function startLossyRpc( + upstream: string, + broadcastMethod = 'eth_sendRawTransaction', +): Promise { const server = createServer(async (request, response) => { const chunks: Buffer[] = []; for await (const chunk of request) chunks.push(chunk as Buffer); @@ -22,7 +26,7 @@ export async function startLossyRpc(upstream: string): Promise { const call = JSON.parse(body) as { id?: unknown; method?: string }; response.setHeader('content-type', 'application/json'); response.end( - call.method === 'eth_sendRawTransaction' + call.method === broadcastMethod ? JSON.stringify({ jsonrpc: '2.0', id: call.id, diff --git a/tests/fixtures/x402/near-delegate.ts b/tests/fixtures/x402/near-delegate.ts new file mode 100644 index 0000000..756fa60 --- /dev/null +++ b/tests/fixtures/x402/near-delegate.ts @@ -0,0 +1,71 @@ +/** + * Builds offline NEAR `exact` payloads in the SDK client's format: one + * `ft_transfer` in a NEP-366 delegate, signed with a real key and encoded as + * base64 Borsh. Field overrides produce malformed and tampered test cases. + */ +import { KeyPair } from '@near-js/crypto'; +import { KeyPairSigner } from '@near-js/signers'; +import { + type Action, + actionCreators, + buildDelegateAction, + encodeSignedDelegate, +} from '@near-js/transactions'; + +export interface DelegateOptions { + readonly senderId?: string; + /** The token contract the delegate calls */ + readonly receiverId?: string; + readonly keyPair?: KeyPair; + readonly methodName?: string; + /** `ft_transfer` argument `receiver_id`, the payee */ + readonly ftReceiver?: string; + readonly amount?: string; + readonly deposit?: bigint; + readonly gas?: bigint; + readonly nonce?: bigint; + readonly maxBlockHeight?: bigint; + /** Replaces the single `ft_transfer` action */ + readonly actions?: Action[]; +} + +export const DELEGATE_DEFAULTS = { + senderId: 'buyer.testnet', + receiverId: '3e2210e1184b45b64c8a434c0a7e7b23cc04ea7eb7a6c3c32520d03d4afcb8af', + ftReceiver: 'merchant.testnet', + amount: '10000', + nonce: 5n, + maxBlockHeight: 1_060n, + gas: 30_000_000_000_000n, +} as const; + +/** The base64 Borsh signed delegate and the key that signed it */ +export async function buildSignedDelegate( + options: DelegateOptions = {}, +): Promise<{ readonly signedDelegateAction: string; readonly keyPair: KeyPair }> { + const keyPair = options.keyPair ?? KeyPair.fromRandom('ed25519'); + const actions = options.actions ?? [ + actionCreators.functionCall( + options.methodName ?? 'ft_transfer', + { + receiver_id: options.ftReceiver ?? DELEGATE_DEFAULTS.ftReceiver, + amount: options.amount ?? DELEGATE_DEFAULTS.amount, + }, + options.gas ?? DELEGATE_DEFAULTS.gas, + options.deposit ?? 1n, + ), + ]; + const delegateAction = buildDelegateAction({ + actions, + maxBlockHeight: options.maxBlockHeight ?? DELEGATE_DEFAULTS.maxBlockHeight, + nonce: options.nonce ?? DELEGATE_DEFAULTS.nonce, + publicKey: keyPair.getPublicKey(), + receiverId: options.receiverId ?? DELEGATE_DEFAULTS.receiverId, + senderId: options.senderId ?? DELEGATE_DEFAULTS.senderId, + }); + const [, signed] = await new KeyPairSigner(keyPair).signDelegateAction(delegateAction); + return { + signedDelegateAction: Buffer.from(encodeSignedDelegate(signed)).toString('base64'), + keyPair, + }; +} diff --git a/tests/fixtures/x402/near-sandbox.ts b/tests/fixtures/x402/near-sandbox.ts new file mode 100644 index 0000000..8cb596a --- /dev/null +++ b/tests/fixtures/x402/near-sandbox.ts @@ -0,0 +1,269 @@ +/** + * A throwaway NEAR sandbox (nearcore's `near-sandbox`) with a NEP-141 token at + * the testnet USDC account id. LOCAL DEVELOPMENT ONLY. + * + * The token is the near-sdk-rs `fungible-token` example + * (`near/near-workspaces-rs@2b72b9b`, `examples/res/fungible_token.wasm`, + * MIT OR Apache-2.0), checked against its SHA-256 before deployment. The + * testnet USDC id is an implicit account, whose key nobody here holds, so + * `sandbox_patch_state` gives it a test key before the contract is deployed. + * + * The sandbox's random chain ID differs from the SDK's accepted + * `near:mainnet` and `near:testnet` IDs. Tests use the testnet ID. + */ +import { type ChildProcess, spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { Account } from '@near-js/accounts'; +import { KeyPair, type KeyPairString } from '@near-js/crypto'; +import { JsonRpcProvider } from '@near-js/providers'; +import { KeyPairSigner } from '@near-js/signers'; + +export const NEAR_TESTNET_USDC = '3e2210e1184b45b64c8a434c0a7e7b23cc04ea7eb7a6c3c32520d03d4afcb8af'; +const FUNGIBLE_TOKEN_WASM = new URL('./near/fungible_token.wasm', import.meta.url); +const FUNGIBLE_TOKEN_SHA256 = 'afdb62b051a7b7222e0fc1b7e119d6921a9addd159fe531ce0801218db74d735'; +const ONE_NEAR = 10n ** 24n; +const TGAS = 10n ** 12n; +const READY_TIMEOUT_MS = 60_000; + +export interface NearTestAccount { + readonly accountId: string; + readonly keyPair: KeyPair; + readonly secretKey: KeyPairString; +} + +export interface NearSandbox { + readonly rpcUrl: string; + /** The genesis account, funded and holding a full-access key */ + readonly root: NearTestAccount; + account(accountId: string, keyPair: KeyPair): Account; + stop(): Promise; +} + +// The binary from NEAR_SANDBOX_BIN, else `near-sandbox` on PATH +function sandboxBinary(): string { + return process.env['NEAR_SANDBOX_BIN'] ?? 'near-sandbox'; +} + +export function isNearSandboxAvailable(): boolean { + return spawnSync(sandboxBinary(), ['--version']).status === 0; +} + +/** One JSON-RPC call; throws on a JSON-RPC error */ +export async function nearRpc( + rpcUrl: string, + method: string, + params: unknown, +): Promise { + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }); + const body = (await response.json()) as { result?: unknown; error?: unknown }; + if (body.error !== undefined) throw new Error(`${method}: ${JSON.stringify(body.error)}`); + return body.result as T; +} + +/** A contract view call at final finality, its result parsed as JSON */ +export async function nearView( + rpcUrl: string, + contractId: string, + method: string, + args: Record, +): Promise { + const result = await nearRpc<{ result: number[] }>(rpcUrl, 'query', { + request_type: 'call_function', + finality: 'final', + account_id: contractId, + method_name: method, + args_base64: Buffer.from(JSON.stringify(args)).toString('base64'), + }); + return JSON.parse(Buffer.from(result.result).toString('utf8')) as T; +} + +/** Base units of `token` held by `accountId`, at final finality */ +export async function ftBalance(rpcUrl: string, token: string, accountId: string): Promise { + return BigInt(await nearView(rpcUrl, token, 'ft_balance_of', { account_id: accountId })); +} + +/** + * Waits for `check` at final finality, matching the facilitator's reads. + * Final state can lag a setup transaction's block. + */ +export async function waitForFinal(check: () => Promise): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (await check().catch(() => false)) return; + await sleep(250); + } + throw new Error('NEAR sandbox state did not reach final finality in time'); +} + +/** + * Starts a sandbox with RPC on `rpcPort` and its peer port at `rpcPort + 1`, + * and waits for its first blocks + */ +export async function startNearSandbox(options: { + readonly rpcPort: number; +}): Promise { + const home = await mkdtemp(join(tmpdir(), 'x402-near-')); + const binary = sandboxBinary(); + const init = spawnSync(binary, ['--home', home, 'init'], { stdio: 'pipe' }); + if (init.status !== 0) { + await rm(home, { recursive: true, force: true }); + throw new Error(`near-sandbox init failed: ${init.stderr.toString().slice(-2000)}`); + } + const configPath = join(home, 'config.json'); + const config = JSON.parse(await readFile(configPath, 'utf8')); + config.rpc.addr = `127.0.0.1:${options.rpcPort}`; + config.network.addr = `127.0.0.1:${options.rpcPort + 1}`; + await writeFile(configPath, JSON.stringify(config)); + + const child = spawn(binary, ['--home', home, 'run'], { stdio: ['ignore', 'ignore', 'pipe'] }); + let stderrTail = ''; + child.stderr?.on('data', (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString()).slice(-4000); + }); + const exitedEarly = new Promise((_resolve, reject) => { + child.once('exit', (code) => + reject(new Error(`near-sandbox exited early (code ${code}):\n${stderrTail}`)), + ); + child.once('error', (err) => reject(err)); + }); + + const rpcUrl = `http://127.0.0.1:${options.rpcPort}`; + const stop = async () => { + await stopProcess(child); + await rm(home, { recursive: true, force: true }); + }; + try { + await Promise.race([waitForBlocks(rpcUrl), exitedEarly]); + } catch (err) { + await stop(); + throw err; + } + + const validator = JSON.parse(await readFile(join(home, 'validator_key.json'), 'utf8')) as { + account_id: string; + secret_key: KeyPairString; + }; + const provider = new JsonRpcProvider({ url: rpcUrl }); + return { + rpcUrl, + root: { + accountId: validator.account_id, + keyPair: KeyPair.fromString(validator.secret_key), + secretKey: validator.secret_key, + }, + account: (accountId, keyPair) => new Account(accountId, provider, new KeyPairSigner(keyPair)), + stop, + }; +} + +/** Creates `.` with a fresh full-access key and `near` NEAR */ +export async function createNearAccount( + sandbox: NearSandbox, + name: string, + near = 10n, +): Promise { + const keyPair = KeyPair.fromRandom('ed25519'); + const accountId = `${name}.${sandbox.root.accountId}`; + await sandbox + .account(sandbox.root.accountId, sandbox.root.keyPair) + .createAccount(accountId, keyPair.getPublicKey(), near * ONE_NEAR); + return { accountId, keyPair, secretKey: keyPair.toString() as KeyPairString }; +} + +/** + * Deploys the fungible token at the testnet USDC id with 6 decimals, its whole + * supply held by `holder`, and registers storage for each of `registered` + */ +export async function deployUsdc( + sandbox: NearSandbox, + holder: string, + supply: bigint, + registered: readonly string[], +): Promise { + const code = await readFile(FUNGIBLE_TOKEN_WASM); + if (createHash('sha256').update(code).digest('hex') !== FUNGIBLE_TOKEN_SHA256) { + throw new Error('vendored fungible_token.wasm does not match its pinned SHA-256'); + } + const root = sandbox.account(sandbox.root.accountId, sandbox.root.keyPair); + // Funding the implicit account creates it; the patched key lets us deploy + await root.sendMoney(NEAR_TESTNET_USDC, 10n * ONE_NEAR); + const tokenKey = KeyPair.fromRandom('ed25519'); + await nearRpc(sandbox.rpcUrl, 'sandbox_patch_state', { + records: [ + { + AccessKey: { + account_id: NEAR_TESTNET_USDC, + public_key: tokenKey.getPublicKey().toString(), + access_key: { nonce: 0, permission: 'FullAccess' }, + }, + }, + ], + }); + const token = sandbox.account(NEAR_TESTNET_USDC, tokenKey); + await token.deployContract(new Uint8Array(code)); + await token.functionCall({ + contractId: NEAR_TESTNET_USDC, + methodName: 'new', + args: { + owner_id: holder, + total_supply: supply.toString(), + metadata: { spec: 'ft-1.0.0', name: 'USD Coin', symbol: 'USDC', decimals: 6 }, + }, + gas: 100n * TGAS, + }); + for (const accountId of registered) { + await root.functionCall({ + contractId: NEAR_TESTNET_USDC, + methodName: 'storage_deposit', + args: { account_id: accountId }, + gas: 30n * TGAS, + attachedDeposit: 10n ** 22n, + }); + } + await waitForFinal(async () => { + for (const accountId of [holder, ...registered]) { + const storage = await nearView(sandbox.rpcUrl, NEAR_TESTNET_USDC, 'storage_balance_of', { + account_id: accountId, + }); + if (storage === null) return false; + } + return true; + }); +} + +async function waitForBlocks(rpcUrl: string): Promise { + const deadline = Date.now() + READY_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + const status = await nearRpc<{ sync_info: { latest_block_height: number } }>( + rpcUrl, + 'status', + [], + ); + if (status.sync_info.latest_block_height > 1) return; + } catch { + // Not listening yet + } + await sleep(250); + } + throw new Error(`near-sandbox at ${rpcUrl} produced no blocks in time`); +} + +async function stopProcess(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve) => { + child.once('exit', () => resolve()); + child.kill('SIGTERM'); + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + }, 5000).unref(); + }); +} diff --git a/tests/fixtures/x402/near/fungible_token.wasm b/tests/fixtures/x402/near/fungible_token.wasm new file mode 100644 index 0000000..2c93e7f Binary files /dev/null and b/tests/fixtures/x402/near/fungible_token.wasm differ diff --git a/tests/fixtures/x402/permit2-contracts.ts b/tests/fixtures/x402/permit2-contracts.ts new file mode 100644 index 0000000..e99c4bb --- /dev/null +++ b/tests/fixtures/x402/permit2-contracts.ts @@ -0,0 +1,396 @@ +/** + * Runtime bytecode of the canonical Permit2 contract (Uniswap Labs, MIT) and the + * x402 exact Permit2 proxy (x402 Foundation, Apache-2.0), read from Base Sepolia + * (`eip155:84532`) at block 47851170. Tests install both on Anvil at their + * canonical addresses with `anvil_setCode`, so Permit2 settlement runs against + * the deployed contracts rather than a stub. + * + * Permit2 caches its chain id and EIP-712 domain separator as immutables. Those + * 35 bytes are the only difference from the Base mainnet copy, and on another + * chain id Permit2 recomputes the separator from `block.chainid` and its own + * address, so this copy behaves as a fresh deployment on Anvil. The proxy's code + * is the same on Base and Base Sepolia. + * + * Multicall3 (mds1/multicall, MIT) has no immutables and the same code on Base + * and Base Sepolia, read at Base Sepolia block 47853650. The SDK diagnoses a + * failed Permit2 simulation through it, so without it a missing allowance is + * reported as `permit2_simulation_failed`. + */ +import { PERMIT2_ADDRESS, x402ExactPermit2ProxyAddress } from '@x402/evm'; +import { keccak256 } from 'viem'; + +export const PERMIT2_CODE_HASH = + '0xdcde65555316946c298e4c60c6213eb5c3aeab4354d1f3fac5427236bcbb9ebe'; +export const PERMIT2_PROXY_CODE_HASH = + '0xce6429c0bb49284660683287c0a8fe548a88379072327d026626909d202048b9'; + +const PERMIT2_CODE = + '0x6040608081526004908136101561001557600080fd5b600090813560e01c80630d58b1db1461126c578063137c29fe' + + '146110755780632a2d80d114610db75780632b67b57014610bde57806330f28b7a14610ade5780633644e51514610a9d' + + '57806336c7851614610a285780633ff9dcb1146109a85780634fe02b441461093f57806365d9723c146107ac57806387' + + '517c451461067a578063927da105146105c3578063cc53287f146104a3578063edd9444b1461033a5763fe8ec1a71461' + + '00c657600080fd5b346103365760c07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc' + + '3601126103365767ffffffffffffffff833581811161033257610114903690860161164b565b60243582811161032e57' + + '61012b903690870161161a565b6101336114e6565b9160843585811161032a5761014b9036908a016115c1565b989095' + + '60a43590811161032657610164913691016115c1565b969095815190610173826113ff565b606b82527f5065726d6974' + + '42617463685769746e6573735472616e7366657246726f6d285460208301527f6f6b656e5065726d697373696f6e735b' + + '5d207065726d69747465642c61646472838301527f657373207370656e6465722c75696e74323536206e6f6e63652c75' + + '696e74323560608301527f3620646561646c696e652c0000000000000000000000000000000000000000006080830152' + + '82519a8b9181610222602085018096611f93565b918237018a8152039961025b7fffffffffffffffffffffffffffffff' + + 'ffffffffffffffffffffffffffffffffe09b8c8101835282611437565b5190209085515161026b81611ebb565b908a5b' + + '8181106102f95750506102f6999a6102ed9183516102a081610294602082018095611f66565b03848101835282611437' + + '565b519020602089810151858b015195519182019687526040820192909252336060820152608081019190915260a081' + + '019390935260643560c08401528260e081015b03908101835282611437565b51902093611cf7565b80f35b8061031161' + + '030b610321938c5161175e565b51612054565b61031b828661175e565b52611f0a565b61026e565b8880fd5b8780fd5b' + + '8480fd5b8380fd5b5080fd5b5091346103365760807fffffffffffffffffffffffffffffffffffffffffffffffffffff' + + 'fffffffffffc3601126103365767ffffffffffffffff9080358281116103325761038b903690830161164b565b602435' + + '83811161032e576103a2903690840161161a565b9390926103ad6114e6565b9160643590811161049f576103c4913691' + + '016115c1565b949093835151976103d489611ebb565b98885b81811061047d5750506102f697988151610425816103f9' + + '602082018095611f66565b037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0810183' + + '5282611437565b5190206020860151828701519083519260208401947ffcf35f5ac6a2c28868dc44c302166470266239' + + '195f02b0ee408334829333b7668652840152336060840152608083015260a082015260a081526102ed8161141b565b80' + + '8b61031b8261049461030b61049a968d5161175e565b9261175e565b6103d7565b8680fd5b5082346105bf5760209081' + + '7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103325780359067ffffffff' + + 'ffffffff821161032e576104f49136910161161a565b929091845b848110610504578580f35b8061051a610515600193' + + '888861196c565b61197c565b61052f84610529848a8a61196c565b0161197c565b3389528385528589209173ffffffff' + + 'ffffffffffffffffffffffffffffffff80911692838b528652868a20911690818a5285528589207fffffffffffffffff' + + 'ffffffff000000000000000000000000000000000000000081541690558551918252848201527f89b1add15eff56b3df' + + 'e299ad94e01f2b52fbcb80ae1a3baea6ae8c04cb2b98a4853392a2016104f9565b8280fd5b50346103365760607fffff' + + 'fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261033657610676816105ff6114a056' + + '5b936106086114c3565b6106106114e6565b73ffffffffffffffffffffffffffffffffffffffff968716835260016020' + + '908152848420928816845291825283832090871683528152919020549251938316845260a083901c65ffffffffffff16' + + '9084015260d09190911c604083015281906060820190565b0390f35b50346103365760807fffffffffffffffffffffff' + + 'fffffffffffffffffffffffffffffffffffffffffc360112610336576106b26114a0565b906106bb6114c3565b916106' + + 'c46114e6565b65ffffffffffff926064358481169081810361032a5779ffffffffffff00000000000000000000000000' + + '00000000000000947fda9fa7c1b00402c17d0161b249b1ab8bbec047c5a52207b9c112deffd817036b94338a52600160' + + '20527fffffffffffff0000000000000000000000000000000000000000000000000000858b209873ffffffffffffffff' + + 'ffffffffffffffffffffffff809416998a8d5260205283878d209b169a8b8d52602052868c209486156000146107a457' + + '504216925b8454921697889360a01b16911617179055815193845260208401523392a480f35b905092610783565b5082' + + '346105bf5760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126105bf5761' + + '07e56114a0565b906107ee6114c3565b9265ffffffffffff604435818116939084810361032a57338852602091600183' + + '528489209673ffffffffffffffffffffffffffffffffffffffff80911697888b528452858a20981697888a5283528489' + + '205460d01c93848711156109175761ffff9085840316116108f05750907f55eb90d810e1700b35a8e7e25395ff7f2b22' + + '59abd7415ca2284dfb1c246418f393929133895260018252838920878a528252838920888a5282528389209079ffffff' + + 'ffffffffffffffffffffffffffffffffffffffffffffff7fffffffffffff000000000000000000000000000000000000' + + '000000000000000083549260d01b16911617905582519485528401523392a480f35b84517f24d35a2600000000000000' + + '0000000000000000000000000000000000000000008152fd5b5084517f756688fe000000000000000000000000000000' + + '000000000000000000000000008152fd5b503461033657807fffffffffffffffffffffffffffffffffffffffffffffff' + + 'fffffffffffffffffc360112610336578060209273ffffffffffffffffffffffffffffffffffffffff61098f6114a056' + + '5b1681528084528181206024358252845220549051908152f35b5082346105bf57817fffffffffffffffffffffffffff' + + 'fffffffffffffffffffffffffffffffffffffc3601126105bf577f3704902f963766a4e561bbaab6e6cdc1b1dd12f6e9' + + 'e99648da8843b3f46b918d90359160243533855284602052818520848652602052818520818154179055815193845260' + + '208401523392a280f35b8234610a9a5760807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + + 'fffffc360112610a9a57610a606114a0565b610a686114c3565b610a706114e6565b6064359173ffffffffffffffffff' + + 'ffffffffffffffffffffff8316830361032e576102f6936117a1565b80fd5b503461033657817fffffffffffffffffff' + + 'fffffffffffffffffffffffffffffffffffffffffffffc36011261033657602090610ad7611b1e565b9051908152f35b' + + '508290346105bf576101007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261' + + '05bf57610b1a3661152a565b90807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7c36' + + '011261033257610b4c611478565b9160e43567ffffffffffffffff8111610bda576102f694610b6f913691016115c156' + + '5b939092610b7c8351612054565b6020840151828501519083519260208401947f939c21a48a8dbe3a9a2404a1d46691' + + 'e4d39f6583d6ec6b35714604c986d801068652840152336060840152608083015260a082015260a08152610bd1816114' + + '1b565b51902091611c25565b8580fd5b509134610336576101007fffffffffffffffffffffffffffffffffffffffffff' + + 'fffffffffffffffffffffc36011261033657610c186114a0565b7fffffffffffffffffffffffffffffffffffffffffff' + + 'ffffffffffffffffffffdc360160c08112610332576080855191610c51836113e3565b1261033257845190610c628261' + + '1398565b73ffffffffffffffffffffffffffffffffffffffff91602435838116810361049f5781526044358381168103' + + '61049f57602082015265ffffffffffff606435818116810361032a5788830152608435908116810361049f5760608201' + + '52815260a435938285168503610bda576020820194855260c4359087830182815260e43567ffffffffffffffff811161' + + '032657610cfe90369084016115c1565b929093804211610d88575050918591610d786102f6999a610d7e95610d238851' + + '611fbe565b90898c511690519083519260208401947ff3841cd1ff0085026a6327b620b67997ce40f282c88a8e905a7a' + + '5626e310f3d086528401526060830152608082015260808152610d70816113ff565b519020611bd9565b916120c7565b' + + '519251169161199d565b602492508a51917fcd21db4f0000000000000000000000000000000000000000000000000000' + + '00008352820152fd5b5091346103365760607fffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + + 'fffffc93818536011261033257610df36114a0565b9260249081359267ffffffffffffffff9788851161032a57859085' + + '3603011261049f578051978589018981108282111761104a578252848301358181116103265785019036602383011215' + + '610326578382013591610e50836115ef565b90610e5d85519283611437565b838252602093878584019160071b830101' + + '91368311611046578801905b828210610fe9575050508a526044610e93868801611509565b96838c0197885201359483' + + '8b0191868352604435908111610fe557610ebb90369087016115c1565b959096804211610fba57505050899899515161' + + '0ed681611ebb565b908b5b818110610f9757505092889492610d7892610f6497958351610f02816103f9868201809561' + + '1f66565b5190209073ffffffffffffffffffffffffffffffffffffffff9a8b8b51169151928551948501957faf1b0d30' + + 'd2cab0380e68f0689007e3254993c596f2fdd0aaa7f4d04f794408638752850152830152608082015260808152610d70' + + '816113ff565b51169082515192845b848110610f78578580f35b80610f918585610f8b600195875161175e565b516119' + + '9d565b01610f6d565b80610311610fac8e9f9e93610fb2945161175e565b51611fbe565b9b9a9b610ed9565b8551917f' + + 'cd21db4f000000000000000000000000000000000000000000000000000000008352820152fd5b8a80fd5b6080823603' + + '126110465785608091885161100281611398565b61100b85611509565b8152611018838601611509565b838201526110' + + '278a8601611607565b8a8201528d611037818701611607565b90820152815201910190610e7a565b8c80fd5b84896041' + + '867f4e487b7100000000000000000000000000000000000000000000000000000000835252fd5b5082346105bf576101' + + '407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126105bf576110b03661152a' + + '565b91807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7c360112610332576110e261' + + '1478565b67ffffffffffffffff93906101043585811161049f5761110590369086016115c1565b909361012435968711' + + '61032a57611125610bd1966102f6983691016115c1565b969095825190611134826113ff565b606482527f5065726d69' + + '745769746e6573735472616e7366657246726f6d28546f6b656e5060208301527f65726d697373696f6e73207065726d' + + '69747465642c6164647265737320737065848301527f6e6465722c75696e74323536206e6f6e63652c75696e74323536' + + '20646561646c60608301527f696e652c0000000000000000000000000000000000000000000000000000000060808301' + + '528351948591816111e3602085018096611f93565b918237018b8152039361121c7fffffffffffffffffffffffffffff' + + 'ffffffffffffffffffffffffffffffffffe095868101835282611437565b5190209261122a8651612054565b60208781' + + '01518589015195519182019687526040820192909252336060820152608081019190915260a081019390935260e43560' + + 'c08401528260e081016102e1565b5082346105bf576020807fffffffffffffffffffffffffffffffffffffffffffffff' + + 'fffffffffffffffffc36011261033257813567ffffffffffffffff92838211610bda5736602383011215610bda578101' + + '3592831161032e576024906007368386831b8401011161049f57865b8581106112e5578780f35b80821b83019060807f' + + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc83360301126103265761139288876001' + + '946060835161132c81611398565b611368608461133c8d8601611509565b9485845261134c60448201611509565b8097' + + '85015261135d60648201611509565b809885015201611509565b918291015273ffffffffffffffffffffffffffffffff' + + 'ffffffff80808093169516931691166117a1565b016112da565b6080810190811067ffffffffffffffff8211176113b4' + + '57604052565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260' + + '246000fd5b6060810190811067ffffffffffffffff8211176113b457604052565b60a0810190811067ffffffffffffff' + + 'ff8211176113b457604052565b60c0810190811067ffffffffffffffff8211176113b457604052565b90601f7fffffff' + + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff8211' + + '176113b457604052565b60c4359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b600080' + + 'fd5b6004359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b6024359073ffffffffffff' + + 'ffffffffffffffffffffffffffff8216820361149b57565b6044359073ffffffffffffffffffffffffffffffffffffff' + + 'ff8216820361149b57565b359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b7fffffff' + + 'fffffffffffffffffffffffffffffffffffffffffffffffffffffffffc01906080821261149b57604080519061156382' + + '6113e3565b8082941261149b57805181810181811067ffffffffffffffff8211176113b457825260043573ffffffffff' + + 'ffffffffffffffffffffffffffffff8116810361149b5781526024356020820152825260443560208301526064359101' + + '52565b9181601f8401121561149b5782359167ffffffffffffffff831161149b576020838186019501011161149b5756' + + '5b67ffffffffffffffff81116113b45760051b60200190565b359065ffffffffffff8216820361149b57565b9181601f' + + '8401121561149b5782359167ffffffffffffffff831161149b576020808501948460061b01011161149b57565b919091' + + '60608184031261149b576040805191611666836113e3565b8294813567ffffffffffffffff9081811161149b57830182' + + '601f8201121561149b578035611693816115ef565b926116a087519485611437565b818452602094858086019360061b' + + '8501019381851161149b579086899897969594939201925b8484106116e3575050505050855280820135908501520135' + + '910152565b90919293949596978483031261149b578851908982019082821085831117611730578a9289928452611714' + + '87611509565b81528287013583820152815201930191908897969594936116c6565b602460007f4e487b710000000000' + + '000000000000000000000000000000000000000000000081526041600452fd5b80518210156117725760209160051b01' + + '0190565b7f4e487b71000000000000000000000000000000000000000000000000000000006000526032600452602460' + + '00fd5b92919273ffffffffffffffffffffffffffffffffffffffff604060008284168152600160205282828220961695' + + '868252602052818120338252602052209485549565ffffffffffff8760a01c1680421161188457508287169683880361' + + '1812575b5050611810955016926118b5565b565b878484161160001461184f57602488604051907ff96fb07100000000' + + '00000000000000000000000000000000000000000000000082526004820152fd5b7fffffffffffffffffffffffff0000' + + '00000000000000000000000000000000000084846118109a031691161790553880611802565b602490604051907fd81b' + + '2f2e0000000000000000000000000000000000000000000000000000000082526004820152fd5b906000606492602095' + + '8295604051947f23b872dd00000000000000000000000000000000000000000000000000000000865260048601526024' + + '85015260448401525af13d15601f3d116001600051141617161561190e57565b60646040517f08c379a0000000000000' + + '00000000000000000000000000000000000000000000815260206004820152601460248201527f5452414e534645525f' + + '46524f4d5f4641494c45440000000000000000000000006044820152fd5b91908110156117725760061b0190565b3573' + + 'ffffffffffffffffffffffffffffffffffffffff8116810361149b5790565b9065ffffffffffff908160608401511673' + + 'ffffffffffffffffffffffffffffffffffffffff90818551169482602082015116928086604080940151169516956000' + + '9187835260016020528383208984526020528383209916988983526020528282209184835460d01c03611af557918561' + + '1ace94927fc6a377bfc4eb120024a8ac08eef205be16b817020812c73223e81d1bdb9708ec9897969450871560001461' + + '1ad35779ffffffffffff00000000000000000000000000000000000000009042165b60a01b167fffffffffffff000000' + + '00000000000000000000000000000000000000000000006001860160d01b1617179055519384938491604091949373ff' + + 'ffffffffffffffffffffffffffffffffffffff606085019616845265ffffffffffff809216602085015216910152565b' + + '0390a4565b5079ffffffffffff000000000000000000000000000000000000000087611a60565b600484517f756688fe' + + '000000000000000000000000000000000000000000000000000000008152fd5b467f0000000000000000000000000000' + + '000000000000000000000000000000014a3403611b69577f010f27a92fb9a32622f44f001dc4d15706a85b33499cfc2c' + + 'e9033113ab26592c90565b60405160208101907f8cad95687ba82c2ce50e74f7b754645e5117c3a5bec8151c0726d585' + + '7980a86682527f9ac997416e8ff9d2ff6bebeb7149f65cdae5e32e2b90440b566bb3044041d36a604082015246606082' + + '015230608082015260808152611bd3816113ff565b51902090565b611be1611b1e565b906040519060208201927f1901' + + '00000000000000000000000000000000000000000000000000000000000084526022830152604282015260428152611b' + + 'd381611398565b9192909360a435936040840151804211611cc65750602084510151808611611c955750918591610d78' + + '611c6594611c60602088015186611e47565b611bd9565b73ffffffffffffffffffffffffffffffffffffffff80915151' + + '1692608435918216820361149b57611810936118b5565b602490604051907f3728b83d00000000000000000000000000' + + '00000000000000000000000000000082526004820152fd5b602490604051907fcd21db4f000000000000000000000000' + + '0000000000000000000000000000000082526004820152fd5b959093958051519560409283830151804211611e175750' + + '848803611dee57611d2e918691610d7860209b611c608d88015186611e47565b60005b868110611d4257505050505050' + + '5050565b611d4d81835161175e565b5188611d5a83878a61196c565b01359089810151808311611dbe57509181888888' + + '6001968596611d84575b50505050505001611d31565b611db395611dad9273ffffffffffffffffffffffffffffffffff' + + 'ffffff6105159351169561196c565b916118b5565b803888888883611d78565b6024908651907f3728b83d0000000000' + + '000000000000000000000000000000000000000000000082526004820152fd5b600484517fff633a3800000000000000' + + '0000000000000000000000000000000000000000008152fd5b6024908551907fcd21db4f000000000000000000000000' + + '0000000000000000000000000000000082526004820152fd5b9073ffffffffffffffffffffffffffffffffffffffff60' + + '0160ff83161b9216600052600060205260406000209060081c6000526020526040600020818154188091551615611e91' + + '57565b60046040517f756688fe000000000000000000000000000000000000000000000000000000008152fd5b90611e' + + 'c5826115ef565b611ed26040519182611437565b8281527fffffffffffffffffffffffffffffffffffffffffffffffff' + + 'ffffffffffffffe0611f0082946115ef565b0190602036910137565b7fffffffffffffffffffffffffffffffffffffff' + + 'ffffffffffffffffffffffffff8114611f375760010190565b7f4e487b71000000000000000000000000000000000000' + + '00000000000000000000600052601160045260246000fd5b805160208092019160005b828110611f7f57505050509056' + + '5b835185529381019392810192600101611f71565b9081519160005b838110611fab575050016000815290565b806020' + + '8092840101518185015201611f9a565b60405160208101917f65626cad6cb96493bf6f5ebea28756c966f023ab9e8a83' + + 'a7101849d5573b3678835273ffffffffffffffffffffffffffffffffffffffff80825116604084015260208201511660' + + '60830152606065ffffffffffff9182604082015116608085015201511660a082015260a0815260c0810181811067ffff' + + 'ffffffffffff8211176113b45760405251902090565b6040516020808201927f618358ac3db8dc274f0cd8829da7e234' + + 'bd48cd73c4a740aede1adec9846d06a1845273ffffffffffffffffffffffffffffffffffffffff815116604084015201' + + '51606082015260608152611bd381611398565b919082604091031261149b576020823592013590565b6000843b61222e' + + '5750604182036121ac576120e4828201826120b1565b939092604010156117725760209360009360ff60406080950135' + + '60f81c5b60405194855216868401526040830152606082015282805260015afa156121a05773ffffffffffffffffffff' + + 'ffffffffffffffffffff806000511691821561217657160361214c57565b60046040517f815e1d640000000000000000' + + '00000000000000000000000000000000000000008152fd5b60046040517f8baa579f0000000000000000000000000000' + + '00000000000000000000000000008152fd5b6040513d6000823e3d90fd5b60408203612204576121c0918101906120b1' + + '565b91601b7f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff84169360ff1c019060ff' + + '8211611f375760209360009360ff608094612102565b60046040517f4be6321b00000000000000000000000000000000' + + '0000000000000000000000008152fd5b929391601f928173ffffffffffffffffffffffffffffffffffffffff60646020' + + '957fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0604051988997889687947f1626ba' + + '7e000000000000000000000000000000000000000000000000000000009e8f8752600487015260406024870152816044' + + '870152868601378b85828601015201168101030192165afa9081156123a857829161232a575b507fffffffff00000000' + + '0000000000000000000000000000000000000000000000009150160361230057565b60046040517fb0669cbc00000000' + + '0000000000000000000000000000000000000000000000008152fd5b90506020813d82116123a0575b81612344602093' + + '83611437565b810103126103365751907fffffffff000000000000000000000000000000000000000000000000000000' + + '0082168203610a9a57507fffffffff0000000000000000000000000000000000000000000000000000000090386122d4' + + '565b3d9150612337565b6040513d84823e3d90fdfea164736f6c6343000811000a'; + +const PERMIT2_PROXY_CODE = + '0x608060405234801561000f575f5ffd5b5060043610610055575f3560e01c806313cd3b5314610059578063156e2152' + + '1461006e5780632c03ae6a1461008c5780636afdd850146100c1578063fa34037814610100575b5f5ffd5b61006c6100' + + '6736600461076c565b610113565b005b610076610211565b604051610083919061080e565b60405180910390f35b6100' + + 'b37fd97b3239a7f32295517bd14cb074edfdd188dfe5eb42f802bb26d4fd1eb12c3781565b6040519081526020016100' + + '83565b6100e87f000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba381565b604051600160' + + '0160a01b039091168152602001610083565b61006c61010e366004610827565b61022d565b61011b610322565b5f7fd9' + + '7b3239a7f32295517bd14cb074edfdd188dfe5eb42f802bb26d4fd1eb12c3761014a60208601866108b5565b60408051' + + '6020818101949094526001600160a01b039092169082015290850135606082015260800160408051601f198184030181' + + '52919052805160209182012091506101cb908790818101359088906101a5908901896108b5565b886020013586604051' + + '8060a0016040528060648152602001610aa8606491398a8a610340565b6040517f97088ec3606cfe8cc112180570d03f' + + 'cde05f9b8e1bfef8e27784eaf5dd5691b6905f90a15061020a60015f516020610b0c5f395f51905f5255565b50505050' + + '50565b6040518060a0016040528060648152602001610aa86064913981565b610235610322565b610251610245602087' + + '01876108b5565b85886020890135610476565b5f7fd97b3239a7f32295517bd14cb074edfdd188dfe5eb42f802bb26d4' + + 'fd1eb12c3761028060208601866108b5565b604080516020818101949094526001600160a01b03909216908201529085' + + '0135606082015260800160408051601f19818403018152919052805160209182012091506102db908790818101359088' + + '906101a5908901896108b5565b6040517fde5b89d10fc800c459329c382fabfcad0be0ed7e5328e01fae04e507b09ef5' + + 'd8905f90a15061031a60015f516020610b0c5f395f51905f5255565b505050505050565b61032a6106b5565b60025f51' + + '6020610b0c5f395f51905f5255565b90565b875f036103605760405163162908e360e11b815260040160405180910390' + + 'fd5b6001600160a01b038716610387576040516349e27cff60e01b815260040160405180910390fd5b6001600160a01b' + + '0386166103ae5760405163ac6b05f560e01b815260040160405180910390fd5b844210156103cf5760405163532a9cfd' + + '60e11b815260040160405180910390fd5b6040805180820182526001600160a01b038089168252602082018b90529151' + + '6309be14ff60e11b815290917f000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba3169063' + + '137c29fe9061043d908d9085908d908b908b908b908b906004016108f6565b5f604051808303815f87803b1580156104' + + '54575f5ffd5b505af1158015610466573d5f5f3e3d5ffd5b5050505050505050505050505050565b8135811461049757' + + '60405163050cda4960e01b815260040160405180910390fd5b6001600160a01b03841663d505accf847f000000000000' + + '000000000000000000000022d473030f116ddee9f6b43ac78ba3853560208701356104df60a0890160808a0161099556' + + '5b604080516001600160e01b031960e089901b1681526001600160a01b03968716600482015295909416602486015260' + + '44850192909252606484015260ff16608483015285013560a4820152606085013560c482015260e4015f604051808303' + + '815f87803b15801561054e575f5ffd5b505af192505050801561055f575060015b6106af5761056b6109b5565b806308' + + 'c379a0036105db575061057f610a06565b8061058a575061063d565b836001600160a01b0316856001600160a01b0316' + + '7f0fea7e5a0e14e9f700b9b0666ffd524bacb9ceb9e6234ea2bfb8e1d0ee2365e7836040516105cd919061080e565b60' + + '405180910390a3506106af565b634e487b710361063d576105ed610a8a565b906105f8575061063d565b836001600160' + + 'a01b0316856001600160a01b03167f278207c426558a8c6ef49e9a3c6f87d257ca5c1852f0c988ad5caf894d19762383' + + '6040516105cd91815260200190565b3d808015610666576040519150601f19603f3d011682016040523d82523d5f6020' + + '84013e61066b565b606091505b50836001600160a01b0316856001600160a01b03167f41d715076dfd9c1fd3135fbb76' + + '75020aab7fb8e82349a0cd5aaa970dcfb816fe836040516105cd919061080e565b50505050565b5f516020610b0c5f39' + + '5f51905f52546002036106e457604051633ee5aeb560e01b815260040160405180910390fd5b565b5f60808284031215' + + '6106f6575f5ffd5b50919050565b80356001600160a01b0381168114610712575f5ffd5b919050565b5f604082840312' + + '156106f6575f5ffd5b5f5f83601f840112610737575f5ffd5b50813567ffffffffffffffff81111561074e575f5ffd5b' + + '602083019150836020828501011115610765575f5ffd5b9250929050565b5f5f5f5f5f6101008688031215610781575f' + + '5ffd5b61078b87876106e6565b9450610799608087016106fc565b93506107a88760a08801610717565b925060e08601' + + '3567ffffffffffffffff8111156107c3575f5ffd5b6107cf88828901610727565b969995985093965092949392505050' + + '565b5f81518084528060208401602086015e5f602082860101526020601f19601f83011685010191505092915050565b' + + '602081525f61082060208301846107e0565b9392505050565b5f5f5f5f5f5f8688036101a081121561083e575f5ffd5b' + + '60a081121561084b575f5ffd5b5086955061085c8860a089016106e6565b945061086b61012088016106fc565b935061' + + '087b886101408901610717565b925061018087013567ffffffffffffffff811115610897575f5ffd5b6108a389828a01' + + '610727565b979a9699509497509295939492505050565b5f602082840312156108c5575f5ffd5b610820826106fc565b' + + '81835281816020850137505f828201602090810191909152601f909101601f19169091010190565b6001600160a01b03' + + '610907896106fc565b168152602088810135818301526040808a0135908301526060808a013590830152875160016001' + + '60a01b0316608083015287015160a08201525f61095660c08301886001600160a01b03169052565b8560e08301526101' + + '406101008301526109736101408301866107e0565b8281036101208401526109878185876108ce565b9a995050505050' + + '5050505050565b5f602082840312156109a5575f5ffd5b813560ff81168114610820575f5ffd5b5f60033d111561033d' + + '5760045f5f3e505f5160e01c90565b601f8201601f1916810167ffffffffffffffff811182821017156109ff57634e48' + + '7b7160e01b5f52604160045260245ffd5b6040525050565b5f60443d1015610a135790565b6040513d60031901600482' + + '3e80513d602482011167ffffffffffffffff82111715610a3d57505090565b808201805167ffffffffffffffff811115' + + '610a59575050505090565b3d8401600319018282016020011115610a73575050505090565b610a826020828501018561' + + '09cd565b509392505050565b5f5f60233d1115610aa357602060045f3e50505f516001905b909156fe5769746e657373' + + '207769746e65737329546f6b656e5065726d697373696f6e73286164647265737320746f6b656e2c75696e7432353620' + + '616d6f756e74295769746e657373286164647265737320746f2c75696e743235362076616c69644166746572299b779b' + + '17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f00a264697066735822122027467754a9b59749d4' + + '29ab315c3464fc316abae10e53d8b86e8fa8ae8a4a0e1f64736f6c634300081c0033'; + +const MULTICALL3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11'; +export const MULTICALL3_CODE_HASH = + '0xd5c15df687b16f2ff992fc8d767b4216323184a2bbc6ee2f9c398c318e770891'; + +const MULTICALL3_CODE = + '0x6080604052600436106100f35760003560e01c80634d2301cc1161008a578063a8b0574e11610059578063a8b0574e' + + '1461025a578063bce38bd714610275578063c3077fa914610288578063ee82ac5e1461029b57600080fd5b80634d2301' + + 'cc146101ec57806372425d9d1461022157806382ad56cb1461023457806386d516e81461024757600080fd5b80633408' + + 'e470116100c65780633408e47014610191578063399542e9146101a45780633e64a696146101c657806342cbb15c1461' + + '01d957600080fd5b80630f28c97d146100f8578063174dea711461011a578063252dba421461013a57806327e86d6e14' + + '61015b575b600080fd5b34801561010457600080fd5b50425b6040519081526020015b60405180910390f35b61012d61' + + '0128366004610a85565b6102ba565b6040516101119190610bbe565b61014d610148366004610a85565b6104ef565b60' + + '4051610111929190610bd8565b34801561016757600080fd5b50437fffffffffffffffffffffffffffffffffffffffff' + + 'ffffffffffffffffffffffff0140610107565b34801561019d57600080fd5b5046610107565b6101b76101b236600461' + + '0c60565b610690565b60405161011193929190610cba565b3480156101d257600080fd5b5048610107565b3480156101' + + 'e557600080fd5b5043610107565b3480156101f857600080fd5b50610107610207366004610ce2565b73ffffffffffff' + + 'ffffffffffffffffffffffffffff163190565b34801561022d57600080fd5b5044610107565b61012d61024236600461' + + '0a85565b6106ab565b34801561025357600080fd5b5045610107565b34801561026657600080fd5b5060405141815260' + + '2001610111565b61012d610283366004610c60565b61085a565b6101b7610296366004610a85565b610a1a565b348015' + + '6102a757600080fd5b506101076102b6366004610d18565b4090565b60606000828067ffffffffffffffff8111156102' + + 'd8576102d8610d31565b60405190808252806020026020018201604052801561031e57816020015b6040805180820190' + + '915260008152606060208201528152602001906001900390816102f65790505b5092503660005b828110156104775760' + + '0085828151811061034157610341610d60565b6020026020010151905087878381811061035d5761035d610d60565b90' + + '5060200281019061036f9190610d8f565b6040810135958601959093506103886020850185610ce2565b73ffffffffff' + + 'ffffffffffffffffffffffffffffff16816103ac6060870187610dcd565b6040516103ba929190610e32565b60006040' + + '518083038185875af1925050503d80600081146103f7576040519150601f19603f3d011682016040523d82523d600060' + + '2084013e6103fc565b606091505b50602080850191909152901515808452908501351761046d577f08c379a000000000' + + '000000000000000000000000000000000000000000000000600052602060045260176024527f4d756c746963616c6c33' + + '3a2063616c6c206661696c656400000000000000000060445260846000fd5b5050600101610325565b508234146104e6' + + '576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601a' + + '60248201527f4d756c746963616c6c333a2076616c7565206d69736d6174636800000000000060448201526064015b60' + + '405180910390fd5b50505092915050565b436060828067ffffffffffffffff81111561050c5761050c610d31565b6040' + + '5190808252806020026020018201604052801561053f57816020015b606081526020019060019003908161052a579050' + + '5b5091503660005b8281101561068657600087878381811061056257610562610d60565b905060200281019061057491' + + '90610e42565b92506105836020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff166105a66020' + + '850185610dcd565b6040516105b4929190610e32565b6000604051808303816000865af19150503d80600081146105f1' + + '576040519150601f19603f3d011682016040523d82523d6000602084013e6105f6565b606091505b5086848151811061' + + '060957610609610d60565b602090810291909101015290508061067d576040517f08c379a00000000000000000000000' + + '0000000000000000000000000000000000815260206004820152601760248201527f4d756c746963616c6c333a206361' + + '6c6c206661696c656400000000000000000060448201526064016104dd565b50600101610546565b5050509250929050' + + '565b43804060606106a086868661085a565b905093509350939050565b6060818067ffffffffffffffff8111156106c7' + + '576106c7610d31565b60405190808252806020026020018201604052801561070d57816020015b604080518082019091' + + '5260008152606060208201528152602001906001900390816106e55790505b5091503660005b828110156104e6576000' + + '84828151811061073057610730610d60565b6020026020010151905086868381811061074c5761074c610d60565b9050' + + '60200281019061075e9190610e76565b925061076d6020840184610ce2565b73ffffffffffffffffffffffffffffffff' + + 'ffffffff166107906040850185610dcd565b60405161079e929190610e32565b6000604051808303816000865af19150' + + '503d80600081146107db576040519150601f19603f3d011682016040523d82523d6000602084013e6107e0565b606091' + + '505b506020808401919091529015158083529084013517610851577f08c379a000000000000000000000000000000000' + + '000000000000000000000000600052602060045260176024527f4d756c746963616c6c333a2063616c6c206661696c65' + + '6400000000000000000060445260646000fd5b50600101610714565b6060818067ffffffffffffffff81111561087657' + + '610876610d31565b6040519080825280602002602001820160405280156108bc57816020015b60408051808201909152' + + '60008152606060208201528152602001906001900390816108945790505b5091503660005b82811015610a1057600084' + + '82815181106108df576108df610d60565b602002602001015190508686838181106108fb576108fb610d60565b905060' + + '200281019061090d9190610e42565b925061091c6020840184610ce2565b73ffffffffffffffffffffffffffffffffff' + + 'ffffff1661093f6020850185610dcd565b60405161094d929190610e32565b6000604051808303816000865af1915050' + + '3d806000811461098a576040519150601f19603f3d011682016040523d82523d6000602084013e61098f565b60609150' + + '5b506020830152151581528715610a07578051610a07576040517f08c379a00000000000000000000000000000000000' + + '0000000000000000000000815260206004820152601760248201527f4d756c746963616c6c333a2063616c6c20666169' + + '6c656400000000000000000060448201526064016104dd565b506001016108c3565b5050509392505050565b60008060' + + '60610a2b60018686610690565b919790965090945092505050565b60008083601f840112610a4b57600080fd5b508135' + + '67ffffffffffffffff811115610a6357600080fd5b6020830191508360208260051b8501011115610a7e57600080fd5b' + + '9250929050565b60008060208385031215610a9857600080fd5b823567ffffffffffffffff811115610aaf57600080fd' + + '5b610abb85828601610a39565b90969095509350505050565b6000815180845260005b81811015610aed576020818501' + + '81015186830182015201610ad1565b81811115610aff576000602083870101525b50601f017fffffffffffffffffffff' + + 'ffffffffffffffffffffffffffffffffffffffffffe0169290920160200192915050565b600082825180855260208086' + + '019550808260051b84010181860160005b84811015610bb1578583037fffffffffffffffffffffffffffffffffffffff' + + 'ffffffffffffffffffffffffe001895281518051151584528401516040858501819052610b9d81860183610ac7565b9a' + + '86019a9450505090830190600101610b4f565b5090979650505050505050565b602081526000610bd16020830184610b' + + '32565b9392505050565b600060408201848352602060408185015281855180845260608601915060608160051b870101' + + '935082870160005b82811015610c52577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + + 'a0888703018452610c40868351610ac7565b95509284019290840190600101610c06565b509398975050505050505050' + + '565b600080600060408486031215610c7557600080fd5b83358015158114610c8557600080fd5b9250602084013567ff' + + 'ffffffffffffff811115610ca157600080fd5b610cad86828701610a39565b9497909650939450505050565b83815282' + + '6020820152606060408201526000610cd96060830184610b32565b95945050505050565b600060208284031215610cf4' + + '57600080fd5b813573ffffffffffffffffffffffffffffffffffffffff81168114610bd157600080fd5b600060208284' + + '031215610d2a57600080fd5b5035919050565b7f4e487b71000000000000000000000000000000000000000000000000' + + '00000000600052604160045260246000fd5b7f4e487b7100000000000000000000000000000000000000000000000000' + + '000000600052603260045260246000fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffff' + + 'ffffffffff81833603018112610dc357600080fd5b9190910192915050565b60008083357fffffffffffffffffffffff' + + 'ffffffffffffffffffffffffffffffffffffffffe1843603018112610e0257600080fd5b83018035915067ffffffffff' + + 'ffffff821115610e1d57600080fd5b602001915036819003821315610a7e57600080fd5b818382376000910190815291' + + '9050565b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc183360301811261' + + '0dc357600080fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa1833603' + + '018112610dc357600080fdfea2646970667358221220bb2b5c71a328032f97c676ae39a1ec2148d3e5d6f73d95e9b179' + + '10152d61f16264736f6c634300080c0033'; + +async function rpc(rpcUrl: string, method: string, params: unknown[]): Promise { + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }); + const body = (await response.json()) as { result?: unknown; error?: unknown }; + if (body.error !== undefined) throw new Error(`${method} failed: ${JSON.stringify(body.error)}`); + return body.result; +} + +async function setCode(rpcUrl: string, address: string, code: string, hash: string) { + if (keccak256(code as `0x${string}`) !== hash) { + throw new Error(`vendored bytecode for ${address} does not match its pinned hash`); + } + await rpc(rpcUrl, 'anvil_setCode', [address, code]); +} + +/** Puts Permit2 and the x402 proxy at their canonical addresses on an Anvil node */ +export async function installPermit2(rpcUrl: string): Promise { + await setCode(rpcUrl, PERMIT2_ADDRESS, PERMIT2_CODE, PERMIT2_CODE_HASH); + await setCode(rpcUrl, x402ExactPermit2ProxyAddress, PERMIT2_PROXY_CODE, PERMIT2_PROXY_CODE_HASH); +} + +/** Puts Multicall3 at its canonical address on an Anvil node */ +export async function installMulticall3(rpcUrl: string): Promise { + await setCode(rpcUrl, MULTICALL3_ADDRESS, MULTICALL3_CODE, MULTICALL3_CODE_HASH); +} diff --git a/tests/fixtures/x402/smoke/buyers.ts b/tests/fixtures/x402/smoke/buyers.ts new file mode 100644 index 0000000..29cb85b --- /dev/null +++ b/tests/fixtures/x402/smoke/buyers.ts @@ -0,0 +1,67 @@ +/** + * SDK clients sign smoke payments as real buyers would, keeping buyer keys + * outside the gateway. + */ +import { createKeyPairSignerFromBytes, getBase58Encoder } from '@solana/kit'; +import { x402Client } from '@x402/core/client'; +import type { Network, PaymentRequired } from '@x402/core/types'; +import { registerExactEvmScheme } from '@x402/evm/exact/client'; +import { createClientNearSigner } from '@x402/near'; +import { ExactNearScheme } from '@x402/near/exact/client'; +import { ExactSvmScheme } from '@x402/svm/exact/client'; +import { privateKeyToAccount } from 'viem/accounts'; +import type { SmokeSettings, SmokeTarget } from './targets'; + +// solana-keygen writes a JSON array of 64 bytes; wallets export base58 +function solanaSigner(secret: string) { + const bytes = secret.startsWith('[') + ? Uint8Array.from(JSON.parse(secret) as number[]) + : getBase58Encoder().encode(secret); + return createKeyPairSignerFromBytes(bytes); +} + +/** The buyer as its chain names it: an address, a wallet or an account */ +export async function buyerIdentity(target: SmokeTarget, settings: SmokeSettings): Promise { + if (target.family === 'evm') + return privateKeyToAccount(settings.buyerKey as `0x${string}`).address; + if (target.family === 'svm') return (await solanaSigner(settings.buyerKey)).address; + return settings.buyerAccount as string; +} + +/** + * Signs a payment for the challenge's offer, returned as the + * `PAYMENT-SIGNATURE` header value. The client keeps its default spend + * controls: default assets only, at most $1 per payment. + */ +export async function signPayment( + target: SmokeTarget, + settings: SmokeSettings, + offered: PaymentRequired, +): Promise { + const client = new x402Client(); + const network = target.network as Network; + if (target.family === 'evm') { + registerExactEvmScheme(client, { + signer: privateKeyToAccount(settings.buyerKey as `0x${string}`), + networks: [network], + }); + } else if (target.family === 'svm') { + client.register( + network, + new ExactSvmScheme(await solanaSigner(settings.buyerKey), { rpcUrl: settings.rpcUrl }), + ); + } else { + client.register( + network, + new ExactNearScheme( + createClientNearSigner({ + accountId: settings.buyerAccount as string, + secretKey: settings.buyerKey as `ed25519:${string}`, + rpcUrls: { [network]: settings.rpcUrl }, + }), + ), + ); + } + const payload = await client.createPaymentPayload(offered); + return Buffer.from(JSON.stringify(payload)).toString('base64'); +} diff --git a/tests/fixtures/x402/smoke/evidence.ts b/tests/fixtures/x402/smoke/evidence.ts new file mode 100644 index 0000000..4765f90 --- /dev/null +++ b/tests/fixtures/x402/smoke/evidence.ts @@ -0,0 +1,261 @@ +/** + * Settlement evidence read from a public chain: the transaction the facilitator + * reported, and the token movement inside it. Balances alone are not evidence, + * since an unrelated transfer can explain a change. + */ +import { setTimeout as sleep } from 'node:timers/promises'; +import { KeyPair, type KeyPairString } from '@near-js/crypto'; +import { PERMIT2_ADDRESS } from '@x402/evm'; +import { createPublicClient, erc20Abi, http, parseEventLogs } from 'viem'; +import { expect } from 'vitest'; +import { ftBalance, nearRpc } from '../near-sandbox'; +import { associatedTokenAddress, solanaRpc } from '../solana-validator'; +import type { SmokeFamily } from './targets'; + +/** A token transfer a settlement must contain */ +export interface ExpectedTransfer { + readonly asset: string; + readonly from: string; + readonly to: string; + /** Base units */ + readonly amount: bigint; + /** EVM transaction hash, Solana signature or NEAR outer transaction hash */ + readonly reference: string; +} + +// Polls until `read` returns a value; public nodes can lag the facilitator's +async function poll(read: () => Promise, what: string, timeoutMs = 180_000) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const value = await read().catch(() => undefined); + if (value !== undefined) return value; + if (Date.now() > deadline) + throw new Error(`timed out after ${timeoutMs} ms waiting for ${what}`); + await sleep(3_000); + } +} + +// The transaction succeeded and emitted the token's Transfer from buyer to payee +async function evmTransfer(rpcUrl: string, expected: ExpectedTransfer): Promise { + const client = createPublicClient({ transport: http(rpcUrl) }); + const receipt = await client.waitForTransactionReceipt({ + hash: expected.reference as `0x${string}`, + timeout: 180_000, + pollingInterval: 3_000, + }); + expect(receipt.status, `transaction ${expected.reference} reverted`).toBe('success'); + const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); + const transfers = parseEventLogs({ abi: erc20Abi, eventName: 'Transfer', logs: receipt.logs }) + .filter((log) => same(log.address, expected.asset)) + .map(({ args }) => args); + expect( + transfers.some( + (args) => + same(args.from, expected.from) && + same(args.to, expected.to) && + args.value === expected.amount, + ), + `no ${expected.asset} Transfer of ${expected.amount} from ${expected.from} to ${expected.to} in ${expected.reference}: ${JSON.stringify(transfers, (_key, value) => (typeof value === 'bigint' ? value.toString() : value))}`, + ).toBe(true); +} + +interface SolanaTokenBalance { + readonly mint: string; + readonly owner?: string; + readonly uiTokenAmount: { readonly amount: string }; +} + +// The confirmed transaction moved the mint from the buyer's to the payee's account +async function svmTransfer(rpcUrl: string, expected: ExpectedTransfer): Promise { + const tx = await poll( + () => + solanaRpc<{ + meta: { + err: unknown; + preTokenBalances: SolanaTokenBalance[]; + postTokenBalances: SolanaTokenBalance[]; + }; + } | null>(rpcUrl, 'getTransaction', [ + expected.reference, + { encoding: 'jsonParsed', commitment: 'confirmed', maxSupportedTransactionVersion: 0 }, + ]).then((result) => result ?? undefined), + `Solana transaction ${expected.reference}`, + ); + expect(tx.meta.err, `transaction ${expected.reference} failed`).toBeNull(); + const held = (balances: SolanaTokenBalance[], owner: string) => + BigInt( + balances.find((entry) => entry.mint === expected.asset && entry.owner === owner) + ?.uiTokenAmount.amount ?? '0', + ); + const change = (owner: string) => + held(tx.meta.postTokenBalances, owner) - held(tx.meta.preTokenBalances, owner); + expect(change(expected.to), `payee's ${expected.asset} change in ${expected.reference}`).toBe( + expected.amount, + ); + expect(change(expected.from), `buyer's ${expected.asset} change in ${expected.reference}`).toBe( + -expected.amount, + ); +} + +interface NearTxStatus { + readonly final_execution_status?: string; + readonly receipts: readonly { + readonly receipt_id: string; + readonly receiver_id: string; + readonly predecessor_id: string; + readonly receipt: { + readonly Action?: { + readonly actions: readonly { + readonly FunctionCall?: { readonly method_name: string; readonly args: string }; + }[]; + }; + }; + }[]; + readonly receipts_outcome: readonly { + readonly id: string; + readonly outcome: { readonly status: Record }; + }[]; +} + +// The final outer transaction carries the buyer's ft_transfer to the payee, and +// the token's receipt for it succeeded +async function nearTransfer( + rpcUrl: string, + expected: ExpectedTransfer, + senders: readonly string[], +): Promise { + // The outer transaction is the relayer's, and the lookup needs its signer + const status = await poll( + async () => { + for (const sender of senders) { + const result = await nearRpc(rpcUrl, 'EXPERIMENTAL_tx_status', { + tx_hash: expected.reference, + sender_account_id: sender, + wait_until: 'FINAL', + }).catch(() => undefined); + if (result !== undefined) return result; + } + return undefined; + }, + `NEAR transaction ${expected.reference} signed by ${senders.join(' or ')}`, + ); + expect(status.final_execution_status).toBe('FINAL'); + + const calls = status.receipts.flatMap((receipt) => + (receipt.receipt.Action?.actions ?? []).flatMap((action) => + action.FunctionCall?.method_name === 'ft_transfer' + ? [ + { + receipt, + args: JSON.parse( + Buffer.from(action.FunctionCall.args, 'base64').toString('utf8'), + ) as Record, + }, + ] + : [], + ), + ); + const call = calls.find( + ({ receipt, args }) => + receipt.receiver_id === expected.asset && + receipt.predecessor_id === expected.from && + args['receiver_id'] === expected.to && + BigInt(String(args['amount'])) === expected.amount, + ); + expect( + call, + `no ft_transfer of ${expected.amount} ${expected.asset} from ${expected.from} to ${expected.to} in ${expected.reference}`, + ).toBeDefined(); + const outcome = status.receipts_outcome.find((entry) => entry.id === call?.receipt.receipt_id); + expect(outcome?.outcome.status, 'the token receipt for the ft_transfer').toHaveProperty( + 'SuccessValue', + ); +} + +/** Asserts from the chain alone that the settlement moved exactly the expected tokens */ +export async function assertTransferOnChain( + family: SmokeFamily, + rpcUrl: string, + expected: ExpectedTransfer, + nearSenders: readonly string[] = [], +): Promise { + if (family === 'evm') return evmTransfer(rpcUrl, expected); + if (family === 'svm') return svmTransfer(rpcUrl, expected); + return nearTransfer(rpcUrl, expected, nearSenders); +} + +/** The buyer's balance of the asset in base units, to fail early and clearly without funds */ +export async function buyerBalance( + family: SmokeFamily, + rpcUrl: string, + asset: string, + buyer: string, +): Promise { + if (family === 'evm') { + return createPublicClient({ transport: http(rpcUrl) }).readContract({ + address: asset as `0x${string}`, + abi: erc20Abi, + functionName: 'balanceOf', + args: [buyer as `0x${string}`], + }); + } + if (family === 'svm') { + const account = await associatedTokenAddress(buyer, asset); + const result = await solanaRpc<{ value: { amount: string } } | null>( + rpcUrl, + 'getTokenAccountBalance', + [account, { commitment: 'confirmed' }], + ).catch(() => null); + return BigInt(result?.value.amount ?? '0'); + } + return ftBalance(rpcUrl, asset, buyer); +} + +/** + * A Solana transfer goes to the payee's associated token account, which the + * SDK client does not create. Returns it when it exists. + */ +export async function solanaPayeeAccount( + rpcUrl: string, + asset: string, + payTo: string, +): Promise { + const account = await associatedTokenAddress(payTo, asset); + const info = await solanaRpc<{ value: unknown } | null>(rpcUrl, 'getAccountInfo', [ + account, + { encoding: 'base64', commitment: 'confirmed' }, + ]); + return info?.value ? account : undefined; +} + +/** + * Whether the buyer signs with a full-access key. A delegated `ft_transfer` + * attaches one yoctoNEAR, which a function-call key cannot. + */ +export async function nearFullAccessKey( + rpcUrl: string, + accountId: string, + key: KeyPairString, +): Promise { + const access = await nearRpc<{ permission: unknown }>(rpcUrl, 'query', { + request_type: 'view_access_key', + finality: 'final', + account_id: accountId, + public_key: KeyPair.fromString(key).getPublicKey().toString(), + }).catch(() => undefined); + return access?.permission === 'FullAccess'; +} + +/** The buyer's Permit2 allowance for the token, which a Permit2 payment spends */ +export async function permit2Allowance( + rpcUrl: string, + asset: string, + buyer: string, +): Promise { + return createPublicClient({ transport: http(rpcUrl) }).readContract({ + address: asset as `0x${string}`, + abi: erc20Abi, + functionName: 'allowance', + args: [buyer as `0x${string}`, PERMIT2_ADDRESS], + }); +} diff --git a/tests/fixtures/x402/smoke/loopback-tls.ts b/tests/fixtures/x402/smoke/loopback-tls.ts new file mode 100644 index 0000000..25808ac --- /dev/null +++ b/tests/fixtures/x402/smoke/loopback-tls.ts @@ -0,0 +1,89 @@ +/** + * HTTPS for the facilitator the harness runs on 127.0.0.1. A mainnet gateway + * refuses a plain-HTTP facilitator, so the run creates a throwaway certificate + * and trusts it for that one origin; every other request keeps the default + * certificate checks. + */ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { request } from 'node:https'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** A self-signed certificate for 127.0.0.1, valid for a day; needs the `openssl` CLI */ +export function loopbackCertificate(): { key: string; cert: string } { + const dir = mkdtempSync(join(tmpdir(), 'oac-smoke-tls-')); + try { + const created = spawnSync('openssl', [ + 'req', + '-x509', + '-newkey', + 'ec', + '-pkeyopt', + 'ec_paramgen_curve:prime256v1', + '-nodes', + '-keyout', + join(dir, 'key.pem'), + '-out', + join(dir, 'cert.pem'), + '-days', + '1', + '-subj', + '/CN=127.0.0.1', + '-addext', + 'subjectAltName=IP:127.0.0.1', + ]); + if (created.status !== 0) { + throw new Error(`openssl could not create the loopback certificate: ${created.stderr}`); + } + return { + key: readFileSync(join(dir, 'key.pem'), 'utf8'), + cert: readFileSync(join(dir, 'cert.pem'), 'utf8'), + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * Sends `fetch` requests for `origin` through `node:https` with `ca` as the + * only trusted root. Returns the function that restores the original `fetch`. + */ +export function trustLoopback(origin: string, ca: string): () => void { + const original = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const outgoing = new Request(input, init); + if (new URL(outgoing.url).origin !== origin) return original(input, init); + const body = outgoing.body ? Buffer.from(await outgoing.arrayBuffer()) : undefined; + return new Promise((resolve, reject) => { + const req = request( + outgoing.url, + { + method: outgoing.method, + headers: Object.fromEntries(outgoing.headers), + ca, + signal: outgoing.signal, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('error', reject); + res.on('end', () => { + const headers = new Headers(); + for (const [name, value] of Object.entries(res.headers)) { + for (const item of [value ?? []].flat()) headers.append(name, item); + } + resolve( + new Response(Buffer.concat(chunks), { status: res.statusCode ?? 500, headers }), + ); + }); + }, + ); + req.on('error', reject); + req.end(body); + }); + }; + return () => { + globalThis.fetch = original; + }; +} diff --git a/tests/fixtures/x402/smoke/suite.ts b/tests/fixtures/x402/smoke/suite.ts new file mode 100644 index 0000000..b7d2088 --- /dev/null +++ b/tests/fixtures/x402/smoke/suite.ts @@ -0,0 +1,360 @@ +/** + * Runs each target through its own gateway using the config loader and SDK + * buyer client. Pays once, checks chain evidence, response and receipt, then + * verifies that replay does not deliver twice. + */ +import { appendFileSync } from 'node:fs'; +import type { KeyPairString } from '@near-js/crypto'; +import { x402Client, x402HTTPClient } from '@x402/core/client'; +import type { PaymentRequired } from '@x402/core/types'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { parseConfig } from '../../../../src/config'; +import { + isCommerceError, + type Logger, + type PaymentProvider, + type ReceiptStore, +} from '../../../../src/core'; +import { createGateway, type GatewayInstance } from '../../../../src/gateway'; +import { createConfiguredPaymentProviders } from '../../../../src/gateway/payment-providers'; +import { createSqliteReceiptStore } from '../../../../src/storage/receipts'; +import { type LoopbackFacilitator, startLoopbackFacilitator } from '../loopback-facilitator'; +import { buyerIdentity, signPayment } from './buyers'; +import { + assertTransferOnChain, + buyerBalance, + nearFullAccessKey, + permit2Allowance, + solanaPayeeAccount, +} from './evidence'; +import { loopbackCertificate, trustLoopback } from './loopback-tls'; +import { planSmoke, type SmokeKind, type SmokeSettings, type SmokeTarget } from './targets'; + +const RESOURCE_ID = 'smoke_report'; +const SETTLE_TIMEOUT_MS = 600_000; + +type FacilitatorAuth = Extract['auth']; + +// What an operator writes for this target: the network's default USDC and a USD price +function smokeConfig( + target: SmokeTarget, + settings: SmokeSettings, + facilitator: { readonly url: string; readonly auth: FacilitatorAuth }, + allowMainnet = true, +): Record { + return { + version: 1, + merchant: { + id: `${target.id}-smoke`, + name: `${target.id} smoke test`, + publicBaseUrl: 'http://127.0.0.1:8080', + }, + server: { port: 8080, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } }, + resources: { + [RESOURCE_ID]: { + name: 'Smoke report', + backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: settings.amount, currency: 'USD' }, + expose: ['http'], + payments: ['x402'], + }, + }, + payments: { + x402: { + enabled: true, + network: target.network, + payTo: settings.payTo, + ...(target.assetTransferMethod !== undefined + ? { assetTransferMethod: target.assetTransferMethod } + : {}), + networks: { [target.network]: { rpcUrl: settings.rpcUrl } }, + maxTimeoutSeconds: target.kind === 'mainnet' ? 600 : 300, + facilitator: { mode: 'remote', url: facilitator.url, auth: facilitator.auth }, + ...(target.kind === 'mainnet' + ? { + ...(allowMainnet ? { allowMainnet: true } : {}), + // Acknowledges a facilitator without a credential; ignored with one + allowUnauthenticatedFacilitator: true, + } + : {}), + }, + }, + }; +} + +// The accounts a NEAR facilitator relays from, which a transaction lookup needs +async function nearRelayers(url: string, network: string): Promise { + const response = await fetch(`${url.replace(/\/$/, '')}/supported`); + const body = (await response.json()) as { signers?: Record }; + const relayers = [...(body.signers?.[network] ?? []), ...(body.signers?.['near:*'] ?? [])]; + if (relayers.length === 0) { + throw new Error(`the facilitator lists no NEAR relayer, so its transactions cannot be read`); + } + return relayers; +} + +const sameAccount = (target: SmokeTarget, a: string, b: string) => + target.family === 'evm' ? a.toLowerCase() === b.toLowerCase() : a === b; + +function defineTarget(kind: SmokeKind, target: SmokeTarget, settings: SmokeSettings): void { + describe(`${target.id} (${target.network})`, () => { + let gateway: GatewayInstance; + let store: ReceiptStore; + let provider: PaymentProvider; + let selfHosted: LoopbackFacilitator | undefined; + let restoreFetch: (() => void) | undefined; + let facilitator: { url: string; auth: FacilitatorAuth }; + let nearSenders: readonly string[] = []; + let buyer: string; + let backendCalls = 0; + // Everything the gateway logged, for the credential check + const logged: string[] = []; + + const invoke = (headers: Record = {}) => + gateway.server.inject({ + method: 'POST', + url: `/api/resources/${RESOURCE_ID}/invoke`, + headers: { 'content-type': 'application/json', ...headers }, + payload: {}, + }); + + beforeAll(async () => { + buyer = await buyerIdentity(target, settings); + if (settings.facilitator.type === 'self-hosted-near') { + const tls = loopbackCertificate(); + selfHosted = await startLoopbackFacilitator({ + tls, + near: { + network: target.network, + rpcUrl: settings.rpcUrl, + relayer: { + accountId: settings.facilitator.relayerAccount, + secretKey: settings.facilitator.relayerKey as KeyPairString, + }, + }, + }); + restoreFetch = trustLoopback(new URL(selfHosted.url).origin, tls.cert); + facilitator = { url: selfHosted.url, auth: { type: 'none' } }; + nearSenders = [settings.facilitator.relayerAccount]; + } else { + facilitator = { url: settings.facilitator.url, auth: settings.facilitator.auth }; + if (target.family === 'near') + nearSenders = await nearRelayers(facilitator.url, target.network); + } + + const capture = (line: unknown, ...rest: unknown[]): void => { + logged.push(JSON.stringify([line, ...rest])); + }; + const makeLogger = (): Logger => ({ + debug: capture, + info: capture, + warn: capture, + error: capture, + child: () => makeLogger(), + }); + const logger = makeLogger(); + + const config = parseConfig(smokeConfig(target, settings, facilitator), {}); + store = createSqliteReceiptStore({ path: ':memory:' }); + await store.init(); + const providers = createConfiguredPaymentProviders(config.payments, logger); + provider = providers[0] as PaymentProvider; + gateway = await createGateway({ + config, + store, + logger, + paymentProviders: providers, + protocolAdapters: [], + // Stubbed: the suite asks whether money moved, and the offline suites + // cover the HTTP backend + backend: { + call: async () => { + backendCalls += 1; + return { status: 200, headers: {}, body: { report: target.id }, durationMs: 1 }; + }, + }, + }); + }, 180_000); + + afterAll(async () => { + await gateway?.close(); + await store?.close(); + await selfHosted?.close(); + restoreFetch?.(); + }); + + if (kind === 'mainnet') { + it('refuses the same config without allowMainnet', () => { + try { + parseConfig(smokeConfig(target, settings, facilitator, false), {}); + expect.unreachable('a mainnet config without allowMainnet must be refused'); + } catch (err) { + expect(isCommerceError(err) && err.code).toBe('CONFIG_INVALID'); + expect(String((err as Error).message)).toContain('allowMainnet'); + } + }); + } + + it(`reports a ${kind} deployment and passes its health check`, async () => { + const res = await gateway.server.inject({ + method: 'GET', + url: '/.well-known/agent-commerce', + }); + const x402 = res.json().payments.x402; + expect(x402).toMatchObject({ mode: kind, network: target.network }); + expect(x402.facilitator).toEqual({ mode: 'remote' }); + expect(res.payload).not.toContain(new URL(facilitator.url).host); + // Chain id, token and facilitator support, before any money moves + const health = await provider.health(); + expect(health.status, `health check failed: ${health.detail}`).toBe('pass'); + }, 120_000); + + it( + 'settles once, proven on chain, and delivers once', + async () => { + // 1. The unpaid request gets the challenge + const unpaid = await invoke(); + expect(unpaid.statusCode).toBe(402); + const offered: PaymentRequired = new x402HTTPClient( + new x402Client(), + ).getPaymentRequiredResponse((name) => unpaid.headers[name.toLowerCase()] as string); + const offer = offered.accepts[0]; + if (offer === undefined) throw new Error('the challenge lists no offer'); + expect(offer.network).toBe(target.network); + expect(sameAccount(target, offer.payTo, settings.payTo)).toBe(true); + const amount = BigInt(offer.amount); + + // 2. Preconditions that would otherwise surface as an opaque refusal + const held = await buyerBalance(target.family, settings.rpcUrl, offer.asset, buyer); + expect( + held >= amount, + `buyer ${buyer} holds ${held} of ${offer.asset}, needs ${amount}`, + ).toBe(true); + if (target.family === 'svm') { + const payee = await solanaPayeeAccount(settings.rpcUrl, offer.asset, settings.payTo); + expect( + payee, + `${settings.payTo} has no token account for ${offer.asset}; send it any amount first`, + ).toBeDefined(); + } + if (target.family === 'near') { + expect( + await nearFullAccessKey(settings.rpcUrl, buyer, settings.buyerKey as KeyPairString), + `the buyer key is not a full-access key of ${buyer}, or the account does not exist`, + ).toBe(true); + } + if (target.assetTransferMethod === 'permit2') { + const allowance = await permit2Allowance(settings.rpcUrl, offer.asset, buyer); + expect( + allowance >= amount, + `buyer ${buyer} has not approved Permit2 for ${offer.asset}; approve it once, then rerun`, + ).toBe(true); + } + + // 3. The buyer pays, and the gateway settles through the facilitator + const header = { 'payment-signature': await signPayment(target, settings, offered) }; + const paid = await invoke(header); + expect(paid.statusCode, `the gateway refused the payment: ${paid.payload}`).toBe(200); + expect(paid.json()).toEqual({ report: target.id }); + const settlement = new x402HTTPClient(new x402Client()).getPaymentSettleResponse( + (name) => paid.headers[name.toLowerCase()] as string, + ); + expect(settlement).toMatchObject({ success: true, network: target.network }); + if (settlement.payer !== undefined) { + expect(sameAccount(target, settlement.payer, buyer)).toBe(true); + } + + // 4. The chain, not the response, proves the transfer + await assertTransferOnChain( + target.family, + settings.rpcUrl, + { + asset: offer.asset, + from: buyer, + to: offer.payTo, + amount, + reference: settlement.transaction, + }, + nearSenders, + ); + + // 5. The receipt names the settlement + const settled = (await store.listReceipts()).filter( + (receipt) => receipt.payment?.externalReference === settlement.transaction, + ); + expect(settled).toHaveLength(1); + expect(settled[0]?.payment).toMatchObject({ status: 'settled', network: target.network }); + expect(settled[0]?.backendStatus).toBe(200); + + // 6. The same payment again moves nothing and delivers nothing + const replay = await invoke(header); + expect([402, 409]).toContain(replay.statusCode); + expect(backendCalls).toBe(1); + expect( + (await store.listReceipts()).filter( + (receipt) => receipt.payment?.externalReference === settlement.transaction, + ), + ).toHaveLength(1); + + // 7. A record of what ran, without secrets + const record = { + target: target.id, + network: target.network, + family: target.family, + method: target.family === 'evm' ? (target.assetTransferMethod ?? 'eip3009') : 'exact', + asset: offer.asset, + amount: amount.toString(), + payer: buyer, + payTo: offer.payTo, + facilitator: + settings.facilitator.type === 'remote' + ? new URL(facilitator.url).origin + : 'self-hosted (SDK facilitator in this run)', + reference: settlement.transaction, + explorer: target.explorer(settlement.transaction), + receipt: settled[0]?.id, + at: new Date().toISOString(), + }; + console.log(`[${kind}] evidence ${JSON.stringify(record)}`); + const evidenceFile = process.env['X402_SMOKE_EVIDENCE_FILE']; + if (evidenceFile) appendFileSync(evidenceFile, `${JSON.stringify(record)}\n`); + }, + SETTLE_TIMEOUT_MS, + ); + + it('never logged a key or a credential', () => { + const haystack = logged.join('\n'); + for (const secret of settings.secrets) expect(haystack).not.toContain(secret); + }); + }); +} + +/** + * Defines the x402 suite for testnets or mainnets. Problems with the requested + * targets fail it before anything is spent; with nothing to run it skips and + * names what each target needs. + */ +export function defineSmokeSuite(kind: SmokeKind): void { + const plan = planSmoke(kind, process.env); + if (plan.errors.length > 0) { + describe(`x402 ${kind} smoke`, () => { + it('has every setting the requested targets need, within the spend caps', () => { + throw new Error(plan.errors.join('\n')); + }); + }); + return; + } + if (plan.skipped.length > 0) { + const needs = plan.skipped.map(({ id, missing }) => `${id} needs ${missing.join(', ')}`); + console.log(`[${kind}] x402 smoke targets skipped: ${needs.join('; ')}`); + } + if (plan.run.length === 0) { + describe.skip(`x402 ${kind} smoke`, () => { + it('settles on a public network', () => {}); + }); + return; + } + for (const { target, settings } of plan.run) defineTarget(kind, target, settings); +} diff --git a/tests/fixtures/x402/smoke/targets.ts b/tests/fixtures/x402/smoke/targets.ts new file mode 100644 index 0000000..f1c2ec9 --- /dev/null +++ b/tests/fixtures/x402/smoke/targets.ts @@ -0,0 +1,401 @@ +/** + * Public-network smoke targets: one per network, family and transfer method. + * Reads settings and selects targets without network calls, enabling offline tests. + * + * Targets pay the network's default USDC; buyer and merchant keys stay outside + * the gateway. Some targets share accounts. + */ +import { parseCanonicalAmount } from '../../../../src/payments/x402/amount'; +import type { X402FacilitatorConfig } from '../../../../src/payments/x402/guardrails'; + +export type SmokeKind = 'testnet' | 'mainnet'; +export type SmokeFamily = 'evm' | 'svm' | 'near'; +type Env = Readonly>; +type FacilitatorAuth = Extract['auth']; + +/** How the gateway reaches the facilitator that settles a target */ +export type SmokeFacilitator = + | { readonly type: 'remote'; readonly url: string; readonly auth: FacilitatorAuth } + // Run the SDK facilitator locally with a gas-paying NEAR relayer. + | { + readonly type: 'self-hosted-near'; + readonly relayerAccount: string; + readonly relayerKey: string; + }; + +export interface SmokeSettings { + readonly rpcUrl: string; + readonly payTo: string; + /** EVM private key, Solana secret key, or NEAR `ed25519:` key of `account` */ + readonly buyerKey: string; + readonly buyerAccount?: string; + readonly facilitator: SmokeFacilitator; + /** Decimal USDC charged once per run */ + readonly amount: string; + /** Values that must never appear in anything the gateway logs */ + readonly secrets: readonly string[]; +} + +export interface SmokeTarget { + readonly id: string; + readonly kind: SmokeKind; + readonly family: SmokeFamily; + readonly network: string; + readonly assetTransferMethod?: 'permit2'; + /** + * Runs only when named in `X402_SMOKE_TARGETS`: it reuses another target's + * buyer, which needs more preparation than that target's funds + */ + readonly onlyWhenNamed?: true; + /** A transaction's page on a public explorer */ + readonly explorer: (reference: string) => string; + readonly resolve: (env: Env) => { + readonly missing: readonly string[]; + readonly settings?: SmokeSettings; + }; +} + +const read = (env: Env, name: string): string | undefined => { + const value = env[name]?.trim(); + return value === undefined || value === '' ? undefined : value; +}; + +// A target's variables: required ones it names when absent, optional ones with defaults +function resolver(spec: { + readonly required: readonly string[]; + readonly settings: (value: (name: string) => string, env: Env) => SmokeSettings; +}): SmokeTarget['resolve'] { + return (env) => { + const missing = spec.required.filter((name) => read(env, name) === undefined); + if (missing.length > 0) return { missing }; + return { missing, settings: spec.settings((name) => read(env, name) as string, env) }; + }; +} + +const amountFor = (env: Env, kind: SmokeKind): string => + read(env, 'X402_SMOKE_AMOUNT') ?? + read(env, kind === 'testnet' ? 'X402_TESTNET_AMOUNT' : 'X402_MAINNET_AMOUNT') ?? + '0.01'; + +const remote = (url: string, auth: FacilitatorAuth = { type: 'none' }): SmokeFacilitator => ({ + type: 'remote', + url, + auth, +}); + +// EVM testnets share one buyer key and merchant address: an EVM key signs on any chain +function evmTestnet(options: { + readonly id: string; + readonly network: string; + readonly rpcVariable: string; + readonly defaultRpc: string; + readonly facilitatorVariable: string; + readonly defaultFacilitator: string; + readonly explorer: string; + readonly assetTransferMethod?: 'permit2'; + readonly onlyWhenNamed?: true; +}): SmokeTarget { + return { + id: options.id, + kind: 'testnet', + family: 'evm', + network: options.network, + ...(options.assetTransferMethod ? { assetTransferMethod: options.assetTransferMethod } : {}), + ...(options.onlyWhenNamed ? { onlyWhenNamed: true } : {}), + explorer: (tx) => `${options.explorer}/tx/${tx}`, + resolve: resolver({ + required: ['X402_TESTNET_BUYER_PRIVATE_KEY', 'X402_TESTNET_MERCHANT_ADDRESS'], + settings: (value, env) => ({ + rpcUrl: read(env, options.rpcVariable) ?? options.defaultRpc, + payTo: value('X402_TESTNET_MERCHANT_ADDRESS'), + buyerKey: value('X402_TESTNET_BUYER_PRIVATE_KEY'), + facilitator: remote(read(env, options.facilitatorVariable) ?? options.defaultFacilitator), + amount: amountFor(env, 'testnet'), + secrets: [value('X402_TESTNET_BUYER_PRIVATE_KEY')], + }), + }), + }; +} + +const SOLANA_EXPLORER = 'https://explorer.solana.com/tx'; + +// A mainnet facilitator credential: CDP, a bearer token, or none +function mainnetAuth(env: Env, tokenVariable: string): FacilitatorAuth { + const id = read(env, 'CDP_API_KEY_ID'); + const secret = read(env, 'CDP_API_KEY_SECRET'); + if (tokenVariable === 'X402_FACILITATOR_TOKEN' && id && secret) { + return { type: 'cdp', apiKeyId: id, apiKeySecret: secret }; + } + const token = read(env, tokenVariable); + return token ? { type: 'bearer', token } : { type: 'none' }; +} + +function authSecrets(auth: FacilitatorAuth): string[] { + if (auth.type === 'bearer') return [auth.token]; + if (auth.type === 'cdp') return [auth.apiKeySecret]; + return []; +} + +// Base mainnet targets share one buyer, merchant and facilitator +function baseMainnet(id: string, assetTransferMethod?: 'permit2'): SmokeTarget { + return { + id, + kind: 'mainnet', + family: 'evm', + network: 'eip155:8453', + ...(assetTransferMethod ? { assetTransferMethod } : {}), + explorer: (tx) => `https://basescan.org/tx/${tx}`, + resolve: resolver({ + required: [ + 'ALLOW_X402_MAINNET', + 'X402_MAINNET_BUYER_PRIVATE_KEY', + 'X402_MAINNET_MERCHANT_ADDRESS', + 'X402_FACILITATOR_URL', + ], + settings: (value, env) => { + const auth = mainnetAuth(env, 'X402_FACILITATOR_TOKEN'); + return { + // A dedicated endpoint avoids rate limits that make a settled payment read as failed + rpcUrl: read(env, 'X402_MAINNET_RPC_URL') ?? 'https://base.drpc.org', + payTo: value('X402_MAINNET_MERCHANT_ADDRESS'), + buyerKey: value('X402_MAINNET_BUYER_PRIVATE_KEY'), + facilitator: remote(value('X402_FACILITATOR_URL'), auth), + amount: amountFor(env, 'mainnet'), + secrets: [value('X402_MAINNET_BUYER_PRIVATE_KEY'), ...authSecrets(auth)], + }; + }, + }), + }; +} + +// Without a facilitator URL, the harness runs the SDK's facilitator with this +// relayer +function nearTarget(kind: SmokeKind): SmokeTarget { + const name = (field: string) => `X402_NEAR_${kind.toUpperCase()}_${field}`; + return { + id: kind === 'testnet' ? 'near-testnet' : 'near', + kind, + family: 'near', + network: `near:${kind}`, + explorer: (tx) => `https://${kind === 'testnet' ? 'testnet.' : ''}nearblocks.io/txns/${tx}`, + resolve: (env) => { + const url = read(env, name('FACILITATOR_URL')); + const auth: FacilitatorAuth = + kind === 'mainnet' ? mainnetAuth(env, name('FACILITATOR_TOKEN')) : { type: 'none' }; + return resolver({ + required: [ + ...(kind === 'mainnet' ? ['ALLOW_X402_MAINNET'] : []), + name('BUYER_ACCOUNT'), + name('BUYER_PRIVATE_KEY'), + name('MERCHANT_ACCOUNT'), + ...(url === undefined ? [name('RELAYER_ACCOUNT'), name('RELAYER_PRIVATE_KEY')] : []), + ], + settings: (value) => ({ + rpcUrl: read(env, name('RPC_URL')) ?? `https://rpc.${kind}.fastnear.com`, + payTo: value(name('MERCHANT_ACCOUNT')), + buyerKey: value(name('BUYER_PRIVATE_KEY')), + buyerAccount: value(name('BUYER_ACCOUNT')), + facilitator: + url !== undefined + ? remote(url, auth) + : { + type: 'self-hosted-near', + relayerAccount: value(name('RELAYER_ACCOUNT')), + relayerKey: value(name('RELAYER_PRIVATE_KEY')), + }, + amount: amountFor(env, kind), + secrets: [ + value(name('BUYER_PRIVATE_KEY')), + ...(url === undefined ? [value(name('RELAYER_PRIVATE_KEY'))] : authSecrets(auth)), + ], + }), + })(env); + }, + }; +} + +export const SMOKE_TARGETS: readonly SmokeTarget[] = [ + evmTestnet({ + id: 'base-sepolia', + network: 'eip155:84532', + rpcVariable: 'X402_TESTNET_RPC_URL', + defaultRpc: 'https://base-sepolia-rpc.publicnode.com', + facilitatorVariable: 'X402_TESTNET_FACILITATOR_URL', + defaultFacilitator: 'https://x402.org/facilitator', + explorer: 'https://sepolia.basescan.org', + }), + evmTestnet({ + id: 'base-sepolia-permit2', + network: 'eip155:84532', + rpcVariable: 'X402_TESTNET_RPC_URL', + defaultRpc: 'https://base-sepolia-rpc.publicnode.com', + facilitatorVariable: 'X402_TESTNET_FACILITATOR_URL', + defaultFacilitator: 'https://x402.org/facilitator', + explorer: 'https://sepolia.basescan.org', + assetTransferMethod: 'permit2', + // The buyer approves Permit2 for USDC once, which costs gas + onlyWhenNamed: true, + }), + evmTestnet({ + id: 'arbitrum-sepolia', + network: 'eip155:421614', + rpcVariable: 'X402_ARBITRUM_SEPOLIA_RPC_URL', + defaultRpc: 'https://arbitrum-sepolia-rpc.publicnode.com', + facilitatorVariable: 'X402_ARBITRUM_SEPOLIA_FACILITATOR_URL', + defaultFacilitator: 'https://facilitator.payai.network', + explorer: 'https://sepolia.arbiscan.io', + // The buyer needs Arbitrum Sepolia USDC as well + onlyWhenNamed: true, + }), + { + id: 'solana-devnet', + kind: 'testnet', + family: 'svm', + network: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + explorer: (tx) => `${SOLANA_EXPLORER}/${tx}?cluster=devnet`, + resolve: resolver({ + required: ['X402_SOLANA_DEVNET_BUYER_SECRET_KEY', 'X402_SOLANA_DEVNET_MERCHANT_ADDRESS'], + settings: (value, env) => ({ + rpcUrl: read(env, 'X402_SOLANA_DEVNET_RPC_URL') ?? 'https://api.devnet.solana.com', + payTo: value('X402_SOLANA_DEVNET_MERCHANT_ADDRESS'), + buyerKey: value('X402_SOLANA_DEVNET_BUYER_SECRET_KEY'), + facilitator: remote( + read(env, 'X402_SOLANA_DEVNET_FACILITATOR_URL') ?? 'https://x402.org/facilitator', + ), + amount: amountFor(env, 'testnet'), + secrets: [value('X402_SOLANA_DEVNET_BUYER_SECRET_KEY')], + }), + }), + }, + nearTarget('testnet'), + // Mainnet: real funds, so every target also needs ALLOW_X402_MAINNET=true and + // an explicit facilitator or relayer, never a default + baseMainnet('base'), + // The buyer approves Permit2 for USDC once, which costs gas + baseMainnet('base-permit2', 'permit2'), + { + id: 'solana', + kind: 'mainnet', + family: 'svm', + network: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + explorer: (tx) => `${SOLANA_EXPLORER}/${tx}`, + resolve: resolver({ + required: [ + 'ALLOW_X402_MAINNET', + 'X402_SOLANA_MAINNET_BUYER_SECRET_KEY', + 'X402_SOLANA_MAINNET_MERCHANT_ADDRESS', + 'X402_SOLANA_MAINNET_FACILITATOR_URL', + ], + settings: (value, env) => { + const auth = mainnetAuth(env, 'X402_SOLANA_MAINNET_FACILITATOR_TOKEN'); + return { + rpcUrl: read(env, 'X402_SOLANA_MAINNET_RPC_URL') ?? 'https://api.mainnet-beta.solana.com', + payTo: value('X402_SOLANA_MAINNET_MERCHANT_ADDRESS'), + buyerKey: value('X402_SOLANA_MAINNET_BUYER_SECRET_KEY'), + facilitator: remote(value('X402_SOLANA_MAINNET_FACILITATOR_URL'), auth), + amount: amountFor(env, 'mainnet'), + secrets: [value('X402_SOLANA_MAINNET_BUYER_SECRET_KEY'), ...authSecrets(auth)], + }; + }, + }), + }, + nearTarget('mainnet'), +]; + +// Caps in decimal USDC: one payment, and the whole run +const LIMITS: Readonly> = { + testnet: { payment: '1.00', total: '1.00' }, + mainnet: { payment: '0.10', total: '0.10' }, +}; +const USDC_DECIMALS = 6; + +export interface SmokePlan { + readonly run: readonly { readonly target: SmokeTarget; readonly settings: SmokeSettings }[]; + /** Targets left out for missing settings, when none was named */ + readonly skipped: readonly { readonly id: string; readonly missing: readonly string[] }[]; + /** Problems that fail the run before anything is spent */ + readonly errors: readonly string[]; +} + +/** + * Selects smoke targets. Explicit `X402_SMOKE_TARGETS` cannot pass by skipping: + * unknown names or missing settings fail. Without names, select configured + * testnet targets except `onlyWhenNamed`; select no mainnet targets. + */ +export function planSmoke( + kind: SmokeKind, + env: Env, + targets: readonly SmokeTarget[] = SMOKE_TARGETS, +): SmokePlan { + const ofKind = targets.filter((target) => target.kind === kind); + const requested = read(env, 'X402_SMOKE_TARGETS') + ?.split(',') + .map((id) => id.trim()) + .filter((id) => id !== ''); + const errors: string[] = []; + const run: SmokePlan['run'][number][] = []; + const skipped: SmokePlan['skipped'][number][] = []; + + if (requested === undefined) { + for (const target of ofKind) { + const { missing, settings } = target.resolve(env); + // Real funds, or a target that needs extra preparation, run only when named + const needsName = kind === 'mainnet' || target.onlyWhenNamed === true; + if (!needsName && settings !== undefined) run.push({ target, settings }); + else { + skipped.push({ + id: target.id, + missing: needsName ? ['X402_SMOKE_TARGETS', ...missing] : missing, + }); + } + } + } else { + for (const id of new Set(requested)) { + const target = ofKind.find((candidate) => candidate.id === id); + if (target === undefined) { + errors.push( + `unknown ${kind} smoke target "${id}"; choose from ${ofKind.map((t) => t.id).join(', ')}`, + ); + continue; + } + const { missing, settings } = target.resolve(env); + if (settings === undefined) { + errors.push(`smoke target "${id}" needs ${missing.join(', ')}`); + } else if (kind === 'mainnet' && read(env, 'ALLOW_X402_MAINNET') !== 'true') { + errors.push(`smoke target "${id}" needs ALLOW_X402_MAINNET=true`); + } else { + run.push({ target, settings }); + } + } + } + + // Bound the spend before anything is signed + const limit = LIMITS[kind]; + const cap = (name: string, value: string): bigint | undefined => { + try { + return parseCanonicalAmount(value, USDC_DECIMALS); + } catch { + errors.push(`${name} "${value}" is not a decimal USDC amount with at most 6 places`); + return undefined; + } + }; + const perPayment = cap('the payment cap', limit.payment) as bigint; + const total = cap('X402_SMOKE_MAX_TOTAL', read(env, 'X402_SMOKE_MAX_TOTAL') ?? limit.total); + let spend = 0n; + for (const { target, settings } of run) { + const amount = cap(`the amount for "${target.id}"`, settings.amount); + if (amount === undefined) continue; + if (amount > perPayment) { + errors.push( + `smoke target "${target.id}" would pay ${settings.amount} USDC, above the ${kind} cap of ${limit.payment}`, + ); + } + spend += amount; + } + if (total !== undefined && spend > total) { + errors.push( + `this run would spend more than X402_SMOKE_MAX_TOTAL allows (${read(env, 'X402_SMOKE_MAX_TOTAL') ?? limit.total} USDC)`, + ); + } + return { run, skipped, errors }; +} diff --git a/tests/fixtures/x402/solana-validator.ts b/tests/fixtures/x402/solana-validator.ts new file mode 100644 index 0000000..eb77791 --- /dev/null +++ b/tests/fixtures/x402/solana-validator.ts @@ -0,0 +1,208 @@ +/** + * A throwaway `solana-test-validator` with its accounts preloaded through + * `--account`: a mint at a fixed address, token accounts holding balances, and + * a funded fee payer. No setup transaction runs, so a test starts from known + * state. LOCAL DEVELOPMENT ONLY. + * + * The validator has a genesis hash of its own, so it is not the cluster any + * CAIP-2 id names. The x402 SDK accepts only the mainnet, devnet and testnet + * ids, so tests run it under the devnet id. + */ +import { type ChildProcess, spawn, spawnSync } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { address, getAddressEncoder, getProgramDerivedAddress } from '@solana/kit'; + +export const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; +const ASSOCIATED_TOKEN_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'; +const SYSTEM_PROGRAM = '11111111111111111111111111111111'; +const LAMPORTS_PER_SOL = 1_000_000_000n; +const READY_TIMEOUT_MS = 60_000; + +export interface PreloadedAccount { + readonly address: string; + readonly owner: string; + readonly lamports: bigint; + readonly data?: Uint8Array; +} + +export interface SolanaValidator { + readonly rpcUrl: string; + stop(): Promise; +} + +export function isSolanaValidatorAvailable(): boolean { + return spawnSync('solana-test-validator', ['--version']).status === 0; +} + +/** The associated token account of `owner` for an SPL Token `mint` */ +export async function associatedTokenAddress(owner: string, mint: string): Promise { + const encoder = getAddressEncoder(); + const [pda] = await getProgramDerivedAddress({ + programAddress: address(ASSOCIATED_TOKEN_PROGRAM), + seeds: [ + encoder.encode(address(owner)), + encoder.encode(address(TOKEN_PROGRAM)), + encoder.encode(address(mint)), + ], + }); + return pda; +} + +/** An initialized SPL Token mint with no mint or freeze authority */ +export function mintAccount(address_: string, decimals: number, supply: bigint): PreloadedAccount { + const data = new Uint8Array(82); + const view = new DataView(data.buffer); + view.setBigUint64(36, supply, true); + data[44] = decimals; + data[45] = 1; + return { address: address_, owner: TOKEN_PROGRAM, lamports: LAMPORTS_PER_SOL, data }; +} + +/** An initialized SPL Token account holding `amount` of `mint` */ +export async function tokenAccount( + owner: string, + mint: string, + amount: bigint, +): Promise { + const encoder = getAddressEncoder(); + const data = new Uint8Array(165); + data.set(encoder.encode(address(mint)), 0); + data.set(encoder.encode(address(owner)), 32); + new DataView(data.buffer).setBigUint64(64, amount, true); + data[108] = 1; + return { + address: await associatedTokenAddress(owner, mint), + owner: TOKEN_PROGRAM, + lamports: LAMPORTS_PER_SOL, + data, + }; +} + +/** A system account holding SOL, such as a fee payer */ +export function walletAccount(address_: string, sol: bigint): PreloadedAccount { + return { address: address_, owner: SYSTEM_PROGRAM, lamports: sol * LAMPORTS_PER_SOL }; +} + +/** + * Starts a validator on `rpcPort` (websocket `rpcPort + 1`) with `accounts` + * in place. Its other ports sit just above, so two validators need RPC ports + * at least 100 apart. + */ +export async function startSolanaValidator(options: { + readonly rpcPort: number; + readonly accounts: readonly PreloadedAccount[]; +}): Promise { + const dir = await mkdtemp(join(tmpdir(), 'x402-solana-')); + const args = [ + '--reset', + '--quiet', + '--ledger', + join(dir, 'ledger'), + '--bind-address', + '127.0.0.1', + '--rpc-port', + String(options.rpcPort), + '--faucet-port', + String(options.rpcPort + 2), + '--gossip-port', + String(options.rpcPort + 3), + '--dynamic-port-range', + `${options.rpcPort + 10}-${options.rpcPort + 60}`, + ]; + for (const [index, account] of options.accounts.entries()) { + const file = join(dir, `account-${index}.json`); + await writeFile( + file, + JSON.stringify({ + pubkey: account.address, + account: { + lamports: Number(account.lamports), + data: [Buffer.from(account.data ?? []).toString('base64'), 'base64'], + owner: account.owner, + executable: false, + rentEpoch: 0, + space: account.data?.length ?? 0, + }, + }), + ); + args.push('--account', account.address, file); + } + + const child = spawn('solana-test-validator', args, { stdio: ['ignore', 'ignore', 'pipe'] }); + let stderrTail = ''; + child.stderr?.on('data', (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString()).slice(-4000); + }); + const exitedEarly = new Promise((_resolve, reject) => { + child.once('exit', (code) => + reject(new Error(`solana-test-validator exited early (code ${code}):\n${stderrTail}`)), + ); + child.once('error', (err) => reject(err)); + }); + + const rpcUrl = `http://127.0.0.1:${options.rpcPort}`; + const stop = async () => { + await stopProcess(child); + await rm(dir, { recursive: true, force: true }); + }; + try { + await Promise.race([waitForSlot(rpcUrl), exitedEarly]); + } catch (err) { + await stop(); + throw err; + } + return { rpcUrl, stop }; +} + +/** One JSON-RPC call; throws on a JSON-RPC error */ +export async function solanaRpc( + rpcUrl: string, + method: string, + params: unknown[], +): Promise { + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }); + const body = (await response.json()) as { result?: unknown; error?: unknown }; + if (body.error !== undefined) throw new Error(`${method}: ${JSON.stringify(body.error)}`); + return body.result as T; +} + +/** Base units held by a token account, as of the latest confirmed block */ +export async function tokenBalance(rpcUrl: string, tokenAccount_: string): Promise { + const result = await solanaRpc<{ value: { amount: string } }>(rpcUrl, 'getTokenAccountBalance', [ + tokenAccount_, + { commitment: 'confirmed' }, + ]); + return BigInt(result.value.amount); +} + +// Ready once a block has been produced, so a blockhash exists to sign against +async function waitForSlot(rpcUrl: string): Promise { + const deadline = Date.now() + READY_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + if ((await solanaRpc(rpcUrl, 'getSlot', [{ commitment: 'confirmed' }])) > 0) return; + } catch { + // Not listening yet + } + await sleep(250); + } + throw new Error(`solana-test-validator at ${rpcUrl} did not produce a block in time`); +} + +async function stopProcess(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve) => { + child.once('exit', () => resolve()); + child.kill('SIGTERM'); + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + }, 5000).unref(); + }); +} diff --git a/tests/fixtures/x402/svm-transaction.ts b/tests/fixtures/x402/svm-transaction.ts new file mode 100644 index 0000000..7bbd599 --- /dev/null +++ b/tests/fixtures/x402/svm-transaction.ts @@ -0,0 +1,110 @@ +/** + * Builds a Solana `exact` payment transaction offline, in the layout the SDK + * client produces: compute unit limit and price, a `TransferChecked` to the + * payee's token account, and a memo. The buyer signs; the fee payer slot stays + * empty for the facilitator. Unit tests use it where no RPC answers; the E2E + * suites use the SDK client itself. + */ +import { + AccountRole, + address, + appendTransactionMessageInstructions, + blockhash, + createTransactionMessage, + getBase64EncodedWireTransaction, + partiallySignTransactionMessageWithSigners, + pipe, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + type TransactionSigner, +} from '@solana/kit'; +import { associatedTokenAddress, TOKEN_PROGRAM } from './solana-validator'; + +const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111'; +const MEMO_PROGRAM = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'; +// Any well-formed blockhash: nothing here reaches a cluster +const BLOCKHASH = 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG'; + +function u32(value: number): Uint8Array { + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setUint32(0, value, true); + return bytes; +} + +function u64(value: bigint): Uint8Array { + const bytes = new Uint8Array(8); + new DataView(bytes.buffer).setBigUint64(0, value, true); + return bytes; +} + +export interface SvmPaymentInput { + readonly buyer: TransactionSigner; + readonly feePayer: string; + readonly mint: string; + readonly payTo: string; + readonly amount: bigint; + readonly decimals: number; + /** Distinguishes otherwise equal transactions, as the SDK's random memo does */ + readonly memo?: string; +} + +/** The base64 wire transaction an `exact` Solana payload carries */ +export async function buildSvmPayment(input: SvmPaymentInput): Promise { + const source = await associatedTokenAddress(input.buyer.address, input.mint); + const destination = await associatedTokenAddress(input.payTo, input.mint); + const message = pipe( + createTransactionMessage({ version: 0 }), + (tx) => setTransactionMessageFeePayer(address(input.feePayer), tx), + (tx) => + appendTransactionMessageInstructions( + [ + // SetComputeUnitLimit (2) and SetComputeUnitPrice (3) + { + programAddress: address(COMPUTE_BUDGET_PROGRAM), + data: new Uint8Array([2, ...u32(20_000)]), + }, + { + programAddress: address(COMPUTE_BUDGET_PROGRAM), + data: new Uint8Array([3, ...u64(1n)]), + }, + // TransferChecked (12): amount, then decimals + { + programAddress: address(TOKEN_PROGRAM), + accounts: [ + { address: address(source), role: AccountRole.WRITABLE }, + { address: address(input.mint), role: AccountRole.READONLY }, + { address: address(destination), role: AccountRole.WRITABLE }, + { + address: input.buyer.address, + role: AccountRole.READONLY_SIGNER, + signer: input.buyer, + }, + ], + data: new Uint8Array([12, ...u64(input.amount), input.decimals]), + }, + { + programAddress: address(MEMO_PROGRAM), + data: new TextEncoder().encode(input.memo ?? 'x402'), + }, + ], + tx, + ), + (tx) => + setTransactionMessageLifetimeUsingBlockhash( + { blockhash: blockhash(BLOCKHASH), lastValidBlockHeight: 1_000n }, + tx, + ), + ); + return getBase64EncodedWireTransaction(await partiallySignTransactionMessageWithSigners(message)); +} + +/** + * The same transaction with different bytes in signature slot `index`: slot + * 0 is the fee payer's, which the facilitator overwrites when it settles + */ +export function withSignatureBytes(transaction: string, index: number, fill: number): string { + const bytes = Buffer.from(transaction, 'base64'); + // One compact-u16 byte counts the signatures while there are fewer than 128 + bytes.fill(fill, 1 + index * 64, 1 + (index + 1) * 64); + return bytes.toString('base64'); +} diff --git a/tests/integration/a2a-x402-extension.test.ts b/tests/integration/a2a-x402-extension.test.ts index 468bb21..6916c69 100644 --- a/tests/integration/a2a-x402-extension.test.ts +++ b/tests/integration/a2a-x402-extension.test.ts @@ -83,7 +83,7 @@ beforeEach(async () => { facilitator.settle.mockReset().mockResolvedValue({ success: true, transaction: TX, - network: 'eip155:84532', + network: 'eip155:31337', payer: buyer.address, }); gateway = await createGateway({ @@ -91,7 +91,7 @@ beforeEach(async () => { store: createFakeStore(), paymentProviders: [ createX402PaymentProvider({ - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: RPC_URL, asset: ASSET, assetName: AUTHORIZATION.name, @@ -101,6 +101,7 @@ beforeEach(async () => { facilitator: REMOTE, }), createMppPaymentProvider({ + network: 'eip155:31337', recipient: PAY_TO, asset: ASSET, assetName: AUTHORIZATION.name, @@ -194,7 +195,7 @@ function pay(task: A2aTask, metadata: Record): Record> { const payments = new x402Client(); - registerExactEvmScheme(payments, { signer: buyer, networks: ['eip155:84532'] }); + registerExactEvmScheme(payments, { signer: buyer, networks: ['eip155:31337'] }); // The test token is not in the SDK's spend-control asset list payments.setSpendControls(false); const required = task.status.message?.metadata?.['x402.payment.required'] as PaymentRequired; @@ -235,7 +236,7 @@ describe('x402 A2A extension', () => { 'x402.payment.status': 'payment-required', 'x402.payment.required': { x402Version: 2, - accepts: [expect.objectContaining({ scheme: 'exact', network: 'eip155:84532' })], + accepts: [expect.objectContaining({ scheme: 'exact', network: 'eip155:31337' })], }, }, }); @@ -263,7 +264,7 @@ describe('x402 A2A extension', () => { expect(paid?.status.message?.metadata).toMatchObject({ 'x402.payment.status': 'payment-completed', 'x402.payment.receipts': [ - expect.objectContaining({ success: true, transaction: TX, network: 'eip155:84532' }), + expect.objectContaining({ success: true, transaction: TX, network: 'eip155:31337' }), ], }); expect(paid?.artifacts[0]?.parts[0]?.data).toEqual({ report: 'paid' }); @@ -305,7 +306,7 @@ describe('x402 A2A extension', () => { 'x402.payment.status': 'payment-failed', 'x402.payment.error': 'insufficient_funds', 'x402.payment.receipts': [ - expect.objectContaining({ success: false, network: 'eip155:84532' }), + expect.objectContaining({ success: false, network: 'eip155:31337' }), ], }); expect(backendCalls).toBe(0); @@ -325,6 +326,29 @@ describe('x402 A2A extension', () => { expect(again.body.error?.code).toBe(-32001); }); + // A full store must preserve purchases whose buyers may have signed. + // New purchases are rejected until a slot is free. + it('refuses a new purchase while every pending slot is taken, and keeps the waiting ones', async () => { + const first = await challenged(); + for (let i = 1; i < 256; i++) await challenged(); + + const { body } = await rpc(buy('x402_report')); + const busy = body.result?.task; + expect(busy?.status.state).toBe('TASK_STATE_FAILED'); + expect(busy?.artifacts?.[0]?.parts[0]?.data).toMatchObject({ + code: 'GATEWAY_BUSY', + retryable: true, + }); + + const paid = await rpc( + pay(first, { + 'x402.payment.status': 'payment-submitted', + 'x402.payment.payload': await paymentPayload(first), + }), + ); + expect(paid.body.result?.task.status.state).toBe('TASK_STATE_COMPLETED'); + }); + it('keeps the task waiting after a malformed payment message', async () => { const task = await challenged(); diff --git a/tests/integration/adversarial-payment.test.ts b/tests/integration/adversarial-payment.test.ts index 81ca684..c7c941a 100644 --- a/tests/integration/adversarial-payment.test.ts +++ b/tests/integration/adversarial-payment.test.ts @@ -62,7 +62,7 @@ function rawConfig(storePath: string): Record { payments: { x402: { enabled: true, - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: 'http://127.0.0.1:8545', asset: '0x5FbDB2315678afecb367f032d93F642f64180aa3', assetName: 'MockUSDC', @@ -224,7 +224,7 @@ describe('adversarial: duplicate concurrent request', () => { it('rejects a malformed PAYMENT-SIGNATURE over HTTP before calling the backend', async () => { let backendCalls = 0; const provider = createX402PaymentProvider({ - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: 'http://127.0.0.1:8545', asset: '0x5FbDB2315678afecb367f032d93F642f64180aa3', assetName: 'MockUSDC', @@ -358,7 +358,7 @@ describe('adversarial: a facilitator that does not answer properly', () => { respond = handler; let backendCalls = 0; const provider = createX402PaymentProvider({ - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: 'http://127.0.0.1:8545', asset: '0x5FbDB2315678afecb367f032d93F642f64180aa3', assetName: 'MockUSDC', diff --git a/tests/integration/ap2-x402-conformance.test.ts b/tests/integration/ap2-x402-conformance.test.ts index 053f884..ca75d7e 100644 --- a/tests/integration/ap2-x402-conformance.test.ts +++ b/tests/integration/ap2-x402-conformance.test.ts @@ -45,8 +45,12 @@ const RESOURCE_ID = 'market_report'; const INPUT = { city: 'Berlin' }; const MERCHANT = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; const ASSET = '0x1111111111111111111111111111111111111111'; -const NETWORK = 'eip155:84532'; +const NETWORK = 'eip155:31337'; const PROOF = 'x402-proof-1'; +// A second offer in the same challenge, which this proof pays +const SECOND_PROOF = 'x402-proof-second-offer'; +const SECOND_NETWORK = 'eip155:8453'; +const SECOND_ASSET = '0x2222222222222222222222222222222222222222'; let parties: Party; let config: GatewayConfig; @@ -79,8 +83,8 @@ interface RailOptions { readonly settle?: () => Promise; } -// Counts what it was asked to do. Its requirement carries the chain -// coordinates a real x402 challenge does, which the mandate must agree with. +// Counts calls and selects the proof's offer from two networks, as x402 does. +// The mandate must match the selected requirement's settlement coordinates. function countingRail(options: RailOptions = {}): PaymentProvider { const settled: PaymentResult = { status: 'settled', @@ -106,9 +110,21 @@ function countingRail(options: RailOptions = {}): PaymentProvider { destination: MERCHANT, network: NETWORK, asset: ASSET, - challenge: { provider: 'x402', version: '2', accepts: [{ scheme: 'exact' }] }, + challenge: { + provider: 'x402', + version: '2', + accepts: [ + { scheme: 'exact', network: NETWORK }, + { scheme: 'exact', network: SECOND_NETWORK }, + ], + }, }; }, + async selectRequirement(ctx): Promise { + return ctx.submission.payload === SECOND_PROOF + ? { ...ctx.requirement, network: SECOND_NETWORK, asset: SECOND_ASSET } + : ctx.requirement; + }, async verify() { counts.verify += 1; if (options.verify) return options.verify(); @@ -505,6 +521,22 @@ describe('AP2 over x402: mandates that must not settle', () => { ); }); + it('binds a mandate to the offer it approved, not to every offer in the challenge', async () => { + const gw = await startGateway(); + const second = await mandate({ profile: { network: SECOND_NETWORK, asset: SECOND_ASSET } }); + + const otherOffer = await purchase(second, gw); + + expect(otherOffer.statusCode).toBe(403); + expect(otherOffer.body['details']).toEqual({ method: 'ap2', reason: 'purchase_mismatch' }); + expect(counts.settle).toBe(0); + + const approvedOffer = await invoke(gw, { proof: SECOND_PROOF, presentation: second }); + + expect(approvedOffer.statusCode).toBe(200); + expect(counts.settle).toBe(1); + }); + it('refuses a mandate silent about the chain the requirement names', async () => { // Fail closed both ways: a mandate that never mentioned a chain must not // unlock a settlement on one. `undefined` is dropped when the JWT is diff --git a/tests/integration/config-gateway-pipeline.test.ts b/tests/integration/config-gateway-pipeline.test.ts index ade7f45..270d8a3 100644 --- a/tests/integration/config-gateway-pipeline.test.ts +++ b/tests/integration/config-gateway-pipeline.test.ts @@ -225,7 +225,7 @@ payments: const ENV = { GATEWAY_PUBLIC_BASE_URL: 'http://localhost:8080', MERCHANT_API_BASE_URL: 'http://merchant.internal:3000', - X402_NETWORK: 'eip155:84532', + X402_NETWORK: 'eip155:31337', X402_RPC_URL: 'http://127.0.0.1:8545', X402_ASSET: '0x1111111111111111111111111111111111111111', MERCHANT_WALLET: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', diff --git a/tests/integration/mcp-payment-bindings.test.ts b/tests/integration/mcp-payment-bindings.test.ts index 610d727..52c42d9 100644 --- a/tests/integration/mcp-payment-bindings.test.ts +++ b/tests/integration/mcp-payment-bindings.test.ts @@ -91,7 +91,7 @@ beforeEach(async () => { facilitator.settle.mockReset().mockResolvedValue({ success: true, transaction: TX, - network: 'eip155:84532', + network: 'eip155:31337', payer: buyer.address, }); gateway = await createGateway({ @@ -99,7 +99,7 @@ beforeEach(async () => { store: createFakeStore(), paymentProviders: [ createX402PaymentProvider({ - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: RPC_URL, asset: ASSET, assetName: AUTHORIZATION.name, @@ -109,6 +109,7 @@ beforeEach(async () => { facilitator: REMOTE, }), createMppPaymentProvider({ + network: 'eip155:31337', recipient: PAY_TO, asset: ASSET, assetName: AUTHORIZATION.name, @@ -144,7 +145,7 @@ async function connect(): Promise { function x402Payments(): x402Client { const payments = new x402Client(); - registerExactEvmScheme(payments, { signer: buyer, networks: ['eip155:84532'] }); + registerExactEvmScheme(payments, { signer: buyer, networks: ['eip155:31337'] }); // Disable SDK spend controls because the test token is not in its asset list payments.setSpendControls(false); return payments; @@ -167,7 +168,7 @@ describe('x402 MCP transport', () => { expect(result.paymentResponse).toMatchObject({ success: true, transaction: TX, - network: 'eip155:84532', + network: 'eip155:31337', amount: '10000', }); expect(facilitator.settle).toHaveBeenCalledTimes(1); @@ -351,7 +352,7 @@ describe('MPP MCP transport', () => { method: 'evm', status: 'success', reference: TX, - chainId: 84532, + chainId: 31337, }); expect(facilitator.settle).toHaveBeenCalledTimes(1); }); diff --git a/tests/integration/mpp-carriers.test.ts b/tests/integration/mpp-carriers.test.ts index eac04b6..b6e6778 100644 --- a/tests/integration/mpp-carriers.test.ts +++ b/tests/integration/mpp-carriers.test.ts @@ -25,6 +25,8 @@ import { createMcpAdapter } from '../../src/protocols/mcp'; import { createSqliteReceiptStore } from '../../src/storage/receipts'; import { createFakeStore } from '../unit/gateway/helpers'; +const SETTLED_TX = `0x${'ab'.repeat(32)}`; + process.env['NODE_ENV'] = 'test'; const facilitator = vi.hoisted(() => ({ verify: vi.fn(), settle: vi.fn() })); @@ -54,7 +56,7 @@ beforeEach(() => { facilitator.verify.mockReset().mockResolvedValue({ isValid: true }); facilitator.settle .mockReset() - .mockResolvedValue({ success: true, transaction: '0xabc', network: 'eip155:84532' }); + .mockResolvedValue({ success: true, transaction: SETTLED_TX, network: 'eip155:31337' }); }); afterEach(async () => { @@ -106,6 +108,7 @@ async function startGateway( store, paymentProviders: [ createMppPaymentProvider({ + network: 'eip155:31337', recipient, asset: ASSET, assetName: AUTHORIZATION.name, @@ -273,10 +276,10 @@ describe('MPP over HTTP', () => { // Check the EVM receipt's challenge and chain identifiers expect(receipt).toMatchObject({ method: 'evm', - reference: '0xabc', + reference: SETTLED_TX, status: 'success', challengeId: Challenge.deserialize(String(challenged.headers['www-authenticate'])).id, - chainId: 84532, + chainId: 31337, }); expect(paid.headers['cache-control']).toBe('private'); }); @@ -304,8 +307,8 @@ describe('MPP over HTTP', () => { expect(receipt?.payment).toMatchObject({ provider: 'mpp', status: 'settled', - externalReference: '0xabc', - network: 'eip155:84532', + externalReference: SETTLED_TX, + network: 'eip155:31337', }); expect(facilitator.settle).toHaveBeenCalledTimes(1); await store.close(); @@ -371,13 +374,13 @@ describe('MPP over HTTP', () => { return { status: 200, body: REPORT, headers: {}, durationMs: 1 }; }, }; - const gw = await startGateway(createFakeStore(), counting); + const gw = await startGateway(createFakeStore(), counting, { paymentFlow: 'authorization' }); const first = (await invokeHttp(gw)).headers['www-authenticate']; facilitator.settle.mockResolvedValueOnce({ success: false, errorReason: 'insufficient_funds', transaction: '', - network: 'eip155:84532', + network: 'eip155:31337', }); const refused = await invokeHttp(gw, { authorization: await credentialFor(first) }); @@ -402,7 +405,7 @@ describe('MPP over HTTP', () => { it('does not settle or reuse a credential after backend failure', async () => { const store = createSqliteReceiptStore({ path: ':memory:' }); await store.init(); - const gw = await startGateway(store, failingBackend); + const gw = await startGateway(store, failingBackend, { paymentFlow: 'authorization' }); const credential = await credentialFor((await invokeHttp(gw)).headers['www-authenticate']); const failed = await invokeHttp(gw, { authorization: credential }); @@ -443,7 +446,7 @@ describe('MPP over HTTP', () => { expect(res.headers['payment-receipt']).toBeUndefined(); expect(res.headers['payment-response']).toBeDefined(); const receipt = Receipt.deserialize(String(res.json().details.payment.receipt)); - expect(receipt).toMatchObject({ method: 'evm', reference: '0xabc', status: 'success' }); + expect(receipt).toMatchObject({ method: 'evm', reference: SETTLED_TX, status: 'success' }); }); it('binds the digest of the request body bytes into the challenge', async () => { diff --git a/tests/integration/mpp-config.test.ts b/tests/integration/mpp-config.test.ts index 869e3a0..8aefa43 100644 --- a/tests/integration/mpp-config.test.ts +++ b/tests/integration/mpp-config.test.ts @@ -84,6 +84,7 @@ describe('payments.mpp wiring', () => { method: 'GET', url: '/.well-known/agent-commerce', }); + // No network configured, so MPP takes its default, Base Sepolia expect(wellKnown.json().payments.mpp).toEqual({ enabled: true, network: 'eip155:84532', @@ -94,6 +95,7 @@ describe('payments.mpp wiring', () => { recipient: MPP.recipient, facilitator: { mode: 'remote' }, mode: 'testnet', + paymentFlow: 'upfront', }); for (const secret of [SECRET, RPC_KEY, FACILITATOR_TENANT]) { expect(wellKnown.body).not.toContain(secret); diff --git a/tests/integration/openapi-import.test.ts b/tests/integration/openapi-import.test.ts index 6e5ce8c..b9cc734 100644 --- a/tests/integration/openapi-import.test.ts +++ b/tests/integration/openapi-import.test.ts @@ -168,7 +168,7 @@ function createFakeX402Provider(): PaymentProvider & { verified: number; settled // Mirrors the x402 block the other integration suites use; the provider itself is a fake const X402_CONFIG = { enabled: true, - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: 'http://127.0.0.1:8545', asset: '0x1111111111111111111111111111111111111111', assetName: 'MockUSDC', diff --git a/tests/integration/x402-payment-flow.test.ts b/tests/integration/x402-payment-flow.test.ts index 88850bd..6f45ba2 100644 --- a/tests/integration/x402-payment-flow.test.ts +++ b/tests/integration/x402-payment-flow.test.ts @@ -52,7 +52,7 @@ beforeEach(async () => { facilitator.verify.mockReset().mockResolvedValue({ isValid: true, payer: buyer.address }); facilitator.settle.mockReset().mockImplementation(async () => { order.push('settle'); - return { success: true, transaction: TX, network: 'eip155:84532', payer: buyer.address }; + return { success: true, transaction: TX, network: 'eip155:31337', payer: buyer.address }; }); const config = parseConfig( { @@ -65,7 +65,7 @@ beforeEach(async () => { payments: { x402: { enabled: true, - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: 'http://127.0.0.1:19321', asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', assetName: 'USDC', @@ -121,7 +121,7 @@ async function paid(resourceId: string) { challenged.json(), ) as PaymentRequired; const payments = new x402Client(); - registerExactEvmScheme(payments, { signer: buyer, networks: ['eip155:84532'] }); + registerExactEvmScheme(payments, { signer: buyer, networks: ['eip155:31337'] }); payments.setSpendControls(false); // the test token is not in the SDK's list const payload = await payments.createPaymentPayload(required); return { @@ -174,7 +174,7 @@ describe('x402 payment flows', () => { success: false, errorReason: 'insufficient_funds', transaction: '', - network: 'eip155:84532', + network: 'eip155:31337', }); const { response } = await paid('default_flow'); diff --git a/tests/mainnet/base.smoke.test.ts b/tests/mainnet/base.smoke.test.ts deleted file mode 100644 index bef667c..0000000 --- a/tests/mainnet/base.smoke.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -/** - * Base mainnet smoke test. **This spends real money.** - * - * Only `npm run test:mainnet` runs it, never CI. It needs - * `ALLOW_X402_MAINNET=true`, a funded buyer key, a merchant address and a - * facilitator URL; a facilitator credential is optional. - * - * What it proves, in order: - * - * the mainnet guard refuses a config without `allowMainnet` - * -> the deployment reports itself as live mainnet - * -> a real payment settles on Base - * -> the receipt records the settlement reference - * -> the resource is delivered exactly once - * -> the same authorization presented again is refused - * -> no credential appears in anything logged - * - * The proof is on-chain balances and a transaction receipt read back from the - * network, never the gateway's own report of success. - */ - -import { privateKeyToAccount } from 'viem/accounts'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { GatewayConfig } from '../../src/config'; -import { parseConfig } from '../../src/config'; -import { - isCommerceError, - type Logger, - PAYMENT_RESPONSE_HEADER, - type ReceiptStore, -} from '../../src/core'; -import { createGateway, type GatewayInstance } from '../../src/gateway'; -import { createPaymentProof, createX402PaymentProvider } from '../../src/payments/x402'; -import { createSqliteReceiptStore } from '../../src/storage/receipts'; -import { - assertBalanceDelta, - assertTransactionSucceeded, - type BalanceSnapshot, - readBalances, - waitForBalances, -} from '../fixtures/x402/settlement'; - -const ALLOWED = process.env['ALLOW_X402_MAINNET'] === 'true'; -const BUYER_KEY = process.env['X402_MAINNET_BUYER_PRIVATE_KEY']; -const MERCHANT = process.env['X402_MAINNET_MERCHANT_ADDRESS']; -// The default public endpoint may rate-limit the polling loop and make a -// settled payment read as a failure, so point X402_MAINNET_RPC_URL at a -// dedicated endpoint -const RPC_URL = process.env['X402_MAINNET_RPC_URL'] ?? 'https://base.drpc.org'; -const FACILITATOR_URL = process.env['X402_FACILITATOR_URL']; -const CDP_API_KEY_ID = process.env['CDP_API_KEY_ID']; -const CDP_API_KEY_SECRET = process.env['CDP_API_KEY_SECRET']; -const BEARER = process.env['X402_FACILITATOR_TOKEN']; -// Tiny on purpose: every run moves this much real USDC -const AMOUNT = process.env['X402_MAINNET_AMOUNT'] ?? '0.01'; - -// USDC on Base, the only asset a mainnet config may name -const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as const; -const NETWORK = 'eip155:8453'; -const RESOURCE_ID = 'mainnet_report'; - -const missing = [ - ALLOWED ? undefined : 'ALLOW_X402_MAINNET=true', - BUYER_KEY ? undefined : 'X402_MAINNET_BUYER_PRIVATE_KEY', - MERCHANT ? undefined : 'X402_MAINNET_MERCHANT_ADDRESS', - FACILITATOR_URL ? undefined : 'X402_FACILITATOR_URL', - // No credential: a facilitator may take none, which - // `allowUnauthenticatedFacilitator` acknowledges -].filter((name): name is string => name !== undefined); - -if (missing.length > 0) { - console.log( - `[mainnet] skipped - needs ${missing.join(', ')}. This suite spends REAL FUNDS; see examples/base-mainnet/README.md.`, - ); -} - -function authBlock(): Record { - if (CDP_API_KEY_ID && CDP_API_KEY_SECRET) { - return { type: 'cdp', apiKeyId: CDP_API_KEY_ID, apiKeySecret: CDP_API_KEY_SECRET }; - } - if (BEARER) return { type: 'bearer', token: BEARER }; - return { type: 'none' }; -} - -function rawConfig(overrides: { allowMainnet?: boolean } = {}): Record { - return { - version: 1, - merchant: { - id: 'mainnet-smoke', - name: 'Base Mainnet Smoke', - publicBaseUrl: 'http://127.0.0.1:8080', - }, - server: { port: 8080, host: '127.0.0.1', allowedOrigins: [] }, - storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, - protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } }, - resources: { - [RESOURCE_ID]: { - name: 'Mainnet report', - description: 'A paid resource settled on Base', - backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, - pricing: { type: 'fixed', amount: AMOUNT, currency: 'USD' }, - expose: ['http'], - payments: ['x402'], - }, - }, - payments: { - x402: { - enabled: true, - network: NETWORK, - rpcUrl: RPC_URL, - asset: USDC, - assetName: 'USD Coin', - assetVersion: '2', - assetDecimals: 6, - payTo: MERCHANT, - maxTimeoutSeconds: 600, - ...(overrides.allowMainnet === false ? {} : { allowMainnet: true }), - // Matters only when no credential is configured - allowUnauthenticatedFacilitator: true, - facilitator: { mode: 'remote', url: FACILITATOR_URL, auth: authBlock() }, - }, - }, - }; -} - -const describeOrSkip = missing.length === 0 ? describe : describe.skip; - -describeOrSkip('Base mainnet - real funds', () => { - let gateway: GatewayInstance; - let store: ReceiptStore; - let buyer: `0x${string}`; - // Everything the gateway logged, for the credential-leak assertion - const logged: string[] = []; - - const balanceQuery = () => ({ - rpcUrl: RPC_URL, - asset: USDC, - buyer, - merchant: MERCHANT as `0x${string}`, - }); - - function balances(): Promise { - return readBalances(balanceQuery()); - } - - beforeAll(async () => { - const config: GatewayConfig = parseConfig(rawConfig(), process.env); - buyer = privateKeyToAccount(BUYER_KEY as `0x${string}`).address; - - const x402 = config.payments.x402; - if (!x402) throw new Error('mainnet config produced no x402 provider'); - - store = createSqliteReceiptStore({ path: ':memory:' }); - await store.init(); - - // Captures everything the gateway and provider log, so the last assertion - // can prove no credential reached it - const capture = (line: unknown, ...rest: unknown[]): void => { - logged.push(JSON.stringify([line, ...rest])); - }; - const makeLogger = (): Logger => ({ - debug: capture, - info: capture, - warn: capture, - error: capture, - child: () => makeLogger(), - }); - const logger = makeLogger(); - - gateway = await createGateway({ - config, - store, - logger, - paymentProviders: [ - createX402PaymentProvider({ - network: x402.network, - rpcUrl: x402.rpcUrl, - asset: x402.asset as `0x${string}`, - assetName: x402.assetName, - assetVersion: x402.assetVersion, - assetDecimals: x402.assetDecimals, - payTo: x402.payTo as `0x${string}`, - maxTimeoutSeconds: x402.maxTimeoutSeconds, - facilitator: x402.facilitator, - ...(x402.allowMainnet !== undefined ? { allowMainnet: x402.allowMainnet } : {}), - ...(x402.allowUnauthenticatedFacilitator !== undefined - ? { allowUnauthenticatedFacilitator: x402.allowUnauthenticatedFacilitator } - : {}), - logger, - }), - ], - protocolAdapters: [], - // Stubbed: this suite asks whether real money moved, and the local E2E - // suites cover the HTTP backend path - backend: { - call: async () => ({ - status: 200, - headers: {}, - body: { report: 'base-mainnet' }, - durationMs: 1, - }), - }, - }); - }, 120_000); - - afterAll(async () => { - await gateway?.close(); - }); - - it('refuses a mainnet config that has not opted in', () => { - // If this guard stops firing, a mainnet deployment could be created by - // accident - try { - parseConfig(rawConfig({ allowMainnet: false }), process.env); - expect.unreachable('a mainnet config without allowMainnet must be refused'); - } catch (err) { - expect(isCommerceError(err) && err.code).toBe('CONFIG_INVALID'); - expect(String((err as Error).message)).toContain('allowMainnet'); - } - }); - - it('reports itself as a live mainnet deployment', async () => { - const res = await gateway.server.inject({ method: 'GET', url: '/.well-known/agent-commerce' }); - expect(res.statusCode).toBe(200); - const x402 = res.json().payments.x402; - expect(x402.mode).toBe('mainnet'); - expect(x402.network).toBe(NETWORK); - expect(x402.asset.toLowerCase()).toBe(USDC.toLowerCase()); - expect(x402.facilitator).toEqual({ mode: 'remote' }); - }, 60_000); - - it('settles real USDC on Base, delivers once, and refuses the same authorization twice', async () => { - const before = await balances(); - expect( - before.buyer, - `buyer ${buyer} holds no USDC on Base - this suite cannot run without real funds`, - ).toBeGreaterThan(0n); - - const challenge = await gateway.server.inject({ - method: 'POST', - url: `/api/resources/${RESOURCE_ID}/invoke`, - payload: {}, - }); - expect(challenge.statusCode).toBe(402); - const accepts = challenge.json().payment.accepts[0] as Record; - expect(accepts['network']).toBe(NETWORK); - expect(accepts['payTo']).toBe(MERCHANT); - const amountBaseUnits = BigInt(accepts['amount'] as string); - - const proof = await createPaymentProof({ - buyerPrivateKey: BUYER_KEY as `0x${string}`, - accepts, - }); - - const paid = await gateway.server.inject({ - method: 'POST', - url: `/api/resources/${RESOURCE_ID}/invoke`, - payload: {}, - headers: { 'payment-signature': proof }, - }); - expect(paid.statusCode, `gateway refused the payment: ${JSON.stringify(paid.json())}`).toBe( - 200, - ); - expect(paid.json()).toMatchObject({ report: 'base-mainnet' }); - - const settlement = JSON.parse( - Buffer.from(String(paid.headers[PAYMENT_RESPONSE_HEADER]), 'base64').toString('utf8'), - ) as { success: boolean; transaction: string; network: string }; - expect(settlement.success).toBe(true); - expect(settlement.network).toBe(NETWORK); - - // The chain, not the HTTP status - const after = await waitForBalances( - balanceQuery(), - (snapshot) => snapshot.buyer < before.buyer, - 180_000, - ); - assertBalanceDelta(before, after, amountBaseUnits); - await assertTransactionSucceeded(RPC_URL, settlement.transaction); - - // The receipt records where to find the settlement - const receipts = await store.listReceipts(); - const settled = receipts.find((r) => r.payment?.externalReference === settlement.transaction); - expect(settled, 'no receipt carries the settlement transaction').toBeDefined(); - expect(settled?.payment?.status).toBe('settled'); - // A 2xx backend status is what marks the receipt delivered - expect(settled?.backendStatus).toBeGreaterThanOrEqual(200); - expect(settled?.backendStatus).toBeLessThan(300); - - // Delivered exactly once: the same authorization presented again is - // refused, and no second transfer follows it - const replay = await gateway.server.inject({ - method: 'POST', - url: `/api/resources/${RESOURCE_ID}/invoke`, - payload: {}, - headers: { 'payment-signature': proof }, - }); - expect(replay.statusCode).toBe(402); - const afterReplay = await balances(); - expect(afterReplay.buyer).toBe(after.buyer); - expect(afterReplay.merchant).toBe(after.merchant); - expect( - (await store.listReceipts()).filter( - (r) => r.payment?.externalReference === settlement.transaction, - ), - ).toHaveLength(1); - - console.log( - `[mainnet] settled ${accepts['amount']} base units to ${MERCHANT} - ` + - `https://basescan.org/tx/${settlement.transaction}`, - ); - }, 600_000); - - it('never logged a credential or a key', () => { - const haystack = logged.join('\n'); - for (const secret of [BUYER_KEY, CDP_API_KEY_SECRET, BEARER].filter( - (value): value is string => typeof value === 'string' && value.length > 0, - )) { - expect(haystack).not.toContain(secret); - } - }); -}); diff --git a/tests/mainnet/x402.smoke.test.ts b/tests/mainnet/x402.smoke.test.ts new file mode 100644 index 0000000..7f6d816 --- /dev/null +++ b/tests/mainnet/x402.smoke.test.ts @@ -0,0 +1,13 @@ +/** + * x402 settlement on mainnets. **This spends real money.** + * + * Only `npm run test:mainnet` runs it, never CI. Nothing runs unless + * `X402_SMOKE_TARGETS` names allowlisted targets (`base`, `base-permit2`, `solana`) and + * `ALLOW_X402_MAINNET=true` is set; each target also needs a funded buyer, a + * merchant address and an explicit facilitator. The run stops before signing + * anything when it would exceed its spend cap. The harness lives in + * tests/fixtures/x402/smoke. + */ +import { defineSmokeSuite } from '../fixtures/x402/smoke/suite'; + +defineSmokeSuite('mainnet'); diff --git a/tests/testnet/base-sepolia.smoke.test.ts b/tests/testnet/base-sepolia.smoke.test.ts deleted file mode 100644 index 02b2b05..0000000 --- a/tests/testnet/base-sepolia.smoke.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -/** - * Base Sepolia smoke test. It spends real testnet USDC through a public RPC - * and a hosted facilitator, so only `npm run test:testnet` runs it, never CI - * (see vitest.testnet.config.ts). Without credentials it skips and names the - * missing variable. - * - * What it proves, in order: - * - * agent request -> 402 v2 challenge -> buyer signature -> remote - * facilitator -> Base Sepolia settlement -> merchant balance rises -> - * resource delivered -> receipt carries the settlement reference - * - * The proof is on-chain balances and a transaction receipt read back from the - * network, never the gateway's own report of success. - * - * The buyer key comes from the environment and is never written to disk, - * logged or put in an assertion message. - */ - -import { privateKeyToAccount } from 'viem/accounts'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { GatewayConfig } from '../../src/config'; -import { parseConfig } from '../../src/config'; -import type { ReceiptStore } from '../../src/core'; -import { PAYMENT_REQUIRED_HEADER, PAYMENT_RESPONSE_HEADER } from '../../src/core'; -import { createGateway, type GatewayInstance } from '../../src/gateway'; -import { createPaymentProof, createX402PaymentProvider } from '../../src/payments/x402'; -import { createSqliteReceiptStore } from '../../src/storage/receipts'; -import { - assertBalanceDelta, - assertTransactionSucceeded, - type BalanceSnapshot, - waitForBalances as pollBalances, - readBalances, -} from '../fixtures/x402/settlement'; - -const BUYER_KEY = process.env['X402_TESTNET_BUYER_PRIVATE_KEY']; -const MERCHANT = process.env['X402_TESTNET_MERCHANT_ADDRESS']; -const RPC_URL = process.env['X402_TESTNET_RPC_URL'] ?? 'https://base-sepolia-rpc.publicnode.com'; -const FACILITATOR_URL = - process.env['X402_TESTNET_FACILITATOR_URL'] ?? 'https://x402.org/facilitator'; -// Small on purpose: every run spends this much real testnet USDC -const AMOUNT = process.env['X402_TESTNET_AMOUNT'] ?? '0.01'; - -// Circle's USDC on Base Sepolia. EIP-712 domain ("USDC", "2"), 6 decimals -const USDC = '0x036CbD53842c5426634e7929541eC2318f3dCF7e' as const; -const NETWORK = 'eip155:84532'; -const RESOURCE_ID = 'testnet_report'; - -const missing = [ - BUYER_KEY ? undefined : 'X402_TESTNET_BUYER_PRIVATE_KEY', - MERCHANT ? undefined : 'X402_TESTNET_MERCHANT_ADDRESS', -].filter((name): name is string => name !== undefined); - -// The YAML an operator would write, parsed by the real loader, which shows a -// public testnet needs configuration and nothing else -function testnetConfigYaml(): string { - return `version: 1 -merchant: - id: testnet-smoke - name: Base Sepolia Smoke Test - publicBaseUrl: http://127.0.0.1:8080 -server: - port: 8080 - host: 127.0.0.1 - allowedOrigins: [] -storage: - receipts: - driver: sqlite - path: ":memory:" -protocols: - http: - enabled: true - mcp: - enabled: false - mountPath: /mcp -resources: - ${RESOURCE_ID}: - name: Testnet report - description: A paid resource settled on Base Sepolia - backend: - type: http - method: GET - url: http://merchant.invalid/api/report - pricing: - type: fixed - amount: "${AMOUNT}" - currency: USD - expose: [http] - payments: [x402] -payments: - x402: - enabled: true - network: ${NETWORK} - rpcUrl: ${RPC_URL} - asset: "${USDC}" - assetName: USDC - assetVersion: "2" - assetDecimals: 6 - payTo: "${MERCHANT}" - maxTimeoutSeconds: 300 - facilitator: - mode: remote - url: ${FACILITATOR_URL} - auth: - type: none -`; -} - -// This suite's balances, polled because our RPC node can lag the facilitator's -function waitForBalances( - predicate: (snapshot: BalanceSnapshot) => boolean, -): Promise { - return pollBalances( - { rpcUrl: RPC_URL, asset: USDC, buyer, merchant: MERCHANT as `0x${string}` }, - predicate, - ); -} - -function balances(): Promise { - return readBalances({ - rpcUrl: RPC_URL, - asset: USDC, - buyer, - merchant: MERCHANT as `0x${string}`, - }); -} - -let buyer: `0x${string}`; - -const describeOrSkip = missing.length === 0 ? describe : describe.skip; - -if (missing.length > 0) { - console.log( - `[testnet] skipped - set ${missing.join(' and ')} to run the Base Sepolia smoke test. ` + - 'It spends real testnet USDC from a dedicated wallet.', - ); -} - -describeOrSkip('Base Sepolia - real settlement through a remote facilitator', () => { - let gateway: GatewayInstance; - let config: GatewayConfig; - let store: ReceiptStore; - - beforeAll(async () => { - config = parseConfig((await import('yaml')).parse(testnetConfigYaml()) as unknown, process.env); - buyer = privateKeyToAccount(BUYER_KEY as `0x${string}`).address; - - const x402 = config.payments.x402; - if (!x402) throw new Error('testnet config produced no x402 provider'); - - store = createSqliteReceiptStore({ path: ':memory:' }); - await store.init(); - - gateway = await createGateway({ - config, - store, - paymentProviders: [ - createX402PaymentProvider({ - network: x402.network, - rpcUrl: x402.rpcUrl, - asset: x402.asset as `0x${string}`, - assetName: x402.assetName, - assetVersion: x402.assetVersion, - assetDecimals: x402.assetDecimals, - payTo: x402.payTo as `0x${string}`, - maxTimeoutSeconds: x402.maxTimeoutSeconds, - facilitator: x402.facilitator, - }), - ], - protocolAdapters: [], - // Stubbed: this suite asks whether money moved on a public chain, and the - // local E2E suites cover the HTTP backend path - backend: { - call: async () => ({ - status: 200, - headers: {}, - body: { report: 'base-sepolia', at: new Date().toISOString() }, - durationMs: 1, - }), - }, - }); - }, 120_000); - - afterAll(async () => { - await gateway?.close(); - }); - - it('reports itself as a public testnet deployment, not as local', async () => { - const res = await gateway.server.inject({ - method: 'GET', - url: '/.well-known/agent-commerce', - }); - expect(res.statusCode).toBe(200); - const x402 = res.json().payments.x402; - expect(x402.mode).toBe('testnet'); - expect(x402.network).toBe(NETWORK); - expect(x402.facilitator).toEqual({ mode: 'remote' }); - // The facilitator endpoint is never published, like rpcUrl - expect(JSON.stringify(res.json())).not.toContain(new URL(FACILITATOR_URL).hostname); - }, 60_000); - - it('settles a real payment on Base Sepolia and delivers the resource', async () => { - const before = await balances(); - - // Without funds nothing below works, and "expected 402 to be 200" would not - // say why. Fail here, naming the address to top up. - expect( - before.buyer, - `buyer ${buyer} holds no Base Sepolia USDC - fund it at https://faucet.circle.com (no ETH needed)`, - ).toBeGreaterThan(0n); - - // 1. The agent asks, unpaid - const challenge = await gateway.server.inject({ - method: 'POST', - url: `/api/resources/${RESOURCE_ID}/invoke`, - payload: {}, - }); - expect(challenge.statusCode).toBe(402); - expect(challenge.headers[PAYMENT_REQUIRED_HEADER]).toBeDefined(); - - const accepts = challenge.json().payment.accepts[0] as Record; - expect(accepts['network']).toBe(NETWORK); - expect(accepts['payTo']).toBe(MERCHANT); - const amountBaseUnits = BigInt(accepts['amount'] as string); - - // 2. The buyer signs it offline. No gas, and the gateway holds no key - const proof = await createPaymentProof({ - buyerPrivateKey: BUYER_KEY as `0x${string}`, - accepts, - }); - - // 3. The gateway verifies and settles through the remote facilitator - const paid = await gateway.server.inject({ - method: 'POST', - url: `/api/resources/${RESOURCE_ID}/invoke`, - payload: {}, - headers: { 'payment-signature': proof }, - }); - expect(paid.statusCode, `gateway refused the payment: ${JSON.stringify(paid.json())}`).toBe( - 200, - ); - expect(paid.json()).toMatchObject({ report: 'base-sepolia' }); - - const settleHeader = paid.headers[PAYMENT_RESPONSE_HEADER]; - expect(settleHeader).toBeDefined(); - const settlement = JSON.parse(Buffer.from(String(settleHeader), 'base64').toString('utf8')) as { - success: boolean; - transaction: string; - network: string; - }; - expect(settlement.success).toBe(true); - expect(settlement.network).toBe(NETWORK); - - // 4. The chain is the proof, not the HTTP status - const after = await waitForBalances((snapshot) => snapshot.buyer < before.buyer); - assertBalanceDelta(before, after, amountBaseUnits); - await assertTransactionSucceeded(RPC_URL, settlement.transaction); - - // 5. The merchant's ledger records where to find it. Read from the store: - // this config sets no admin token, so the operator route answers 404. - const receipts = await store.listReceipts(); - const settled = receipts.find((r) => r.payment?.externalReference === settlement.transaction); - expect(settled, 'no receipt carries the settlement transaction').toBeDefined(); - expect(settled?.payment?.status).toBe('settled'); - expect(settled?.payment?.network).toBe(NETWORK); - // A 2xx backend status is what marks the receipt delivered - expect(settled?.backendStatus).toBeGreaterThanOrEqual(200); - expect(settled?.backendStatus).toBeLessThan(300); - - console.log( - `[testnet] settled ${accepts['amount']} base units to ${MERCHANT} - ` + - `https://sepolia.basescan.org/tx/${settlement.transaction}`, - ); - }, 300_000); - - it('still fails closed: a tampered authorization is refused and moves nothing', async () => { - const before = await balances(); - - const challenge = await gateway.server.inject({ - method: 'POST', - url: `/api/resources/${RESOURCE_ID}/invoke`, - payload: {}, - }); - const accepts = challenge.json().payment.accepts[0] as Record; - - // Signed for a different recipient than the challenge names - const proof = await createPaymentProof({ - buyerPrivateKey: BUYER_KEY as `0x${string}`, - accepts, - overrides: { payTo: '0x000000000000000000000000000000000000dEaD' }, - }); - - const refused = await gateway.server.inject({ - method: 'POST', - url: `/api/resources/${RESOURCE_ID}/invoke`, - payload: {}, - headers: { 'payment-signature': proof }, - }); - expect(refused.statusCode).toBe(402); - - // No polling: this asserts nothing happened, and a late transfer would - // break the next run's exact delta instead - const after = await balances(); - expect(after.buyer).toBe(before.buyer); - expect(after.merchant).toBe(before.merchant); - }, 120_000); -}); diff --git a/tests/testnet/x402.smoke.test.ts b/tests/testnet/x402.smoke.test.ts new file mode 100644 index 0000000..8a67d60 --- /dev/null +++ b/tests/testnet/x402.smoke.test.ts @@ -0,0 +1,15 @@ +/** + * x402 settlement on public testnets: Base Sepolia (EIP-3009 and Permit2), + * Arbitrum Sepolia, Solana devnet and NEAR testnet. It spends testnet funds + * through public RPCs and facilitators, so only `npm run test:testnet` runs + * it, never CI (see vitest.testnet.config.ts). + * + * `X402_SMOKE_TARGETS` names the targets to run, and then each must run: a + * missing setting fails. Without it, every target whose settings are present + * runs, except `base-sepolia-permit2` and `arbitrum-sepolia`, which reuse the + * Base Sepolia buyer and run only when named; the rest skip, naming what they + * need. The harness lives in tests/fixtures/x402/smoke. + */ +import { defineSmokeSuite } from '../fixtures/x402/smoke/suite'; + +defineSmokeSuite('testnet'); diff --git a/tests/unit/authorization-ap2/checkout-signer.test.ts b/tests/unit/authorization-ap2/checkout-signer.test.ts index d28be67..6e70b48 100644 --- a/tests/unit/authorization-ap2/checkout-signer.test.ts +++ b/tests/unit/authorization-ap2/checkout-signer.test.ts @@ -60,7 +60,7 @@ function signOptions(overrides: Record = {}) { currency: 'USDC', paymentMethod: 'x402', destination: '0xMERCHANT', - network: 'eip155:84532', + network: 'eip155:31337', asset: '0xASSET', now: NOW, ...overrides, @@ -76,7 +76,7 @@ function requirement(): PaymentRequirement { amount: '0.01', currency: 'USDC', destination: '0xMERCHANT', - network: 'eip155:84532', + network: 'eip155:31337', asset: '0xASSET', challenge: { provider: 'x402', version: '2', accepts: [] }, }; diff --git a/tests/unit/authorization-ap2/purchase-binding.test.ts b/tests/unit/authorization-ap2/purchase-binding.test.ts index 262fc4a..81dd4ad 100644 --- a/tests/unit/authorization-ap2/purchase-binding.test.ts +++ b/tests/unit/authorization-ap2/purchase-binding.test.ts @@ -48,7 +48,7 @@ function requirement(overrides: Partial = {}): PaymentRequir amount: '0.01', currency: 'USDC', destination: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', - network: 'eip155:84532', + network: 'eip155:31337', asset: '0x1111111111111111111111111111111111111111', challenge: { provider: 'x402', version: '2', accepts: [] }, ...overrides, @@ -84,7 +84,7 @@ function profileClaims(overrides: Record = {}): Record { }); it('compares network identifiers exactly', async () => { - await bindRejection(await mandateFor(profileClaims({ network: 'EIP155:84532' }))); + await bindRejection(await mandateFor(profileClaims({ network: 'EIP155:31337' }))); }); it.each(['profile', 'resource_id', 'input_hash', 'amount', 'currency', 'payment_method'])( diff --git a/tests/unit/cli/doctor.test.ts b/tests/unit/cli/doctor.test.ts index 99631da..3176763 100644 --- a/tests/unit/cli/doctor.test.ts +++ b/tests/unit/cli/doctor.test.ts @@ -33,7 +33,7 @@ function healthyFetch(overrides: Record Response | Promise { it('WARNs when the configured asset is still the init placeholder', async () => { // The live cross-check alone would pass the placeholder (see the - // placeholder branch of the Payments check in doctor.ts) + // placeholder branch of the Payments check in doctor/payments.ts) const report = await runDoctor( { gatewayUrl: GATEWAY }, { @@ -225,7 +225,7 @@ describe('runDoctor: degraded scenarios report the failure', () => { it('probes an illegal {param} at the URL the runtime builds, not the one the operator meant', async () => { // Doctor must leave `{report id}` literal, as the runtime does (see - // substitutePathParams in doctor.ts) + // substitutePathParams in doctor/index.ts) const probed: string[] = []; const report = await runDoctor( { gatewayUrl: GATEWAY }, @@ -448,6 +448,9 @@ describe('runDoctor: degraded scenarios report the failure', () => { // Both rails were described, so their settings reached the output path expect(printed).toContain('x402 v2 (scheme=exact) enabled'); expect(printed).toContain('MPP charge/evm/authorization enabled'); + // Neither rail sets a flow: x402 defaults to authorization, MPP to upfront + expect(printed).toMatch(/x402 v2 \(scheme=exact\) enabled - [^\n]*paymentFlow=authorization/); + expect(printed).toMatch(/MPP charge\/evm\/authorization enabled - [^\n]*paymentFlow=upfront/); for (const secret of [ 'ADMIN-TOKEN', 'RPC-KEY', @@ -516,16 +519,15 @@ describe('runDoctor: degraded scenarios report the failure', () => { }; const testnet = { network: 'eip155:84532', allowMainnet: false }; - // Chain id 84532 is both the local dev chain and public Base Sepolia, so the - // facilitator mode decides which one the report names + // The network decides which environment the report names it.each([ [ - 'a local facilitator on 84532', + 'the local chain', X402_CONFIG, - 'LOCAL dev chain (eip155:84532, chain id shared with Base Sepolia), destination=0xf39F…2266, facilitator=local (in-process)', + 'LOCAL dev chain (eip155:31337), destination=0xf39F…2266, facilitator=local (in-process)', ], [ - 'a remote facilitator on 84532', + 'a remote facilitator on Base Sepolia', { ...mainnetX402({ type: 'bearer', token: 'T' }), ...testnet }, 'TESTNET on Base Sepolia (eip155:84532), destination=0x1111…1111, facilitator=remote (auth=bearer)', ], @@ -639,7 +641,7 @@ describe('runDoctor: degraded scenarios report the failure', () => { ); const payments = report.checks.find((c) => c.name === 'Payments'); expect(payments?.status).toBe('FAIL'); - expect(payments?.detail).toContain('eip155:84532'); + expect(payments?.detail).toContain('eip155:31337'); expect(payments?.detail).toContain('"base"'); }); @@ -729,6 +731,201 @@ describe('runDoctor: degraded scenarios report the failure', () => { }); }); +describe('runDoctor: several x402 options', () => { + const OPTIONS = [ + { + network: 'eip155:84532', + rpcUrl: 'https://sepolia.example', + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + assetName: 'USDC', + assetVersion: '2', + assetDecimals: 6, + payTo: '0x1111111111111111111111111111111111111111', + }, + { + network: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + rpcUrl: 'https://devnet.example', + asset: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + assetDecimals: 6, + payTo: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + }, + { + network: 'near:testnet', + rpcUrl: 'https://near.example', + asset: '3e2210e1184b45b64c8a434c0a7e7b23cc04ea7eb7a6c3c32520d03d4afcb8af', + assetDecimals: 6, + payTo: 'merchant.testnet', + }, + ]; + const config = (x402: Record = {}) => + makeGatewayConfig({ + payments: { + x402: { + ...X402_CONFIG, + ...(OPTIONS[0] as (typeof OPTIONS)[0]), + facilitator: { + mode: 'remote', + url: 'https://facilitator.example', + auth: { type: 'none' }, + }, + paymentFlow: 'authorization', + options: OPTIONS, + ...x402, + }, + }, + } as Partial); + // The live document as the gateway publishes it: the first offer, then all of them + const live = ( + options: readonly Record[], + x402: Record = {}, + ) => ({ + merchant: { id: 'demo-merchant' }, + payments: { + x402: { + enabled: true, + ...(options[0] ?? {}), + paymentFlow: 'authorization', + options, + ...x402, + }, + }, + }); + const publicTerms = OPTIONS.map(({ rpcUrl: _rpcUrl, ...terms }) => terms); + const paymentsCheck = async (body: unknown, gatewayConfig = config()) => { + const report = await runDoctor( + { gatewayUrl: GATEWAY }, + { + fetchImpl: healthyFetch({ + [`${GATEWAY}/.well-known/agent-commerce`]: () => jsonResponse(body), + }), + loadConfig: async () => gatewayConfig, + createStore: () => makeFakeReceiptStore(), + }, + ); + return report.checks.find((c) => c.name === 'Payments'); + }; + + it('passes when every live option matches, naming each network', async () => { + const check = await paymentsCheck(live(publicTerms)); + expect(check?.status).toBe('PASS'); + expect(check?.detail).toContain('3 options'); + expect(check?.detail).toContain('Base Sepolia (eip155:84532)'); + expect(check?.detail).toContain('Solana Devnet (solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1)'); + expect(check?.detail).toContain('NEAR Testnet (near:testnet)'); + }); + + it('compares Solana addresses exactly, unlike EVM ones', async () => { + const changed = publicTerms.map((terms, index) => + index === 1 ? { ...terms, payTo: terms.payTo.toLowerCase() } : terms, + ); + const check = await paymentsCheck(live(changed)); + expect(check?.status).toBe('FAIL'); + expect(check?.detail).toMatch(/^option 1 payTo: gateway is using 9wzdx/); + + const evmCase = publicTerms.map((terms, index) => + index === 0 ? { ...terms, asset: terms.asset.toLowerCase() } : terms, + ); + expect((await paymentsCheck(live(evmCase)))?.status).toBe('PASS'); + }); + + it('fails when the gateway offers a different set of options', async () => { + const check = await paymentsCheck(live(publicTerms.slice(0, 2))); + expect(check?.status).toBe('FAIL'); + expect(check?.detail).toMatch( + /^options: gateway is using 2 offer\(s\) but local config resolves to 3/, + ); + }); + + it('fails on a different payment flow or per-resource override', async () => { + const flow = await paymentsCheck(live(publicTerms, { paymentFlow: 'upfront' })); + expect(flow?.detail).toMatch(/^paymentFlow: gateway is using upfront/); + + const override = await paymentsCheck( + live(publicTerms, { resourcePaymentFlows: { report: 'upfront' } }), + ); + expect(override?.status).toBe('FAIL'); + expect(override?.detail).toMatch( + /^resourcePaymentFlows: gateway is using report=upfront but local config resolves to none/, + ); + }); + + it('names every network in the mainnet safety check', async () => { + const report = await runDoctor( + { gatewayUrl: GATEWAY }, + { + fetchImpl: healthyFetch(), + loadConfig: async () => + config({ + network: 'eip155:8453', + allowMainnet: true, + options: [ + { ...OPTIONS[0], network: 'eip155:8453' }, + { ...OPTIONS[1], network: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' }, + ], + }), + createStore: () => makeFakeReceiptStore(), + }, + ); + const safety = report.checks.find((c) => c.name === 'Mainnet safety'); + expect(safety?.detail).toContain('Base (eip155:8453)'); + expect(safety?.detail).toContain('Solana (solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp)'); + }); + + // The report's own offers: the NEAR option alone + const OWN = { report: [OPTIONS[2]] }; + const ownTerms = { report: [publicTerms[2]] }; + + it("compares each resource's own offers, and names the resource in a mismatch", async () => { + const withOwn = config({ resourceOptions: OWN }); + const matching = await paymentsCheck(live(publicTerms, { resourceOptions: ownTerms }), withOwn); + expect(matching?.status).toBe('PASS'); + expect(matching?.detail).toContain('1 resource with its own offers'); + + const missing = await paymentsCheck(live(publicTerms), withOwn); + expect(missing?.status).toBe('FAIL'); + expect(missing?.detail).toMatch( + /^resources\.report\.x402\.accepts: gateway is using 0 offer\(s\) but local config resolves to 1/, + ); + + const other = await paymentsCheck( + live(publicTerms, { resourceOptions: { report: [publicTerms[1]] } }), + withOwn, + ); + expect(other?.detail).toMatch(/^resources\.report\.x402\.accepts\[0\] network/); + }); + + it('reports each published offer health, once per offer', async () => { + const offersCheck = async (statuses: readonly (string | undefined)[]) => { + const terms = publicTerms.map((t, index) => + statuses[index] !== undefined ? { ...t, status: statuses[index] } : t, + ); + const report = await runDoctor( + { gatewayUrl: GATEWAY }, + { + fetchImpl: healthyFetch({ + [`${GATEWAY}/.well-known/agent-commerce`]: () => + jsonResponse(live(terms, { resourceOptions: { report: [terms[2]] } })), + }), + loadConfig: async () => config({ resourceOptions: OWN }), + createStore: () => makeFakeReceiptStore(), + }, + ); + return report.checks.find((c) => c.name === 'Payments (offers)'); + }; + + expect(await offersCheck([undefined, undefined, undefined])).toBeUndefined(); + expect(await offersCheck(['pass', 'pass', 'pass'])).toMatchObject({ + status: 'PASS', + detail: '3 offer(s) pass their health checks', + }); + const failing = await offersCheck(['pass', 'fail', 'warn']); + expect(failing?.status).toBe('FAIL'); + expect(failing?.detail).toMatch( + /^failing: Solana Devnet \(solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1\) .+; degraded: NEAR Testnet \(near:testnet\) [^;]+\. The gateway logs/, + ); + }); +}); + describe('printDoctorReport', () => { it('emits machine-readable JSON with --json', async () => { const report = await runDoctor( diff --git a/tests/unit/cli/examples.test.ts b/tests/unit/cli/examples.test.ts index ac23998..f9f3824 100644 --- a/tests/unit/cli/examples.test.ts +++ b/tests/unit/cli/examples.test.ts @@ -24,10 +24,21 @@ const EXAMPLES = ['simple-paid-api', 'free-and-premium', 'paid-mcp-tool', 'acp-c * guardrails refuse outside local mode. So these are validated with a wallet * supplied rather than dropped from the sweep. */ -const PUBLIC_EXAMPLES = ['base-sepolia', 'base-mainnet', 'base-mainnet-payai'] as const; +const PUBLIC_EXAMPLES = [ + 'base-sepolia', + 'arbitrum-sepolia', + 'solana-devnet', + 'near-testnet', + 'base-mainnet', + 'base-mainnet-payai', + 'multi-network-mainnet', +] as const; const PUBLIC_ENV = { MERCHANT_WALLET: '0x1111111111111111111111111111111111111111', + SOLANA_MERCHANT_WALLET: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + NEAR_MERCHANT_ACCOUNT: 'merchant.testnet', + X402_FACILITATOR_TOKEN: 'facilitator-token', ALLOW_X402_MAINNET: 'true', GATEWAY_PUBLIC_BASE_URL: 'https://gateway.example.com', ADMIN_TOKEN: 'admin-token', diff --git a/tests/unit/cli/lib.test.ts b/tests/unit/cli/lib.test.ts index 76f4d59..ebc0ef7 100644 --- a/tests/unit/cli/lib.test.ts +++ b/tests/unit/cli/lib.test.ts @@ -86,6 +86,15 @@ describe('readVersionReport', () => { expect(pin.via.length).toBeGreaterThan(0); } }); + // Each x402 chain family's SDK, so an operator can see what settles their offers + it('reports the x402 family packages with their manifest pins', () => { + const pins = Object.fromEntries( + readVersionReport().pinned.map((pin) => [pin.name, pin.version]), + ); + for (const name of ['@x402/core', '@x402/evm', '@x402/svm', '@solana/kit', '@x402/near']) { + expect(pins[name], name).toMatch(/^\d+\.\d+\.\d+$/); + } + }); }); describe('io', () => { diff --git a/tests/unit/cli/manifest-env.test.ts b/tests/unit/cli/manifest-env.test.ts index bba22e6..8602908 100644 --- a/tests/unit/cli/manifest-env.test.ts +++ b/tests/unit/cli/manifest-env.test.ts @@ -9,7 +9,7 @@ import { } from '../../../src/cli/lib/manifest-env'; const MANIFEST = { - chainId: 84532, + chainId: 31337, rpcUrl: 'http://127.0.0.1:8545', asset: '0x5fbdb2315678afecb367f032d93f642f64180aa3', assetName: 'MockUSDC', diff --git a/tests/unit/cli/packaging.test.ts b/tests/unit/cli/packaging.test.ts index 8ddcf33..8843129 100644 --- a/tests/unit/cli/packaging.test.ts +++ b/tests/unit/cli/packaging.test.ts @@ -157,6 +157,20 @@ describe('published package metadata', () => { expect(manifest.pnpm).toBeUndefined(); }); + it('pins every @x402 package to one version older than 2.26.0', () => { + // Keep the @x402 pins aligned. The EVM facilitator in 2.26.0-2.28.0 can + // leave a failed `eth_getCode` promise unhandled, exiting Node. Raise the + // bound only for a release that fixes this + const versions = new Set( + Object.entries({ ...manifest.peerDependencies, ...manifest.devDependencies }) + .filter(([name]) => name.startsWith('@x402/')) + .map(([, version]) => version), + ); + expect(versions.size).toBe(1); + const [major, minor] = ([...versions][0] as string).split('.').map(Number); + expect(major === 2 && (minor as number) < 26).toBe(true); + }); + it('keeps the AP2 crypto libraries optional and exactly pinned', () => { // A mandate decides whether a purchase was authorized, so nothing in that // path floats. `jose` and `@sd-jwt/core` are optional peers because a @@ -193,8 +207,11 @@ describe('published package metadata', () => { '@coinbase/x402', '@modelcontextprotocol/server', '@sd-jwt/core', + '@solana/kit', '@x402/core', '@x402/evm', + '@x402/near', + '@x402/svm', 'canonicalize', 'jose', 'mppx', @@ -397,18 +414,26 @@ describe.skipIf(!existsSync(libEntry))('optional-peer subpaths', () => { it('imports only its own peer, in each subpath', () => { expect(bareImportsOf(mcpEntry)).toEqual(['@modelcontextprotocol/server']); - // `@coinbase/x402` is a dynamic import, loaded only for facilitator auth.type `cdp` + // Dynamic imports: `@coinbase/x402` loads only for facilitator auth.type + // `cdp`, `@x402/svm` and `@solana/kit` only for a Solana offer, + // `@x402/near` only for a NEAR offer expect(bareImportsOf(x402Entry).sort()).toEqual([ '@coinbase/x402', + '@solana/kit', '@x402/core', '@x402/evm', + '@x402/near', + '@x402/svm', 'viem', ]); // MPP settles through an x402 facilitator, so its entry also imports the x402 peers expect(bareImportsOf(mppEntry).sort()).toEqual([ '@coinbase/x402', + '@solana/kit', '@x402/core', '@x402/evm', + '@x402/near', + '@x402/svm', 'mppx', 'viem', ]); @@ -453,7 +478,7 @@ describe.skipIf(!existsSync(libEntry))('optional-peer subpaths', () => { `import { CommerceError } from ${JSON.stringify(libEntry)}; import { x402 } from ${JSON.stringify(x402Entry)}; let caught; - try { x402({ asset: 'not-an-address', payTo: '0x0', network: 'eip155:84532' }); } + try { x402({ asset: 'not-an-address', payTo: '0x0', network: 'eip155:31337' }); } catch (e) { caught = e; } process.stdout.write(String(caught instanceof CommerceError));`, ], diff --git a/tests/unit/cli/validate.test.ts b/tests/unit/cli/validate.test.ts index 7941c24..926994e 100644 --- a/tests/unit/cli/validate.test.ts +++ b/tests/unit/cli/validate.test.ts @@ -407,7 +407,7 @@ payments: {} MPP_CHALLENGE_SECRET: 'MPP-CHALLENGE-SECRET-0123456789abcdef', }; const rail = ` - network: eip155:84532 + network: eip155:31337 rpcUrl: "https://rpc.example/v2/\${RPC_KEY}" asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" assetName: USDC diff --git a/tests/unit/config/fixtures.ts b/tests/unit/config/fixtures.ts index b4ede15..9c683a8 100644 --- a/tests/unit/config/fixtures.ts +++ b/tests/unit/config/fixtures.ts @@ -58,7 +58,7 @@ export function validRawConfig(): Record { payments: { x402: { enabled: true, - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: 'http://127.0.0.1:8545', asset: '0x1111111111111111111111111111111111111111', assetName: 'MockUSDC', diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index 208e2cb..b757c37 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -321,6 +321,12 @@ describe('parseConfig', () => { describe('x402 deployment guardrails', () => { const MERCHANT = '0x1111111111111111111111111111111111111111'; const BASE_USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; + // Public Base Sepolia, which settles only in its USDC + const BASE_SEPOLIA = { + network: 'eip155:84532', + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + assetName: 'USDC', + }; function withX402(overrides: Record): Record { const raw = validRawConfig(); @@ -357,6 +363,7 @@ describe('parseConfig', () => { it('accepts a remote facilitator on a testnet', () => { const config = parseConfig( withX402({ + ...BASE_SEPOLIA, payTo: MERCHANT, facilitator: { mode: 'remote', url: 'https://facilitator.example.com' }, }), @@ -391,17 +398,21 @@ describe('parseConfig', () => { }); it('rejects an unknown CAIP-2 network', () => { - expect(messageFor(withX402({ network: 'eip155:1' }))).toContain('not a supported network'); - expect(messageFor(withX402({ network: 'solana:mainnet' }))).toContain( - 'not a supported network', - ); + expect(messageFor(withX402({ network: 'eip155:10' }))).toContain('not a known network'); + expect(messageFor(withX402({ network: 'solana:mainnet' }))).toContain('not a known network'); }); it('rejects a mainnet served by the in-process facilitator', () => { // The local facilitator signs with a key this process holds: a hot // wallet inside the resource server const message = messageFor( - withX402({ network: 'eip155:8453', asset: BASE_USDC, payTo: MERCHANT, allowMainnet: true }), + withX402({ + network: 'eip155:8453', + asset: BASE_USDC, + assetName: 'USD Coin', + payTo: MERCHANT, + allowMainnet: true, + }), ); expect(message).toContain('mainnet'); expect(message).toContain('remote facilitator'); @@ -504,7 +515,10 @@ describe('parseConfig', () => { it('rejects a well-known development payTo on a non-local deployment', () => { // The fixture's payTo is Anvil account #1, whose private key is public const message = messageFor( - withX402({ facilitator: { mode: 'remote', url: 'https://facilitator.example.com' } }), + withX402({ + ...BASE_SEPOLIA, + facilitator: { mode: 'remote', url: 'https://facilitator.example.com' }, + }), ); expect(message).toContain('well-known Anvil development address'); }); @@ -640,7 +654,7 @@ describe('parseConfig', () => { payTo: MERCHANT, facilitator: { mode: 'remote', - url: 'https://facilitator.example.com', + url: 'https://api.cdp.coinbase.com/platform/v2/x402', auth: { type: 'cdp', apiKeyId: 'key-id', apiKeySecret: '${CDP_SECRET:- }' }, }, }), @@ -648,6 +662,43 @@ describe('parseConfig', () => { expect(message).toContain('apiKeySecret is empty'); }); + // `@coinbase/x402` signs for 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({ + payTo: MERCHANT, + facilitator: { + mode: 'remote', + url, + auth: { type: 'cdp', apiKeyId: 'key-id', apiKeySecret: 'key-secret' }, + }, + }), + ); + 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', () => { + const config = parseConfig( + withX402({ + payTo: MERCHANT, + facilitator: { + mode: 'remote', + url: 'https://api.cdp.coinbase.com/platform/v2/x402/', + auth: { type: 'cdp', apiKeyId: 'key-id', apiKeySecret: 'key-secret' }, + }, + }), + {}, + ); + expect(config.payments.x402?.facilitator).toMatchObject({ mode: 'remote' }); + }); + it('rejects an auth type nobody implements, rather than sending nothing', () => { const raw = withX402({ payTo: MERCHANT, @@ -2190,6 +2241,26 @@ describe('payments.mpp', () => { expectConfigInvalid(() => parseConfig(mppConfig(mpp), {}), path); }); + // MPP charges its own 6-decimal USDC, whatever token x402 offers + it('checks an MPP-only resource against MPP decimals, not the x402 options', () => { + const raw = mppConfig(); + const x402 = (validRawConfig()['payments'] as { x402: Record }).x402; + (raw['payments'] as Record)['x402'] = { + ...x402, + asset: '0x2222222222222222222222222222222222222222', + assetName: 'Coarse', + assetVersion: '1', + assetDecimals: 2, + }; + const resources = raw['resources'] as Record }>; + (resources['market_report'] as { pricing: Record }).pricing = { + type: 'fixed', + amount: '0.001', + currency: 'USDC', + }; + expect(() => parseConfig(raw, {})).not.toThrow(); + }); + it('refuses an unknown key in the MPP block', () => { expectConfigInvalid( () => parseConfig(mppConfig({ currency: 'EUR' }), {}), @@ -2273,13 +2344,13 @@ describe('payments.mpp', () => { }); describe('paymentFlow', () => { - it('defaults to authorization, as the x402 rail does', () => { - expect(parseConfig(mppConfig(), {}).payments.mpp?.paymentFlow).toBe('authorization'); + it('defaults to upfront, unlike the x402 rail', () => { + expect(parseConfig(mppConfig(), {}).payments.mpp?.paymentFlow).toBe('upfront'); }); - it('accepts upfront for the rail', () => { - const config = parseConfig(mppConfig({ paymentFlow: 'upfront' }), {}); - expect(config.payments.mpp?.paymentFlow).toBe('upfront'); + it('accepts authorization for the rail', () => { + const config = parseConfig(mppConfig({ paymentFlow: 'authorization' }), {}); + expect(config.payments.mpp?.paymentFlow).toBe('authorization'); expect(config.payments.mpp?.resourcePaymentFlows).toBeUndefined(); }); @@ -2314,6 +2385,20 @@ describe('payments.mpp', () => { expect(payments.x402?.resourcePaymentFlows).toEqual({ market_report: 'upfront' }); expect(payments.mpp?.resourcePaymentFlows).toEqual({ market_report: 'upfront' }); }); + + it('keeps an override off a rail the resource does not name', () => { + const raw = mppConfig(); + const mpp = (raw['payments'] as Record)['mpp']; + raw['payments'] = { ...(validRawConfig()['payments'] as Record), mpp }; + (raw['resources'] as Record>)['market_report'] = { + ...(raw['resources'] as Record>)['market_report'], + payments: ['x402'], + paymentFlow: 'upfront', + }; + const { payments } = parseConfig(raw, {}); + expect(payments.x402?.resourcePaymentFlows).toEqual({ market_report: 'upfront' }); + expect(payments.mpp).not.toHaveProperty('resourcePaymentFlows'); + }); }); }); diff --git a/tests/unit/config/x402-options.test.ts b/tests/unit/config/x402-options.test.ts new file mode 100644 index 0000000..c98798c --- /dev/null +++ b/tests/unit/config/x402-options.test.ts @@ -0,0 +1,621 @@ +/** + * Check direct fields and the `options` array, including asset defaults, + * overrides and configuration refusals before payment. + */ +import { describe, expect, it } from 'vitest'; +import { parseConfig } from '../../../src/config'; +import { type CommerceError, isCommerceError } from '../../../src/core'; +import { validRawConfig } from './fixtures'; + +const MERCHANT = '0x1111111111111111111111111111111111111111'; +const OTHER_TOKEN = '0x2222222222222222222222222222222222222222'; +const REMOTE = { + mode: 'remote', + url: 'https://facilitator.example.com', + auth: { type: 'bearer', token: 'secret-token' }, +}; + +// The fixture's x402 block with its option fields taken out +function operational(): Record { + const x402 = { ...(validRawConfig()['payments'] as { x402: Record }).x402 }; + for (const field of ['network', 'payTo', 'asset', 'assetName', 'assetVersion', 'assetDecimals']) { + delete x402[field]; + } + return x402; +} + +function withX402(x402: Record, price?: Record) { + const raw = validRawConfig(); + (raw['payments'] as Record)['x402'] = x402; + if (price) { + const resources = raw['resources'] as Record }>; + resources['market_report'] = { + ...(resources['market_report'] as { pricing: Record }), + pricing: { type: 'fixed', ...price }, + }; + } + return raw; +} + +function refusal(raw: Record): CommerceError { + try { + parseConfig(raw, {}); + } catch (error) { + if (isCommerceError(error) && error.code === 'CONFIG_INVALID') return error; + throw error; + } + throw new Error('expected parseConfig to refuse this configuration'); +} + +const pathOf = (error: CommerceError) => (error.details as { path?: string }).path; + +// A remote testnet option on Base Sepolia, with nothing about the asset +const MINIMAL = { network: 'eip155:84532', payTo: MERCHANT }; + +describe('payments.x402 forms', () => { + it('reads the direct fields and a one-element options list identically', () => { + const direct = validRawConfig(); + const x402 = (direct['payments'] as { x402: Record }).x402; + const option = Object.fromEntries( + ['network', 'payTo', 'asset', 'assetName', 'assetVersion', 'assetDecimals'].map((field) => [ + field, + x402[field], + ]), + ); + const listed = withX402({ ...operational(), options: [option] }); + expect(parseConfig(listed, {}).payments.x402).toEqual(parseConfig(direct, {}).payments.x402); + }); + + it('refuses both forms together, an empty list, and an object in place of the list', () => { + expect(pathOf(refusal(withX402({ ...operational(), ...MINIMAL, options: [MINIMAL] })))).toBe( + 'payments.x402.options', + ); + expect(refusal(withX402({ ...operational(), options: [] })).message).toContain('is empty'); + // A schema refusal names its path in the message + expect(refusal(withX402({ ...operational(), options: MINIMAL })).message).toContain( + '"payments.x402.options": Expected array', + ); + }); + + it('needs network and payTo when there is no options list', () => { + expect(pathOf(refusal(withX402({ ...operational(), payTo: MERCHANT })))).toBe( + 'payments.x402.network', + ); + }); + + it('refuses a scheme other than exact', () => { + const error = refusal( + withX402({ ...operational(), options: [{ ...MINIMAL, scheme: 'upto' }] }), + ); + expect(pathOf(error)).toBe('payments.x402.options[0].scheme'); + }); +}); + +describe('default assets', () => { + it('settles a minimal mainnet option in the network USDC, in either form', () => { + const mainnet = { ...operational(), facilitator: REMOTE, allowMainnet: true }; + for (const raw of [ + withX402({ ...mainnet, network: 'eip155:8453', payTo: MERCHANT }), + withX402({ ...mainnet, options: [{ network: 'eip155:8453', payTo: MERCHANT }] }), + ]) { + expect(parseConfig(raw, {}).payments.x402).toMatchObject({ + network: 'eip155:8453', + asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + assetName: 'USD Coin', + assetVersion: '2', + assetDecimals: 6, + }); + } + }); + + it('prices a default-asset option in USD or in the default token, nothing else', () => { + const minimal = { ...operational(), facilitator: REMOTE, ...MINIMAL }; + for (const currency of ['USD', 'USDC']) { + expect(() => parseConfig(withX402(minimal, { amount: '0.01', currency }), {})).not.toThrow(); + } + const error = refusal(withX402(minimal, { amount: '0.01', currency: 'EUR' })); + expect(pathOf(error)).toBe('resources.market_report.pricing.currency'); + }); + + // Naming the default USDC charges the price in USDC all the same + it('applies the currency rule when an option names its network USDC', () => { + const named = { + ...operational(), + facilitator: REMOTE, + ...MINIMAL, + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + }; + for (const currency of ['USD', 'USDC']) { + expect(() => parseConfig(withX402(named, { amount: '0.01', currency }), {})).not.toThrow(); + } + const error = refusal(withX402(named, { amount: '0.01', currency: 'EUR' })); + expect(pathOf(error)).toBe('resources.market_report.pricing.currency'); + }); + + it('keeps a configured asset under the currency label it always had', () => { + // The fixture names MockUSDC explicitly; its label is the merchant's choice + const raw = validRawConfig(); + const resources = raw['resources'] as Record }>; + (resources['market_report'] as { pricing: Record }).pricing['currency'] = + 'POINTS'; + expect(() => parseConfig(raw, {})).not.toThrow(); + }); + + it('refuses a price finer than the default asset, before any conversion', () => { + const error = refusal( + withX402( + { ...operational(), facilitator: REMOTE, ...MINIMAL }, + { + amount: '0.0000001', + currency: 'USD', + }, + ), + ); + expect(error.message).toContain('6 decimals'); + }); +}); + +describe('asset overrides', () => { + const base = { ...operational(), facilitator: REMOTE }; + + it('refuses metadata that contradicts the default asset it would describe', () => { + expect(pathOf(refusal(withX402({ ...base, ...MINIMAL, assetDecimals: 18 })))).toBe( + 'payments.x402.assetDecimals', + ); + const named = refusal( + withX402({ + ...base, + ...MINIMAL, + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + assetName: 'USD Coin', + }), + ); + expect(named.message).toContain('EIP-712 domain name "USDC"'); + }); + + it('needs every asset field on the local chain, which has no default token', () => { + const local = { ...operational(), network: 'eip155:31337', payTo: MERCHANT }; + const error = refusal(withX402({ ...local, asset: OTHER_TOKEN, assetDecimals: 6 })); + expect(error.message).toContain( + 'has no default asset, so the option needs assetName, assetVersion', + ); + const token = { asset: OTHER_TOKEN, assetName: 'Other', assetVersion: '1', assetDecimals: 18 }; + expect(() => parseConfig(withX402({ ...local, ...token }), {})).not.toThrow(); + }); + + it('takes a Permit2 token with only its address and decimals', () => { + const local = { ...operational(), network: 'eip155:31337', payTo: MERCHANT }; + const token = { asset: OTHER_TOKEN, assetDecimals: 18 }; + const x402 = parseConfig(withX402({ ...local, ...token, assetTransferMethod: 'permit2' }), {}) + .payments.x402; + expect(x402).toMatchObject({ ...token, assetTransferMethod: 'permit2' }); + expect(x402?.assetName).toBeUndefined(); + const unknown = refusal(withX402({ ...local, ...token, assetTransferMethod: 'permit' })); + expect(pathOf(unknown)).toBe('payments.x402.assetTransferMethod'); + }); + + it('refuses another token on a public network, testnet or mainnet', () => { + const token = { asset: OTHER_TOKEN, assetName: 'Other', assetVersion: '1', assetDecimals: 6 }; + expect(refusal(withX402({ ...base, ...MINIMAL, ...token })).message).toContain( + 'is not USDC on Base Sepolia', + ); + const mainnet = { ...base, allowMainnet: true, network: 'eip155:1', payTo: MERCHANT }; + expect(refusal(withX402({ ...mainnet, ...token })).message).toContain( + 'is not USDC on Ethereum', + ); + }); +}); + +describe('payments.x402.networks', () => { + const OP = { 'eip155:10': { kind: 'mainnet', displayName: 'OP Mainnet' } }; + const token = { asset: OTHER_TOKEN, assetName: 'Token', assetVersion: '1', assetDecimals: 6 }; + + it('adds an EVM network with its environment, whose mainnet guardrails then apply', () => { + const raw = withX402({ + ...operational(), + facilitator: REMOTE, + networks: OP, + network: 'eip155:10', + payTo: MERCHANT, + ...token, + }); + expect(refusal(raw).message).toContain('allowMainnet'); + (raw['payments'] as { x402: Record }).x402['allowMainnet'] = true; + expect(parseConfig(raw, {}).payments.x402).toMatchObject({ + network: 'eip155:10', + networks: OP, + }); + }); + + it('needs the asset spelled out, since a declared network has no default', () => { + const error = refusal( + withX402({ + ...operational(), + facilitator: REMOTE, + allowMainnet: true, + networks: OP, + network: 'eip155:10', + payTo: MERCHANT, + }), + ); + expect(error.message).toContain('has no default asset'); + }); + + it('refuses a chain id above 2^53, which a number cannot hold exactly', () => { + const declare = (id: string) => + withX402({ + ...operational(), + facilitator: REMOTE, + networks: { [id]: { kind: 'testnet', displayName: 'Big' } }, + network: id, + payTo: MERCHANT, + ...token, + }); + expect(pathOf(refusal(declare('eip155:9007199254740993')))).toBe( + 'payments.x402.networks.eip155:9007199254740993', + ); + expect(parseConfig(declare('eip155:9007199254740991'), {}).payments.x402).toMatchObject({ + network: 'eip155:9007199254740991', + }); + }); + + it('refuses to redeclare a built-in network or to declare a non-EVM one', () => { + const relabeled = refusal( + withX402({ + ...operational(), + ...MINIMAL, + networks: { 'eip155:8453': { kind: 'testnet', displayName: 'Base' } }, + }), + ); + expect(pathOf(relabeled)).toBe('payments.x402.networks.eip155:8453'); + const foreign = refusal( + withX402({ + ...operational(), + ...MINIMAL, + networks: { 'solana:x': { kind: 'testnet', displayName: 'X' } }, + }), + ); + expect(foreign.message).toContain('Only EVM networks can be declared'); + }); +}); + +describe('configurations refused before startup', () => { + it('points a local config still on eip155:84532 at the local chain id', () => { + const error = refusal(withX402({ ...operational(), network: 'eip155:84532', payTo: MERCHANT })); + expect(error.message).toContain('the local Anvil chain is eip155:31337'); + expect(pathOf(error)).toBe('payments.x402.facilitator.mode'); + }); + + it('keeps the in-process facilitator on the Anvil chain id', () => { + const error = refusal( + withX402({ ...operational(), network: 'eip155:421614', payTo: MERCHANT }), + ); + expect(pathOf(error)).toBe('payments.x402.facilitator.mode'); + }); + + it('checks Solana and NEAR options and serves them beside EVM ones', () => { + const solana = { network: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1' }; + const wallet = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'; + const badKey = refusal( + withX402({ + ...operational(), + facilitator: REMOTE, + options: [{ ...solana, payTo: MERCHANT }], + }), + ); + expect(badKey.message).toContain('not a plausible address'); + + const { rpcUrl: _shared, ...shared } = operational(); + const x402 = parseConfig( + withX402({ + ...shared, + facilitator: REMOTE, + networks: { + 'eip155:84532': { rpcUrl: 'https://base-sepolia.example' }, + [solana.network]: { rpcUrl: 'https://solana-devnet.example' }, + 'near:testnet': { rpcUrl: 'https://near-testnet.example' }, + }, + options: [ + MINIMAL, + { ...solana, payTo: wallet }, + { network: 'near:testnet', payTo: 'merchant.testnet' }, + ], + }), + {}, + ).payments.x402; + expect(x402?.options?.[1]).toMatchObject({ + network: solana.network, + payTo: wallet, + asset: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + assetDecimals: 6, + }); + + expect(x402?.options?.[2]).toMatchObject({ + network: 'near:testnet', + payTo: 'merchant.testnet', + asset: '3e2210e1184b45b64c8a434c0a7e7b23cc04ea7eb7a6c3c32520d03d4afcb8af', + assetDecimals: 6, + }); + + const badAccount = refusal( + withX402({ + ...operational(), + facilitator: REMOTE, + options: [{ network: 'near:testnet', payTo: 'Merchant.testnet' }], + }), + ); + expect(pathOf(badAccount)).toBe('payments.x402.options[0].payTo'); + }); +}); + +describe('several options', () => { + const ARBITRUM_SEPOLIA = { network: 'eip155:421614', payTo: MERCHANT }; + const RPCS = { + 'eip155:84532': { rpcUrl: 'https://base-sepolia.example' }, + 'eip155:421614': { rpcUrl: 'https://arbitrum-sepolia.example' }, + }; + const remote = () => { + const { rpcUrl: _shared, ...rest } = operational(); + return { ...rest, facilitator: REMOTE }; + }; + + it('resolves each option in order, each with its own network RPC', () => { + const x402 = parseConfig( + withX402({ ...remote(), networks: RPCS, options: [MINIMAL, ARBITRUM_SEPOLIA] }), + {}, + ).payments.x402; + expect(x402?.options?.map((o) => [o.network, o.rpcUrl, o.asset])).toEqual([ + [ + 'eip155:84532', + 'https://base-sepolia.example', + '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + ], + [ + 'eip155:421614', + 'https://arbitrum-sepolia.example', + '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', + ], + ]); + // The single-option fields repeat the first option + expect(x402).toMatchObject({ network: 'eip155:84532', rpcUrl: 'https://base-sepolia.example' }); + }); + + it('refuses two networks on one RPC endpoint, a repeated option and a mix of kinds', () => { + const shared = { ...remote(), rpcUrl: 'https://rpc.example' }; + expect( + refusal(withX402({ ...shared, options: [MINIMAL, ARBITRUM_SEPOLIA] })).message, + ).toContain('would use the same RPC endpoint'); + expect(refusal(withX402({ ...shared, options: [MINIMAL, MINIMAL] })).message).toContain( + 'repeats an earlier option', + ); + // EVM addresses compare without case + const lower = { ...MINIMAL, payTo: `0x${'ab'.repeat(20)}` }; + const upper = { ...MINIMAL, payTo: `0x${'AB'.repeat(20)}` }; + expect(refusal(withX402({ ...shared, options: [lower, upper] })).message).toContain( + 'repeats an earlier option', + ); + // One endpoint, spelled with and without a trailing slash + const slashed = { + ...remote(), + networks: { + 'eip155:84532': { rpcUrl: 'https://rpc.example' }, + 'eip155:421614': { rpcUrl: 'https://rpc.example/' }, + }, + }; + expect( + refusal(withX402({ ...slashed, options: [MINIMAL, ARBITRUM_SEPOLIA] })).message, + ).toContain('would use the same RPC endpoint'); + const mixed = refusal( + withX402({ + ...remote(), + allowMainnet: true, + networks: { ...RPCS, 'eip155:8453': { rpcUrl: 'https://base.example' } }, + options: [MINIMAL, { network: 'eip155:8453', payTo: MERCHANT }], + }), + ); + expect(pathOf(mixed)).toBe('payments.x402.options[1].network'); + }); + + it('prices every default-asset option in their token or USD', () => { + const config = { ...remote(), networks: RPCS, options: [MINIMAL, ARBITRUM_SEPOLIA] }; + for (const currency of ['USD', 'USDC']) { + expect(() => parseConfig(withX402(config, { amount: '0.01', currency }), {})).not.toThrow(); + } + expect(pathOf(refusal(withX402(config, { amount: '0.01', currency: 'EUR' })))).toBe( + 'resources.market_report.pricing.currency', + ); + }); + + it('bounds the price by the option with the fewest decimals', () => { + const local = operational(); + const coarse = { + network: 'eip155:31337', + payTo: MERCHANT, + asset: OTHER_TOKEN, + assetName: 'Coarse', + assetVersion: '1', + assetDecimals: 2, + }; + const mock = (validRawConfig()['payments'] as { x402: Record }).x402; + const fine = { ...coarse, asset: mock['asset'], assetName: 'MockUSDC', assetDecimals: 6 }; + const error = refusal( + withX402({ ...local, options: [fine, coarse] }, { amount: '0.001', currency: 'USDC' }), + ); + expect(error.message).toContain('2 decimals'); + }); + + // The value is not echoed: an RPC URL can carry an API key + it.each([ + ['payments.x402.rpcUrl', { rpcUrl: 'file:///etc/passwd' }], + ['payments.x402.rpcUrl', { rpcUrl: 'not a url' }], + [ + 'payments.x402.networks.eip155:84532.rpcUrl', + { networks: { 'eip155:84532': { rpcUrl: 'ftp://rpc.example/key-123' } } }, + ], + ])('refuses a %s that is not an http(s) URL', (path, fields) => { + const error = refusal(withX402({ ...remote(), ...MINIMAL, ...fields })); + expect(error.message).toBe( + `Configuration invalid at "${path}": must be an http:// or https:// URL`, + ); + expect(JSON.stringify(error.toInfo())).not.toMatch(/passwd|key-123|not a url/); + }); + + it('takes an RPC alone for a built-in network, and refuses half a declaration', () => { + expect(() => + parseConfig(withX402({ ...remote(), networks: RPCS, ...MINIMAL }), {}), + ).not.toThrow(); + const unknown = refusal( + withX402({ + ...remote(), + ...MINIMAL, + rpcUrl: 'https://rpc.example', + networks: { 'eip155:10': { rpcUrl: 'https://op.example' } }, + }), + ); + expect(unknown.message).toContain('not a known network'); + const half = refusal( + withX402({ + ...remote(), + ...MINIMAL, + rpcUrl: 'https://rpc.example', + networks: { 'eip155:10': { kind: 'mainnet' } }, + }), + ); + expect(half.message).toContain('needs both kind and displayName'); + }); +}); + +describe('resource offers (x402.accepts)', () => { + const ARBITRUM_SEPOLIA = { network: 'eip155:421614', payTo: MERCHANT }; + const RPCS = { + 'eip155:84532': { rpcUrl: 'https://base-sepolia.example' }, + 'eip155:421614': { rpcUrl: 'https://arbitrum-sepolia.example' }, + }; + const remote = () => { + const { rpcUrl: _shared, ...rest } = operational(); + return { ...rest, facilitator: REMOTE, networks: RPCS }; + }; + // Sets a resource's own offers, and optionally its other fields + function withAccepts( + raw: Record, + accepts: unknown, + id = 'market_report', + fields: Record = {}, + ): Record { + const resources = raw['resources'] as Record>; + resources[id] = { ...resources[id], ...fields, x402: { accepts } }; + return raw; + } + + it('gives a resource its own offers and leaves the rail options to the others', () => { + const x402 = parseConfig( + withAccepts(withX402({ ...remote(), options: [MINIMAL] }), [ARBITRUM_SEPOLIA]), + {}, + ).payments.x402; + expect(x402?.options?.map((o) => o.network)).toEqual(['eip155:84532']); + expect(x402?.resourceOptions?.['market_report']).toEqual([ + { + network: 'eip155:421614', + rpcUrl: 'https://arbitrum-sepolia.example', + asset: '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', + assetName: 'USD Coin', + assetVersion: '2', + assetDecimals: 6, + payTo: MERCHANT, + }, + ]); + expect(parseConfig(validRawConfig(), {}).payments.x402).not.toHaveProperty('resourceOptions'); + }); + + it('refuses an empty list, a resource that cannot use it, and a missing rail', () => { + const rail = () => withX402({ ...remote(), options: [MINIMAL] }); + const empty = refusal(withAccepts(rail(), [])); + expect(empty.message).toContain('is empty'); + expect(pathOf(empty)).toBe('resources.market_report.x402.accepts'); + expect(pathOf(refusal(withAccepts(rail(), [MINIMAL], 'weather_basic')))).toBe( + 'resources.weather_basic.x402', + ); + const withoutRail = withAccepts(validRawConfig(), [MINIMAL]); + delete (withoutRail['payments'] as Record)['x402']; + expect(refusal(withoutRail).message).toContain('payments.x402 is not configured'); + }); + + it('resolves each list under the rail settings: RPC, repeats and one kind of network', () => { + const { networks: _rpcs, ...noRpc } = remote(); + const missing = refusal( + withAccepts( + withX402({ + ...noRpc, + networks: { 'eip155:84532': RPCS['eip155:84532'] }, + options: [MINIMAL], + }), + [ARBITRUM_SEPOLIA], + ), + ); + expect(missing.message).toContain('resources.market_report.x402.accepts[0].network'); + expect( + refusal(withAccepts(withX402({ ...remote(), options: [MINIMAL] }), [MINIMAL, MINIMAL])) + .message, + ).toContain('repeats an earlier option'); + const mixed = refusal( + withAccepts( + withX402({ + ...remote(), + allowMainnet: true, + networks: { ...RPCS, 'eip155:8453': { rpcUrl: 'https://base.example' } }, + options: [MINIMAL], + }), + [{ network: 'eip155:8453', payTo: MERCHANT }], + ), + ); + expect(pathOf(mixed)).toBe('resources.market_report.x402.accepts[0].network'); + // The rail's own option may repeat in a resource's list + expect(() => + parseConfig( + withAccepts(withX402({ ...remote(), options: [MINIMAL] }), [MINIMAL, ARBITRUM_SEPOLIA]), + {}, + ), + ).not.toThrow(); + }); + + it('checks the price against the resource offers, not the rail options', () => { + const coarse = { + network: 'eip155:31337', + payTo: MERCHANT, + asset: OTHER_TOKEN, + assetName: 'Coarse', + assetVersion: '1', + assetDecimals: 2, + }; + const mock = (validRawConfig()['payments'] as { x402: Record }).x402; + const fine = { ...coarse, asset: mock['asset'], assetName: 'MockUSDC', assetDecimals: 6 }; + const price = { amount: '0.001', currency: 'USDC' }; + expect( + refusal(withAccepts(withX402({ ...operational(), options: [fine] }, price), [coarse])) + .message, + ).toContain('2 decimals'); + expect(() => + parseConfig( + withAccepts(withX402({ ...operational(), options: [coarse] }, price), [fine]), + {}, + ), + ).not.toThrow(); + + // A declared network's token takes any label; a resource on default USDC does not + const opSepolia = { + 'eip155:11155420': { + kind: 'testnet', + displayName: 'OP Sepolia', + rpcUrl: 'https://op-sepolia.example', + }, + }; + const custom = { ...coarse, network: 'eip155:11155420', assetDecimals: 6 }; + const eur = { amount: '0.01', currency: 'EUR' }; + const rail = () => + withX402({ ...remote(), networks: { ...RPCS, ...opSepolia }, options: [custom] }, eur); + expect(() => parseConfig(rail(), {})).not.toThrow(); + expect(pathOf(refusal(withAccepts(rail(), [MINIMAL])))).toBe( + 'resources.market_report.pricing.currency', + ); + }); +}); diff --git a/tests/unit/core/execution/backend-http.test.ts b/tests/unit/core/execution/backend-http.test.ts index 68244bd..d344a11 100644 --- a/tests/unit/core/execution/backend-http.test.ts +++ b/tests/unit/core/execution/backend-http.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; import type { BackendHandler } from '../../../../src/core/domain/resource'; import { isCommerceError } from '../../../../src/core/errors'; -import { - HttpBackendExecutor, - validateBackendRequestShape, -} from '../../../../src/core/execution/backend-http'; +import { HttpBackendExecutor } from '../../../../src/core/execution/backend-http'; +import { validateBackendRequestShape } from '../../../../src/core/execution/backend-request'; import { NOOP_LOGGER } from '../../../../src/core/interfaces/logger'; function jsonResponse( diff --git a/tests/unit/core/execution/pipeline-authorization.test.ts b/tests/unit/core/execution/pipeline-authorization.test.ts index 4918169..a9c1aa6 100644 --- a/tests/unit/core/execution/pipeline-authorization.test.ts +++ b/tests/unit/core/execution/pipeline-authorization.test.ts @@ -632,3 +632,60 @@ describe('execution pipeline authorization', () => { expect(types(store)).not.toContain('authorization.verified'); }); }); + +describe('execution pipeline offer selection', () => { + it('selects the offer first, then binds the mandate, the reservation and settlement to it', async () => { + const calls: string[] = []; + const seen: Record = {}; + const base = createFakePaymentProvider({ + createRequirement: async (ctx) => ({ + id: 'requirement-1', + requestId: ctx.requestId, + resourceId: ctx.resource.id, + provider: 'x402', + amount: ctx.amount, + currency: ctx.currency, + challenge: { provider: 'x402', version: '2', accepts: [{ offer: 0 }, { offer: 1 }] }, + }), + }); + const provider = { + ...base, + async selectRequirement(ctx: Parameters>[0]) { + calls.push('select'); + return { + ...ctx.requirement, + destination: '0xSECOND', + network: 'eip155:2', + asset: '0xTOKEN2', + }; + }, + async verify(ctx: Parameters[0]) { + calls.push('verify'); + seen['verify'] = ctx.requirement.destination; + return base.verify(ctx); + }, + async settle(ctx: Parameters[0]) { + calls.push('settle'); + seen['settle'] = ctx.requirement.destination; + return base.settle(ctx); + }, + }; + const auth = createFakeAuthorizationProvider(); + const { pipeline } = buildPipeline({ + paymentProviders: [provider], + authorizationProviders: [auth], + }); + + const outcome = (await pipeline.execute(makeRequest())) as DeliveredOutcome; + + expect(outcome.kind).toBe('delivered'); + expect(calls).toEqual(['select', 'verify', 'settle']); + expect(seen).toEqual({ verify: '0xSECOND', settle: '0xSECOND' }); + // The mandate is checked against the chosen offer, not the full challenge + expect(auth.contexts[0]?.requirement).toMatchObject({ + destination: '0xSECOND', + network: 'eip155:2', + asset: '0xTOKEN2', + }); + }); +}); diff --git a/tests/unit/core/execution/pipeline-settlement-order.test.ts b/tests/unit/core/execution/pipeline-settlement-order.test.ts index 9d8f07f..c77e369 100644 --- a/tests/unit/core/execution/pipeline-settlement-order.test.ts +++ b/tests/unit/core/execution/pipeline-settlement-order.test.ts @@ -7,6 +7,7 @@ import type { CanonicalRequest } from '../../../../src/core/domain/request'; import { CommerceError, isCommerceError } from '../../../../src/core/errors'; import { createExecutionPipeline } from '../../../../src/core/execution/pipeline'; import { createResourceRegistry } from '../../../../src/core/execution/registry'; +import type { PaymentAttemptReservation } from '../../../../src/core/interfaces/store'; import { createCapturingLogger, createFakeAuthorizationProvider, @@ -125,6 +126,27 @@ describe('settling after the backend', () => { expect(store.events.map((e) => e.type)).toContain('payment.rejected'); }); + it.each([ + [{ settleAfterBackend: true, reuseRejectedAuthorization: true }, true], + [{ settleAfterBackend: true }, false], + ])( + 'asks the store to reclaim a rejected attempt only for a rail that allows reuse: %o', + async (requirementMetadata, reclaim) => { + const { pipeline, store } = setup({ provider: { requirementMetadata } }); + const reserve = store.reservePaymentAttempt; + const reservations: PaymentAttemptReservation[] = []; + store.reservePaymentAttempt = (reservation) => { + reservations.push(reservation); + return reserve(reservation); + }; + + await pipeline.execute(paidRequest()); + + expect(reservations).toHaveLength(1); + expect(reservations[0]?.reclaimRejected).toBe(reclaim ? true : undefined); + }, + ); + it('answers a retry with the same authorization with a fresh challenge', async () => { const { pipeline } = setup({ backendFails: true }); await refusal(pipeline.execute(paidRequest())); diff --git a/tests/unit/core/execution/pipeline.test.ts b/tests/unit/core/execution/pipeline.test.ts index 346ee78..7553f01 100644 --- a/tests/unit/core/execution/pipeline.test.ts +++ b/tests/unit/core/execution/pipeline.test.ts @@ -1601,6 +1601,99 @@ describe('createExecutionPipeline', () => { expect(attempt?.status).toBe('settlement-uncertain'); }); + // A provider that knows nothing was submitted leaves the authorization + // spendable, so the attempt is released for the buyer's retry + it('settle() throwing with settlementNotSubmitted records rejected and answers 503 without blame', async () => { + const resource = makeResource({ + id: 'res-1', + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + paymentMethods: ['x402'], + }); + const store = createFakeStore(); + const { CommerceError } = await import('../../../../src/core/errors'); + const provider = createFakePaymentProvider({ + settle: async () => { + throw new CommerceError('PAYMENT_PROVIDER_UNAVAILABLE', 'chain read failed', { + details: { + settlementNotSubmitted: true, + reason: 'invalid_exact_near_access_key_lookup_failed', + }, + }); + }, + }); + const pipeline = createExecutionPipeline({ + resources: createResourceRegistry([resource]), + paymentProviders: [provider], + store, + backend: createFakeBackendExecutor(), + events: store, + logger: createCapturingLogger(), + clock: createFakeClock(), + ids: createFakeIdGenerator(), + }); + + await expect( + pipeline.execute(makeRequest({ payment: { method: 'x402', payload: 'proof' } })), + ).rejects.toSatisfy( + (error: unknown) => + isCommerceError(error) && + error.code === 'PAYMENT_PROVIDER_UNAVAILABLE' && + error.httpStatus === 503 && + error.details === undefined, + ); + + const attempt = [...store.attempts.values()][0]; + expect(attempt?.status).toBe('rejected'); + expect(attempt?.rejectionReason).toBe('invalid_exact_near_access_key_lookup_failed'); + expect(store.receipts).toHaveLength(0); + expect(store.events.at(-1)).toMatchObject({ + type: 'payment.rejected', + data: { + reason: 'settlement-not-submitted', + providerReason: 'invalid_exact_near_access_key_lookup_failed', + }, + }); + expectOneRequestId(store); + }); + + it('records the transaction a rejected settlement names', async () => { + const resource = makeResource({ + id: 'res-1', + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + paymentMethods: ['x402'], + }); + const store = createFakeStore(); + const provider = createFakePaymentProvider({ + settle: async () => ({ + status: 'rejected', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + rejectionReason: 'invalid_exact_evm_transaction_failed', + externalReference: '0xreverted', + }), + }); + const pipeline = createExecutionPipeline({ + resources: createResourceRegistry([resource]), + paymentProviders: [provider], + store, + backend: createFakeBackendExecutor(), + events: store, + logger: createCapturingLogger(), + clock: createFakeClock(), + ids: createFakeIdGenerator(), + }); + + await expect( + pipeline.execute(makeRequest({ payment: { method: 'x402', payload: 'proof' } })), + ).rejects.toSatisfy( + (error: unknown) => isCommerceError(error) && error.code === 'PAYMENT_SETTLEMENT_FAILED', + ); + const attempt = [...store.attempts.values()][0]; + expect(attempt?.status).toBe('rejected'); + expect(attempt?.externalReference).toBe('0xreverted'); + }); + it('tolerates a non-Error thrown by a persistence call', async () => { const resource = makeResource({ id: 'res-1', pricing: { type: 'free' } }); const store = createFakeStore({ diff --git a/tests/unit/gateway/helpers.ts b/tests/unit/gateway/helpers.ts index 0cb3177..94620f9 100644 --- a/tests/unit/gateway/helpers.ts +++ b/tests/unit/gateway/helpers.ts @@ -253,7 +253,7 @@ export function makeGatewayConfig(overrides: Partial = {}): Gatew payments: { x402: { enabled: true, - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: 'http://127.0.0.1:8545', asset: '0x1111111111111111111111111111111111111111', assetName: 'MockUSDC', diff --git a/tests/unit/gateway/main.test.ts b/tests/unit/gateway/main.test.ts index d560936..4425fa8 100644 --- a/tests/unit/gateway/main.test.ts +++ b/tests/unit/gateway/main.test.ts @@ -57,7 +57,7 @@ function baseConfig(dir: string): RawConfig { payments: { x402: { enabled: true, - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: RPC_URL, asset: '0x1111111111111111111111111111111111111111', assetName: 'MockUSDC', @@ -147,6 +147,7 @@ function fullConfig(dir: string) { report['payments'] = ['x402', 'mpp']; report['authorization'] = { required: ['ap2'] }; config.payments['mpp'] = { + network: 'eip155:31337', enabled: true, rpcUrl: RPC_URL, asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', diff --git a/tests/unit/gateway/payment-providers.test.ts b/tests/unit/gateway/payment-providers.test.ts index ffee1de..448113b 100644 --- a/tests/unit/gateway/payment-providers.test.ts +++ b/tests/unit/gateway/payment-providers.test.ts @@ -98,9 +98,9 @@ describe('createConfiguredPaymentProviders', () => { }); it.each([ - ['the rail', { paymentFlow: 'upfront' as const }], - ['the resource', { resourcePaymentFlows: { market_report: 'upfront' as const } }], - ])('passes an MPP upfront flow set on %s to the provider', async (_label, flow) => { + ['the rail', { paymentFlow: 'authorization' as const }], + ['the resource', { resourcePaymentFlows: { market_report: 'authorization' as const } }], + ])('passes an MPP authorization flow set on %s to the provider', async (_label, flow) => { const resource = makeGatewayConfig().resources.find((r) => r.id === 'market_report'); if (resource === undefined) throw new Error('fixture missing'); const settleAfterBackend = async (mpp: Mpp): Promise => { @@ -116,7 +116,31 @@ describe('createConfiguredPaymentProviders', () => { return requirement.metadata?.['settleAfterBackend']; }; - expect(await settleAfterBackend(MAINNET_MPP)).toBe(true); - expect(await settleAfterBackend({ ...MAINNET_MPP, ...flow })).toBe(false); + expect(await settleAfterBackend(MAINNET_MPP)).toBe(false); + expect(await settleAfterBackend({ ...MAINNET_MPP, ...flow })).toBe(true); + }); + + it('gives a resource with its own offers only those offers', async () => { + const resource = makeGatewayConfig().resources.find((r) => r.id === 'market_report'); + if (resource === undefined) throw new Error('fixture missing'); + const own = { ...MAINNET_X402, payTo: '0x2222222222222222222222222222222222222222' }; + const [provider] = createConfiguredPaymentProviders( + { x402: { ...MAINNET_X402, resourceOptions: { market_report: [own] } } }, + NOOP_LOGGER, + ); + if (provider === undefined) throw new Error('no provider'); + const payees = async (id: string) => + ( + await provider.createRequirement({ + requestId: 'req-1', + resource: { ...resource, id }, + amount: '0.01', + currency: 'USDC', + requestedAt: '2026-01-01T00:00:00.000Z', + }) + ).challenge.accepts.map((offer) => offer['payTo']); + + expect(await payees('market_report')).toEqual([own.payTo]); + expect(await payees('another_report')).toEqual([MAINNET_X402.payTo]); }); }); diff --git a/tests/unit/gateway/server.test.ts b/tests/unit/gateway/server.test.ts index b492cca..879ad98 100644 --- a/tests/unit/gateway/server.test.ts +++ b/tests/unit/gateway/server.test.ts @@ -123,7 +123,7 @@ function x402Provider(overrides: Parameters[0] amount: ctx.amount, currency: ctx.currency, destination: '0xMERCHANT', - network: 'eip155:84532', + network: 'eip155:31337', challenge: { provider: 'x402', version: '2', @@ -256,7 +256,9 @@ describe('createGateway HTTP surface', () => { const body = res.json(); expect(body.merchant.id).toBe('demo-store'); expect(body.payments.x402.payTo).toBe('0x70997970C51812dc3A010C7d01b50e0d17dc79C8'); - expect(body.payments.x402.network).toBe('eip155:84532'); + expect(body.payments.x402.network).toBe('eip155:31337'); + expect(body.payments.x402.paymentFlow).toBe('authorization'); + expect(body.payments.x402).not.toHaveProperty('resourcePaymentFlows'); expect(body.adapters).toHaveLength(1); expect(body.paymentProviders).toHaveLength(1); expect(body.store.name).toBe('fake-store'); @@ -266,6 +268,31 @@ describe('createGateway HTTP surface', () => { expect(raw).not.toContain('signerPrivateKey'); }); + it("publishes each rail's payment flow and its per-resource overrides", async () => { + const base = makeGatewayConfig(); + const x402 = base.payments.x402; + if (x402 === undefined) throw new Error('fixture has no x402'); + const gateway = await buildGateway({ + config: { + ...base, + payments: { + x402: { + ...x402, + paymentFlow: 'upfront', + resourcePaymentFlows: { market_report: 'authorization' }, + }, + }, + }, + }); + const body = ( + await gateway.server.inject({ method: 'GET', url: '/.well-known/agent-commerce' }) + ).json(); + expect(body.payments.x402).toMatchObject({ + paymentFlow: 'upfront', + resourcePaymentFlows: { market_report: 'authorization' }, + }); + }); + // These wire identifiers are in deployed clients, so a rename breaks them. // The MCP server name, x402 resource URI and local chain name belong to other areas. it('publishes the agent-commerce wire identity at /.well-known/agent-commerce', async () => { @@ -332,6 +359,88 @@ describe('createGateway HTTP surface', () => { expect(res.json().payments.x402.payTo).toBeDefined(); }); + it("publishes each option's offer terms on /.well-known, never its RPC URL", async () => { + const base = makeGatewayConfig(); + const x402 = base.payments.x402 as NonNullable; + const rpcUrl = 'https://rpc.example/v2/OPTION-RPC-KEY'; + const terms = { network: x402.network, payTo: x402.payTo }; + const eip3009 = { + ...terms, + asset: x402.asset, + assetName: 'MockUSDC', + assetVersion: '2', + assetDecimals: 6, + }; + const permit2 = { + ...terms, + asset: '0x2222222222222222222222222222222222222222', + assetDecimals: 18, + assetTransferMethod: 'permit2' as const, + }; + const config = makeGatewayConfig({ + payments: { + x402: { ...x402, options: [eip3009, permit2].map((option) => ({ ...option, rpcUrl })) }, + }, + }); + const gateway = await buildGateway({ config }); + const res = await gateway.server.inject({ method: 'GET', url: '/.well-known/agent-commerce' }); + expect(res.json().payments.x402.options).toEqual([eip3009, permit2]); + expect(res.payload).not.toContain('OPTION-RPC-KEY'); + }); + + it("publishes each resource's own offers, and every offer's health without its detail", async () => { + const base = makeGatewayConfig(); + const x402 = base.payments.x402 as NonNullable; + const rpcUrl = 'https://rpc.example/v2/OPTION-RPC-KEY'; + const option = (asset: string) => ({ + network: x402.network, + payTo: x402.payTo, + asset, + assetName: 'MockUSDC', + assetVersion: '2', + assetDecimals: 6, + }); + const first = option(x402.asset); + const second = option('0x2222222222222222222222222222222222222222'); + const own = option('0x3333333333333333333333333333333333333333'); + const config = makeGatewayConfig({ + payments: { + x402: { + ...x402, + options: [first, second].map((o) => ({ ...o, rpcUrl })), + resourceOptions: { market_report: [{ ...own, rpcUrl }] }, + }, + }, + }); + // The provider names EVM offers in lowercase; the config spells them mixed + const name = (o: typeof first) => + `${o.network} ${o.asset.toLowerCase()} ${o.payTo.toLowerCase()}`; + const provider = { + ...createFakePaymentProvider(), + health: async () => ({ + status: 'fail' as const, + detail: 'SECRET-DETAIL', + checkedAt: 'x', + components: [ + { name: name(first), status: 'pass' as const }, + { name: name(second), status: 'fail' as const }, + { name: name(own), status: 'pass' as const }, + ], + }), + }; + const gateway = await buildGateway({ config, paymentProviders: [provider] }); + const res = await gateway.server.inject({ method: 'GET', url: '/.well-known/agent-commerce' }); + expect(res.json().payments.x402).toMatchObject({ + options: [ + { ...first, status: 'pass' }, + { ...second, status: 'fail' }, + ], + resourceOptions: { market_report: [{ ...own, status: 'pass' }] }, + }); + expect(res.payload).not.toContain('OPTION-RPC-KEY'); + expect(res.payload).not.toContain('SECRET-DETAIL'); + }); + it('GET /api/resources lists resources without leaking backend header secrets', async () => { const config = makeGatewayConfig(); const withSecretHeader = { @@ -529,7 +638,7 @@ describe('createGateway HTTP surface', () => { provider: 'x402', amount: '0.01', currency: 'USDC', - network: 'eip155:84532', + network: 'eip155:31337', payer: '0xBUYER', externalReference: 'tx-1', metadata: { amountBaseUnits: '10000' }, @@ -541,7 +650,7 @@ describe('createGateway HTTP surface', () => { expect(decodeHeader(res.headers[PAYMENT_RESPONSE_HEADER])).toMatchObject({ success: true, transaction: 'tx-1', - network: 'eip155:84532', + network: 'eip155:31337', payer: '0xBUYER', // The x402 settlement amount uses base units amount: '10000', @@ -759,7 +868,7 @@ describe('createGateway HTTP surface', () => { provider: 'x402', amount: '0.01', currency: 'USDC', - network: 'eip155:84532', + network: 'eip155:31337', payer: '0xBUYER', rejectionReason: 'insufficient_funds', }), @@ -772,7 +881,7 @@ describe('createGateway HTTP surface', () => { success: false, errorReason: 'insufficient_funds', transaction: '', - network: 'eip155:84532', + network: 'eip155:31337', payer: '0xBUYER', }); // x402 answers a refused settlement with PAYMENT-RESPONSE alone @@ -797,7 +906,7 @@ describe('createGateway HTTP surface', () => { success: false, errorReason: 'settlement_pending', transaction: '0xabc', - network: 'eip155:84532', + network: 'eip155:31337', }); expect(res.headers[PAYMENT_REQUIRED_HEADER]).toBeUndefined(); }); @@ -811,6 +920,19 @@ describe('createGateway HTTP surface', () => { }); expect(res.statusCode).toBe(503); expect(res.json().code).toBe('PAYMENT_PROVIDER_UNAVAILABLE'); + // An outage is no verdict, so the client may retry the same payment + expect(res.headers['retry-after']).toBe('5'); + }); + + it('sends no Retry-After on a refused payment', async () => { + const gateway = await buildGateway({ paymentProviders: [x402Provider()] }); + const res = await gateway.server.inject({ + method: 'POST', + url: '/api/resources/market_report/invoke', + payload: {}, + }); + expect(res.statusCode).toBe(402); + expect(res.headers['retry-after']).toBeUndefined(); }); it('returns 402 and a spent-nonce challenge for a settled replay', async () => { @@ -826,6 +948,64 @@ describe('createGateway HTTP surface', () => { }); }); + it.each([ + ['eip155:31337', 'invalid_exact_evm_nonce_already_used'], + ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', 'duplicate_settlement'], + ['near:testnet', 'invalid_exact_near_payload_delegate_action_nonce_already_used'], + ])('names the replay code of the paid network %s', async (network, code) => { + const provider = x402Provider({ + createRequirement: async (ctx) => ({ + id: 'requirement-1', + requestId: ctx.requestId, + resourceId: ctx.resource.id, + provider: 'x402', + amount: ctx.amount, + currency: ctx.currency, + network, + challenge: { + provider: 'x402', + version: '2', + accepts: [{ scheme: 'exact', network }], + envelope: { x402Version: 2, accepts: [{ scheme: 'exact', network }] }, + }, + }), + }); + const gateway = await buildGateway({ paymentProviders: [provider] }); + expect((await invokePaid(gateway)).statusCode).toBe(200); + + const replay = await invokePaid(gateway); + expect(replay.statusCode).toBe(402); + expect(replay.json().details).toMatchObject({ network }); + expect(decodeHeader(replay.headers[PAYMENT_REQUIRED_HEADER])).toMatchObject({ error: code }); + }); + + it('keeps the EVM replay code when the challenge names no network', async () => { + const provider = x402Provider({ + createRequirement: async (ctx) => ({ + id: 'requirement-1', + requestId: ctx.requestId, + resourceId: ctx.resource.id, + provider: 'x402', + amount: ctx.amount, + currency: ctx.currency, + challenge: { + provider: 'x402', + version: '2', + accepts: [{ scheme: 'exact' }], + envelope: { x402Version: 2, accepts: [{ scheme: 'exact' }] }, + }, + }), + }); + const gateway = await buildGateway({ paymentProviders: [provider] }); + expect((await invokePaid(gateway)).statusCode).toBe(200); + + const replay = await invokePaid(gateway); + expect(replay.json().details).not.toHaveProperty('network'); + expect(decodeHeader(replay.headers[PAYMENT_REQUIRED_HEADER])).toMatchObject({ + error: 'invalid_exact_evm_nonce_already_used', + }); + }); + it('returns 409 when the first payment attempt is unfinished', async () => { const store = createFakeStore(); store.attempts.set('replay-key-1', { @@ -846,6 +1026,7 @@ describe('createGateway HTTP surface', () => { // No new challenge: the first payment may still settle expect(replay.statusCode).toBe(409); expect(replay.json().code).toBe('PAYMENT_REPLAYED'); + expect(replay.json().details).toMatchObject({ network: 'eip155:31337' }); expect(replay.headers[PAYMENT_REQUIRED_HEADER]).toBeUndefined(); }); @@ -1053,8 +1234,8 @@ describe('createGateway HTTP surface', () => { enabled: true, network: 'eip155:84532', rpcUrl: 'http://127.0.0.1:8545', - asset: '0x1111111111111111111111111111111111111111', - assetName: 'MockUSDC', + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + assetName: 'USDC', assetVersion: '2', assetDecimals: 6, payTo: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', diff --git a/tests/unit/payments-mpp/provider.test.ts b/tests/unit/payments-mpp/provider.test.ts index f706155..fc1c9c5 100644 --- a/tests/unit/payments-mpp/provider.test.ts +++ b/tests/unit/payments-mpp/provider.test.ts @@ -86,7 +86,7 @@ function movableClock(start = Date.now()): Clock & { advance(ms: number): void } function x402Settlement(payTo = recipient, asset: `0x${string}` = ASSET): PaymentProvider { return createX402PaymentProvider({ - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: 'http://127.0.0.1:19321', // not contacted because the HTTP client is mocked asset, assetName: AUTHORIZATION.name, @@ -98,6 +98,7 @@ function x402Settlement(payTo = recipient, asset: `0x${string}` = ASSET): Paymen } const BASE_OPTIONS = { + network: 'eip155:31337', recipient, asset: ASSET, assetName: AUTHORIZATION.name, @@ -300,14 +301,14 @@ describe('MPP createRequirement', () => { expect(request.recipient).toBe(recipient); expect(request.currency).toBe(ASSET); expect(request.methodDetails).toMatchObject({ - chainId: 84532, + chainId: 31337, credentialTypes: ['authorization'], }); expect(requirement).toMatchObject({ provider: 'mpp', amount: '0.01', destination: recipient, - network: 'eip155:84532', + network: 'eip155:31337', asset: ASSET, expiresAt: '2026-09-23T12:05:00.000Z', }); @@ -366,23 +367,28 @@ describe('MPP createRequirement', () => { }); it.each([ - ['authorization by default', {}, true], - ['upfront for the rail', { paymentFlow: 'upfront' }, false], + ['upfront by default', {}, false], + ['authorization for the rail', { paymentFlow: 'authorization' }, true], [ - 'a per-resource upfront override', - { resourcePaymentFlows: { market_report: 'upfront' } }, + 'a per-resource authorization override', + { resourcePaymentFlows: { market_report: 'authorization' } }, + true, + ], + [ + 'a per-resource upfront override of an authorization rail', + { paymentFlow: 'authorization', resourcePaymentFlows: { market_report: 'upfront' } }, false, ], [ - 'a per-resource authorization override of an upfront rail', - { paymentFlow: 'upfront', resourcePaymentFlows: { market_report: 'authorization' } }, - true, + "another resource's override", + { resourcePaymentFlows: { other_report: 'authorization' } }, + false, ], - ["another resource's override", { resourcePaymentFlows: { other_report: 'upfront' } }, true], ] as const)( 'asks the pipeline to settle after the backend under %s', async (_label, flow, expected) => { const requirement = await requirementFor(makeProvider(flow)); + // No reuse marker: a challenge's credential is single-use, even unsettled expect(requirement.metadata).toEqual({ settleAfterBackend: expected }); }, ); @@ -457,12 +463,12 @@ describe('MPP verify', () => { payer: buyer.address, payee: recipient, amount: '0.01', - network: 'eip155:84532', + network: 'eip155:31337', asset: ASSET, }); const { nonce } = Credential.deserialize(credential).payload as { nonce: `0x${string}` }; expect(result.replayKey).toBe( - computeReplayKey({ chainId: 84532, asset: ASSET, from: buyer.address, nonce }), + computeReplayKey({ chainId: 31337, asset: ASSET, from: buyer.address, nonce }), ); expect(Method.broadcastCredential).not.toHaveBeenCalled(); expect(facilitator.verify).toHaveBeenCalledTimes(1); @@ -692,7 +698,7 @@ describe('MPP verify', () => { amount: '0.01', currency: ASSET, recipient, - chainId: 84532, + chainId: 31337, decimals: 6, credentialTypes: ['authorization'], ...request, @@ -807,7 +813,7 @@ describe('MPP verify', () => { const misattributed = await signedCredential( issuedChallenge(requirement), {}, - { source: `did:pkh:eip155:84532:${stranger.address}` }, + { source: `did:pkh:eip155:31337:${stranger.address}` }, ); const result = await verifyWith(provider, requirement, misattributed); expect(result.rejectionReason).toBe('source_mismatch'); @@ -817,8 +823,8 @@ describe('MPP verify', () => { const SETTLED = { success: true, - transaction: '0xabc', - network: 'eip155:84532', + transaction: `0x${'ab'.repeat(32)}`, + network: 'eip155:31337', }; async function verifiedPayment(provider = makeProvider()) { @@ -849,12 +855,12 @@ describe('MPP settle', () => { expect(result).toMatchObject({ status: 'settled', provider: 'mpp', - externalReference: '0xabc', + externalReference: `0x${'ab'.repeat(32)}`, payer: buyer.address, payee: recipient, amount: '0.01', currency: 'USDC', - network: 'eip155:84532', + network: 'eip155:31337', }); const { type: _type, @@ -868,7 +874,7 @@ describe('MPP settle', () => { expect(payload['payload']).toEqual({ signature, authorization }); expect(requirements).toMatchObject({ scheme: 'exact', - network: 'eip155:84532', + network: 'eip155:31337', amount: '10000', asset: ASSET, payTo: recipient, @@ -911,7 +917,7 @@ describe('MPP settle', () => { success: false, errorReason: 'transaction_failed', transaction: '', - network: 'eip155:84532', + network: 'eip155:31337', }); expect(await settle()).toMatchObject({ @@ -936,7 +942,7 @@ describe('MPP settle', () => { success: false, errorReason: 'settlement_pending', transaction: '0xpending', - network: 'eip155:84532', + network: 'eip155:31337', }); await expect(settle()).rejects.toSatisfy( @@ -1006,32 +1012,32 @@ describe('MPP through the execution pipeline', () => { expect(facilitator.settle).toHaveBeenCalledTimes(1); }); - it('settles after the backend by default and issues a receipt', async () => { + it('settles before the backend by default and issues a receipt', async () => { const provider = makeProvider(); const { pipeline, calls } = pipelineFor(provider); const credential = await clientCredential(await requirementFor(provider)); const outcome = await pipeline.execute(paidRequest(credential)); - expect(calls).toEqual(['backend', 'settle']); + expect(calls).toEqual(['settle', 'backend']); expect(outcome).toMatchObject({ kind: 'delivered', payment: { provider: 'mpp', status: 'settled', metadata: { receipt: expect.any(String) } }, }); }); - it('settles before the backend under upfront', async () => { - const provider = makeProvider({ paymentFlow: 'upfront' }); + it('settles after the backend under authorization', async () => { + const provider = makeProvider({ paymentFlow: 'authorization' }); const { pipeline, calls } = pipelineFor(provider); const credential = await clientCredential(await requirementFor(provider)); await pipeline.execute(paidRequest(credential)); - expect(calls).toEqual(['settle', 'backend']); + expect(calls).toEqual(['backend', 'settle']); }); - it('settles nothing when the backend fails, and refuses the credential afterwards', async () => { - const provider = makeProvider(); + it('settles nothing when the backend fails under authorization, and refuses the credential afterwards', async () => { + const provider = makeProvider({ paymentFlow: 'authorization' }); const { pipeline, store, calls } = pipelineFor(provider, { backendFails: true }); const credential = await clientCredential(await requirementFor(provider)); @@ -1067,7 +1073,7 @@ describe('MPP through the execution pipeline', () => { }); it('withholds the response when settlement is not confirmed after the backend ran', async () => { - const provider = makeProvider(); + const provider = makeProvider({ paymentFlow: 'authorization' }); const { pipeline, store, calls } = pipelineFor(provider); facilitator.settle.mockRejectedValueOnce(new Error('The operation timed out')); const credential = await clientCredential(await requirementFor(provider)); diff --git a/tests/unit/payments-x402/addresses.test.ts b/tests/unit/payments-x402/addresses.test.ts new file mode 100644 index 0000000..acd9f5b --- /dev/null +++ b/tests/unit/payments-x402/addresses.test.ts @@ -0,0 +1,129 @@ +// Address checks per family +import { describe, expect, it } from 'vitest'; +import { + isBurnAddress, + isTransactionReference, + normalizeAddress, + sameAddress, +} from '../../../src/payments/x402/addresses'; +import { requireX402Network } from '../../../src/payments/x402/networks'; + +const BASE = requireX402Network('eip155:8453', 'test'); +const SOLANA = requireX402Network('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 'test'); +const NEAR = requireX402Network('near:mainnet', 'test'); + +describe('EVM addresses', () => { + it('accepts 20 hex bytes in any letter case and keeps them as given', () => { + const mixed = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; + expect(normalizeAddress(BASE, mixed)).toBe(mixed); + expect(sameAddress(BASE, mixed, mixed.toLowerCase())).toBe(true); + }); + + it('refuses anything else, and knows the zero address', () => { + for (const bad of [ + '0x123', + `0x${'g'.repeat(40)}`, + 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + ]) { + expect(normalizeAddress(BASE, bad)).toBeUndefined(); + } + expect(isBurnAddress(BASE, `0x${'0'.repeat(40)}`)).toBe(true); + }); +}); + +describe('Solana addresses', () => { + it('accepts a base58 key of 32 bytes, case preserved and compared exactly', () => { + const mint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + expect(normalizeAddress(SOLANA, mint)).toBe(mint); + expect(sameAddress(SOLANA, mint, mint.toLowerCase())).toBe(false); + }); + + it('refuses characters outside base58 and keys of another length', () => { + // 0, O, I and l are not in the alphabet + expect( + normalizeAddress(SOLANA, 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt10'), + ).toBeUndefined(); + // 31 zero bytes, then 44 characters worth 33 bytes + expect(normalizeAddress(SOLANA, '1'.repeat(31))).toBeUndefined(); + expect(normalizeAddress(SOLANA, 'z'.repeat(44))).toBeUndefined(); + }); + + it('treats the all-zero key as a burn address', () => { + const zero = '1'.repeat(32); + expect(normalizeAddress(SOLANA, zero)).toBe(zero); + expect(isBurnAddress(SOLANA, zero)).toBe(true); + }); +}); + +describe('NEAR account IDs', () => { + // Examples from the NEAR protocol specification's account ID rules + it('accepts named accounts, subaccounts and implicit accounts', () => { + for (const id of [ + 'ok', + 'bowen', + 'ek-2', + 'ek.near', + 'com', + 'google.com', + 'bowen.google.com', + 'near', + 'illia.cheap-accounts.near', + 'max_99.near', + '100', + 'near2019', + 'over.9000', + 'a.bro', + 'bro.a', + // An implicit account and an Ethereum-implicit account + '17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1', + '0x06012c8cf97bead5deae237070f9587f8e7a266d', + 'a'.repeat(64), + ]) { + expect(normalizeAddress(NEAR, id), id).toBe(id); + } + }); + + it('refuses IDs that break the rule, whatever their suffix', () => { + for (const id of [ + 'a', + 'A', + 'Abc', + '-near', + 'near-', + '-near-', + 'near.', + '.near', + 'near..near', + 'near_-near', + 'near-_near', + 'near__near', + 'a b', + 'alice.near!', + 'a'.repeat(65), + ]) { + expect(normalizeAddress(NEAR, id), id).toBeUndefined(); + } + }); + + it('compares account IDs exactly and has no zero account', () => { + expect(sameAddress(NEAR, 'alice.near', 'alice.near')).toBe(true); + expect(isBurnAddress(NEAR, 'system')).toBe(false); + }); +}); + +describe('transaction references', () => { + it('reads each family in its own form', () => { + const near = 'FGgDFXFA3UCj533aWRMtV6iL3kVzLmgJKNt22HK9A7Z5'; + expect(isTransactionReference(NEAR, near)).toBe(true); + // A Solana signature is 64 bytes, not a 32-byte NEAR hash + expect( + isTransactionReference( + NEAR, + '61HQAvrUL3kcNhYW9nYLGYvVbY7fREZaNvpvTyoMEpEHXcMS2i4pF8ThAGH8QNHHvhUMor8GEffUdHLSp4qQJd83', + ), + ).toBe(false); + expect(isTransactionReference(NEAR, '')).toBe(false); + expect(isTransactionReference(BASE, `0x${'ab'.repeat(32)}`)).toBe(true); + expect(isTransactionReference(BASE, near)).toBe(false); + }); +}); diff --git a/tests/unit/payments-x402/client.test.ts b/tests/unit/payments-x402/client.test.ts index f4efeba..e521dc2 100644 --- a/tests/unit/payments-x402/client.test.ts +++ b/tests/unit/payments-x402/client.test.ts @@ -12,7 +12,7 @@ const PAY_TO = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const; // `PaymentRequired` envelope, and the price is `amount` const ACCEPTS = { scheme: 'exact', - network: 'eip155:84532', + network: 'eip155:31337', amount: '10000', payTo: PAY_TO, maxTimeoutSeconds: 60, @@ -39,7 +39,7 @@ describe('createPaymentProof', () => { const decoded = decode(proof); expect(decoded.x402Version).toBe(2); expect(decoded.accepted.scheme).toBe('exact'); - expect(decoded.accepted.network).toBe('eip155:84532'); + expect(decoded.accepted.network).toBe('eip155:31337'); expect(decoded.payload.authorization.from?.toLowerCase()).toBe(BUYER_ADDRESS.toLowerCase()); expect(decoded.payload.authorization.to?.toLowerCase()).toBe(PAY_TO.toLowerCase()); expect(decoded.payload.authorization.value).toBe('10000'); @@ -64,7 +64,7 @@ describe('createPaymentProof', () => { domain: { name: 'MockUSDC', version: '2', - chainId: 84532, + chainId: 31337, verifyingContract: ASSET, }, types: { @@ -147,6 +147,15 @@ describe('createPaymentProof', () => { ); }); + it('refuses an offer that asks for another transfer method', async () => { + await expect( + createPaymentProof({ + buyerPrivateKey: BUYER_PRIVATE_KEY, + accepts: { ...ACCEPTS, extra: { ...ACCEPTS.extra, assetTransferMethod: 'permit2' } }, + }), + ).rejects.toThrow(/signs EIP-3009 only, and this offer uses "permit2"/); + }); + it('throws a clear error when extra.name/extra.version are missing', async () => { const { extra, ...withoutExtra } = ACCEPTS; await expect( diff --git a/tests/unit/payments-x402/facilitator-isolation.test.ts b/tests/unit/payments-x402/facilitator-isolation.test.ts new file mode 100644 index 0000000..7b7fb4d --- /dev/null +++ b/tests/unit/payments-x402/facilitator-isolation.test.ts @@ -0,0 +1,98 @@ +/** + * Verify that the remote binding loads without the EVM stack and preserves + * `/supported` metadata. The mocks throw if an import loads an EVM dependency + */ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock('viem', () => { + throw new Error('viem was loaded'); +}); +vi.mock('@x402/evm', () => { + throw new Error('@x402/evm was loaded'); +}); +vi.mock('@x402/evm/exact/facilitator', () => { + throw new Error('@x402/evm/exact/facilitator was loaded'); +}); +vi.mock('@x402/core/facilitator', () => { + throw new Error('@x402/core/facilitator was loaded'); +}); + +// Trimmed x402.org response: retain the Solana fee payer needed to build offers +const SUPPORTED = { + kinds: [ + { x402Version: 2, scheme: 'exact', network: 'eip155:84532' }, + { + x402Version: 2, + scheme: 'exact', + network: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + extra: { feePayer: 'CKPKJWNdJEqa81x7CkZ14BVPiY6y16Sxs7owznqtWYp5' }, + }, + ], + extensions: ['eip2612GasSponsoring'], + signers: { 'solana:*': ['CKPKJWNdJEqa81x7CkZ14BVPiY6y16Sxs7owznqtWYp5'] }, +}; + +let server: Server; +let url: string; + +beforeAll(async () => { + server = createServer((req, res) => { + // A facilitator that rate-limits `/supported` and asks for a long wait + if (req.url === '/limited/supported') { + res.statusCode = 429; + res.setHeader('retry-after', '30'); + res.end(); + return; + } + if (req.url === '/supported') { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(SUPPORTED)); + return; + } + res.statusCode = 404; + res.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(() => new Promise((resolve) => server.close(() => resolve()))); + +describe('remote facilitator binding', () => { + it('loads without the EVM stack and keeps the whole /supported answer', async () => { + const { createRemoteFacilitatorBinding } = await import( + '../../../src/payments/x402/facilitator' + ); + const binding = createRemoteFacilitatorBinding({ url, auth: { type: 'none' } }); + if (binding.kind !== 'remote') throw new Error('expected a remote binding'); + expect(await binding.supported()).toEqual(SUPPORTED); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // The SDK retries a 429 after its Retry-After; readiness and Solana + // challenges wait on `/supported`, so the binding bounds the whole read + it('gives up on /supported at its deadline, retry waits included', async () => { + const { createRemoteFacilitatorBinding, SUPPORTED_TIMEOUT_MS } = await import( + '../../../src/payments/x402/facilitator' + ); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const binding = createRemoteFacilitatorBinding({ + url: `${url}/limited`, + auth: { type: 'none' }, + }); + if (binding.kind !== 'remote') throw new Error('expected a remote binding'); + + const read = binding.supported(); + const outcome = expect(read).rejects.toMatchObject({ + code: 'PAYMENT_PROVIDER_UNAVAILABLE', + message: expect.stringContaining('did not answer within 10 s'), + }); + await vi.advanceTimersByTimeAsync(SUPPORTED_TIMEOUT_MS); + await outcome; + }); +}); diff --git a/tests/unit/payments-x402/local-chain.test.ts b/tests/unit/payments-x402/local-chain.test.ts index e9c32cd..1cff116 100644 --- a/tests/unit/payments-x402/local-chain.test.ts +++ b/tests/unit/payments-x402/local-chain.test.ts @@ -113,7 +113,7 @@ describeWithAnvil('deployLocalChain (real ephemeral anvil)', () => { it('deploys fresh, mints exactly the target buyer balance, and reports freshlyDeployed', async () => { const result = await deployLocalChain({ rpcUrl: anvil.rpcUrl, buyerInitialBalance: '10.00' }); expect(result.freshlyDeployed).toBe(true); - expect(result.chainId).toBe(84532); + expect(result.chainId).toBe(31337); expect(result.assetDecimals).toBe(6); expect(result.asset).toMatch(/^0x[0-9a-fA-F]{40}$/); expect(await balanceOf(result.asset, result.buyer.address)).toBe(10_000_000n); @@ -157,8 +157,9 @@ describeWithAnvil('deployLocalChain (real ephemeral anvil)', () => { }); describeWithAnvil('deployLocalChain - wrong chain id', () => { - it('rejects when pointed at a chain that is not id 84532', async () => { - const wrongChain = await startAnvil({ port: 18714, chainId: 31337, silent: true }); + it('rejects when pointed at a chain that is not the local id 31337', async () => { + // Base Sepolia's id, the one the local chain used to share + const wrongChain = await startAnvil({ port: 18714, chainId: 84532, silent: true }); try { await expect( deployLocalChain({ rpcUrl: wrongChain.rpcUrl, buyerInitialBalance: '1.00' }), diff --git a/tests/unit/payments-x402/manifest.test.ts b/tests/unit/payments-x402/manifest.test.ts index 456f947..56610e6 100644 --- a/tests/unit/payments-x402/manifest.test.ts +++ b/tests/unit/payments-x402/manifest.test.ts @@ -9,7 +9,7 @@ import { } from '../../../src/payments/x402/local-chain/manifest'; const VALID_MANIFEST: LocalChainManifest = { - chainId: 84532, + chainId: 31337, rpcUrl: 'http://127.0.0.1:8545', asset: '0x5FbDB2315678afecb367f032d93F642f64180aa3', assetName: 'MockUSDC', diff --git a/tests/unit/payments-x402/near.test.ts b/tests/unit/payments-x402/near.test.ts new file mode 100644 index 0000000..d41382a --- /dev/null +++ b/tests/unit/payments-x402/near.test.ts @@ -0,0 +1,150 @@ +import { KeyPair } from '@near-js/crypto'; +import { actionCreators } from '@near-js/transactions'; +import { describe, expect, it } from 'vitest'; +import { inspectNearPayload, type NearTransferTerms } from '../../../src/payments/x402/near'; +import { computeNearReplayKey } from '../../../src/payments/x402/replay-key'; +import { buildSignedDelegate, DELEGATE_DEFAULTS } from '../../fixtures/x402/near-delegate'; + +const TERMS: NearTransferTerms = { + network: 'near:testnet', + asset: DELEGATE_DEFAULTS.receiverId, + payTo: DELEGATE_DEFAULTS.ftReceiver, + amount: DELEGATE_DEFAULTS.amount, +}; + +const inspect = (signedDelegateAction: unknown, terms = TERMS) => + inspectNearPayload({ signedDelegateAction }, terms); + +describe('inspectNearPayload', () => { + it('reads the payer and keys the payment by sender, key and nonce', async () => { + const { signedDelegateAction, keyPair } = await buildSignedDelegate(); + expect(inspect(signedDelegateAction)).toEqual({ + payer: DELEGATE_DEFAULTS.senderId, + replayKey: computeNearReplayKey({ + network: 'near:testnet', + senderId: DELEGATE_DEFAULTS.senderId, + publicKey: keyPair.getPublicKey().toString(), + nonce: DELEGATE_DEFAULTS.nonce, + }), + }); + }); + + it('reads a secp256k1 key with its curve', async () => { + const keyPair = KeyPair.fromRandom('secp256k1'); + const { signedDelegateAction } = await buildSignedDelegate({ keyPair }); + expect(keyPair.getPublicKey().toString()).toMatch(/^secp256k1:/); + expect(inspect(signedDelegateAction)).toMatchObject({ payer: DELEGATE_DEFAULTS.senderId }); + }); + + it('gives the same key to another delegate of the same sender, key and nonce', async () => { + // NEAR consumes the nonce, whatever else the delegate says + const keyPair = KeyPair.fromRandom('ed25519'); + const first = await buildSignedDelegate({ keyPair }); + const other = await buildSignedDelegate({ keyPair, gas: 40_000_000_000_000n }); + const otherAsset = await buildSignedDelegate({ keyPair, receiverId: 'usdt.testnet' }); + const key = (inspect(first.signedDelegateAction) as { replayKey: string }).replayKey; + expect((inspect(other.signedDelegateAction) as { replayKey: string }).replayKey).toBe(key); + expect( + ( + inspect(otherAsset.signedDelegateAction, { ...TERMS, asset: 'usdt.testnet' }) as { + replayKey: string; + } + ).replayKey, + ).toBe(key); + }); + + it('keeps another access key, nonce or network apart', async () => { + const keyPair = KeyPair.fromRandom('ed25519'); + const key = ( + inspect((await buildSignedDelegate({ keyPair })).signedDelegateAction) as { + replayKey: string; + } + ).replayKey; + const otherKey = await buildSignedDelegate(); + const otherNonce = await buildSignedDelegate({ keyPair, nonce: 6n }); + const mainnet = inspect((await buildSignedDelegate({ keyPair })).signedDelegateAction, { + ...TERMS, + network: 'near:mainnet', + }); + for (const result of [ + inspect(otherKey.signedDelegateAction), + inspect(otherNonce.signedDelegateAction), + mainnet, + ]) { + expect((result as { replayKey: string }).replayKey).not.toBe(key); + } + }); + + it.each<[string, Parameters[0], string]>([ + [ + 'another token contract', + { receiverId: 'usdt.testnet' }, + 'invalid_exact_near_payload_token_contract_mismatch', + ], + [ + 'another recipient', + { ftReceiver: 'attacker.testnet' }, + 'invalid_exact_near_payload_recipient_mismatch', + ], + ['a smaller amount', { amount: '9999' }, 'invalid_exact_near_payload_amount_mismatch'], + ['a padded amount', { amount: '010000' }, 'invalid_exact_near_payload_amount_mismatch'], + [ + 'another method', + { methodName: 'ft_transfer_call' }, + 'invalid_exact_near_payload_method_name', + ], + ['no deposit', { deposit: 0n }, 'invalid_exact_near_payload_attached_deposit'], + ['two yoctoNEAR', { deposit: 2n }, 'invalid_exact_near_payload_attached_deposit'], + ])('refuses %s', async (_label, options, reason) => { + expect(inspect((await buildSignedDelegate(options)).signedDelegateAction)).toBe(reason); + }); + + it('refuses an extra action, a non-call action and no action', async () => { + const transfer = actionCreators.functionCall( + 'ft_transfer', + { receiver_id: TERMS.payTo, amount: TERMS.amount }, + 30_000_000_000_000n, + 1n, + ); + expect( + inspect((await buildSignedDelegate({ actions: [transfer, transfer] })).signedDelegateAction), + ).toBe('invalid_exact_near_payload_action_count'); + expect( + inspect( + (await buildSignedDelegate({ actions: [actionCreators.transfer(1n)] })) + .signedDelegateAction, + ), + ).toBe('invalid_exact_near_payload_action_kind'); + expect(inspect((await buildSignedDelegate({ actions: [] })).signedDelegateAction)).toBe( + 'invalid_exact_near_payload_action_count', + ); + }); + + it('refuses arguments that are not an ft_transfer', async () => { + const call = actionCreators.functionCall('ft_transfer', { amount: TERMS.amount }, 30n, 1n); + expect(inspect((await buildSignedDelegate({ actions: [call] })).signedDelegateAction)).toBe( + 'invalid_exact_near_payload_ft_transfer_args', + ); + }); + + it('refuses a payload that is not one bounded, decodable signed delegate', async () => { + const { signedDelegateAction } = await buildSignedDelegate(); + const bytes = Buffer.from(signedDelegateAction, 'base64'); + expect(inspectNearPayload('x', TERMS)).toBe('invalid_exact_near_payload_shape'); + expect(inspectNearPayload({}, TERMS)).toBe('invalid_exact_near_payload_shape'); + expect(inspect('A'.repeat(4097))).toBe('invalid_exact_near_payload_shape'); + // The same bytes in another base64 spelling + expect(inspect(signedDelegateAction.replace(/=+$/, ''))).toBe( + 'invalid_exact_near_payload_signed_delegate_action', + ); + for (const malformed of [ + bytes.subarray(0, bytes.length - 10), + Buffer.concat([bytes, Buffer.from([0])]), + Buffer.from('not a delegate'), + ]) { + expect(inspect(malformed.toString('base64'))).toBe( + 'invalid_exact_near_payload_signed_delegate_action', + ); + } + }); +}); diff --git a/tests/unit/payments-x402/offer-currency.test.ts b/tests/unit/payments-x402/offer-currency.test.ts new file mode 100644 index 0000000..53d37a6 --- /dev/null +++ b/tests/unit/payments-x402/offer-currency.test.ts @@ -0,0 +1,29 @@ +// The currency a payment records: the token an offer settles, not the price's label +import { describe, expect, it } from 'vitest'; +import { requireX402Network } from '../../../src/payments/x402/networks'; +import { offerCurrency } from '../../../src/payments/x402/provider/offers'; +import type { Offer } from '../../../src/payments/x402/provider/types'; + +const offer = (network: string, asset: string) => + ({ profile: requireX402Network(network, 'test'), asset }) as unknown as Offer; + +describe('offerCurrency', () => { + it("names the network's default token, whatever the price says", () => { + const usdc = '0x036CbD53842c5426634e7929541eC2318f3dCF7e'; + expect(offerCurrency(offer('eip155:84532', usdc), 'USD')).toBe('USDC'); + // EVM addresses compare without case + expect(offerCurrency(offer('eip155:84532', usdc.toLowerCase()), 'USD')).toBe('USDC'); + expect( + offerCurrency( + offer('near:testnet', '3e2210e1184b45b64c8a434c0a7e7b23cc04ea7eb7a6c3c32520d03d4afcb8af'), + 'USD', + ), + ).toBe('USDC'); + }); + + it("keeps the price's currency for a token it has no symbol for", () => { + expect(offerCurrency(offer('eip155:84532', `0x${'12'.repeat(20)}`), 'EURC')).toBe('EURC'); + // The local chain has no default token + expect(offerCurrency(offer('eip155:31337', `0x${'12'.repeat(20)}`), 'USD')).toBe('USD'); + }); +}); diff --git a/tests/unit/payments-x402/options.test.ts b/tests/unit/payments-x402/options.test.ts new file mode 100644 index 0000000..6aae251 --- /dev/null +++ b/tests/unit/payments-x402/options.test.ts @@ -0,0 +1,86 @@ +// Options of every family resolve here, before config load applies its guardrails +import { describe, expect, it } from 'vitest'; +import { resolveX402Option } from '../../../src/payments/x402/options'; + +const SOLANA_DEVNET = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'; +const SOLANA_PAYEE = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'; +const NEAR_TESTNET = 'near:testnet'; +const NEAR_PAYEE = 'merchant.testnet'; + +describe('resolveX402Option', () => { + it('fills a Solana option from the network USDC mint, with no EIP-712 domain', () => { + const option = resolveX402Option({ network: SOLANA_DEVNET, payTo: SOLANA_PAYEE }, 'opt'); + expect(option).toMatchObject({ + scheme: 'exact', + payTo: SOLANA_PAYEE, + asset: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + assetDecimals: 6, + defaultAssetSymbol: 'USDC', + }); + expect(option.network.family).toBe('svm'); + expect(option.assetName).toBeUndefined(); + }); + + it('refuses an EIP-712 domain on a Solana option', () => { + expect(() => + resolveX402Option({ network: SOLANA_DEVNET, payTo: SOLANA_PAYEE, assetName: 'USDC' }, 'opt'), + ).toThrow(/EVM tokens only/); + }); + + it('fills a NEAR option from the network USDC contract, an implicit account', () => { + const option = resolveX402Option({ network: NEAR_TESTNET, payTo: NEAR_PAYEE }, 'opt'); + expect(option).toMatchObject({ + payTo: NEAR_PAYEE, + asset: '3e2210e1184b45b64c8a434c0a7e7b23cc04ea7eb7a6c3c32520d03d4afcb8af', + assetDecimals: 6, + defaultAssetSymbol: 'USDC', + }); + expect(option.network.family).toBe('near'); + }); + + it('refuses a payee that is not a NEAR account ID, and an EIP-712 domain', () => { + expect(() => + resolveX402Option({ network: NEAR_TESTNET, payTo: 'Merchant.testnet' }, 'opt'), + ).toThrow(/NEAR account ID/); + expect(() => + resolveX402Option({ network: NEAR_TESTNET, payTo: NEAR_PAYEE, assetVersion: '1' }, 'opt'), + ).toThrow(/EVM tokens only/); + }); + + it('needs decimals for a token other than the default', () => { + expect(() => + resolveX402Option({ network: NEAR_TESTNET, payTo: NEAR_PAYEE, asset: 'usdt.testnet' }, 'opt'), + ).toThrow(/needs assetDecimals/); + }); + + describe('assetTransferMethod', () => { + const LOCAL = { + network: 'eip155:31337', + payTo: '0x1111111111111111111111111111111111111111', + asset: '0x5FbDB2315678afecb367f032d93F642f64180aa3', + assetDecimals: 18, + }; + + it('needs no EIP-712 domain for a Permit2 token', () => { + const option = resolveX402Option({ ...LOCAL, assetTransferMethod: 'permit2' }, 'opt'); + expect(option.assetTransferMethod).toBe('permit2'); + expect(option.assetName).toBeUndefined(); + }); + + it('still needs the domain for an EIP-3009 token, the default method', () => { + expect(() => resolveX402Option(LOCAL, 'opt')).toThrow(/assetName/); + }); + + it('refuses an unknown method and any method off EVM', () => { + expect(() => resolveX402Option({ ...LOCAL, assetTransferMethod: 'permit' }, 'opt')).toThrow( + /opt\.assetTransferMethod: "permit" is not supported/, + ); + expect(() => + resolveX402Option( + { network: SOLANA_DEVNET, payTo: SOLANA_PAYEE, assetTransferMethod: 'permit2' }, + 'opt', + ), + ).toThrow(/EVM networks only/); + }); + }); +}); diff --git a/tests/unit/payments-x402/provider-health-mocked.test.ts b/tests/unit/payments-x402/provider-health-mocked.test.ts index 423ea2a..8133d99 100644 --- a/tests/unit/payments-x402/provider-health-mocked.test.ts +++ b/tests/unit/payments-x402/provider-health-mocked.test.ts @@ -47,6 +47,7 @@ function mockAsGenuineAnvilNode(): void { } const ASSET = '0x5FbDB2315678afecb367f032d93F642f64180aa3' as const; +const BASE_SEPOLIA_USDC = '0x036CbD53842c5426634e7929541eC2318f3dCF7e' as const; const PAY_TO = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const; // A dev payTo is refused on any non-local deployment, so remote mode needs another const REMOTE_PAY_TO = '0x1111111111111111111111111111111111111111' as const; @@ -59,11 +60,13 @@ async function makeProvider( rpcUrl = 'http://127.0.0.1:19322', ) { const { createX402PaymentProvider } = await import('../../../src/payments/x402/provider'); + // Remote mode stands for public Base Sepolia, which settles only in its USDC + const local = facilitatorMode === 'local'; return createX402PaymentProvider({ - network: 'eip155:84532', + network: local ? 'eip155:31337' : 'eip155:84532', rpcUrl, - asset: ASSET, - assetName: 'MockUSDC', + asset: local ? ASSET : BASE_SEPOLIA_USDC, + assetName: local ? 'MockUSDC' : 'USDC', assetVersion: '2', assetDecimals: 6, payTo: facilitatorMode === 'local' ? PAY_TO : REMOTE_PAY_TO, @@ -96,7 +99,7 @@ describe('health() - mocked RPC client', () => { }); it('passes when chain id matches, the asset has code, and the RPC confirms it is Anvil', async () => { - getChainIdMock.mockResolvedValueOnce(84532); + getChainIdMock.mockResolvedValueOnce(31337); getCodeMock.mockResolvedValueOnce('0x6080604052'); mockAsGenuineAnvilNode(); const provider = await makeProvider(); @@ -104,8 +107,8 @@ describe('health() - mocked RPC client', () => { expect(health.status).toBe('pass'); }); - it('fails when the chain id matches (84532) but the RPC does not answer anvil_nodeInfo - e.g. real Base Sepolia', async () => { - getChainIdMock.mockResolvedValueOnce(84532); + it('fails when the chain id matches but the RPC does not answer anvil_nodeInfo - e.g. a Hardhat node on 31337', async () => { + getChainIdMock.mockResolvedValueOnce(31337); getCodeMock.mockResolvedValueOnce('0x6080604052'); requestMock.mockRejectedValueOnce(new Error('the method anvil_nodeInfo does not exist')); const provider = await makeProvider(); @@ -117,9 +120,11 @@ describe('health() - mocked RPC client', () => { it('passes for a remote facilitator that advertises our scheme and network, without probing anvil_nodeInfo', async () => { getChainIdMock.mockResolvedValueOnce(84532); getCodeMock.mockResolvedValueOnce('0x6080604052'); - supportedMock.mockResolvedValueOnce([ - { x402Version: 2, scheme: 'exact', network: 'eip155:84532' }, - ]); + supportedMock.mockResolvedValueOnce({ + kinds: [{ x402Version: 2, scheme: 'exact', network: 'eip155:84532' }], + extensions: [], + signers: {}, + }); const provider = await makeProvider('remote'); const health = await provider.health(); expect(health.status).toBe('pass'); @@ -134,9 +139,11 @@ describe('health() - mocked RPC client', () => { // after the buyer has signed getChainIdMock.mockResolvedValueOnce(84532); getCodeMock.mockResolvedValueOnce('0x6080604052'); - supportedMock.mockResolvedValueOnce([ - { x402Version: 2, scheme: 'exact', network: 'eip155:8453' }, - ]); + supportedMock.mockResolvedValueOnce({ + kinds: [{ x402Version: 2, scheme: 'exact', network: 'eip155:8453' }], + extensions: [], + signers: {}, + }); const provider = await makeProvider('remote'); const health = await provider.health(); expect(health.status).toBe('fail'); @@ -177,7 +184,7 @@ describe('health() - mocked RPC client', () => { }); it('fails when the asset address has no code', async () => { - getChainIdMock.mockResolvedValueOnce(84532); + getChainIdMock.mockResolvedValueOnce(31337); getCodeMock.mockResolvedValueOnce('0x'); const provider = await makeProvider(); const health = await provider.health(); @@ -186,7 +193,7 @@ describe('health() - mocked RPC client', () => { }); it('fails when getCode returns undefined', async () => { - getChainIdMock.mockResolvedValueOnce(84532); + getChainIdMock.mockResolvedValueOnce(31337); getCodeMock.mockResolvedValueOnce(undefined); const provider = await makeProvider(); const health = await provider.health(); @@ -202,13 +209,76 @@ describe('health() - mocked RPC client', () => { }); }); +describe('health() - several offers', () => { + const OTHER_ASSET = '0x2222222222222222222222222222222222222222' as const; + + beforeEach(() => { + vi.clearAllMocks(); + getChainIdMock.mockResolvedValue(31337); + requestMock.mockResolvedValue({ clientVersion: 'anvil/v1.1.0' }); + getCodeMock.mockResolvedValue('0x6080604052'); + }); + + async function makeTwoOfferProvider() { + const { createX402PaymentProvider } = await import('../../../src/payments/x402/provider'); + const offer = (asset: string) => ({ + network: 'eip155:31337', + rpcUrl: 'http://127.0.0.1:19322', + asset, + assetName: 'MockUSDC', + assetVersion: '2', + assetDecimals: 6, + payTo: PAY_TO, + }); + return createX402PaymentProvider({ + offers: [offer(ASSET), offer(OTHER_ASSET)], + facilitator: { mode: 'local', signerPrivateKey: BUYER_PRIVATE_KEY }, + }); + } + + it('checks every offer and reports each as a component', async () => { + getCodeMock.mockImplementation(async ({ address }: { address: string }) => + address === OTHER_ASSET ? '0x' : '0x6080604052', + ); + const health = await (await makeTwoOfferProvider()).health(); + + expect(health.status).toBe('fail'); + // Case variants of EVM addresses share a component name + const payTo = PAY_TO.toLowerCase(); + expect(health.components).toEqual([ + { name: `eip155:31337 ${ASSET.toLowerCase()} ${payTo}`, status: 'pass' }, + { name: `eip155:31337 ${OTHER_ASSET} ${payTo}`, status: 'fail' }, + ]); + // Detail includes only the failing offer + expect(health.detail).toMatch(new RegExp(`^eip155:31337 ${OTHER_ASSET} ${payTo}: No contract`)); + expect(health.detail).not.toContain(ASSET.toLowerCase()); + }); + + it('fails only the offer whose check threw', async () => { + getChainIdMock.mockRejectedValueOnce(new Error('boom')); + const health = await (await makeTwoOfferProvider()).health(); + + expect(health.status).toBe('fail'); + expect(health.components?.map((component) => component.status)).toEqual(['fail', 'pass']); + expect(health.detail).toContain('boom'); + }); + + it('passes with every offer named in the detail', async () => { + const health = await (await makeTwoOfferProvider()).health(); + + expect(health.status).toBe('pass'); + expect(health.components?.map((component) => component.status)).toEqual(['pass', 'pass']); + expect(health.detail).toContain(OTHER_ASSET); + }); +}); + describe('health() - caching / single-flight', () => { beforeEach(() => { vi.clearAllMocks(); }); it('collapses a burst of concurrent calls into a single underlying probe', async () => { - getChainIdMock.mockResolvedValueOnce(84532); + getChainIdMock.mockResolvedValueOnce(31337); getCodeMock.mockResolvedValueOnce('0x6080604052'); mockAsGenuineAnvilNode(); const provider = await makeProvider(); @@ -222,7 +292,7 @@ describe('health() - caching / single-flight', () => { }); it('serves the cached result for a second call within the TTL - no new RPC round-trip', async () => { - getChainIdMock.mockResolvedValueOnce(84532); + getChainIdMock.mockResolvedValueOnce(31337); getCodeMock.mockResolvedValueOnce('0x6080604052'); mockAsGenuineAnvilNode(); const { clock, advance } = makeFakeClock(); @@ -237,7 +307,7 @@ describe('health() - caching / single-flight', () => { }); it('issues a fresh probe once the TTL elapses', async () => { - getChainIdMock.mockResolvedValueOnce(84532); + getChainIdMock.mockResolvedValueOnce(31337); getCodeMock.mockResolvedValueOnce('0x6080604052'); mockAsGenuineAnvilNode(); const { clock, advance } = makeFakeClock(); @@ -254,7 +324,7 @@ describe('health() - caching / single-flight', () => { expect(second.status).toBe('fail'); }); - it('caches a fail result for the same TTL as a pass - symmetric on purpose (see the health cache comment in provider.ts)', async () => { + it('caches a fail result for the same TTL as a pass - symmetric on purpose (see the health cache comment in provider/health.ts)', async () => { getChainIdMock.mockRejectedValueOnce(new Error('connection refused')); const { clock, advance } = makeFakeClock(); const provider = await makeProvider('local', clock); diff --git a/tests/unit/payments-x402/provider-near.test.ts b/tests/unit/payments-x402/provider-near.test.ts new file mode 100644 index 0000000..e46b6f0 --- /dev/null +++ b/tests/unit/payments-x402/provider-near.test.ts @@ -0,0 +1,425 @@ +/** + * Tests NEAR offers, delegate validation, settlement classification and RPC + * readiness, with the remote facilitator mocked at the SDK HTTP client. + */ +import { KeyPair } from '@near-js/crypto'; +import type { SupportedResponse } from '@x402/core/types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CommerceError, CommerceResource, PaymentContext } from '../../../src/core'; +import { isCommerceError } from '../../../src/core'; +import { + createX402PaymentProvider, + type X402MultiOfferProviderOptions, +} from '../../../src/payments/x402/provider'; +import { computeNearReplayKey } from '../../../src/payments/x402/replay-key'; +import { buildSignedDelegate } from '../../fixtures/x402/near-delegate'; + +const verifyMock = vi.fn(); +const settleMock = vi.fn(); +const getSupportedMock = vi.fn(); + +vi.mock('@x402/core/http', () => ({ + FacilitatorResponseError: class extends Error {}, + HTTPFacilitatorClient: class { + verify(...args: unknown[]) { + return verifyMock(...args); + } + settle(...args: unknown[]) { + return settleMock(...args); + } + getSupported() { + return getSupportedMock(); + } + }, +})); + +const NETWORK = 'near:testnet'; +const USDC = '3e2210e1184b45b64c8a434c0a7e7b23cc04ea7eb7a6c3c32520d03d4afcb8af'; +const PAY_TO = 'merchant.testnet'; +const BUYER = 'buyer.testnet'; +const RPC_URL = 'https://near-testnet.example'; +const TX_HASH = 'FGgDFXFA3UCj533aWRMtV6iL3kVzLmgJKNt22HK9A7Z5'; + +const RESOURCE: CommerceResource = { + id: 'demo.report', + name: 'Demo report', + handler: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: '0.01', currency: 'USD' }, + exposedVia: ['http'], + paymentMethods: ['x402'], +}; + +const paymentContext = (currency = 'USD'): PaymentContext => ({ + requestId: 'req-1', + resource: RESOURCE, + amount: '0.01', + currency, + requestedAt: new Date().toISOString(), +}); + +const supported = (network: `${string}:${string}` = NETWORK): SupportedResponse => ({ + kinds: [{ x402Version: 2, scheme: 'exact', network }], + extensions: [], + signers: { 'near:*': ['relayer.testnet'] }, +}); + +function makeProvider(overrides: Partial = {}) { + return createX402PaymentProvider({ + offers: [{ network: NETWORK, rpcUrl: RPC_URL, asset: USDC, assetDecimals: 6, payTo: PAY_TO }], + facilitator: { mode: 'remote', url: 'https://facilitator.example', auth: { type: 'none' } }, + ...overrides, + }); +} + +async function paidRequest( + provider: ReturnType, + delegate: Parameters[0] = {}, + currency?: string, +) { + const requirement = await provider.createRequirement(paymentContext(currency)); + const accepted = requirement.challenge.accepts[0] as Record; + const keyPair = delegate.keyPair ?? KeyPair.fromRandom('ed25519'); + const { signedDelegateAction } = await buildSignedDelegate({ + senderId: BUYER, + ftReceiver: PAY_TO, + ...delegate, + keyPair, + }); + const submission = { + method: 'x402' as const, + payload: Buffer.from( + JSON.stringify({ x402Version: 2, accepted, payload: { signedDelegateAction } }), + ).toString('base64'), + }; + return { + keyPair, + context: { requestId: 'req-1', resource: RESOURCE, requirement, submission }, + }; +} + +const unavailable = (err: unknown): err is CommerceError => + isCommerceError(err) && err.code === 'PAYMENT_PROVIDER_UNAVAILABLE'; + +beforeEach(() => { + vi.resetAllMocks(); + getSupportedMock.mockResolvedValue(supported()); +}); + +describe('a NEAR offer', () => { + it('carries no scheme-specific extra and names the family', async () => { + const provider = makeProvider(); + const requirement = await provider.createRequirement(paymentContext()); + expect(requirement.challenge.accepts[0]).toMatchObject({ + scheme: 'exact', + network: NETWORK, + asset: USDC, + payTo: PAY_TO, + amount: '10000', + extra: {}, + }); + expect(provider.descriptor.supportedSpec).toContain('family=near'); + expect(provider.descriptor.capabilities).toContain('exact-near'); + }); + + it.each<[string, Partial, RegExp]>([ + ['a payee that is not an account ID', { payTo: 'Merchant.testnet' }, /payTo.*NEAR account ID/], + ['an EVM transfer method', { assetTransferMethod: 'permit2' }, /EVM networks only/], + ])('refuses %s at construction', (_label, change, message) => { + expect(() => + makeProvider({ + offers: [ + { + network: NETWORK, + rpcUrl: RPC_URL, + asset: USDC, + assetDecimals: 6, + payTo: PAY_TO, + ...change, + }, + ], + }), + ).toThrow(message); + }); + + // The NEAR facilitator refuses every payment under any other timeout + it.each([0, -1, 1.5])('refuses maxTimeoutSeconds %s', (maxTimeoutSeconds) => { + expect(() => makeProvider({ maxTimeoutSeconds })).toThrow(/maxTimeoutSeconds/); + }); + + it('refuses the in-process facilitator', () => { + expect(() => + makeProvider({ facilitator: { mode: 'local', signerPrivateKey: `0x${'11'.repeat(32)}` } }), + ).toThrow(/serves the local Anvil chain/); + }); +}); + +describe('verify()', () => { + it('passes a delegate for the offered transfer on, keyed by sender, key and nonce', async () => { + const provider = makeProvider(); + const { context, keyPair } = await paidRequest(provider); + verifyMock.mockResolvedValueOnce({ isValid: true, payer: BUYER }); + + expect(await provider.verify(context)).toMatchObject({ + status: 'verified', + payer: BUYER, + payee: PAY_TO, + amount: '0.01', + network: NETWORK, + asset: USDC, + replayKey: computeNearReplayKey({ + network: NETWORK, + senderId: BUYER, + publicKey: keyPair.getPublicKey().toString(), + nonce: 5n, + }), + }); + }); + + it.each<[string, Parameters[0], string]>([ + [ + 'another recipient', + { ftReceiver: 'attacker.testnet' }, + 'invalid_exact_near_payload_recipient_mismatch', + ], + [ + 'another token', + { receiverId: 'usdt.testnet' }, + 'invalid_exact_near_payload_token_contract_mismatch', + ], + ['another amount', { amount: '9999' }, 'invalid_exact_near_payload_amount_mismatch'], + ['no deposit', { deposit: 0n }, 'invalid_exact_near_payload_attached_deposit'], + ])( + 'refuses a delegate for %s before the facilitator sees it', + async (_label, delegate, reason) => { + const provider = makeProvider(); + const { context } = await paidRequest(provider, delegate); + expect((await provider.verify(context)).rejectionReason).toBe(reason); + expect(verifyMock).not.toHaveBeenCalled(); + }, + ); + + it.each([ + 'invalid_exact_near_access_key_lookup_failed', + 'invalid_exact_near_current_block_height_unavailable', + 'unexpected_verify_error', + ])('treats %s, the facilitator failing to read the chain, as no verdict', async (reason) => { + const provider = makeProvider(); + const { context } = await paidRequest(provider); + verifyMock.mockResolvedValueOnce({ isValid: false, invalidReason: reason }); + await expect(provider.verify(context)).rejects.toSatisfy(unavailable); + }); + + it('records a refusal the facilitator found in the payment', async () => { + const provider = makeProvider(); + const { context } = await paidRequest(provider); + verifyMock.mockResolvedValueOnce({ + isValid: false, + invalidReason: 'invalid_exact_near_payload_delegate_action_nonce_already_used', + }); + expect((await provider.verify(context)).rejectionReason).toBe( + 'invalid_exact_near_payload_delegate_action_nonce_already_used', + ); + }); +}); + +describe('settle()', () => { + async function settleWith(response: Record, currency?: string) { + const provider = makeProvider(); + const { context } = await paidRequest(provider, {}, currency); + verifyMock.mockResolvedValueOnce({ isValid: true, payer: BUYER }); + const verification = await provider.verify(context); + settleMock.mockResolvedValueOnce({ network: NETWORK, ...response }); + return provider.settle({ ...context, verification }); + } + + it('settles with the outer transaction hash once the inner transfer succeeded', async () => { + await expect( + settleWith({ success: true, transaction: TX_HASH, payer: BUYER }), + ).resolves.toMatchObject({ + status: 'settled', + externalReference: TX_HASH, + payer: BUYER, + metadata: { amountBaseUnits: '10000' }, + }); + }); + + // A USD price settles in the network's USDC; the receipt names the token + it('records the token it settles, and the price in another currency as the quote', async () => { + const settled = await settleWith({ success: true, transaction: TX_HASH, payer: BUYER }); + expect(settled).toMatchObject({ + currency: 'USDC', + metadata: { quote: { amount: '0.01', currency: 'USD' } }, + }); + + const inToken = await settleWith({ success: true, transaction: TX_HASH, payer: BUYER }, 'USDC'); + expect(inToken.currency).toBe('USDC'); + expect(inToken.metadata).not.toHaveProperty('quote'); + }); + + it.each<[string, Record]>([ + [ + 'a throw after the delegate may have been submitted', + { success: false, transaction: '', errorReason: 'settlement_failed' }, + ], + [ + 'a settlement already in flight', + { success: false, transaction: '', errorReason: 'duplicate_settlement' }, + ], + [ + 'a failure naming a transaction with a reason other than a failed transfer', + { + success: false, + transaction: TX_HASH, + errorReason: 'invalid_exact_near_balance_check_failed', + }, + ], + // The spec leaves a failure's reason to the facilitator, with an empty + // transaction even after a submission + [ + "a reason outside the SDK's pre-submission vocabulary", + { success: false, transaction: '', errorReason: 'relayer_rpc_timeout' }, + ], + ['a failure without a reason', { success: false, transaction: '' }], + ['a success without a NEAR hash', { success: true, transaction: `0x${'ab'.repeat(32)}` }], + [ + 'a success on another network', + { success: true, transaction: TX_HASH, network: 'near:mainnet' }, + ], + ])('leaves the outcome unknown for %s', async (_label, response) => { + await expect(settleWith(response)).rejects.toSatisfy( + (err: unknown) => unavailable(err) && err.details?.['settlementNotSubmitted'] === undefined, + ); + }); + + // The SDK returns these from its own checks before it submits, so the buyer's + // retry, which signs the same access-key nonce, must be able to pay again + it.each([ + 'invalid_exact_near_balance_check_failed', + 'invalid_exact_near_access_key_lookup_failed', + 'invalid_exact_near_no_relayer_configured', + ])('reports %s at settle as no verdict with nothing submitted', async (errorReason) => { + await expect(settleWith({ success: false, transaction: '', errorReason })).rejects.toSatisfy( + (err: unknown) => + unavailable(err) && + err.details?.['settlementNotSubmitted'] === true && + err.details['reason'] === errorReason, + ); + }); + + it.each<[string, Record]>([ + [ + 'an inner transfer that failed on chain', + { success: false, transaction: TX_HASH, errorReason: 'settlement_failed' }, + ], + [ + 'a refusal before submission', + { success: false, transaction: '', errorReason: 'insufficient_funds' }, + ], + [ + 'a delegate whose window passed before submission', + { + success: false, + transaction: '', + errorReason: 'invalid_exact_near_payload_delegate_action_expired', + }, + ], + [ + "the SDK's fallback for a failed check at settle", + { success: false, transaction: '', errorReason: 'verification_failed' }, + ], + ])('records a rejection for %s', async (_label, response) => { + const rejected = await settleWith(response); + expect(rejected).toMatchObject({ + status: 'rejected', + rejectionReason: response['errorReason'], + }); + // The failed outer transaction, which the relayer paid for + expect(rejected.externalReference).toBe(response['transaction'] || undefined); + }); +}); + +describe('health()', () => { + const rpc = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', async (_url: string, init: { body: string }) => { + const { method, params } = JSON.parse(init.body) as { + method: string; + params: Record; + }; + const answer = rpc(method, params) as { result?: unknown; error?: unknown }; + return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, ...answer })); + }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const view = (value: unknown) => ({ + result: { result: [...Buffer.from(JSON.stringify(value))], logs: [], block_height: 1 }, + }); + + // A healthy testnet RPC; `change` overrides single answers + function chain(change: Record = {}) { + rpc.mockImplementation((method: string, params: Record) => { + const answers: Record = { + status: { result: { chain_id: 'testnet' } }, + view_account: { result: { code_hash: '5tvUMpoLY7fpqRUMBDtEvDWrPmWRHsCV8kcvb5nx8Byp' } }, + ft_metadata: view({ spec: 'ft-1.0.0', decimals: 6 }), + storage_balance_of: view({ total: '1250000000000000000000', available: '0' }), + ...change, + }; + if (method === 'status') return answers['status']; + const key = (params['method_name'] ?? params['request_type']) as string; + return answers[key]; + }); + } + + it('passes for the named chain, a token with the configured decimals and a registered payee', async () => { + chain(); + const health = await makeProvider().health(); + expect(health.status).toBe('pass'); + expect(health.detail).toContain(`storage for ${PAY_TO}`); + }); + + it('passes for a token without NEP-145 storage management', async () => { + chain({ + storage_balance_of: { + error: { cause: { info: { vm_error: '{"MethodResolveError":"MethodNotFound"}' } } }, + }, + }); + expect((await makeProvider().health()).status).toBe('pass'); + }); + + it.each<[string, Record, RegExp]>([ + [ + 'an RPC serving another chain', + { status: { result: { chain_id: 'mainnet' } } }, + /serves chain mainnet, expected testnet/, + ], + [ + 'a token account without code', + { view_account: { result: { code_hash: '11111111111111111111111111111111' } } }, + /No contract code/, + ], + [ + 'a token with other decimals', + { ft_metadata: view({ decimals: 24 }) }, + /has 24 decimals, configured 6/, + ], + ['an unregistered payee', { storage_balance_of: view(null) }, /no storage registered/], + ])('fails for %s', async (_label, change, detail) => { + chain(change); + const health = await makeProvider().health(); + expect(health.status).toBe('fail'); + expect(health.detail).toMatch(detail); + }); + + it('fails for a facilitator that does not list the network', async () => { + chain(); + getSupportedMock.mockResolvedValue(supported('near:mainnet')); + const health = await makeProvider().health(); + expect(health.status).toBe('fail'); + expect(health.detail).toMatch(/does not advertise/); + }); +}); diff --git a/tests/unit/payments-x402/provider-remote-facilitator.test.ts b/tests/unit/payments-x402/provider-remote-facilitator.test.ts index d833518..5a4d21d 100644 --- a/tests/unit/payments-x402/provider-remote-facilitator.test.ts +++ b/tests/unit/payments-x402/provider-remote-facilitator.test.ts @@ -12,12 +12,13 @@ import { registerExactEvmScheme } from '@x402/evm/exact/client'; import { privateKeyToAccount } from 'viem/accounts'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { CommerceResource, PaymentContext } from '../../../src/core'; -import { isCommerceError } from '../../../src/core'; +import { isCommerceError, NOOP_LOGGER } from '../../../src/core'; import { createPaymentProof } from '../../../src/payments/x402/client'; import { createX402PaymentProvider, type X402ProviderOptions, } from '../../../src/payments/x402/provider'; +import { computePermit2ReplayKey } from '../../../src/payments/x402/replay-key'; const verifyMock = vi.fn(); const settleMock = vi.fn(); @@ -76,8 +77,7 @@ function makeProvider( overrides: Partial = {}, ) { return createX402PaymentProvider({ - ...overrides, - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: RPC_URL, asset: ASSET, assetName: 'MockUSDC', @@ -85,6 +85,7 @@ function makeProvider( assetDecimals: 6, payTo: PAY_TO, facilitator: { mode: 'remote', url: FACILITATOR_URL, auth }, + ...overrides, }); } @@ -106,7 +107,15 @@ describe('provider - remote facilitator', () => { }); it('holds no signing key and reports itself as a remote testnet deployment', () => { - const provider = makeProvider({ type: 'none' }); + // Public Base Sepolia, which settles only in its USDC + const provider = makeProvider( + { type: 'none' }, + { + network: 'eip155:84532', + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + assetName: 'USDC', + }, + ); expect(provider.descriptor.capabilities).toContain('remote-facilitator'); expect(provider.descriptor.capabilities).toContain('mode=testnet'); }); @@ -139,8 +148,8 @@ describe('provider - remote facilitator', () => { verifyMock.mockResolvedValueOnce({ isValid: true }); settleMock.mockResolvedValueOnce({ success: true, - transaction: '0xabc', - network: 'eip155:84532', + transaction: `0x${'ab'.repeat(32)}`, + network: 'eip155:31337', payer: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', }); @@ -160,7 +169,27 @@ describe('provider - remote facilitator', () => { verification, }); expect(settlement.status).toBe('settled'); - expect(settlement.externalReference).toBe('0xabc'); + expect(settlement.externalReference).toBe(`0x${'ab'.repeat(32)}`); + }); + + it.each([ + ['without a transaction hash', { transaction: '0xabc', network: 'eip155:31337' }], + ['on another network', { transaction: `0x${'ab'.repeat(32)}`, network: 'eip155:84532' }], + ])('leaves a reported success %s with an unknown outcome', async (_label, response) => { + const provider = makeProvider({ type: 'none' }); + const { requirement, payload } = await proofFor(provider); + verifyMock.mockResolvedValueOnce({ isValid: true }); + settleMock.mockResolvedValueOnce({ success: true, ...response }); + const context = { + requestId: 'req-1', + resource: RESOURCE, + requirement, + submission: { method: 'x402' as const, payload }, + }; + const verification = await provider.verify(context); + await expect(provider.settle({ ...context, verification })).rejects.toSatisfy( + (err: unknown) => isCommerceError(err) && err.code === 'PAYMENT_PROVIDER_UNAVAILABLE', + ); }); it('treats a facilitator that produced no verdict as unavailable, not as a bad payment', async () => { @@ -288,7 +317,7 @@ describe('provider - remote facilitator', () => { success: false, errorReason: 'insufficient_funds', transaction: '', - network: 'eip155:84532', + network: 'eip155:31337', }), ); @@ -320,7 +349,7 @@ describe('provider - remote facilitator', () => { success: false, ...(errorReason !== undefined ? { errorReason } : {}), transaction, - network: 'eip155:84532', + network: 'eip155:31337', }), ); @@ -490,7 +519,7 @@ describe('provider - a proof must echo the offer it pays', () => { const client = new x402Client(); registerExactEvmScheme(client, { signer: privateKeyToAccount(BUYER_PRIVATE_KEY), - networks: ['eip155:84532'], + networks: ['eip155:31337'], }); client.setSpendControls(false); // the test token is not in the SDK's list const sdkPayload = await client.createPaymentPayload( @@ -513,3 +542,474 @@ describe('provider - a proof must echo the offer it pays', () => { }, ); }); + +describe('provider - several offers', () => { + // Three tokens to three payees on one chain, so every offer is distinct + const OFFERS = [ + { asset: ASSET, payTo: PAY_TO }, + { + asset: '0x2222222222222222222222222222222222222222', + payTo: '0x3333333333333333333333333333333333333333', + }, + { + asset: '0x4444444444444444444444444444444444444444', + payTo: '0x5555555555555555555555555555555555555555', + }, + ] as const; + + const makeMultiProvider = () => + createX402PaymentProvider({ + offers: OFFERS.map((offer) => ({ + ...offer, + network: 'eip155:31337', + rpcUrl: RPC_URL, + assetName: 'MockUSDC', + assetVersion: '2', + assetDecimals: 6, + })), + facilitator: { mode: 'remote', url: FACILITATOR_URL, auth: { type: 'none' } }, + }); + + // A proof built by the SDK client for the offer `pick` chooses + async function sdkProof(envelope: unknown, pick: number) { + const client = new x402Client((_version, accepts) => accepts[pick] as never); + registerExactEvmScheme(client, { + signer: privateKeyToAccount(BUYER_PRIVATE_KEY), + networks: ['eip155:31337'], + }); + client.setSpendControls(false); + const payload = await client.createPaymentPayload(envelope as PaymentRequired); + return { + method: 'x402' as const, + payload: Buffer.from(JSON.stringify(payload)).toString('base64'), + }; + } + + beforeEach(() => { + verifyMock.mockReset(); + settleMock.mockReset(); + }); + + it('offers every option under one resource URL and names no single destination', async () => { + const requirement = await makeMultiProvider().createRequirement(paymentContext()); + expect(requirement.challenge.accepts.map((a) => a['payTo'])).toEqual( + OFFERS.map((o) => o.payTo), + ); + expect((requirement.challenge.envelope as unknown as PaymentRequired).resource.url).toBe( + 'resource://agent-commerce/resources/demo.report', + ); + expect(requirement.destination).toBeUndefined(); + expect(requirement.network).toBeUndefined(); + expect(requirement.asset).toBeUndefined(); + }); + + it.each([0, 1, 2])( + 'verifies and settles the offer the SDK client chose: offer %i', + async (pick) => { + const provider = makeMultiProvider(); + const requirement = await provider.createRequirement(paymentContext()); + const submission = await sdkProof(requirement.challenge.envelope, pick); + const context = { requestId: 'req-1', resource: RESOURCE, requirement, submission }; + + const selected = await provider.selectRequirement?.(context); + if (!selected) throw new Error('a provider with several offers selects one'); + const offer = OFFERS[pick] as (typeof OFFERS)[number]; + expect(selected).toMatchObject({ + id: requirement.id, + destination: offer.payTo, + network: 'eip155:31337', + asset: offer.asset, + challenge: requirement.challenge, + }); + + verifyMock.mockResolvedValueOnce({ isValid: true }); + const verification = await provider.verify({ ...context, requirement: selected }); + expect(verification).toMatchObject({ status: 'verified', asset: offer.asset }); + expect(verification.payee?.toLowerCase()).toBe(offer.payTo); + + settleMock.mockResolvedValueOnce({ + success: true, + transaction: `0x${'ab'.repeat(32)}`, + network: 'eip155:31337', + }); + const settled = await provider.settle({ ...context, requirement: selected, verification }); + expect(settled.status).toBe('settled'); + // The facilitator settles exactly the chosen offer + expect(settleMock.mock.calls[0]?.[1]).toEqual(requirement.challenge.accepts[pick]); + }, + ); + + it('leaves the requirement unselected when the echo matches no offer, and verify rejects it', async () => { + const provider = makeMultiProvider(); + const requirement = await provider.createRequirement(paymentContext()); + const proof = await sdkProof(requirement.challenge.envelope, 1); + const decoded = JSON.parse(Buffer.from(proof.payload, 'base64').toString('utf8')); + decoded.accepted.payTo = '0x6666666666666666666666666666666666666666'; + const submission = { + method: 'x402' as const, + payload: Buffer.from(JSON.stringify(decoded)).toString('base64'), + }; + const context = { requestId: 'req-1', resource: RESOURCE, requirement, submission }; + + expect(await provider.selectRequirement?.(context)).toBe(requirement); + const result = await provider.verify(context); + expect(result.rejectionReason).toBe('invalid_payment_requirements'); + expect(verifyMock).not.toHaveBeenCalled(); + }); + + it('selects per request, so concurrent buyers can pay different offers', async () => { + const provider = makeMultiProvider(); + const [first, last] = await Promise.all( + [0, 2].map(async (pick) => { + const requirement = await provider.createRequirement(paymentContext()); + const submission = await sdkProof(requirement.challenge.envelope, pick); + return provider.selectRequirement?.({ + requestId: 'r', + resource: RESOURCE, + requirement, + submission, + }); + }), + ); + expect(first?.destination).toBe(OFFERS[0].payTo); + expect(last?.destination).toBe(OFFERS[2].payTo); + }); +}); + +describe('provider - resource offers', () => { + const offer = (asset: string, payTo: string) => ({ + asset, + payTo, + network: 'eip155:31337', + rpcUrl: RPC_URL, + assetName: 'MockUSDC', + assetVersion: '2', + assetDecimals: 6, + }); + const DEFAULT = offer(ASSET, PAY_TO); + // The report's own offers, in an order that differs from every other list: + // the second repeats the default + const OWN = [ + offer( + '0x2222222222222222222222222222222222222222', + '0x3333333333333333333333333333333333333333', + ), + DEFAULT, + ]; + const OTHER: CommerceResource = { ...RESOURCE, id: 'demo.other' }; + + const makeProvider = (resourceOffers: Record) => + createX402PaymentProvider({ + offers: [DEFAULT], + resourceOffers, + facilitator: { mode: 'remote', url: FACILITATOR_URL, auth: { type: 'none' } }, + }); + + const contextFor = (resource: CommerceResource): PaymentContext => ({ + ...paymentContext(), + resource, + }); + + async function sdkProof(envelope: unknown, pick: number) { + const client = new x402Client((_version, accepts) => accepts[pick] as never); + registerExactEvmScheme(client, { + signer: privateKeyToAccount(BUYER_PRIVATE_KEY), + networks: ['eip155:31337'], + }); + client.setSpendControls(false); + const payload = await client.createPaymentPayload(envelope as PaymentRequired); + return { + method: 'x402' as const, + payload: Buffer.from(JSON.stringify(payload)).toString('base64'), + }; + } + + beforeEach(() => { + verifyMock.mockReset(); + settleMock.mockReset(); + }); + + it('challenges each resource with its own list, and others with the default', async () => { + const provider = makeProvider({ 'demo.report': OWN }); + expect(provider.selectRequirement).toBeDefined(); + expect(provider.descriptor.capabilities).toContain('multiple-offers'); + + const own = await provider.createRequirement(paymentContext()); + expect(own.challenge.accepts.map((a) => a['payTo'])).toEqual(OWN.map((o) => o.payTo)); + expect(own.destination).toBeUndefined(); + + const other = await provider.createRequirement(contextFor(OTHER)); + expect(other.challenge.accepts.map((a) => a['payTo'])).toEqual([PAY_TO]); + expect(other.destination).toBe(PAY_TO); + }); + + it('verifies and settles the resource offer the buyer chose', async () => { + const provider = makeProvider({ 'demo.report': OWN }); + const requirement = await provider.createRequirement(paymentContext()); + const submission = await sdkProof(requirement.challenge.envelope, 0); + const context = { requestId: 'req-1', resource: RESOURCE, requirement, submission }; + + const selected = await provider.selectRequirement?.(context); + expect(selected?.destination).toBe(OWN[0]?.payTo); + if (!selected) throw new Error('a resource with several offers selects one'); + + verifyMock.mockResolvedValueOnce({ isValid: true }); + const verification = await provider.verify({ ...context, requirement: selected }); + expect(verification).toMatchObject({ status: 'verified', asset: OWN[0]?.asset }); + + settleMock.mockResolvedValueOnce({ + success: true, + transaction: `0x${'ab'.repeat(32)}`, + network: 'eip155:31337', + }); + const settled = await provider.settle({ ...context, requirement: selected, verification }); + expect(settled.status).toBe('settled'); + expect(settleMock.mock.calls[0]?.[1]).toEqual(requirement.challenge.accepts[0]); + }); + + it('does not let a proof for another resource offer pay this one', async () => { + const provider = makeProvider({ 'demo.report': [OWN[0] as typeof DEFAULT] }); + const other = await provider.createRequirement(contextFor(OTHER)); + const submission = await sdkProof(other.challenge.envelope, 0); + const requirement = await provider.createRequirement(paymentContext()); + + const result = await provider.verify({ + requestId: 'req-1', + resource: RESOURCE, + requirement, + submission, + }); + + expect(result.rejectionReason).toBe('invalid_payment_requirements'); + expect(verifyMock).not.toHaveBeenCalled(); + }); + + it('refuses an empty resource list at construction', () => { + expect(() => makeProvider({ 'demo.report': [] })).toThrow(/resourceOffers.demo.report/); + }); +}); + +describe('provider - Permit2 offers', () => { + type Permit2Proof = { + payload: { + permit2Authorization: { + from: string; + spender: string; + nonce: string; + permitted: { token: string; amount: string }; + witness: { to: string }; + }; + }; + extensions?: Record; + resource?: unknown; + }; + + const buyer = privateKeyToAccount(BUYER_PRIVATE_KEY); + + // No token domain: Permit2 signs its own + const warn = vi.fn(); + const makePermit2Provider = () => + createX402PaymentProvider({ + network: 'eip155:31337', + rpcUrl: RPC_URL, + asset: ASSET, + assetDecimals: 6, + payTo: PAY_TO, + assetTransferMethod: 'permit2', + facilitator: { mode: 'remote', url: FACILITATOR_URL, auth: { type: 'none' } }, + logger: { ...NOOP_LOGGER, warn }, + }); + + async function signedProof(provider: ReturnType) { + const requirement = await provider.createRequirement(paymentContext()); + const client = new x402Client(); + registerExactEvmScheme(client, { signer: buyer, networks: ['eip155:31337'] }); + client.setSpendControls(false); + const envelope = requirement.challenge.envelope as unknown as PaymentRequired; + const proof = (await client.createPaymentPayload(envelope)) as unknown as Permit2Proof; + return { requirement, proof }; + } + + const submission = (proof: unknown) => ({ + method: 'x402' as const, + payload: Buffer.from(JSON.stringify(proof)).toString('base64'), + }); + + beforeEach(() => { + verifyMock.mockReset(); + settleMock.mockReset(); + }); + + it('names the method in the offer and the descriptor, without a token domain', async () => { + const provider = makePermit2Provider(); + const requirement = await provider.createRequirement(paymentContext()); + expect(requirement.challenge.accepts[0]?.['extra']).toEqual({ assetTransferMethod: 'permit2' }); + expect(provider.descriptor.capabilities).toContain('permit2'); + }); + + it('refuses an EIP-3009 offer without the token domain', () => { + expect(() => + createX402PaymentProvider({ + network: 'eip155:31337', + rpcUrl: RPC_URL, + asset: ASSET, + assetDecimals: 6, + payTo: PAY_TO, + facilitator: { mode: 'remote', url: FACILITATOR_URL, auth: { type: 'none' } }, + }), + ).toThrow(/EIP-3009 offer needs "assetName" and "assetVersion"/); + }); + + it('verifies and settles an SDK-signed payload, forwarding no extensions', async () => { + const provider = makePermit2Provider(); + const { requirement, proof } = await signedProof(provider); + const auth = proof.payload.permit2Authorization; + // An unadvertised gas-sponsoring extension never reaches the facilitator + proof.extensions = { eip2612GasSponsoring: { info: { from: buyer.address } } }; + verifyMock.mockResolvedValueOnce({ isValid: true }); + settleMock.mockResolvedValueOnce({ + success: true, + transaction: `0x${'ab'.repeat(32)}`, + network: 'eip155:31337', + payer: buyer.address, + }); + + const context = { + requestId: 'req-1', + resource: RESOURCE, + requirement, + submission: submission(proof), + }; + const verification = await provider.verify(context); + expect(verification).toMatchObject({ + status: 'verified', + payer: buyer.address, + payee: PAY_TO, + amount: '0.01', + replayKey: computePermit2ReplayKey({ + chainId: 31337, + owner: buyer.address, + nonce: auth.nonce, + }), + }); + const forwarded = verifyMock.mock.calls[0]?.[0] as Permit2Proof; + expect(forwarded.payload.permit2Authorization).toEqual(auth); + expect(forwarded).not.toHaveProperty('extensions'); + expect(forwarded).not.toHaveProperty('resource'); + + const settlement = await provider.settle({ ...context, verification }); + expect(settlement.status).toBe('settled'); + expect(settlement.metadata).toEqual({ amountBaseUnits: '10000' }); + expect(settleMock.mock.calls[0]?.[0]).not.toHaveProperty('extensions'); + }); + + const OTHER = '0x2222222222222222222222222222222222222222'; + it.each<[string, (auth: Permit2Proof['payload']['permit2Authorization']) => void]>([ + [ + 'invalid_permit2_spender', + (auth) => { + auth.spender = OTHER; + }, + ], + [ + 'permit2_token_mismatch', + (auth) => { + auth.permitted.token = OTHER; + }, + ], + [ + 'invalid_permit2_recipient_mismatch', + (auth) => { + auth.witness.to = OTHER; + }, + ], + [ + 'permit2_amount_mismatch', + (auth) => { + auth.permitted.amount = '10001'; + }, + ], + ])('refuses %s before the facilitator sees it', async (reason, tamper) => { + const provider = makePermit2Provider(); + const { requirement, proof } = await signedProof(provider); + tamper(proof.payload.permit2Authorization); + const result = await provider.verify({ + requestId: 'req-1', + resource: RESOURCE, + requirement, + submission: submission(proof), + }); + expect(result.rejectionReason).toBe(reason); + expect(verifyMock).not.toHaveBeenCalled(); + }); + + // The SDK's Permit2 path returns every throw from the broadcast call with no + // hash, the catch-all with the message appended + it.each([ + [ + 'the catch-all with a message, as an unknown outcome', + 'invalid_exact_evm_transaction_failed: request timed out. URL: https://rpc.example/v2/RPC-KEY', + 'throws', + ], + ['a recognized Permit2 revert, as a refusal', 'permit2_invalid_nonce', 'rejected'], + ])('settle() treats a failure without a hash, %s', async (_label, errorReason, outcome) => { + const provider = makePermit2Provider(); + const { requirement, proof } = await signedProof(provider); + verifyMock.mockResolvedValueOnce({ isValid: true }); + settleMock.mockResolvedValueOnce({ + success: false, + errorReason, + transaction: '', + network: 'eip155:31337', + }); + const context = { + requestId: 'req-1', + resource: RESOURCE, + requirement, + submission: submission(proof), + }; + const settled = provider.settle({ ...context, verification: await provider.verify(context) }); + if (outcome === 'throws') { + await expect(settled).rejects.toSatisfy( + (err: unknown) => isCommerceError(err) && err.code === 'PAYMENT_PROVIDER_UNAVAILABLE', + ); + expect(warn).toHaveBeenCalled(); + expect(JSON.stringify(warn.mock.calls)).not.toContain('RPC-KEY'); + } else { + await expect(settled).resolves.toMatchObject({ + status: 'rejected', + rejectionReason: errorReason, + }); + } + }); + + it('refuses an EIP-3009 payload for a Permit2 offer', async () => { + const provider = makePermit2Provider(); + const requirement = await provider.createRequirement(paymentContext()); + const eip3009 = await proofFor(makeProvider({ type: 'none' })); + const result = await provider.verify({ + requestId: 'req-1', + resource: RESOURCE, + requirement, + submission: { method: 'x402', payload: eip3009.payload }, + }); + expect(result.rejectionReason).toBe('unsupported_scheme'); + expect(verifyMock).not.toHaveBeenCalled(); + }); + + it('refuses a payload carrying both authorizations, which the SDK would settle through Permit2', async () => { + const eip3009Provider = makeProvider({ type: 'none' }); + const { requirement, payload } = await proofFor(eip3009Provider); + const { proof } = await signedProof(makePermit2Provider()); + const decoded = JSON.parse(Buffer.from(payload, 'base64').toString('utf8')); + decoded.payload.permit2Authorization = proof.payload.permit2Authorization; + const result = await eip3009Provider.verify({ + requestId: 'req-1', + resource: RESOURCE, + requirement, + submission: submission(decoded), + }); + expect(result.rejectionReason).toBe('unsupported_scheme'); + expect(verifyMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/payments-x402/provider-sdk-mocked.test.ts b/tests/unit/payments-x402/provider-sdk-mocked.test.ts index 736b92b..2c65e68 100644 --- a/tests/unit/payments-x402/provider-sdk-mocked.test.ts +++ b/tests/unit/payments-x402/provider-sdk-mocked.test.ts @@ -1,5 +1,5 @@ /** - * The branches of provider.ts that depend on what the SDK facilitator returns + * The x402 provider's branches that depend on what the SDK facilitator returns * or throws: success, rejection with and without a reason, and thrown errors of * several shapes. Mocked at the SDK boundary; tests/e2e/payment covers real * on-chain settlement. @@ -112,7 +112,7 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { function makeProvider() { return createX402PaymentProvider({ - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: RPC_URL, asset: ASSET, assetName: 'MockUSDC', @@ -142,7 +142,7 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { function baseOptions() { return { - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: RPC_URL, asset: ASSET, assetName: 'MockUSDC', @@ -209,7 +209,7 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { const { nonce } = JSON.parse(Buffer.from(proof, 'base64').toString('utf8')).payload .authorization; expect(result.replayKey).toBe( - computeReplayKey({ chainId: 84532, asset: ASSET, from: BUYER_ADDRESS, nonce }), + computeReplayKey({ chainId: 31337, asset: ASSET, from: BUYER_ADDRESS, nonce }), ); expect(result.payer).toBe(BUYER_ADDRESS); expect(verifyMock).toHaveBeenCalledOnce(); @@ -236,7 +236,7 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { settleMock.mockResolvedValueOnce({ success: true, transaction: `0x${'ab'.repeat(32)}`, - network: 'eip155:84532', + network: 'eip155:31337', }); const verification = await provider.verify({ @@ -272,7 +272,7 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { settleMock.mockResolvedValueOnce({ success: true, transaction: `0x${'ab'.repeat(32)}`, - network: 'eip155:84532', + network: 'eip155:31337', }); const verification = await provider.verify({ @@ -317,8 +317,13 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { it.each([ ['unsupported characters', 'do not expose: secret', 'invalid_payment'], - ['exactly 64 characters', 'x'.repeat(64), 'x'.repeat(64)], - ['more than 64 characters', 'x'.repeat(65), 'invalid_payment'], + ['exactly 128 characters', 'x'.repeat(128), 'x'.repeat(128)], + ['more than 128 characters', 'x'.repeat(129), 'invalid_payment'], + [ + 'the longest SDK reason', + 'invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction_too_high', + 'invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction_too_high', + ], ['an empty value', '', 'invalid_payment'], ['only whitespace', ' ', 'invalid_payment'], ['surrounding whitespace', ' insufficient_funds ', 'insufficient_funds'], @@ -392,8 +397,8 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { }); settleMock.mockResolvedValueOnce({ success: true, - transaction: '0xdeadbeef', - network: 'eip155:84532', + transaction: `0x${'ab'.repeat(32)}`, + network: 'eip155:31337', payer: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', }); @@ -407,17 +412,20 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { provider: 'x402', amount: '0.01', currency: 'USD', - network: 'eip155:84532', + network: 'eip155:31337', asset: ASSET, replayKey: '0xabc', }, }); expect(result.status).toBe('settled'); - expect(result.externalReference).toBe('0xdeadbeef'); + expect(result.externalReference).toBe(`0x${'ab'.repeat(32)}`); expect(result.payee).toBe(PAY_TO); - expect(result.network).toBe('eip155:84532'); + expect(result.network).toBe('eip155:31337'); expect(result.asset).toBe(ASSET); expect(result.replayKey).toBe('0xabc'); + // The local chain's token has no symbol, so the resource's currency names it + expect(result.currency).toBe('USD'); + expect(result.metadata).not.toHaveProperty('quote'); expect(result.settledAt).toBeDefined(); }); @@ -430,8 +438,8 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { }); settleMock.mockResolvedValueOnce({ success: true, - transaction: '0xfeedface', - network: 'eip155:84532', + transaction: `0x${'fe'.repeat(32)}`, + network: 'eip155:31337', }); const result = await provider.settle({ @@ -455,7 +463,7 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { settleMock.mockResolvedValueOnce({ success: false, - network: 'eip155:84532', + network: 'eip155:31337', errorReason: 'insufficient_funds', payer: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', }); @@ -479,7 +487,7 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { expect(withReason.asset).toBe(ASSET); expect(withReason.replayKey).toBe('0xdef'); - settleMock.mockResolvedValueOnce({ success: false, network: 'eip155:84532' }); + settleMock.mockResolvedValueOnce({ success: false, network: 'eip155:31337' }); const withoutReason = await provider.settle({ requestId: 'req-1', resource: RESOURCE, @@ -493,8 +501,8 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { it.each([ ['unsupported characters', 'do not expose: secret', 'settlement_failed'], - ['exactly 64 characters', 'x'.repeat(64), 'x'.repeat(64)], - ['more than 64 characters', 'x'.repeat(65), 'settlement_failed'], + ['exactly 128 characters', 'x'.repeat(128), 'x'.repeat(128)], + ['more than 128 characters', 'x'.repeat(129), 'settlement_failed'], ['an empty value', '', 'settlement_failed'], ['only whitespace', ' ', 'settlement_failed'], ['surrounding whitespace', ' insufficient_funds ', 'insufficient_funds'], @@ -507,7 +515,7 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { }); settleMock.mockResolvedValueOnce({ success: false, - network: 'eip155:84532', + network: 'eip155:31337', errorReason, }); @@ -543,14 +551,14 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { provider: 'x402', amount: '0.01', currency: 'USD', - network: 'eip155:84532', + network: 'eip155:31337', asset: ASSET, replayKey: '0xreverted', }, }); expect(reverted.status).toBe('rejected'); expect(reverted.rejectionReason).toBe('transaction_reverted'); - expect(reverted.network).toBe('eip155:84532'); + expect(reverted.network).toBe('eip155:31337'); expect(reverted.asset).toBe(ASSET); expect(reverted.replayKey).toBe('0xreverted'); @@ -617,14 +625,22 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { ); }); - it.each([ + it.each<[string, string, string, string?]>([ ['without a hash, as an unknown outcome', '', 'throws'], ['with a value that is not a hash, as an unknown outcome', 'not-a-hash', 'throws'], ['with a truncated hash, as an unknown outcome', '0xdeadbeef', 'throws'], ['with a hash, as a mined revert', `0x${'cd'.repeat(32)}`, 'rejected'], + // The SDK's answer when the receipt wait failed and its pending store could + // not record the broadcast + [ + 'with a hash for a broadcast it could not record, as an unknown outcome', + `0x${'cd'.repeat(32)}`, + 'throws', + 'settlement_pending, but failed to persist for retry: store offline', + ], ])( 'settle() treats the SDK catch-all transaction failure %s', - async (_label, transaction, outcome) => { + async (_label, transaction, outcome, errorMessage) => { const provider = makeProvider(); const requirement = await provider.createRequirement(paymentContext()); const proof = await createPaymentProof({ @@ -634,8 +650,9 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { settleMock.mockResolvedValueOnce({ success: false, errorReason: 'invalid_exact_evm_transaction_failed', + ...(errorMessage !== undefined ? { errorMessage } : {}), transaction, - network: 'eip155:84532', + network: 'eip155:31337', }); const settled = provider.settle({ @@ -659,6 +676,79 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { }, ); + it('settle() records a reverted transaction on its rejection', async () => { + const provider = makeProvider(); + const requirement = await provider.createRequirement(paymentContext()); + const proof = await createPaymentProof({ + buyerPrivateKey: BUYER_PRIVATE_KEY, + accepts: requirement.challenge.accepts[0] as Record, + }); + const REVERTED_TX_HASH = `0x${'ef'.repeat(32)}`; + settleMock.mockResolvedValueOnce({ + success: false, + errorReason: 'invalid_exact_evm_transaction_failed', + transaction: REVERTED_TX_HASH, + network: 'eip155:31337', + }); + + await expect( + provider.settle({ + requestId: 'req-1', + resource: RESOURCE, + requirement, + submission: { method: 'x402', payload: proof }, + verification: { status: 'verified', provider: 'x402', amount: '0.01', currency: 'USD' }, + }), + ).resolves.toMatchObject({ + status: 'rejected', + rejectionReason: 'invalid_exact_evm_transaction_failed', + externalReference: REVERTED_TX_HASH, + }); + }); + + // Only a reverted receipt proves a named transaction moved nothing. The SDK + // reports a mined transfer whose Transfer log it cannot match as a failure. + it.each([ + [ + 'a mined transfer whose Transfer log did not match', + 'invalid_exact_evm_transfer_event_mismatch', + ], + ['a reason outside the SDK vocabulary', 'unexpected_settle_error'], + ['no reason', undefined], + ])( + 'settle() treats a failure naming a transaction with %s as an unknown outcome', + async (_label, errorReason) => { + const provider = makeProvider(); + const requirement = await provider.createRequirement(paymentContext()); + const proof = await createPaymentProof({ + buyerPrivateKey: BUYER_PRIVATE_KEY, + accepts: requirement.challenge.accepts[0] as Record, + }); + const MINED_TX_HASH = `0x${'12'.repeat(32)}`; + settleMock.mockResolvedValueOnce({ + success: false, + ...(errorReason !== undefined ? { errorReason } : {}), + transaction: MINED_TX_HASH, + network: 'eip155:31337', + }); + + await expect( + provider.settle({ + requestId: 'req-1', + resource: RESOURCE, + requirement, + submission: { method: 'x402', payload: proof }, + verification: { status: 'verified', provider: 'x402', amount: '0.01', currency: 'USD' }, + }), + ).rejects.toSatisfy( + (err: unknown) => + isCommerceError(err) && + err.code === 'PAYMENT_PROVIDER_UNAVAILABLE' && + err.details?.['transactionHash'] === MINED_TX_HASH, + ); + }, + ); + it('settle() attaches transactionHash to PAYMENT_PROVIDER_UNAVAILABLE when the broadcast succeeded but confirmation timed out', async () => { // `settlement_pending` means the transfer was broadcast and may be on-chain, // so it becomes an unavailable provider carrying the hash, never a @@ -675,7 +765,7 @@ describe('provider - SDK-boundary branches (mocked x402/facilitator)', () => { success: false, errorReason: 'settlement_pending', transaction: BROADCAST_TX_HASH, - network: 'eip155:84532', + network: 'eip155:31337', payer: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', }); @@ -751,7 +841,7 @@ describe('provider - a failed signer call is classified as an outage or a verdic function makeProvider() { return createX402PaymentProvider({ - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: RPC_URL, asset: ASSET, assetName: 'MockUSDC', @@ -780,12 +870,12 @@ describe('provider - a failed signer call is classified as an outage or a verdic ); settleMock.mockImplementationOnce(async () => (await signerCall(method)) - ? { success: true, transaction: `0x${'ab'.repeat(32)}`, network: 'eip155:84532' } + ? { success: true, transaction: `0x${'ab'.repeat(32)}`, network: 'eip155:31337' } : { success: false, errorReason: 'invalid_exact_evm_signature', transaction: '', - network: 'eip155:84532', + network: 'eip155:31337', }, ); const provider = makeProvider(); diff --git a/tests/unit/payments-x402/provider-svm.test.ts b/tests/unit/payments-x402/provider-svm.test.ts new file mode 100644 index 0000000..f2cb62f --- /dev/null +++ b/tests/unit/payments-x402/provider-svm.test.ts @@ -0,0 +1,520 @@ +/** + * Solana offers through a remote facilitator, mocked at the SDK's HTTP client: + * the fee payer from `/supported`, verification, settlement classification and + * the readiness checks against a Solana RPC + */ +import { generateKeyPairSigner, type KeyPairSigner } from '@solana/kit'; +import type { PaymentRequirements, SupportedResponse } from '@x402/core/types'; +import { ExactSvmScheme } from '@x402/svm/exact/server'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + Clock, + CommerceResource, + PaymentContext, + PaymentRequirement, +} from '../../../src/core'; +import { isCommerceError } from '../../../src/core'; +import { createPaymentProof } from '../../../src/payments/x402/client'; +import { + createX402PaymentProvider, + type X402MultiOfferProviderOptions, +} from '../../../src/payments/x402/provider'; +import { svmReplayKey } from '../../../src/payments/x402/svm'; +import { mintAccount } from '../../fixtures/x402/solana-validator'; +import { buildSvmPayment } from '../../fixtures/x402/svm-transaction'; + +const verifyMock = vi.fn(); +const settleMock = vi.fn(); +const getSupportedMock = vi.fn(); + +vi.mock('@x402/core/http', () => ({ + FacilitatorResponseError: class extends Error {}, + HTTPFacilitatorClient: class { + verify(...args: unknown[]) { + return verifyMock(...args); + } + settle(...args: unknown[]) { + return settleMock(...args); + } + getSupported() { + return getSupportedMock(); + } + }, +})); + +const DEVNET = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'; +const DEVNET_GENESIS = 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG'; +const USDC = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; +const RPC_URL = 'https://solana-devnet.example'; +// A well-formed transaction signature: 64 bytes in base58 +const SIGNATURE = + '61HQAvrUL3kcNhYW9nYLGYvVbY7fREZaNvpvTyoMEpEHXcMS2i4pF8ThAGH8QNHHvhUMor8GEffUdHLSp4qQJd83'; + +const RESOURCE: CommerceResource = { + id: 'demo.report', + name: 'Demo report', + handler: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: '0.01', currency: 'USD' }, + exposedVia: ['http'], + paymentMethods: ['x402'], +}; + +const paymentContext = (): PaymentContext => ({ + requestId: 'req-1', + resource: RESOURCE, + amount: '0.01', + currency: 'USD', + requestedAt: new Date().toISOString(), +}); + +let buyer: KeyPairSigner; +let payTo: string; +let feePayer: string; + +function supported(payer: string | undefined, signers: string[] = []): SupportedResponse { + return { + kinds: [ + { + x402Version: 2, + scheme: 'exact', + network: DEVNET, + ...(payer !== undefined ? { extra: { feePayer: payer } } : {}), + }, + ], + extensions: [], + signers: { 'solana:*': signers }, + }; +} + +// A clock that only advance() moves, for the fee payer snapshot +let nowMs = 0; +const clock: Clock = { + now: () => new Date(), + nowIso: () => new Date().toISOString() as ReturnType, + monotonicMs: () => nowMs, +}; + +function makeProvider(overrides: Partial = {}) { + return createX402PaymentProvider({ + offers: [{ network: DEVNET, rpcUrl: RPC_URL, asset: USDC, assetDecimals: 6, payTo }], + facilitator: { mode: 'remote', url: 'https://facilitator.example', auth: { type: 'none' } }, + clock, + ...overrides, + }); +} + +async function paidRequest(provider: ReturnType) { + const requirement = await provider.createRequirement(paymentContext()); + const accepted = requirement.challenge.accepts[0] as Record; + const transaction = await buildSvmPayment({ + buyer, + feePayer, + mint: USDC, + payTo, + amount: 10_000n, + decimals: 6, + }); + const submission = { + method: 'x402' as const, + payload: Buffer.from( + JSON.stringify({ x402Version: 2, accepted, payload: { transaction } }), + ).toString('base64'), + }; + return { + requirement, + transaction, + context: { requestId: 'req-1', resource: RESOURCE, requirement, submission }, + }; +} + +beforeAll(async () => { + buyer = await generateKeyPairSigner(); + payTo = (await generateKeyPairSigner()).address; + feePayer = (await generateKeyPairSigner()).address; +}); + +beforeEach(() => { + vi.resetAllMocks(); + nowMs = 0; + getSupportedMock.mockResolvedValue(supported(feePayer, [feePayer])); +}); + +describe('a Solana offer', () => { + it('carries the fee payer from /supported, as the SDK server scheme adds it', async () => { + const provider = makeProvider(); + const requirement = await provider.createRequirement(paymentContext()); + const offered = requirement.challenge.accepts[0] as Record; + expect(offered).toMatchObject({ network: DEVNET, asset: USDC, payTo, amount: '10000' }); + expect(offered['extra']).toEqual({ feePayer }); + + const kind = supported(feePayer).kinds[0] as SupportedResponse['kinds'][number]; + const sdk = await new ExactSvmScheme().enhancePaymentRequirements( + { ...(offered as unknown as PaymentRequirements), extra: {} }, + kind, + [], + ); + expect(sdk.extra).toEqual(offered['extra']); + expect(provider.descriptor.capabilities).toContain('exact-svm'); + expect(provider.descriptor.supportedSpec).toContain('family=solana'); + }); + + it('keeps its fee payer across snapshots while the facilitator still signs with it', async () => { + const provider = makeProvider(); + const other = (await generateKeyPairSigner()).address; + const feePayerOf = async () => + ( + (await provider.createRequirement(paymentContext())).challenge.accepts[0] as { + extra: { feePayer: string }; + } + ).extra.feePayer; + expect(await feePayerOf()).toBe(feePayer); + + nowMs += 61_000; + getSupportedMock.mockResolvedValue(supported(other, [feePayer, other])); + expect(await feePayerOf()).toBe(feePayer); + + nowMs += 61_000; + getSupportedMock.mockResolvedValue(supported(other, [other])); + expect(await feePayerOf()).toBe(other); + }); + + it('builds no challenge when the facilitator names no fee payer', async () => { + getSupportedMock.mockResolvedValue(supported(undefined)); + await expect(makeProvider().createRequirement(paymentContext())).rejects.toSatisfy( + (err: unknown) => isCommerceError(err) && err.code === 'PAYMENT_PROVIDER_UNAVAILABLE', + ); + }); + + it.each<[string, Partial, RegExp]>([ + [ + 'a payee that is not a Solana key', + { payTo: '0x1111111111111111111111111111111111111111' }, + /payTo.*Solana/, + ], + ['an EVM transfer method', { assetTransferMethod: 'permit2' }, /EVM networks only/], + ])('refuses %s at construction', (_label, change, message) => { + expect(() => + makeProvider({ + offers: [ + { network: DEVNET, rpcUrl: RPC_URL, asset: USDC, assetDecimals: 6, payTo, ...change }, + ], + }), + ).toThrow(message); + }); + + it('refuses the in-process facilitator', () => { + expect(() => + makeProvider({ facilitator: { mode: 'local', signerPrivateKey: `0x${'11'.repeat(32)}` } }), + ).toThrow(/serves the local Anvil chain/); + }); +}); + +describe('an EVM offer beside a Solana offer', () => { + const BASE_SEPOLIA_USDC = '0x036CbD53842c5426634e7929541eC2318f3dCF7e'; + const EVM_PAY_TO = '0x1111111111111111111111111111111111111111'; + const BUYER_KEY = `0x${'5d'.repeat(32)}` as const; + + it('selects the offer each proof pays, by family', async () => { + const provider = makeProvider({ + offers: [ + { + network: 'eip155:84532', + rpcUrl: 'https://base-sepolia.example', + asset: BASE_SEPOLIA_USDC, + assetName: 'USDC', + assetVersion: '2', + assetDecimals: 6, + payTo: EVM_PAY_TO, + }, + { network: DEVNET, rpcUrl: RPC_URL, asset: USDC, assetDecimals: 6, payTo }, + ], + }); + const requirement = await provider.createRequirement(paymentContext()); + const [evmOffer, svmOffer] = requirement.challenge.accepts as Record[]; + expect(requirement.destination).toBeUndefined(); + + const evmProof = await createPaymentProof({ + buyerPrivateKey: BUYER_KEY, + accepts: evmOffer ?? {}, + }); + const transaction = await buildSvmPayment({ + buyer, + feePayer, + mint: USDC, + payTo, + amount: 10_000n, + decimals: 6, + }); + const svmProof = Buffer.from( + JSON.stringify({ x402Version: 2, accepted: svmOffer, payload: { transaction } }), + ).toString('base64'); + + const select = (payload: string) => + provider.selectRequirement?.({ + requestId: 'req-1', + resource: RESOURCE, + requirement, + submission: { method: 'x402', payload }, + }); + expect(await select(evmProof)).toMatchObject({ + destination: EVM_PAY_TO, + network: 'eip155:84532', + metadata: { 'x402.offer': 0 }, + }); + expect(await select(svmProof)).toMatchObject({ + destination: payTo, + network: DEVNET, + asset: USDC, + metadata: { 'x402.offer': 1 }, + }); + }); +}); + +describe('verify()', () => { + it('passes the transaction on and keys the payment by its message', async () => { + const provider = makeProvider(); + const { context, transaction } = await paidRequest(provider); + verifyMock.mockResolvedValueOnce({ isValid: true, payer: buyer.address }); + + const result = await provider.verify(context); + expect(result).toMatchObject({ + status: 'verified', + payer: buyer.address, + payee: payTo, + amount: '0.01', + network: DEVNET, + asset: USDC, + replayKey: (svmReplayKey({ transaction }, DEVNET) as { replayKey: string }).replayKey, + }); + expect(verifyMock.mock.calls[0]?.[0]).toMatchObject({ payload: { transaction } }); + }); + + it('reads Solana addresses with their case', async () => { + const provider = makeProvider(); + const { context, requirement } = await paidRequest(provider); + const decoded = JSON.parse(Buffer.from(context.submission.payload, 'base64').toString('utf8')); + decoded.accepted.payTo = payTo.toLowerCase(); + const result = await provider.verify({ + ...context, + requirement: requirement as PaymentRequirement, + submission: { + method: 'x402', + payload: Buffer.from(JSON.stringify(decoded)).toString('base64'), + }, + }); + expect(result.rejectionReason).toBe('invalid_payment_requirements'); + expect(verifyMock).not.toHaveBeenCalled(); + }); + + // These compare the gateway's own offer with the facilitator's fee payers, or + // with the buyer's echo, which the gateway already matched + it.each([ + 'invalid_exact_svm_fee_payer_not_managed_by_facilitator', + 'invalid_exact_svm_payload_missing_fee_payer', + 'invalid_exact_svm_unsupported_scheme', + 'invalid_exact_svm_network_mismatch', + ])('treats %s as no verdict', async (invalidReason) => { + const provider = makeProvider(); + const { context } = await paidRequest(provider); + verifyMock.mockResolvedValueOnce({ isValid: false, invalidReason }); + await expect(provider.verify(context)).rejects.toSatisfy( + (err: unknown) => isCommerceError(err) && err.code === 'PAYMENT_PROVIDER_UNAVAILABLE', + ); + }); + + it('refuses a transaction that does not decode before the facilitator sees it', async () => { + const provider = makeProvider(); + const { context } = await paidRequest(provider); + const decoded = JSON.parse(Buffer.from(context.submission.payload, 'base64').toString('utf8')); + decoded.payload.transaction = Buffer.from('not a transaction').toString('base64'); + const result = await provider.verify({ + ...context, + submission: { + method: 'x402', + payload: Buffer.from(JSON.stringify(decoded)).toString('base64'), + }, + }); + expect(result.rejectionReason).toBe( + 'invalid_exact_svm_payload_transaction_could_not_be_decoded', + ); + expect(verifyMock).not.toHaveBeenCalled(); + }); +}); + +describe('settle()', () => { + async function settleWith(response: Record) { + const provider = makeProvider(); + const { context } = await paidRequest(provider); + verifyMock.mockResolvedValueOnce({ isValid: true, payer: buyer.address }); + const verification = await provider.verify(context); + settleMock.mockResolvedValueOnce({ network: DEVNET, ...response }); + return provider.settle({ ...context, verification }); + } + + const unavailable = (err: unknown) => + isCommerceError(err) && err.code === 'PAYMENT_PROVIDER_UNAVAILABLE'; + + it('settles with the transaction signature as the reference', async () => { + await expect(settleWith({ success: true, transaction: SIGNATURE })).resolves.toMatchObject({ + status: 'settled', + externalReference: SIGNATURE, + network: DEVNET, + metadata: { amountBaseUnits: '10000' }, + }); + }); + + it('keeps the payer verify() established when the settlement names the fee payer', async () => { + // The Solana scheme's settlement example calls `payer` the fee payer + await expect( + settleWith({ success: true, transaction: SIGNATURE, payer: feePayer }), + ).resolves.toMatchObject({ status: 'settled', payer: buyer.address }); + }); + + it.each<[string, Record]>([ + [ + 'a success without a Solana signature', + { success: true, transaction: `0x${'ab'.repeat(32)}` }, + ], + [ + 'a success on another network', + { success: true, transaction: SIGNATURE, network: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' }, + ], + [ + 'the broadcast catch-all without a signature', + { success: false, transaction: '', errorReason: 'invalid_exact_svm_transaction_failed' }, + ], + [ + 'a settlement already in flight for the same transaction', + { success: false, transaction: '', errorReason: 'duplicate_settlement' }, + ], + [ + 'a confirmed transaction whose transfer is not yet visible', + { + success: false, + transaction: SIGNATURE, + errorReason: 'post_settlement_transfer_not_confirmed', + }, + ], + [ + 'a broadcast that was not confirmed', + { success: false, transaction: SIGNATURE, errorReason: 'settlement_pending' }, + ], + [ + 'an unconfirmed broadcast the facilitator could not record for a retry', + { + success: false, + transaction: SIGNATURE, + errorReason: 'invalid_exact_svm_transaction_failed', + errorMessage: 'settlement_pending, but failed to persist for retry: store offline', + }, + ], + ])('leaves the outcome unknown for %s', async (_label, response) => { + await expect(settleWith(response)).rejects.toSatisfy(unavailable); + }); + + it.each<[string, Record]>([ + [ + 'a transaction that failed on chain', + { + success: false, + transaction: SIGNATURE, + errorReason: 'invalid_exact_svm_transaction_failed', + }, + ], + [ + 'a refusal before the broadcast', + { success: false, transaction: '', errorReason: 'invalid_exact_svm_payload_amount_mismatch' }, + ], + ])('records a rejection for %s', async (_label, response) => { + await expect(settleWith(response)).resolves.toMatchObject({ + status: 'rejected', + rejectionReason: response['errorReason'], + }); + }); +}); + +describe('health()', () => { + const rpc = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', async (_url: string, init: { body: string }) => { + const { method, params } = JSON.parse(init.body) as { method: string; params: unknown[] }; + return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: rpc(method, params) })); + }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const mintResult = (decimals: number) => { + const account = mintAccount(USDC, decimals, 1_000_000n); + return { + context: { slot: 1 }, + value: { + owner: account.owner, + data: [Buffer.from(account.data ?? []).toString('base64'), 'base64'], + }, + }; + }; + + it('passes for the named cluster, a mint with the configured decimals and a fee payer', async () => { + rpc.mockImplementation((method: string) => + method === 'getGenesisHash' ? DEVNET_GENESIS : mintResult(6), + ); + const health = await makeProvider().health(); + expect(health.status).toBe('pass'); + expect(health.detail).toContain(`fee payer ${feePayer}`); + }); + + it.each<[string, () => void, RegExp]>([ + [ + 'an RPC serving another cluster', + () => + rpc.mockImplementation((m: string) => + m === 'getGenesisHash' ? '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d' : mintResult(6), + ), + /serves solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp, expected solana:EtWT/, + ], + [ + 'a mint with other decimals', + () => + rpc.mockImplementation((m: string) => + m === 'getGenesisHash' ? DEVNET_GENESIS : mintResult(9), + ), + /has 9 decimals, configured 6/, + ], + [ + 'no mint at the asset address', + () => + rpc.mockImplementation((m: string) => + m === 'getGenesisHash' ? DEVNET_GENESIS : { context: { slot: 1 }, value: null }, + ), + /No initialized token mint/, + ], + [ + // The payment's transaction cannot create the account it pays into + 'a payee without a token account for the mint', + () => + rpc.mockImplementation((m: string, params: unknown[]) => { + if (m === 'getGenesisHash') return DEVNET_GENESIS; + return params[0] === USDC ? mintResult(6) : { context: { slot: 1 }, value: null }; + }), + /has no token account for mint .* create its associated token account/, + ], + [ + 'a facilitator without a fee payer', + () => { + rpc.mockImplementation((m: string) => + m === 'getGenesisHash' ? DEVNET_GENESIS : mintResult(6), + ); + getSupportedMock.mockResolvedValue(supported(undefined)); + }, + /with a fee payer/, + ], + ])('fails for %s', async (_label, arrange, detail) => { + arrange(); + const health = await makeProvider().health(); + expect(health.status).toBe('fail'); + expect(health.detail).toMatch(detail); + }); +}); diff --git a/tests/unit/payments-x402/provider.test.ts b/tests/unit/payments-x402/provider.test.ts index 2197ef9..a5e3d12 100644 --- a/tests/unit/payments-x402/provider.test.ts +++ b/tests/unit/payments-x402/provider.test.ts @@ -24,7 +24,7 @@ const BUYER_PRIVATE_KEY = function makeProvider(overrides: Partial = {}) { return createX402PaymentProvider({ - network: 'eip155:84532', + network: 'eip155:31337', rpcUrl: UNREACHABLE_RPC_URL, asset: ASSET, assetName: 'MockUSDC', @@ -68,8 +68,8 @@ describe('descriptor', () => { expect(provider.descriptor.capabilities).toContain('exact-evm'); expect(provider.descriptor.capabilities).toContain('eip-3009'); // What the provider does not implement must be listed - expect(provider.descriptor.unsupported).toContain('svm'); - expect(provider.descriptor.unsupported).toContain('permit2'); + expect(provider.descriptor.capabilities).not.toContain('permit2'); + expect(provider.descriptor.unsupported).toContain('permit2 gas sponsoring extensions'); expect(provider.descriptor.unsupported).toContain('batch-settlement scheme'); // The descriptor names the deployment, because chain id 84532 alone cannot // tell local from Base Sepolia @@ -88,14 +88,14 @@ describe('createRequirement', () => { expect(requirement.requestId).toBe('req-1'); expect(requirement.amount).toBe('0.01'); expect(requirement.destination).toBe(PAY_TO); - expect(requirement.network).toBe('eip155:84532'); + expect(requirement.network).toBe('eip155:31337'); expect(requirement.asset).toBe(ASSET); expect(requirement.challenge.provider).toBe('x402'); expect(requirement.challenge.accepts).toHaveLength(1); const accepted = requirement.challenge.accepts[0] as Record; expect(accepted['scheme']).toBe('exact'); - expect(accepted['network']).toBe('eip155:84532'); + expect(accepted['network']).toBe('eip155:31337'); expect(accepted['amount']).toBe('10000'); // 0.01 * 10^6 expect(accepted['payTo']).toBe(PAY_TO); expect(accepted['asset']).toBe(ASSET); @@ -107,8 +107,11 @@ describe('createRequirement', () => { version: '2', assetTransferMethod: 'eip3009', }); - // This marker asks the pipeline to settle after the backend. - expect(requirement.metadata).toEqual({ settleAfterBackend: true }); + // Settle after the backend, and allow retries of a payload that settled nothing + expect(requirement.metadata).toEqual({ + settleAfterBackend: true, + reuseRejectedAuthorization: true, + }); }); it('declares upfront and requests settlement before the backend', async () => { @@ -118,7 +121,10 @@ describe('createRequirement', () => { const accepted = requirement.challenge.accepts[0] as { extra: Record }; expect(accepted.extra['paymentFlow']).toBe('upfront'); - expect(requirement.metadata).toEqual({ settleAfterBackend: false }); + expect(requirement.metadata).toEqual({ + settleAfterBackend: false, + reuseRejectedAuthorization: true, + }); }); it('lets one resource override the flow', async () => { @@ -129,8 +135,8 @@ describe('createRequirement', () => { paymentContext({ resource: { ...paymentContext().resource, id: 'other.report' } }), ); - expect(overridden.metadata).toEqual({ settleAfterBackend: false }); - expect(other.metadata).toEqual({ settleAfterBackend: true }); + expect(overridden.metadata).toMatchObject({ settleAfterBackend: false }); + expect(other.metadata).toMatchObject({ settleAfterBackend: true }); }); it('carries the v2 PaymentRequired envelope, matching the accepts it lists', async () => { @@ -420,7 +426,7 @@ describe('verify - rejects before touching the network', () => { expect(result.rejectionReason).toBe('invalid_x402_version'); }); - it('rejects a Permit2 payload - the challenge asks for EIP-3009', async () => { + it('rejects a Permit2 payload for an EIP-3009 offer', async () => { const provider = makeProvider(); const requirement = await provider.createRequirement(paymentContext()); const permit2Payload = { @@ -508,6 +514,40 @@ describe('verify - rejects before touching the network', () => { await expect(verifyOnce(garbage)).resolves.toMatchObject({ status: 'rejected' }); } }); + + // The SDK client writes decimal uint256 strings and a 32-byte hex nonce; any + // other spelling is refused before it can reach the replay key or the receipt + it.each([ + ['a short nonce', { nonce: '0x1234' }], + ['an uppercase 0X nonce', { nonce: `0X${'11'.repeat(32)}` }], + ['a 33-byte nonce', { nonce: `0x${'11'.repeat(33)}` }], + ['a numeric nonce', { nonce: 12345 }], + ['a hex value', { value: '0x2710' }], + ['a padded value', { value: '010000' }], + ['a value with whitespace', { value: ' 10000 ' }], + ['a signed validBefore', { validBefore: '+99999999999' }], + ])('refuses an EIP-3009 authorization with %s', async (_label, change) => { + const provider = makeProvider(); + const requirement = await provider.createRequirement(paymentContext()); + const proof = await createPaymentProof({ + buyerPrivateKey: BUYER_PRIVATE_KEY, + accepts: requirement.challenge.accepts[0] as Record, + }); + const decoded = JSON.parse(Buffer.from(proof, 'base64').toString('utf8')); + decoded.payload.authorization = { ...decoded.payload.authorization, ...change }; + + await expect( + provider.verify({ + requestId: 'req-1', + resource: RESOURCE, + requirement, + submission: { + method: 'x402', + payload: Buffer.from(JSON.stringify(decoded)).toString('base64'), + }, + }), + ).resolves.toMatchObject({ status: 'rejected', rejectionReason: 'invalid_payload' }); + }); }); describe('verify - provider unavailable', () => { @@ -611,6 +651,26 @@ describe('createX402PaymentProvider - Anvil dev-key guards', () => { }); }); +describe('createX402PaymentProvider - address checks', () => { + // Config load checks the shape only; the provider also checks the EIP-55 + // checksum, and a message never repeats the value, which could be a key + it.each(['payTo', 'asset'] as const)( + 'refuses a %s with a bad checksum without echoing it', + (field) => { + const badChecksum = '0x111111111111111111111111111111111111ABcd'; + let thrown: unknown; + try { + makeProvider({ [field]: badChecksum }); + } catch (err) { + thrown = err; + } + expect(isCommerceError(thrown) && thrown.code).toBe('CONFIG_INVALID'); + expect((thrown as Error).message).toContain(`"${field}" is not a valid EVM address`); + expect((thrown as Error).message).not.toContain(badChecksum); + }, + ); +}); + describe('createX402PaymentProvider - deployment guardrails', () => { // The provider applies these itself, for a library caller who never runs the // config loader. Each case breaks one term of a valid Base mainnet deployment. diff --git a/tests/unit/payments-x402/replay-key.test.ts b/tests/unit/payments-x402/replay-key.test.ts index d311355..a97b11a 100644 --- a/tests/unit/payments-x402/replay-key.test.ts +++ b/tests/unit/payments-x402/replay-key.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { computeReplayKey } from '../../../src/payments/x402/replay-key'; +import { computePermit2ReplayKey, computeReplayKey } from '../../../src/payments/x402/replay-key'; const BASE = { - chainId: 84532, + chainId: 31337, asset: '0x5FbDB2315678afecb367f032d93F642f64180aa3' as const, from: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' as const, nonce: `0x${'11'.repeat(32)}` as const, @@ -52,3 +52,20 @@ describe('computeReplayKey', () => { expect(key).toMatch(/^0x[0-9a-f]{64}$/); }); }); + +describe('computePermit2ReplayKey', () => { + const PERMIT2 = { chainId: 31337, owner: BASE.from, nonce: '42' }; + + it('changes with the chain, the owner and the nonce', () => { + const key = computePermit2ReplayKey(PERMIT2); + expect(computePermit2ReplayKey({ ...PERMIT2 })).toBe(key); + expect(computePermit2ReplayKey({ ...PERMIT2, chainId: 1 })).not.toBe(key); + expect(computePermit2ReplayKey({ ...PERMIT2, owner: BASE.asset })).not.toBe(key); + expect(computePermit2ReplayKey({ ...PERMIT2, nonce: '43' })).not.toBe(key); + }); + + it('never equals the EIP-3009 key for the same chain, payer and nonce value', () => { + const nonce = `0x${(42).toString(16).padStart(64, '0')}` as const; + expect(computePermit2ReplayKey(PERMIT2)).not.toBe(computeReplayKey({ ...BASE, nonce })); + }); +}); diff --git a/tests/unit/payments-x402/sdk-entry-points.test.ts b/tests/unit/payments-x402/sdk-entry-points.test.ts new file mode 100644 index 0000000..869af87 --- /dev/null +++ b/tests/unit/payments-x402/sdk-entry-points.test.ts @@ -0,0 +1,128 @@ +/** + * Check the pinned SDK entry points and behavior used by the multi-network + * design. Review that design before accepting a pin change that fails these checks. + */ +import { HTTPFacilitatorClient } from '@x402/core/http'; +import { x402ResourceServer } from '@x402/core/server'; +import type { SchemeNetworkServer } from '@x402/core/types'; +import { + getDefaultAsset as evmDefaultAsset, + isEIP3009Payload, + isPermit2Payload, + PERMIT2_ADDRESS, + x402ExactPermit2ProxyAddress, +} from '@x402/evm'; +import { ExactEvmScheme } from '@x402/evm/exact/server'; +import { + decodeSignedDelegateB64, + getDefaultAsset as nearDefaultAsset, + parseFtTransferArgs, +} from '@x402/near'; +import { ExactNearScheme } from '@x402/near/exact/server'; +import { + decodeTransactionFromPayload, + getDefaultAsset as svmDefaultAsset, + transactionMessageHash, +} from '@x402/svm'; +import { ExactSvmScheme } from '@x402/svm/exact/server'; +import { describe, expect, it } from 'vitest'; +import { BUILT_IN_NETWORKS } from '../../../src/payments/x402/networks'; + +const BASE = 'eip155:8453'; +const SOLANA_DEVNET = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'; +const NEAR_TESTNET = 'near:testnet'; + +describe('pinned x402 SDK entry points', () => { + it('builds and matches offers through the resource server', () => { + expect(typeof x402ResourceServer.prototype.buildPaymentRequirements).toBe('function'); + expect(typeof x402ResourceServer.prototype.findMatchingRequirements).toBe('function'); + expect(typeof HTTPFacilitatorClient.prototype.getSupported).toBe('function'); + }); + + it('tells EIP-3009 from Permit2 payloads and names the canonical Permit2 contracts', () => { + expect(isEIP3009Payload({ authorization: {} } as never)).toBe(true); + expect(isPermit2Payload({ permit2Authorization: {} } as never)).toBe(true); + expect(PERMIT2_ADDRESS).toBe('0x000000000022D473030F116dDEE9F6B43aC78BA3'); + expect(x402ExactPermit2ProxyAddress).toBe('0x402085c248EeA27D92E8b30b2C58ed07f9E20001'); + }); + + it('exports what the Solana and NEAR replay identities read', () => { + expect(typeof decodeTransactionFromPayload).toBe('function'); + expect(typeof transactionMessageHash).toBe('function'); + expect(typeof decodeSignedDelegateB64).toBe('function'); + expect( + parseFtTransferArgs(new TextEncoder().encode('{"receiver_id":"m.near","amount":"1"}')), + ).toEqual({ + receiver_id: 'm.near', + amount: '1', + }); + }); + + it('adds nothing to a NEAR offer: the facilitator picks its relayer', async () => { + const offer = { + scheme: 'exact', + network: NEAR_TESTNET, + asset: 'usdc.testnet', + amount: '1', + payTo: 'm.testnet', + maxTimeoutSeconds: 60, + extra: {}, + } as const; + const kind = { x402Version: 2, scheme: 'exact', network: NEAR_TESTNET } as const; + expect( + await new ExactNearScheme().enhancePaymentRequirements(offer as never, kind as never, []), + ).toEqual(offer); + }); + + it('marks only the Solana blockhash hints as dynamic for offer matching', () => { + // Read as the resource server reads them, through the scheme interface + const dynamic = (scheme: SchemeNetworkServer) => scheme.dynamicExtraFields; + expect(dynamic(new ExactSvmScheme())).toEqual(['recentBlockhash', 'lastValidBlockHeight']); + expect(dynamic(new ExactEvmScheme())).toBeUndefined(); + expect(dynamic(new ExactNearScheme())).toBeUndefined(); + }); + + it('resolves a dollar price to each network default stablecoin', async () => { + expect(await new ExactEvmScheme().parsePrice('$0.01', BASE)).toEqual({ + amount: '10000', + asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + extra: { name: 'USD Coin', version: '2' }, + }); + expect(await new ExactSvmScheme().parsePrice('$0.01', SOLANA_DEVNET)).toMatchObject({ + amount: '10000', + asset: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + }); + expect(await new ExactNearScheme().parsePrice('$0.01', NEAR_TESTNET)).toMatchObject({ + amount: '10000', + asset: '3e2210e1184b45b64c8a434c0a7e7b23cc04ea7eb7a6c3c32520d03d4afcb8af', + }); + }); + + // Config load cannot import the SDK, so the profiles repeat its default assets + // The local Anvil chain has no default asset and is skipped + it.each(BUILT_IN_NETWORKS.filter((n) => n.canonicalAsset).map((n) => [n.id, n] as const))( + '%s defaults to the asset the SDK names', + (_id, network) => { + const id = network.id as `${string}:${string}`; + if (network.family === 'evm') { + const sdk = evmDefaultAsset(id); + const { asset, name, version, decimals, symbol } = sdk; + expect(network.canonicalAsset).toEqual({ symbol, address: asset, name, version, decimals }); + // A default asset settles through EIP-3009, the method an option without one gets + expect(sdk.assetTransferMethod ?? 'eip3009').toBe('eip3009'); + return; + } + const sdk = network.family === 'svm' ? svmDefaultAsset(id) : nearDefaultAsset(id); + expect(network.canonicalAsset).toEqual({ + symbol: sdk.symbol, + address: sdk.asset, + decimals: sdk.decimals, + }); + }, + ); + + it('truncates excess precision to zero, so config load must refuse it first', async () => { + const parsed = await new ExactEvmScheme().parsePrice('$0.0000001', BASE); + expect(parsed.amount).toBe('0'); + }); +}); diff --git a/tests/unit/payments-x402/smoke-targets.test.ts b/tests/unit/payments-x402/smoke-targets.test.ts new file mode 100644 index 0000000..6c80a67 --- /dev/null +++ b/tests/unit/payments-x402/smoke-targets.test.ts @@ -0,0 +1,190 @@ +// Which public-network smoke targets run, decided offline before anything is spent +import { generateKeyPairSync } from 'node:crypto'; +import { getBase58Decoder } from '@solana/kit'; +import { describe, expect, it } from 'vitest'; +import { buyerIdentity } from '../../fixtures/x402/smoke/buyers'; +import { planSmoke, SMOKE_TARGETS, type SmokeSettings } from '../../fixtures/x402/smoke/targets'; + +const EVM = { + X402_TESTNET_BUYER_PRIVATE_KEY: `0x${'ab'.repeat(32)}`, + X402_TESTNET_MERCHANT_ADDRESS: '0x1111111111111111111111111111111111111111', +}; +const NEAR = { + X402_NEAR_TESTNET_BUYER_ACCOUNT: 'buyer.testnet', + X402_NEAR_TESTNET_BUYER_PRIVATE_KEY: 'ed25519:buyer-key', + X402_NEAR_TESTNET_MERCHANT_ACCOUNT: 'merchant.testnet', +}; +const BASE_MAINNET = { + ALLOW_X402_MAINNET: 'true', + X402_MAINNET_BUYER_PRIVATE_KEY: `0x${'cd'.repeat(32)}`, + X402_MAINNET_MERCHANT_ADDRESS: '0x2222222222222222222222222222222222222222', + X402_FACILITATOR_URL: 'https://facilitator.example.com', +}; + +const ids = (plan: ReturnType) => plan.run.map(({ target }) => target.id); + +describe('planSmoke', () => { + it('runs nothing without settings, and names what each target needs', () => { + const testnet = planSmoke('testnet', {}); + expect(testnet).toMatchObject({ run: [], errors: [] }); + expect(testnet.skipped.find(({ id }) => id === 'solana-devnet')?.missing).toEqual([ + 'X402_SOLANA_DEVNET_BUYER_SECRET_KEY', + 'X402_SOLANA_DEVNET_MERCHANT_ADDRESS', + ]); + // Mainnet also needs its targets named + expect(planSmoke('mainnet', BASE_MAINNET).skipped[0]?.missing).toEqual(['X402_SMOKE_TARGETS']); + }); + + it('runs the testnet targets whose settings are present, leaving the prepared ones to a name', () => { + const plan = planSmoke('testnet', EVM); + expect(ids(plan)).toEqual(['base-sepolia']); + // Permit2 needs an approval and Arbitrum Sepolia its own USDC + for (const id of ['base-sepolia-permit2', 'arbitrum-sepolia']) { + expect(plan.skipped.find((skip) => skip.id === id)?.missing).toEqual(['X402_SMOKE_TARGETS']); + } + expect( + ids( + planSmoke('testnet', { + ...EVM, + X402_SMOKE_TARGETS: 'base-sepolia-permit2,arbitrum-sepolia', + }), + ), + ).toEqual(['base-sepolia-permit2', 'arbitrum-sepolia']); + }); + + it('fails a named target that is unknown, of the other kind, or missing a setting', () => { + const plan = planSmoke('testnet', { + ...EVM, + X402_SMOKE_TARGETS: 'base-sepolia, solana-devnet,base', + }); + expect(ids(plan)).toEqual(['base-sepolia']); + expect(plan.errors).toEqual([ + 'smoke target "solana-devnet" needs X402_SOLANA_DEVNET_BUYER_SECRET_KEY, X402_SOLANA_DEVNET_MERCHANT_ADDRESS', + expect.stringMatching(/^unknown testnet smoke target "base"/), + ]); + }); + + it('moves real funds only with ALLOW_X402_MAINNET set to exactly true', () => { + const named = { ...BASE_MAINNET, X402_SMOKE_TARGETS: 'base' }; + expect(ids(planSmoke('mainnet', named))).toEqual(['base']); + expect(planSmoke('mainnet', { ...named, ALLOW_X402_MAINNET: 'yes' }).errors).toEqual([ + 'smoke target "base" needs ALLOW_X402_MAINNET=true', + ]); + }); + + it('pays Base mainnet through Permit2 when base-permit2 is named', () => { + const plan = planSmoke('mainnet', { ...BASE_MAINNET, X402_SMOKE_TARGETS: 'base-permit2' }); + expect(plan.run.map(({ target }) => [target.id, target.assetTransferMethod])).toEqual([ + ['base-permit2', 'permit2'], + ]); + }); + + it('refuses a payment above its cap, a run above its total, and a malformed amount', () => { + const named = { ...EVM, X402_SMOKE_TARGETS: 'base-sepolia,arbitrum-sepolia' }; + expect(planSmoke('testnet', { ...named, X402_SMOKE_AMOUNT: '1.01' }).errors).toContainEqual( + expect.stringMatching(/above the testnet cap of 1\.00/), + ); + expect( + planSmoke('testnet', { ...named, X402_SMOKE_AMOUNT: '0.30', X402_SMOKE_MAX_TOTAL: '0.50' }) + .errors, + ).toEqual([expect.stringMatching(/more than X402_SMOKE_MAX_TOTAL allows \(0\.50 USDC\)/)]); + expect(planSmoke('testnet', { ...named, X402_SMOKE_AMOUNT: '$1' }).errors).toContainEqual( + expect.stringMatching(/not a decimal USDC amount/), + ); + const mainnet = { ...BASE_MAINNET, X402_SMOKE_TARGETS: 'base', X402_SMOKE_AMOUNT: '0.11' }; + expect(planSmoke('mainnet', mainnet).errors).toContainEqual( + expect.stringMatching(/above the mainnet cap of 0\.10/), + ); + }); + + it('runs its own NEAR facilitator unless one is named, and keeps every key secret', () => { + const selfHosted = planSmoke('testnet', { + ...NEAR, + X402_NEAR_TESTNET_RELAYER_ACCOUNT: 'relayer.testnet', + X402_NEAR_TESTNET_RELAYER_PRIVATE_KEY: 'ed25519:relayer-key', + X402_SMOKE_TARGETS: 'near-testnet', + }).run[0]?.settings; + expect(selfHosted?.facilitator).toMatchObject({ type: 'self-hosted-near' }); + expect(selfHosted?.secrets).toEqual(['ed25519:buyer-key', 'ed25519:relayer-key']); + + const remote = planSmoke('testnet', { + ...NEAR, + X402_NEAR_TESTNET_FACILITATOR_URL: 'https://near-facilitator.example.com', + X402_SMOKE_TARGETS: 'near-testnet', + }); + expect(remote.errors).toEqual([]); + expect(remote.run[0]?.settings.facilitator).toMatchObject({ type: 'remote' }); + }); + + it('pays NEAR mainnet only when allowed, through its own relayer or a named facilitator', () => { + const near = { + X402_NEAR_MAINNET_BUYER_ACCOUNT: 'b'.repeat(64), + X402_NEAR_MAINNET_BUYER_PRIVATE_KEY: 'ed25519:buyer-key', + X402_NEAR_MAINNET_MERCHANT_ACCOUNT: 'merchant.near', + X402_NEAR_MAINNET_RELAYER_ACCOUNT: 'relayer.near', + X402_NEAR_MAINNET_RELAYER_PRIVATE_KEY: 'ed25519:relayer-key', + X402_SMOKE_TARGETS: 'near', + }; + expect(planSmoke('mainnet', near).errors).toEqual([ + 'smoke target "near" needs ALLOW_X402_MAINNET', + ]); + const selfHosted = planSmoke('mainnet', { ...near, ALLOW_X402_MAINNET: 'true' }).run[0]; + expect(selfHosted?.target).toMatchObject({ network: 'near:mainnet', kind: 'mainnet' }); + expect(selfHosted?.settings).toMatchObject({ + rpcUrl: 'https://rpc.mainnet.fastnear.com', + facilitator: { type: 'self-hosted-near', relayerAccount: 'relayer.near' }, + secrets: ['ed25519:buyer-key', 'ed25519:relayer-key'], + }); + + const remote = planSmoke('mainnet', { + ...near, + ALLOW_X402_MAINNET: 'true', + X402_NEAR_MAINNET_FACILITATOR_URL: 'https://near-facilitator.example.com', + X402_NEAR_MAINNET_FACILITATOR_TOKEN: 'near-token', + }).run[0]?.settings; + expect(remote?.facilitator).toEqual({ + type: 'remote', + url: 'https://near-facilitator.example.com', + auth: { type: 'bearer', token: 'near-token' }, + }); + expect(remote?.secrets).toEqual(['ed25519:buyer-key', 'near-token']); + }); + + it("sends a mainnet facilitator credential and keeps it out of the logs' haystack", () => { + const settings = planSmoke('mainnet', { + ...BASE_MAINNET, + X402_SMOKE_TARGETS: 'base', + CDP_API_KEY_ID: 'key-id', + CDP_API_KEY_SECRET: 'key-secret', + }).run[0]?.settings; + expect(settings?.facilitator).toMatchObject({ auth: { type: 'cdp', apiKeyId: 'key-id' } }); + expect(settings?.secrets).toContain('key-secret'); + }); +}); + +describe('buyerIdentity', () => { + const target = (id: string) => SMOKE_TARGETS.find((candidate) => candidate.id === id) as never; + const settings = (buyerKey: string, buyerAccount?: string) => + ({ buyerKey, ...(buyerAccount ? { buyerAccount } : {}) }) as SmokeSettings; + + it('reads a Solana secret key as solana-keygen writes it or as a wallet exports it', async () => { + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const seed = Buffer.from(privateKey.export({ format: 'jwk' }).d as string, 'base64url'); + const raw = Buffer.from(publicKey.export({ format: 'jwk' }).x as string, 'base64url'); + const secret = new Uint8Array([...seed, ...raw]); + const address = getBase58Decoder().decode(raw); + + const devnet = target('solana-devnet'); + expect(await buyerIdentity(devnet, settings(JSON.stringify([...secret])))).toBe(address); + expect(await buyerIdentity(devnet, settings(getBase58Decoder().decode(secret)))).toBe(address); + }); + + it('derives an EVM address from the key and takes a NEAR account as configured', async () => { + expect(await buyerIdentity(target('base-sepolia'), settings(`0x${'11'.repeat(32)}`))).toBe( + '0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A', + ); + expect( + await buyerIdentity(target('near-testnet'), settings('ed25519:key', 'buyer.testnet')), + ).toBe('buyer.testnet'); + }); +}); diff --git a/tests/unit/payments-x402/svm.test.ts b/tests/unit/payments-x402/svm.test.ts new file mode 100644 index 0000000..a52f052 --- /dev/null +++ b/tests/unit/payments-x402/svm.test.ts @@ -0,0 +1,75 @@ +import { generateKeyPairSigner } from '@solana/kit'; +import { decodeTransactionFromPayload, transactionMessageHash } from '@x402/svm'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { computeSvmReplayKey } from '../../../src/payments/x402/replay-key'; +import { associatedTokenAccount, svmReplayKey } from '../../../src/payments/x402/svm'; +import { buildSvmPayment, withSignatureBytes } from '../../fixtures/x402/svm-transaction'; + +const DEVNET = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'; +const MAINNET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; +const USDC = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; + +let transaction: string; +let other: string; + +beforeAll(async () => { + const buyer = await generateKeyPairSigner(); + const input = { + buyer, + feePayer: (await generateKeyPairSigner()).address, + mint: USDC, + payTo: (await generateKeyPairSigner()).address, + amount: 10_000n, + decimals: 6, + }; + transaction = await buildSvmPayment(input); + other = await buildSvmPayment({ ...input, memo: 'another payment' }); +}); + +const keyOf = (value: string, network = DEVNET) => svmReplayKey({ transaction: value }, network); + +// Expected values from `findAssociatedTokenPda` in @solana-program/token and +// token-2022, the helper the SDK client uses to address the payee +describe('associatedTokenAccount', () => { + it.each([ + ['TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', 'Ah4G934QoGavLuFxq3mEAqSK5WeWubrTQnoFqHKUznWW'], + ['TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', '9tMS3soKEbZruwgPBFWq9T2yBgvNY48had8vxRtWiM7E'], + ])('derives the account the SDK client pays under %s', async (tokenProgram, expected) => { + await expect( + associatedTokenAccount('Dupg3J9doyGhLSgjKXp6BsqpqyGJtvSyBQwwVbkBr3Sf', USDC, tokenProgram), + ).resolves.toBe(expected); + }); +}); + +describe('svmReplayKey', () => { + it('hashes the message bytes the SDK hashes, under the network', () => { + const messageHash = transactionMessageHash(decodeTransactionFromPayload({ transaction })); + expect(keyOf(transaction)).toEqual({ + replayKey: computeSvmReplayKey({ network: DEVNET, messageHash }), + }); + }); + + it('keeps the key when the fee payer slot or any other signature changes', () => { + // The facilitator fills slot 0 when it settles; a resubmission with other + // signature bytes is the same payment + expect(keyOf(withSignatureBytes(transaction, 0, 0xab))).toEqual(keyOf(transaction)); + expect(keyOf(withSignatureBytes(transaction, 1, 0xcd))).toEqual(keyOf(transaction)); + }); + + it('gives another message or another network another key', () => { + expect(keyOf(other)).not.toEqual(keyOf(transaction)); + expect(keyOf(transaction, MAINNET)).not.toEqual(keyOf(transaction)); + }); + + it('refuses a payload that is not one bounded, decodable transaction', () => { + expect(svmReplayKey('x', DEVNET)).toBe('invalid_exact_svm_payload_transaction'); + expect(svmReplayKey({}, DEVNET)).toBe('invalid_exact_svm_payload_transaction'); + expect(keyOf('A'.repeat(1645))).toBe('invalid_exact_svm_payload_transaction'); + expect(keyOf('not base64 at all!')).toBe( + 'invalid_exact_svm_payload_transaction_could_not_be_decoded', + ); + expect(keyOf(Buffer.from([1, 2, 3]).toString('base64'))).toBe( + 'invalid_exact_svm_payload_transaction_could_not_be_decoded', + ); + }); +}); diff --git a/tests/unit/protocols-a2a/pipeline-mapping.test.ts b/tests/unit/protocols-a2a/pipeline-mapping.test.ts index 42cc458..735c1c7 100644 --- a/tests/unit/protocols-a2a/pipeline-mapping.test.ts +++ b/tests/unit/protocols-a2a/pipeline-mapping.test.ts @@ -81,7 +81,7 @@ const paymentRequired: ExecutionOutcome = { amount: '0.01', currency: 'USDC', destination: '0x1111111111111111111111111111111111111111', - network: 'eip155:84532', + network: 'eip155:31337', asset: '0x2222222222222222222222222222222222222222', expiresAt: '2026-01-01T00:05:00.000Z', challenge: { provider: 'x402', version: '2', accepts: [{ scheme: 'exact' }] }, @@ -302,7 +302,7 @@ describe('A2A SendMessage onto the execution pipeline', () => { amount: '0.01', currency: 'USDC', destination: '0x1111111111111111111111111111111111111111', - network: 'eip155:84532', + network: 'eip155:31337', asset: '0x2222222222222222222222222222222222222222', expiresAt: '2026-01-01T00:05:00.000Z', accepts: [{ scheme: 'exact' }], diff --git a/tests/unit/protocols-a2a/x402-extension.test.ts b/tests/unit/protocols-a2a/x402-extension.test.ts index 005ec1b..54c8921 100644 --- a/tests/unit/protocols-a2a/x402-extension.test.ts +++ b/tests/unit/protocols-a2a/x402-extension.test.ts @@ -59,17 +59,28 @@ describe('pending x402 payments', () => { expect(pending.get('task-1')).toBeUndefined(); }); - it('evicts the oldest purchase when full', () => { + it('refuses a new purchase when full, keeping the live ones', () => { const pending = createPendingPayments(clockAt(0).clock, 2); - pending.put('task-1', PURCHASE); - pending.put('task-2', PURCHASE); - pending.put('task-3', PURCHASE); + expect(pending.put('task-1', PURCHASE)).toBe(true); + expect(pending.put('task-2', PURCHASE)).toBe(true); + expect(pending.put('task-3', PURCHASE)).toBe(false); - expect(pending.get('task-1')).toBeUndefined(); + expect(pending.get('task-1')).toBeDefined(); expect(pending.get('task-2')).toBeDefined(); - expect(pending.get('task-3')).toBeDefined(); + expect(pending.get('task-3')).toBeUndefined(); expect(pending.size()).toBe(2); }); + + it('takes a new purchase once an expired one frees its slot', () => { + const time = clockAt(Date.parse('2026-10-05T00:00:00Z')); + const pending = createPendingPayments(time.clock, 1); + expect(pending.put('task-1', PURCHASE, '2026-10-05T00:01:00Z')).toBe(true); + expect(pending.put('task-2', PURCHASE)).toBe(false); + + time.advance(60_000); + expect(pending.put('task-2', PURCHASE)).toBe(true); + expect(pending.get('task-2')).toBeDefined(); + }); }); describe('payment messages', () => { @@ -90,7 +101,7 @@ describe('payment messages', () => { status: 'settled', provider: 'x402', currency: 'USDC', - network: 'eip155:84532', + network: 'eip155:31337', externalReference: '0xabc', }, }, @@ -123,18 +134,30 @@ describe('payment messages', () => { }); }); + it('reports a replay with the code of the network it paid', () => { + const replay = new CommerceError('PAYMENT_REPLAYED', 'Already used', { + httpStatus: 402, + details: { attemptStatus: 'settled', network: 'near:testnet' }, + }); + + expect(paymentFailureMetadata(replay)).toMatchObject({ + 'x402.payment.error': 'invalid_exact_near_payload_delegate_action_nonce_already_used', + 'x402.payment.receipts': [{ success: false, network: 'near:testnet' }], + }); + }); + it('fills an empty receipt network from the requirement and keeps one the error names', () => { const refused = new CommerceError('PAYMENT_INVALID', 'Refused', { details: { reason: 'insufficient_funds' }, }); - expect(paymentFailureMetadata(refused, 'eip155:84532')).toMatchObject({ - 'x402.payment.receipts': [{ success: false, network: 'eip155:84532' }], + expect(paymentFailureMetadata(refused, 'eip155:31337')).toMatchObject({ + 'x402.payment.receipts': [{ success: false, network: 'eip155:31337' }], }); const named = new CommerceError('PAYMENT_SETTLEMENT_FAILED', 'Refused', { details: { reason: 'unexpected_settle_error', network: 'eip155:8453' }, }); - expect(paymentFailureMetadata(named, 'eip155:84532')).toMatchObject({ + expect(paymentFailureMetadata(named, 'eip155:31337')).toMatchObject({ 'x402.payment.receipts': [{ network: 'eip155:8453' }], }); }); diff --git a/tests/unit/protocols-acp/fixtures.ts b/tests/unit/protocols-acp/fixtures.ts index d03bb37..4e5bf58 100644 --- a/tests/unit/protocols-acp/fixtures.ts +++ b/tests/unit/protocols-acp/fixtures.ts @@ -116,7 +116,7 @@ export const paymentRequired: ExecutionOutcome = { amount: '0.01', currency: 'USDC', destination: '0x1111111111111111111111111111111111111111', - network: 'eip155:84532', + network: 'eip155:31337', asset: '0x2222222222222222222222222222222222222222', expiresAt: '2026-01-01T00:05:00.000Z', challenge: { provider: 'x402', version: '2', accepts: [{ scheme: 'exact' }] }, diff --git a/tests/unit/protocols-mcp/result-mapping.test.ts b/tests/unit/protocols-mcp/result-mapping.test.ts new file mode 100644 index 0000000..31c803f --- /dev/null +++ b/tests/unit/protocols-mcp/result-mapping.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { CommerceError } from '../../../src/core'; +import { errorResult } from '../../../src/protocols/mcp/result-mapping'; + +describe('errorResult', () => { + // Finished replays return a fresh challenge with the paid network's replay code + it.each([ + ['near:testnet', 'invalid_exact_near_payload_delegate_action_nonce_already_used'], + ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', 'duplicate_settlement'], + ['eip155:84532', 'invalid_exact_evm_nonce_already_used'], + ])('gives an x402 replay on %s its family code', (network, code) => { + const replay = new CommerceError( + 'PAYMENT_REPLAYED', + 'Payment authorization has already been used', + { + httpStatus: 402, + details: { attemptStatus: 'settled', network, challenge: { x402Version: 2, accepts: [] } }, + }, + ); + + const result = errorResult(replay, 'x402'); + + expect(result._meta?.['x402/error']).toMatchObject({ error: code }); + }); +}); diff --git a/tests/unit/storage-receipts/no-secrets.test.ts b/tests/unit/storage-receipts/no-secrets.test.ts index ba87631..e1345fa 100644 --- a/tests/unit/storage-receipts/no-secrets.test.ts +++ b/tests/unit/storage-receipts/no-secrets.test.ts @@ -231,7 +231,7 @@ describe('bearer-token-shaped keys', () => { payTo: '0xdef', requestId: 'req_1', resourceId: 'market_report', - network: 'eip155:84532', + network: 'eip155:31337', asset: '0x123', status: 'settled', txHash: '0xfeed', diff --git a/tests/unit/storage-receipts/payment-attempts.test.ts b/tests/unit/storage-receipts/payment-attempts.test.ts index c5d44d2..e9e6253 100644 --- a/tests/unit/storage-receipts/payment-attempts.test.ts +++ b/tests/unit/storage-receipts/payment-attempts.test.ts @@ -1,6 +1,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { isCommerceError, @@ -22,6 +23,9 @@ function makeReservation( currency: overrides.currency ?? 'USDC', ...(overrides.payer !== undefined ? { payer: overrides.payer } : {}), ...(overrides.payee !== undefined ? { payee: overrides.payee } : {}), + ...(overrides.reclaimRejected !== undefined + ? { reclaimRejected: overrides.reclaimRejected } + : {}), }; } @@ -172,6 +176,189 @@ describe('reservePaymentAttempt', () => { }); }); +// Rails that permit reusing an authorization can reclaim an attempt that was +// rejected, after a backend failure or a refused settlement: it settled nothing +describe('reclaiming a rejected attempt', () => { + const retry = { reclaimRejected: true } as const; + async function rejected( + target: ReceiptStore, + replayKey: string, + rejectionReason = 'backend_failed', + ): Promise { + await target.reservePaymentAttempt(makeReservation({ replayKey })); + await target.updatePaymentAttempt({ replayKey, status: 'rejected', rejectionReason }); + } + + let store: ReceiptStore; + + beforeEach(async () => { + store = createSqliteReceiptStore({ + path: ':memory:', + clock: createFakeClock(), + ids: createFakeIds(), + }); + await store.init(); + }); + + afterEach(async () => { + await store.close(); + }); + + it.each([ + 'backend_failed', + 'invalid_exact_near_payload_delegate_action_expired', + 'insufficient_funds', + ])('turns an attempt rejected with %s into the retry reservation', async (reason) => { + await rejected(store, 'retry', reason); + + const reclaimed = await store.reservePaymentAttempt( + makeReservation({ replayKey: 'retry', requestId: 'req_2', payer: '0xb', ...retry }), + ); + + expect(reclaimed).toMatchObject({ + id: 'attempt_2', + requestId: 'req_2', + replayKey: 'retry', + status: 'reserved', + payer: '0xb', + }); + expect(reclaimed).not.toHaveProperty('rejectionReason'); + expect(await store.listPaymentAttempts()).toEqual([reclaimed]); + }); + + it('refuses it to a reservation that does not ask, or to another provider', async () => { + await rejected(store, 'single-use'); + + for (const reservation of [ + makeReservation({ replayKey: 'single-use', requestId: 'req_2' }), + makeReservation({ replayKey: 'single-use', requestId: 'req_2', provider: 'mpp', ...retry }), + ]) { + await expect(store.reservePaymentAttempt(reservation)).rejects.toMatchObject({ + code: 'PAYMENT_REPLAYED', + details: { attemptStatus: 'rejected' }, + }); + } + }); + + it('still refuses an attempt that is not rejected', async () => { + const cases = [ + { status: 'reserved' }, + { status: 'verified' }, + { status: 'settled' }, + { status: 'settlement-uncertain' }, + { status: 'failed' }, + { status: 'settled', rejectionReason: 'backend_failed' }, + ] as const; + for (const [index, update] of cases.entries()) { + const replayKey = `kept_${index}`; + await store.reservePaymentAttempt(makeReservation({ replayKey })); + if (update.status !== 'reserved') await store.updatePaymentAttempt({ replayKey, ...update }); + + await expect( + store.reservePaymentAttempt(makeReservation({ replayKey, requestId: 'req_2', ...retry })), + JSON.stringify(update), + ).rejects.toMatchObject({ + code: 'PAYMENT_REPLAYED', + details: { attemptStatus: update.status }, + }); + } + }); + + // The reclaim overwrites the attempt's row, so the row it replaces is kept + it('keeps each attempt a reclaim replaces in payment_attempt_history', async () => { + const dir = mkdtempSync(join(tmpdir(), 'oac-history-')); + const path = join(dir, 'receipts.sqlite'); + const file = createSqliteReceiptStore({ path, clock: createFakeClock(), ids: createFakeIds() }); + try { + await rejected(file, 'twice', 'insufficient_funds'); + await file.reservePaymentAttempt( + makeReservation({ replayKey: 'twice', requestId: 'req_2', ...retry }), + ); + await file.updatePaymentAttempt({ + replayKey: 'twice', + status: 'rejected', + rejectionReason: 'backend_failed', + externalReference: '0xfailed', + }); + await file.reservePaymentAttempt( + makeReservation({ replayKey: 'twice', requestId: 'req_3', ...retry }), + ); + // A refused reclaim archives nothing + await rejected(file, 'other'); + await expect( + file.reservePaymentAttempt( + makeReservation({ replayKey: 'other', requestId: 'req_4', provider: 'mpp', ...retry }), + ), + ).rejects.toMatchObject({ code: 'PAYMENT_REPLAYED' }); + + const db = new Database(path, { readonly: true }); + const history = db + .prepare( + 'SELECT request_id, replay_key, status, rejection_reason, external_reference, superseded_at FROM payment_attempt_history ORDER BY seq', + ) + .all(); + db.close(); + expect(history).toEqual([ + expect.objectContaining({ + request_id: 'req_1', + replay_key: 'twice', + status: 'rejected', + rejection_reason: 'insufficient_funds', + external_reference: null, + }), + expect.objectContaining({ + request_id: 'req_2', + replay_key: 'twice', + status: 'rejected', + rejection_reason: 'backend_failed', + external_reference: '0xfailed', + }), + ]); + expect( + history.every( + (row) => typeof (row as { superseded_at: unknown }).superseded_at === 'string', + ), + ).toBe(true); + expect((await file.listPaymentAttempts()).find((a) => a.replayKey === 'twice')).toMatchObject( + { requestId: 'req_3', status: 'reserved' }, + ); + } finally { + await file.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('lets one of two stores on the same file reclaim it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'oac-reclaim-')); + const path = join(dir, 'receipts.sqlite'); + const first = createSqliteReceiptStore({ path }); + const second = createSqliteReceiptStore({ path }); + try { + await rejected(first, 'contested'); + + const results = await Promise.allSettled([ + first.reservePaymentAttempt( + makeReservation({ replayKey: 'contested', requestId: 'a', ...retry }), + ), + second.reservePaymentAttempt( + makeReservation({ replayKey: 'contested', requestId: 'b', ...retry }), + ), + ]); + + expect(results.map((r) => r.status).sort()).toEqual(['fulfilled', 'rejected']); + const lost = results.find((r) => r.status === 'rejected') as PromiseRejectedResult; + expect(lost.reason).toMatchObject({ + code: 'PAYMENT_REPLAYED', + details: { attemptStatus: 'reserved' }, + }); + } finally { + await first.close(); + await second.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe('updatePaymentAttempt', () => { let store: ReceiptStore;