Go live with real USDC
This is the operator manual for turning on real on-chain settlement. Tollgate
already ships with it enabled: the exact scheme verifies signatures with real
EIP-712 recovery in-process and delegates balance checks, simulation, and
broadcasting to a live x402 facilitator. What is left for you is funding and,
eventually, mainnet. Follow the steps in order; every step has a check.
How settlement works now
Settlement is non-custodial and direct: USDC moves straight from the buyer to the publisher's own address. The platform never touches the funds.
- Each publisher sets a receive address on their dashboard. The on-chain
(
exact) scheme is only offered for resources whose publisher has one; the 402 offer'spayTois that publisher's address, not a platform treasury. - An agent or wallet signs a USDC
TransferWithAuthorization(EIP-3009) to that address — a gasless, off-chain signature for an exact amount, once. - Tollgate recovers the signer locally (viem) and rejects anything malformed, expired, underpaying, or misdirected before touching the network.
- The facilitator (
X402_FACILITATOR_URL, defaulthttps://x402.org/facilitator) re-verifies, checks balance and nonce, simulates, broadcasts, pays the gas, and returns the tx hash. The USDC lands in the publisher's wallet. - Tollgate records the payment (with the tx hash and the
settle_toaddress) and returns the content with anX-PAYMENT-RESPONSEheader and a receipt.
The payer never spends gas. The platform never holds anyone's keys or funds. Because settlement is direct, there is no platform payout step for on-chain sales — the money is already the publisher's. (The internal balance / withdraw flow now applies only to the sandbox-credit rail, which is test money.)
Step 1 · Create the treasury address
The treasury receives every on-chain payment (PAY_TO_ADDRESS in the 402 offers).
Any EVM address works. Two good options:
Option A, MetaMask (simplest). Create a fresh account in MetaMask, name it "Tollgate treasury", copy the address.
Option B, a key you can script (recommended for ops).
node -e "
const { generatePrivateKey, privateKeyToAccount } = require('viem/accounts');
const pk = generatePrivateKey();
console.log('PRIVATE KEY (store in a password manager, never in git):', pk);
console.log('ADDRESS:', privateKeyToAccount(pk).address);
"
Run it inside the project directory (viem is installed there). Store the private key in a password manager or secret store. The address is public.
Check: you have an 0x… address and its key is stored somewhere safe that
is not this repository.
Step 2 · Point the deployment at it
cd ~/projects/402
railway variables --set 'PAY_TO_ADDRESS=0xYOURTREASURY...' --skip-deploys
railway variables --set 'X402_NETWORK=base-sepolia' --skip-deploys # already the default
railway variables --set 'X402_FACILITATOR_URL=https://x402.org/facilitator' --skip-deploys
railway up --detach
Check: curl -s https://<your-app>/r/eu-rates/today -H 'Accept: application/json' | jq '.accepts[0].payTo'
prints your treasury address.
Step 3 · Fund a test wallet with Base Sepolia USDC
The payer side needs testnet USDC (the treasury does not need funding).
- Create or pick a second MetaMask account (the "buyer").
- Get Base Sepolia ETH for it: https://www.alchemy.com/faucets/base-sepolia (any Base Sepolia faucet works; ETH is only needed if you later send funds around, not for x402 payments themselves).
- Get Base Sepolia USDC from Circle's faucet at https://faucet.circle.com, select network "Base Sepolia", paste the buyer address. It sends 10 USDC.
- Add the token to MetaMask if you want to see the balance:
contract
0x036CbD53842c5426634e7929541eC2318f3dCF7e, symbol USDC, 6 decimals.
Check: MetaMask shows a Base Sepolia USDC balance on the buyer account.
Step 4 · Make a real purchase from a browser
- Open any resource page, for example
https://<your-app>/r/eu-rates. - Press Pay with wallet. MetaMask pops a signature request titled
TransferWithAuthorization. Inspect it:tomust be your treasury,valuethe price in atomic units (4000 = $0.004), the validity window short. - Sign. Tollgate pays via the facilitator and renders the content plus a receipt in a few seconds.
Check: the status panel shows 200 OK · receipt tg_…, and the payment row
appears in your storefront's "Latest paid requests" with scheme exact. The
tx_ref on the payment is a real transaction hash; view it at
https://sepolia.basescan.org/tx/<hash>, where you will see a USDC transfer to
your treasury, gas paid by the facilitator.
Step 5 · Same thing from the command line
# In the buyer wallet, export the private key (MetaMask: account details).
export TOLLGATE_EVM_KEY=0x...buyer_private_key...
npx tollgate get https://<your-app>/r/eu-rates/today --scheme exact
The CLI fetches the 402 offer, signs the authorization locally with viem, pays, and prints the content plus the receipt and tx hash.
Check: exit code 0, receipt printed, new exact row in the ledger.
Step 6 · Watch the failure modes work
Production readiness means the rejections work too:
# Unfunded wallet → the facilitator rejects with insufficient_funds
TOLLGATE_EVM_KEY=$(node -e "console.log(require('viem/accounts').generatePrivateKey())") \
npx tollgate get https://<your-app>/r/eu-rates/today --scheme exact
# → 402 · insufficient_funds (this is the correct behavior)
A tampered signature, a wrong recipient, or an expired window are rejected
locally with the specific invalid_exact_evm_payload_* codes before any network
call. The smoke gate (npm run smoke) asserts this on every deploy.
Step 7 · Mainnet, when you are ready
The code is ready; going live is configuration plus one Coinbase CDP key. The CDP facilitator auth (a per-request EdDSA Bearer JWT) is already implemented and switches on automatically when the two CDP variables are present.
Create a CDP API key at https://portal.cdp.coinbase.com — choose the Ed25519 key type (the recommended one). You get an API Key ID and a base64 API Key Secret. Keep the secret in a secret store, never in git.
Set the mainnet variables on the web service:
railway variables --set 'X402_NETWORK=base' \
--set 'USDC_ADDRESS=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' \
--set 'X402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402' \
--set 'CDP_API_KEY_ID=<your key id>' \
--set 'CDP_API_KEY_SECRET=<your base64 secret>' --skip-deploys
railway up --detach
- Verify:
GET /facilitator/supportedshould report the CDP host, andnpm run smoke -- https://<your-app>runs a live-facilitator verify of a genuine signature. A real purchase needs a buyer wallet funded with mainnet USDC on Base.
How it works under the hood:
CDP_API_KEY_ID+CDP_API_KEY_SECRETmakebuildProvider()attach a CDP Bearer JWT to every/verifyand/settlecall (src/util/cdp-jwt.ts, EdDSA, claims bound toPOST host/path, 2-minute expiry). No SDK, no code change needed — just the two variables. Covered bytests/mainnet.test.ts.- EIP-712 domain footgun (handled): mainnet Base USDC signs with
name: "USD Coin", Base Sepolia withname: "USDC".buildRequirementspicks the right name fromX402_NETWORKautomatically; wallets echo the offer'sextra(the built-in payer code does). If you ever pointUSDC_ADDRESSat a non-Circle token, update that mapping inengine.ts. - Settlement stays non-custodial: on mainnet, as on testnet, USDC settles
straight to each publisher's own receive address. There is no platform
treasury to fund or reconcile;
PAY_TO_ADDRESSis unused for real sales.
Step 8 · Publisher withdrawals
On-chain sales settle directly to the publisher's own address, so there is no
withdrawal step for real money. The internal balance and the payouts queue
apply only to the sandbox-credit rail (test money). If you keep sandbox
credits enabled in production, settling those is a
treasury operation: send USDC from the treasury to payouts.address and mark
the row sent. Automating that (a worker signing with the treasury key) is the
one remaining piece of money movement that is deliberately manual, so a human
controls outflows.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
402 · facilitator_unreachable |
Facilitator down or egress blocked | Check X402_FACILITATOR_URL, try curl <url>/supported from the server |
402 · insufficient_funds |
Buyer wallet has no USDC on the offer's network | Fund via faucet (testnet) or bridge (mainnet) |
402 · invalid_exact_evm_payload_signature |
Wrong EIP-712 domain (name/version/chainId) | Let the payer echo the offer's extra; never hardcode |
| MetaMask shows no signature prompt | Wrong network selected in the wallet | The signature itself is chain-agnostic; check the site connected to the right account |
| Payment succeeds but no receipt row | Check server logs | railway logs, settlement errors log with the [x402] prefix |