Non-custodial x402 facilitator with atomic fee splitting. Implements the standard x402 facilitator interface (/verify + /settle + /supported) and routes payments through on-chain splitter contracts.
Called by the seller's paywall middleware, not by the agent directly.
Standard x402 Facilitator (Coinbase) 402md Relay (this service)
───────────────────────────────────── ─────────────────────────────────────
Agent signs → facilitator verifies → Agent signs → relay verifies →
transfer(agent → seller) splitter.pay(agent, seller, amount)
100% goes to seller. No fee. ├── 99.75% → seller
└── 0.25% → treasury
Atomic split. Non-custodial.
The relay is a drop-in replacement for standard x402 facilitators. It adds a 0.25% platform fee via an on-chain splitter contract that distributes funds in the same transaction. The contract balance is always $0 after each tx — 402md never holds user funds.
1. Agent requests seller API (e.g., POST scraper.com/v1/scrape)
2. Seller's paywall middleware returns HTTP 402:
{
x402Version: 2,
accepts: [{
scheme: "exact",
network: "eip155:8453",
amount: "10000",
payTo: "0xSPLITTER...",
asset: "0x833589...", // USDC
extra: { facilitator: "https://relay.402.md/skills/web-scraper-pro" }
}]
}
3. Agent's x402 client signs a USDC authorization and resends with X-PAYMENT header
4. Seller's paywall receives X-PAYMENT:
a. POST relay.402.md/skills/web-scraper-pro/v1~scrape/verify
→ Relay validates signature, returns { isValid: true, payer: "0xAGENT" }
b. POST relay.402.md/skills/web-scraper-pro/v1~scrape/settle
→ Relay resolves seller wallet by slug
→ Calls splitter.pay(agent, seller, USDC, amount)
→ Returns { success: true, transaction: "0xTXHASH", network: "eip155:8453" }
c. On success → next() → serve response to agent
5. Agent receives response + PAYMENT-RESPONSE header
The agent never knows the relay exists. The seller's paywall middleware handles all communication with the relay.
| Layer | Technology | Why |
|---|---|---|
| Runtime | Bun | ~50ms cold start, native fetch, built-in test runner |
| Framework | Elysia.js | Type-safe, schema validation via TypeBox, near-zero overhead |
| ORM | Drizzle ORM | SQL-like, zero overhead, schema-first |
| Database | PostgreSQL | Shared with the main backend (reads skills/wallets, writes ledger) |
| Cache | Redis | Idempotency locks (SET NX EX) |
| Blockchain | viem (Base), @stellar/stellar-sdk (Stellar) | EVM + Soroban support |
relay/
├── src/
│ ├── index.ts # Entrypoint — app.listen()
│ ├── app.ts # Elysia app with plugins and routes
│ │
│ ├── routes/
│ │ ├── verify.ts # POST /skills/:slug/:endpointPath/verify
│ │ ├── settle.ts # POST /skills/:slug/:endpointPath/settle
│ │ └── supported.ts # GET /supported
│ │
│ ├── services/
│ │ ├── facilitator.ts # Core: verify + settle logic
│ │ └── ledger.ts # Writes CREDIT + FEE to PostgreSQL
│ │
│ ├── providers/
│ │ ├── types.ts # SettleParams interface
│ │ ├── base.ts # EVM: SplitterBase.settleAndSplit() via viem
│ │ └── stellar.ts # Soroban: splitter.pay() via stellar-sdk
│ │
│ ├── db/
│ │ ├── client.ts # Drizzle client (postgres.js)
│ │ ├── schema.ts # Drizzle schema (skills, wallets, ledger)
│ │ └── queries.ts # findSkillBySlug, findSellerWallet, etc.
│ │
│ ├── config/
│ │ ├── env.ts # Typed env validation
│ │ └── tokens.ts # Token contract addresses per network
│ │
│ └── lib/
│ ├── decimal.ts # Fee calculation helpers
│ ├── errors.ts # AppError, ValidationError, SettlementError, etc.
│ └── redis.ts # Idempotency lock helpers
│
└── test/
├── verify.test.ts
├── settle.test.ts
└── helpers/
└── fixtures.ts
Validates that a PaymentPayload is legitimate (valid signature, correct amount, unused nonce). Does not submit anything on-chain.
Request body:
{
"x402Version": 2,
"paymentPayload": {
"x402Version": 2,
"accepted": { "scheme": "exact", "network": "eip155:8453", "...": "..." },
"payload": { "from": "0xAGENT...", "nonce": "0x...", "signature": "0x..." }
},
"paymentRequirements": {
"scheme": "exact",
"network": "eip155:8453",
"asset": "0x833589...",
"amount": "10000",
"payTo": "0xSPLITTER...",
"maxTimeoutSeconds": 300,
"extra": {}
}
}Response:
{
"isValid": true,
"payer": "0xAGENT..."
}On failure:
{
"isValid": false,
"invalidReason": "Amount mismatch: expected 10000, got 5000"
}Submits the payment on-chain via the splitter contract. Uses :slug to resolve the seller address from the database and calls splitter.pay(agent, seller, token, amount). The :endpointPath is recorded in the ledger for analytics (e.g., v1~scrape maps to /v1/scrape).
Same request body as /verify.
Response:
{
"success": true,
"payer": "0xAGENT...",
"transaction": "0xTXHASH...",
"network": "eip155:8453"
}On failure:
{
"success": false,
"errorReason": "SETTLEMENT_FAILED",
"errorMessage": "Agent has insufficient USDC balance",
"transaction": "",
"network": "eip155:8453"
}Returns which networks and schemes the facilitator supports.
Response:
{
"kinds": [
{ "x402Version": 2, "scheme": "exact", "network": "eip155:8453" },
{ "x402Version": 2, "scheme": "exact", "network": "eip155:84532" },
{ "x402Version": 2, "scheme": "exact", "network": "stellar:pubnet" },
{ "x402Version": 2, "scheme": "exact", "network": "stellar:testnet" }
],
"extensions": [],
"signers": {
"eip155:*": ["0xRELAY_ADDRESS"],
"stellar:*": ["GRELAY_PUBLIC_KEY"]
}
}{ "status": "ok", "service": "relay" }Uses EIP-3009 TransferWithAuthorization. The agent signs authorization for the splitter contract to pull USDC.
SplitterBase.settleAndSplit(token, from, seller, value, validAfter, validBefore, nonce, v, r, s)
1. receiveWithAuthorization(from → contract) ← EIP-3009, msg.sender == to
2. transfer(contract → seller, 99.75%)
3. transfer(contract → treasury, 0.25% + gas)
Modifier: onlyRelay — only the relay address can call
Contract balance after each tx: $0
- Relay pays gas in ETH (~$0.005, deducted from seller's share in USDC)
- Replay-safe: EIP-3009 invalidates nonce internally on the USDC contract
- Settlement time: ~2 seconds
Uses SorobanAuthorizationEntry (auth tree). The agent signs authorization covering both sub-invocations (token.transfer x2).
splitter.pay(from, seller, token, amount)
1. from.require_auth() ← validates full auth tree
2. token.transfer(from → seller, 99.75%)
3. token.transfer(from → treasury, 0.25%)
Atomic: same tx, same auth tree
Gas: relay pays XLM (~$0.00001)
- Relay is the source account (pays gas in XLM, absorbed by the platform)
- Replay-safe: nonce +
max_ledgerexpiration in auth entry - Settlement time: ~5 seconds
Both providers implement retry with exponential backoff (3 attempts: 2s → 4s → 8s).
Fees are deducted from the seller's share. The agent always pays the exact skill price.
| Network | Platform fee | Gas fee (deducted from seller) | Settlement |
|---|---|---|---|
| Stellar | 0.25% | $0 (platform absorbs ~$0.00001 XLM) | ~5 seconds |
| Base | 0.25% | $0.005 | ~2 seconds |
Example ($0.50 skill on Base):
Agent pays: $0.50
Platform fee (0.25%): -$0.00125
Gas fee: -$0.005
Seller receives: $0.49375
Treasury receives: $0.00625
Settlements are idempotent via Redis locks keyed by settle:{network}:{payer}:{nonce}:
SET NX EX 60acquires a lock before settlement- On success, the result is cached for 300s
- Duplicate requests return the cached result
- On failure, the lock is released so the request can be retried
The relay shares the same PostgreSQL instance as the main backend. It reads from skills and seller_wallets, and writes to ledger_entries. The Drizzle schema mirrors only the tables the relay needs — it does not own or run migrations (the main backend's ORM is the schema owner).
| Table | Access | Purpose |
|---|---|---|
skills |
read | Resolve slug → skill (price, seller, currency, status) |
seller_wallets |
read | Resolve seller → wallet address per network |
ledger_entries |
write | Record CREDIT + FEE entries per settlement |
- Bun >= 1.0
- PostgreSQL (shared with main backend)
- Redis
bun installCopy .env.example and fill in values:
cp .env.example .env| Variable | Description |
|---|---|
PORT |
Server port (default: 3001) |
DATABASE_URL |
PostgreSQL connection string |
REDIS_URL |
Redis connection string |
STELLAR_HORIZON_URL |
Horizon server URL |
STELLAR_SOROBAN_RPC_URL |
Soroban RPC URL |
STELLAR_NETWORK_PASSPHRASE |
Network passphrase |
STELLAR_SPLITTER_CONTRACT_ID |
Soroban splitter contract address |
STELLAR_RELAY_SECRET |
Relay's Stellar secret key (source account, pays gas) |
STELLAR_RELAY_PUBLIC |
Relay's Stellar public key |
BASE_RPC_URL |
Base RPC endpoint |
BASE_CHAIN_ID |
Chain ID (8453 = mainnet, 84532 = sepolia) |
BASE_SPLITTER_ADDRESS |
SplitterBase contract address |
BASE_RELAY_PRIVATE_KEY |
Relay's EVM private key (pays gas) |
BASE_RELAY_ADDRESS |
Relay's EVM public address |
# Development (hot reload)
bun run dev
# Production
bun run startbun run dev # Start with --watch
bun run start # Production start
bun test # Run test suite
bun run typecheck # TypeScript strict check
bun run lint # ESLint (auto-fix)
bun run format # Prettier (write)
bun run format:fix # Prettier (write all files)docker build -t 402md-relay .
docker run -p 3001:3001 --env-file .env 402md-relay- ESLint with
typescript-eslint,eslint-plugin-prettier, andeslint-plugin-drizzle - Prettier for formatting (no semicolons, single quotes, trailing comma: none)
- Husky pre-commit hook runs:
lint-staged→tsc --noEmit→prettier --check .→eslint - Husky commit-msg hook enforces conventional commits (
feat:,fix:,chore:, etc.) - TypeScript strict mode with
noUnusedLocalsandnoUnusedParameters
import { paywall } from '@402md/x402'
app.post(
'/v1/scrape',
paywall({
price: '0.01',
payTo: '0xSPLITTER...', // splitter contract (singleton)
network: 'base',
facilitatorUrl: 'https://relay.402.md/skills/web-scraper-pro'
}),
handler
)The paywall middleware automatically appends the endpoint path and /verify or /settle:
Base facilitator URL: https://relay.402.md/skills/web-scraper-pro
Endpoint called: /v1/scrape
Verify call: POST https://relay.402.md/skills/web-scraper-pro/v1~scrape/verify
Settle call: POST https://relay.402.md/skills/web-scraper-pro/v1~scrape/settle
| Coinbase (x402.org) | OpenZeppelin (Stellar) | 402md Relay | |
|---|---|---|---|
| Interface | /verify + /settle |
/verify + /settle |
/skills/:slug/:endpoint/verify + /settle |
| Settlement | transfer(agent -> payTo) |
transfer(agent -> payTo) |
splitter.pay(agent, seller) |
| Fee | 0% | 0% | 0.25% (atomic split) |
payTo points to |
Seller directly | Seller directly | Splitter contract |
| Context | Stateless | Stateless | Slug resolves seller from DB |
| Networks | Base | Stellar | Base + Stellar |
| Non-custodial | Yes | Yes | Yes (splitter distributes in same tx) |
MIT