From fa5f8a04db5e693242dd184c3ae2ee6d2061bb45 Mon Sep 17 00:00:00 2001 From: SergeevDmitry Date: Sun, 23 Aug 2026 16:16:05 +0200 Subject: [PATCH 1/6] payment: add CDP facilitator auth and a manual Base mainnet smoke suite --- .github/workflows/mainnet-smoke.yml | 85 + README.md | 7 +- docs/configuration.md | 9 +- docs/mainnet.md | 151 ++ docs/protocols.md | 8 +- docs/security.md | 5 +- examples/base-mainnet/README.md | 53 + examples/base-mainnet/config.yaml | 111 ++ package-lock.json | 1428 ++++++++++++++++- package.json | 6 + src/cli/lib/versions.ts | 1 + src/config/schema.ts | 9 + src/payments/x402/facilitator.ts | 65 +- src/payments/x402/guardrails.ts | 53 +- tests/mainnet/base.smoke.test.ts | 328 ++++ tests/unit/cli/packaging.test.ts | 8 +- tests/unit/config/schema.test.ts | 49 + .../payments-x402/facilitator-cdp.test.ts | 150 ++ tsup.config.ts | 1 + vitest.mainnet.config.ts | 28 + 20 files changed, 2474 insertions(+), 81 deletions(-) create mode 100644 .github/workflows/mainnet-smoke.yml create mode 100644 docs/mainnet.md create mode 100644 examples/base-mainnet/README.md create mode 100644 examples/base-mainnet/config.yaml create mode 100644 tests/mainnet/base.smoke.test.ts create mode 100644 tests/unit/payments-x402/facilitator-cdp.test.ts create mode 100644 vitest.mainnet.config.ts diff --git a/.github/workflows/mainnet-smoke.yml b/.github/workflows/mainnet-smoke.yml new file mode 100644 index 0000000..9e41947 --- /dev/null +++ b/.github/workflows/mainnet-smoke.yml @@ -0,0 +1,85 @@ +name: Mainnet smoke (Base) — REAL FUNDS + +# Manual only, and deliberately awkward. Every run of this workflow moves real +# money on Base. It is not on a schedule, not on a push, and not on a pull +# request: a fork PR that could trigger it would be a way to drain the wallet +# by opening one. +on: + workflow_dispatch: + inputs: + confirm: + description: 'Type SPEND REAL FUNDS to confirm' + required: true + default: '' + amount: + description: 'USDC to spend (every run spends it)' + required: false + default: '0.01' + +permissions: + contents: read + +concurrency: + # Never two at once: both runs share one buyer wallet, and two in-flight + # EIP-3009 authorisations against one nonce space is the race the gateway's + # replay reservation exists to catch — not something to trigger on purpose. + group: mainnet-smoke + cancel-in-progress: false + +jobs: + smoke: + name: Base mainnet settlement + runs-on: ubuntu-latest + timeout-minutes: 30 + # A protected environment adds a required reviewer in front of real money. + environment: mainnet + steps: + - name: Confirm intent + env: + CONFIRM: ${{ inputs.confirm }} + run: | + if [ "$CONFIRM" != "SPEND REAL FUNDS" ]; then + echo "::error::This workflow spends real money. Re-run and type: SPEND REAL FUNDS" + exit 1 + fi + + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - run: npm ci + + # The CDP auth type needs this optional peer; it is not installed by + # default and nothing else in the suite requires it. + - run: npm install --no-save @coinbase/x402@2.1.0 + + - name: Settle on Base + env: + ALLOW_X402_MAINNET: 'true' + X402_MAINNET_BUYER_PRIVATE_KEY: ${{ secrets.X402_MAINNET_BUYER_PRIVATE_KEY }} + X402_MAINNET_MERCHANT_ADDRESS: ${{ secrets.X402_MAINNET_MERCHANT_ADDRESS }} + X402_MAINNET_RPC_URL: ${{ secrets.X402_MAINNET_RPC_URL }} + X402_FACILITATOR_URL: ${{ secrets.X402_FACILITATOR_URL }} + CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }} + CDP_API_KEY_SECRET: ${{ secrets.CDP_API_KEY_SECRET }} + X402_FACILITATOR_TOKEN: ${{ secrets.X402_FACILITATOR_TOKEN }} + X402_MAINNET_AMOUNT: ${{ inputs.amount }} + run: npm run test:mainnet + + # The suite skips itself when a credential is missing, which would + # otherwise look exactly like a pass on a workflow whose whole purpose is + # to prove a real settlement happened. + - name: Fail if the smoke test skipped + if: always() + env: + BUYER: ${{ secrets.X402_MAINNET_BUYER_PRIVATE_KEY }} + MERCHANT: ${{ secrets.X402_MAINNET_MERCHANT_ADDRESS }} + FACILITATOR: ${{ secrets.X402_FACILITATOR_URL }} + run: | + if [ -z "$BUYER" ] || [ -z "$MERCHANT" ] || [ -z "$FACILITATOR" ]; then + echo "::error::mainnet secrets are not set in the 'mainnet' environment; the smoke test skipped and proved nothing." + exit 1 + fi diff --git a/README.md b/README.md index 2c3e4bb..e79751e 100644 --- a/README.md +++ b/README.md @@ -301,8 +301,10 @@ a funded test wallet, skips itself without one, and is deliberately outside `npm test` and `npm run test:e2e` — both of those must stay offline. **Mainnet is a different claim, and it is not made.** `eip155:8453` is -configurable and guarded, and no payment has been settled on it. See -[docs/testnet.md](docs/testnet.md). +configurable and guarded — an explicit opt-in, a remote authenticated +facilitator over HTTPS, a non-development `payTo` and the canonical USDC, all +checked at config load — but no payment has been settled on it. See +[docs/mainnet.md](docs/mainnet.md). ## Development @@ -334,6 +336,7 @@ discipline is a release requirement, not a mood. | [Protocols](docs/protocols.md) | exactly what is and is not supported | | [Configuration](docs/configuration.md) | `config.yaml` reference | | [Base Sepolia](docs/testnet.md) | running on a public testnet, and proving it | +| [Base mainnet](docs/mainnet.md) | real funds: what is refused, and why | | [Security model](docs/security.md) | trust boundaries, and what we do not defend | | [Contracts](docs/contracts.md) | the frozen cross-package contract | | [Adapter guide](docs/contributing-adapters.md) | add a protocol or a payment rail | diff --git a/docs/configuration.md b/docs/configuration.md index 363fe89..838cfba 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -154,10 +154,15 @@ refused on testnet too. the gateway runs at startup — the same function, not a second copy of the rules. +`facilitator.auth` has three types: `none`, `bearer` (a static token, needs +nothing installed) and `cdp` (Coinbase Developer Platform, which signs a fresh +JWT per request and needs the optional peer `@coinbase/x402`). Anything else is +refused at config load rather than sent nothing. + **Base Sepolia is exercised; mainnet is not.** A real payment has settled on Base Sepolia through the public facilitator ([testnet.md](testnet.md)). Nothing -has settled on `eip155:8453`, and the guardrails above are checks, not -evidence. +has settled on `eip155:8453` — see [mainnet.md](mainnet.md) for what the +guardrails refuse there, which are checks, not evidence. ## Unsupported JSON Schema keywords have a cost diff --git a/docs/mainnet.md b/docs/mainnet.md new file mode 100644 index 0000000..5d889d2 --- /dev/null +++ b/docs/mainnet.md @@ -0,0 +1,151 @@ +# Base mainnet + +Real funds. Read this before the config. + +> **Status.** Implemented and guarded; **not demonstrated**. No payment has +> settled on `eip155:8453` from this repository. [Has it actually +> settled?](#has-it-actually-settled) is updated only when one has. + +## What is different from a testnet + +Two things, and neither is a flag. + +**The gateway holds no key, and cannot.** `facilitator.mode: local` signs with +a key this process holds — a hot wallet inside the resource server, which is +the arrangement the non-custodial design exists to avoid. It is refused on +mainnet outright, not warned about. A mainnet deployment settles through a +remote facilitator, which broadcasts and pays the gas. + +**Nothing is defaulted.** On a network where money is real, a default is a way +to lose it by accident. Every requirement below is checked at config load, so +`agent-commerce validate` reports it and the gateway does not start: + +| Refused | Because | +|---|---| +| `allowMainnet` absent or false | mainnet is never a default | +| `facilitator.mode: local` | a hot wallet inside the resource server | +| `facilitator.auth.type: none` | an unauthenticated production facilitator | +| a non-HTTPS `facilitator.url` | authorisations and settlement results in the clear | +| an empty credential | a blank token reaches the facilitator as "unauthenticated" | +| a well-known Anvil `payTo` | its private key is public knowledge | +| any `asset` but USDC on Base | settling in an unintended token | + +Seen from the outside: + +```console +$ agent-commerce validate --config config.yaml +FAIL CONFIG_INVALID: payments.x402: network "eip155:8453" (Base) settles real funds. + Set payments.x402.allowMainnet: true to acknowledge this explicitly — it is + never the default. +``` + +## Configuration + +A complete file is in +[`examples/base-mainnet/config.yaml`](../examples/base-mainnet/config.yaml). + +```yaml +payments: + x402: + network: eip155:8453 + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" # USDC on Base + assetName: USDC + assetVersion: "2" + assetDecimals: 6 + payTo: ${MERCHANT_WALLET} + maxTimeoutSeconds: 600 + allowMainnet: ${ALLOW_X402_MAINNET} + facilitator: + mode: remote + url: ${X402_FACILITATOR_URL} + auth: + type: cdp + apiKeyId: ${CDP_API_KEY_ID} + apiKeySecret: ${CDP_API_KEY_SECRET} +``` + +An unresolved `${VAR}` fails config loading rather than resolving to an empty +string, so a missing credential is a startup failure, never a silently +unauthenticated facilitator. + +## Choosing a facilitator + +There is no free mainnet facilitator equivalent to the public testnet one: +`https://x402.org/facilitator` advertises `eip155:84532` and nothing on +mainnet. Whichever you pick is a real counterparty that sees every payment +authorisation you handle, and probably sends you a bill. + +Three auth types exist: + +| `auth.type` | For | Installs | +|---|---|---| +| `none` | facilitators that take no credential | nothing | +| `bearer` | any facilitator with a static token | nothing | +| `cdp` | Coinbase Developer Platform | `@coinbase/x402` | + +**`bearer` is the cheaper path in every sense.** `cdp` exists because CDP signs +a fresh JWT per request over method + host + path, which a static header cannot +express — it is not a preference for Coinbase, and nothing in the architecture +is shaped around them. + +### The `@coinbase/x402` dependency + +It is an **optional peer**, imported dynamically only when `auth.type: cdp` is +configured. Nobody else installs it, and it is absent from the default install. + +Know what it brings: `@coinbase/x402` → `@coinbase/cdp-sdk` → `axios`, which at +the time of writing carries ten high-severity advisories, plus a Solana client +tree this project has no use for. That is a real supply-chain surface on the +highest-stakes path in the system. If your facilitator accepts a static token, +`bearer` avoids all of it. + +A missing peer is a *configuration* failure, surfaced through `health()` and +`/ready` before any buyer signs anything — never an exception inside `verify()` +with an authorisation already spent. + +## The smoke test + +```bash +export ALLOW_X402_MAINNET=true +export X402_MAINNET_BUYER_PRIVATE_KEY=0x... # funded with USDC on Base +export X402_MAINNET_MERCHANT_ADDRESS=0x... +export X402_FACILITATOR_URL=https://... +export CDP_API_KEY_ID=... CDP_API_KEY_SECRET=... # or X402_FACILITATOR_TOKEN +npm run test:mainnet +``` + +**Every run spends `X402_MAINNET_AMOUNT` (default `0.01`) of real USDC.** It +skips itself, naming what is missing, unless all of the above are set — five +separate deliberate acts. + +It proves, in order: the guard refuses a config that has not opted in · +authentication reaches the facilitator · a payment settles on Base · the +receipt carries the settlement reference and a delivery timestamp · the +resource is delivered exactly once · the same authorisation 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. The +gateway's own report of success is not the proof, and `retry: 0` is set +deliberately — a retried settlement is a second payment. + +In CI it is `.github/workflows/mainnet-smoke.yml`: `workflow_dispatch` only, +behind a `mainnet` environment (add a required reviewer), requiring the literal +string `SPEND REAL FUNDS` typed into an input, serialised so two runs cannot +share one nonce space, and failing rather than passing when its secrets are +absent. + +## Keys + +The buyer key is read from the environment, never written to a config file, +never logged, and never included in an assertion message. The merchant side +never needs a key at all — only an address to settle to. A mainnet key must +never appear in this repository, in `.env.testnet`, or in a config file. + +## Has it actually settled? + +**No.** Every settlement this project has performed was on the local +deterministic chain or on Base Sepolia ([testnet.md](testnet.md)). + +The mainnet path is implemented, guarded, and covered by a smoke test that has +never been run against a funded wallet. When it has, this section carries the +transaction rather than a claim. diff --git a/docs/protocols.md b/docs/protocols.md index 3af52cd..2b4e9ec 100644 --- a/docs/protocols.md +++ b/docs/protocols.md @@ -99,9 +99,11 @@ it normalises into `CanonicalRequest` and lets the pipeline decide. ### Not implemented -Solana/SVM, the `deferred` scheme, Permit2, per-request signed facilitator -credentials (a CDP JWT and anything like it — only `none` and `bearer` auth -exist), multi-asset routing and dynamic pricing. +Solana/SVM, the `deferred` scheme, Permit2, multi-asset routing and dynamic +pricing. + +Facilitator auth covers `none`, `bearer` and `cdp`; any other scheme is refused +at config load rather than sent nothing. A remote HTTP facilitator **is** supported (`facilitator.mode: remote`), and Base Sepolia settlement is demonstrated on chain — see diff --git a/docs/security.md b/docs/security.md index 6e84e78..64524e4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -218,8 +218,9 @@ facilitator the gateway holds no signing key at all. Base Sepolia settlement has been performed and verified on chain ([testnet.md](testnet.md)). **Mainnet has not**, and nothing here should be read as a claim that it has: what this section describes is what the -configuration permits and refuses, not what has been exercised with real -funds. +configuration permits and refuses, not what has been exercised with real funds. +The full runbook, including the supply-chain cost of the CDP auth type, is +[mainnet.md](mainnet.md). ## Threats we are not addressing in the alpha diff --git a/examples/base-mainnet/README.md b/examples/base-mainnet/README.md new file mode 100644 index 0000000..5130fbd --- /dev/null +++ b/examples/base-mainnet/README.md @@ -0,0 +1,53 @@ +# Example: base-mainnet — REAL FUNDS + +The same gateway, settling real USDC on Base. Read +[docs/mainnet.md](../../docs/mainnet.md) first — it explains what is refused +and why, and this file assumes it. + +Two things are structurally different from the local and testnet examples: + +- **No `signerPrivateKey`, and no way to have one.** `facilitator.mode: local` + is refused on mainnet: it signs with a key this process holds, which is a hot + wallet inside the resource server. A remote facilitator broadcasts and pays + the gas. +- **Nothing is defaulted.** Every `${VAR}` below has no fallback, so a missing + one fails config loading rather than resolving to something plausible. + +## What you need + +| | | +|---|---| +| `MERCHANT_WALLET` | your wallet. Address only — the gateway never wants a merchant key. | +| `ALLOW_X402_MAINNET=true` | the explicit opt-in. Never a default. | +| `X402_FACILITATOR_URL` | https, and authenticated. There is no free mainnet facilitator. | +| CDP credentials, or a bearer token | `bearer` needs nothing installed; `cdp` pulls `@coinbase/x402`. | +| `GATEWAY_ADMIN_TOKEN` | without it the receipt routes 404 and you cannot read your own ledger. | +| A Base RPC | health checks only. A dedicated endpoint — the public one's outages become your readiness failures. | + +## Validate before anything else + +```bash +ALLOW_X402_MAINNET=true \ +MERCHANT_WALLET=0xYourWallet \ +GATEWAY_PUBLIC_BASE_URL=https://your.gateway \ +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 +``` + +Drop any one of those and it fails, naming the path: + +```console +FAIL CONFIG_INVALID: Unresolved environment variable "${ALLOW_X402_MAINNET}" + referenced at config path "$.payments.x402.allowMainnet" +``` + +`doctor` then reports the deployment as `LIVE MAINNET MODE — REAL FUNDS`. + +## Proving it settles + +`npm run test:mainnet`, with the variables in +[docs/mainnet.md](../../docs/mainnet.md#the-smoke-test). Every run spends real +USDC. It has not been run from this repository. diff --git a/examples/base-mainnet/config.yaml b/examples/base-mainnet/config.yaml new file mode 100644 index 0000000..38a036e --- /dev/null +++ b/examples/base-mainnet/config.yaml @@ -0,0 +1,111 @@ +# --------------------------------------------------------------------------- +# Example: base-mainnet — REAL FUNDS +# +# Every line marked "required" below is checked at config load. The gateway +# will not start without them, and `agent-commerce validate` reports which one +# is missing. None of them have defaults: on a network where money is real, +# a default is a way to lose it by accident. +# +# The gateway holds no key here. A mainnet deployment must use a remote +# facilitator — the in-process one signs with a key this process would have to +# hold, which is a hot wallet inside the resource server, and that is refused. +# +# ALLOW_X402_MAINNET=true MERCHANT_WALLET=0x... X402_FACILITATOR_URL=... \ +# CDP_API_KEY_ID=... CDP_API_KEY_SECRET=... \ +# npm run agent-commerce -- validate --config examples/base-mainnet/config.yaml +# --------------------------------------------------------------------------- + +version: 1 + +merchant: + id: base-mainnet-example + name: Base Mainnet Example + publicBaseUrl: ${GATEWAY_PUBLIC_BASE_URL} + +server: + port: ${GATEWAY_PORT:-8080} + host: 0.0.0.0 + # Required in practice: operator routes carry the commerce ledger — payer + # addresses, amounts, settlement hashes. Without this they 404, which is safe + # but means you cannot read your own receipts over HTTP. + adminToken: ${GATEWAY_ADMIN_TOKEN} + allowedOrigins: [] + +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 Base. + input: + type: object + properties: {} + additionalProperties: false + backend: + type: http + method: GET + url: ${MERCHANT_API_BASE_URL}/api/report + timeoutMs: 10000 + pricing: + type: fixed + amount: "0.01" + currency: USDC + expose: [http, mcp] + payments: [x402] + +payments: + x402: + enabled: true + + # required. Base mainnet. + network: eip155:8453 + + # Health checks and nothing else — the facilitator does the chain work. + # A dedicated endpoint is strongly preferred here; the public one is rate + # limited and its outages become your readiness failures. + rpcUrl: ${X402_RPC_URL:-https://mainnet.base.org} + + # required, and checked: a mainnet config may only name USDC on Base. + # Settling a mainnet payment in an unintended token is refused. + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + assetName: USDC + assetVersion: "2" + assetDecimals: 6 + + # required. Your wallet. Never a gateway-owned one, and never a + # development address — startup refuses a well-known Anvil account here. + payTo: ${MERCHANT_WALLET} + + # Public-chain block times and a hosted facilitator's queue are not yours + # to control. Short windows here become failed payments. + maxTimeoutSeconds: 600 + + # required, and required to be `true`. Mainnet is never a default. + allowMainnet: ${ALLOW_X402_MAINNET} + + facilitator: + # required. `local` is refused on mainnet. + mode: remote + # required, and must be https. + url: ${X402_FACILITATOR_URL} + # required to be something other than `none` on mainnet. + # + # `cdp` needs the optional peer @coinbase/x402, which signs a fresh JWT + # per request. For any facilitator that takes a static token, use: + # auth: + # type: bearer + # token: ${X402_FACILITATOR_TOKEN} + auth: + type: cdp + apiKeyId: ${CDP_API_KEY_ID} + apiKeySecret: ${CDP_API_KEY_SECRET} diff --git a/package-lock.json b/package-lock.json index 0df3742..6973d13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devlab.group/agent-commerce", - "version": "0.1.0-alpha.0", + "version": "0.2.0-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devlab.group/agent-commerce", - "version": "0.1.0-alpha.0", + "version": "0.2.0-beta.0", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "1.7.0", @@ -23,6 +23,7 @@ }, "devDependencies": { "@biomejs/biome": "2.5.9", + "@coinbase/x402": "2.1.0", "@modelcontextprotocol/sdk": "1.30.0", "@types/better-sqlite3": "9.6.0", "@types/node": "24.13.3", @@ -47,12 +48,16 @@ "npm": ">=10" }, "peerDependencies": { + "@coinbase/x402": "2.1.0", "@modelcontextprotocol/sdk": "1.30.0", "@x402/core": "2.23.0", "@x402/evm": "2.23.0", "viem": "2.55.18" }, "peerDependenciesMeta": { + "@coinbase/x402": { + "optional": true + }, "@modelcontextprotocol/sdk": { "optional": true }, @@ -325,6 +330,83 @@ "node": ">= 20.12.0" } }, + "node_modules/@coinbase/cdp-sdk": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@coinbase/cdp-sdk/-/cdp-sdk-1.55.0.tgz", + "integrity": "sha512-5PbUg3n3Jk9nm8nEStskRv6jTrVZKkgwxMFjW+i/xUDDzK1fXksKwXdjgUiHB1hf0FZx1LiYBosrh4IULxFyPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana-program/system": "^0.10.0", + "@solana-program/token": "^0.9.0", + "@solana/kit": "^5.5.1", + "abitype": "1.0.6", + "axios": "1.16.0", + "axios-retry": "^4.5.0", + "bs58": "^6.0.0", + "jose": "^6.2.0", + "md5": "^2.3.0", + "uncrypto": "^0.1.3", + "viem": "^2.47.0", + "zod": "^3.25.76" + }, + "peerDependencies": { + "@x402/core": "^2.21.0", + "@x402/evm": "^2.21.0", + "@x402/extensions": "^2.21.0", + "@x402/svm": "^2.21.0" + }, + "peerDependenciesMeta": { + "@x402/core": { + "optional": true + }, + "@x402/evm": { + "optional": true + }, + "@x402/extensions": { + "optional": true + }, + "@x402/svm": { + "optional": true + } + } + }, + "node_modules/@coinbase/cdp-sdk/node_modules/abitype": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.6.tgz", + "integrity": "sha512-MMSqYh4+C/aVqI2RQaWqbvI4Kxo5cQV40WQ4QFtDnNzCkqChm8MuENhElmynZlO0qUy/ObkEUaXtKqYnx1Kp3A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3 >=3.22.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@coinbase/x402": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@coinbase/x402/-/x402-2.1.0.tgz", + "integrity": "sha512-aKeM+cz//+FjzPVu/zgz7830x0KLtKarwCyxoeC71QgCn+Xcf0NhFpn3Qyw0H496y5YOuR/IQ67gP8DZ/hXFqQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@coinbase/cdp-sdk": "^1.29.0", + "@x402/core": "^2.0.0", + "viem": "^2.21.26", + "zod": "^3.24.2" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -1624,76 +1706,1073 @@ ], "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@solana-program/system": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@solana-program/system/-/system-0.10.0.tgz", + "integrity": "sha512-Go+LOEZmqmNlfr+Gjy5ZWAdY5HbYzk2RBewD9QinEU/bBSzpFfzqDRT55JjFRBGJUvMgf3C2vfXEGT4i8DSI4g==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@solana/kit": "^5.0" + } + }, + "node_modules/@solana-program/token": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@solana-program/token/-/token-0.9.0.tgz", + "integrity": "sha512-vnZxndd4ED4Fc56sw93cWZ2djEeeOFxtaPS8SPf5+a+JZjKA/EnKqzbE1y04FuMhIVrLERQ8uR8H2h72eZzlsA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@solana/kit": "^5.0" + } + }, + "node_modules/@solana/accounts": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/accounts/-/accounts-5.5.1.tgz", + "integrity": "sha512-TfOY9xixg5rizABuLVuZ9XI2x2tmWUC/OoN556xwfDlhBHBjKfszicYYOyD6nbFmwTGYarCmyGIdteXxTXIdhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/addresses": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/addresses/-/addresses-5.5.1.tgz", + "integrity": "sha512-5xoah3Q9G30HQghu/9BiHLb5pzlPKRC3zydQDmE3O9H//WfayxTFppsUDCL6FjYUHqj/wzK6CWHySglc2RkpdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/assertions": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/assertions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/assertions/-/assertions-5.5.1.tgz", + "integrity": "sha512-YTCSWAlGwSlVPnWtWLm3ukz81wH4j2YaCveK+TjpvUU88hTy6fmUqxi0+hvAMAe4zKXpJyj3Az7BrLJRxbIm4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-5.5.1.tgz", + "integrity": "sha512-Vea29nJub/bXjfzEV7ZZQ/PWr1pYLZo3z0qW0LQL37uKKVzVFRQlwetd7INk3YtTD3xm9WUYr7bCvYUk3uKy2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/options": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-5.5.1.tgz", + "integrity": "sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-data-structures": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-5.5.1.tgz", + "integrity": "sha512-97bJWGyUY9WvBz3mX1UV3YPWGDTez6btCfD0ip3UVEXJbItVuUiOkzcO5iFDUtQT5riKT6xC+Mzl+0nO76gd0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-numbers": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-5.5.1.tgz", + "integrity": "sha512-rllMIZAHqmtvC0HO/dc/21wDuWaD0B8Ryv8o+YtsICQBuiL/0U4AGwH7Pi5GNFySYk0/crSuwfIqQFtmxNSPFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-strings": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-5.5.1.tgz", + "integrity": "sha512-7klX4AhfHYA+uKKC/nxRGP2MntbYQCR3N6+v7bk1W/rSxYuhNmt+FN8aoThSZtWIKwN6BEyR1167ka8Co1+E7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "fastestsmallesttextencoderdecoder": "^1.0.22", + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "fastestsmallesttextencoderdecoder": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/errors": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-5.5.1.tgz", + "integrity": "sha512-vFO3p+S7HoyyrcAectnXbdsMfwUzY2zYFUc2DEe5BwpiE9J1IAxPBGjOWO6hL1bbYdBrlmjNx8DXCslqS+Kcmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "commander": "14.0.2" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/errors/node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@solana/fast-stable-stringify": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/fast-stable-stringify/-/fast-stable-stringify-5.5.1.tgz", + "integrity": "sha512-Ni7s2FN33zTzhTFgRjEbOVFO+UAmK8qi3Iu0/GRFYK4jN696OjKHnboSQH/EacQ+yGqS54bfxf409wU5dsLLCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/functional": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/functional/-/functional-5.5.1.tgz", + "integrity": "sha512-tTHoJcEQq3gQx5qsdsDJ0LEJeFzwNpXD80xApW9o/PPoCNimI3SALkZl+zNW8VnxRrV3l3yYvfHWBKe/X3WG3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/instruction-plans": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/instruction-plans/-/instruction-plans-5.5.1.tgz", + "integrity": "sha512-7z3CB7YMcFKuVvgcnNY8bY6IsZ8LG61Iytbz7HpNVGX2u1RthOs1tRW8luTzSG1MPL0Ox7afyAVMYeFqSPHnaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/instructions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/instructions/-/instructions-5.5.1.tgz", + "integrity": "sha512-h0G1CG6S+gUUSt0eo6rOtsaXRBwCq1+Js2a+Ps9Bzk9q7YHNFA75/X0NWugWLgC92waRp66hrjMTiYYnLBoWOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/keys": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/keys/-/keys-5.5.1.tgz", + "integrity": "sha512-KRD61cL7CRL+b4r/eB9dEoVxIf/2EJ1Pm1DmRYhtSUAJD2dJ5Xw8QFuehobOGm9URqQ7gaQl+Fkc1qvDlsWqKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/assertions": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/kit": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-5.5.1.tgz", + "integrity": "sha512-irKUGiV2yRoyf+4eGQ/ZeCRxa43yjFEL1DUI5B0DkcfZw3cr0VJtVJnrG8OtVF01vT0OUfYOcUn6zJW5TROHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/accounts": "5.5.1", + "@solana/addresses": "5.5.1", + "@solana/codecs": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/instruction-plans": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/offchain-messages": "5.5.1", + "@solana/plugin-core": "5.5.1", + "@solana/programs": "5.5.1", + "@solana/rpc": "5.5.1", + "@solana/rpc-api": "5.5.1", + "@solana/rpc-parsed-types": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-subscriptions": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/signers": "5.5.1", + "@solana/sysvars": "5.5.1", + "@solana/transaction-confirmation": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/nominal-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/nominal-types/-/nominal-types-5.5.1.tgz", + "integrity": "sha512-I1ImR+kfrLFxN5z22UDiTWLdRZeKtU0J/pkWkO8qm/8WxveiwdIv4hooi8pb6JnlR4mSrWhq0pCIOxDYrL9GIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/offchain-messages": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/offchain-messages/-/offchain-messages-5.5.1.tgz", + "integrity": "sha512-g+xHH95prTU+KujtbOzj8wn+C7ZNoiLhf3hj6nYq3MTyxOXtBEysguc97jJveUZG0K97aIKG6xVUlMutg5yxhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/options": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/options/-/options-5.5.1.tgz", + "integrity": "sha512-eo971c9iLNLmk+yOFyo7yKIJzJ/zou6uKpy6mBuyb/thKtS/haiKIc3VLhyTXty3OH2PW8yOlORJnv4DexJB8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/plugin-core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/plugin-core/-/plugin-core-5.5.1.tgz", + "integrity": "sha512-VUZl30lDQFJeiSyNfzU1EjYt2QZvoBFKEwjn1lilUJw7KgqD5z7mbV7diJhT+dLFs36i0OsjXvq5kSygn8YJ3A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/programs": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/programs/-/programs-5.5.1.tgz", + "integrity": "sha512-7U9kn0Jsx1NuBLn5HRTFYh78MV4XN145Yc3WP/q5BlqAVNlMoU9coG5IUTJIG847TUqC1lRto3Dnpwm6T4YRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/promises": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/promises/-/promises-5.5.1.tgz", + "integrity": "sha512-T9lfuUYkGykJmppEcssNiCf6yiYQxJkhiLPP+pyAc2z84/7r3UVIb2tNJk4A9sucS66pzJnVHZKcZVGUUp6wzA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc/-/rpc-5.5.1.tgz", + "integrity": "sha512-ku8zTUMrkCWci66PRIBC+1mXepEnZH/q1f3ck0kJZ95a06bOTl5KU7HeXWtskkyefzARJ5zvCs54AD5nxjQJ+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/fast-stable-stringify": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/rpc-api": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-transport-http": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-api": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-api/-/rpc-api-5.5.1.tgz", + "integrity": "sha512-XWOQQPhKl06Vj0xi3RYHAc6oEQd8B82okYJ04K7N0Vvy3J4PN2cxeK7klwkjgavdcN9EVkYCChm2ADAtnztKnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/rpc-parsed-types": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-parsed-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-parsed-types/-/rpc-parsed-types-5.5.1.tgz", + "integrity": "sha512-HEi3G2nZqGEsa3vX6U0FrXLaqnUCg4SKIUrOe8CezD+cSFbRTOn3rCLrUmJrhVyXlHoQVaRO9mmeovk31jWxJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-spec": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec/-/rpc-spec-5.5.1.tgz", + "integrity": "sha512-m3LX2bChm3E3by4mQrH4YwCAFY57QBzuUSWqlUw7ChuZ+oLLOq7b2czi4i6L4Vna67j3eCmB3e+4tqy1j5wy7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/rpc-spec-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-spec-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec-types/-/rpc-spec-types-5.5.1.tgz", + "integrity": "sha512-6OFKtRpIEJQs8Jb2C4OO8KyP2h2Hy1MFhatMAoXA+0Ik8S3H+CicIuMZvGZ91mIu/tXicuOOsNNLu3HAkrakrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions/-/rpc-subscriptions-5.5.1.tgz", + "integrity": "sha512-CTMy5bt/6mDh4tc6vUJms9EcuZj3xvK0/xq8IQ90rhkpYvate91RjBP+egvjgSayUg9yucU9vNuUpEjz4spM7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/fast-stable-stringify": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-subscriptions-api": "5.5.1", + "@solana/rpc-subscriptions-channel-websocket": "5.5.1", + "@solana/rpc-subscriptions-spec": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/subscribable": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-api": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-api/-/rpc-subscriptions-api-5.5.1.tgz", + "integrity": "sha512-5Oi7k+GdeS8xR2ly1iuSFkAv6CZqwG0Z6b1QZKbEgxadE1XGSDrhM2cn59l+bqCozUWCqh4c/A2znU/qQjROlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/rpc-subscriptions-spec": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-channel-websocket": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-channel-websocket/-/rpc-subscriptions-channel-websocket-5.5.1.tgz", + "integrity": "sha512-7tGfBBrYY8TrngOyxSHoCU5shy86iA9SRMRrPSyBhEaZRAk6dnbdpmUTez7gtdVo0BCvh9nzQtUycKWSS7PnFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/rpc-subscriptions-spec": "5.5.1", + "@solana/subscribable": "5.5.1", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-spec": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-spec/-/rpc-subscriptions-spec-5.5.1.tgz", + "integrity": "sha512-iq+rGq5fMKP3/mKHPNB6MC8IbVW41KGZg83Us/+LE3AWOTWV1WT20KT2iH1F1ik9roi42COv/TpoZZvhKj45XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/subscribable": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-transformers": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-transformers/-/rpc-transformers-5.5.1.tgz", + "integrity": "sha512-OsWqLCQdcrRJKvHiMmwFhp9noNZ4FARuMkHT5us3ustDLXaxOjF0gfqZLnMkulSLcKt7TGXqMhBV+HCo7z5M8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-transport-http": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-transport-http/-/rpc-transport-http-5.5.1.tgz", + "integrity": "sha512-yv8GoVSHqEV0kUJEIhkdOVkR2SvJ6yoWC51cJn2rSV7plr6huLGe0JgujCmB7uZhhaLbcbP3zxXxu9sOjsi7Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "undici-types": "^7.19.2" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-transport-http/node_modules/undici-types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.29.0.tgz", + "integrity": "sha512-vamA8dGlzMwhpyYpQp9d8vka3o4D/yn5I7ez7Or+msDA4bZ8Uh+Zy91WvWf3I73gDAkFha9JcYRqm2li0Npfgg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@solana/rpc-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-types/-/rpc-types-5.5.1.tgz", + "integrity": "sha512-bibTFQ7PbHJJjGJPmfYC2I+/5CRFS4O2p9WwbFraX1Keeel+nRrt/NBXIy8veP5AEn2sVJIyJPpWBRpCx1oATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/signers": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/signers/-/signers-5.5.1.tgz", + "integrity": "sha512-FY0IVaBT2kCAze55vEieR6hag4coqcuJ31Aw3hqRH7mv6sV8oqwuJmUrx+uFwOp1gwd5OEAzlv6N4hOOple4sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/offchain-messages": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", - "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", - "cpu": [ - "x64" - ], + "node_modules/@solana/subscribable": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/subscribable/-/subscribable-5.5.1.tgz", + "integrity": "sha512-9K0PsynFq0CsmK1CDi5Y2vUIJpCqkgSS5yfDN0eKPgHqEptLEaia09Kaxc90cSZDZU5mKY/zv1NBmB6Aro9zQQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", - "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", - "cpu": [ - "x64" - ], + "node_modules/@solana/sysvars": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/sysvars/-/sysvars-5.5.1.tgz", + "integrity": "sha512-k3Quq87Mm+geGUu1GWv6knPk0ALsfY6EKSJGw9xUJDHzY/RkYSBnh0RiOrUhtFm2TDNjOailg8/m0VHmi3reFA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@solana/accounts": "5.5.1", + "@solana/codecs": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } }, - "node_modules/@scure/base": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", - "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "node_modules/@solana/transaction-confirmation": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/transaction-confirmation/-/transaction-confirmation-5.5.1.tgz", + "integrity": "sha512-j4mKlYPHEyu+OD7MBt3jRoX4ScFgkhZC6H65on4Fux6LMScgivPJlwnKoZMnsgxFgWds0pl+BYzSiALDsXlYtw==", "dev": true, "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/rpc": "5.5.1", + "@solana/rpc-subscriptions": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@scure/bip32": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", - "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "node_modules/@solana/transaction-messages": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/transaction-messages/-/transaction-messages-5.5.1.tgz", + "integrity": "sha512-aXyhMCEaAp3M/4fP0akwBBQkFPr4pfwoC5CLDq999r/FUwDax2RE/h4Ic7h2Xk+JdcUwsb+rLq85Y52hq84XvQ==", "dev": true, "license": "MIT", "dependencies": { - "@noble/curves": "~1.9.0", - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/rpc-types": "5.5.1" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@scure/bip39": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", - "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "node_modules/@solana/transactions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/transactions/-/transactions-5.5.1.tgz", + "integrity": "sha512-8hHtDxtqalZ157pnx6p8k10D7J/KY/biLzfgh9R09VNLLY3Fqi7kJvJCr7M2ik3oRll56pxhraAGCC9yIT6eOA==", "dev": true, "license": "MIT", "dependencies": { - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@standard-schema/spec": { @@ -2077,6 +3156,13 @@ "js-tokens": "^10.0.0" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -2106,6 +3192,38 @@ "fastq": "^1.17.1" } }, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios-retry": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", + "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "is-retry-allowed": "^2.2.0" + }, + "peerDependencies": { + "axios": "0.x || 1.x" + } + }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "dev": true, + "license": "MIT" + }, "node_modules/better-sqlite3": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", @@ -2157,6 +3275,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -2234,6 +3362,29 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2257,6 +3408,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", @@ -2367,6 +3531,16 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2402,6 +3576,16 @@ } } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2513,6 +3697,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -2909,6 +4109,67 @@ "rollup": "^4.34.8" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3029,6 +4290,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -3130,6 +4407,13 @@ "node": ">= 10" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -3137,6 +4421,19 @@ "dev": true, "license": "MIT" }, + "node_modules/is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3647,6 +4944,18 @@ "node": ">= 0.4" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/media-typer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", @@ -4154,6 +5463,16 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -5498,6 +6817,13 @@ "dev": true, "license": "MIT" }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", diff --git a/package.json b/package.json index ad3eca6..bf1ed53 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:testnet": "vitest run --config vitest.testnet.config.ts", + "test:mainnet": "vitest run --config vitest.mainnet.config.ts", "check:contract": "node scripts/contract-surface.mjs", "check:metadata": "node scripts/check-package-metadata.mjs", "verify": "npm run check:contract && npm run lint && npm run typecheck && npm test", @@ -96,12 +97,16 @@ "zod": "3.25.76" }, "peerDependencies": { + "@coinbase/x402": "2.1.0", "@modelcontextprotocol/sdk": "1.30.0", "@x402/core": "2.23.0", "@x402/evm": "2.23.0", "viem": "2.55.18" }, "peerDependenciesMeta": { + "@coinbase/x402": { + "optional": true + }, "@modelcontextprotocol/sdk": { "optional": true }, @@ -117,6 +122,7 @@ }, "devDependencies": { "@biomejs/biome": "2.5.9", + "@coinbase/x402": "2.1.0", "@modelcontextprotocol/sdk": "1.30.0", "@types/better-sqlite3": "9.6.0", "@types/node": "24.13.3", diff --git a/src/cli/lib/versions.ts b/src/cli/lib/versions.ts index 5d70511..0fa040f 100644 --- a/src/cli/lib/versions.ts +++ b/src/cli/lib/versions.ts @@ -72,6 +72,7 @@ export function readVersionReport(): VersionReport { const candidates: readonly [string, string][] = [ ['@modelcontextprotocol/sdk', 'protocols/mcp'], + ['@coinbase/x402', 'payments/x402'], ['@x402/core', 'payments/x402'], ['@x402/evm', 'payments/x402'], ['viem', 'payments/x402'], diff --git a/src/config/schema.ts b/src/config/schema.ts index 452b89a..35f5707 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -179,6 +179,15 @@ const ResourcesMapSchema = z.record(z.string().min(1), ResourceEntrySchema); const FacilitatorAuthSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('none') }).strict(), z.object({ type: z.literal('bearer'), token: z.string().min(1) }).strict(), + // Coinbase Developer Platform. Needs the optional peer `@coinbase/x402`, + // which signs a fresh JWT per request; a static header cannot express it. + z + .object({ + type: z.literal('cdp'), + apiKeyId: z.string().min(1), + apiKeySecret: z.string().min(1), + }) + .strict(), ]); const FacilitatorSchema = z.discriminatedUnion('mode', [ diff --git a/src/payments/x402/facilitator.ts b/src/payments/x402/facilitator.ts index b350153..161385a 100644 --- a/src/payments/x402/facilitator.ts +++ b/src/payments/x402/facilitator.ts @@ -27,6 +27,7 @@ import type { } from '@x402/core/types'; import { toFacilitatorEvmSigner } from '@x402/evm'; import { registerExactEvmScheme } from '@x402/evm/exact/facilitator'; +import { CommerceError } from '../../core/index.js'; import type { LocalFacilitatorClient } from './chain.js'; import type { FacilitatorAuth } from './guardrails.js'; @@ -143,6 +144,50 @@ export interface RemoteFacilitatorOptions { readonly timeoutMs?: number; } +/** The SDK's path-keyed auth-header shape. A flat object throws inside it. */ +type AuthHeaderFactory = () => Promise>>; + +/** + * CDP signs a fresh JWT per request over method + host + path, so a static + * header cannot express it. `@coinbase/x402` does that signing, and is + * imported dynamically: a static import would drag the whole CDP SDK — and its + * Solana, axios and JOSE dependencies — into the `/x402` subpath for everyone, + * including the majority who use a facilitator that needs no credential at all. + * + * The import is started at construction rather than on the first payment. A + * missing peer must surface as an unhealthy provider before a buyer signs + * anything, not as a failure inside verify() with their authorisation already + * spent. + */ +function cdpAuthHeaders(apiKeyId: string, apiKeySecret: string): AuthHeaderFactory { + const loading = import('@coinbase/x402').then( + (mod) => mod.createCdpAuthHeaders(apiKeyId, apiKeySecret), + (cause: unknown) => { + throw new CommerceError( + 'CONFIG_INVALID', + 'x402 provider: facilitator.auth.type is "cdp", which needs the optional peer ' + + '"@coinbase/x402". Install it (npm install @coinbase/x402), or use a facilitator ' + + 'that accepts a static token with auth.type "bearer".', + { cause }, + ); + }, + ); + // Nothing awaits this until the first call; without a handler the rejection + // above would be an unhandled promise rejection at startup. + loading.catch(() => {}); + + return async () => { + const create = await loading; + if (!create) { + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + 'x402 provider: @coinbase/x402 returned no auth-header factory', + ); + } + return (await create()) as Record>; + }; +} + /** * An HTTP facilitator. * @@ -154,15 +199,17 @@ export function createRemoteFacilitatorBinding( isTransportError: (err: unknown) => boolean, ): FacilitatorBinding { const auth = options.auth; - const createAuthHeaders = - auth.type === 'bearer' - ? async (): Promise>> => { - // The SDK requires a path-keyed object; a flat headers object throws - // rather than silently dropping auth on every request. - const headers = { Authorization: `Bearer ${auth.token}` }; - return { verify: headers, settle: headers, supported: headers }; - } - : undefined; + let createAuthHeaders: AuthHeaderFactory | undefined; + if (auth.type === 'bearer') { + createAuthHeaders = async () => { + // The SDK requires a path-keyed object; a flat headers object throws + // rather than silently dropping auth on every request. + const headers = { Authorization: `Bearer ${auth.token}` }; + return { verify: headers, settle: headers, supported: headers }; + }; + } else if (auth.type === 'cdp') { + createAuthHeaders = cdpAuthHeaders(auth.apiKeyId, auth.apiKeySecret); + } const client = new HTTPFacilitatorClient({ url: options.url, diff --git a/src/payments/x402/guardrails.ts b/src/payments/x402/guardrails.ts index f64c6ba..41bbe72 100644 --- a/src/payments/x402/guardrails.ts +++ b/src/payments/x402/guardrails.ts @@ -27,15 +27,25 @@ import { /** * How the gateway authenticates to a remote facilitator. * - * Deliberately generic: x402 facilitators are not a Coinbase-only category, - * and a scheme that only fits one vendor's credentials would make the - * abstraction a fiction. `bearer` covers every facilitator that takes a static - * token; a facilitator needing per-request signed credentials (CDP's JWT among - * them) is not supported yet and is refused rather than silently sent nothing. + * Deliberately a list, not a vendor: x402 facilitators are not a Coinbase-only + * category, and a scheme that only fitted one vendor's credentials would make + * the abstraction a fiction. + * + * - `none` — the facilitator takes no credential (the public testnet one). + * - `bearer` — a static token. Covers most self-hosted and third-party + * facilitators, and needs nothing installed. + * - `cdp` — Coinbase Developer Platform, which signs a fresh JWT per request + * over method + host + path, so a static header cannot express it. Handled by + * the optional peer `@coinbase/x402`, imported only when this type is + * configured. Anyone not using CDP never installs it. + * + * A facilitator whose scheme is none of these is refused at config load rather + * than sent nothing. */ export type FacilitatorAuth = | { readonly type: 'none' } - | { readonly type: 'bearer'; readonly token: string }; + | { readonly type: 'bearer'; readonly token: string } + | { readonly type: 'cdp'; readonly apiKeyId: string; readonly apiKeySecret: string }; export type X402FacilitatorConfig = | { readonly mode: 'local'; readonly signerPrivateKey: string } @@ -93,11 +103,17 @@ export function resolveX402Deployment(input: X402DeploymentInput): X402Deploymen 'payments.x402.facilitator.auth', ); } - if (input.facilitator.auth.type === 'bearer' && input.facilitator.auth.token.trim() === '') { - throw invalid( - 'payments.x402: facilitator.auth.type is "bearer" but the token is empty. An empty credential is refused rather than sent.', - 'payments.x402.facilitator.auth.token', - ); + // An empty credential is refused rather than sent: a blank token or key + // reaches the facilitator as "unauthenticated" and fails every payment + // after the buyer has already signed. `${VAR:- }` resolving to whitespace + // is the realistic way this happens. + for (const [field, value] of credentialFields(input.facilitator.auth)) { + if (value.trim() === '') { + throw invalid( + `payments.x402: facilitator.auth.type is "${input.facilitator.auth.type}" but ${field} is empty. An empty credential is refused rather than sent.`, + `payments.x402.facilitator.auth.${field}`, + ); + } } } @@ -158,6 +174,21 @@ function assertFacilitatorUrlIsSafe(url: string, mode: DeploymentMode): void { ); } +/** The secret-bearing fields of an auth block, for emptiness checks only. Never logged. */ +function credentialFields(auth: FacilitatorAuth): readonly (readonly [string, string])[] { + switch (auth.type) { + case 'bearer': + return [['token', auth.token]]; + case 'cdp': + return [ + ['apiKeyId', auth.apiKeyId], + ['apiKeySecret', auth.apiKeySecret], + ]; + default: + return []; + } +} + function sameAddress(a: string, b: string): boolean { return a.toLowerCase() === b.toLowerCase(); } diff --git a/tests/mainnet/base.smoke.test.ts b/tests/mainnet/base.smoke.test.ts new file mode 100644 index 0000000..a9cb07f --- /dev/null +++ b/tests/mainnet/base.smoke.test.ts @@ -0,0 +1,328 @@ +/** + * Base mainnet smoke test. **This spends real money.** + * + * Never part of `npm test`, `npm run test:e2e` or `npm run test:testnet`, and + * never triggered by a push or a pull request. It runs only when a human sets + * `ALLOW_X402_MAINNET=true` *and* supplies a funded buyer key, a merchant + * address and facilitator credentials — four separate deliberate acts. + * + * What it proves, in the order the exit criteria ask for it: + * + * the mainnet guard refuses an incomplete config + * -> authentication reaches the facilitator + * -> a real payment settles on Base + * -> the receipt records the settlement reference + * -> the resource is delivered exactly once + * -> the same authorisation 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/index.js'; +import { parseConfig } from '../../src/config/index.js'; +import { + isCommerceError, + type Logger, + PAYMENT_RESPONSE_HEADER, + type ReceiptStore, +} from '../../src/core/index.js'; +import { createGateway, type GatewayInstance } from '../../src/gateway/index.js'; +import { createPaymentProof, createX402PaymentProvider } from '../../src/payments/x402/index.js'; +import { createSqliteReceiptStore } from '../../src/storage/receipts/index.js'; +import { + assertBalanceDelta, + assertTransactionSucceeded, + type BalanceSnapshot, + readBalances, +} from '../fixtures/x402/settlement.js'; + +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']; +const RPC_URL = process.env['X402_MAINNET_RPC_URL'] ?? 'https://mainnet.base.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']; +/** Deliberately tiny. Every run of this file moves this much real USDC. */ +const AMOUNT = process.env['X402_MAINNET_AMOUNT'] ?? '0.01'; + +/** USDC on Base. The only asset a mainnet config is allowed to 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', + CDP_API_KEY_ID || BEARER + ? undefined + : 'CDP_API_KEY_ID + CDP_API_KEY_SECRET (or X402_FACILITATOR_TOKEN)', +].filter((name): name is string => name !== undefined); + +if (missing.length > 0) { + // eslint-disable-next-line no-console + console.log( + `[mainnet] skipped — needs ${missing.join(', ')}. This suite spends REAL FUNDS; see docs/mainnet.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 }; + } + return { type: 'bearer', token: BEARER }; +} + +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: 'USDC', + assetVersion: '2', + assetDecimals: 6, + payTo: MERCHANT, + maxTimeoutSeconds: 600, + ...(overrides.allowMainnet === false ? {} : { allowMainnet: 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[] = []; + + function balances(): Promise { + return readBalances({ + rpcUrl: RPC_URL, + asset: USDC, + buyer, + merchant: MERCHANT as `0x${string}`, + }); + } + + /** + * Independent RPC nodes do not give read-your-writes: the facilitator + * confirms against its node and returns while ours is still a block behind. + * The expected delta stays exact; only the waiting is tolerant. + */ + async function waitForBalances( + predicate: (snapshot: BalanceSnapshot) => boolean, + timeoutMs = 180_000, + ): Promise { + const deadline = Date.now() + timeoutMs; + let snapshot = await balances(); + while (!predicate(snapshot) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 3_000)); + snapshot = await balances(); + } + return snapshot; + } + + 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 any of 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 } : {}), + logger, + }), + ], + protocolAdapters: [], + // Stubbed on purpose: this suite asks whether real money moved, and the + // HTTP backend path is covered by the deterministic local E2E. + 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', () => { + // The guard is the reason this file can exist at all. If it ever stops + // firing, every other assertion here is being made about a deployment that + // could have been 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 authorisation 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}`, + rpcUrl: RPC_URL, + 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((snapshot) => snapshot.buyer < before.buyer); + assertBalanceDelta(before, after, amountBaseUnits); + await assertTransactionSucceeded(RPC_URL, settlement.transaction); + + // The receipt records where to find it, and that delivery happened. + 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?.deliveredAt).toBeDefined(); + + // Delivered exactly once: the same authorisation 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); + + // eslint-disable-next-line no-console + 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/unit/cli/packaging.test.ts b/tests/unit/cli/packaging.test.ts index 4841237..3b98e1d 100644 --- a/tests/unit/cli/packaging.test.ts +++ b/tests/unit/cli/packaging.test.ts @@ -159,7 +159,13 @@ describe('published package metadata', () => { for (const subpath of ['./mcp', './x402']) { expect(exportsField?.[subpath]).toBeDefined(); } - for (const peer of ['@modelcontextprotocol/sdk', '@x402/core', '@x402/evm', 'viem']) { + for (const peer of [ + '@coinbase/x402', + '@modelcontextprotocol/sdk', + '@x402/core', + '@x402/evm', + 'viem', + ]) { expect(manifest.peerDependencies?.[peer]).toBeDefined(); expect(manifest.peerDependenciesMeta?.[peer]?.optional).toBe(true); // In `dependencies` too would defeat the point — npm installs those. diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index f027126..3e19bc9 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -361,6 +361,55 @@ describe('parseConfig', () => { expect(message).toContain('plain HTTP'); }); + it('accepts a mainnet facilitator authenticated with CDP credentials', () => { + const config = parseConfig( + withX402({ + network: 'eip155:8453', + asset: BASE_USDC, + payTo: MERCHANT, + allowMainnet: true, + 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', + auth: { type: 'cdp', apiKeyId: 'key-id', apiKeySecret: 'key-secret' }, + }); + }); + + it('rejects an empty CDP credential rather than sending it', () => { + const message = messageFor( + withX402({ + payTo: MERCHANT, + facilitator: { + mode: 'remote', + url: 'https://facilitator.example.com', + auth: { type: 'cdp', apiKeyId: 'key-id', apiKeySecret: '${CDP_SECRET:- }' }, + }, + }), + ); + expect(message).toContain('apiKeySecret is empty'); + }); + + it('rejects an auth type nobody implements, rather than sending nothing', () => { + const message = messageFor( + withX402({ + payTo: MERCHANT, + facilitator: { + mode: 'remote', + url: 'https://facilitator.example.com', + auth: { type: 'hmac', secret: 's' }, + }, + }), + ); + expect(message).toContain('payments.x402.facilitator'); + }); + it('rejects an empty bearer token rather than sending it', () => { const raw = withX402({ payTo: MERCHANT, diff --git a/tests/unit/payments-x402/facilitator-cdp.test.ts b/tests/unit/payments-x402/facilitator-cdp.test.ts new file mode 100644 index 0000000..9d3d13c --- /dev/null +++ b/tests/unit/payments-x402/facilitator-cdp.test.ts @@ -0,0 +1,150 @@ +/** + * The CDP auth type, and what happens when its optional peer is absent. + * + * `@coinbase/x402` is imported dynamically so that the majority — who use a + * facilitator needing no credential at all — never install the CDP SDK and its + * Solana/axios/JOSE tree. That laziness has a failure mode worth pinning: a + * missing peer must be a *configuration* failure visible before anyone pays, + * never an exception inside verify() with a buyer's authorisation already + * spent. + * + * `vi.doMock` + `vi.resetModules` rather than a hoisted `vi.mock`: the two + * cases below need the same specifier to resolve differently, which a single + * hoisted factory cannot express. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const FACILITATOR_URL = 'https://facilitator.example.com'; + +interface CapturedConfig { + url: string; + createAuthHeaders?: () => Promise>>; +} + +/** Captures what the binding hands to the SDK's HTTP client. */ +function mockHttpClient(captured: CapturedConfig[]): void { + vi.doMock('@x402/core/http', () => ({ + FacilitatorResponseError: class extends Error {}, + HTTPFacilitatorClient: class { + constructor(config: CapturedConfig) { + captured.push(config); + } + verify() { + throw new Error('not used'); + } + settle() { + throw new Error('not used'); + } + getSupported() { + throw new Error('not used'); + } + }, + })); +} + +async function buildBinding(auth: { + type: 'cdp'; + apiKeyId: string; + apiKeySecret: string; +}): Promise { + const captured: CapturedConfig[] = []; + mockHttpClient(captured); + const { createRemoteFacilitatorBinding } = await import( + '../../../src/payments/x402/facilitator.js' + ); + createRemoteFacilitatorBinding({ url: FACILITATOR_URL, auth }, () => false); + const config = captured[0]; + if (!config) throw new Error('the binding built no HTTP client'); + return config; +} + +describe('facilitator auth: cdp', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.doUnmock('@coinbase/x402'); + vi.doUnmock('@x402/core/http'); + }); + + it('signs each request through @coinbase/x402, path-keyed as the SDK requires', async () => { + const seen: Array<{ id: string; secret: string }> = []; + vi.doMock('@coinbase/x402', () => ({ + createCdpAuthHeaders: (id: string, secret: string) => { + seen.push({ id, secret }); + return async () => ({ + verify: { Authorization: 'Bearer jwt-verify' }, + settle: { Authorization: 'Bearer jwt-settle' }, + supported: { Authorization: 'Bearer jwt-supported' }, + }); + }, + })); + + const config = await buildBinding({ + type: 'cdp', + apiKeyId: 'key-id', + apiKeySecret: 'key-secret', + }); + expect(config.url).toBe(FACILITATOR_URL); + expect(config.createAuthHeaders).toBeDefined(); + + const headers = await config.createAuthHeaders?.(); + // Per path, not flat: CDP signs over method + host + path, so one header + // for every route would be wrong even if the SDK accepted it. + expect(headers).toEqual({ + verify: { Authorization: 'Bearer jwt-verify' }, + settle: { Authorization: 'Bearer jwt-settle' }, + supported: { Authorization: 'Bearer jwt-supported' }, + }); + expect(seen).toEqual([{ id: 'key-id', secret: 'key-secret' }]); + }); + + it('turns a missing @coinbase/x402 into CONFIG_INVALID naming the peer', async () => { + vi.doMock('@coinbase/x402', () => { + throw new Error("Cannot find package '@coinbase/x402'"); + }); + + const config = await buildBinding({ + type: 'cdp', + apiKeyId: 'key-id', + apiKeySecret: 'key-secret', + }); + + // Construction itself must not throw — the provider is built + // synchronously, and the diagnosis belongs where it can be reported. + expect(config.createAuthHeaders).toBeDefined(); + // Imported here, not at the top of the file: `vi.resetModules()` gives the + // module under test a fresh graph, and a `CommerceError` from the outer + // graph is a different class — `instanceof` across the two is false. + const { isCommerceError } = await import('../../../src/core/index.js'); + await expect(config.createAuthHeaders?.()).rejects.toSatisfy( + (err: unknown) => + isCommerceError(err) && + err.code === 'CONFIG_INVALID' && + err.message.includes('@coinbase/x402'), + ); + }); + + it('never puts the credential in anything the binding describes', async () => { + vi.doMock('@coinbase/x402', () => ({ + createCdpAuthHeaders: () => async () => ({}), + })); + mockHttpClient([]); + const { createRemoteFacilitatorBinding } = await import( + '../../../src/payments/x402/facilitator.js' + ); + const binding = createRemoteFacilitatorBinding( + { + url: FACILITATOR_URL, + auth: { type: 'cdp', apiKeyId: 'key-id', apiKeySecret: 'super-secret' }, + }, + () => false, + ); + // `describe` reaches logs, health details and doctor output. + expect(binding.describe).not.toContain('super-secret'); + expect(binding.describe).not.toContain('key-id'); + expect(binding.describe).toContain('auth=cdp'); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 4e3777a..f3cc846 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -40,6 +40,7 @@ function versionReport(): string { // install small. Reading only `dependencies` would blank the report. const deps = { ...pkg.dependencies, ...pkg.peerDependencies }; const wanted = [ + '@coinbase/x402', '@modelcontextprotocol/sdk', '@x402/core', '@x402/evm', diff --git a/vitest.mainnet.config.ts b/vitest.mainnet.config.ts new file mode 100644 index 0000000..e289c54 --- /dev/null +++ b/vitest.mainnet.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config'; + +/** + * Base mainnet smoke suite. **This spends real money.** + * + * Separate from every other vitest config on purpose. `vitest.config.ts` and + * `vitest.e2e.config.ts` must never leave the machine; `vitest.testnet.config.ts` + * spends test funds. This one moves real value, so it is never part of + * `npm run verify`, never runs on a push or a pull request, and skips itself + * unless a human has set `ALLOW_X402_MAINNET=true` and supplied credentials. + * + * Run it with `npm run test:mainnet`, or from the manual + * `.github/workflows/mainnet-smoke.yml`. + */ +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['tests/mainnet/**/*.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**'], + testTimeout: 600_000, + hookTimeout: 600_000, + fileParallelism: false, + pool: 'forks', + // No retries, ever: a retried settlement is a second payment. + retry: 0, + }, +}); From 9dc2ea8776bcde68c793c698e9a7e02c97e1339e Mon Sep 17 00:00:00 2001 From: SergeevDmitry Date: Sun, 23 Aug 2026 18:12:53 +0200 Subject: [PATCH 2/6] payment: enable Base mainnet settlement via an unauthenticated facilitator --- .github/workflows/mainnet-smoke.yml | 85 ------------ .github/workflows/testnet-smoke.yml | 69 --------- .gitignore | 1 + README.md | 65 ++++----- docs/configuration.md | 63 +++++---- docs/mainnet.md | 151 -------------------- docs/payment-flow.md | 94 +++++++------ docs/protocols.md | 45 +++--- docs/security.md | 66 ++++----- docs/testnet.md | 169 ----------------------- examples/base-mainnet-payai/README.md | 80 +++++++++++ examples/base-mainnet-payai/config.yaml | 103 ++++++++++++++ examples/base-mainnet/README.md | 29 +++- examples/base-mainnet/config.yaml | 13 +- examples/base-sepolia/README.md | 10 +- src/cli/commands/doctor.ts | 20 ++- src/config/schema.ts | 16 +++ src/gateway/main.ts | 3 + src/payments/x402/guardrails.ts | 76 ++++++++-- src/payments/x402/networks.ts | 34 ++++- src/payments/x402/provider.ts | 10 ++ tests/mainnet/base.smoke.test.ts | 46 ++++-- tests/testnet/base-sepolia.smoke.test.ts | 21 ++- tests/unit/config/schema.test.ts | 93 ++++++++++++- vitest.mainnet.config.ts | 11 +- vitest.testnet.config.ts | 11 +- 26 files changed, 687 insertions(+), 697 deletions(-) delete mode 100644 .github/workflows/mainnet-smoke.yml delete mode 100644 .github/workflows/testnet-smoke.yml delete mode 100644 docs/mainnet.md delete mode 100644 docs/testnet.md create mode 100644 examples/base-mainnet-payai/README.md create mode 100644 examples/base-mainnet-payai/config.yaml diff --git a/.github/workflows/mainnet-smoke.yml b/.github/workflows/mainnet-smoke.yml deleted file mode 100644 index 9e41947..0000000 --- a/.github/workflows/mainnet-smoke.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Mainnet smoke (Base) — REAL FUNDS - -# Manual only, and deliberately awkward. Every run of this workflow moves real -# money on Base. It is not on a schedule, not on a push, and not on a pull -# request: a fork PR that could trigger it would be a way to drain the wallet -# by opening one. -on: - workflow_dispatch: - inputs: - confirm: - description: 'Type SPEND REAL FUNDS to confirm' - required: true - default: '' - amount: - description: 'USDC to spend (every run spends it)' - required: false - default: '0.01' - -permissions: - contents: read - -concurrency: - # Never two at once: both runs share one buyer wallet, and two in-flight - # EIP-3009 authorisations against one nonce space is the race the gateway's - # replay reservation exists to catch — not something to trigger on purpose. - group: mainnet-smoke - cancel-in-progress: false - -jobs: - smoke: - name: Base mainnet settlement - runs-on: ubuntu-latest - timeout-minutes: 30 - # A protected environment adds a required reviewer in front of real money. - environment: mainnet - steps: - - name: Confirm intent - env: - CONFIRM: ${{ inputs.confirm }} - run: | - if [ "$CONFIRM" != "SPEND REAL FUNDS" ]; then - echo "::error::This workflow spends real money. Re-run and type: SPEND REAL FUNDS" - exit 1 - fi - - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - - - run: npm ci - - # The CDP auth type needs this optional peer; it is not installed by - # default and nothing else in the suite requires it. - - run: npm install --no-save @coinbase/x402@2.1.0 - - - name: Settle on Base - env: - ALLOW_X402_MAINNET: 'true' - X402_MAINNET_BUYER_PRIVATE_KEY: ${{ secrets.X402_MAINNET_BUYER_PRIVATE_KEY }} - X402_MAINNET_MERCHANT_ADDRESS: ${{ secrets.X402_MAINNET_MERCHANT_ADDRESS }} - X402_MAINNET_RPC_URL: ${{ secrets.X402_MAINNET_RPC_URL }} - X402_FACILITATOR_URL: ${{ secrets.X402_FACILITATOR_URL }} - CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }} - CDP_API_KEY_SECRET: ${{ secrets.CDP_API_KEY_SECRET }} - X402_FACILITATOR_TOKEN: ${{ secrets.X402_FACILITATOR_TOKEN }} - X402_MAINNET_AMOUNT: ${{ inputs.amount }} - run: npm run test:mainnet - - # The suite skips itself when a credential is missing, which would - # otherwise look exactly like a pass on a workflow whose whole purpose is - # to prove a real settlement happened. - - name: Fail if the smoke test skipped - if: always() - env: - BUYER: ${{ secrets.X402_MAINNET_BUYER_PRIVATE_KEY }} - MERCHANT: ${{ secrets.X402_MAINNET_MERCHANT_ADDRESS }} - FACILITATOR: ${{ secrets.X402_FACILITATOR_URL }} - run: | - if [ -z "$BUYER" ] || [ -z "$MERCHANT" ] || [ -z "$FACILITATOR" ]; then - echo "::error::mainnet secrets are not set in the 'mainnet' environment; the smoke test skipped and proved nothing." - exit 1 - fi diff --git a/.github/workflows/testnet-smoke.yml b/.github/workflows/testnet-smoke.yml deleted file mode 100644 index 4b068b7..0000000 --- a/.github/workflows/testnet-smoke.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Testnet smoke (Base Sepolia) - -# Manual only. This workflow spends real testnet USDC and calls a public RPC -# and a hosted facilitator, so it must never run on a pull request — a fork PR -# would otherwise be able to drain the test wallet by opening one, and CI's -# determinism guarantee only holds because nothing on the normal path leaves -# the runner. -on: - workflow_dispatch: - inputs: - amount: - description: 'USDC to spend per settlement (small; every run spends it)' - required: false - default: '0.01' - facilitator_url: - description: 'x402 facilitator endpoint' - required: false - default: 'https://x402.org/facilitator' - -permissions: - contents: read - -concurrency: - # One at a time: two concurrent runs share one buyer wallet, and two - # in-flight EIP-3009 authorisations against the same nonce space is exactly - # the race the gateway's replay reservation exists to catch. Not something - # to trigger from CI on purpose. - group: testnet-smoke - cancel-in-progress: false - -jobs: - smoke: - name: Base Sepolia settlement - runs-on: ubuntu-latest - timeout-minutes: 20 - environment: testnet - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - - - run: npm ci - - - name: Settle on Base Sepolia - env: - # Dedicated test wallet. Never a mainnet key, never reused anywhere. - X402_TESTNET_BUYER_PRIVATE_KEY: ${{ secrets.X402_TESTNET_BUYER_PRIVATE_KEY }} - X402_TESTNET_MERCHANT_ADDRESS: ${{ secrets.X402_TESTNET_MERCHANT_ADDRESS }} - X402_TESTNET_RPC_URL: ${{ secrets.X402_TESTNET_RPC_URL }} - X402_TESTNET_FACILITATOR_URL: ${{ inputs.facilitator_url }} - X402_TESTNET_AMOUNT: ${{ inputs.amount }} - run: npm run test:testnet - - # The suite skips itself when its secrets are absent, which would - # otherwise look identical to a pass. Fail the run instead, so a - # missing secret is never mistaken for a green testnet. - - name: Fail if the smoke test skipped - if: always() - env: - BUYER: ${{ secrets.X402_TESTNET_BUYER_PRIVATE_KEY }} - MERCHANT: ${{ secrets.X402_TESTNET_MERCHANT_ADDRESS }} - run: | - if [ -z "$BUYER" ] || [ -z "$MERCHANT" ]; then - echo "::error::X402_TESTNET_BUYER_PRIVATE_KEY / X402_TESTNET_MERCHANT_ADDRESS are not set in the 'testnet' environment; the smoke test skipped and proved nothing." - exit 1 - fi diff --git a/.gitignore b/.gitignore index b9b3f03..31b671d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ coverage/ *.log .env .env.local +.env.chains .env.testnet .env.*.local data/ diff --git a/README.md b/README.md index e79751e..4e72bb4 100644 --- a/README.md +++ b/README.md @@ -206,13 +206,13 @@ See [docs/configuration.md](docs/configuration.md). ## Protocol support -| Protocol | Status | Pinned revision | -| --------------------- | --------- | ---------------------------------- | -| **MCP** | Supported | `@modelcontextprotocol/sdk@1.30.0` | +| Protocol | Status | Pinned revision | +| --------------------- | --------- | -------------------------------------------------------- | +| **MCP** | Supported | `@modelcontextprotocol/sdk@1.30.0` | | **x402** | Supported | x402 v2 (`@x402/core`, `@x402/evm`), scheme `exact`, EVM | -| **HTTP** | Supported | native routes | -| UCP | Planned | — | -| ACP · MPP · A2A · AP2 | Planned | — | +| **HTTP** | Supported | native routes | +| UCP | Planned | — | +| ACP · MPP · A2A · AP2 | Planned | — | "Planned" means **no code ships for it**. Each adapter reports its own `supportedSpec`, `capabilities` and `unsupported` list at runtime via @@ -235,6 +235,25 @@ checkable, not marketing. Detail: [docs/protocols.md](docs/protocols.md). Detail: [docs/payment-flow.md](docs/payment-flow.md). +### Settled on public networks + +Not a roadmap entry. Both of these moved 0.01 USDC from a buyer to a merchant +through a remote facilitator: + +| Network | Transaction | +| ------------ | --------------------------------------------------------------------------------------------------------------------- | +| Base Sepolia | [`0xea41b234c4…`](https://sepolia.basescan.org/tx/0xea41b234c4645a4d335589ec9753646aa7cccd1b97e9e15823b88bff7b54a247) | +| Base | [`0x57ec81c2a3…`](https://basescan.org/tx/0x57ec81c2a360d14d59a43cf4e24be09a6bd75cbe6185016372895bda73e42763) | + +In both, the gateway held no key, signed nothing and paid no gas — the buyer +signed an EIP-3009 authorisation offline holding no ETH, and the facilitator +broadcast it. Each run reads the buyer and merchant balances and the +transaction receipt back off the chain afterwards; the gateway's own report of +success is not the proof. + +Reproduce with `npm run test:testnet` / `npm run test:mainnet` — both spend +real funds, skip themselves without credentials, and never run in CI. + ## Diagnostics ```console @@ -277,35 +296,6 @@ reachable by anyone else, know the split: [SECURITY.md](SECURITY.md) states plainly what this does and does not protect. -## Public networks - -**Base Sepolia settlement is demonstrated**, not merely configurable: USDC has -moved from buyer to merchant through the public facilitator, with the gateway -holding no key and paying no gas. The transaction is in -[docs/testnet.md](docs/testnet.md). The deterministic local chain -(Anvil + MockUSDC) is still what the default test suite covers. - -Base Sepolia (`eip155:84532`), Base mainnet (`eip155:8453`) and a remote HTTP -facilitator can now be configured, with guardrails that refuse the combinations -that lose money — mainnet needs an explicit `allowMainnet`, a remote -facilitator over HTTPS with a credential, a non-development `payTo`, and the -canonical USDC for the chain. Those are checked at config load, so -`agent-commerce validate` catches them and the gateway will not start without -them. - -A ready-to-run testnet config is in -[`examples/base-sepolia/`](examples/base-sepolia/), and -`npm run test:testnet` drives the whole flow against Base Sepolia and reads -the balances and transaction receipt back off the chain to prove it. It needs -a funded test wallet, skips itself without one, and is deliberately outside -`npm test` and `npm run test:e2e` — both of those must stay offline. - -**Mainnet is a different claim, and it is not made.** `eip155:8453` is -configurable and guarded — an explicit opt-in, a remote authenticated -facilitator over HTTPS, a non-development `payTo` and the canonical USDC, all -checked at config load — but no payment has been settled on it. See -[docs/mainnet.md](docs/mainnet.md). - ## Development ```bash @@ -318,7 +308,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). ## Roadmap -**Now (v0.2.0-beta)** — MCP, x402 v2, Base Sepolia settlement, receipts, doctor, deterministic demo. +**Now (v0.2.0-beta)** — MCP, x402 v2, settlement on the local chain, Base +Sepolia and Base mainnet, receipts, doctor, deterministic demo. **Next** — OpenAPI import · a stronger conformance suite · a `doctor` GitHub Action · UCP · MPP · ACP · A2A · AP2 · Shopify and WooCommerce examples · @@ -335,8 +326,6 @@ discipline is a release requirement, not a mood. | [Payment flow](docs/payment-flow.md) | the paid round trip, and every way it fails | | [Protocols](docs/protocols.md) | exactly what is and is not supported | | [Configuration](docs/configuration.md) | `config.yaml` reference | -| [Base Sepolia](docs/testnet.md) | running on a public testnet, and proving it | -| [Base mainnet](docs/mainnet.md) | real funds: what is refused, and why | | [Security model](docs/security.md) | trust boundaries, and what we do not defend | | [Contracts](docs/contracts.md) | the frozen cross-package contract | | [Adapter guide](docs/contributing-adapters.md) | add a protocol or a payment rail | diff --git a/docs/configuration.md b/docs/configuration.md index 838cfba..b037895 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -25,15 +25,15 @@ npm run agent-commerce -- validate ## Top level -| Key | Required | Purpose | -|---|---|---| -| `version` | yes | must be `1` | -| `merchant` | yes | `id`, `name`, `publicBaseUrl` | -| `server` | yes | `port`, `host` | -| `storage.receipts` | yes | `driver: sqlite`, `path` | -| `protocols` | yes | which surfaces are enabled | -| `resources` | yes | the capabilities you expose | -| `payments` | when a paid resource exists | rail configuration | +| Key | Required | Purpose | +| ------------------ | --------------------------- | ----------------------------- | +| `version` | yes | must be `1` | +| `merchant` | yes | `id`, `name`, `publicBaseUrl` | +| `server` | yes | `port`, `host` | +| `storage.receipts` | yes | `driver: sqlite`, `path` | +| `protocols` | yes | which surfaces are enabled | +| `resources` | yes | the capabilities you expose | +| `payments` | when a paid resource exists | rail configuration | ## Resources @@ -98,10 +98,10 @@ presenter can confirm where money goes. `network` is a CAIP-2 identifier and must be one this build knows: -| `network` | | Notes | -|---|---|---| +| `network` | | Notes | +| -------------- | ------------ | ------------------------------------------ | | `eip155:84532` | Base Sepolia | the chain id the local dev chain also uses | -| `eip155:8453` | Base | mainnet; real funds | +| `eip155:8453` | Base | mainnet; real funds | Anything else is `CONFIG_INVALID` at load. The chain id is signed into the buyer's EIP-712 domain, so an unrecognised network is never guessed at. @@ -137,14 +137,15 @@ between the local dev chain and public Base Sepolia. It is reported by `eip155:8453` moves real money, so a config naming it must also say so. All of these are refused at config load, before the gateway starts: -| Refused | Because | -|---|---| -| `allowMainnet` absent or false | mainnet is never a default | -| `facilitator.mode: local` | the in-process signer is a hot wallet inside the resource server | -| `facilitator.auth.type: none` | an unauthenticated production facilitator | -| a non-HTTPS `facilitator.url` | authorisations and settlement results in the clear | -| a well-known Anvil `payTo` | its private key is public knowledge | -| an `asset` that is not USDC on Base | settling in an unintended token | +| Refused | Because | +| ----------------------------------- | ----------------------------------------------------------- | +| `allowMainnet` absent or false | mainnet is never a default | +| `facilitator.mode: local` | a funded gas key inside the resource server | +| `facilitator.auth.type: none` | unless `allowUnauthenticatedFacilitator` accepts it by name | +| a non-HTTPS `facilitator.url` | authorisations and settlement results in the clear | +| a well-known Anvil `payTo` | its private key is public knowledge | +| an `asset` that is not USDC on Base | settling in an unintended token | +| an `assetName` that is not the EIP-712 domain USDC reports | every payment refused after the buyer signed | The same rules apply to any non-local deployment where they make sense: plain HTTP is allowed only to a local/private host, and a development `payTo` is @@ -159,10 +160,24 @@ nothing installed) and `cdp` (Coinbase Developer Platform, which signs a fresh JWT per request and needs the optional peer `@coinbase/x402`). Anything else is refused at config load rather than sent nothing. -**Base Sepolia is exercised; mainnet is not.** A real payment has settled on -Base Sepolia through the public facilitator ([testnet.md](testnet.md)). Nothing -has settled on `eip155:8453` — see [mainnet.md](mainnet.md) for what the -guardrails refuse there, which are checks, not evidence. +### `assetName` is the EIP-712 domain, not the symbol + +The two USDC deployments disagree. Base Sepolia's reports `"USDC"`; Base +mainnet's reports `"USD Coin"` — it predates the rename. The buyer signs that +string into their EIP-712 domain and the scheme checks it, so naming the +obvious-looking value gets every payment refused +`invalid_exact_evm_token_name_mismatch` *after* they have signed. Both values +are pinned in the network registry (`src/payments/x402/networks.ts`) and +checked at config load, so a mismatch stops the gateway starting instead. + +### Running against a public network + +Worked 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; both spend real funds, skip themselves +without credentials, and never run in CI. ## Unsupported JSON Schema keywords have a cost diff --git a/docs/mainnet.md b/docs/mainnet.md deleted file mode 100644 index 5d889d2..0000000 --- a/docs/mainnet.md +++ /dev/null @@ -1,151 +0,0 @@ -# Base mainnet - -Real funds. Read this before the config. - -> **Status.** Implemented and guarded; **not demonstrated**. No payment has -> settled on `eip155:8453` from this repository. [Has it actually -> settled?](#has-it-actually-settled) is updated only when one has. - -## What is different from a testnet - -Two things, and neither is a flag. - -**The gateway holds no key, and cannot.** `facilitator.mode: local` signs with -a key this process holds — a hot wallet inside the resource server, which is -the arrangement the non-custodial design exists to avoid. It is refused on -mainnet outright, not warned about. A mainnet deployment settles through a -remote facilitator, which broadcasts and pays the gas. - -**Nothing is defaulted.** On a network where money is real, a default is a way -to lose it by accident. Every requirement below is checked at config load, so -`agent-commerce validate` reports it and the gateway does not start: - -| Refused | Because | -|---|---| -| `allowMainnet` absent or false | mainnet is never a default | -| `facilitator.mode: local` | a hot wallet inside the resource server | -| `facilitator.auth.type: none` | an unauthenticated production facilitator | -| a non-HTTPS `facilitator.url` | authorisations and settlement results in the clear | -| an empty credential | a blank token reaches the facilitator as "unauthenticated" | -| a well-known Anvil `payTo` | its private key is public knowledge | -| any `asset` but USDC on Base | settling in an unintended token | - -Seen from the outside: - -```console -$ agent-commerce validate --config config.yaml -FAIL CONFIG_INVALID: payments.x402: network "eip155:8453" (Base) settles real funds. - Set payments.x402.allowMainnet: true to acknowledge this explicitly — it is - never the default. -``` - -## Configuration - -A complete file is in -[`examples/base-mainnet/config.yaml`](../examples/base-mainnet/config.yaml). - -```yaml -payments: - x402: - network: eip155:8453 - asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" # USDC on Base - assetName: USDC - assetVersion: "2" - assetDecimals: 6 - payTo: ${MERCHANT_WALLET} - maxTimeoutSeconds: 600 - allowMainnet: ${ALLOW_X402_MAINNET} - facilitator: - mode: remote - url: ${X402_FACILITATOR_URL} - auth: - type: cdp - apiKeyId: ${CDP_API_KEY_ID} - apiKeySecret: ${CDP_API_KEY_SECRET} -``` - -An unresolved `${VAR}` fails config loading rather than resolving to an empty -string, so a missing credential is a startup failure, never a silently -unauthenticated facilitator. - -## Choosing a facilitator - -There is no free mainnet facilitator equivalent to the public testnet one: -`https://x402.org/facilitator` advertises `eip155:84532` and nothing on -mainnet. Whichever you pick is a real counterparty that sees every payment -authorisation you handle, and probably sends you a bill. - -Three auth types exist: - -| `auth.type` | For | Installs | -|---|---|---| -| `none` | facilitators that take no credential | nothing | -| `bearer` | any facilitator with a static token | nothing | -| `cdp` | Coinbase Developer Platform | `@coinbase/x402` | - -**`bearer` is the cheaper path in every sense.** `cdp` exists because CDP signs -a fresh JWT per request over method + host + path, which a static header cannot -express — it is not a preference for Coinbase, and nothing in the architecture -is shaped around them. - -### The `@coinbase/x402` dependency - -It is an **optional peer**, imported dynamically only when `auth.type: cdp` is -configured. Nobody else installs it, and it is absent from the default install. - -Know what it brings: `@coinbase/x402` → `@coinbase/cdp-sdk` → `axios`, which at -the time of writing carries ten high-severity advisories, plus a Solana client -tree this project has no use for. That is a real supply-chain surface on the -highest-stakes path in the system. If your facilitator accepts a static token, -`bearer` avoids all of it. - -A missing peer is a *configuration* failure, surfaced through `health()` and -`/ready` before any buyer signs anything — never an exception inside `verify()` -with an authorisation already spent. - -## The smoke test - -```bash -export ALLOW_X402_MAINNET=true -export X402_MAINNET_BUYER_PRIVATE_KEY=0x... # funded with USDC on Base -export X402_MAINNET_MERCHANT_ADDRESS=0x... -export X402_FACILITATOR_URL=https://... -export CDP_API_KEY_ID=... CDP_API_KEY_SECRET=... # or X402_FACILITATOR_TOKEN -npm run test:mainnet -``` - -**Every run spends `X402_MAINNET_AMOUNT` (default `0.01`) of real USDC.** It -skips itself, naming what is missing, unless all of the above are set — five -separate deliberate acts. - -It proves, in order: the guard refuses a config that has not opted in · -authentication reaches the facilitator · a payment settles on Base · the -receipt carries the settlement reference and a delivery timestamp · the -resource is delivered exactly once · the same authorisation 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. The -gateway's own report of success is not the proof, and `retry: 0` is set -deliberately — a retried settlement is a second payment. - -In CI it is `.github/workflows/mainnet-smoke.yml`: `workflow_dispatch` only, -behind a `mainnet` environment (add a required reviewer), requiring the literal -string `SPEND REAL FUNDS` typed into an input, serialised so two runs cannot -share one nonce space, and failing rather than passing when its secrets are -absent. - -## Keys - -The buyer key is read from the environment, never written to a config file, -never logged, and never included in an assertion message. The merchant side -never needs a key at all — only an address to settle to. A mainnet key must -never appear in this repository, in `.env.testnet`, or in a config file. - -## Has it actually settled? - -**No.** Every settlement this project has performed was on the local -deterministic chain or on Base Sepolia ([testnet.md](testnet.md)). - -The mainnet path is implemented, guarded, and covered by a smoke test that has -never been run against a funded wallet. When it has, this section carries the -transaction rather than a claim. diff --git a/docs/payment-flow.md b/docs/payment-flow.md index 1859dfa..8f57456 100644 --- a/docs/payment-flow.md +++ b/docs/payment-flow.md @@ -4,12 +4,12 @@ How a paid resource actually gets paid for, end to end. ## Roles -| Role | Holds a key? | Where it runs | -|---|---|---| -| Buyer agent | yes — its own | the agent's machine (`demo/agent` in the demo) | -| Gateway | **no** | merchant infrastructure | -| Facilitator | a gas-paying signer | local dev chain in the demo; external in production | -| Merchant | destination address only | configuration (`payTo`) | +| Role | Holds a key? | Where it runs | +| ----------- | ------------------------ | --------------------------------------------------- | +| Buyer agent | yes — its own | the agent's machine (`demo/agent` in the demo) | +| Gateway | **no** | merchant infrastructure | +| Facilitator | a gas-paying signer | local dev chain in the demo; external in production | +| Merchant | destination address only | configuration (`payTo`) | The gateway is in the middle of the *protocol* and outside the *custody*. @@ -70,21 +70,21 @@ happened without ever being able to take it. ## Fail-closed matrix -| Condition | Result | Delivered? | -|---|---|---| -| no proof supplied | `PaymentRequiredOutcome`, 402 + envelope | no | -| malformed proof | `PAYMENT_INVALID` | no | -| bad signature | `PAYMENT_INVALID` | no | -| wrong amount (`value < amount`) | `PAYMENT_INVALID` | no | -| wrong recipient (`to != payTo`) | `PAYMENT_INVALID` | no | -| wrong network | `PAYMENT_INVALID` | no | -| wrong asset | `PAYMENT_INVALID` | no | -| authorisation expired / not yet valid | `PAYMENT_INVALID` | no | -| insufficient balance | `PAYMENT_INVALID` | no | -| authorisation already seen | `PAYMENT_REPLAYED` | no | -| provider/RPC unreachable | `PAYMENT_PROVIDER_UNAVAILABLE` (retryable) | no | -| settlement transaction fails | `PAYMENT_SETTLEMENT_FAILED` | no | -| backend fails **after** settlement | `BACKEND_ERROR` / `BACKEND_TIMEOUT` | no — payment recorded, delivery failed | +| Condition | Result | Delivered? | +| ------------------------------------- | ------------------------------------------ | -------------------------------------- | +| no proof supplied | `PaymentRequiredOutcome`, 402 + envelope | no | +| malformed proof | `PAYMENT_INVALID` | no | +| bad signature | `PAYMENT_INVALID` | no | +| wrong amount (`value < amount`) | `PAYMENT_INVALID` | no | +| wrong recipient (`to != payTo`) | `PAYMENT_INVALID` | no | +| wrong network | `PAYMENT_INVALID` | no | +| wrong asset | `PAYMENT_INVALID` | no | +| authorisation expired / not yet valid | `PAYMENT_INVALID` | no | +| insufficient balance | `PAYMENT_INVALID` | no | +| authorisation already seen | `PAYMENT_REPLAYED` | no | +| provider/RPC unreachable | `PAYMENT_PROVIDER_UNAVAILABLE` (retryable) | no | +| settlement transaction fails | `PAYMENT_SETTLEMENT_FAILED` | no | +| backend fails **after** settlement | `BACKEND_ERROR` / `BACKEND_TIMEOUT` | no — payment recorded, delivery failed | The last row is the honest one: settlement is final, so a backend failure after payment is a reconciliation problem, not a rollback. It is recorded as a @@ -132,29 +132,39 @@ relies on. ## Public networks -Base Sepolia settlement is demonstrated — see [testnet.md](testnet.md) for the -transaction. Base mainnet is configurable and guarded, and nothing has settled -on it. The deterministic local chain remains what the default suite covers. - -Three things shape what a public-network config is allowed to look like: +Base Sepolia (`eip155:84532`) and Base (`eip155:8453`) both settle, and both +have been exercised against the real chains — the same pipeline, the same +provider, a different `network` and a remote facilitator. Four things shape +what a public-network config is allowed to look like: - **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. -- **Mainnet is refused unless every guardrail is satisfied** — explicit - `allowMainnet`, a remote facilitator over HTTPS carrying a credential, a - non-development `payTo`, and the canonical USDC for the chain. These are - checked at config load, so `agent-commerce validate` catches them, and the - gateway will not start without them. See - [configuration.md](configuration.md). -- **In local mode `health()` still probes `anvil_nodeInfo`**, so a local - facilitator pointed at a real node reports unhealthy and `/ready` returns - 503. In remote mode it asks the facilitator what it supports instead, and - fails if our scheme and network are not on the list. - -There is still no "live mode" toggle. There is configuration, and there are -checks that refuse the combinations that would lose money. - -Running it, and proving a payment settled there: -[testnet.md](testnet.md). +- **Mainnet is refused unless every guardrail is satisfied** — an explicit + `allowMainnet`, a remote facilitator over HTTPS, a second explicit + acknowledgement if that facilitator takes no credential, a non-development + `payTo`, and the canonical USDC for the chain including the EIP-712 domain + name it actually reports. All checked at config load, so + `agent-commerce validate` catches them and the gateway will not start + without them. 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. Naming the wrong one gets every payment refused + `invalid_exact_evm_token_name_mismatch` *after* they signed — so it is + refused at startup instead. +- **`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 + facilitator what it supports and fails if our scheme and network are not on + its list — a facilitator that is up but cannot settle this pair would + otherwise fail every payment after the buyer signed. + +There is no "live mode" toggle in the sense of a switch that makes things +work. `allowMainnet` is an acknowledgement, not an enabler: everything is +already wired, and what that flag does is refuse to proceed until someone has +said out loud that the money is real. + +Running against a public network is [configuration.md](configuration.md); a +worked config for each is in `examples/base-sepolia/` and +`examples/base-mainnet/`. diff --git a/docs/protocols.md b/docs/protocols.md index 2b4e9ec..79faf6f 100644 --- a/docs/protocols.md +++ b/docs/protocols.md @@ -5,16 +5,16 @@ implemented, exactly what is not, and pins the revisions. ## Support matrix -| Protocol | Status | Revision | What works | -|---|---|---|---| -| **MCP** | Supported | `@modelcontextprotocol/sdk@1.30.0` | tool discovery, tool invocation, payment-required and error mapping | -| **x402** | Supported | x402 **v2** (`@x402/core@2.23.0`, `@x402/evm@2.23.0`), scheme `exact`, EVM, EIP-3009 | challenge, verification, settlement, replay binding | -| **HTTP** | Supported | — | native resource routes with `PAYMENT-SIGNATURE` | -| UCP | Planned | — | not in v0.1 | -| ACP | Planned | — | not in v0.1 | -| MPP | Planned | — | not in v0.1 | -| A2A | Planned | — | not in v0.1 | -| AP2 | Planned | — | not in v0.1 | +| Protocol | Status | Revision | What works | +| -------- | --------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | +| **MCP** | Supported | `@modelcontextprotocol/sdk@1.30.0` | tool discovery, tool invocation, payment-required and error mapping | +| **x402** | Supported | x402 **v2** (`@x402/core@2.23.0`, `@x402/evm@2.23.0`), scheme `exact`, EVM, EIP-3009 | challenge, verification, settlement, replay binding | +| **HTTP** | Supported | — | native resource routes with `PAYMENT-SIGNATURE` | +| UCP | Planned | — | not in v0.1 | +| ACP | Planned | — | not in v0.1 | +| MPP | Planned | — | not in v0.1 | +| A2A | Planned | — | not in v0.1 | +| AP2 | Planned | — | not in v0.1 | "Planned" means **no code ships for it**. There is no partial adapter, no endpoint and no diagnostic pretending otherwise. @@ -106,22 +106,21 @@ Facilitator auth covers `none`, `bearer` and `cdp`; any other scheme is refused at config load rather than sent nothing. A remote HTTP facilitator **is** supported (`facilitator.mode: remote`), and -Base Sepolia settlement is demonstrated on chain — see -[testnet.md](testnet.md). Base mainnet is configurable and guarded by -[configuration.md](configuration.md)'s checks, and nothing has settled on it. +so are Base Sepolia and Base mainnet — both have settled real payments through +one. What guards mainnet is in [configuration.md](configuration.md). ## HTTP surface -| Route | Purpose | -|---|---| -| `GET /health` | liveness | -| `GET /ready` | readiness — config, store, required adapters and configured payment providers | -| `GET /.well-known/agent-commerce` | merchant info, adapter descriptors, pinned versions, effective settlement destination | -| `GET /api/resources` | canonical resource list | -| `POST /api/resources/:id/invoke` | invoke; `PAYMENT-SIGNATURE` in, `402` + body envelope and `PAYMENT-REQUIRED` header when unpaid, `PAYMENT-RESPONSE` out | -| `GET /api/receipts`, `GET /api/events` | audit | -| `GET /api/events/stream` | SSE event feed | -| `/mcp` | MCP Streamable HTTP | +| Route | Purpose | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `GET /health` | liveness | +| `GET /ready` | readiness — config, store, required adapters and configured payment providers | +| `GET /.well-known/agent-commerce` | merchant info, adapter descriptors, pinned versions, effective settlement destination | +| `GET /api/resources` | canonical resource list | +| `POST /api/resources/:id/invoke` | invoke; `PAYMENT-SIGNATURE` in, `402` + body envelope and `PAYMENT-REQUIRED` header when unpaid, `PAYMENT-RESPONSE` out | +| `GET /api/receipts`, `GET /api/events` | audit | +| `GET /api/events/stream` | SSE event feed | +| `/mcp` | MCP Streamable HTTP | ## Adding a protocol diff --git a/docs/security.md b/docs/security.md index 64524e4..5414475 100644 --- a/docs/security.md +++ b/docs/security.md @@ -86,14 +86,14 @@ field. A merchant that wants pass-through is a per-resource opt-in, post-alpha. Validated at the boundary, before anything else happens: -| Input | Check | -|---|---| -| resource input | JSON Schema from the resource definition, closed by default at every level: an object schema — root, nested under `properties`, or nested under `items` — that omits `additionalProperties` gets `additionalProperties: false` stamped on recursively at config load, not just at the root; an operator who sets it explicitly (including explicitly to `true`) is respected at whichever level they set it. A resource that declares no `input:` at all gets an empty closed schema, not an always-valid one — declaring nothing means accepting nothing. Unknown properties, including prototype-named keys (`__proto__`, `constructor`, …), are matched by own-property lookup only. | -| path parameters | URL-encoded on substitution | -| body size | capped at 256 KB, one number for both surfaces, enforced in two different places: Fastify's `bodyLimit` runs inside a body parser on the HTTP routes; `/mcp` deliberately installs a no-op parser so the MCP transport can read the raw stream, so the mount enforces its own byte count instead. A cap that only protects one of two entry points, or two caps that can silently drift apart, is how `/mcp` ended up with no cap at all in the first place. | -| content type | JSON enforced on the invoke routes. **Not** on `/mcp`, where a wildcard no-op parser hands the raw stream to the MCP SDK and the SDK does its own enforcement. | -| payment proof | decoded and schema-validated by the payment provider; a malformed proof is a rejection, never a crash | -| configuration | Zod, strict, before startup | +| Input | Check | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| resource input | JSON Schema from the resource definition, closed by default at every level: an object schema — root, nested under `properties`, or nested under `items` — that omits `additionalProperties` gets `additionalProperties: false` stamped on recursively at config load, not just at the root; an operator who sets it explicitly (including explicitly to `true`) is respected at whichever level they set it. A resource that declares no `input:` at all gets an empty closed schema, not an always-valid one — declaring nothing means accepting nothing. Unknown properties, including prototype-named keys (`__proto__`, `constructor`, …), are matched by own-property lookup only. | +| path parameters | URL-encoded on substitution | +| body size | capped at 256 KB, one number for both surfaces, enforced in two different places: Fastify's `bodyLimit` runs inside a body parser on the HTTP routes; `/mcp` deliberately installs a no-op parser so the MCP transport can read the raw stream, so the mount enforces its own byte count instead. A cap that only protects one of two entry points, or two caps that can silently drift apart, is how `/mcp` ended up with no cap at all in the first place. | +| content type | JSON enforced on the invoke routes. **Not** on `/mcp`, where a wildcard no-op parser hands the raw stream to the MCP SDK and the SDK does its own enforcement. | +| payment proof | decoded and schema-validated by the payment provider; a malformed proof is a rejection, never a crash | +| configuration | Zod, strict, before startup | The reserved `_payment` field is stripped from tool input before schema validation, so it can never collide with a resource's own properties. @@ -119,13 +119,13 @@ Covered in detail in [payment-flow.md](payment-flow.md). The invariants: The surface splits by audience, and the split is enforced, not advisory: -| Route | Audience | Protection | -|---|---|---| -| `POST /api/resources/:id/invoke` | agents | payment, not authentication | -| `/mcp` | agents | payment, not authentication | -| `GET /api/resources`, `/health`, `/.well-known/agent-commerce` | anyone | none — public by design | -| `GET /ready` | operators | none, but detail is a fixed vocabulary, never raw errors | -| `GET /api/receipts`, `/api/events`, `/api/events/stream` | **operators** | `server.adminToken`, compared in constant time | +| Route | Audience | Protection | +| -------------------------------------------------------------- | ------------- | -------------------------------------------------------- | +| `POST /api/resources/:id/invoke` | agents | payment, not authentication | +| `/mcp` | agents | payment, not authentication | +| `GET /api/resources`, `/health`, `/.well-known/agent-commerce` | anyone | none — public by design | +| `GET /ready` | operators | none, but detail is a fixed vocabulary, never raw errors | +| `GET /api/receipts`, `/api/events`, `/api/events/stream` | **operators** | `server.adminToken`, compared in constant time | The operator routes carry the merchant's commerce ledger. With no `server.adminToken` configured they return **404**, not open data — a missing @@ -205,22 +205,26 @@ succeeds and gives the money away. ## Mainnet `eip155:8453` is refused unless the configuration says so in full: an explicit -`allowMainnet`, a remote facilitator reached over HTTPS and carrying a -credential, a non-development `payTo`, and the canonical USDC for the chain. -All of it is checked at config load, so the gateway does not start otherwise -and `agent-commerce validate` reports it without starting anything. - -The in-process facilitator is never allowed on a mainnet: it signs with a key -this process holds, which is a hot wallet inside the resource server — the -arrangement the non-custodial design exists to avoid. With a remote -facilitator the gateway holds no signing key at all. - -Base Sepolia settlement has been performed and verified on chain -([testnet.md](testnet.md)). **Mainnet has not**, and nothing here should be -read as a claim that it has: what this section describes is what the -configuration permits and refuses, not what has been exercised with real funds. -The full runbook, including the supply-chain cost of the CDP auth type, is -[mainnet.md](mainnet.md). +`allowMainnet`, a remote facilitator reached over HTTPS, a second explicit +acknowledgement (`allowUnauthenticatedFacilitator`) if that facilitator takes +no credential, a non-development `payTo`, and the canonical USDC for the chain +including the EIP-712 domain name it reports. All of it is checked at config +load, so the gateway does not start otherwise and `agent-commerce validate` +reports it without starting anything. + +A facilitator cannot redirect your money — an EIP-3009 authorisation names its +recipient, its amount and its chain, so it can broadcast exactly that transfer +or nothing. What it can do is see every authorisation you handle, and stop +answering. That is why an unauthenticated one is a separate, explicit +acknowledgement rather than a warning. + +The in-process facilitator is never allowed on a mainnet. To be precise about +why: it is not a custody problem — the facilitator signer never holds buyer or +merchant funds, it pays gas and broadcasts `transferWithAuthorization`, and the +money moves buyer to merchant directly on-chain. It is a *funded key inside the +resource server*, so compromising that process means draining the gas wallet +and broadcasting arbitrary transactions from it. With a remote facilitator the +gateway holds no signing key at all. ## Threats we are not addressing in the alpha diff --git a/docs/testnet.md b/docs/testnet.md deleted file mode 100644 index 8746693..0000000 --- a/docs/testnet.md +++ /dev/null @@ -1,169 +0,0 @@ -# Base Sepolia - -Running the gateway against a public testnet, and proving that a payment -actually settled there. - -> **Status.** Demonstrated. A real payment settled on Base Sepolia on -> 2026-08-22 — -> [`0xf3288399…`](https://sepolia.basescan.org/tx/0xf3288399e31eab683f9bced802fad2dcf44072f93e1aa51223a0f8398e7668e8). -> Details at the bottom: [Has it actually settled?](#has-it-actually-settled). - -## What changes, and what doesn't - -Nothing in the application. A public testnet is a different `network` and a -different facilitator: - -```yaml -payments: - x402: - network: eip155:84532 - asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" # Circle USDC - assetName: USDC - assetVersion: "2" - assetDecimals: 6 - payTo: ${MERCHANT_WALLET} - facilitator: - mode: remote - url: https://x402.org/facilitator - auth: - type: none -``` - -A complete file is in [`examples/base-sepolia/config.yaml`](../examples/base-sepolia/config.yaml). - -Two things go away: - -- **The gateway holds no signing key.** `facilitator.mode: local` needs a - `signerPrivateKey` to broadcast; a remote facilitator does that itself, and - pays the gas. -- **MockUSDC goes away.** `asset` is the real Circle deployment. - -## The chain id trap - -Base Sepolia's chain id is **84532** — the same one this project's local Anvil -chain uses, deliberately, so the unmodified x402 SDK can talk to it. Nothing -may read "public network" out of the network id alone. - -What distinguishes them is the facilitator. `local | testnet | mainnet` is -derived from the network *and* the facilitator together, and is reported by -`doctor`, by the provider's `health()`, and at `/.well-known/agent-commerce` -as `payments.x402.mode`. A local deployment says so: - -```console -PASS Payments x402 v2 (scheme=exact) enabled — LOCAL dev chain (eip155:84532, - chain id shared with Base Sepolia), destination=0x7099…79C8, - facilitator=local -``` - -## What you need - -| | | -|---|---| -| Merchant address | any address you control (`MERCHANT_WALLET`). **Address only.** The gateway never wants a merchant key, on any network. | -| Buyer wallet | a dedicated test wallet holding Base Sepolia USDC. Get it from https://faucet.circle.com. | -| Buyer ETH | **none.** The buyer signs an EIP-3009 authorisation offline; the facilitator broadcasts it and pays the gas. | -| RPC endpoint | only used for health checks — the facilitator does the chain work. The public default works; a dedicated endpoint is more reliable. | - -The public facilitator at `https://x402.org/facilitator` advertises -`x402Version: 2, scheme: exact, network: eip155:84532` and takes no -credential. Check for yourself before trusting this page: - -```bash -node -e "fetch('https://x402.org/facilitator/supported').then(r=>r.json()).then(d=> - console.log(d.kinds.filter(k=>k.network==='eip155:84532')))" -``` - -`health()` asks the configured facilitator the same question at startup and -fails if our scheme and network are not on its list — a facilitator that is up -but cannot settle this pair would otherwise fail every payment, after the -buyer has already signed. - -## Proving it settles - -```bash -set -a; . ./.env.testnet; set +a # or export the variables yourself -npm run test:testnet -``` - -`.env.testnet` is git-ignored and holds the wallets. Nothing loads it -automatically — sourcing it is deliberate, so a testnet key never leaks into a -shell that did not ask for one. - -| Variable | Default | -|---|---| -| `X402_TESTNET_BUYER_PRIVATE_KEY` | required | -| `X402_TESTNET_MERCHANT_ADDRESS` | required | -| `X402_TESTNET_RPC_URL` | `https://base-sepolia-rpc.publicnode.com` | -| `X402_TESTNET_FACILITATOR_URL` | `https://x402.org/facilitator` | -| `X402_TESTNET_AMOUNT` | `0.01` | - -The suite drives the real gateway through the whole chain — unpaid request, -402 v2 challenge, buyer signature, remote facilitator, settlement, delivery — -and then reads the **buyer and merchant balances and the transaction receipt -back off the network**. The gateway's own report of success is not the proof, -and a run that only saw an HTTP 200 fails. - -It also re-checks fail-closed on the public network: an authorisation signed -for a different recipient is refused, and both balances are asserted -unchanged. - -It spends `X402_TESTNET_AMOUNT` USDC on every run. - -### Why it is not in `npm test` - -`vitest.config.ts` and `vitest.e2e.config.ts` must never touch a public RPC, -a public chain or a hosted facilitator — that is what makes CI reproducible -and what keeps a fork's pull request from spending anything. This suite has -its own config (`vitest.testnet.config.ts`), its own script -(`npm run test:testnet`), and skips itself with an explanation when its -variables are absent. - -In CI it is a manual workflow — `.github/workflows/testnet-smoke.yml`, -`workflow_dispatch` only, reading a dedicated buyer key from the `testnet` -environment's secrets. It is `concurrency: testnet-smoke` with -`cancel-in-progress: false`, because two concurrent runs share one buyer -wallet and two in-flight authorisations against one nonce space is the race -the gateway's replay reservation exists to catch — not something to trigger on -purpose. The workflow fails, rather than passing, when the secrets are missing -and the suite skips. - -## Keys - -The buyer key is read from the environment, never written to a config file, -never logged, and never included in an assertion message. Use a wallet created -for this and nothing else. A mainnet key must never appear here. - -The merchant side never needs a key at all — only an address to settle to. - -## Has it actually settled? - -**Yes.** 2026-08-22, `npm run test:testnet` against public Base Sepolia and the -public facilitator at `https://x402.org/facilitator`: - -| | | -|---|---| -| Transaction | [`0xf3288399e31eab683f9bced802fad2dcf44072f93e1aa51223a0f8398e7668e8`](https://sepolia.basescan.org/tx/0xf3288399e31eab683f9bced802fad2dcf44072f93e1aa51223a0f8398e7668e8) | -| Status | `0x1` (success), block 45823961, 85 728 gas | -| Amount | 0.01 USDC (`10000` base units) | -| Asset | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` — Circle USDC | -| Buyer | signed offline, holds no ETH, paid no gas | -| Gas paid by | `0xd407e409E34E0b9afb99EcCeb609bDbcD5e7f1bf` — the facilitator's own signer | -| Gateway | held no key, signed nothing, broadcast nothing | - -Balances moved by exactly the price, read back from the chain rather than -taken from the gateway's own report. The receipt carries the transaction hash -and a delivery timestamp; the same run also confirmed fail-closed on the -public network, refusing an authorisation signed for a different recipient -with both balances unchanged. - -### One thing the first run got wrong - -It failed — `expected 0n to be 10000n` — on a payment that had plainly -settled. Read-your-writes does not hold across independent RPC nodes: the -facilitator confirmed against its node and returned, and the node this suite -reads from was still a block behind. - -The suite now polls for the delta with a bounded timeout. The expected amount -is still exact and a settlement that never lands still fails — the polling -absorbs node lag, it does not soften the assertion. Worth knowing before -writing any other test against a public chain. diff --git a/examples/base-mainnet-payai/README.md b/examples/base-mainnet-payai/README.md new file mode 100644 index 0000000..7119257 --- /dev/null +++ b/examples/base-mainnet-payai/README.md @@ -0,0 +1,80 @@ +# Example: base-mainnet-payai — REAL FUNDS + +The cheapest way to prove the mainnet path works end to end. PayAI's public +facilitator serves x402 v2 `exact` on `eip155:8453` and takes **no +credential** — no account to open, no SDK to install, no `@coinbase/x402`. + +Verified 2026-08-23: + +```bash +node -e "fetch('https://facilitator.payai.network/supported').then(r=>r.json()) + .then(d=>console.log(d.kinds.filter(k=>k.network==='eip155:8453')))" +# [ { x402Version: 2, scheme: 'exact', network: 'eip155:8453' } ] +``` + +`/verify` and `/settle` answer `400 invalid_payment_requirements` on an empty +body rather than `401`, so no credential is required to call them. + +## Two acknowledgements, not one + +```yaml +allowMainnet: true # I meant to use real money +allowUnauthenticatedFacilitator: true # I accept THIS counterparty +``` + +Neither implies the other, and dropping either one fails config loading: + +```console +FAIL CONFIG_INVALID: payments.x402: facilitator https://facilitator.payai.network + takes no credential, and this is a mainnet deployment. It will see every + payment authorisation you handle, with no account, terms or support behind + it. Set payments.x402.allowUnauthenticatedFacilitator: true to accept that, + or configure facilitator.auth. +``` + +`doctor` reports it as a **WARN**, not a pass, for as long as it stays this way. + +## What you are and are not exposed to + +**Not at risk: your money.** An EIP-3009 authorisation names its recipient, its +amount and its chain. A facilitator can broadcast exactly that transfer or +nothing — it cannot redirect funds to itself, and it cannot charge a different +amount. + +**At risk:** + +- **Availability.** No SLA, no account, nobody to call. If it rate-limits or + disappears, every paid call fails closed — correct, and earning nothing. +- **Your payment graph.** It necessarily sees every authorisation: payer + addresses, amounts, timing. +- **Continuity.** Nothing obliges it to stay free, or to stay up. + +Fine for a proving run. Think harder before production. The alternatives are a +facilitator with a static token (`auth.type: bearer`, installs nothing), CDP +(`auth.type: cdp`, needs `@coinbase/x402`), or running your own — `mode: +remote` does not care who operates the endpoint. See +[docs/configuration.md](../../docs/configuration.md). + +## Run it + +```bash +ALLOW_X402_MAINNET=true MERCHANT_WALLET=0xYourWallet \ + npm run agent-commerce -- validate --config examples/base-mainnet-payai/config.yaml +``` + +To actually settle: + +```bash +export ALLOW_X402_MAINNET=true +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 +npm run test:mainnet +``` + +No credential variables needed. **Every run spends real USDC** (default 0.01). + +This is the configuration Base mainnet settlement was first proven with: a real +0.01 USDC payment settled through PayAI, buyer down and merchant up by exactly +the price, verified by reading the balances and the transaction receipt back +off the chain. diff --git a/examples/base-mainnet-payai/config.yaml b/examples/base-mainnet-payai/config.yaml new file mode 100644 index 0000000..c4c6a78 --- /dev/null +++ b/examples/base-mainnet-payai/config.yaml @@ -0,0 +1,103 @@ +# --------------------------------------------------------------------------- +# Example: base-mainnet-payai — REAL FUNDS, unauthenticated facilitator +# +# The cheapest way to prove the mainnet path works end to end: PayAI's public +# facilitator serves x402 v2 `exact` on eip155:8453 and takes no credential, so +# there is no account to open and no SDK to install. +# +# That is also its cost. Read `allowUnauthenticatedFacilitator` below before +# using this for anything but a proving run. +# +# Verified 2026-08-23 — https://facilitator.payai.network/supported advertises +# {"x402Version":2,"scheme":"exact","network":"eip155:8453"} +# and answers /verify and /settle without a credential. Check it yourself: +# node -e "fetch('https://facilitator.payai.network/supported').then(r=>r.json()) +# .then(d=>console.log(d.kinds.filter(k=>k.network==='eip155:8453')))" +# +# ALLOW_X402_MAINNET=true MERCHANT_WALLET=0x... \ +# npm run agent-commerce -- validate --config examples/base-mainnet-payai/config.yaml +# --------------------------------------------------------------------------- + +version: 1 + +merchant: + id: base-mainnet-payai-example + name: Base Mainnet (PayAI) Example + publicBaseUrl: ${GATEWAY_PUBLIC_BASE_URL:-http://localhost:8080} + +server: + port: ${GATEWAY_PORT:-8080} + host: 0.0.0.0 + allowedOrigins: [] + +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 Base. + 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:8453 + rpcUrl: ${X402_RPC_URL:-https://base.drpc.org} + + # USDC on Base. A mainnet config may name no other asset. + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + # The EIP-712 domain name the mainnet contract actually reports. Not + # "USDC": that deployment predates the rename, and a mismatch is + # refused after the buyer has already signed. + assetName: "USD Coin" + assetVersion: "2" + assetDecimals: 6 + + # Your wallet. Address only — the gateway never wants a merchant key. + payTo: ${MERCHANT_WALLET} + + maxTimeoutSeconds: 600 + + # "I meant to use real money." + allowMainnet: ${ALLOW_X402_MAINNET} + + # "I accept THIS counterparty, with no account and no terms." + # + # A separate decision from allowMainnet, and not a smaller one. A + # facilitator cannot redirect your money — an EIP-3009 authorisation names + # its recipient, amount and chain — but it does see every payment + # authorisation you handle, and if it rate-limits or disappears, every paid + # call fails closed and earns nothing. There is no SLA here and nobody to + # call. Fine for a proving run; think harder before production. + allowUnauthenticatedFacilitator: true + + facilitator: + mode: remote + url: https://facilitator.payai.network + auth: + type: none diff --git a/examples/base-mainnet/README.md b/examples/base-mainnet/README.md index 5130fbd..041ddde 100644 --- a/examples/base-mainnet/README.md +++ b/examples/base-mainnet/README.md @@ -1,8 +1,8 @@ # Example: base-mainnet — REAL FUNDS The same gateway, settling real USDC on Base. Read -[docs/mainnet.md](../../docs/mainnet.md) first — it explains what is refused -and why, and this file assumes it. +[docs/configuration.md](../../docs/configuration.md) first — it explains what +is refused and why, and this file assumes it. Two things are structurally different from the local and testnet examples: @@ -48,6 +48,25 @@ FAIL CONFIG_INVALID: Unresolved environment variable "${ALLOW_X402_MAINNET}" ## Proving it settles -`npm run test:mainnet`, with the variables in -[docs/mainnet.md](../../docs/mainnet.md#the-smoke-test). Every run spends real -USDC. It has not been run from this repository. +```bash +export ALLOW_X402_MAINNET=true +export X402_MAINNET_BUYER_PRIVATE_KEY=0x... # funded with USDC on Base +export X402_MAINNET_MERCHANT_ADDRESS=0x... +export X402_FACILITATOR_URL=https://... +export CDP_API_KEY_ID=... CDP_API_KEY_SECRET=... # or X402_FACILITATOR_TOKEN +npm run test:mainnet +``` + +**Every run spends `X402_MAINNET_AMOUNT` (default `0.01`) of real USDC.** It +skips itself, naming what is missing, unless all of the above are set. + +It proves, in order: the guard refuses a config that has not opted in · +authentication reaches the facilitator · a payment settles on Base · the +receipt carries the settlement reference and a delivery timestamp · the +resource is delivered exactly once · the same authorisation 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. + +It never runs in CI — there is no workflow and there must not be one. A +workflow means a mainnet key in repository secrets, spendable by anyone with +write access. diff --git a/examples/base-mainnet/config.yaml b/examples/base-mainnet/config.yaml index 38a036e..1ffc575 100644 --- a/examples/base-mainnet/config.yaml +++ b/examples/base-mainnet/config.yaml @@ -7,8 +7,10 @@ # a default is a way to lose it by accident. # # The gateway holds no key here. A mainnet deployment must use a remote -# facilitator — the in-process one signs with a key this process would have to -# hold, which is a hot wallet inside the resource server, and that is refused. +# facilitator: the in-process one would need a funded gas key inside this +# process, so compromising the resource server drains it. That is refused. +# (It is not a custody question — that signer never holds buyer or merchant +# funds; the money moves buyer to merchant directly on-chain.) # # ALLOW_X402_MAINNET=true MERCHANT_WALLET=0x... X402_FACILITATOR_URL=... \ # CDP_API_KEY_ID=... CDP_API_KEY_SECRET=... \ @@ -73,12 +75,15 @@ payments: # Health checks and nothing else — the facilitator does the chain work. # A dedicated endpoint is strongly preferred here; the public one is rate # limited and its outages become your readiness failures. - rpcUrl: ${X402_RPC_URL:-https://mainnet.base.org} + rpcUrl: ${X402_RPC_URL:-https://base.drpc.org} # required, and checked: a mainnet config may only name USDC on Base. # Settling a mainnet payment in an unintended token is refused. asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" - assetName: USDC + # The EIP-712 domain name the mainnet contract actually reports. Not + # "USDC": that deployment predates the rename, and a mismatch is + # refused after the buyer has already signed. + assetName: "USD Coin" assetVersion: "2" assetDecimals: 6 diff --git a/examples/base-sepolia/README.md b/examples/base-sepolia/README.md index c4a4cc3..9c704f1 100644 --- a/examples/base-sepolia/README.md +++ b/examples/base-sepolia/README.md @@ -18,11 +18,11 @@ the gateway never sees it. ## What you need -| | | -|---|---| -| A merchant address | any address you control. `MERCHANT_WALLET`. Address only — no key, ever. | +| | | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| A merchant address | any address you control. `MERCHANT_WALLET`. Address only — no key, ever. | | A buyer wallet with testnet USDC | https://faucet.circle.com. The buyer needs **no ETH**: EIP-3009 is signed offline and the facilitator pays the gas. | -| A Base Sepolia RPC | only for health checks. The public default works; a dedicated endpoint is more reliable. | +| A Base Sepolia RPC | only for health checks. The public default works; a dedicated endpoint is more reliable. | Unlike the other examples, this one does **not** validate with no environment set: `MERCHANT_WALLET` has no default, because the only default available @@ -64,7 +64,7 @@ network** — the gateway's own report of success is not the proof. It spends an explanation when those variables are absent. It has been run: [`0xf3288399…`](https://sepolia.basescan.org/tx/0xf3288399e31eab683f9bced802fad2dcf44072f93e1aa51223a0f8398e7668e8) -settled 0.01 USDC on 2026-08-22. See [docs/testnet.md](../../docs/testnet.md). +settled 0.01 USDC on 2026-08-22. It is deliberately outside `npm test` and `npm run test:e2e`: both of those must stay deterministic and offline. diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 7a69c25..6b926f2 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -345,13 +345,23 @@ export async function runDoctor( // has already passed them — but an operator about to move real money // should be told which guarantees they are relying on, and see the // banner without having to read a log. + // An unauthenticated mainnet facilitator is allowed, but only by name. + // Saying so here is the point: the acknowledgement lives in a config + // file someone wrote weeks ago, and this is where they look today. + const unauthenticated = + x402.facilitator.mode === 'remote' && x402.facilitator.auth.type === 'none'; checks.push({ name: 'Mainnet safety', - status: 'INFO', - detail: - `${describeDeploymentMode('mainnet')} — enforced at config load: explicit allowMainnet ` + - 'opt-in, remote facilitator over HTTPS with a credential, non-development payTo, ' + - 'and the canonical asset for this network. Payments are fail-closed.', + status: unauthenticated ? 'WARN' : 'INFO', + detail: unauthenticated + ? `${describeDeploymentMode('mainnet')} — settling through a facilitator that takes no ` + + 'credential, accepted via allowUnauthenticatedFacilitator. It sees every payment ' + + 'authorisation you handle, with no account or terms behind it. Everything else is ' + + 'enforced at config load: explicit allowMainnet opt-in, HTTPS, non-development ' + + 'payTo, canonical asset. Payments are fail-closed.' + : `${describeDeploymentMode('mainnet')} — enforced at config load: explicit allowMainnet ` + + 'opt-in, remote facilitator over HTTPS with a credential, non-development payTo, ' + + 'and the canonical asset for this network. Payments are fail-closed.', }); } } diff --git a/src/config/schema.ts b/src/config/schema.ts index 35f5707..b21c928 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -224,6 +224,8 @@ const X402Schema = z facilitator: FacilitatorSchema, /** Real funds. Never defaulted — see src/payments/x402/guardrails.ts. */ allowMainnet: BooleanOrString.optional(), + /** Accepts a mainnet facilitator that takes no credential. Never defaulted. */ + allowUnauthenticatedFacilitator: BooleanOrString.optional(), }) .strict(); @@ -281,6 +283,7 @@ export interface GatewayConfig { readonly maxTimeoutSeconds: number; readonly facilitator: X402FacilitatorConfig; readonly allowMainnet?: boolean; + readonly allowUnauthenticatedFacilitator?: boolean; }; }; } @@ -464,6 +467,14 @@ function normalise(raw: RawConfig): GatewayConfig { ...(x402Raw.allowMainnet !== undefined ? { allowMainnet: toBoolean(x402Raw.allowMainnet, 'payments.x402.allowMainnet') } : {}), + ...(x402Raw.allowUnauthenticatedFacilitator !== undefined + ? { + allowUnauthenticatedFacilitator: toBoolean( + x402Raw.allowUnauthenticatedFacilitator, + 'payments.x402.allowUnauthenticatedFacilitator', + ), + } + : {}), } : undefined; @@ -477,8 +488,13 @@ function normalise(raw: RawConfig): GatewayConfig { 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 } + : {}), }); } diff --git a/src/gateway/main.ts b/src/gateway/main.ts index dea0334..d4043c6 100644 --- a/src/gateway/main.ts +++ b/src/gateway/main.ts @@ -62,6 +62,9 @@ async function main(): Promise { maxTimeoutSeconds: x402.maxTimeoutSeconds, facilitator: x402.facilitator, ...(x402.allowMainnet !== undefined ? { allowMainnet: x402.allowMainnet } : {}), + ...(x402.allowUnauthenticatedFacilitator !== undefined + ? { allowUnauthenticatedFacilitator: x402.allowUnauthenticatedFacilitator } + : {}), logger, }), ); diff --git a/src/payments/x402/guardrails.ts b/src/payments/x402/guardrails.ts index 41bbe72..82be9a6 100644 --- a/src/payments/x402/guardrails.ts +++ b/src/payments/x402/guardrails.ts @@ -55,9 +55,21 @@ export interface X402DeploymentInput { readonly network: string; readonly payTo: string; readonly asset: string; + /** EIP-712 domain of the asset, as the token itself reports it. */ + readonly assetName?: string; + readonly assetVersion?: string; readonly facilitator: X402FacilitatorConfig; /** Required, and required to be `true`, before anything settles on a mainnet. */ readonly allowMainnet?: boolean; + /** + * Required, and required to be `true`, to settle on a mainnet through a + * facilitator that takes no credential. + * + * Separate from `allowMainnet` because it names a different decision: not + * "I meant to use real money" but "I accept this particular counterparty + * without an account, terms, or anyone to call". + */ + readonly allowUnauthenticatedFacilitator?: boolean; } export interface X402Deployment { @@ -78,9 +90,13 @@ export function resolveX402Deployment(input: X402DeploymentInput): X402Deploymen const profile = requireNetworkProfile(input.network, 'payments.x402.network'); const mode = resolveDeploymentMode(profile, input.facilitator.mode); - // The in-process facilitator signs with a key this process holds. On a - // mainnet that is a hot wallet inside the resource server — the arrangement - // this project exists to avoid — so it is not a warning, it is refused. + // The in-process facilitator signs with a key this process holds. Not a + // custody problem — that signer never holds buyer or merchant funds, it pays + // gas and broadcasts `transferWithAuthorization`, and the money moves buyer + // to merchant directly on-chain. What it *is*, on a mainnet, is a funded key + // inside the resource server: compromise that process and an attacker drains + // the gas wallet and broadcasts from it. Separate processes, separate blast + // radius — so it is refused, not warned about. if (profile.kind === 'mainnet' && input.facilitator.mode === 'local') { throw invalid( `payments.x402: network "${profile.id}" (${profile.displayName}) is a mainnet and cannot be served by facilitator.mode "local". A mainnet deployment must settle through a remote facilitator.`, @@ -97,10 +113,21 @@ export function resolveX402Deployment(input: X402DeploymentInput): X402Deploymen if (input.facilitator.mode === 'remote') { assertFacilitatorUrlIsSafe(input.facilitator.url, mode); - if (mode === 'mainnet' && input.facilitator.auth.type === 'none') { + // Not a funds check. An EIP-3009 authorisation names its recipient, its + // amount and its chain, so a facilitator can broadcast exactly that + // transfer or nothing — it cannot redirect the money. What a credential + // buys is a *relationship*: rate limits, terms, someone to call when + // settlement stops. Running production payments through a counterparty you + // have none of that with is a real choice, and this is where it gets made + // explicitly rather than by omission. + if ( + mode === 'mainnet' && + input.facilitator.auth.type === 'none' && + input.allowUnauthenticatedFacilitator !== true + ) { throw invalid( - 'payments.x402: a mainnet facilitator must be authenticated. Set payments.x402.facilitator.auth to a supported type — an unauthenticated production facilitator is refused.', - 'payments.x402.facilitator.auth', + `payments.x402: facilitator ${describeOrigin(input.facilitator.url)} takes no credential, and this is a mainnet deployment. It will see every payment authorisation you handle, with no account, terms or support behind it. Set payments.x402.allowUnauthenticatedFacilitator: true to accept that, or configure facilitator.auth.`, + 'payments.x402.allowUnauthenticatedFacilitator', ); } // An empty credential is refused rather than sent: a blank token or key @@ -131,11 +158,29 @@ export function resolveX402Deployment(input: X402DeploymentInput): X402Deploymen // Mainnet only. A testnet is exactly where pointing at a mock token is the // right thing to do, so the same check there would block the normal case. const canonical = profile.canonicalAsset; - if (mode === 'mainnet' && canonical && !sameAddress(input.asset, canonical.address)) { - throw invalid( - `payments.x402: asset ${input.asset} is not ${canonical.symbol} on ${profile.displayName} (expected ${canonical.address}). Settling a mainnet payment in an unintended token is refused.`, - 'payments.x402.asset', - ); + if (mode === 'mainnet' && canonical) { + if (!sameAddress(input.asset, canonical.address)) { + throw invalid( + `payments.x402: asset ${input.asset} is not ${canonical.symbol} on ${profile.displayName} (expected ${canonical.address}). Settling a mainnet payment in an unintended token is refused.`, + 'payments.x402.asset', + ); + } + // The EIP-712 domain is signed by the buyer and checked by the scheme. Get + // it wrong and every payment is refused `invalid_exact_evm_token_name_mismatch` + // *after* the buyer signed — a config error charged to the buyer's patience. + // Caught here instead, before the gateway starts. + if (input.assetName !== undefined && input.assetName !== canonical.name) { + throw invalid( + `payments.x402: 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.`, + 'payments.x402.assetName', + ); + } + if (input.assetVersion !== undefined && input.assetVersion !== canonical.version) { + throw invalid( + `payments.x402: assetVersion "${input.assetVersion}" is not the EIP-712 domain version ${canonical.symbol} reports on ${profile.displayName} (expected "${canonical.version}").`, + 'payments.x402.assetVersion', + ); + } } return { profile, mode }; @@ -174,6 +219,15 @@ function assertFacilitatorUrlIsSafe(url: string, mode: DeploymentMode): void { ); } +/** Host only — a facilitator URL can carry a tenant path or an API key. */ +function describeOrigin(url: string): string { + try { + return new URL(url).origin; + } catch { + return '[unparseable facilitator.url]'; + } +} + /** The secret-bearing fields of an auth block, for emptiness checks only. Never logged. */ function credentialFields(auth: FacilitatorAuth): readonly (readonly [string, string])[] { switch (auth.type) { diff --git a/src/payments/x402/networks.ts b/src/payments/x402/networks.ts index 29a36fe..bf0d222 100644 --- a/src/payments/x402/networks.ts +++ b/src/payments/x402/networks.ts @@ -28,11 +28,23 @@ export interface NetworkProfile { readonly displayName: string; readonly kind: 'testnet' | 'mainnet'; /** - * The USDC deployment this network is expected to settle in. Enforced only - * on mainnet (see `guardrails.ts`) — a testnet is where a mock token is a - * legitimate thing to point at. + * The USDC deployment this network is expected to settle in, with the EIP-712 + * domain the token actually reports. Enforced only on mainnet (see + * `guardrails.ts`) — a testnet is where a mock token is a legitimate thing to + * point at. + * + * `name` is not decorative and is not the symbol: it is signed into every + * buyer's EIP-712 domain, and the two USDC deployments disagree. Base Sepolia + * reports `"USDC"`; Base mainnet reports `"USD Coin"`. Configure the wrong + * one and every payment is refused `invalid_exact_evm_token_name_mismatch` + * after the buyer has signed. Read back from the contracts, not assumed. */ - readonly canonicalAsset?: { readonly symbol: string; readonly address: string }; + readonly canonicalAsset?: { + readonly symbol: string; + readonly address: string; + readonly name: string; + readonly version: string; + }; } const PROFILES: readonly NetworkProfile[] = [ @@ -41,14 +53,24 @@ const PROFILES: readonly NetworkProfile[] = [ chainId: 84532, displayName: 'Base Sepolia', kind: 'testnet', - canonicalAsset: { symbol: 'USDC', address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e' }, + canonicalAsset: { + symbol: 'USDC', + address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + name: 'USDC', + version: '2', + }, }, { id: 'eip155:8453', chainId: 8453, displayName: 'Base', kind: 'mainnet', - canonicalAsset: { symbol: 'USDC', address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' }, + canonicalAsset: { + symbol: 'USDC', + address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + name: 'USD Coin', + version: '2', + }, }, ]; diff --git a/src/payments/x402/provider.ts b/src/payments/x402/provider.ts index e60d5ce..4c7f114 100644 --- a/src/payments/x402/provider.ts +++ b/src/payments/x402/provider.ts @@ -109,6 +109,11 @@ export interface X402ProviderOptions { * default — see `guardrails.ts`. */ readonly allowMainnet?: boolean; + /** + * Required to be `true` to settle on a mainnet through a facilitator that + * takes no credential. Never a default. + */ + readonly allowUnauthenticatedFacilitator?: boolean; readonly logger?: Logger; readonly clock?: Clock; readonly ids?: IdGenerator; @@ -152,8 +157,13 @@ export function createX402PaymentProvider(options: X402ProviderOptions): Payment 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; // Constructed once, here, rather than inside settle(): assertDevKeyIsLocalOnly diff --git a/tests/mainnet/base.smoke.test.ts b/tests/mainnet/base.smoke.test.ts index a9cb07f..07c474d 100644 --- a/tests/mainnet/base.smoke.test.ts +++ b/tests/mainnet/base.smoke.test.ts @@ -43,7 +43,9 @@ import { 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']; -const RPC_URL = process.env['X402_MAINNET_RPC_URL'] ?? 'https://mainnet.base.org'; +// Any public endpoint will rate-limit a polling loop; `mainnet.base.org` did, +// mid-run, and reported a settled payment as a failure. Use a dedicated one. +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']; @@ -61,15 +63,14 @@ const missing = [ BUYER_KEY ? undefined : 'X402_MAINNET_BUYER_PRIVATE_KEY', MERCHANT ? undefined : 'X402_MAINNET_MERCHANT_ADDRESS', FACILITATOR_URL ? undefined : 'X402_FACILITATOR_URL', - CDP_API_KEY_ID || BEARER - ? undefined - : 'CDP_API_KEY_ID + CDP_API_KEY_SECRET (or X402_FACILITATOR_TOKEN)', + // No credential requirement: a facilitator may legitimately take none, and + // `allowUnauthenticatedFacilitator` is how that is accepted. ].filter((name): name is string => name !== undefined); if (missing.length > 0) { // eslint-disable-next-line no-console console.log( - `[mainnet] skipped — needs ${missing.join(', ')}. This suite spends REAL FUNDS; see docs/mainnet.md.`, + `[mainnet] skipped — needs ${missing.join(', ')}. This suite spends REAL FUNDS; see examples/base-mainnet/README.md.`, ); } @@ -77,7 +78,8 @@ 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 }; } - return { type: 'bearer', token: BEARER }; + if (BEARER) return { type: 'bearer', token: BEARER }; + return { type: 'none' }; } function rawConfig(overrides: { allowMainnet?: boolean } = {}): Record { @@ -107,12 +109,14 @@ function rawConfig(overrides: { allowMainnet?: boolean } = {}): Record { * Independent RPC nodes do not give read-your-writes: the facilitator * confirms against its node and returns while ours is still a block behind. * The expected delta stays exact; only the waiting is tolerant. + * + * Read errors inside the window are tolerated too, and that is not + * laxity — a public RPC rate-limiting the poll is not evidence about the + * payment. Failing there once reported a settlement that had plainly + * happened as a failure. If the deadline passes the last snapshot is + * returned and the exact-delta assertion fails on real numbers, or the + * underlying error surfaces if nothing was ever read. */ async function waitForBalances( predicate: (snapshot: BalanceSnapshot) => boolean, timeoutMs = 180_000, ): Promise { const deadline = Date.now() + timeoutMs; - let snapshot = await balances(); - while (!predicate(snapshot) && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 3_000)); - snapshot = await balances(); + let snapshot: BalanceSnapshot | undefined; + let lastError: unknown; + while (Date.now() < deadline) { + try { + snapshot = await balances(); + lastError = undefined; + if (predicate(snapshot)) return snapshot; + } catch (err) { + lastError = err; + } + await new Promise((resolve) => setTimeout(resolve, 6_000)); } - return snapshot; + if (snapshot) return snapshot; + throw lastError ?? new Error('no balance snapshot was ever read'); } beforeAll(async () => { @@ -195,6 +214,9 @@ describeOrSkip('Base mainnet — real funds', () => { maxTimeoutSeconds: x402.maxTimeoutSeconds, facilitator: x402.facilitator, ...(x402.allowMainnet !== undefined ? { allowMainnet: x402.allowMainnet } : {}), + ...(x402.allowUnauthenticatedFacilitator !== undefined + ? { allowUnauthenticatedFacilitator: x402.allowUnauthenticatedFacilitator } + : {}), logger, }), ], diff --git a/tests/testnet/base-sepolia.smoke.test.ts b/tests/testnet/base-sepolia.smoke.test.ts index afc5ce9..7abf67b 100644 --- a/tests/testnet/base-sepolia.smoke.test.ts +++ b/tests/testnet/base-sepolia.smoke.test.ts @@ -133,12 +133,21 @@ async function waitForBalances( timeoutMs = 90_000, ): Promise { const deadline = Date.now() + timeoutMs; - let snapshot = await balances(); - while (!predicate(snapshot) && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 2_000)); - snapshot = await balances(); + let snapshot: BalanceSnapshot | undefined; + let lastError: unknown; + while (Date.now() < deadline) { + try { + snapshot = await balances(); + lastError = undefined; + if (predicate(snapshot)) return snapshot; + } catch (err) { + // A public RPC rate-limiting the poll is not evidence about the payment. + lastError = err; + } + await new Promise((resolve) => setTimeout(resolve, 3_000)); } - return snapshot; + if (snapshot) return snapshot; + throw lastError ?? new Error('no balance snapshot was ever read'); } function balances(): Promise { @@ -158,7 +167,7 @@ if (missing.length > 0) { // eslint-disable-next-line no-console console.log( `[testnet] skipped — set ${missing.join(' and ')} to run the Base Sepolia smoke test. ` + - 'It spends real testnet USDC from a dedicated wallet; see docs/testnet.md.', + 'It spends real testnet USDC from a dedicated wallet.', ); } diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index 3e19bc9..3a2a25c 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -219,6 +219,7 @@ describe('parseConfig', () => { withX402({ network: 'eip155:8453', asset: BASE_USDC, + assetName: 'USD Coin', payTo: MERCHANT, allowMainnet: true, facilitator: { @@ -255,6 +256,7 @@ describe('parseConfig', () => { withX402({ network: 'eip155:8453', asset: BASE_USDC, + assetName: 'USD Coin', payTo: MERCHANT, facilitator: { mode: 'remote', @@ -266,17 +268,57 @@ describe('parseConfig', () => { expect(message).toContain('allowMainnet'); }); - it('rejects an unauthenticated mainnet facilitator', () => { + it('rejects an unauthenticated mainnet facilitator until it is accepted by name', () => { + const unauthenticated = { + network: 'eip155:8453', + asset: BASE_USDC, + assetName: 'USD Coin', + payTo: MERCHANT, + allowMainnet: true, + facilitator: { mode: 'remote', url: 'https://facilitator.example.com/v2/x402' }, + }; + const message = messageFor(withX402(unauthenticated)); + expect(message).toContain('allowUnauthenticatedFacilitator'); + // The origin, so an operator can see *which* counterparty they are being + // asked about — but never the path, which can carry a tenant or a key. + expect(message).toContain('https://facilitator.example.com'); + expect(message).not.toContain('/v2/x402'); + + // Accepting it explicitly is allowed. It is a real choice, not a bug. + const config = parseConfig( + withX402({ ...unauthenticated, allowUnauthenticatedFacilitator: true }), + {}, + ); + expect(config.payments.x402?.allowUnauthenticatedFacilitator).toBe(true); + }); + + it('does not let allowUnauthenticatedFacilitator stand in for allowMainnet', () => { + // Two different decisions: "I meant to use real money" and "I accept + // this counterparty". Neither implies the other. const message = messageFor( withX402({ network: 'eip155:8453', asset: BASE_USDC, + assetName: 'USD Coin', payTo: MERCHANT, - allowMainnet: true, + allowUnauthenticatedFacilitator: true, facilitator: { mode: 'remote', url: 'https://facilitator.example.com' }, }), ); - expect(message).toContain('must be authenticated'); + expect(message).toContain('allowMainnet'); + }); + + it('needs no acknowledgement for an unauthenticated facilitator below mainnet', () => { + // The public testnet facilitator takes no credential and never will. + expect(() => + parseConfig( + withX402({ + payTo: MERCHANT, + facilitator: { mode: 'remote', url: 'https://x402.org/facilitator' }, + }), + {}, + ), + ).not.toThrow(); }); it('rejects a plain-HTTP facilitator on a public host', () => { @@ -313,6 +355,49 @@ describe('parseConfig', () => { expect(message).toContain('well-known Anvil development address'); }); + it('rejects a mainnet assetName that is not the EIP-712 domain the token reports', () => { + // Base mainnet USDC reports "USD Coin"; Base Sepolia's reports "USDC". + // The name is signed into the buyer's domain, so the obvious-looking + // value gets every payment refused *after* they signed. + const message = messageFor( + withX402({ + network: 'eip155:8453', + asset: BASE_USDC, + assetName: 'USDC', + payTo: MERCHANT, + allowMainnet: true, + facilitator: { + mode: 'remote', + url: 'https://facilitator.example.com', + auth: { type: 'bearer', token: 'secret-token' }, + }, + }), + ); + expect(message).toContain('EIP-712 domain name'); + expect(message).toContain('USD Coin'); + }); + + it('accepts the EIP-712 domain the mainnet token actually reports', () => { + expect(() => + parseConfig( + withX402({ + network: 'eip155:8453', + asset: BASE_USDC, + assetName: 'USD Coin', + assetVersion: '2', + payTo: MERCHANT, + allowMainnet: true, + facilitator: { + mode: 'remote', + url: 'https://facilitator.example.com', + auth: { type: 'bearer', token: 'secret-token' }, + }, + }), + {}, + ), + ).not.toThrow(); + }); + it('rejects a mainnet asset that is not the canonical USDC', () => { const message = messageFor( withX402({ @@ -349,6 +434,7 @@ describe('parseConfig', () => { withX402({ network: 'eip155:8453', asset: BASE_USDC, + assetName: 'USD Coin', payTo: MERCHANT, allowMainnet: true, facilitator: { @@ -366,6 +452,7 @@ describe('parseConfig', () => { withX402({ network: 'eip155:8453', asset: BASE_USDC, + assetName: 'USD Coin', payTo: MERCHANT, allowMainnet: true, facilitator: { diff --git a/vitest.mainnet.config.ts b/vitest.mainnet.config.ts index e289c54..e1631b4 100644 --- a/vitest.mainnet.config.ts +++ b/vitest.mainnet.config.ts @@ -6,11 +6,14 @@ import { defineConfig } from 'vitest/config'; * Separate from every other vitest config on purpose. `vitest.config.ts` and * `vitest.e2e.config.ts` must never leave the machine; `vitest.testnet.config.ts` * spends test funds. This one moves real value, so it is never part of - * `npm run verify`, never runs on a push or a pull request, and skips itself - * unless a human has set `ALLOW_X402_MAINNET=true` and supplied credentials. + * `npm run verify` and skips itself unless a human has set + * `ALLOW_X402_MAINNET=true` and supplied credentials. * - * Run it with `npm run test:mainnet`, or from the manual - * `.github/workflows/mainnet-smoke.yml`. + * Run it with `npm run test:mainnet`, from a machine that holds the wallet. + * + * There is no CI workflow for it, and there must not be one. A workflow means + * a mainnet key in repository secrets that anyone with write access can spend + * — the single most dangerous thing this repository could hold. */ export default defineConfig({ test: { diff --git a/vitest.testnet.config.ts b/vitest.testnet.config.ts index 81e47d7..81deef0 100644 --- a/vitest.testnet.config.ts +++ b/vitest.testnet.config.ts @@ -5,10 +5,13 @@ import { defineConfig } from 'vitest/config'; * `vitest.e2e.config.ts` on purpose: both of those must never touch a public * RPC, a public chain or a hosted facilitator, and this one does all three. * - * It spends real testnet funds, so it is never part of `npm run verify`, never - * runs on a pull request, and skips itself when its credentials are absent. - * Run it with `npm run test:testnet`, or from the manual - * `.github/workflows/testnet-smoke.yml`. + * It spends real testnet funds, so it is never part of `npm run verify` and + * skips itself when its credentials are absent. Run it with + * `npm run test:testnet`, from a machine that holds the wallet. + * + * There is no CI workflow for it, deliberately: a workflow means a funded key + * in repository secrets, triggerable by anyone with write access. The key + * stays with a human. * * Timeouts are generous because a public chain's block time and a hosted * facilitator's queue are not ours to control. From 01de39df1e6a332f98d6ae62b8f3aecbe5c31935 Mon Sep 17 00:00:00 2001 From: SergeevDmitry Date: Sun, 23 Aug 2026 18:26:10 +0200 Subject: [PATCH 3/6] payment: treat any facilitator throw as no-verdict, and cover the remaining adversarial scenarios --- docs/security.md | 80 ++++- src/payments/x402/facilitator.ts | 24 +- src/payments/x402/provider.ts | 8 +- tests/integration/adversarial-payment.test.ts | 291 ++++++++++++++++++ .../payments-x402/facilitator-cdp.test.ts | 13 +- 5 files changed, 388 insertions(+), 28 deletions(-) create mode 100644 tests/integration/adversarial-payment.test.ts diff --git a/docs/security.md b/docs/security.md index 5414475..8dd45dd 100644 --- a/docs/security.md +++ b/docs/security.md @@ -183,11 +183,27 @@ backpressure. Put the gateway behind your own edge if you expose it publicly. ## Dependencies -`@x402/core`, `@x402/evm`, `@modelcontextprotocol/sdk`, `viem`, `fastify` and their transitive -dependencies are third-party code, pinned exactly in -. `npm audit` runs in the release -workflow; high and critical findings are assessed and documented before a -release rather than auto-blocking on irrelevant transitive advisories. +`@x402/core`, `@x402/evm`, `@modelcontextprotocol/sdk`, `viem`, `fastify`, +`pino` and `better-sqlite3` are third-party code, pinned exactly. `npm audit` +runs in the release workflow; high and critical findings are assessed and +documented before a release rather than auto-blocking on irrelevant transitive +advisories. + +What a consumer actually installs, audited against the published tarball: + +| Install | `npm audit` | +| ------------------------------------------------------------------- | ---------------------- | +| the package alone | **0 vulnerabilities** | +| plus `@modelcontextprotocol/sdk`, `@x402/core`, `@x402/evm`, `viem` | **0 vulnerabilities** | +| plus `@coinbase/x402` (only for `auth.type: cdp`) | 2 — 1 high, 1 moderate | + +The whole delta is CDP: `@coinbase/x402` → `@coinbase/cdp-sdk` → `axios`, which +carries a set of high-severity advisories, plus a Solana client tree this +project has no use for. It is an optional peer, imported dynamically only when +that auth type is configured, so nobody else pays for it — and `auth.type: +bearer` covers any facilitator with a static token and installs nothing. This +is stated rather than buried because the affected path is the one handling real +money. ## Development keys @@ -226,11 +242,59 @@ resource server*, so compromising that process means draining the gas wallet and broadcasting arbitrary transactions from it. With a remote facilitator the gateway holds no signing key at all. -## Threats we are not addressing in the alpha +## Adversarial scenarios, and where each is tested + +Every row below has an executed test, not a claim. Nothing here is asserted by +reading a log line: settlement outcomes are read back off the chain, and +rejection outcomes assert that balances did not move. + +| Scenario | Outcome | Where | +| -------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------- | +| header tampering (`PAYMENT-SIGNATURE` mangled, wrong header, absent) | 402, no delivery | `tests/unit/gateway`, `tests/e2e/payment` | +| payload tampering (any field of the authorisation) | `PAYMENT_INVALID` | `tests/e2e/payment`, `tests/unit/payments-x402` | +| network substitution | `wrong_network` before settlement | `tests/e2e/payment` | +| asset substitution (a real but different token) | `wrong_asset` before settlement | `tests/e2e/payment` | +| `payTo` substitution | `wrong_recipient` before settlement | `tests/e2e/payment`, testnet suite | +| amount manipulation (below the price) | `wrong_amount` before settlement | `tests/e2e/payment` | +| replay, sequentially | refused, no second transfer | `tests/e2e/payment`, mainnet suite | +| **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 | +| expired authorisation (`validBefore` in the past) | refused before settlement | `tests/e2e/payment` | +| facilitator timeout | `PAYMENT_PROVIDER_UNAVAILABLE`, settlement treated as *uncertain* | `tests/unit/payments-x402` | +| **facilitator 401 / 5xx** | `PAYMENT_PROVIDER_UNAVAILABLE`, never charged to the buyer | `tests/integration/adversarial-payment.test.ts` | +| **malformed facilitator response** | refused; never read as a verdict | same | +| backend timeout | `BACKEND_TIMEOUT` | `tests/unit/core/execution` | +| backend 500 after payment | receipt records paid-and-undelivered; payer told it settled | `tests/unit/gateway`, `tests/unit/core/execution` | +| receipt-store failure | `STORAGE_ERROR`, never mislabelled `PAYMENT_REPLAYED` | `tests/unit/storage-receipts` | +| RPC unreachable during verify | `PAYMENT_PROVIDER_UNAVAILABLE`, not "bad signature" | `tests/unit/payments-x402` | + +Two of those exist because writing them found a bug. The SDK's `exact`/EVM +scheme reports an unreachable node as `invalid_exact_evm_signature`, and its +HTTP facilitator client throws a bare `Error` for a 401 or 5xx — both would +have recorded a failure of ours as the payer's fault, and burned an +authorisation nothing had checked. The provider now treats *any* throw out of a +facilitator call as "no verdict obtained", because a verdict arrives as a +returned value. + +## Threats we are not addressing Buyer identity and screening · fraud and disputes · refunds and chargebacks · multi-tenancy and RBAC · host compromise · supply-chain attestation · side-channel and timing analysis · protocol-level censorship or MEV around -settlement · availability guarantees. +settlement · availability guarantees · a malicious facilitator withholding +settlement (it cannot redirect funds, but it can decline to broadcast, and +fail-closed means the resource is simply not delivered). -An independent security audit has not been performed. +## Independent review + +**No independent security review has been performed.** Not commissioned, not +scheduled, not in progress. Everything above is self-assessment by the people +who wrote the code, which is the weakest kind of assurance there is. + +Before treating this as production-ready for real funds, the areas worth an +outside pair of eyes are `src/payments/x402` (verification and settlement), +payment enforcement in `src/core/execution/pipeline.ts`, the mainnet guards in +`src/payments/x402/guardrails.ts`, and the receipt and payment-attempt state +transitions in `src/storage/receipts`. + +This section is updated when that changes, and not before. diff --git a/src/payments/x402/facilitator.ts b/src/payments/x402/facilitator.ts index 161385a..8ae2fb2 100644 --- a/src/payments/x402/facilitator.ts +++ b/src/payments/x402/facilitator.ts @@ -17,7 +17,7 @@ * object literal against an RPC or HTTP round-trip. */ import { x402Facilitator } from '@x402/core/facilitator'; -import { FacilitatorResponseError, HTTPFacilitatorClient } from '@x402/core/http'; +import { HTTPFacilitatorClient } from '@x402/core/http'; import type { Network, PaymentPayload, @@ -196,7 +196,6 @@ function cdpAuthHeaders(apiKeyId: string, apiKeySecret: string): AuthHeaderFacto */ export function createRemoteFacilitatorBinding( options: RemoteFacilitatorOptions, - isTransportError: (err: unknown) => boolean, ): FacilitatorBinding { const auth = options.auth; let createAuthHeaders: AuthHeaderFactory | undefined; @@ -226,12 +225,21 @@ export function createRemoteFacilitatorBinding( try { return await call(); } catch (err) { - // A facilitator that timed out or answered with something - // unparseable produced no verdict. Treating either as a payment - // failure would blame the buyer for the facilitator being down — - // and for settle(), a timeout is explicitly indeterminate: the - // transfer may have gone through after we stopped waiting. - if (err instanceof FacilitatorResponseError || isTransportError(err)) failed = true; + // *Any* throw out of the SDK client means no verdict was obtained. + // A verdict arrives as a returned `VerifyResponse`/`SettleResponse`, + // including a negative one; the client only throws when it could not + // reach the facilitator, could not authenticate to it, got a non-2xx, + // or could not parse what came back. None of those are facts about + // the payment, and recording them against the payer would blame the + // buyer for our credential or the facilitator's outage. + // + // Deliberately not a predicate over error shapes: the SDK throws a + // bare `Error` for an HTTP status (`Facilitator verify failed (401)`) + // and a `FacilitatorResponseError` for a bad body, so matching on + // type or message would have missed exactly the credential case that + // matters most. For settle() this also preserves the indeterminate + // reading — a timeout may have settled after we stopped waiting. + failed = true; throw err; } }; diff --git a/src/payments/x402/provider.ts b/src/payments/x402/provider.ts index 4c7f114..1d3d055 100644 --- a/src/payments/x402/provider.ts +++ b/src/payments/x402/provider.ts @@ -193,10 +193,10 @@ export function createX402PaymentProvider(options: X402ProviderOptions): Payment } binding = createLocalFacilitatorBinding(client, network, isProviderUnavailableError); } else { - binding = createRemoteFacilitatorBinding( - { url: options.facilitator.url, auth: options.facilitator.auth }, - isProviderUnavailableError, - ); + binding = createRemoteFacilitatorBinding({ + url: options.facilitator.url, + auth: options.facilitator.auth, + }); } // health()'s own read-only client, with a real transport-level timeout (see diff --git a/tests/integration/adversarial-payment.test.ts b/tests/integration/adversarial-payment.test.ts new file mode 100644 index 0000000..e1e123c --- /dev/null +++ b/tests/integration/adversarial-payment.test.ts @@ -0,0 +1,291 @@ +/** + * Adversarial payment scenarios that only mean anything above the unit level. + * + * The provider's own negative cases (wrong amount, wrong recipient, wrong + * network, wrong asset, tampered payload, expired authorisation) are covered + * against a real chain in `tests/e2e/payment`. What is here is the set that + * needs the *pipeline*, the *store* or a real *HTTP facilitator* to be + * meaningful: + * + * - two identical requests genuinely in flight at once + * - the same authorisation replayed after the process restarts + * - a facilitator that answers 401, 500, or with something unparseable + * + * Deterministic and offline: the facilitator is a loopback HTTP server this + * file starts, and the chain is not involved at all. + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { parseConfig } from '../../src/config/index.js'; +import type { + AdapterDescriptor, + PaymentContext, + PaymentProvider, + PaymentRequirement, + PaymentResult, + PaymentSettlementContext, + PaymentVerificationContext, + ReceiptStore, +} from '../../src/core/index.js'; +import { isCommerceError } from '../../src/core/index.js'; +import { createGateway, type GatewayInstance } from '../../src/gateway/index.js'; +import { createSqliteReceiptStore } from '../../src/storage/receipts/index.js'; + +const RESOURCE_ID = 'paid_report'; +const VALID_PROOF = 'valid-proof'; + +function rawConfig(storePath: string): Record { + return { + version: 1, + merchant: { id: 'adversarial', name: 'Adversarial', publicBaseUrl: 'http://127.0.0.1:8080' }, + server: { port: 8080, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: storePath } }, + protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } }, + resources: { + [RESOURCE_ID]: { + name: 'Paid report', + backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: '0.01', currency: 'USD' }, + expose: ['http'], + payments: ['x402'], + }, + }, + payments: { + x402: { + enabled: true, + network: 'eip155:84532', + rpcUrl: 'http://127.0.0.1:8545', + asset: '0x5FbDB2315678afecb367f032d93F642f64180aa3', + assetName: 'MockUSDC', + assetVersion: '2', + assetDecimals: 6, + payTo: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + maxTimeoutSeconds: 120, + facilitator: { mode: 'local', signerPrivateKey: '0xKEY' }, + }, + }, + }; +} + +const descriptor: AdapterDescriptor = { + name: 'fake-x402', + kind: 'payment', + implementationVersion: '0.0.0-test', + supportedSpec: 'x402/v2', + capabilities: [], + status: 'experimental', +}; + +/** + * A provider whose `settle()` is slow and counted. + * + * Slow on purpose: the replay reservation is a race, and a settle that returns + * instantly lets two "concurrent" requests serialise by accident, which would + * make this test pass without proving anything. + */ +function countingProvider(settleDelayMs: number): PaymentProvider & { settleCalls: () => number } { + let settleCalls = 0; + return { + name: 'x402', + descriptor, + settleCalls: () => settleCalls, + createRequirement: async (ctx: PaymentContext): Promise => ({ + id: 'req-1', + requestId: ctx.requestId, + resourceId: ctx.resource.id, + provider: 'x402', + amount: ctx.amount, + currency: ctx.currency, + destination: '0xMERCHANT', + challenge: { provider: 'x402', version: '2', accepts: [{ scheme: 'exact' }] }, + }), + verify: async (ctx: PaymentVerificationContext): Promise => + ctx.submission.payload === VALID_PROOF + ? { + status: 'verified', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + // One authorisation, one key — the whole point of the reservation. + replayKey: '0xreplaykey', + } + : { + status: 'rejected', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + rejectionReason: 'invalid_payment', + }, + settle: async (_ctx: PaymentSettlementContext): Promise => { + settleCalls += 1; + await new Promise((resolve) => setTimeout(resolve, settleDelayMs)); + return { + status: 'settled', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + externalReference: '0xTXHASH', + replayKey: '0xreplaykey', + }; + }, + health: async () => ({ status: 'pass', checkedAt: new Date().toISOString() }), + }; +} + +async function buildGateway( + storePath: string, + provider: PaymentProvider, +): Promise<{ gateway: GatewayInstance; store: ReceiptStore }> { + const config = parseConfig(rawConfig(storePath), {}); + const store = createSqliteReceiptStore({ path: storePath }); + await store.init(); + const gateway = await createGateway({ + config, + store, + paymentProviders: [provider], + protocolAdapters: [], + backend: { + call: async () => ({ status: 200, headers: {}, body: { ok: true }, durationMs: 1 }), + }, + }); + return { gateway, store }; +} + +function invoke(gateway: GatewayInstance, proof: string) { + return gateway.server.inject({ + method: 'POST', + url: `/api/resources/${RESOURCE_ID}/invoke`, + payload: {}, + headers: { 'payment-signature': proof }, + }); +} + +describe('adversarial: duplicate concurrent request', () => { + let dir: string; + + beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), 'oac-adversarial-')); + }); + + afterAll(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('settles exactly once when the same authorisation arrives twice at the same moment', async () => { + const provider = countingProvider(150); + const { gateway } = await buildGateway(join(dir, 'concurrent.sqlite'), provider); + + // Genuinely in flight together: neither has finished settling when the + // other starts. On-chain nonce checks cannot help here — the first + // transaction has not landed yet — so the gateway's own reservation is the + // only thing standing between one authorisation and two deliveries. + const [a, b] = await Promise.all([invoke(gateway, VALID_PROOF), invoke(gateway, VALID_PROOF)]); + + const statuses = [a.statusCode, b.statusCode].sort(); + expect(statuses).toEqual([200, 409]); + expect(provider.settleCalls()).toBe(1); + + const loser = a.statusCode === 409 ? a : b; + expect(loser.json().code).toBe('PAYMENT_REPLAYED'); + + await gateway.close(); + }); + + it('still refuses the authorisation after the gateway restarts', async () => { + // The reservation lives in SQLite, not in memory. A process restart is the + // cheapest way to find out whether that is true. + const storePath = join(dir, 'restart.sqlite'); + + const first = await buildGateway(storePath, countingProvider(0)); + expect((await invoke(first.gateway, VALID_PROOF)).statusCode).toBe(200); + await first.gateway.close(); + + const second = await buildGateway(storePath, countingProvider(0)); + const replayed = await invoke(second.gateway, VALID_PROOF); + expect(replayed.statusCode).toBe(409); + expect(replayed.json().code).toBe('PAYMENT_REPLAYED'); + // And nothing was settled the second time round. + expect(second.gateway).toBeDefined(); + await second.gateway.close(); + }); +}); + +describe('adversarial: a facilitator that does not answer properly', () => { + let server: Server; + let url: string; + let respond: (res: ServerResponse) => void; + + beforeAll(async () => { + server = createServer((_req, res) => respond(res)); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + async function verifyThrough(handler: (res: ServerResponse) => void): Promise { + respond = handler; + const { createRemoteFacilitatorBinding } = await import( + '../../src/payments/x402/facilitator.js' + ); + const binding = createRemoteFacilitatorBinding({ url, auth: { type: 'none' } }); + const session = binding.open(); + try { + // Shapes do not matter: the transport answer is what is under test. + await session.verify({} as never, {} as never); + return { threw: false, transportFailed: session.transportFailed() }; + } catch (err) { + return { threw: true, transportFailed: session.transportFailed(), err }; + } + } + + it('treats 401 as the facilitator failing, not the buyer', async () => { + // A credential problem is ours. Recording it against the payer would blame + // them for our misconfiguration and burn an authorisation nothing checked. + const outcome = (await verifyThrough((res) => { + res.writeHead(401, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'unauthorized' })); + })) as { threw: boolean; transportFailed: boolean }; + expect(outcome.threw).toBe(true); + expect(outcome.transportFailed).toBe(true); + }); + + it('treats 500 as the facilitator failing', async () => { + const outcome = (await verifyThrough((res) => { + res.writeHead(500, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'boom' })); + })) as { threw: boolean; transportFailed: boolean }; + expect(outcome.threw).toBe(true); + expect(outcome.transportFailed).toBe(true); + }); + + it('refuses to read a verdict out of an unparseable 200', async () => { + // The dangerous shape: a 200 that is not a verify response. Anything that + // guessed "looks fine" here would deliver a paid resource on no evidence. + const outcome = (await verifyThrough((res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('gateway timeout'); + })) as { threw: boolean; transportFailed: boolean }; + expect(outcome.threw).toBe(true); + expect(outcome.transportFailed).toBe(true); + }); + + it('refuses a 200 whose JSON is well-formed but not a verify response', async () => { + const outcome = (await verifyThrough((res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ totally: 'unrelated' })); + })) as { threw: boolean; transportFailed: boolean; err?: unknown }; + expect(outcome.threw).toBe(true); + // Never `isValid: true` by omission. + if (outcome.err !== undefined) { + expect(isCommerceError(outcome.err) || outcome.err instanceof Error).toBe(true); + } + }); +}); diff --git a/tests/unit/payments-x402/facilitator-cdp.test.ts b/tests/unit/payments-x402/facilitator-cdp.test.ts index 9d3d13c..d2b3b2d 100644 --- a/tests/unit/payments-x402/facilitator-cdp.test.ts +++ b/tests/unit/payments-x402/facilitator-cdp.test.ts @@ -53,7 +53,7 @@ async function buildBinding(auth: { const { createRemoteFacilitatorBinding } = await import( '../../../src/payments/x402/facilitator.js' ); - createRemoteFacilitatorBinding({ url: FACILITATOR_URL, auth }, () => false); + createRemoteFacilitatorBinding({ url: FACILITATOR_URL, auth }); const config = captured[0]; if (!config) throw new Error('the binding built no HTTP client'); return config; @@ -135,13 +135,10 @@ describe('facilitator auth: cdp', () => { const { createRemoteFacilitatorBinding } = await import( '../../../src/payments/x402/facilitator.js' ); - const binding = createRemoteFacilitatorBinding( - { - url: FACILITATOR_URL, - auth: { type: 'cdp', apiKeyId: 'key-id', apiKeySecret: 'super-secret' }, - }, - () => false, - ); + const binding = createRemoteFacilitatorBinding({ + url: FACILITATOR_URL, + auth: { type: 'cdp', apiKeyId: 'key-id', apiKeySecret: 'super-secret' }, + }); // `describe` reaches logs, health details and doctor output. expect(binding.describe).not.toContain('super-secret'); expect(binding.describe).not.toContain('key-id'); From ba14d62ad869eb5d750841f88fb2725cb432ee79 Mon Sep 17 00:00:00 2001 From: SergeevDmitry Date: Sun, 23 Aug 2026 22:17:49 +0200 Subject: [PATCH 4/6] docs: document the facilitator model and public-network setup, and pin the README config to the schema --- README.md | 95 ++++++++++++++++++++++++++++++++- tests/unit/cli/examples.test.ts | 59 +++++++++++++++++++- 2 files changed, 152 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4e72bb4..f787411 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ has no business installing. | gateway, config, receipts, CLI | `@devlab.group/agent-commerce` | `from '@devlab.group/agent-commerce'` | | expose resources as MCP tools | `+ @modelcontextprotocol/sdk` | `from '@devlab.group/agent-commerce/mcp'` | | accept x402 payments | `+ @x402/core @x402/evm viem` | `from '@devlab.group/agent-commerce/x402'` | +| authenticate to a CDP facilitator | `+ @coinbase/x402` | (no import — loaded on demand) | ```bash npm install @devlab.group/agent-commerce @modelcontextprotocol/sdk @x402/core @x402/evm viem @@ -118,6 +119,13 @@ one. Import a subpath without its peer installed and Node fails at load naming the missing package — deliberately, rather than starting a gateway that silently serves nothing. +`@coinbase/x402` is the odd one out: it has no import of its own and is loaded +dynamically, only when `facilitator.auth.type: cdp` is configured. It is worth +avoiding if you can — it brings `@coinbase/cdp-sdk` and `axios`, which carry +high-severity advisories, while the package itself and the other three peers +audit clean. `auth.type: bearer` covers any facilitator with a static token and +installs nothing. + ## Quickstart Requirements: **Node >= 22**, **npm 10**, **Docker**. Nothing else — no API @@ -235,7 +243,12 @@ checkable, not marketing. Detail: [docs/protocols.md](docs/protocols.md). Detail: [docs/payment-flow.md](docs/payment-flow.md). -### Settled on public networks +## Public networks + +Same gateway, same pipeline — a different `network` and a facilitator that is +not this process. No code changes, and no "live mode" to switch on. + +### It has actually settled Not a roadmap entry. Both of these moved 0.01 USDC from a buyer to a merchant through a remote facilitator: @@ -254,6 +267,86 @@ success is not the proof. Reproduce with `npm run test:testnet` / `npm run test:mainnet` — both spend real funds, skip themselves without credentials, and never run in CI. +### The facilitator model + +A **facilitator** verifies the buyer's authorisation and broadcasts the +transfer. It is the only component that needs gas, and it is never this +gateway on a public network. + +| `facilitator.mode` | Who signs | Where it is allowed | +| --- | --- | --- | +| `local` | this process, with an Anvil dev key | the local dev chain only | +| `remote` | an HTTP facilitator you point at | anywhere | + +With `remote`, the gateway holds **no signing key at all**. The buyer signs an +EIP-3009 authorisation offline — no ETH required — and the facilitator pays the +gas. A facilitator cannot redirect your money: the authorisation names its +recipient, amount and chain, so it can broadcast exactly that transfer or +nothing. What it can do is see every authorisation you handle, and stop +answering. + +Three auth types: `none`, `bearer` (a static token, installs nothing) and `cdp` +(Coinbase Developer Platform, which signs a fresh JWT per request). Anything +else is refused at config load rather than sent nothing. You can also run your +own — `remote` does not care who operates the endpoint. + +### Base Sepolia + +```yaml +payments: + x402: + enabled: true + network: eip155:84532 + rpcUrl: https://base-sepolia-rpc.publicnode.com # health checks only + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" # Circle USDC + assetName: USDC + assetVersion: "2" + assetDecimals: 6 + payTo: ${MERCHANT_WALLET} + maxTimeoutSeconds: 300 + facilitator: + mode: remote + url: https://x402.org/facilitator + auth: { type: none } +``` + +Full config in [`examples/base-sepolia/`](examples/base-sepolia/). Test USDC +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`. + +### Base mainnet + +Real funds, so nothing is defaulted. Every one of these is checked at config +load, and the gateway will not start without them: + +| Required | | +| --- | --- | +| `allowMainnet: true` | mainnet is never a default | +| `facilitator.mode: remote` | `local` needs a funded gas key inside this process | +| an HTTPS `facilitator.url` | | +| `allowUnauthenticatedFacilitator: true` | only if that facilitator takes no credential | +| a non-development `payTo` | | +| `asset` = USDC on Base, `assetName: "USD Coin"` | **not** `"USDC"` — that deployment predates the rename, and the buyer signs the name into their EIP-712 domain | + +Full config in [`examples/base-mainnet/`](examples/base-mainnet/), and +[`examples/base-mainnet-payai/`](examples/base-mainnet-payai/) for an +unauthenticated facilitator. `npm run test:mainnet` proves it end to end and +spends real USDC on every run. + +`agent-commerce validate` reports any of the above before anything starts, and +`doctor` prints `LIVE MAINNET MODE — REAL FUNDS`. + +> Neither public-network suite runs in CI — there is no workflow and there must +> not be one. A workflow means a funded key in repository secrets, spendable by +> anyone with write access. Both suites run from the machine that holds the +> wallet, and skip themselves without credentials. + ## Diagnostics ```console diff --git a/tests/unit/cli/examples.test.ts b/tests/unit/cli/examples.test.ts index c599eb7..0554736 100644 --- a/tests/unit/cli/examples.test.ts +++ b/tests/unit/cli/examples.test.ts @@ -11,10 +11,31 @@ import { describe, expect, it } from 'vitest'; import { parse as parseYaml } from 'yaml'; import { parseConfig } from '../../../src/config/index.js'; -const EXAMPLES_DIR = join(import.meta.dirname, '../../../examples'); +const REPO_ROOT = join(import.meta.dirname, '../../..'); +const EXAMPLES_DIR = join(REPO_ROOT, 'examples'); const EXAMPLES = ['simple-paid-api', 'free-and-premium', 'paid-mcp-tool'] as const; +/** + * The public-network examples need an environment: on a real chain there is no + * safe default for a merchant wallet — the only one available would be an Anvil + * development address, which the guardrails refuse outside local mode. That + * refusal is the feature, 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_ENV = { + MERCHANT_WALLET: '0x1111111111111111111111111111111111111111', + ALLOW_X402_MAINNET: 'true', + GATEWAY_PUBLIC_BASE_URL: 'https://gateway.example.com', + GATEWAY_ADMIN_TOKEN: 'admin-token', + MERCHANT_API_BASE_URL: 'http://localhost:3000', + X402_FACILITATOR_URL: 'https://facilitator.example.com', + CDP_API_KEY_ID: 'key-id', + CDP_API_KEY_SECRET: 'key-secret', +}; + describe('examples/**/config.yaml', () => { it.each(EXAMPLES)('%s: validates against the real config loader with no environment', (name) => { const yamlText = readFileSync(join(EXAMPLES_DIR, name, 'config.yaml'), 'utf8'); @@ -22,6 +43,42 @@ describe('examples/**/config.yaml', () => { expect(config.resources.length).toBeGreaterThan(0); }); + it.each(PUBLIC_EXAMPLES)('%s: validates with a merchant wallet supplied', (name) => { + const yamlText = readFileSync(join(EXAMPLES_DIR, name, 'config.yaml'), 'utf8'); + const config = parseConfig(parseYaml(yamlText), PUBLIC_ENV); + expect(config.payments.x402?.facilitator.mode).toBe('remote'); + }); + + /** + * The README's own YAML is documentation the moment it stops validating, and + * a setup snippet that fails on copy-paste is worse than none. It shipped + * once missing three required fields. + */ + it("the README's public-network snippet parses as a real config", () => { + const readme = readFileSync(join(REPO_ROOT, 'README.md'), 'utf8'); + const block = /```yaml\n(payments:\n[\s\S]*?)```/.exec(readme)?.[1]; + expect(block, 'README no longer contains a `payments:` yaml block').toBeDefined(); + + const config = parseConfig( + parseYaml(`version: 1 +merchant: { id: readme, name: Readme, 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: + r: + name: R + backend: { type: http, method: GET, url: 'http://merchant.invalid/x' } + pricing: { type: fixed, amount: '0.01', currency: USD } + expose: [http] + payments: [x402] +${block}`), + PUBLIC_ENV, + ); + expect(config.payments.x402?.enabled).toBe(true); + expect(config.payments.x402?.network).toBe('eip155:84532'); + }); + it('simple-paid-api: HTTP only, one paid resource', () => { const yamlText = readFileSync(join(EXAMPLES_DIR, 'simple-paid-api/config.yaml'), 'utf8'); const config = parseConfig(parseYaml(yamlText), {}); From 000e2107cb0df8cff0d927869a80049b40d0f568 Mon Sep 17 00:00:00 2001 From: SergeevDmitry Date: Sun, 23 Aug 2026 23:13:51 +0200 Subject: [PATCH 5/6] security: refuse backend.url parameters before the end of the host; other fixes --- .github/workflows/ci.yml | 30 ++-- .github/workflows/release.yml | 30 ++-- README.md | 18 +-- SECURITY.md | 15 +- contracts/artifacts/MockUSDC.json | 2 +- contracts/src/MockUSDC.sol | 5 +- demo/agent/src/run.ts | 17 +- demo/agent/test/run.test.ts | 9 ++ docs/contributing-adapters.md | 2 +- docs/payment-flow.md | 4 +- docs/security.md | 22 ++- src/config/env.ts | 73 +++++++-- src/config/schema.ts | 96 +++++++++++- src/gateway/well-known.ts | 25 ++- src/index.ts | 6 +- src/payments/x402/chain.ts | 20 ++- src/payments/x402/dev-key-guard.ts | 24 ++- src/payments/x402/guardrails.ts | 27 +++- src/payments/x402/provider.ts | 62 ++++++-- src/storage/receipts/store.ts | 34 +++- tests/e2e/payment/x402-settlement.e2e.test.ts | 20 +++ tests/testnet/base-sepolia.smoke.test.ts | 4 +- tests/unit/cli/packaging.test.ts | 18 ++- tests/unit/config/env.test.ts | 48 +++++- tests/unit/config/schema.test.ts | 147 +++++++++++++++++- .../unit/storage-receipts/permissions.test.ts | 75 +++++++++ 26 files changed, 706 insertions(+), 127 deletions(-) create mode 100644 tests/unit/storage-receipts/permissions.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5d70f4..a9bdf30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,10 +33,10 @@ jobs: name: Lint & typecheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ env.NODE_VERSION }} cache: npm @@ -51,24 +51,24 @@ jobs: name: Unit & integration tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ env.NODE_VERSION }} cache: npm # tests/unit/payments-x402/local-chain.test.ts spawns a real ephemeral # Anvil — it is a unit test of the deploy engine, not of a mock. Without # Foundry here the whole file errors with ENOENT. - - uses: foundry-rs/foundry-toolchain@v1 + - uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1 with: # Pinned: the default is `nightly`, a mutable input to a suite this # project calls deterministic. version: ${{ env.FOUNDRY_VERSION }} - run: npm ci - run: npm run test:coverage - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: coverage @@ -79,10 +79,10 @@ jobs: name: Solidity contracts runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - - uses: foundry-rs/foundry-toolchain@v1 + - uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1 with: # Pinned: the default is `nightly`, a mutable input to a suite this # project calls deterministic. @@ -96,14 +96,14 @@ jobs: name: Deterministic E2E runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ env.NODE_VERSION }} cache: npm - - uses: foundry-rs/foundry-toolchain@v1 + - uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1 with: # Pinned: the default is `nightly`, a mutable input to a suite this # project calls deterministic. @@ -117,10 +117,10 @@ jobs: name: npm package runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ env.NODE_VERSION }} cache: npm @@ -221,10 +221,10 @@ jobs: name: Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ env.NODE_VERSION }} cache: npm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4c5b25..dd9a0f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,10 +26,10 @@ jobs: name: Dependency audit review runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '22' cache: npm @@ -39,7 +39,7 @@ jobs: # automatically block. - run: npm audit --audit-level high || true - run: npm audit --json > audit.json || true - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dependency-audit path: audit.json @@ -48,14 +48,14 @@ jobs: name: Fresh-clone quickstart smoke test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '22' cache: npm - - uses: foundry-rs/foundry-toolchain@v1 + - uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1 with: # Pinned: the default is `nightly`, a mutable input to a suite this # project calls deterministic. @@ -96,10 +96,15 @@ jobs: # until the setting is updated. id-token: write steps: - - uses: actions/checkout@v4 + # Every action below runs inside a job holding `id-token: write`, so a + # moving tag here is a publish credential: whoever can repoint + # `actions/checkout@v4` can mint an npm token for this package. Actions + # are pinned by commit SHA — the trailing comment is the human-readable + # version, the SHA is what GitHub resolves. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '22' cache: npm @@ -107,7 +112,7 @@ jobs: # `prepublishOnly` runs the full `npm run verify`, and part of that suite # spawns a real Anvil. Without Foundry here the publish fails on a test # that has nothing to do with the artifact being published. - - uses: foundry-rs/foundry-toolchain@v1 + - uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1 with: # Pinned: the default is `nightly`, a mutable input to a suite this # project calls deterministic. @@ -115,7 +120,12 @@ jobs: # setup-node pairs Node 22 with npm 10, which predates trusted # publishing (needs >= 11.5.1). Without this the publish fails asking for # credentials that deliberately do not exist. - - run: npm install -g npm@latest + # + # Exact version, not `@latest`: this npm is the process that mints the + # OIDC claim and uploads the tarball, so letting it float means the + # publish path changes under an unrelated release with nothing in this + # repository recording it. + - run: npm install -g npm@11.6.2 - run: npm ci - name: Tag must match the version being published # A tag that disagrees with package.json publishes a version nobody diff --git a/README.md b/README.md index f787411..0f55707 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ const gateway = await createGateway({ paymentProviders: [], protocolAdapters: [], }); -const { url } = await gateway.listen; +const { url } = await gateway.listen(); ``` ### Optional peers — install only the rails you use @@ -352,14 +352,14 @@ spends real USDC on every run. ```console $ npm run agent-commerce -- doctor --config config-demo.yaml -PASS Config valid — 2 resource(s), merchant "Demo Data Store" -PASS Gateway healthy and ready at http://127.0.0.1:8080 -PASS Backend 2/2 backend host(s) reachable -PASS Protocols http=on mcp=on (/mcp) -PASS Payments x402 v2 (scheme=exact) enabled — LOCAL on Base Sepolia (eip155:84532), destination=0x7099…79C8, facilitator=local -INFO Payments (MPP) planned — not implemented in v0.1 -PASS Storage sqlite schema v1 writable; receipts=2 -PASS Protocol versions reported by gateway /.well-known/agent-commerce +PASS Config valid — 2 resource(s), merchant "Demo Data Store" (using local chain manifest .deploy/local.json for X402_ASSET, X402_ASSET_NAME, X402_ASSET_VERSION, X402_ASSET_DECIMALS, MERCHANT_WALLET, X402_FACILITATOR_PRIVATE_KEY) +PASS Gateway healthy and ready at http://127.0.0.1:8080 +PASS Backend 2/2 backend host(s) reachable +PASS Protocols http=on mcp=on (/mcp) +PASS Payments x402 v2 (scheme=exact) enabled — LOCAL dev chain (eip155:84532, chain id shared with Base Sepolia), destination=0x7099…79C8, facilitator=local +INFO Payments (MPP) planned — not implemented in this release +PASS Storage sqlite schema v1 writable; receipts=2 +PASS Protocol versions reported by gateway /.well-known/agent-commerce Score: 7/7 checks passed ``` diff --git a/SECURITY.md b/SECURITY.md index 6636383..1b955d7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,7 +17,8 @@ custodian. itself. With x402 `exact`/EVM this is an EIP-3009 `transferWithAuthorization`: the buyer signs an authorisation that names the merchant as recipient, so a facilitator that broadcasts it cannot redirect the money. -- Rationale and detail:. +- Rationale and detail: [`docs/payment-flow.md`](docs/payment-flow.md), which + walks the money path end to end. ## Private-key policy @@ -128,12 +129,12 @@ backend or your business secure. the resource. There is no KYC, sanctions screening, fraud scoring or dispute mechanism. - **It does not make payments reversible.** On-chain settlement is final. There - are no refunds, chargebacks or escrow in v0.1. + are no refunds, chargebacks or escrow. - **It does not protect against SSRF beyond configuration discipline.** The gateway calls the backend URLs an administrator configured. Redirects are not followed. But if you configure an internal URL, the gateway will call it — agent- or user-controlled backend URLs are forbidden, and there is no - allowlist enforcement in v0.1. + allowlist enforcement. - **It does not audit the payment protocol or its SDKs.** x402, the MCP SDK and their transitive dependencies are third-party code. - **It does not provide multi-tenancy, RBAC or policy controls.** @@ -170,13 +171,13 @@ backend or your business secure. - **It does not rate limit anything.** Free resources are an unauthenticated proxy to your backend at whatever rate a caller chooses. Rate limiting, quotas and abuse controls belong in your API or your edge. -- **The alpha has not had an independent security audit.** ## Supported versions -| Version | Supported | -| ------------- | ------------------------------ | -| `0.1.x-alpha` | Latest alpha only, best effort | +| Version | Supported | +| ------------ | ----------------------------- | +| `0.2.x-beta` | Latest beta only, best effort | +| `0.1.x` | Not supported | ## Reporting a vulnerability diff --git a/contracts/artifacts/MockUSDC.json b/contracts/artifacts/MockUSDC.json index 185fea0..c882ffb 100644 --- a/contracts/artifacts/MockUSDC.json +++ b/contracts/artifacts/MockUSDC.json @@ -464,5 +464,5 @@ "inputs": [] } ], - "bytecode": "0x6080806040523461001657610bf7908161001b8239f35b5f80fdfe60806040526004361015610011575f80fd5b5f3560e01c806306fdde0314610114578063095ea7b31461010f57806318160ddd1461010a57806323b872dd14610105578063313ce567146101005780633644e515146100fb57806340c10f19146100f657806354fd4d50146100f157806370a08231146100ec57806395d89b41146100e7578063a0cc6a68146100e2578063a9059cbb146100dd578063cf092995146100d8578063dd62ed3e146100d3578063e3ee160e146100ce5763e94a0102146100c9575f80fd5b61078e565b6106aa565b610653565b6105bc565b61058b565b610551565b610514565b6104dc565b6104c1565b610403565b6103e1565b6103c6565b610311565b6102f5565b61026e565b610213565b634e487b7160e01b5f52604160045260245ffd5b6040810190811067ffffffffffffffff82111761014957604052565b610119565b6080810190811067ffffffffffffffff82111761014957604052565b90601f8019910116810190811067ffffffffffffffff82111761014957604052565b67ffffffffffffffff811161014957601f01601f191660200190565b604051906101b58261012d565b60088252674d6f636b5553444360c01b6020830152565b602080825282518183018190529093925f5b8281106101ff57505060409293505f838284010152601f8019910116010190565b8181018601518482016040015285016101de565b3461023e575f36600319011261023e5761023a61022e6101a8565b604051918291826101cc565b0390f35b5f80fd5b600435906001600160a01b038216820361023e57565b602435906001600160a01b038216820361023e57565b3461023e57604036600319011261023e57610287610242565b60243590335f526002602052816102b18260405f209060018060a01b03165f5260205260405f2090565b556040519182526001600160a01b03169033907f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92590602090a3602060405160018152f35b3461023e575f36600319011261023e5760205f54604051908152f35b3461023e57606036600319011261023e5761032a610242565b610332610258565b6001600160a01b0382165f818152600260209081526040808320338452909152902090926044359291548381106103b45760018101610383575b506103779350610927565b60405160018152602090f35b8381039081116103af575f9485526002602090815260408087203388529091528520556103779361036c565b6107d7565b6040516313be252b60e01b8152600490fd5b3461023e575f36600319011261023e57602060405160068152f35b3461023e575f36600319011261023e5760206103fb6107eb565b604051908152f35b3461023e57604036600319011261023e5761041c610242565b6001600160a01b03166024358115610492575f54908082018092116103af5761048d7fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef915f93845584845260016020526040842061047b82825461086f565b90556040519081529081906020820190565b0390a3005b60405163d92e233d60e01b8152600490fd5b604051906104b18261012d565b60018252601960f91b6020830152565b3461023e575f36600319011261023e5761023a61022e6104a4565b3461023e57602036600319011261023e576001600160a01b036104fd610242565b165f526001602052602060405f2054604051908152f35b3461023e575f36600319011261023e5761023a6040516105338161012d565b60058152646d5553444360d81b6020820152604051918291826101cc565b3461023e575f36600319011261023e5760206040517f7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a22678152f35b3461023e57604036600319011261023e576105b16105a7610242565b6024359033610927565b602060405160018152f35b3461023e5760e036600319011261023e576105d5610242565b6105dd610258565b9060c4359167ffffffffffffffff831161023e573660238401121561023e5782600401359161060b8361018c565b92610619604051948561016a565b808452366024828701011161023e576020815f9260246106519801838801378501015260a43591608435916064359160443591610887565b005b3461023e57604036600319011261023e5760206106a1610671610242565b610679610258565b6001600160a01b039182165f9081526002855260408082209290931681526020919091522090565b54604051908152f35b3461023e5761012036600319011261023e576106c4610242565b6106cc610258565b6044356064356084359160a4359360c43560ff8116810361023e575f8760209261073661070b8a8a8a8a8a61010435986107068a8a6109c6565b610a33565b9260405193849360e43591859094939260ff6060936080840197845216602083015260408201520152565b838052039060015afa15610789575f516001600160a01b03908116801591821561077c575b505061076a5761065195610ae7565b604051638baa579f60e01b8152600490fd5b8816141590505f8061075b565b61087c565b3461023e57604036600319011261023e576001600160a01b036107af610242565b165f52600360205260405f206024355f52602052602060ff60405f2054166040519015158152f35b634e487b7160e01b5f52601160045260245ffd5b6107f36101a8565b602081519101206108026104a4565b602081519101206040519060208201927f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f8452604083015260608201524660808201523060a082015260a0815260c0810181811067ffffffffffffffff8211176101495760405251902090565b919082018092116103af57565b6040513d5f823e3d90fd5b95604181510361076a575f87826108e4602080950151916108bd8b8b8b8b8b606060408801519701518b1a99610706888c6109c6565b92604051948594859094939260ff6060936080840197845216602083015260408201520152565b838052039060015afa15610789575f516001600160a01b03908116801591821561091a575b505061076a5761091895610ae7565b565b8816141590505f80610909565b6001600160a01b03828116939190841561049257811692835f52600160205260405f2054918383106109b4576001600160a01b039081165f908152600160205260408082209486900390945591168152208054918083018093116103af577fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9260209255604051908152a3565b604051631e9acf1760e31b8152600490fd5b907f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a010610a215760ff16601b8114159081610a15575b50610a0357565b60405163449f5db160e01b8152600490fd5b601c915014155f6109fc565b60405163185f3d1d60e21b8152600490fd5b9391949290946040519460208601967f7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267885260018060a01b038092166040880152166060860152608085015260a084015260c083015260e082015260e08152610100810181811067ffffffffffffffff82111761014957604052519020610ab86107eb565b9060405190602082019261190160f01b84526022830152604282015260428152610ae18161014e565b51902090565b91939092421115610baf57421015610b9d576001600160a01b0381165f908152600360205260409020845f5260205260ff60405f205416610b8b576001600160a01b0381165f9081526003602052604090206109189490815f52602052610b5860405f20600160ff19825416179055565b6001600160a01b0382167f98de503528ee59b575ef0c0a2576a82497bfc029a5685b209e9ec333479b10a55f80a3610927565b604051634a8478f960e11b8152600490fd5b604051630f05f5bf60e01b8152600490fd5b604051636fc721b960e11b8152600490fdfea2646970667358221220887d6ba1b8dc0ba34f668c127836e53f5a190ba1b74820010f3d3864ae64b46964736f6c63430008180033" + "bytecode": "0x6080806040523461001657610bf7908161001b8239f35b5f80fdfe60806040526004361015610011575f80fd5b5f3560e01c806306fdde0314610114578063095ea7b31461010f57806318160ddd1461010a57806323b872dd14610105578063313ce567146101005780633644e515146100fb57806340c10f19146100f657806354fd4d50146100f157806370a08231146100ec57806395d89b41146100e7578063a0cc6a68146100e2578063a9059cbb146100dd578063cf092995146100d8578063dd62ed3e146100d3578063e3ee160e146100ce5763e94a0102146100c9575f80fd5b61078e565b6106aa565b610653565b6105bc565b61058b565b610551565b610514565b6104dc565b6104c1565b610403565b6103e1565b6103c6565b610311565b6102f5565b61026e565b610213565b634e487b7160e01b5f52604160045260245ffd5b6040810190811067ffffffffffffffff82111761014957604052565b610119565b6080810190811067ffffffffffffffff82111761014957604052565b90601f8019910116810190811067ffffffffffffffff82111761014957604052565b67ffffffffffffffff811161014957601f01601f191660200190565b604051906101b58261012d565b60088252674d6f636b5553444360c01b6020830152565b602080825282518183018190529093925f5b8281106101ff57505060409293505f838284010152601f8019910116010190565b8181018601518482016040015285016101de565b3461023e575f36600319011261023e5761023a61022e6101a8565b604051918291826101cc565b0390f35b5f80fd5b600435906001600160a01b038216820361023e57565b602435906001600160a01b038216820361023e57565b3461023e57604036600319011261023e57610287610242565b60243590335f526002602052816102b18260405f209060018060a01b03165f5260205260405f2090565b556040519182526001600160a01b03169033907f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92590602090a3602060405160018152f35b3461023e575f36600319011261023e5760205f54604051908152f35b3461023e57606036600319011261023e5761032a610242565b610332610258565b6001600160a01b0382165f818152600260209081526040808320338452909152902090926044359291548381106103b45760018101610383575b506103779350610927565b60405160018152602090f35b8381039081116103af575f9485526002602090815260408087203388529091528520556103779361036c565b6107d7565b6040516313be252b60e01b8152600490fd5b3461023e575f36600319011261023e57602060405160068152f35b3461023e575f36600319011261023e5760206103fb6107eb565b604051908152f35b3461023e57604036600319011261023e5761041c610242565b6001600160a01b03166024358115610492575f54908082018092116103af5761048d7fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef915f93845584845260016020526040842061047b82825461086f565b90556040519081529081906020820190565b0390a3005b60405163d92e233d60e01b8152600490fd5b604051906104b18261012d565b60018252601960f91b6020830152565b3461023e575f36600319011261023e5761023a61022e6104a4565b3461023e57602036600319011261023e576001600160a01b036104fd610242565b165f526001602052602060405f2054604051908152f35b3461023e575f36600319011261023e5761023a6040516105338161012d565b60058152646d5553444360d81b6020820152604051918291826101cc565b3461023e575f36600319011261023e5760206040517f7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a22678152f35b3461023e57604036600319011261023e576105b16105a7610242565b6024359033610927565b602060405160018152f35b3461023e5760e036600319011261023e576105d5610242565b6105dd610258565b9060c4359167ffffffffffffffff831161023e573660238401121561023e5782600401359161060b8361018c565b92610619604051948561016a565b808452366024828701011161023e576020815f9260246106519801838801378501015260a43591608435916064359160443591610887565b005b3461023e57604036600319011261023e5760206106a1610671610242565b610679610258565b6001600160a01b039182165f9081526002855260408082209290931681526020919091522090565b54604051908152f35b3461023e5761012036600319011261023e576106c4610242565b6106cc610258565b6044356064356084359160a4359360c43560ff8116810361023e575f8760209261073661070b8a8a8a8a8a61010435986107068a8a6109c6565b610a33565b9260405193849360e43591859094939260ff6060936080840197845216602083015260408201520152565b838052039060015afa15610789575f516001600160a01b03908116801591821561077c575b505061076a5761065195610ae7565b604051638baa579f60e01b8152600490fd5b8816141590505f8061075b565b61087c565b3461023e57604036600319011261023e576001600160a01b036107af610242565b165f52600360205260405f206024355f52602052602060ff60405f2054166040519015158152f35b634e487b7160e01b5f52601160045260245ffd5b6107f36101a8565b602081519101206108026104a4565b602081519101206040519060208201927f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f8452604083015260608201524660808201523060a082015260a0815260c0810181811067ffffffffffffffff8211176101495760405251902090565b919082018092116103af57565b6040513d5f823e3d90fd5b95604181510361076a575f87826108e4602080950151916108bd8b8b8b8b8b606060408801519701518b1a99610706888c6109c6565b92604051948594859094939260ff6060936080840197845216602083015260408201520152565b838052039060015afa15610789575f516001600160a01b03908116801591821561091a575b505061076a5761091895610ae7565b565b8816141590505f80610909565b6001600160a01b03828116939190841561049257811692835f52600160205260405f2054918383106109b4576001600160a01b039081165f908152600160205260408082209486900390945591168152208054918083018093116103af577fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9260209255604051908152a3565b604051631e9acf1760e31b8152600490fd5b907f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a010610a215760ff16601b8114159081610a15575b50610a0357565b60405163449f5db160e01b8152600490fd5b601c915014155f6109fc565b60405163185f3d1d60e21b8152600490fd5b9391949290946040519460208601967f7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267885260018060a01b038092166040880152166060860152608085015260a084015260c083015260e082015260e08152610100810181811067ffffffffffffffff82111761014957604052519020610ab86107eb565b9060405190602082019261190160f01b84526022830152604282015260428152610ae18161014e565b51902090565b91939092421115610baf57421015610b9d576001600160a01b0381165f908152600360205260409020845f5260205260ff60405f205416610b8b576001600160a01b0381165f9081526003602052604090206109189490815f52602052610b5860405f20600160ff19825416179055565b6001600160a01b0382167f98de503528ee59b575ef0c0a2576a82497bfc029a5685b209e9ec333479b10a55f80a3610927565b604051634a8478f960e11b8152600490fd5b604051630f05f5bf60e01b8152600490fd5b604051636fc721b960e11b8152600490fdfea26469706673582212201ff3fd0c3d12a80f1b116266f80f9f9b7142bafd0992fc320e901a944c2989ad64736f6c63430008180033" } diff --git a/contracts/src/MockUSDC.sol b/contracts/src/MockUSDC.sol index a2090a2..d93c836 100644 --- a/contracts/src/MockUSDC.sol +++ b/contracts/src/MockUSDC.sol @@ -7,7 +7,10 @@ pragma solidity ^0.8.24; /// holds real value with this contract's address. /// /// Implements a minimal ERC-20 plus EIP-3009 `transferWithAuthorization`, matching the -/// subset of Circle's FiatTokenV2 ABI that the pinned x402 SDK (version 1.2.0) expects: both +/// subset of Circle's FiatTokenV2 ABI that the pinned x402 SDK expects — the core and evm +/// packages at 2.23.0, protocol v2. Their scoped npm names are spelled out nowhere in this +/// comment on purpose: solc reads a word starting with "at" in a doc comment as a NatSpec +/// tag and refuses to compile the file. Both /// the `(v,r,s)` EOA-signature overload and the `(bytes signature)` overload, because the SDK /// selects between them by signature length. contract MockUSDC { diff --git a/demo/agent/src/run.ts b/demo/agent/src/run.ts index fe53b46..5cca2cc 100644 --- a/demo/agent/src/run.ts +++ b/demo/agent/src/run.ts @@ -17,6 +17,7 @@ import { isPaymentRequiredEnvelope, PAYMENT_INPUT_FIELD, } from '../../../src/core/index.js'; +import { LOCAL_NETWORK } from '../../../src/payments/x402/chain.js'; import { type BalanceReader, createBalanceReader, @@ -89,13 +90,24 @@ export function assertPaymentIsExpected( payTo?: string | undefined; asset?: string | undefined; amount?: string | undefined; + network?: string | undefined; }, - expected: { merchant: string; asset: string; maxValue: bigint }, + expected: { merchant: string; asset: string; maxValue: bigint; network: string }, ): void { const step = 'check the payment requirement before signing'; const same = (a: string | undefined, b: string): boolean => typeof a === 'string' && a.toLowerCase() === b.toLowerCase(); + // The network is what `createPaymentProof` derives the EIP-712 chain id + // from, so an unpinned one lets a hostile gateway obtain a signature domained + // to a chain of its choosing — bounded by the pinned asset, but a real gap in + // a routine every reader is invited to copy. + if (accepts.network !== expected.network) { + throw new DemoAgentStepError( + step, + `challenge names network "${String(accepts.network)}" but the expected network is "${expected.network}" — refusing to sign`, + ); + } if (!same(accepts.payTo, expected.merchant)) { throw new DemoAgentStepError( step, @@ -230,11 +242,12 @@ export async function runDemoAgent(deps: DemoAgentDeps = {}): Promise { // intent. Damage here is bounded by a single-use nonce and a local chain, // and this demo does trust its own gateway — but demos get copied, and a // real buyer that signs an unchecked challenge has no recourse. These - // three assertions are what that buyer must do, written out. + // assertions are what that buyer must do, written out. assertPaymentIsExpected(accepts, { merchant: manifest.merchant.address, asset: manifest.asset, maxValue: BigInt(MAX_DEMO_PAYMENT_UNITS), + network: process.env['X402_NETWORK'] ?? LOCAL_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 b0004ad..0682951 100644 --- a/demo/agent/test/run.test.ts +++ b/demo/agent/test/run.test.ts @@ -83,6 +83,9 @@ const PAYMENT_REQUIRED_ENVELOPE = { accepts: [ { scheme: 'exact', + // The buyer pins this before signing: it is what the EIP-712 chain id + // is derived from, so a fixture that omits it is a refusal. + network: 'eip155:84532', payTo: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', asset: '0x5fbdb2315678afecb367f032d93f642f64180aa3', amount: '10000', @@ -384,11 +387,13 @@ describe('the buyer checks the 402 challenge before signing', () => { merchant: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', asset: '0x5FbDB2315678afecb367f032d93F642f64180aa3', maxValue: MAX_DEMO_PAYMENT_UNITS, + network: 'eip155:84532', }; const good = { payTo: expected.merchant, asset: expected.asset, amount: '10000', + network: expected.network, }; it('accepts the challenge the demo actually expects', () => { @@ -416,6 +421,10 @@ describe('the buyer checks the 402 challenge before signing', () => { ['a zero amount', { amount: '0' }, 'outside the accepted range'], ['a non-integer amount', { amount: '1.5' }, 'not an integer'], ['a missing recipient', { payTo: undefined }, 'expected merchant'], + // The network decides the chain id signed into the EIP-712 domain, so an + // unpinned one lets the challenge choose which chain the signature is for. + ['a substituted network', { network: 'eip155:1' }, 'expected network'], + ['a missing network', { network: undefined }, 'expected network'], ])('refuses to sign %s', (_label, override, expectedMessage) => { expect(() => assertPaymentIsExpected({ ...good, ...override }, expected)).toThrowError( new RegExp(expectedMessage), diff --git a/docs/contributing-adapters.md b/docs/contributing-adapters.md index 60a49ea..43cbca8 100644 --- a/docs/contributing-adapters.md +++ b/docs/contributing-adapters.md @@ -32,7 +32,7 @@ export function createExampleAdapter: HttpProtocolAdapter { descriptor: { name: 'example', kind: 'protocol', - implementationVersion: '0.1.0', + implementationVersion: '0.2.0-beta.0', supportedSpec: 'example-spec@2026-01-01', // pin it, do not hand-wave capabilities: ['discovery', 'invoke'], unsupported: ['subscriptions', 'batch'], // be explicit diff --git a/docs/payment-flow.md b/docs/payment-flow.md index 8f57456..048ec6a 100644 --- a/docs/payment-flow.md +++ b/docs/payment-flow.md @@ -126,9 +126,7 @@ The demo and CI settle for real, on a chain they own: - 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. -No public RPC, no public chain, no hosted facilitator, no real money. See - for the exact SDK behaviour this -relies on. +No public RPC, no public chain, no hosted facilitator, no real money. ## Public networks diff --git a/docs/security.md b/docs/security.md index 8dd45dd..ff3c7a4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -52,7 +52,11 @@ The gateway makes outbound HTTP calls to URLs it was configured with. For v0.1: - backend URLs are **administrator-controlled configuration only**; - dynamic, agent- or user-controlled backend URLs are **forbidden** — no code path constructs a backend URL from request input beyond `{param}` substitution - into a configured template, with each value URL-encoded; + into a configured template, with each value URL-encoded. A parameter may only + appear once the authority is complete: a template whose `{param}` reaches the + scheme, host or port is refused at config load, because caller input would + otherwise choose which host the gateway calls and every path-position defence + (containment check, `encodeURIComponent`) is inert there; - **redirects are not followed** (`redirect: 'manual'`); a 3xx is a `BACKEND_ERROR`; - every call is bounded by an explicit timeout. @@ -88,7 +92,7 @@ Validated at the boundary, before anything else happens: | Input | Check | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| resource input | JSON Schema from the resource definition, closed by default at every level: an object schema — root, nested under `properties`, or nested under `items` — that omits `additionalProperties` gets `additionalProperties: false` stamped on recursively at config load, not just at the root; an operator who sets it explicitly (including explicitly to `true`) is respected at whichever level they set it. A resource that declares no `input:` at all gets an empty closed schema, not an always-valid one — declaring nothing means accepting nothing. Unknown properties, including prototype-named keys (`__proto__`, `constructor`, …), are matched by own-property lookup only. | +| resource input | JSON Schema from the resource definition, closed by default at every level: an object schema — root, nested under `properties`, nested under `items`, or nested under an `additionalProperties` subschema — that omits `additionalProperties` gets `additionalProperties: false` stamped on recursively at config load, not just at the root. That enumeration was written from the stamper rather than from the validator and drifted three times; it now matches what `compileJsonSchema` actually recurses into; an operator who sets it explicitly (including explicitly to `true`) is respected at whichever level they set it. A resource that declares no `input:` at all gets an empty closed schema, not an always-valid one — declaring nothing means accepting nothing. Unknown properties, including prototype-named keys (`__proto__`, `constructor`, …), are matched by own-property lookup only. | | path parameters | URL-encoded on substitution | | body size | capped at 256 KB, one number for both surfaces, enforced in two different places: Fastify's `bodyLimit` runs inside a body parser on the HTTP routes; `/mcp` deliberately installs a no-op parser so the MCP transport can read the raw stream, so the mount enforces its own byte count instead. A cap that only protects one of two entry points, or two caps that can silently drift apart, is how `/mcp` ended up with no cap at all in the first place. | | content type | JSON enforced on the invoke routes. **Not** on `/mcp`, where a wildcard no-op parser hands the raw stream to the MCP SDK and the SDK does its own enforcement. | @@ -156,14 +160,13 @@ The backend's **status code** is returned — that is ours to state and useful and the body is logged server-side at debug level only. The same rule governs health and readiness detail: a fixed vocabulary on the -wire, raw messages to the log. See -; the rule generalises to any -value crossing a trust boundary. +wire, raw messages to the log; the rule generalises to any value crossing a +trust boundary. ## Denial of service -Not a focus of v0.1, but more is in place than this section used to list -. What exists: +Not a focus of this release, but more is in place than this section used to +list. What exists: - request body-size cap, enforced inside the body parsers - a **1 MB cap on the merchant backend's *response*** — `AbortSignal.timeout` @@ -260,12 +263,17 @@ rejection outcomes assert that balances did not move. | **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 | | expired authorisation (`validBefore` in the past) | refused before settlement | `tests/e2e/payment` | +| not-yet-valid authorisation (`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 hostile or unbounded facilitator rejection string | clamped before it reaches buyer, event or ledger | `tests/unit/payments-x402` | | facilitator timeout | `PAYMENT_PROVIDER_UNAVAILABLE`, settlement treated as *uncertain* | `tests/unit/payments-x402` | | **facilitator 401 / 5xx** | `PAYMENT_PROVIDER_UNAVAILABLE`, never charged to the buyer | `tests/integration/adversarial-payment.test.ts` | | **malformed facilitator response** | refused; never read as a verdict | same | | backend timeout | `BACKEND_TIMEOUT` | `tests/unit/core/execution` | | backend 500 after payment | receipt records paid-and-undelivered; payer told it settled | `tests/unit/gateway`, `tests/unit/core/execution` | | receipt-store failure | `STORAGE_ERROR`, never mislabelled `PAYMENT_REPLAYED` | `tests/unit/storage-receipts` | +| a local reader racing the ledger's creation | database and sidecars are 0600 from the moment SQLite opens them | `tests/unit/storage-receipts/permissions.test.ts` | | RPC unreachable during verify | `PAYMENT_PROVIDER_UNAVAILABLE`, not "bad signature" | `tests/unit/payments-x402` | Two of those exist because writing them found a bug. The SDK's `exact`/EVM diff --git a/src/config/env.ts b/src/config/env.ts index d06db8c..9eb1940 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -15,6 +15,20 @@ * `${VAR:-default}` falls back to `default` when `VAR` is absent OR empty, * matching common shell semantics. * + * Anything else brace-shaped is refused: `${VAR-x}`, `${VAR:=x}`, `${VAR:?x}` + * are valid *shell* and would otherwise load as literal strings. That is + * harmless where a downstream check rejects the literal, and quietly wrong for + * a free-string field — `adminToken: ${ADMIN_TOKEN-fallback}` would run the + * gateway with that literal as the ledger credential while the operator + * believed an env secret gated it. A bare `$VAR` is deliberately left alone: it + * is not brace-shaped, and refusing it would reject values that legitimately + * contain a dollar sign. + * + * Every rule above is decided from the **template**, before substitution. + * Deciding afterwards is how `${A:-${B}}` used to corrupt a value on the one + * branch its guard did not cover, and how a resolved secret containing a + * `${…}`-shaped fragment used to get quoted back in the error text. + * * The resolved *value* of a variable is never included in any error message — * only the variable *name* and the config path are. */ @@ -22,6 +36,15 @@ import { CommerceError } from '../core/index.js'; const PLACEHOLDER_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}/g; +/** An innermost brace token: `${` … `}` with no braces between. */ +const BRACE_TOKEN = /\$\{[^{}]*\}/g; + +/** The same grammar as PLACEHOLDER_PATTERN, anchored, for validating one token. */ +const SUPPORTED_TOKEN = /^\$\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}$/; + +/** A placeholder whose default segment opens another placeholder. */ +const NESTED_PLACEHOLDER = /\$\{[^{}]*\$\{/; + export function substituteEnv(value: unknown, env: NodeJS.ProcessEnv, path = '$'): unknown { if (typeof value === 'string') { return substituteString(value, env, path); @@ -40,28 +63,44 @@ export function substituteEnv(value: unknown, env: NodeJS.ProcessEnv, path = '$' } function substituteString(value: string, env: NodeJS.ProcessEnv, path: string): string { - const substituted = substituteOnce(value, env, path); + assertTemplateIsSupported(value, path); + return substituteOnce(value, env, path); +} + +/** + * Refuses a template this module cannot honour, before any substitution runs. + * + * `${A:-${B}}` is the case that motivated this. `[^}]*` cannot span the inner + * `}`, so the match consumes `${A:-${B` and leaves a stray `}` behind. When `A` + * is unset the leftover `${B}` was visible afterwards and got caught; when `A` + * is *set* — the normal case, and the whole reason someone writes a default — + * substitution succeeded and the stray `}` was appended to the resolved value + * with nothing to notice it. For `adminToken` that means the gateway compares + * against a credential the operator does not hold, with no diagnostic. + * + * Deciding from the template covers both branches with one rule, and means no + * error message is ever derived from a resolved value. + */ +function assertTemplateIsSupported(value: string, path: string): void { + if (NESTED_PLACEHOLDER.test(value)) { + throw new CommerceError( + 'CONFIG_INVALID', + `Configuration value at "${path}" nests placeholders (e.g. "\${A:-\${B}}"), which is not supported — the inner one is not resolved and its closing brace ends up in the value. Use a plain default, or set the variable.`, + { details: { path } }, + ); + } - // `${A:-${B}}` with A unset produced the literal string - // `${B}` — an unresolved placeholder that survived into the config, directly - // contradicting this module's promise that one fails loading immediately. - // (`[^}]*` cannot span the inner `}`, so the default captured is `${B`, and - // the trailing `}` is ordinary text.) Refusing is the fail-closed reading of - // that promise; resolving recursively would mean deciding what `${A:-${B:-c}}` - // and a self-reference should do, which no config here needs. A `${` with no - // closing brace is left alone — it is not a placeholder. - PLACEHOLDER_PATTERN.lastIndex = 0; - if (PLACEHOLDER_PATTERN.test(substituted)) { - PLACEHOLDER_PATTERN.lastIndex = 0; - const residual = PLACEHOLDER_PATTERN.exec(substituted)?.[0] ?? '${…}'; - PLACEHOLDER_PATTERN.lastIndex = 0; + BRACE_TOKEN.lastIndex = 0; + for (const [token] of value.matchAll(BRACE_TOKEN)) { + if (SUPPORTED_TOKEN.test(token)) continue; + // The token itself, never the surrounding value: this runs pre-substitution, + // so it cannot contain a resolved secret. throw new CommerceError( 'CONFIG_INVALID', - `Configuration value at "${path}" still contains the unresolved placeholder "${residual}" after substitution — nested placeholders (e.g. "\${A:-\${B}}") are not supported. Use a plain default, or set the variable.`, - { details: { path, residual } }, + `Configuration value at "${path}" contains "${token}", which is not a supported placeholder. Only "\${VAR}" and "\${VAR:-default}" are recognised; shell forms such as "\${VAR-default}", "\${VAR:=default}" and "\${VAR:?message}" would load as literal text.`, + { details: { path, token } }, ); } - return substituted; } function substituteOnce(value: string, env: NodeJS.ProcessEnv, path: string): string { diff --git a/src/config/schema.ts b/src/config/schema.ts index b21c928..4ca2624 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -112,13 +112,60 @@ const StorageSchema = z }) .strict(); +/** + * Every path the gateway registers itself (`src/gateway/routes.ts`). An MCP + * mount that equals one of these makes Fastify's `.all()` a duplicate of the + * registered route; one that is a path-prefix of them swallows their 404s + * through the mount's `${mountPath}/*` wildcard. + */ +const RESERVED_GATEWAY_PATHS = [ + '/health', + '/ready', + '/.well-known/agent-commerce', + '/api/resources', + '/api/resources/:id/invoke', + '/api/receipts', + '/api/events', + '/api/events/stream', +] as const; + +/** + * `mountPath` reaches Fastify as a route pattern, and a bad one throws inside + * route registration — deferred to `server.ready()`, so `createGateway` fails + * wholesale with an opaque `FST_ERR_*` instead of the adapter alone degrading. + * Catching the shape here turns that into a CONFIG_INVALID naming the value. + * Fastify pattern syntax (`:param`, `*`) is rejected rather than supported: + * the mount registers its own wildcard, so a pattern here has no meaning. + */ +const McpMountPathSchema = 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".`, + }, + ); + const ProtocolsSchema = z .object({ http: z.object({ enabled: BooleanOrString }).strict(), mcp: z .object({ enabled: BooleanOrString, - mountPath: z.string().min(1), + mountPath: McpMountPathSchema, }) .strict(), }) @@ -730,6 +777,18 @@ function defaultClosedObjectSchema(schema: Record): Record { const adapters = await Promise.all( - options.adapterRuntimes.map(async (runtime) => ({ - ...runtime.adapter.descriptor, - health: await getAdapterHealth(runtime, options.clock), - })), + options.adapterRuntimes.map(async (runtime) => { + const health = await getAdapterHealth(runtime, options.clock); + return { ...runtime.adapter.descriptor, health: publicHealth(health) }; + }), ); const x402 = options.config.payments.x402; diff --git a/src/index.ts b/src/index.ts index be14f23..4446bef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,9 +13,9 @@ * * **The protocol adapter and the payment provider are not here.** They live at * `@devlab.group/agent-commerce/mcp` and `@devlab.group/agent-commerce/x402`, because each needs - * an optional peer dependency the rest of the package does not: importing - * keeping them here would make `createGateway` drag in a browser wallet stack - * and 340 packages. See + * an optional peer dependency the rest of the package does not. Keeping them + * here would make `createGateway` drag the whole EVM signing and RPC stack into + * every install, including one serving a single free HTTP resource. See * `src/mcp.ts` and `src/x402.ts`. Everything reachable from *this* entry point * needs only the package's own `dependencies`. */ diff --git a/src/payments/x402/chain.ts b/src/payments/x402/chain.ts index f7883e6..e753ccd 100644 --- a/src/payments/x402/chain.ts +++ b/src/payments/x402/chain.ts @@ -71,9 +71,17 @@ export function chainIdFromCaip2(network: string): number | undefined { return Number.isSafeInteger(chainId) && chainId > 0 ? chainId : undefined; } -export function buildLocalChain(rpcUrl: string): Chain { +/** + * `chainId` defaults to the local dev chain because the *facilitator* client + * can only ever exist there — a local facilitator is refused on any mainnet. + * The read-only health client is different: it is built for whatever network + * is configured, including Base, and inherited 84532 on every one of them. + * Inert while it only issues raw `getChainId`/`getCode`/`anvil_nodeInfo`, and + * wrong the moment anything through it consults `chain.id`. + */ +export function buildLocalChain(rpcUrl: string, chainId: number = LOCAL_CHAIN_ID): Chain { return { - id: LOCAL_CHAIN_ID, + id: chainId, name: 'agent-commerce-local', nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, rpcUrls: { @@ -92,9 +100,13 @@ export function buildLocalChain(rpcUrl: string): Chain { * only stops *waiting* for the request while the request itself keeps * running server-side. Omit it to keep viem's own default (10s). */ -export function createLocalPublicClient(rpcUrl: string, timeoutMs?: number): PublicClient { +export function createLocalPublicClient( + rpcUrl: string, + timeoutMs?: number, + chainId?: number, +): PublicClient { return createPublicClient({ - chain: buildLocalChain(rpcUrl), + chain: buildLocalChain(rpcUrl, chainId), transport: http(rpcUrl, timeoutMs !== undefined ? { timeout: timeoutMs } : undefined), }); } diff --git a/src/payments/x402/dev-key-guard.ts b/src/payments/x402/dev-key-guard.ts index b0b4a08..d2723dc 100644 --- a/src/payments/x402/dev-key-guard.ts +++ b/src/payments/x402/dev-key-guard.ts @@ -65,11 +65,21 @@ function parseHostnameOrThrow(rpcUrl: string): string { try { return new URL(rpcUrl).hostname; } catch (cause) { - throw new CommerceError( - 'CONFIG_INVALID', - `x402 provider: rpcUrl "${rpcUrl}" is not a valid URL`, - { cause }, - ); + // Not the URL itself: every commercial EVM RPC provider embeds an API key + // in the path, and these messages reach `validate`, `doctor`, startup + // output and CI logs. + throw new CommerceError('CONFIG_INVALID', 'x402 provider: rpcUrl is not a valid URL', { + cause, + }); + } +} + +/** Host only, for diagnostics. An RPC URL's path routinely carries a key. */ +function describeRpc(rpcUrl: string): string { + try { + return new URL(rpcUrl).origin; + } catch { + return '[unparseable rpcUrl]'; } } @@ -101,7 +111,7 @@ export function assertDevKeyIsLocalOnly(rpcUrl: string, signerPrivateKey: string throw new CommerceError( 'CONFIG_INVALID', 'x402 provider: facilitator.signerPrivateKey is a well-known Anvil development key, but rpcUrl ' + - `"${rpcUrl}" does not look like a local/private chain. A public dev key must never sign against ` + + `${describeRpc(rpcUrl)} does not look like a local/private chain. A public dev key must never sign against ` + 'a public network — it is instantly drainable and can grief real settlements via nonce exhaustion. ' + 'Use a real facilitator key for any non-local RPC, or point rpcUrl at your local/dev chain.', ); @@ -130,7 +140,7 @@ export function assertPayToIsNotDevAddress(rpcUrl: string, payTo: string): void throw new CommerceError( 'CONFIG_INVALID', `x402 provider: "payTo" (${payTo}) is a well-known Anvil development address, but rpcUrl ` + - `"${rpcUrl}" does not look like a local/private chain. The private key behind that address ` + + `${describeRpc(rpcUrl)} does not look like a local/private chain. The private key behind that address ` + 'is public knowledge, so any revenue settled to it is immediately spendable by anyone. Set ' + '"payTo" to your own merchant wallet for any non-local RPC, or point rpcUrl at your ' + 'local/dev chain.', diff --git a/src/payments/x402/guardrails.ts b/src/payments/x402/guardrails.ts index 82be9a6..a34a619 100644 --- a/src/payments/x402/guardrails.ts +++ b/src/payments/x402/guardrails.ts @@ -148,6 +148,17 @@ export function resolveX402Deployment(input: X402DeploymentInput): X402Deploymen // `dev-key-guard` already refuses one against a public *RPC*; this refuses // one on any non-local *deployment*, which is the case a remote facilitator // creates — there the RPC host says nothing about where settlement lands. + // The zero address lived only in the config loader, so a library consumer + // calling `createX402PaymentProvider` directly — the path this shared + // definition exists to cover — could boot a mainnet provider whose every + // payment burns. Round 6's lesson, regressed for one check. + if (/^0x0{40}$/i.test(input.payTo)) { + throw invalid( + 'payments.x402: "payTo" is the zero address. Every payment settled there is destroyed.', + 'payments.x402.payTo', + ); + } + if (mode !== 'local' && isWellKnownDevAddress(input.payTo)) { throw invalid( `payments.x402: "payTo" (${input.payTo}) is a well-known Anvil development address and this is a ${mode} deployment. Anyone can spend what settles there. Set payTo to your own merchant wallet.`, @@ -197,24 +208,26 @@ function assertFacilitatorUrlIsSafe(url: string, mode: DeploymentMode): void { try { parsed = new URL(url); } catch (cause) { - throw new CommerceError( - 'CONFIG_INVALID', - `payments.x402: facilitator.url "${url}" is not a valid URL`, - { cause, details: { path: 'payments.x402.facilitator.url' } }, - ); + throw new CommerceError('CONFIG_INVALID', `payments.x402: facilitator.url is not a valid URL`, { + cause, + details: { path: 'payments.x402.facilitator.url' }, + }); } if (parsed.protocol === 'https:') return; if (parsed.protocol !== 'http:') { throw invalid( - `payments.x402: facilitator.url must be https (or http on a local/private host); got "${parsed.protocol}//"`, + `payments.x402: facilitator ${describeOrigin(url)} must be reached over https (or http on a local/private host); got "${parsed.protocol}//"`, 'payments.x402.facilitator.url', ); } if (mode !== 'mainnet' && isLikelyLocalOrPrivateHost(parsed.hostname)) return; + // The origin, never the whole URL: the path can carry a tenant or an API + // key, which is why `/.well-known` withholds this field entirely. A refusal + // message goes to `validate`, `doctor`, startup output and CI logs. throw invalid( - `payments.x402: facilitator.url "${url}" uses plain HTTP. Payment authorisations and settlement results would travel unencrypted. Use https, or point at a local/private host on a non-mainnet deployment.`, + `payments.x402: facilitator ${describeOrigin(url)} is reached over plain HTTP. Payment authorisations and settlement results would travel unencrypted. Use https, or point at a local/private host on a non-mainnet deployment.`, 'payments.x402.facilitator.url', ); } diff --git a/src/payments/x402/provider.ts b/src/payments/x402/provider.ts index 1d3d055..67f81a5 100644 --- a/src/payments/x402/provider.ts +++ b/src/payments/x402/provider.ts @@ -75,6 +75,23 @@ 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'; +/** + * `invalidReason`/`errorReason` are `z.string()` in the SDK schema — no length + * or charset bound. They become the message a buyer is told about their own + * payment, a persisted and SSE-streamed event field, and a column in the + * merchant's ledger. A remote facilitator is a counterparty this design + * explicitly contemplates having no account or terms with, so treat its + * strings as untrusted input rather than as diagnostics. + */ +const MAX_REASON_LENGTH = 64; +const REASON_SHAPE = /^[a-z0-9_.-]+$/i; + +function sanitiseReason(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 { /** @@ -203,7 +220,11 @@ export function createX402PaymentProvider(options: X402ProviderOptions): Payment // createLocalPublicClient's doc comment). Verification and settlement read // the chain through the facilitator signer instead, so this is the only // public client the provider builds. - const healthPublicClient = createLocalPublicClient(options.rpcUrl, HEALTH_TIMEOUT_MS); + const healthPublicClient = createLocalPublicClient( + options.rpcUrl, + HEALTH_TIMEOUT_MS, + profile.chainId, + ); const clock = options.clock ?? systemClock; const ids = options.ids ?? DEFAULT_IDS; @@ -310,13 +331,13 @@ export function createX402PaymentProvider(options: X402ProviderOptions): Payment * data; the SDK's scheme facilitator likewise takes the requirements as an * explicit argument rather than reading them off the payload. * - * Deliberately accepts overpayment: only `authorizedValue < required` is - * rejected (`wrong_amount`), never `authorizedValue > required`. This - * matches x402's own semantics — the requirement's `amount` names a floor, - * not an exact amount — and the settled `PaymentResult.amount` always - * records the actual authorised value, so bookkeeping stays truthful to what - * really moved rather than silently topping up or rejecting a generous - * buyer. + * The amount must match **exactly**. This once accepted overpayment on the + * reading that `amount` names a floor — but the pinned `@x402/evm@2.23.0` + * exact/EVM scheme compares with `!==` and rejects anything else as + * `invalid_exact_evm_payload_authorization_value_mismatch`, in both verify + * and settle, and hosted facilitators run that same code. Accepting more + * here only moved the rejection downstream and turned a clean + * `wrong_amount` into an opaque SDK reason. */ async function verify(context: PaymentVerificationContext): Promise { const { requirement, submission } = context; @@ -398,8 +419,11 @@ export function createX402PaymentProvider(options: X402ProviderOptions): Payment } catch { return rejected('malformed_payment_payload'); } - if (authorizedValue < required) { - return rejected('wrong_amount'); // overpayment is allowed through — see the doc comment above + if (authorizedValue !== required) { + // Both directions: the scheme enforces equality, so an overpayment is + // rejected here with a reason the buyer can act on rather than by the + // facilitator with one they cannot. + return rejected('wrong_amount'); } const scope = binding.open(); @@ -429,7 +453,15 @@ export function createX402PaymentProvider(options: X402ProviderOptions): Payment { details: { reportedReason: sdkResult.invalidReason ?? 'invalid_payment' } }, ); } - return rejected(sdkResult.invalidReason ?? 'invalid_payment'); + // The raw string still reaches the operator's logs below; only what is + // shown to the buyer and written to the ledger is constrained. + if (sdkResult.invalidReason !== undefined) { + logger.debug( + { reportedReason: sdkResult.invalidReason }, + 'x402 verify(): facilitator rejection reason', + ); + } + return rejected(sanitiseReason(sdkResult.invalidReason, 'invalid_payment')); } // Derived from the chain id the authorisation is actually bound to, never @@ -536,8 +568,14 @@ export function createX402PaymentProvider(options: X402ProviderOptions): Payment }, ); } + if (sdkResult.errorReason !== undefined) { + logger.debug( + { reportedReason: sdkResult.errorReason }, + 'x402 settle(): facilitator failure reason', + ); + } return { - ...rejectedSettlement(sdkResult.errorReason ?? 'settlement_failed'), + ...rejectedSettlement(sanitiseReason(sdkResult.errorReason, 'settlement_failed')), network: sdkResult.network, ...(sdkResult.payer !== undefined ? { payer: sdkResult.payer } : {}), }; diff --git a/src/storage/receipts/store.ts b/src/storage/receipts/store.ts index 2567bd6..3e301bf 100644 --- a/src/storage/receipts/store.ts +++ b/src/storage/receipts/store.ts @@ -5,7 +5,15 @@ * statement; the only thing that varies between calls is parameters. */ import { randomUUID } from 'node:crypto'; -import { accessSync, chmodSync, existsSync, constants as fsConstants, mkdirSync } from 'node:fs'; +import { + accessSync, + chmodSync, + closeSync, + existsSync, + constants as fsConstants, + mkdirSync, + openSync, +} from 'node:fs'; import { dirname } from 'node:path'; import Database from 'better-sqlite3'; import { @@ -90,8 +98,11 @@ function isUniqueConstraintOn(err: unknown, column: string): boolean { * `redact.ts` is recursive), but business metadata all the same. * * Called *after* `journal_mode = WAL`, because the sidecars do not exist until - * then. Best-effort by design: a filesystem without POSIX modes must not stop - * the gateway from starting, so a failure warns rather than throws. + * then. Belt-and-braces since the main file is now pre-created at 0600 (see + * `createSqliteReceiptStore`): this still tightens a database that already + * existed with a looser mode. Best-effort by design: a filesystem without + * POSIX modes must not stop the gateway from starting, so a failure warns + * rather than throws. */ function restrictDatabasePermissions(path: string, logger: Logger): void { for (const file of [path, `${path}-wal`, `${path}-shm`]) { @@ -144,6 +155,23 @@ export function createSqliteReceiptStore(options: SqliteReceiptStoreOptions): Re } } + // Create the file ourselves, at 0600, *before* SQLite can create it at + // `0666 & ~umask`. The chmod below only narrows the mode after the fact: a + // local co-tenant who opens the file inside that window keeps a readable + // descriptor across the chmod and reads every receipt written afterwards. + // SQLite copies the main file's mode onto the -wal/-shm sidecars, so this + // one call covers all three. `mode` applies on creation only, so an + // existing file is untouched here and left to the chmod. + if (isFileBacked) { + try { + closeSync(openSync(path, 'a', 0o600)); + } catch { + // Deliberately silent: whatever stopped us (unwritable directory, + // exotic filesystem) is about to be reported by `new Database` with a + // better message, or is harmless and covered by the chmod below. + } + } + const db = new Database(path); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); diff --git a/tests/e2e/payment/x402-settlement.e2e.test.ts b/tests/e2e/payment/x402-settlement.e2e.test.ts index 6013d0f..1604267 100644 --- a/tests/e2e/payment/x402-settlement.e2e.test.ts +++ b/tests/e2e/payment/x402-settlement.e2e.test.ts @@ -338,6 +338,26 @@ describe('x402 settlement — real local chain', () => { expect(after).toEqual(before); }); + it('9b. an authorisation that is not yet valid (validAfter in the future) is rejected before settlement', async () => { + // The mirror image of test 9, and the half that had no test: EIP-3009 + // bounds an authorisation at both ends, `MockUSDC` enforces both, and the + // SDK has its own `ErrValidAfterInFuture`. Untested enforcement is + // indistinguishable from absent enforcement. + const before = await balances(); + const notYetValid = Math.floor(Date.now() / 1000) + 3600; + const { requirement, proof } = await buildValidProof('1.00', { validAfter: notYetValid }); + + const verifyResult = await provider.verify({ + requestId: requirement.requestId, + resource: RESOURCE, + requirement, + submission: { method: 'x402', payload: proof }, + }); + expect(verifyResult.status).toBe('rejected'); + const after = await balances(); + expect(after).toEqual(before); + }); + it('10. provider failure: RPC unreachable yields PAYMENT_PROVIDER_UNAVAILABLE, not a silent pass', async () => { const unavailableProvider = createX402PaymentProvider({ network: 'eip155:84532', diff --git a/tests/testnet/base-sepolia.smoke.test.ts b/tests/testnet/base-sepolia.smoke.test.ts index 7abf67b..d178897 100644 --- a/tests/testnet/base-sepolia.smoke.test.ts +++ b/tests/testnet/base-sepolia.smoke.test.ts @@ -5,7 +5,9 @@ * Deliberately NOT part of `npm test` or `npm run test:e2e`: those must stay * deterministic and offline. This one spends real testnet USDC, talks to a * public RPC and a hosted facilitator, and is run on demand - * (`npm run test:testnet`) or from the manual GitHub Actions workflow. + * (`npm run test:testnet`) from the machine that holds the wallet. Never + * triggered by a push or a pull request: there is no workflow for it and there + * must not be one, because that would mean a funded key in repository secrets. * * It skips itself — loudly, naming the variable — when the credentials are * absent, so a developer who runs it by accident gets an explanation rather diff --git a/tests/unit/cli/packaging.test.ts b/tests/unit/cli/packaging.test.ts index 3b98e1d..48bb483 100644 --- a/tests/unit/cli/packaging.test.ts +++ b/tests/unit/cli/packaging.test.ts @@ -308,11 +308,21 @@ describe.skipIf(!existsSync(libEntry))('built library entry', () => { const version = load( "const s = m.receipts({ path: ':memory:' }); process.stdout.write(s.descriptor.implementationVersion)", ); + const manifestVersion = ( + JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')) as { version: string } + ).version; expect(version).not.toContain('0.0.0-unknown'); - expect(version).toBe( - (JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')) as { version: string }) - .version, - ); + // The likeliest cause of a mismatch here is a stale `dist/` — the version + // is injected by tsup at build time, so a bundle built before a version + // change keeps reporting the old one and turns the whole suite red for a + // reason that has nothing to do with the source. Say so in the failure, + // because the assertion alone reads like a source bug. + expect( + version, + `built bundle reports "${version}" but package.json says "${manifestVersion}". ` + + 'The version is injected at build time, so `dist/` is almost certainly stale — ' + + 'run `npm run build` and re-run this test.', + ).toBe(manifestVersion); }); }); diff --git a/tests/unit/config/env.test.ts b/tests/unit/config/env.test.ts index e97e94e..d6ba116 100644 --- a/tests/unit/config/env.test.ts +++ b/tests/unit/config/env.test.ts @@ -77,17 +77,51 @@ describe('substituteEnv', () => { }); describe('nested placeholders and the unresolved-placeholder promise', () => { - // The module header promises an unresolved placeholder fails loading - // immediately. `${A:-${B}}` produced the literal string `${B}` instead: - // `[^}]*` cannot span the inner `}`, so the default captured is `${B` and - // the trailing `}` is ordinary text. Silently passing an unresolved - // placeholder into a payment configuration is the failure mode the promise - // exists to prevent, so this is now an error either way. + // `${A:-${B}}` is not resolvable: `[^}]*` cannot span the inner `}`, so the + // match consumes `${A:-${B` and a stray `}` is left as ordinary text. + // + // The first fix caught this by scanning the *result*, which only works on the + // branch where `A` is unset — the leftover `${B}` is visible. With `A` set — + // the normal case, and the entire reason someone writes a default — + // substitution succeeded and the stray `}` was appended to the value with + // nothing to notice it. For `adminToken` that means the gateway compares + // against a credential the operator does not hold. So the decision moved to + // the *template*, and `A` set is the case that matters most here. it.each([ ['neither variable set', {}], ['the inner variable set', { B: 'bee' }], + ['the outer variable set — the branch the result-scan missed', { A: 'real-secret-token' }], + ['both set', { A: 'real-secret-token', B: 'bee' }], ])('rejects ${A:-${B}} when %s', (_label, env) => { - expect(() => substituteEnv({ x: '${A:-${B}}' }, env)).toThrowError(/unresolved placeholder/); + expect(() => substituteEnv({ x: '${A:-${B}}' }, env)).toThrowError(/nests placeholders/); + }); + + it('never quotes a resolved value back in an error', () => { + // The result-scan reproduced a verbatim fragment of the resolved secret + // into the message and into `details`. Deciding from the template means a + // value that merely looks like a placeholder is passed through untouched. + expect(substituteEnv({ x: '${SECRET}' }, { SECRET: 'prefix-${INNER}-suffix' })).toEqual({ + x: 'prefix-${INNER}-suffix', + }); + }); + + // Valid shell, unsupported here, and previously loaded as literal text. Most + // fields reject the literal downstream; `adminToken` does not, and would run + // the gateway with `"${ADMIN_TOKEN-fallback}"` as the ledger credential while + // the operator believed an env secret gated it. + it.each(['${VAR-default}', '${VAR:=default}', '${VAR:?message}'])( + 'rejects the unsupported shell form %s rather than loading it literally', + (template) => { + expect(() => substituteEnv({ x: template }, { VAR: 'v' })).toThrowError( + /not a supported placeholder/, + ); + }, + ); + + it('control: a bare $VAR is left alone — it is not brace-shaped', () => { + expect(substituteEnv({ x: 'pa$$word and $VAR' }, { VAR: 'v' })).toEqual({ + x: 'pa$$word and $VAR', + }); }); it('control: a plain default still resolves', () => { diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index 3a2a25c..2ca8334 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -177,6 +177,104 @@ describe('parseConfig', () => { }); }); + describe('backend.url templating', () => { + function withBackendUrl(url: string): Record { + const raw = validRawConfig(); + const resources = raw['resources'] as Record>; + resources['templated'] = { + name: 'Templated', + input: { type: 'object', properties: { host: { type: 'string' } }, required: ['host'] }, + backend: { type: 'http', method: 'GET', url }, + pricing: { type: 'free' }, + expose: ['http'], + }; + return raw; + } + + /** + * Every other defence around `{param}` guards the path position, and in the + * host position all of them fail open at once: `new URL('http://{host}/api')` + * parses so the URL check passes; the runtime containment check is skipped + * because its literal prefix (`http://`) does not itself parse as a URL; and + * `encodeURIComponent` does not escape dots, so a hostname survives whole. + * Caller input would then choose which host the gateway calls — the cloud + * metadata service, an internal address, anything. + */ + it.each([ + ['the whole host', 'http://{host}/api'], + ['a host prefix', 'http://{tenant}.api.internal/v1'], + ['host and port', 'http://{host}:8080/api'], + ])('refuses a parameter that spans %s', (_label, url) => { + expectConfigInvalid(() => parseConfig(withBackendUrl(url), {})); + try { + parseConfig(withBackendUrl(url), {}); + } catch (error) { + if (isCommerceError(error)) { + expect(error.message).toContain('before the end of the host'); + } + } + }); + + it('refuses a parameter in the scheme, via the absolute-URL check', () => { + // Refused one check earlier: `{scheme}://…` parses with protocol + // `{scheme}:`, which is neither http nor https. Same outcome, different + // message, and asserted separately so a change to either is visible. + expectConfigInvalid(() => parseConfig(withBackendUrl('{scheme}://backend.local/api'), {})); + }); + + it.each([ + ['a path segment', 'http://backend.local/user/{host}'], + ['a query value', 'http://backend.local/search?q={host}'], + ['the whole path', 'http://backend.local/{host}'], + ])('still accepts a parameter in %s', (_label, url) => { + expect(() => parseConfig(withBackendUrl(url), {})).not.toThrow(); + }); + }); + + describe('closed-schema stamping', () => { + /** + * The stamper's third drift from the validator, after `required` and tuple + * `items`. `additionalProperties: {schema}` is the idiomatic "map of typed + * objects" shape and the validator applies that subschema recursively — so + * without recursion here, every node beneath it stayed open and unknown + * keys reached the merchant's backend. + */ + it('closes objects nested under an additionalProperties subschema', () => { + const raw = validRawConfig(); + const resources = raw['resources'] as Record>; + resources['mapped'] = { + name: 'Mapped', + input: { + type: 'object', + properties: { + meta: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { name: { type: 'string' } }, + }, + }, + }, + }, + backend: { type: 'http', method: 'GET', url: 'http://localhost:3000/x' }, + pricing: { type: 'free' }, + expose: ['http'], + }; + const config = parseConfig(raw, {}); + const schema = config.resources.find((r) => r.id === 'mapped')?.inputSchema as Record< + string, + unknown + >; + const meta = (schema['properties'] as Record>)['meta']; + const valueSchema = meta?.['additionalProperties'] as Record; + expect(valueSchema['additionalProperties']).toBe(false); + + const validate = compileJsonSchema(schema); + expect(validate({ meta: { any: { name: 'ok', SMUGGLED: 'x' } } }).valid).toBe(false); + expect(validate({ meta: { any: { name: 'ok' } } }).valid).toBe(true); + }); + }); + describe('x402 deployment guardrails', () => { const MERCHANT = '0x1111111111111111111111111111111111111111'; const BASE_USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; @@ -419,7 +517,7 @@ describe('parseConfig', () => { messageFor( withX402({ payTo: MERCHANT, facilitator: { mode: 'remote', url: 'ftp://x402.invalid' } }), ), - ).toContain('must be https'); + ).toContain('must be reached over https'); expect( messageFor( withX402({ payTo: MERCHANT, facilitator: { mode: 'remote', url: 'not a url' } }), @@ -1233,3 +1331,50 @@ describe('parseConfig', () => { } }); }); + +describe('protocols.mcp.mountPath', () => { + function withMountPath(mountPath: unknown): Record { + const raw = validRawConfig(); + (raw['protocols'] as { mcp: Record }).mcp['mountPath'] = mountPath; + return raw; + } + + // A bad mount only fails inside Fastify's route registration, deferred to + // server.ready(), which takes the whole gateway down with an opaque FST_ERR_* + // instead of degrading the one adapter. These must be CONFIG_INVALID at load. + it.each([ + ['no leading slash', 'mcp'], + ['a Fastify parameter', '/mcp/:id'], + ['a Fastify wildcard', '/mcp/*'], + ['a query marker', '/mcp?x=1'], + ['whitespace', '/mcp path'], + ['a trailing newline', '/mcp\n'], + ['a route the gateway serves', '/health'], + ['another route the gateway serves', '/api/receipts'], + ['a prefix of a gateway route', '/api'], + ['the root path, which is a prefix of everything', '/'], + ])('rejects %s', (_label, mountPath) => { + expectConfigInvalid(() => parseConfig(withMountPath(mountPath), {})); + }); + + it('names the offending field in the error', () => { + try { + parseConfig(withMountPath('/health'), {}); + expect.unreachable(); + } catch (error) { + if (isCommerceError(error)) { + expect(error.message).toContain('mountPath'); + } + } + }); + + it('control: an ordinary mount path still validates', () => { + const config = parseConfig(withMountPath('/mcp'), {}); + expect(config.protocols.mcp.mountPath).toBe('/mcp'); + }); + + it('control: a nested mount path outside the reserved prefixes still validates', () => { + const config = parseConfig(withMountPath('/agents/mcp'), {}); + expect(config.protocols.mcp.mountPath).toBe('/agents/mcp'); + }); +}); diff --git a/tests/unit/storage-receipts/permissions.test.ts b/tests/unit/storage-receipts/permissions.test.ts new file mode 100644 index 0000000..27bf775 --- /dev/null +++ b/tests/unit/storage-receipts/permissions.test.ts @@ -0,0 +1,75 @@ +/** + * The steady-state mode (0600 on the database and both sidecars) is asserted + * in persistence.test.ts. It cannot prove *ordering*, though: a chmod after + * `new Database` produces the same end state as pre-creating the file, while + * leaving a window in which a local co-tenant can open the ledger read-only + * and keep that descriptor across the chmod. Only a reading taken at the + * moment SQLite first opens the file distinguishes the two, so this file wraps + * the driver's constructor to take one. + */ +import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSqliteReceiptStore } from '../../../src/storage/receipts/index.js'; + +const observed = vi.hoisted(() => ({ modes: [] as (number | null)[] })); + +vi.mock('better-sqlite3', async (importOriginal) => { + const actual = (await importOriginal()) as { default: new (...args: never[]) => object }; + return { + default: new Proxy(actual.default, { + construct(target, args) { + const path = args[0]; + observed.modes.push( + typeof path === 'string' && path !== ':memory:' && existsSync(path) + ? statSync(path).mode & 0o777 + : null, + ); + return Reflect.construct(target, args); + }, + }), + }; +}); + +describe('the ledger is never world-readable, not even briefly', () => { + // Skipped on platforms without POSIX modes rather than asserting nonsense. + const posix = process.platform !== 'win32'; + + beforeEach(() => { + observed.modes.length = 0; + }); + + it.runIf(posix)('creates the database file at 0600 before SQLite opens it', () => { + const dir = mkdtempSync(join(tmpdir(), 'oac-precreate-')); + const dbPath = join(dir, 'receipts.sqlite'); + + const store = createSqliteReceiptStore({ path: dbPath }); + store.close(); + + // null would mean the file did not exist yet — i.e. SQLite created it + // itself, at `0666 & ~umask`. + expect(observed.modes).toEqual([0o600]); + expect(statSync(dbPath).mode & 0o777).toBe(0o600); + rmSync(dir, { recursive: true, force: true }); + }); + + it.runIf(posix)('reopens an existing database without disturbing it', () => { + const dir = mkdtempSync(join(tmpdir(), 'oac-reopen-')); + const dbPath = join(dir, 'receipts.sqlite'); + createSqliteReceiptStore({ path: dbPath }).close(); + + const store = createSqliteReceiptStore({ path: dbPath }); + store.close(); + + expect(observed.modes).toEqual([0o600, 0o600]); + expect(statSync(dbPath).mode & 0o777).toBe(0o600); + rmSync(dir, { recursive: true, force: true }); + }); + + it('does not pre-create anything for :memory:', () => { + const store = createSqliteReceiptStore({ path: ':memory:' }); + store.close(); + expect(existsSync(':memory:')).toBe(false); + }); +}); From de3938b1438bb2483e5d4919555f53e01e68a372 Mon Sep 17 00:00:00 2001 From: SergeevDmitry Date: Sun, 23 Aug 2026 23:54:44 +0200 Subject: [PATCH 6/6] chore: bump version --- .github/workflows/release.yml | 36 +--------------------- CONTRIBUTING.md | 2 +- README.md | 11 ++++--- SECURITY.md | 13 +++----- demo/dashboard/test/components.test.ts | 10 +++--- docs/configuration.md | 2 +- docs/contracts.md | 2 +- docs/contributing-adapters.md | 2 +- docs/protocols.md | 10 +++--- docs/security.md | 42 +++++++++----------------- package.json | 2 +- src/core/domain/common.ts | 4 +-- src/core/interfaces/store.ts | 2 +- src/gateway/well-known.ts | 2 +- src/payments/x402/chain.ts | 13 +++++--- src/payments/x402/guardrails.ts | 3 +- tests/conformance/mcp/fixtures.ts | 2 +- 17 files changed, 56 insertions(+), 102 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd9a0f6..ab3497d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,39 +127,5 @@ jobs: # repository recording it. - run: npm install -g npm@11.6.2 - run: npm ci - - name: Tag must match the version being published - # A tag that disagrees with package.json publishes a version nobody - # can find from the release, and npm versions are immutable. - run: | - tag="${GITHUB_REF_NAME#v}" - pkg="$(node -p "require('./package.json').version")" - if [ "$tag" != "$pkg" ]; then - echo "tag ${GITHUB_REF_NAME} does not match package.json version ${pkg}" >&2 - exit 1 - fi - echo "publishing ${pkg}" - # `prepublishOnly` runs build + verify + the built-CLI smoke test, so the - # artifact is rebuilt from this checkout rather than trusted from cache. - # `--access public` is not passed here: publishConfig.access carries it, - # and a packaging test asserts that, so the two cannot disagree. - # Provenance is attached automatically when publishing via OIDC. - # - # The dist-tag is derived from the version rather than hardcoded: npm >= 12 - # refuses to publish a prerelease without an explicit `--tag`, because the - # default is `latest` and that would serve a beta to everyone running a - # plain `npm install`. `0.2.0-beta.0` publishes under `beta`, `1.0.0-rc.1` - # under `rc`, and only a version with no prerelease part takes `latest`. - name: Publish - run: | - version="$(node -p "require('./package.json').version")" - case "$version" in - *-*) - suffix="${version#*-}" - tag="${suffix%%.*}" - ;; - *) - tag="latest" - ;; - esac - echo "publishing $version under dist-tag $tag" - npm publish --tag "$tag" + run: npm publish --tag latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dbe9eb4..50f8e0a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ architecture is deliberate and the scope is deliberately narrow. ## Ground rules -1. **Scope discipline is a release requirement.** v0.1 is MCP + x402 only. +1. **Scope discipline is a release requirement.** This release is MCP + x402 only. New protocols and rails land after the adapter model survives real use. Classify every proposal as `BLOCKER` / `QUALITY` / `NICE-TO-HAVE` / `POST-ALPHA` — the default answer to a new capability is `POST-ALPHA`. diff --git a/README.md b/README.md index 0f55707..5a88197 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,11 @@ Status

