Skip to content

402md/relay

Repository files navigation

@402md/relay

Bun Elysia TypeScript Drizzle PostgreSQL Redis Viem Stellar SDK x402 License

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.

How it works

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.

Full payment flow

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.

Stack

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

Project structure

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

API reference

POST /skills/:slug/:endpointPath/verify

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"
}

POST /skills/:slug/:endpointPath/settle

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"
}

GET /supported

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"]
  }
}

GET /health

{ "status": "ok", "service": "relay" }

Settlement providers

Base (EVM)

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

Stellar (Soroban)

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_ledger expiration in auth entry
  • Settlement time: ~5 seconds

Both providers implement retry with exponential backoff (3 attempts: 2s → 4s → 8s).

Fee structure

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

Idempotency

Settlements are idempotent via Redis locks keyed by settle:{network}:{payer}:{nonce}:

  1. SET NX EX 60 acquires a lock before settlement
  2. On success, the result is cached for 300s
  3. Duplicate requests return the cached result
  4. On failure, the lock is released so the request can be retried

Database

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).

Tables used

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

Setup

Prerequisites

  • Bun >= 1.0
  • PostgreSQL (shared with main backend)
  • Redis

Install

bun install

Environment variables

Copy .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

Run

# Development (hot reload)
bun run dev

# Production
bun run start

Commands

bun 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

docker build -t 402md-relay .
docker run -p 3001:3001 --env-file .env 402md-relay

Code quality

  • ESLint with typescript-eslint, eslint-plugin-prettier, and eslint-plugin-drizzle
  • Prettier for formatting (no semicolons, single quotes, trailing comma: none)
  • Husky pre-commit hook runs: lint-stagedtsc --noEmitprettier --check .eslint
  • Husky commit-msg hook enforces conventional commits (feat:, fix:, chore:, etc.)
  • TypeScript strict mode with noUnusedLocals and noUnusedParameters

How sellers configure their paywall

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

Comparison with existing facilitators

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)

License

MIT

About

Non-custodial x402 facilitator with atomic fee splitting. Routes payments through on-chain splitter contracts on Base and Stellar

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors