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..ab3497d 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,41 +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
- - 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`.
+ # 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: 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/.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/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 2c3e4bb..5a88197 100644
--- a/README.md
+++ b/README.md
@@ -13,8 +13,11 @@
-> **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).
---
@@ -87,7 +90,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
@@ -102,6 +105,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 +122,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
@@ -206,13 +217,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,19 +246,123 @@ checkable, not marketing. Detail: [docs/protocols.md](docs/protocols.md).
Detail: [docs/payment-flow.md](docs/payment-flow.md).
+## 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:
+
+| 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.
+
+### 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
$ 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
```
@@ -277,33 +392,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, and no payment has been settled on it. See
-[docs/testnet.md](docs/testnet.md).
-
## Development
```bash
@@ -316,7 +404,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
## Roadmap
-**Now (v0.2.0-beta)** — MCP, x402 v2, Base Sepolia settlement, 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 ·
@@ -333,7 +422,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 |
| [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/SECURITY.md b/SECURITY.md
index 6636383..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
@@ -17,7 +19,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 +131,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 +173,6 @@ 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 |
## 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/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 363fe89..0c10370 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
@@ -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]
@@ -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
@@ -154,10 +155,29 @@ refused on testnet too.
the gateway runs at startup — the same function, not a second copy of the
rules.
-**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.
+`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.
+
+### `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/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 60a49ea..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.1.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/payment-flow.md b/docs/payment-flow.md
index 1859dfa..048ec6a 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
@@ -126,35 +126,43 @@ 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
-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 3af52cd..d1c3f3a 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 | — | 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.
@@ -99,27 +99,28 @@ 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
-[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 6e84e78..3608e7a 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -47,12 +47,16 @@ 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
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.
@@ -68,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.
@@ -86,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 |
-|---|---|
-| 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`, 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 |
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 +123,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
@@ -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`
@@ -183,11 +186,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
@@ -205,27 +224,71 @@ 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.
-
-## Threats we are not addressing in the alpha
+`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.
+
+## 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` |
+| 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
+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.
-
-An independent security audit has not been performed.
+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).
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
new file mode 100644
index 0000000..041ddde
--- /dev/null
+++ b/examples/base-mainnet/README.md
@@ -0,0 +1,72 @@
+# Example: base-mainnet — REAL FUNDS
+
+The same gateway, settling real USDC on Base. Read
+[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:
+
+- **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
+
+```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
new file mode 100644
index 0000000..1ffc575
--- /dev/null
+++ b/examples/base-mainnet/config.yaml
@@ -0,0 +1,116 @@
+# ---------------------------------------------------------------------------
+# 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 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=... \
+# 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://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"
+ # 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
+
+ # 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/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/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..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",
@@ -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/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/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/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 452b89a..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(),
})
@@ -179,6 +226,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', [
@@ -215,6 +271,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();
@@ -272,6 +330,7 @@ export interface GatewayConfig {
readonly maxTimeoutSeconds: number;
readonly facilitator: X402FacilitatorConfig;
readonly allowMainnet?: boolean;
+ readonly allowUnauthenticatedFacilitator?: boolean;
};
};
}
@@ -455,6 +514,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;
@@ -468,8 +535,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 }
+ : {}),
});
}
@@ -705,6 +777,18 @@ function defaultClosedObjectSchema(schema: Record): 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/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/gateway/well-known.ts b/src/gateway/well-known.ts
index fbb1d19..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 };
@@ -86,14 +86,31 @@ export interface BuildWellKnownOptions {
readonly clock: Clock;
}
+/**
+ * Strips a health result down to what an anonymous caller may see.
+ *
+ * `/ready` already codifies this rule and maps `detail` to a fixed vocabulary,
+ * because the raw string is exactly the internal detail an unauthenticated
+ * route must not carry — `getAdapterHealth` puts `describeError()` there for a
+ * thrown `health()`, and a start failure puts the raw throw message, routinely
+ * a module path or an internal hostname. This route is the same trust level and
+ * was returning it verbatim. `createGateway` is public API and accepts
+ * arbitrary adapters, so "our own adapters say nothing sensitive" is not a
+ * property this code can rely on.
+ */
+function publicHealth(health: AdapterHealth): AdapterHealth {
+ const { detail: _detail, ...rest } = health;
+ return rest;
+}
+
export async function buildWellKnownDocument(
options: BuildWellKnownOptions,
): Promise {
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..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
@@ -71,9 +74,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 +103,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/facilitator.ts b/src/payments/x402/facilitator.ts
index b350153..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,
@@ -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.
*
@@ -151,18 +196,19 @@ export interface RemoteFacilitatorOptions {
*/
export function createRemoteFacilitatorBinding(
options: RemoteFacilitatorOptions,
- 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,
@@ -179,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/guardrails.ts b/src/payments/x402/guardrails.ts
index f64c6ba..ea2b4a2 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 }
@@ -45,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 {
@@ -68,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.`,
@@ -87,17 +113,34 @@ 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',
);
}
- 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}`,
+ );
+ }
}
}
@@ -105,6 +148,18 @@ 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 — 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.',
+ '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.`,
@@ -115,11 +170,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 };
@@ -136,28 +209,54 @@ 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',
);
}
+/** 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) {
+ 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/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..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 {
/**
@@ -109,6 +126,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 +174,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
@@ -183,17 +210,21 @@ 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
// 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;
@@ -300,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;
@@ -388,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();
@@ -419,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
@@ -526,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/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 = {
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/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/mainnet/base.smoke.test.ts b/tests/mainnet/base.smoke.test.ts
new file mode 100644
index 0000000..07c474d
--- /dev/null
+++ b/tests/mainnet/base.smoke.test.ts
@@ -0,0 +1,350 @@
+/**
+ * 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'];
+// 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'];
+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',
+ // 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 examples/base-mainnet/README.md.`,
+ );
+}
+
+function authBlock(): Record {
+ if (CDP_API_KEY_ID && CDP_API_KEY_SECRET) {
+ return { type: 'cdp', apiKeyId: CDP_API_KEY_ID, apiKeySecret: CDP_API_KEY_SECRET };
+ }
+ if (BEARER) return { type: 'bearer', token: BEARER };
+ return { type: 'none' };
+}
+
+function rawConfig(overrides: { allowMainnet?: boolean } = {}): Record {
+ return {
+ version: 1,
+ merchant: {
+ id: 'mainnet-smoke',
+ name: 'Base Mainnet Smoke',
+ publicBaseUrl: 'http://127.0.0.1:8080',
+ },
+ server: { port: 8080, host: '127.0.0.1', allowedOrigins: [] },
+ storage: { receipts: { driver: 'sqlite', path: ':memory:' } },
+ protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } },
+ resources: {
+ [RESOURCE_ID]: {
+ name: 'Mainnet report',
+ description: 'A paid resource settled on Base',
+ backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' },
+ pricing: { type: 'fixed', amount: AMOUNT, currency: 'USD' },
+ expose: ['http'],
+ payments: ['x402'],
+ },
+ },
+ payments: {
+ x402: {
+ enabled: true,
+ network: NETWORK,
+ rpcUrl: RPC_URL,
+ asset: USDC,
+ assetName: 'USD Coin',
+ assetVersion: '2',
+ assetDecimals: 6,
+ payTo: MERCHANT,
+ maxTimeoutSeconds: 600,
+ ...(overrides.allowMainnet === false ? {} : { allowMainnet: true }),
+ // Only meaningful when no credential is configured; harmless otherwise.
+ allowUnauthenticatedFacilitator: true,
+ facilitator: { mode: 'remote', url: FACILITATOR_URL, auth: authBlock() },
+ },
+ },
+ };
+}
+
+const describeOrSkip = missing.length === 0 ? describe : describe.skip;
+
+describeOrSkip('Base mainnet — real funds', () => {
+ let gateway: GatewayInstance;
+ let store: ReceiptStore;
+ let buyer: `0x${string}`;
+ /** Everything the gateway logged, for the credential-leak assertion. */
+ const logged: string[] = [];
+
+ 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.
+ *
+ * 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: 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));
+ }
+ if (snapshot) return snapshot;
+ throw lastError ?? new Error('no balance snapshot was ever read');
+ }
+
+ 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 } : {}),
+ ...(x402.allowUnauthenticatedFacilitator !== undefined
+ ? { allowUnauthenticatedFacilitator: x402.allowUnauthenticatedFacilitator }
+ : {}),
+ 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/testnet/base-sepolia.smoke.test.ts b/tests/testnet/base-sepolia.smoke.test.ts
index afc5ce9..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
@@ -133,12 +135,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 +169,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/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), {});
diff --git a/tests/unit/cli/packaging.test.ts b/tests/unit/cli/packaging.test.ts
index 4841237..48bb483 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.
@@ -302,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 f027126..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';
@@ -219,6 +317,7 @@ describe('parseConfig', () => {
withX402({
network: 'eip155:8453',
asset: BASE_USDC,
+ assetName: 'USD Coin',
payTo: MERCHANT,
allowMainnet: true,
facilitator: {
@@ -255,6 +354,7 @@ describe('parseConfig', () => {
withX402({
network: 'eip155:8453',
asset: BASE_USDC,
+ assetName: 'USD Coin',
payTo: MERCHANT,
facilitator: {
mode: 'remote',
@@ -266,17 +366,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 +453,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({
@@ -334,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' } }),
@@ -349,6 +532,7 @@ describe('parseConfig', () => {
withX402({
network: 'eip155:8453',
asset: BASE_USDC,
+ assetName: 'USD Coin',
payTo: MERCHANT,
allowMainnet: true,
facilitator: {
@@ -361,6 +545,56 @@ 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,
+ assetName: 'USD Coin',
+ 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,
@@ -1097,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/payments-x402/facilitator-cdp.test.ts b/tests/unit/payments-x402/facilitator-cdp.test.ts
new file mode 100644
index 0000000..d2b3b2d
--- /dev/null
+++ b/tests/unit/payments-x402/facilitator-cdp.test.ts
@@ -0,0 +1,147 @@
+/**
+ * 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 });
+ 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' },
+ });
+ // `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/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);
+ });
+});
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..e1631b4
--- /dev/null
+++ b/vitest.mainnet.config.ts
@@ -0,0 +1,31 @@
+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` and skips itself unless a human has set
+ * `ALLOW_X402_MAINNET=true` and supplied credentials.
+ *
+ * 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: {
+ 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,
+ },
+});
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.