A production-ready payment settlement service for the x402 protocol. Built with Elysia and Node.js, it verifies cryptographic payment signatures and settles transactions on-chain for EVM (Base) and SVM (Solana) networks.
- Overview
- Architecture
- Quick Start
- Configuration
- Custom Signers
- API Reference
- Payment Schemes
- Extending the Facilitator
- Testing
- Production Deployment
The x402 Facilitator acts as a trusted intermediary between clients making payments and resource servers providing paid content. It:
- Verifies payment signatures and authorizations
- Settles transactions on-chain (EVM/Solana)
- Manages batched payment sessions for efficient settlement (upto scheme)
| Network | CAIP-2 Identifier | Schemes |
|---|---|---|
| Base Mainnet | eip155:8453 |
exact, upto |
| Base Sepolia | eip155:84532 |
exact, upto |
| Ethereum | eip155:1 |
exact, upto |
| Optimism | eip155:10 |
exact, upto |
| Arbitrum | eip155:42161 |
exact, upto |
| Polygon | eip155:137 |
exact, upto |
| Solana Devnet | solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 |
exact |
| Solana Mainnet | solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp |
exact |
┌─────────────────────────────────────────────────────────────────────┐
│ x402 Facilitator │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ /verify │ │ /settle │ │ /supported │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Payment Scheme Registry │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Exact (EVM) │ │ Upto (EVM) │ │ Exact (SVM) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────┼────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ EVM Signer │ │ SVM Signer │ │Session Store│ │
│ │ (Viem/CDP) │ │(Solana Kit) │ │ (In-Memory) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
└─────────┼────────────────────┼────────────────────┼────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ EVM RPC │ │ Solana RPC │ │ Sweeper │
└─────────────┘ └─────────────┘ └─────────────┘
| Component | File | Responsibility |
|---|---|---|
| HTTP Server | src/app.ts |
Elysia server with endpoints and middleware |
| Facilitator Factory | src/setup.ts |
createFacilitator() with signer injection |
| Default Signers | src/signers/default.ts |
EVM/SVM wallet from env vars |
| CDP Signer | src/signers/cdp.ts |
Coinbase Developer Platform adapter |
| Upto Scheme | src/upto/evm/facilitator.ts |
Permit-based batched payments |
| Session Store | src/upto/store.ts |
In-memory session management |
| Sweeper | src/upto/sweeper.ts |
Background batch settlement |
Exact Payment (Immediate Settlement)
Client → POST /verify → Signature validation → VerifyResponse
Client → POST /settle → On-chain transfer → SettleResponse (tx hash)
Upto Payment (Batched Settlement)
Client → POST /verify → Permit validation → Session created/updated
↓
Accumulate pending spend across requests
↓
Sweeper triggers → POST /settle (batch) → Reset pending
- Node.js v20+
- Bun runtime
- EVM private key with Base ETH for gas (or CDP account)
- SVM private key with SOL for fees (optional)
# Clone and install
git clone <repository-url>
cd facilitator
bun install
# Configure environment
cp .env-local .env
# Edit .env with your private keys
# Start development server
bun devThe server starts at http://localhost:8090.
curl http://localhost:8090/supportedDefault Signer (Private Key)
| Variable | Required | Default | Description |
|---|---|---|---|
EVM_PRIVATE_KEY |
Yes* | - | Ethereum private key (hex format) |
SVM_PRIVATE_KEY |
Yes* | - | Solana private key (Base58 format) |
PORT |
No | 8090 |
Server port |
EVM_RPC_URL_BASE |
No | - | Custom RPC URL for Base |
*Required when using default signers. Not needed with CDP signer.
CDP Signer (Coinbase Developer Platform)
| Variable | Required | Default | Description |
|---|---|---|---|
CDP_API_KEY_ID |
Yes | - | CDP API key ID |
CDP_API_KEY_SECRET |
Yes | - | CDP API key secret |
CDP_WALLET_SECRET |
Yes | - | CDP wallet secret |
Enable distributed tracing:
export OTEL_SERVICE_NAME="x402-facilitator"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"The facilitator supports pluggable signers via the createFacilitator() factory.
import { createFacilitator } from "./setup.js";
import { evmSigner, svmSigner } from "./signers/index.js";
const facilitator = createFacilitator({
evmSigners: [
{
signer: evmSigner,
networks: ["eip155:8453", "eip155:84532"],
schemes: ["exact", "upto"],
},
],
svmSigners: [
{
signer: svmSigner,
networks: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
},
],
hooks: {
onAfterSettle: async (ctx) => console.log("Settled:", ctx),
},
});Use Coinbase Developer Platform for managed key custody:
bun add @coinbase/cdp-sdkimport { CdpClient } from "@coinbase/cdp-sdk";
import { createCdpEvmSigner, createFacilitator } from "./setup.js";
// Initialize CDP (uses env vars by default)
const cdp = new CdpClient();
const account = await cdp.evm.getOrCreateAccount({ name: "facilitator" });
// Create signer for Base
const cdpSigner = createCdpEvmSigner({
cdpClient: cdp,
account,
network: "base",
rpcUrl: process.env.EVM_RPC_URL_BASE,
});
// Create facilitator
const facilitator = createFacilitator({
evmSigners: [
{ signer: cdpSigner, networks: "eip155:8453", schemes: ["exact", "upto"] },
],
});import { createMultiNetworkCdpSigners, createFacilitator } from "./setup.js";
const signers = createMultiNetworkCdpSigners({
cdpClient: cdp,
account,
networks: {
base: process.env.EVM_RPC_URL_BASE,
"base-sepolia": process.env.BASE_SEPOLIA_RPC_URL,
optimism: process.env.OPTIMISM_RPC_URL,
},
});
const facilitator = createFacilitator({
evmSigners: [
{ signer: signers.base!, networks: "eip155:8453" },
{ signer: signers["base-sepolia"]!, networks: "eip155:84532" },
{ signer: signers.optimism!, networks: "eip155:10" },
],
});| CDP Network | CAIP-2 | Chain ID |
|---|---|---|
base |
eip155:8453 |
8453 |
base-sepolia |
eip155:84532 |
84532 |
ethereum |
eip155:1 |
1 |
ethereum-sepolia |
eip155:11155111 |
11155111 |
optimism |
eip155:10 |
10 |
arbitrum |
eip155:42161 |
42161 |
polygon |
eip155:137 |
137 |
avalanche |
eip155:43114 |
43114 |
Returns supported payment schemes and networks.
Response:
{
"kinds": [
{ "x402Version": 2, "scheme": "exact", "network": "eip155:8453" },
{ "x402Version": 2, "scheme": "upto", "network": "eip155:8453" },
{ "x402Version": 2, "scheme": "exact", "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" }
],
"signers": {
"eip155": ["0x..."],
"solana": ["..."]
}
}Validates a payment signature against requirements.
Request:
{
"paymentPayload": {
"x402Version": 2,
"resource": { "url": "...", "description": "...", "mimeType": "..." },
"accepted": {
"scheme": "exact",
"network": "eip155:8453",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"amount": "1000",
"payTo": "0x..."
},
"payload": { "signature": "0x...", "authorization": {} }
},
"paymentRequirements": {
"scheme": "exact",
"network": "eip155:8453",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"amount": "1000",
"payTo": "0x..."
}
}Response (Success):
{ "isValid": true, "payer": "0x..." }Response (Failure):
{ "isValid": false, "invalidReason": "invalid_signature" }Executes on-chain payment settlement.
Request: Same as /verify
Response (Success):
{
"success": true,
"transaction": "0x...",
"network": "eip155:8453",
"payer": "0x..."
}Response (Failure):
{
"success": false,
"errorReason": "insufficient_balance",
"network": "eip155:8453"
}Immediate, single-transaction settlement. Each payment request results in one on-chain transfer.
Supported tokens:
- USDC on Base (
0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) - SPL tokens on Solana
Permit-based flow for efficient EVM token payments:
- Client signs once - ERC-2612 Permit for a cap amount
- Multiple requests - Reuse the same Permit signature
- Automatic batching - Sweeper settles accumulated spend
- Settlement triggers:
- Idle timeout (2 minutes of inactivity)
- Deadline buffer (60 seconds before Permit expires)
- Cap threshold (90% of cap reached)
Session Lifecycle:
┌─────────┐ verify ┌─────────┐ sweep/close ┌─────────┐
│ None │ ───────────────▶│ Open │ ──────────────────▶ │ Closed │
└─────────┘ └────┬────┘ └─────────┘
│ settle
▼
┌─────────┐
│Settling │
└────┬────┘
│ success
▼
Back to Open (if cap/deadline allow)
Limitations:
- ERC-2612 Permit tokens only (not EIP-3009)
- EOA signatures only (no smart wallets/EIP-1271)
- In-memory sessions (lost on restart)
import { createFacilitator } from "./setup.js";
const facilitator = createFacilitator({
evmSigners: [
{
signer: evmSigner,
networks: ["eip155:8453", "eip155:10", "eip155:42161"], // Base + Optimism + Arbitrum
schemes: ["exact", "upto"],
},
],
});Add custom logic at key points:
const facilitator = createFacilitator({
evmSigners: [{ signer, networks: "eip155:8453" }],
hooks: {
onBeforeVerify: async (ctx) => {
// Rate limiting, logging
},
onAfterVerify: async (ctx) => {
// Track verified payments
},
onBeforeSettle: async (ctx) => {
// Validate before settlement
},
onAfterSettle: async (ctx) => {
// Analytics, notifications
},
onSettleFailure: async (ctx) => {
// Alerting, retry logic
},
},
});Replace in-memory storage with persistent storage:
import { UptoSessionStore } from "./upto/store.js";
class RedisSessionStore implements UptoSessionStore {
async get(id: string) { /* Redis get */ }
async set(id: string, session: UptoSession) { /* Redis set */ }
async delete(id: string) { /* Redis del */ }
async entries() { /* Redis scan */ }
}Create adapters for other wallet providers:
import { toFacilitatorEvmSigner } from "@x402/evm";
const customSigner = toFacilitatorEvmSigner({
address: wallet.address,
getCode: (args) => publicClient.getCode(args),
readContract: (args) => publicClient.readContract(args),
writeContract: (args) => wallet.writeContract(args),
verifyTypedData: (args) => publicClient.verifyTypedData(args),
sendTransaction: (args) => wallet.sendTransaction(args),
waitForTransactionReceipt: (args) => publicClient.waitForTransactionReceipt(args),
});# Run tests
bun test
# Watch mode
bun test:watch
# Coverage
bun test:coverage-
Start the facilitator:
bun dev
-
Start the demo paid API:
bun smoke:api
-
Run the smoke client:
export CLIENT_EVM_PRIVATE_KEY="0x..." bun smoke:upto
-
Private Key Management
- Use CDP for managed custody (recommended)
- Or use secrets managers (AWS Secrets Manager, HashiCorp Vault)
- Never commit
.envfiles with real keys - Rotate keys periodically
-
Network Security
- Run behind a reverse proxy (nginx, Cloudflare)
- Enable TLS/HTTPS
- Implement rate limiting
-
Signature Validation
- All signatures verified via EIP-712 typed data
- Permit deadlines enforced with buffer
- Network/chain ID validation prevents replay attacks
-
Session Persistence
- Replace
InMemoryUptoSessionStorewith Redis/PostgreSQL - Required for multi-instance deployments
- Replace
-
RPC Resilience
- Configure multiple RPC endpoints
- Implement retry logic with exponential backoff
- Consider RPC providers with built-in failover
-
Monitoring
- Enable OpenTelemetry tracing
- Set up alerts for settlement failures
- Monitor transaction costs and gas prices
# docker-compose.yml
services:
facilitator:
build: .
environment:
# Option 1: CDP (recommended)
- CDP_API_KEY_ID=${CDP_API_KEY_ID}
- CDP_API_KEY_SECRET=${CDP_API_KEY_SECRET}
- CDP_WALLET_SECRET=${CDP_WALLET_SECRET}
# Option 2: Raw private keys
# - EVM_PRIVATE_KEY=${EVM_PRIVATE_KEY}
# - SVM_PRIVATE_KEY=${SVM_PRIVATE_KEY}
- PORT=8090
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318
ports:
- "8090:8090"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8090/supported"]
interval: 30s
timeout: 10s
retries: 3Networks use CAIP-2 format:
| Network | Identifier |
|---|---|
| Ethereum Mainnet | eip155:1 |
| Base Mainnet | eip155:8453 |
| Base Sepolia | eip155:84532 |
| Optimism | eip155:10 |
| Arbitrum | eip155:42161 |
| Polygon | eip155:137 |
| Solana Devnet | solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 |
| Solana Mainnet | solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp |
MIT