x402-micro-tollgate
Visa for AI agents that can’t hold a card.
Second-scale settlement bridge for agent traffic — the Web3 tollbooth that clears USDC micropayments and takes 0.1%.
为千万级无支付能力的 AI Agent 提供秒级过桥清算,抽成 0.1% 的 Web3 版 Visa 收费站
Security backed by Coinbase CDP. We don't touch your keys or settle payments on custom cryptography — EIP-3009 authorization nonces are single-use at the CDP facilitator (source of truth for on-chain uniqueness). The gateway hardens four buyer/seller trust surfaces: payment-proof replay/race, SSRF on URL fetch, upstream bypass (shared-secret trust header), and Base congestion / settle-latency (pending + retry same proof — never treat HTTP timeout alone as “payment failed”).
What it is
A thin self-hosted tollgate in front of your API or MCP tools. When an agent calls a paid route, it gets HTTP 402 as a price tag (not a hard deny). It pays a small USDC (or configured USDT) amount; you deliver. Default clearing is Base + USDC via Coinbase CDP; multi-network accepts are env-driven (see matrix below). Humans can still hit a free path; agents convert at the booth.
Self-host is free (MIT). Repo: github.com/kevin2003050666-coder/x402-micro-tollgate · Questions: [email protected]
Not an official Coinbase product. Not a full agent marketplace. Not a billing SaaS. A sharp Visa-style tollbooth. Not fiat custody.
Why this exists: MANIFESTO.md — pain, engineering, on-chain proof, MIT.
Network × asset matrix
Default deploy profile: Base USDC only (dev: Base Sepolia). Enable multi with NETWORKS + ASSETS or ACCEPTS_JSON / X402_* aliases. Browser HTML 402 shows a chain + asset picker when more than one accept is configured.
USDT on EVM typically uses extra.assetTransferMethod: "permit2" (not EIP-3009). FeeSplitter production path remains Base + USDC; other chains are config stubs under contracts/deployments/.
Browser wallets
Who it’s for
- API sellers who want agents to pay per call — without building a billing console or selling monthly seats
- Tool builders shipping paid MCP endpoints into Cursor / Claude
- Operators who want Visa-like take-rate economics (0.1%), not hosting invoices
How money works
- Price tag — unpaid traffic gets HTTP 402 with the amount due (conversion, not rejection)
- Pay — the agent settles a USDC micropayment in seconds via Coinbase CDP
- Unlock — the tollgate proxies to your upstream; you keep ~99.9%, protocol take 0.1%
No protocol monthly fee. Monetization is the toll — like interchange on card rails.
1-minute quickstart
Requires Node.js 22+.
git clone https://github.com/kevin2003050666-coder/x402-micro-tollgate
cd x402-micro-tollgate && cp .env.example .env
# set CDP_API_KEY_ID, CDP_API_KEY_SECRET, X402_PAY_TO (optional: PUBLIC_BASE_URL, UPSTREAM_URL)
npm i && npm start
# curl http://127.0.0.1:8402/health → 200
# curl http://127.0.0.1:8402/x402/discover → 200 (free agent yellow pages)
# curl http://127.0.0.1:8402/v1/quote → 402
# curl "http://127.0.0.1:8402/v1/fetch-md?url=https://example.com" → 402 (paid HTML→Markdown demo)
npx one-liners (no clone):
npx [email protected]
npx [email protected] --seller 0xYourReceivingAddress --stdio
--seller / -s sets X402_PAY_TO before boot (env vars still work). Default without --stdio is HTTP + /mcp on port 8402.
Agent / crawler docs: llms.txt (also GET /llms.txt and GET /.well-known/llms.txt) · OpenAPI 3.1: docs/openapi.yaml · well-known: GET /.well-known/x402.json, GET /.well-known/agent-card.json
Alternative one-liner: docker compose up --build
Without CDP keys the process runs in demo mode (protocol-shaped 402 / MCP PaymentRequired, no on-chain settle).
Cursor mcp.json:
{
"mcpServers": {
"x402-micro-tollgate": {
"url": "http://127.0.0.1:8402/mcp"
}
}
}
Stdio:
{
"mcpServers": {
"x402-micro-tollgate": {
"command": "npx",
"args": [
"[email protected]",
"--seller",
"0xYourReceivingAddress",
"--stdio"
],
"env": {
"CDP_API_KEY_ID": "...",
"CDP_API_KEY_SECRET": "...",
"PUBLIC_BASE_URL": "https://your.public.host"
}
}
}
}
(X402_PAY_TO in env still works if you omit --seller.)
Library drop-in (permissionless seller)
import express from "express";
import { x402Tollgate } from "x402-micro-tollgate";
const app = express();
app.use("/v1", await x402Tollgate({ seller: process.env.SELLER! }));
Or set SELLER / X402_SELLER in .env and run npm start. See Environment for the $10 threshold, factory address, and fee-release details.
Buyer / Agent client
Thin TypeScript helper that wraps official @x402/fetch + @x402/evm ExactEvmScheme. On HTTP 402 it parses PAYMENT-REQUIRED, checks budgets, signs EIP-3009 once, and retries once with PAYMENT-SIGNATURE (never loops).
npm i [email protected]
import { createX402Fetch } from "x402-micro-tollgate/client";
const fetch402 = createX402Fetch({ privateKey: process.env.BUYER_KEY as `0x${string}` });
const res = await fetch402("https://x402-micro-tollgate.onrender.com/v1/quote");
Defaults: maxSingleSpendUsdc = 0.05, maxTotalSpendUsdc = 1.00 (clear Error if exceeded). Circuit breaker (before sign): max 10 paid 402s / 0.05 USDC per rolling 60s, plus fingerprint dead-loop halt (maxPaidRequestsPerMinute, maxSpendUsdcPerMinute, enableFingerprintBreaker). Hot-wallet warning: keep only ~$5–$10 USDC on the signing key — treat it as an agent spend faucet, not a treasury.
Human-delegated agent credit (CLI)
Thin PoC: Human sets B_max → Agent loops createX402Fetch against a paid URL (auto 402 → pay once → retry). Chrome / Telegram / Session Key / Passkey UI are deferred.
# SAFETY: fund BUYER_PRIVATE_KEY with ≤ $5–$10 USDC only. Never commit keys.
export BUYER_PRIVATE_KEY=0xYourHotWalletKey
# optional: TARGET_URL MAX_SINGLE_USDC MAX_TOTAL_USDC ROUNDS
npm run poc:buyer-agent
# or: npx tsx scripts/buyer-agent-poc.ts
The script imports the local client (../src/client) so repo CI does not need a live chain pay. For a live demo against the published package, swap the import to x402-micro-tollgate/client (same API as 0.3.2+). Each round prints status, autoPaid, and sessionSpendUsdc on stdout; the safety banner goes to stderr.
Deploy
Uses render.yaml: Node 22, npm start, health /health, env names from .env.example. Set CDP_API_KEY_ID, CDP_API_KEY_SECRET, and X402_PAY_TO in the dashboard for live settlement; set PUBLIC_BASE_URL to your https://….onrender.com origin for Bazaar.
Ops bump: redeploy discover + well-known (force Render GitHub auto-deploy so production picks up GET /x402/discover, GET /.well-known/x402.json, GET /.well-known/agent-card.json, and MCP fetch_md). If auto-deploy does not fire within a few minutes after this lands on main, use the Render dashboard → Manual Deploy → Deploy latest commit.
Self-host production: Docker — docker compose up --build (see Dockerfile / docker-compose.yml).
Environment
Permissionless seller + $10 threshold (0.3.0)
0.3.0: set SELLER (or use x402Tollgate({ seller })) and FACTORY_ADDRESS for ≥ $10 CREATE2 routing. No MERCHANTS_JSON required.
x402 exact + EIP-3009 only credits payTo — it does not execute FeeSplitter / factory code and does not split in the same transaction. For ≥ $10: set FACTORY_ADDRESS to the operator-deployed factory (Base reference: contracts/deployments/base.json), call getOrCreate(seller) before the first such settle, then release() later (99.9% / 0.1%). Address typos send funds to the wrong place — the gateway checksum-validates SELLER at startup. Do not hardcode private keys or operator secrets in source.
Merchant registry (optional hosted multi-tenant)
Operator FEE_COLLECTOR is fixed: 0xa922F38041B5ee227c96A547F106F1330447e30E. Each merchant gets their own FeeSplitter (seller = merchant wallet, feeCollector = operator, feeBps = 10). The registry maps merchantId → splitter address (payTo) + seller for display. When SELLER is set, the registry is optional; ?merchant= still works if you provide MERCHANTS_JSON.
Register a merchant:
- Deploy
FeeSplitterwithseller= merchant wallet,feeCollector=0xa922F38041B5ee227c96A547F106F1330447e30E,feeBps= 10, and the chain’s native USDC asasset. - Add an entry to
MERCHANTS_JSON(ormerchants.json/ copy frommerchants.example.json):{ "acme": { "seller": "0xMerchantWallet…", "payTo": "0xDeployedFeeSplitter…", "label": "Acme API" } } - Call gated APIs with
?merchant=acmeor headerx-merchant-id: acme(case-insensitive). Agents SHOULD always send merchant id. If omitted, the gateway falls back toDEFAULT_MERCHANT(demo) — that means traffic (and USDC) can silently land on the demo FeeSplitter. SetREQUIRE_MERCHANT=trueto reject missing merchant with400 { "error": "merchant_required" }. Unknown merchant on gated paths →400{ "error": "unknown_merchant" }. - Free listing:
GET /merchants(also/v1/merchants). - Agent yellow pages:
GET /x402/discover(aliasGET /discover) — stable JSON catalog derived from the same registry /SELLER/ demo. JSON config stays supported; there is no on-chainRegistry.sol$5 stake in this release.
Note: The Base demo splitter has seller = feeCollector = operator — fine for demo. Real merchants need their own splitter with their wallet as seller.
CDP createX402Server uses a single global payTo for SDK init (X402_PAY_TO or the default merchant splitter). Per-request merchant routing rewrites PAYMENT-REQUIRED accepts[].payTo to the resolved FeeSplitter (same pattern as the https resource.url rewrite).
If X402_PAY_TO is still an EOA and you only have one merchant, behavior stays simple. Multi-merchant production should point each registry payTo at a deployed splitter — x402/exact credits that splitter via EIP-3009; call release() later (manually or via the optional keeper) to send 99.9% / 0.1%. Same contract on Base / Arbitrum / Polygon — see the multi-chain FeeSplitter matrix. This is receive → later release(), not an atomic same-transaction split and not OpenZeppelin PaymentSplitter.
Discovery (live) + liquidity roadmap (planned)
Ship now — Discovery only. Free route GET /x402/discover (alias /discover) returns agent-readable yellow pages from existing sources (MERCHANTS_JSON / merchants file / SELLER / built-in demo) + PUBLIC_BASE_URL. No 402. Example shape:
{
"version": 1,
"network": "eip155:8453",
"updatedAt": "2026-09-03T00:00:00.000Z",
"source": "merchants",
"services": [{
"id": "demo",
"label": "demo (operator is also seller)",
"endpoint": "https://your-host/v1/quote?merchant=demo",
"mcp": "https://your-host/mcp",
"capabilities": ["quote", "proxy", "fetch-md"],
"price": "$0.001",
"asset": "USDC",
"payTo": "0x…",
"seller": "0x…",
"status": "demo"
}]
}
status is health-honest: live only when CDP facilitator credentials + payTo are configured; otherwise demo (or config when accepts are non-live). This is not an on-chain Agent Registry.
Planned (documented only — do not treat as shipped):
Full notes: docs/ROADMAP-LIQUIDITY.md. No depositBounty / claimBounty on FeeSplitter and no fake “live” cross-chain clearing claims in this release.
发现层已上线(
GET /x402/discover);闪电流动性池与反向赏金仅为规划,见 roadmap。
Gas vs micropayment floor (optional)
Micropayments ($0.001–$0.01) assume Base low fees. When L2 gas spikes, estimated settle cost can eat most of a tiny payment. Opt in with X402_DYNAMIC_MIN_ENABLED=true: a cached Base baseFee oracle (TTL 15–60s, no RPC spam) estimates gas USD (gasUsedEstimate * baseFee * X402_ETH_USD) and, if that exceeds X402_GAS_COST_MAX_FRACTION of the payment (default 50%), bumps the displayed/enforced minimum accept amount and may raise feeFreeBelow so FeeSplitter+release() isn’t used until larger amounts. Default is OFF so demo $0.001 still works under normal conditions. Operators can also set a static X402_MIN_PRICE_USDC. Current effective mins are on GET /health → gasFloor.
Security (gateway hardening)
Four defenses buyers and sellers should know about:
- Payment signature replay / race — Idempotency key = SHA-256(
PAYMENT-SIGNATURE). An in-process mutex +PaymentDedupeStore(memory today; Redis-shaped interface) does set-if-absent before upstream. Concurrent losers get409 { "error": "payment_already_used" }. Durable mark happens only after settle success; verify failure releases the pending reservation so honest retries are not bricked. - SSRF on
/v1/fetch-md— Onlyhttp/https; DNS resolve rejects private/bogon/link-local/metadata; DNS is re-checked before fetch (rebinding defense); redirects are disabled. - Upstream bypass — Optional
UPSTREAM_SHARED_SECRET: after payment, proxy injectsX-Tollgate-Secret/X-Tollgate-Paid/X-Tollgate-Timestamp. Your upstream must require them (see snippet below). This is a shared-secret MVP, not mTLS. - Base congestion / settle latency —
X402_SETTLE_TIMEOUT_MS(default 3 minutes) is separate from the short verify budget. If settle is still in progress when the waiter expires →202 { "error": "payment_pending", "retry_with_same_proof": true }. Do not create a new payment and do not treat the buyer as failed solely because HTTP timed out — retry the same proof; the gateway resumes without double-settling.
CDP facilitator is a single-vendor settle dependency by default; see SECURITY.md for X402_FACILITATOR_URL (alternate facilitator when available — no multi-facilitator routing yet).
Payment proof idempotency + settle pending
CDP x402 exact + EIP-3009 authorizations are single-use at the facilitator (nonce). The gateway additionally tracks each proof fingerprint as pending → settled → consumed:
Process-local only by default (restart clears). Facilitator remains authoritative for on-chain nonce uniqueness.
Upstream trust header (Express example)
import { createHmac, timingSafeEqual } from "node:crypto";
import type { RequestHandler } from "express";
const SECRET = process.env.UPSTREAM_SHARED_SECRET!;
export const requireTollgate: RequestHandler = (req, res, next) => {
const secret = req.header("x-tollgate-secret") ?? "";
const paid = req.header("x-tollgate-paid") ?? "";
const ts = req.header("x-tollgate-timestamp") ?? "";
const expected = createHmac("sha256", SECRET)
.update(`${ts}.${req.method.toUpperCase()}.${req.path}`, "utf8")
.digest("hex");
const okSecret =
secret.length === SECRET.length &&
timingSafeEqual(Buffer.from(secret), Buffer.from(SECRET));
const okPaid =
paid.length === expected.length &&
timingSafeEqual(Buffer.from(paid), Buffer.from(expected));
if (!okSecret || !okPaid) {
res.status(401).json({ error: "tollgate_required" });
return;
}
next();
};
// app.use(requireTollgate); // reject public direct hits
Or import verifyUpstreamTrustHeaders from x402-micro-tollgate in your upstream process.
Optional FeeSplitter release keeper
Scaffold in src/keeper.ts. Off by default (KEEPER_ENABLED unset/false). When enabled, every KEEPER_INTERVAL_MS (default 1h) it checks USDC balances of registry payTo FeeSplitter addresses and calls release() when balance ≥ KEEPER_MIN_USDC (default $1).
Warning: Gas for release() can exceed the 0.1% operator fee on sub-cent payments — hence the min-balance gate. Do not turn this on by default on Render. Prefer KEEPER_DRY_RUN=true (logs keeper_would_release) before using a real KEEPER_PRIVATE_KEY. Never commit keys.
Bazaar discovery (agents find sellers)
Bazaar is the x402 discovery catalog.
- HTTP:
createX402Serverauto-injects bazaar; we override withdiscoverable: true, descriptions, and input/output schemas (GET /v1/quote+ gated prefix proxies). - MCP:
x402ResourceServerdoes not auto-declare Bazaar — we registerbazaarResourceServerExtensionand passdeclareDiscoveryExtension({ toolName, inputSchema, … })onget_quote/proxy_request/fetch_md. Resource URL isPUBLIC_BASE_URL/mcp(a real http(s) URL, not a display name). - Origin well-known:
GET /.well-known/x402.json(alias/.well-known/x402) mirrors discover + lists live paid HTTP/MCP resources for facilitator-independent crawlers.
To appear in Bazaar:
- Set
PUBLIC_BASE_URLto a public https origin (localhost listings are a no-op for real crawlers). - Run with live CDP credentials +
X402_PAY_TO. - Complete one successful settlement through the CDP facilitator (empty-body probes on gated routes return 402, not 400).
Launch tip: share your /mcp or /v1/quote URL in Discord #x402 after that first settlement.
Pay in browser (Smart Wallet)
When a browser hits a gated route (Accept: text/html + Mozilla UA), the gateway returns a thin HTML paywall instead of JSON. Agents / MCP / curl keep the JSON 402 body.
What you get
- Connect / create a Coinbase Smart Wallet via the official
@x402/paywallEVM UI (Coinbase Wallet connector — Passkey Smart Wallet, no MetaMask required). - Sign the x402
exactEIP-3009 payment on Base (or Base Sepolia in development) and retry withPAYMENT-SIGNATURE. - Get USDC (optional): when server CDP keys are set, the paywall shows a Get USDC button that calls
POST /x402/session-tokenand opens Coinbase Onramp. Apple Pay / card appear only if Onramp supports them for your domain — this repo does not fake Apple Pay outside Onramp, and does not claim a guaranteed “10 second Apple Pay” without Portal production access.
Setup (buyer UX)
# .env — server secrets stay on the host
CDP_API_KEY_ID=…
CDP_API_KEY_SECRET=…
X402_PAY_TO=0x… # or SELLER=0x…
# Public client key only (browser-safe)
CDP_CLIENT_API_KEY=… # from portal.cdp.coinbase.com
# Optional but recommended for live demo / Bazaar
PUBLIC_BASE_URL=https://your.host
X402_ENVIRONMENT=development # Base Sepolia (eip155:84532)
# X402_ENVIRONMENT=production # Base mainnet (eip155:8453)
Honesty / production notes
- MVP networks: Base Sepolia (
development) and Base mainnet (production/NETWORK=eip155:8453). - Onramp production: enable Onramp in CDP Portal, allowlist your domain, and complete any Apple Pay domain verification Coinbase requires. Until then, Smart Wallet pay + sign + retry still works; Get USDC may error or omit card rails.
- CSP: the paywall ships inline scripts (same as upstream
@x402/paywall). Prefer not to set a strictscript-srcwithout nonces on 402 HTML responses. - Security: wallet private keys never touch the frontend; only the public client key is embedded. Session tokens are minted server-side.
Try it: open https://your-host/v1/quote in Chrome after configuring the keys above.
What it does
Agents / clients
├─ HTTP /v1/* → x402 402 JSON (agents) or Smart Wallet HTML paywall (browsers)
├─ GET /v1/fetch-md → paid HTML→Markdown demo (same x402 gate)
├─ GET /x402/discover → free agent yellow pages (alias /discover; from merchants JSON)
├─ GET /.well-known/x402.json → free Bazaar-friendly origin manifest (alias /.well-known/x402)
├─ GET /.well-known/agent-card.json → free agent card (alias /.well-known/agent.json)
├─ GET /llms.txt → free AI-crawler summary (alias /.well-known/llms.txt)
├─ GET /openapi.yaml → free OpenAPI 3.1 (alias /docs/openapi.yaml)
├─ POST /x402/session-token → free Onramp session token (when CDP server keys set)
├─ GET /merchants → free merchant registry (id, label, seller, payTo)
├─ GET /health → free (+ paywall config flags)
├─ GET / → developer landing (EN / 中文)
└─ MCP /mcp → server_info (free), get_quote + proxy_request + fetch_md (paid + bazaar)
CLI: npx [email protected] | --seller 0x… / -s | --stdio | --port N
Agent SEO: llms.txt · docs/openapi.yaml
Publishing
Package identity (must stay in sync):
package.jsonmcpName=io.github.kevin2003050666-coder/x402-micro-tollgateserver.jsonname= same string
Flow (needs tokens on a machine that has them — not this VM):
- Public GitHub mirror at
kevin2003050666-coder/x402-micro-tollgate npm publish(or tagv*→.github/workflows/publish-mcp.yml)mcp-publisher publish(OIDC) → official MCP Registry- PulseMCP auto-ingests from the official registry — do not submit to PulseMCP directly
Update server.json remotes[0].url to your real public /mcp before publishing remotes.
Tests
npm test
No live CDP credentials required.
License
MIT · See CONTRIBUTING.md · SECURITY.md