-> **Beta.** `v0.2.0-beta` is experimental. Do not use it with production funds -> without an independent review. See [SECURITY.md](SECURITY.md). +> **No commissioned security audit.** `v1.0.0` commits to a stable public API +> and wire contract; it makes no assurance claim about the payment path. There +> is no third-party audit report to point you at. Weigh that before putting +> production funds through it. +> See [SECURITY.md](SECURITY.md). --- @@ -401,8 +404,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). ## Roadmap -**Now (v0.2.0-beta)** — MCP, x402 v2, settlement on the local chain, Base -Sepolia and Base mainnet, receipts, doctor, deterministic demo. +**Now (v1.0.0)** — MCP, x402 v2, settlement on the local chain, Base Sepolia +and Base mainnet, receipts, doctor, deterministic demo. **Next** — OpenAPI import · a stronger conformance suite · a `doctor` GitHub Action · UCP · MPP · ACP · A2A · AP2 · Shopify and WooCommerce examples · diff --git a/SECURITY.md b/SECURITY.md index 1b955d7..9287735 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,9 @@ # Security Policy -> **Beta warning.** `v0.2.0-beta` is experimental software. Do not use it with -> production funds without an independent security review. +> **No commissioned security audit.** `v1.0.0` commits to a stable public API +> and wire contract; it makes no assurance claim about the payment path. There +> is no third-party audit report to point you at. Weigh that before putting +> production funds through it. ## Non-custodial by design @@ -172,13 +174,6 @@ backend or your business secure. proxy to your backend at whatever rate a caller chooses. Rate limiting, quotas and abuse controls belong in your API or your edge. -## Supported versions - -| Version | Supported | -| ------------ | ----------------------------- | -| `0.2.x-beta` | Latest beta only, best effort | -| `0.1.x` | Not supported | - ## Reporting a vulnerability Please report security issues **privately** — do not open a public issue. diff --git a/demo/dashboard/test/components.test.ts b/demo/dashboard/test/components.test.ts index 16a94ff..00b70e1 100644 --- a/demo/dashboard/test/components.test.ts +++ b/demo/dashboard/test/components.test.ts @@ -75,7 +75,7 @@ describe('StatusPanel', () => { wellKnown: { gateway: { implementationVersion: '0.1.0', - supportedSpec: 'agent-commerce/v0.2.0-beta', + supportedSpec: 'agent-commerce/v1.0.0', }, merchant: { id: 'demo', name: 'Demo Merchant', publicBaseUrl: 'http://localhost:8080' }, protocols: { http: { enabled: true }, mcp: { enabled: true, mountPath: '/mcp' } }, @@ -95,7 +95,7 @@ describe('StatusPanel', () => { wellKnown: { gateway: { implementationVersion: '0.1.0', - supportedSpec: 'agent-commerce/v0.2.0-beta', + supportedSpec: 'agent-commerce/v1.0.0', }, merchant: { id: 'demo', name: 'Demo Merchant', publicBaseUrl: 'http://localhost:8080' }, protocols: { http: { enabled: true }, mcp: { enabled: true, mountPath: '/mcp' } }, @@ -113,7 +113,7 @@ describe('StatusPanel', () => { wellKnown: { gateway: { implementationVersion: '0.1.0', - supportedSpec: 'agent-commerce/v0.2.0-beta', + supportedSpec: 'agent-commerce/v1.0.0', }, merchant: { id: 'demo', name: 'Demo Merchant', publicBaseUrl: 'http://localhost:8080' }, protocols: { http: { enabled: true }, mcp: { enabled: true, mountPath: '/mcp' } }, @@ -145,7 +145,7 @@ describe('StatusPanel', () => { wellKnown: { gateway: { implementationVersion: '0.1.0', - supportedSpec: 'agent-commerce/v0.2.0-beta', + supportedSpec: 'agent-commerce/v1.0.0', }, merchant: { id: 'demo', name: 'Demo Merchant', publicBaseUrl: 'http://localhost:8080' }, protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } }, @@ -247,7 +247,7 @@ describe('ReceiptList', () => { }); // A settled payment with a non-2xx backendStatus is the one row an operator - // must be able to spot — v0.1 has no refunds, so seeing it is the only + // must be able to spot — there are no refunds, so seeing it is the only // remedy. Without this, it renders identically to a successful delivery. describe('paid-but-undelivered visibility', () => { it('renders a settled+500 receipt as not delivered and highlights it for attention', () => { diff --git a/docs/configuration.md b/docs/configuration.md index b037895..0c10370 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -54,7 +54,7 @@ resources: headers: # secrets by reference only Authorization: Bearer ${BACKEND_TOKEN} pricing: - type: fixed # free | fixed (dynamic is rejected in v0.1) + type: fixed # free | fixed (dynamic is rejected) amount: "0.01" # decimal string, display units, never a float currency: USDC expose: [http, mcp] diff --git a/docs/contracts.md b/docs/contracts.md index ba52b16..0716cc3 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -57,7 +57,7 @@ the generated file is right and this table is stale. ## Change log - Initial freeze (v0.1.0-alpha) -- UCP removed from v0.1 scope; `ProtocolName` = `'http' | 'mcp'` +- UCP removed from scope; `ProtocolName` = `'http' | 'mcp'` - `core` adds `./execution` subpath (non-frozen) - `payment-x402` adds `./testing.js` subpath (non-frozen); becomes the canonical import path for `readLocalChainManifest` - **Behaviour change (no type change):** `toCommerceError` no longer copies an arbitrary Error's `message` into the client-visible `message`. The original is kept on `cause`, which is never serialised. Found in the contract-freeze adversarial review. diff --git a/docs/contributing-adapters.md b/docs/contributing-adapters.md index 43cbca8..b74e724 100644 --- a/docs/contributing-adapters.md +++ b/docs/contributing-adapters.md @@ -32,7 +32,7 @@ export function createExampleAdapter: HttpProtocolAdapter { descriptor: { name: 'example', kind: 'protocol', - implementationVersion: '0.2.0-beta.0', + implementationVersion: '1.0.0', supportedSpec: 'example-spec@2026-01-01', // pin it, do not hand-wave capabilities: ['discovery', 'invoke'], unsupported: ['subscriptions', 'batch'], // be explicit diff --git a/docs/protocols.md b/docs/protocols.md index 79faf6f..d1c3f3a 100644 --- a/docs/protocols.md +++ b/docs/protocols.md @@ -10,11 +10,11 @@ implemented, exactly what is not, and pins the revisions. | **MCP** | Supported | `@modelcontextprotocol/sdk@1.30.0` | tool discovery, tool invocation, payment-required and error mapping | | **x402** | Supported | x402 **v2** (`@x402/core@2.23.0`, `@x402/evm@2.23.0`), scheme `exact`, EVM, EIP-3009 | challenge, verification, settlement, replay binding | | **HTTP** | Supported | — | native resource routes with `PAYMENT-SIGNATURE` | -| UCP | Planned | — | not in v0.1 | -| ACP | Planned | — | not in v0.1 | -| MPP | Planned | — | not in v0.1 | -| A2A | Planned | — | not in v0.1 | -| AP2 | Planned | — | not in v0.1 | +| UCP | Planned | — | planned, no code ships | +| ACP | Planned | — | planned, no code ships | +| MPP | Planned | — | planned, no code ships | +| A2A | Planned | — | planned, no code ships | +| AP2 | Planned | — | planned, no code ships | "Planned" means **no code ships for it**. There is no partial adapter, no endpoint and no diagnostic pretending otherwise. diff --git a/docs/security.md b/docs/security.md index ff3c7a4..3608e7a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -47,7 +47,7 @@ in configuration error messages — errors name the *variable*, not the value. ## SSRF -The gateway makes outbound HTTP calls to URLs it was configured with. For v0.1: +The gateway makes outbound HTTP calls to URLs it was configured with: - backend URLs are **administrator-controlled configuration only**; - dynamic, agent- or user-controlled backend URLs are **forbidden** — no code @@ -72,7 +72,7 @@ Caller input can never override a query parameter the operator baked into `backend.url`. A collision is rejected, not silently applied — otherwise an input key named after an embedded `?apikey=…` would replace it. -Not implemented in v0.1: an IP/CIDR allowlist or a private-address blocklist. If +Not implemented: an IP/CIDR allowlist or a private-address blocklist. If you configure `http://169.254.169.254/…`, the gateway will call it. Treat configuration as privileged. @@ -90,14 +90,14 @@ field. A merchant that wants pass-through is a per-resource opt-in, post-alpha. Validated at the boundary, before anything else happens: -| Input | Check | -| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Input | Check | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | resource input | JSON Schema from the resource definition, closed by default at every level: an object schema — root, nested under `properties`, nested under `items`, or nested under an `additionalProperties` subschema — that omits `additionalProperties` gets `additionalProperties: false` stamped on recursively at config load, not just at the root. That enumeration was written from the stamper rather than from the validator and drifted three times; it now matches what `compileJsonSchema` actually recurses into; an operator who sets it explicitly (including explicitly to `true`) is respected at whichever level they set it. A resource that declares no `input:` at all gets an empty closed schema, not an always-valid one — declaring nothing means accepting nothing. Unknown properties, including prototype-named keys (`__proto__`, `constructor`, …), are matched by own-property lookup only. | -| path parameters | URL-encoded on substitution | -| body size | capped at 256 KB, one number for both surfaces, enforced in two different places: Fastify's `bodyLimit` runs inside a body parser on the HTTP routes; `/mcp` deliberately installs a no-op parser so the MCP transport can read the raw stream, so the mount enforces its own byte count instead. A cap that only protects one of two entry points, or two caps that can silently drift apart, is how `/mcp` ended up with no cap at all in the first place. | -| content type | JSON enforced on the invoke routes. **Not** on `/mcp`, where a wildcard no-op parser hands the raw stream to the MCP SDK and the SDK does its own enforcement. | -| payment proof | decoded and schema-validated by the payment provider; a malformed proof is a rejection, never a crash | -| configuration | Zod, strict, before startup | +| path parameters | URL-encoded on substitution | +| body size | capped at 256 KB, one number for both surfaces, enforced in two different places: Fastify's `bodyLimit` runs inside a body parser on the HTTP routes; `/mcp` deliberately installs a no-op parser so the MCP transport can read the raw stream, so the mount enforces its own byte count instead. A cap that only protects one of two entry points, or two caps that can silently drift apart, is how `/mcp` ended up with no cap at all in the first place. | +| content type | JSON enforced on the invoke routes. **Not** on `/mcp`, where a wildcard no-op parser hands the raw stream to the MCP SDK and the SDK does its own enforcement. | +| payment proof | decoded and schema-validated by the payment provider; a malformed proof is a rejection, never a crash | +| configuration | Zod, strict, before startup | The reserved `_payment` field is stripped from tool input before schema validation, so it can never collide with a resource's own properties. @@ -263,17 +263,17 @@ rejection outcomes assert that balances did not move. | **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 | | expired authorisation (`validBefore` in the past) | refused before settlement | `tests/e2e/payment` | -| not-yet-valid authorisation (`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 hostile or unbounded facilitator rejection string | clamped before it reaches buyer, event or ledger | `tests/unit/payments-x402` | +| not-yet-valid authorisation (`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 hostile or unbounded facilitator rejection string | clamped before it reaches buyer, event or ledger | `tests/unit/payments-x402` | | facilitator timeout | `PAYMENT_PROVIDER_UNAVAILABLE`, settlement treated as *uncertain* | `tests/unit/payments-x402` | | **facilitator 401 / 5xx** | `PAYMENT_PROVIDER_UNAVAILABLE`, never charged to the buyer | `tests/integration/adversarial-payment.test.ts` | | **malformed facilitator response** | refused; never read as a verdict | same | | backend timeout | `BACKEND_TIMEOUT` | `tests/unit/core/execution` | | backend 500 after payment | receipt records paid-and-undelivered; payer told it settled | `tests/unit/gateway`, `tests/unit/core/execution` | | receipt-store failure | `STORAGE_ERROR`, never mislabelled `PAYMENT_REPLAYED` | `tests/unit/storage-receipts` | -| a local reader racing the ledger's creation | database and sidecars are 0600 from the moment SQLite opens them | `tests/unit/storage-receipts/permissions.test.ts` | +| a local reader racing the ledger's creation | database and sidecars are 0600 from the moment SQLite opens them | `tests/unit/storage-receipts/permissions.test.ts` | | RPC unreachable during verify | `PAYMENT_PROVIDER_UNAVAILABLE`, not "bad signature" | `tests/unit/payments-x402` | Two of those exist because writing them found a bug. The SDK's `exact`/EVM @@ -292,17 +292,3 @@ side-channel and timing analysis · protocol-level censorship or MEV around settlement · availability guarantees · a malicious facilitator withholding settlement (it cannot redirect funds, but it can decline to broadcast, and fail-closed means the resource is simply not delivered). - -## Independent review - -**No independent security review has been performed.** Not commissioned, not -scheduled, not in progress. Everything above is self-assessment by the people -who wrote the code, which is the weakest kind of assurance there is. - -Before treating this as production-ready for real funds, the areas worth an -outside pair of eyes are `src/payments/x402` (verification and settlement), -payment enforcement in `src/core/execution/pipeline.ts`, the mainnet guards in -`src/payments/x402/guardrails.ts`, and the receipt and payment-attempt state -transitions in `src/storage/receipts`. - -This section is updated when that changes, and not before. diff --git a/package.json b/package.json index bf1ed53..6f0556c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devlab.group/agent-commerce", - "version": "0.2.0-beta.0", + "version": "1.0.0", "description": "Open-source, self-hosted, non-custodial commerce and payment orchestration gateway for AI agents.", "license": "Apache-2.0", "type": "module", diff --git a/src/core/domain/common.ts b/src/core/domain/common.ts index 5d65a71..ed715cd 100644 --- a/src/core/domain/common.ts +++ b/src/core/domain/common.ts @@ -14,10 +14,10 @@ */ export type JsonSchema = Record; -/** Protocol surfaces a resource can be exposed through in v0.1. */ +/** Protocol surfaces a resource can be exposed through in this release. */ export type ProtocolName = 'http' | 'mcp'; -/** Payment methods a resource can accept in v0.1. */ +/** Payment methods a resource can accept in this release. */ export type PaymentMethodName = 'x402'; /** ISO-8601 timestamp string, always UTC with millisecond precision. */ diff --git a/src/core/interfaces/store.ts b/src/core/interfaces/store.ts index 4d31546..de11a14 100644 --- a/src/core/interfaces/store.ts +++ b/src/core/interfaces/store.ts @@ -68,7 +68,7 @@ export interface ReceiptStore { * Receipts whose delivery did not succeed — `backendStatus` outside 2xx. * * A paid-but-undelivered purchase is the one row a merchant must notice: - * settlement is final and v0.1 has no refunds, so *seeing* it is the only + * settlement is final and this release has no refunds, so *seeing* it is the only * remedy available. Counted rather than listed for the same reason as * {@link countReceipts} — `listReceipts` is clamped, so counting by list * length silently saturates. diff --git a/src/gateway/well-known.ts b/src/gateway/well-known.ts index 331dfaf..4262dc8 100644 --- a/src/gateway/well-known.ts +++ b/src/gateway/well-known.ts @@ -52,7 +52,7 @@ import { type AdapterRuntime, getAdapterHealth } from './adapters.js'; * spec, and announcing a new one on every version bump would tell every * client the protocol moved when it did not. */ -const GATEWAY_SUPPORTED_SPEC = 'agent-commerce/v0.2.0-beta'; +const GATEWAY_SUPPORTED_SPEC = 'agent-commerce/v1.0.0'; export interface WellKnownDocument { readonly gateway: { readonly implementationVersion: string; readonly supportedSpec: string }; diff --git a/src/payments/x402/chain.ts b/src/payments/x402/chain.ts index e753ccd..16a603d 100644 --- a/src/payments/x402/chain.ts +++ b/src/payments/x402/chain.ts @@ -11,11 +11,14 @@ * The `Local` prefix on everything here describes the *chain* these clients * are built for, not a test-only status: `provider.ts` calls * `createLocalPublicClient` and `createLocalFacilitatorClient` on the real - * settlement path, for every payment. This is not demo scaffolding that a - * production build could drop — v0.1 settles against the deterministic local - * chain, so this file *is* the settlement transport. Supporting a public - * network means making `buildLocalChain`'s hardcoded `LOCAL_CHAIN_ID` a - * parameter, not bypassing this module. + * settlement path, and this is not demo scaffolding a production build could + * drop. + * + * Public networks are supported, and `buildLocalChain` now takes the chain id + * as a parameter — exactly what this comment used to say would be required. + * What stays local is the *facilitator* client: signing in-process is refused + * on any mainnet, so that client can only ever exist on the dev chain. The + * read-only health client is built for whatever network is configured. * * `dev-key-guard.ts` is the boundary that keeps that arrangement safe: it * refuses at provider construction if a dev key or dev `payTo` is pointed at diff --git a/src/payments/x402/guardrails.ts b/src/payments/x402/guardrails.ts index a34a619..ea2b4a2 100644 --- a/src/payments/x402/guardrails.ts +++ b/src/payments/x402/guardrails.ts @@ -151,7 +151,8 @@ export function resolveX402Deployment(input: X402DeploymentInput): X402Deploymen // The zero address lived only in the config loader, so a library consumer // calling `createX402PaymentProvider` directly — the path this shared // definition exists to cover — could boot a mainnet provider whose every - // payment burns. Round 6's lesson, regressed for one check. + // payment burns — sharing a *call* is not sharing a definition, and this + // check had drifted out of the shared one. if (/^0x0{40}$/i.test(input.payTo)) { throw invalid( 'payments.x402: "payTo" is the zero address. Every payment settled there is destroyed.', diff --git a/tests/conformance/mcp/fixtures.ts b/tests/conformance/mcp/fixtures.ts index f8d58ad..3823f20 100644 --- a/tests/conformance/mcp/fixtures.ts +++ b/tests/conformance/mcp/fixtures.ts @@ -74,7 +74,7 @@ export const NO_SCHEMA_RESOURCE: CommerceResource = { /** * Dynamic pricing exists in the type system for forward compatibility (config - * validation rejects it in v0.1) — the adapter must still describe it as paid + * validation rejects it) — the adapter must still describe it as paid * without inventing an amount/currency it does not have. */ export const DYNAMIC_PRICED_RESOURCE: CommerceResource = {