diff --git a/.gitignore b/.gitignore index f7da16b..4668226 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,13 @@ dist coverage .DS_Store -# Markdown files (except skills and application content) +# Markdown files (except skills, application content, and public docs) *.md !README.md !.claude/skills/**/*.md !packages/*/src/**/*.md +!docs/index.md +!docs/tutorials/**/*.md +!docs/how-to/**/*.md +!docs/reference/**/*.md +!docs/explanation/**/*.md diff --git a/README.md b/README.md index bc542b6..b288cf9 100644 --- a/README.md +++ b/README.md @@ -9,36 +9,15 @@ [![Stellar](https://img.shields.io/badge/Stellar-Soroban-blueviolet)](https://stellar.org) [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6)](https://www.typescriptlang.org) -**[402md](https://402.md/)** is a company specialized in payment infrastructure. The **Facilitator** is a cross-chain USDC settlement service for the x402 and MPP ecosystems. +**Cross-chain USDC settlement for agentic payments.** Buyer pays on any supported chain, seller receives on theirs — one wallet, one HTTP request, zero bridging logic. -## Why - -A seller monetizing an API via x402 can only receive payments from buyers on chains they explicitly support. A seller on Stellar misses every buyer on Base or Solana. Adding chains means managing multiple wallets, multiple SDKs, and bridging logic. - -The 402md Facilitator eliminates this. The seller registers once — one wallet, one chain — and gets a `merchantId` plus the facilitator's receiving addresses on every supported chain. Buyers pay on whatever chain they're on using standard `@x402/client`, zero changes. The Facilitator identifies the seller, bridges via [Circle CCTP V2](https://www.circle.com/cross-chain-transfer-protocol) (native USDC, no wrapped tokens, no slippage), and delivers USDC to the seller's wallet. Same-chain payments settle directly. - -Dual-protocol: supports both [x402](https://x402.org) (Coinbase's HTTP 402 protocol) and [MPP](https://www.machinepayments.com/) (Machine Payments Protocol) via the [`@stellar/mpp`](https://github.com/stellar/stellar-mpp-sdk) SDK. Sellers on Stellar get micropayments through Charge Mode with zero custom libraries. - -Includes a **bazaar** — a discovery endpoint where agents can find paywalled services — and an **on-ramp** endpoint listing fiat-to-USDC providers (Bridge.xyz, MoneyGram). - -Free and open source. No platform fee, sellers only pay network gas. MIT licensed. - -## How It Works - -Without 402md, this payment fails: - -```mermaid -flowchart LR - Buyer["Buyer\n(Solana)"] - Seller["Seller\n(Stellar)"] +**[402md](https://402.md)** is a company building payment infrastructure. The Facilitator is its open-source rail for the [x402](https://x402.org) and [MPP](https://www.machinepayments.com) ecosystems. - Buyer -- "USDC on Solana" --x Seller +## Why - style Buyer fill:#1a1a2e,stroke:#9945FF,color:#fff - style Seller fill:#1a1a2e,stroke:#7B68EE,color:#fff -``` +Without 402md, a seller only receives payments from buyers on chains they explicitly support. A seller on Stellar misses every buyer on Base or Solana. Adding chains means managing multiple wallets and multiple SDKs. -With 402md: +402md collapses that. The seller registers once — **one wallet, one chain** — and gets a `merchantId` plus the facilitator's addresses on every supported chain. Buyers pay on whatever chain they're on using the standard `@x402/client`. The facilitator verifies, bridges via [Circle CCTP V2](https://www.circle.com/cross-chain-transfer-protocol) (native USDC, no wrapped tokens, no slippage), and delivers USDC to the seller's wallet. ```mermaid flowchart LR @@ -57,124 +36,11 @@ flowchart LR style Seller fill:#1a1a2e,stroke:#7B68EE,color:#fff ``` -### x402 Cross-Chain Settlement - -Example: an AI agent on Base pays for a search API hosted by a seller on Stellar. The agent gets the resource in milliseconds. Settlement runs in background via Temporal. - -When the destination is Stellar, the EVM adapter uses `depositForBurnWithHook` with `CctpForwarder` — the CCTP V2 contract that atomically mints and forwards USDC to the seller's Stellar address. - -```mermaid -sequenceDiagram - participant Agent as AI Agent (Base) - participant Seller as Seller API (Stellar) - participant Relay as 402md Relay - participant Worker as 402md Worker - participant Source as Base - participant CCTP as Circle CCTP V2 - participant Dest as Stellar - - Agent->>Seller: GET /search?q=stellar - Seller-->>Agent: 402 Payment Required (accepts: Base, Solana, Stellar) - - Note over Agent: Signs USDC authorization
to facilitator address on Base - - Agent->>Seller: GET /search + payment header - Seller->>Relay: POST /verify (paymentPayload + merchantId) - Relay-->>Seller: { isValid: true } ~ms - Seller-->>Agent: 200 OK + results - - Note over Agent: Agent has results.
Settlement happens async - - Seller->>Relay: POST /settle (paymentPayload + merchantId) - Relay->>Worker: startWorkflow(crossChainSettle) - - rect rgb(45, 50, 60) - Note over Worker,Dest: Temporal Workflow — crossChainSettle - Worker->>Source: 1. pullFromBuyer — $1.00 USDC - Source-->>Worker: pull tx confirmed - Note over Worker: 2. Retain gas allowance ($0.0005) - Worker->>Source: 3. depositForBurnWithHook — $0.9995
(mintRecipient = CctpForwarder,
hookData = seller's Stellar address) - Source-->>Worker: burn tx confirmed - Worker->>CCTP: 4. waitAttestation (poll Circle Iris API) - CCTP-->>Worker: attestation received - Worker->>Dest: 5. receive_message on MessageTransmitter
CctpForwarder mints + forwards to seller - Dest-->>Worker: mint tx confirmed - Worker->>Worker: 6. Record in ledger - end -``` - -### x402 Same-Chain Settlement - -Both parties on the same chain. No bridge needed. - -```mermaid -sequenceDiagram - participant Agent as AI Agent (Base) - participant Seller as Seller API (Base) - participant Relay as 402md Relay - participant Worker as 402md Worker - participant Chain as Base - - Agent->>Seller: GET /weather - Seller-->>Agent: 402 Payment Required - - Agent->>Seller: GET /weather + payment header - Seller->>Relay: POST /verify - Relay-->>Seller: { isValid: true } ~ms - Seller-->>Agent: 200 OK + resource - - Seller->>Relay: POST /settle - Relay->>Worker: startWorkflow(sameChainSettle) - - rect rgb(45, 50, 60) - Note over Worker,Chain: Temporal Workflow — sameChainSettle - Worker->>Chain: 1. pullFromBuyer — $1.00 USDC - Note over Worker: 2. Deduct gas allowance - Worker->>Chain: 3. Transfer net USDC to seller - Worker->>Worker: 4. Record in ledger - end -``` - -### MPP Charge Mode (Stellar) - -MPP uses push mode via the official `@stellar/mpp` SDK. The buyer signs a Soroban SAC `transfer` invocation. The facilitator verifies and settles on-chain. - -```mermaid -sequenceDiagram - participant Agent as AI Agent (Stellar) - participant Seller as Seller API (Stellar) - participant Relay as 402md Relay - - Agent->>Seller: GET /search?q=stellar - Seller->>Relay: GET /merchants/:id/mpp/charge?amount=1000000 - Relay-->>Seller: 402 + WWW-Authenticate (MPP challenge) - Seller-->>Agent: 402 + MPP challenge - - Note over Agent: Signs Soroban SAC transfer
via @stellar/mpp client - - Agent->>Seller: GET /search + Payment credential - Seller->>Relay: GET /merchants/:id/mpp/charge + credential - Note over Relay: @stellar/mpp SDK verifies
tx on-chain - Relay-->>Seller: 200 + Payment-Receipt - Seller-->>Agent: 200 OK + results -``` +Dual-protocol: x402 (Coinbase) for cross-chain pull payments, and [MPP](https://www.machinepayments.com) (Stripe + Tempo) via the [`@stellar/mpp`](https://github.com/stellar/stellar-mpp-sdk) SDK for Stellar-native push payments. -### Settlement Times +## Seller quick start -| Origin | Destination | Time | -| ---------- | --------------- | ---------- | -| Stellar | Base, Solana | ~5-10s | -| Solana | Base, Stellar | ~25-30s | -| Base (EVM) | Solana, Stellar | ~15-19 min | -| Same-chain | Same-chain | < 5s | - -> Settlement time is dominated by source chain finality. Circle issues the CCTP attestation after hard finality; the destination mint is near-instant. - -## Seller DX - -No dashboard, no login, no SDK. One curl to start receiving cross-chain USDC: - -**1. Register your wallet (one-time):** +**1. Register (one curl, no auth, no dashboard):** ```bash curl -X POST https://api.402md.com/register \ @@ -182,22 +48,9 @@ curl -X POST https://api.402md.com/register \ -d '{ "wallet": "GSELLER...", "network": "stellar:pubnet" }' ``` -**Response:** - -```json -{ - "merchantId": "hb-a1b2c3", - "wallet": "GSELLER...", - "network": "stellar:pubnet", - "facilitatorAddresses": { - "eip155:8453": "0xFacilitatorBase", - "solana:mainnet": "FacilitatorSolAddr", - "stellar:pubnet": "GFacilitatorStellarAddr" - } -} -``` +You get a `merchantId` and a map of facilitator addresses per enabled chain. -**2. Use the standard `@x402/express` SDK from Coinbase — zero 402md dependencies:** +**2. Use standard `@x402/express` from Coinbase — no 402md SDK:** ```typescript import { paymentMiddleware } from '@x402/express' @@ -226,280 +79,93 @@ app.use( ) ``` -**3. Need USDC? Check available on-ramp providers:** - -```bash -curl https://api.402md.com/onramp?network=stellar:pubnet&walletAddress=GSELLER... -``` - -That's it. The seller's API now accepts USDC from any supported chain. Buyers on Base pay on Base; the seller receives on Stellar. No bridging logic, no multi-chain wallet management. - -## API Endpoints - -### Sellers - -| Method | Endpoint | Description | -| ------ | --------------------------- | ---------------------------------------------------------------- | -| `POST` | `/register` | Register seller wallet, get `merchantId` + facilitator addresses | -| `GET` | `/discover?merchantId=` | Accepted networks + fees for a seller (cacheable) | -| `GET` | `/supported` | List all supported networks | -| `GET` | `/.well-known/x402.json` | x402 V2 service discovery metadata | - -### Settlements (x402) - -| Method | Endpoint | Description | -| ------ | -------------------------------------- | --------------------------------------- | -| `POST` | `/verify` | Verify buyer payment (~ms, synchronous) | -| `POST` | `/settle` | Dispatch settlement workflow (async) | -| `GET` | `/bridge/status/:workflowId` | Real-time settlement status + tx hashes | -| `GET` | `/bridge/fees?from=&to=` | Fee quote for a chain pair | - -### MPP (Stellar) - -| Method | Endpoint | Description | -| ---------- | ----------------------------------- | ---------------------------------------------- | -| `GET` | `/merchants/:merchantId/mpp/config` | MPP method spec (currency, recipient, network) | -| `GET/POST` | `/merchants/:merchantId/mpp/charge` | Charge Mode — handles 402 challenge + payment | - -### Discovery & On-Ramp - -| Method | Endpoint | Description | -| ------ | --------- | --------------------------------------------------- | -| `GET` | `/bazaar` | List registered sellers (filter by network, search) | -| `GET` | `/onramp` | List fiat-to-USDC providers (Bridge.xyz, MoneyGram) | -| `GET` | `/health` | Service health check (DB, Redis, Temporal) | - -## Supported Chains - -All EVM chains share the same `createEvmAdapter` — one private key, one `0x...` address across every EIP-155 chain. Non-EVM chains each have their own adapter and key. - -| Chain | CAIP-2 | CCTP Domain | Family | SDK | -| --------------- | ---------------- | ----------- | ------- | ---------------------- | -| **Base** | `eip155:8453` | 6 | EVM | `viem` | -| **Ethereum** | `eip155:1` | 0 | EVM | `viem` | -| **Optimism** | `eip155:10` | 2 | EVM | `viem` | -| **Arbitrum** | `eip155:42161` | 3 | EVM | `viem` | -| **Linea** | `eip155:59144` | 11 | EVM | `viem` | -| **Unichain** | `eip155:130` | 10 | EVM | `viem` | -| **World Chain** | `eip155:480` | 14 | EVM | `viem` | -| **Solana** | `solana:mainnet` | 5 | Solana | `@solana/web3.js` | -| **Stellar** | `stellar:pubnet` | 27 | Stellar | `@stellar/stellar-sdk` | - -| Family | Pull Mechanism | CCTP Burn | -| ------- | ------------------------------------------------ | --------------------------------------------------------- | -| EVM | EIP-3009 `transferWithAuthorization` | `depositForBurn` / `depositForBurnWithHook` (for Stellar) | -| Solana | Facilitator as fee payer + SPL `TransferChecked` | CCTP program `depositForBurn` | -| Stellar | Facilitator as fee source + payment operation | Soroban `deposit_for_burn` on TokenMessengerMinter | - -When the destination is Stellar (CCTP domain 27), the EVM adapter uses `depositForBurnWithHook` with the `CctpForwarder` contract. This encodes the seller's Stellar address in `hookData` so the forwarder atomically mints and forwards USDC to the seller. - -Chains are **opt-in** — set `FACILITATOR_` + the chain's RPC URL and the chain is registered at boot. Unconfigured chains are silently excluded, so the relay/worker start fine with a subset. - -Adding another CCTP V2 chain (e.g., BNB Smart Chain when it gets native USDC, or any new EVM CCTP V2 deployment) requires only a new chain definition — zero contract deployments, zero audits. Chains on CCTP V1 only (Polygon, Avalanche) are out of scope until V1 support lands in the adapter. - -## Fee Model - -**No platform fee.** 402md Facilitator charges 0% commission. The only cost is a gas allowance — a fixed amount per route that covers the on-chain transactions the facilitator submits on behalf of the buyer and seller (pull, burn, mint, transfer). This allowance is deducted from the gross payment before delivering to the seller, so the facilitator is never out of pocket. - -### How it works - -In x402, the buyer signs an authorization but **does not pay gas** — the facilitator submits all transactions. The gas allowance reimburses the facilitator from the payment itself: - -``` -Buyer pays: $1.000000 USDC -Gas allowance: -$0.000500 (fixed, covers pull + burn + mint) -Platform fee: -$0.000000 (0%) - ────────── -Seller receives: $0.999500 USDC -``` - -In MPP Charge Mode, the buyer broadcasts the transaction directly and pays gas themselves. No deduction from the seller. - -### Gas allowance per route +That's it. Buyers on Base pay on Base, the seller receives on Stellar. -| Route | Gas Allowance | Seller receives (on $1.00) | -| ----------------- | ------------- | -------------------------- | -| Base → Stellar | $0.000500 | $0.999500 | -| Stellar → Base | $0.000500 | $0.999500 | -| Base → Solana | $0.000800 | $0.999200 | -| Solana → Base | $0.001200 | $0.998800 | -| Solana → Stellar | $0.000800 | $0.999200 | -| Stellar → Solana | $0.000800 | $0.999200 | -| Base → Base | $0.000400 | $0.999600 | -| Solana → Solana | $0.000800 | $0.999200 | -| Stellar → Stellar | $0.000006 | $0.999994 | +Full walkthrough → [**tutorial: first cross-chain payment**](./docs/tutorials/01-first-cross-chain-payment.md). -These are fixed values, not estimates. If actual gas is lower than the allowance, the facilitator retains the difference. If higher, the facilitator absorbs it. CCTP itself charges no fee — burn/mint is 1:1. +## Documentation -### Summary +Organized under the [Diátaxis](https://diataxis.fr) framework. -| Scenario | Cost | Who Pays | -| ------------------ | ------------------------------------- | --------------------------- | -| Same-chain (x402) | Gas allowance (fixed schedule) | Deducted from seller payout | -| Cross-chain (x402) | Gas allowance (fixed schedule) | Deducted from seller payout | -| MPP Charge | Gas (buyer broadcasts, pays directly) | Buyer | -| Platform fee | None (0%) | — | +| | Description | Start here | +| ----------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| 📚 **[Tutorials](./docs/tutorials/)** | Learn by following end-to-end examples | [Your first cross-chain payment](./docs/tutorials/01-first-cross-chain-payment.md) | +| 🛠️ **[How-to guides](./docs/how-to/)** | Solve a specific task — register, accept multiple chains, deploy, monitor | [Register and get addresses](./docs/how-to/sellers/register-and-get-addresses.md) | +| 📖 **[Reference](./docs/reference/)** | Full API, supported chains, env vars, fees, error codes | [API overview](./docs/reference/api/overview.md) | +| 💡 **[Explanation](./docs/explanation/)** | Why the system is designed this way | [Architecture overview](./docs/explanation/architecture.md) | -## Security +Start at **[`docs/index.md`](./docs/index.md)** for the full map. -- **Non-custodial** — CCTP mints directly to seller via CctpForwarder. Facilitator never custodies seller funds -- **No custom smart contracts** — calls standard USDC (EIP-3009) + CCTP TokenMessenger/CctpForwarder via chain SDKs -- **Circuit breakers** — per-tx limit, daily volume limit, global pause (all off-chain via Redis) -- **Replay protection** — EIP-3009 nonce (EVM) + authorization nonce (Solana/Stellar) + Redis dedup -- **Gas wallet isolation** — facilitator hot wallet can only submit settlement transactions -- **Idempotent workflows** — deterministic Temporal workflow IDs prevent duplicate settlements - -## Monorepo Structure - -``` -packages/ -├── relay/ @402md/relay — HTTP API (Elysia/Bun) -├── worker/ @402md/worker — Settlement workflows (Temporal/Node.js) -├── shared/ @402md/shared — Network adapters, DB schema, cache, tracing -├── demo-seller/ @402md/demo-seller — Example: paywalled search API on Stellar -└── demo-agent/ @402md/demo-agent — Example: agent that discovers + pays services -scripts/ -└── demo.sh End-to-end demo orchestrator -``` +## What makes it different -| Package | Runtime | Framework | Purpose | -| ------------- | ------- | --------------------- | ---------------------------------------------------------------------- | -| `relay` | Bun | Elysia.js | HTTP API, seller registration, payment verification, Temporal dispatch | -| `worker` | Node.js | Temporal SDK | On-chain settlement: pull, CCTP burn/mint, ledger | -| `shared` | — | — | Network adapters (EVM, Solana, Stellar), Drizzle schema, Redis, OTEL | -| `demo-seller` | Bun | Elysia + @stellar/mpp | MPP-paywalled search API on Stellar testnet | -| `demo-agent` | Bun | @stellar/mpp | Agent that discovers sellers via bazaar and pays via MPP | +- **0% platform fee.** Only a fixed USDC gas allowance per route (e.g. $0.0005 on Base ↔ Stellar). No spread, no percentage cut. [See the schedule](./docs/reference/fees.md). +- **Native USDC, no wrapped tokens.** CCTP V2 burns on source, mints on destination. 1:1, no slippage. +- **Non-custodial.** CCTP mints directly to the seller's wallet via `CctpForwarder`. The facilitator never custodies seller funds. [Details](./docs/explanation/non-custodial-model.md). +- **No custom smart contracts.** Calls standard USDC (EIP-3009) + CCTP TokenMessenger directly. Zero audit surface, zero per-chain deploys. +- **Durable settlement via Temporal.** Every x402 payment is a workflow with retries, compensation, and observability. [Why](./docs/explanation/temporal-workflows.md). +- **Opt-in chains.** Set `FACILITATOR_` + RPC URL and the chain is registered at boot. Adding a new CCTP V2 chain is a config change. -> Worker uses Node.js because the Temporal SDK requires native modules incompatible with Bun. +## Supported chains -## Getting Started +Base, Ethereum, Optimism, Arbitrum, Linea, Unichain, World Chain (EVM — single key for all 7), Solana, Stellar. Mainnet and testnet. -### Prerequisites +Full table with CAIP-2 IDs, CCTP domains, and per-family pull mechanisms → [**reference: chains**](./docs/reference/chains.md). -- [Bun](https://bun.sh/) (latest) -- [Node.js](https://nodejs.org/) 20+ -- [Docker](https://www.docker.com/) (for local infrastructure) +## Settlement times -### Setup +| Source → Destination | Time | +| -------------------- | ------------------------------------------------- | +| Same-chain | < 5 s | +| Stellar → anything | ~5–10 s | +| Solana → anything | ~25–30 s | +| EVM → anything | ~15–19 min (source finality + Circle attestation) | -Start local infrastructure (PostgreSQL, Redis, Temporal): +## Running locally ```bash -docker compose up -d -``` +docker compose up -d # PostgreSQL + Redis + Temporal +temporal operator namespace create 402md-settlement -Install dependencies and build all packages: - -```bash bun install bun run build -``` -Push the database schema: - -```bash -cd packages/relay -bun run db:push +cd packages/relay && bun run db:migrate && bun run dev # terminal 1 +cd packages/worker && bun run dev # terminal 2 ``` -Run the relay: +Full setup, including wallet funding and one-time USDC approvals, in [**how-to: set up dev environment**](./docs/how-to/contributors/set-up-dev-environment.md). -```bash -cd packages/relay -bun run dev -``` - -Run the worker (separate terminal): - -```bash -cd packages/worker -bun run dev -``` - -The relay starts at `http://localhost:3000`. Temporal UI is available at `http://localhost:8233`. - -### Demo - -Run the full end-to-end demo (starts relay, worker, demo-seller, and demo-agent): +End-to-end demo: ```bash ./scripts/demo.sh ``` -The demo-seller auto-registers with the facilitator, creates a paywalled search API, and the demo-agent discovers it via `/bazaar`, makes paid queries via MPP Charge Mode, and prints results. - -### Environment Variables - -Each package requires a `.env` file. See `.env.example` in each package directory for required variables. +[How the demo works](./docs/how-to/contributors/run-the-demo.md). -Key variables: +## Monorepo -| Variable | Description | -| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| `NETWORK_ENV` | `testnet` or `mainnet` (default: `testnet`) | -| `FACILITATOR_STELLAR` | Your Stellar public key (receiving address) | -| `FACILITATOR_PRIVATE_KEY_STELLAR` | Your Stellar secret key (worker only, signs txs) | -| `FACILITATOR_BASE` (and `_ETHEREUM` / `_OPTIMISM` / `_ARBITRUM` / `_LINEA` / `_UNICHAIN` / `_WORLDCHAIN`) | Your EVM address per chain (same `0x...` across all EVM chains) | -| `FACILITATOR_PRIVATE_KEY_EVM` | Your EVM private key (worker only, shared across all 7 EVM chains) | -| `MPP_SECRET_KEY` | HMAC secret for MPP challenge binding | - -## Scripts - -| Command | Description | -| -------------------- | ------------------------------ | -| `bun run build` | Build all packages (Turborepo) | -| `bun run test` | Run all tests | -| `bun run lint` | Lint all packages | -| `bun run format` | Check formatting (Prettier) | -| `bun run format:fix` | Fix formatting | -| `./scripts/demo.sh` | Run end-to-end demo | - -### Relay-specific - -| Command | Description | -| --------------------- | --------------------------- | -| `bun run db:generate` | Generate Drizzle migrations | -| `bun run db:push` | Push schema to database | -| `bun run db:migrate` | Run migrations | - -## Infrastructure - -| Service | Port | Purpose | -| ------------- | ---- | ----------------------------------------------------------- | -| PostgreSQL 15 | 5432 | Application database (shared schema, relay owns migrations) | -| Redis 7 | 6379 | Replay protection, circuit breakers, daily volume tracking | -| Temporal | 7233 | Durable workflow orchestration (self-hosted OSS) | -| Temporal UI | 8233 | Workflow visibility dashboard | - -### Performance Targets +``` +packages/ +├── relay/ — HTTP API (Elysia/Bun) +├── worker/ — Settlement workflows (Temporal/Node.js) +├── shared/ — Network adapters, DB schema, cache, tracing +├── demo-seller/ — Example paywalled API on Stellar +└── demo-agent/ — Example agent that discovers + pays +``` -| Metric | Target | -| ---------------------- | ----------------------------------- | -| Verify latency | < 50ms p95 | -| Same-chain settlement | < 5s | -| Cross-chain settlement | ~5s-19min (depends on source chain) | -| Concurrent settlements | 100+ simultaneous workflows | -| Workflows/month | Up to 100K (single PG node) | -| Relay uptime | 99.9% | +Architecture deep-dive → [**explanation: architecture**](./docs/explanation/architecture.md). ## Contributing -Contributions are welcome! This is an open source project and we appreciate help from the community. - -1. Fork the repository -2. Create your feature branch (`git checkout -b feat/my-feature`) -3. Commit your changes (`git commit -m 'feat: add my feature'`) -4. Push to the branch (`git push origin feat/my-feature`) -5. Open a Pull Request - -See [`.claude/rules/code-standards.md`](./.claude/rules/code-standards.md) for coding conventions and [`.claude/rules/git-workflow.md`](./.claude/rules/git-workflow.md) for commit message format. +Contributions welcome. Fork, branch, PR. -## Key Documents +- [Set up a dev environment](./docs/how-to/contributors/set-up-dev-environment.md) +- [Add a new EVM chain](./docs/how-to/contributors/add-a-new-evm-chain.md) +- [Write a network adapter](./docs/how-to/contributors/write-a-new-network-adapter.md) -- [`402md-bridge-technical-spec.md`](./402md-bridge-technical-spec.md) — Full technical specification (~2,600 lines) -- [`docs/plans/`](./docs/plans/) — Implementation plans -- [`.claude/rules/`](./.claude/rules/) — Architecture decisions, code standards, security model +Follow the rules in [`.claude/rules/`](./.claude/rules/) — code standards, commit format, testing, security. ## License -402md Facilitator is licensed under the MIT license. See the [`LICENSE`](LICENSE) file for more information. +MIT. See [`LICENSE`](LICENSE). diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md new file mode 100644 index 0000000..5548518 --- /dev/null +++ b/docs/explanation/architecture.md @@ -0,0 +1,203 @@ +# Architecture overview + +The Facilitator has two long-running processes and three backing services. Every architectural choice here trades away generality for three properties: **cross-chain by default, durable under failure, low per-transaction cost**. + +## The two processes + +### Relay (`packages/relay`, Bun + Elysia) + +Stateless HTTP API. Accepts `POST /verify` and `POST /settle` from x402 sellers, returns cached discovery info, and dispatches Temporal workflows. It talks to PostgreSQL (to persist sellers and ledger entries), Redis (for replay protection and circuit breakers), and Temporal (to start workflows). It never signs a blockchain transaction. + +This is where HTTP hotpaths live. Verify latency has a target of < 50 ms p95, because it sits inline with the buyer's request. + +### Worker (`packages/worker`, Node.js + Temporal SDK) + +Stateful only in the sense that it holds keys. It polls two Temporal task queues (`fast-settlement`, `cross-settlement`) and executes activities: pull USDC from buyer, burn on CCTP, wait for attestation, mint on destination, record to ledger. Every chain interaction lives in this process. + +The worker runs on Node.js (not Bun) because the Temporal TypeScript SDK uses native modules that Bun's runtime does not yet support. This is the only reason for the split runtime. + +## The three backing services + +### PostgreSQL + +Single database shared by relay and worker. Schemas: + +- `sellers` — merchant IDs, wallet addresses, registered network. +- `bazaarResources` — the public catalog of paywalled resources, refreshed as payments flow. +- `transactions` — ledger of every settled payment. + +The relay owns migrations. The worker imports schema types from `@402md/shared` but does not generate migrations. This keeps one source of truth. + +Temporal itself also uses PostgreSQL (for its own persistence), but in a separate schema. Same database instance, logically separated. + +### Redis + +- Replay protection: `SETNX` on an EIP-3009 nonce / authorization nonce / transaction hash before settling. +- Daily volume circuit breaker: `INCRBY` against a daily-keyed counter with 24-hour TTL. +- Pause flag: single key `facilitator:pause`. +- Rate limits: per-IP counters per endpoint. + +No business data lives in Redis. If Redis disappears, the relay degrades (replay protection falls back to the DB-unique ledger), but settlements already in flight complete. + +### Temporal + +Durable workflow engine. Two workflow types: + +- `sameChainSettle` — buyer and seller on the same chain. Pull, transfer, record. +- `crossChainSettle` — different chains. Pull, CCTP burn, wait for Circle's attestation, mint on destination, record. + +Every on-chain call is a Temporal activity with its own retry policy. See [Temporal workflows](./temporal-workflows.md) for why this matters. + +## The full request flow + +```mermaid +flowchart TD + A[Buyer — any chain] -->|1. GET /resource| B[Seller API
@x402/express] + B -->|2. 402 Payment Required
accepts: chain1, chain2, ...| A + A -->|3. Sign EIP-3009 / Soroban / Solana authorization| A + A -->|4. GET /resource + X-PAYMENT header| B + B -->|5. POST /verify ~ms| R[Relay] + R -->|isValid: true| B + B -->|6. 200 OK + resource| A + B -.->|7. POST /settle async| R + R -->|8. startWorkflow| W[Worker] + W -->|pullFromBuyer| C1[Source chain] + W -->|cctpBurn| C1 + W -->|waitAttestation| IRIS[Circle Iris API] + W -->|cctpMint| C2[Destination chain] + W -->|recordPayment| DB[(PostgreSQL)] + C2 -->|USDC delivered| S[Seller wallet] + + style A fill:#1a1a2e,stroke:#9945FF,color:#fff + style B fill:#1a1a2e,stroke:#F59E0B,color:#fff + style R fill:#1a1a2e,stroke:#00D4AA,color:#fff + style W fill:#1a1a2e,stroke:#00D4AA,color:#fff + style S fill:#1a1a2e,stroke:#7B68EE,color:#fff + style IRIS fill:#1a1a2e,stroke:#00D4AA,color:#fff +``` + +Steps 1–6 are synchronous from the buyer's perspective; 7–end run in background. + +## x402 cross-chain settlement + +Example: an AI agent on Base pays for a search API hosted by a seller on Stellar. The agent gets the resource in milliseconds. Settlement runs in background via Temporal. + +When the destination is Stellar, the EVM adapter uses `depositForBurnWithHook` with `CctpForwarder` — the CCTP V2 contract that atomically mints and forwards USDC to the seller's Stellar address. + +```mermaid +sequenceDiagram + participant Agent as AI Agent (Base) + participant Seller as Seller API (Stellar) + participant Relay as Relay + participant Worker as Worker + participant Source as Base + participant CCTP as Circle CCTP V2 + participant Dest as Stellar + + Agent->>Seller: GET /search?q=stellar + Seller-->>Agent: 402 Payment Required (accepts: Base, Solana, Stellar) + + Note over Agent: Signs USDC authorization
to Facilitator address on Base + + Agent->>Seller: GET /search + payment header + Seller->>Relay: POST /verify (paymentPayload + merchantId) + Relay-->>Seller: { isValid: true } ~ms + Seller-->>Agent: 200 OK + results + + Note over Agent: Agent has results.
Settlement happens async + + Seller->>Relay: POST /settle (paymentPayload + merchantId) + Relay->>Worker: startWorkflow(crossChainSettle) + + rect rgb(45, 50, 60) + Note over Worker,Dest: Temporal Workflow — crossChainSettle + Worker->>Source: 1. pullFromBuyer — $1.00 USDC + Source-->>Worker: pull tx confirmed + Note over Worker: 2. Retain gas allowance ($0.0005) + Worker->>Source: 3. depositForBurnWithHook — $0.9995
(mintRecipient = CctpForwarder,
hookData = seller's Stellar address) + Source-->>Worker: burn tx confirmed + Worker->>CCTP: 4. waitAttestation (poll Circle Iris API) + CCTP-->>Worker: attestation received + Worker->>Dest: 5. receive_message on MessageTransmitter
CctpForwarder mints + forwards to seller + Dest-->>Worker: mint tx confirmed + Worker->>Worker: 6. Record in ledger + end +``` + +## x402 same-chain settlement + +Both parties on the same chain. No bridge needed. + +```mermaid +sequenceDiagram + participant Agent as AI Agent (Base) + participant Seller as Seller API (Base) + participant Relay as Relay + participant Worker as Worker + participant Chain as Base + + Agent->>Seller: GET /weather + Seller-->>Agent: 402 Payment Required + + Agent->>Seller: GET /weather + payment header + Seller->>Relay: POST /verify + Relay-->>Seller: { isValid: true } ~ms + Seller-->>Agent: 200 OK + resource + + Seller->>Relay: POST /settle + Relay->>Worker: startWorkflow(sameChainSettle) + + rect rgb(45, 50, 60) + Note over Worker,Chain: Temporal Workflow — sameChainSettle + Worker->>Chain: 1. pullFromBuyer — $1.00 USDC + Note over Worker: 2. Deduct gas allowance + Worker->>Chain: 3. Transfer net USDC to seller + Worker->>Worker: 4. Record in ledger + end +``` + +## Why relay + worker are separate + +- **Blast radius.** The relay is internet-facing. The worker holds private keys. Splitting the processes means a relay compromise never exposes signing material. +- **Scaling shape.** HTTP request rate and settlement throughput do not scale together. A relay pod handles thousands of verifies per second. A worker pod handles settlements bound by chain finality (minutes). Scale independently. +- **Runtime split.** Bun for the relay (faster HTTP, faster startup), Node for the worker (Temporal SDK compatibility). + +## Why Temporal + +Blockchain settlements span several transactions across multiple chains and can fail at any step. Temporal gives us: + +- **Durable state.** If the worker crashes mid-flow, it resumes exactly where it left off. A burned-but-not-minted workflow never disappears. +- **Retry policies per activity.** Attestation polling can retry for 30 minutes; on-chain transactions retry 10 times with exponential backoff. These are declared, not coded imperatively. +- **Search attributes.** Every workflow is indexed by `sellerNetwork`, `buyerNetwork`, `settlementStatus`, `protocol` — queryable from the UI or CLI. +- **Idempotent dispatch.** Workflow IDs are deterministic (derived from the payment signature hash). Duplicate `POST /settle` calls become no-ops. + +## No custom smart contracts + +Every on-chain call is against a standard contract: + +- USDC's `transferWithAuthorization` (EIP-3009) or equivalent on Solana/Stellar. +- Circle's `TokenMessengerV2.depositForBurn` and `MessageTransmitter.receiveMessage`. +- Circle's `CctpForwarder` when the destination is Stellar (lets EVM burns atomically forward to a Stellar address). + +This eliminates audit surface, removes per-chain deployment, and means adding a new EVM CCTP V2 chain is config-only. + +See [why CCTP V2](./why-cctp-v2.md). + +## No dashboard, no SDK + +The seller-side developer experience is `curl POST /register` and a paste-in `@x402/express` config. There is no login, no admin UI, no 402md SDK. Sellers use Coinbase's standard x402 middleware; the Facilitator is invisible to their codebase. + +The intent is to keep the Facilitator as close as possible to "a URL you post to" — minimal integration surface, maximum chain coverage. + +## What is not here + +- **No seller custody.** The Facilitator never holds seller funds beyond the gas allowance. See [non-custodial model](./non-custodial-model.md). +- **No per-transaction fee discovery.** Gas allowances are fixed per route. See [fees](../reference/fees.md). +- **No Temporal-less path.** Every settlement — even same-chain — goes through a workflow. The durability floor is the same for a $0.001 payment and a $10,000 payment. + +## Next + +- [Why CCTP V2](./why-cctp-v2.md) +- [Non-custodial model](./non-custodial-model.md) +- [Temporal workflows](./temporal-workflows.md) +- [Security model](./security-model.md) diff --git a/docs/explanation/dual-protocol-x402-mpp.md b/docs/explanation/dual-protocol-x402-mpp.md new file mode 100644 index 0000000..db5708e --- /dev/null +++ b/docs/explanation/dual-protocol-x402-mpp.md @@ -0,0 +1,74 @@ +# Dual protocol: x402 and MPP + +The facilitator speaks two HTTP payment protocols: [x402](https://x402.org) from Coinbase and [MPP](https://www.machinepayments.com) from Stripe and Tempo. They solve overlapping problems with different trade-offs; we support both because different buyer populations converge on different protocols. + +## TL;DR + +| Axis | x402 | MPP | +| -------------- | ------------------------------------------------- | --------------------------- | +| Who pays gas | Facilitator (buyer signs, facilitator broadcasts) | Buyer (broadcasts directly) | +| Cross-chain | Yes, via CCTP V2 | No (push to one chain) | +| Chains | EVM, Solana, Stellar | Stellar only, today | +| Seller library | `@x402/express` (Coinbase) | `@stellar/mpp` (Stellar) | +| Flow | Pull (facilitator ⇒ buyer) | Push (buyer ⇒ seller) | +| Best for | Any agent, any chain | Stellar-native agents | + +## x402 — pull, cross-chain, gas-free for buyer + +x402 layers on HTTP 402 Payment Required. A seller's endpoint returns 402 with a list of `accepts` entries: scheme, network, `payTo`, amount. The buyer's client picks one, signs an authorization against their own wallet, and retries with the signature in `X-PAYMENT`. + +The seller's middleware (e.g. `@x402/express`) forwards the signed payload to the Facilitator for `POST /verify`. On success, the seller returns the resource. Settlement runs async via `POST /settle`. + +**Properties that matter:** + +- **Gas-free for the buyer.** The signature is an authorization — the Facilitator broadcasts the actual pull transaction and pays the gas. Cost is folded into the gas allowance. +- **Pull model.** The facilitator pulls USDC from the buyer. No prior relationship, no deposit, no account. +- **Cross-chain.** Any supported chain in, any supported chain out. The buyer doesn't know or care that the seller is on a different chain. This is where CCTP V2 earns its keep. +- **Standard Coinbase middleware.** Sellers do not install a 402md SDK. They use `@x402/express` with a `payTo` pointing to the Facilitator and a `merchantId` in `extra`. + +Use x402 whenever you can. It is the primary protocol. + +## MPP — push, same-chain, buyer pays gas + +MPP's Charge Mode uses HTTP negotiation similarly, but the buyer broadcasts the payment themselves. The seller (or the Facilitator on behalf of the seller) verifies the on-chain transaction matches the challenge. + +On Stellar specifically, the `@stellar/mpp` SDK handles the full state machine: issue challenge, sign Soroban SAC transfer on the client, verify on the server. The facilitator's `/merchants/:id/mpp/charge` endpoint is just a hosted `Mppx` server that knows your `merchantId`. + +**Properties that matter:** + +- **Buyer pays gas.** There is no authorization — the buyer signs and broadcasts. This means no facilitator gas float per chain per buyer. +- **Push model.** The buyer sends the payment directly to the seller's wallet. The facilitator never touches the funds. +- **Stellar only, today.** The protocol is specified for Stellar first. The facilitator's implementation is Stellar-only. +- **No cross-chain.** The payment stays on whatever chain the buyer used. If the seller is elsewhere, MPP doesn't help. + +Use MPP when your buyer and seller are both on Stellar and you want to avoid the small gas float on the Facilitator side, or when your buyers prefer to pay their own gas (for regulatory/UX reasons). + +## Why not just x402? + +MPP adds real value for Stellar-native operations: + +- Stellar's operational footprint is small. MPP keeps it small — no facilitator-side broadcast, no attestation, no custody window. Settlement is a single Stellar tx. +- Some buyer populations (Stellar-first agents) already integrate `@stellar/mpp`. They can pay without learning x402. + +There is some duplication. A Stellar-to-Stellar payment can go either way: + +- x402: buyer signs an authorization, facilitator pulls and transfers. Facilitator pays the one Stellar fee (absorbed into the $0.000006 allowance). +- MPP: buyer broadcasts directly. Buyer pays the one Stellar fee. + +The difference is who pays the fee and what gets logged in Temporal. x402 paths always go through a workflow; MPP paths verify on-chain and return — no workflow state. + +## Why not just MPP? + +MPP is Stellar-shaped today. It does not define cross-chain semantics, and extending it to EVM would duplicate most of what x402 already specifies. Coinbase's x402 has the momentum on EVM; Stripe/Tempo have the momentum on Stellar. We support both and let buyers choose. + +## What this means for your integration + +- **Accepting both:** wire `@x402/express` for general coverage, and expose `/mpp/config` for Stellar-native agents. Both routes can coexist on the same Express app — they don't conflict. +- **Publishing only x402:** you lose access to Stellar-first agents, but you gain cross-chain. Probably the right default for most sellers. +- **Publishing only MPP:** you lose everyone outside Stellar. Useful if your product is Stellar-native. + +See [use MPP on Stellar](../how-to/sellers/use-mpp-on-stellar.md) for the MPP path and [build a paywalled API with x402](../tutorials/02-paywalled-api-with-x402.md) for the x402 path. + +## The future + +Expect both protocols to converge slowly. When MPP gets EVM semantics and x402 gets a richer dispute model, the choice will be less about chain and more about buyer UX. For now: x402 for breadth, MPP for depth on Stellar. diff --git a/docs/explanation/merchant-id-as-primitive.md b/docs/explanation/merchant-id-as-primitive.md new file mode 100644 index 0000000..a9d787c --- /dev/null +++ b/docs/explanation/merchant-id-as-primitive.md @@ -0,0 +1,70 @@ +# `merchantId` as a primitive + +The single most important design decision in the Facilitator's public API is that sellers have **one identity across all chains**. That identity is `merchantId`. + +## What it is + +A short opaque string — `hb-a1b2c3` in the documentation examples. It is returned by `POST /register` and it is the primary key of the `sellers` table. It is not secret; it is printed in logs, shown in dashboards, and carried in payment payloads. + +## What it solves + +A seller naturally has one wallet. Maybe two — one on Stellar for low-fee receiving, one on EVM for treasury — but more commonly, one. + +A seller's buyers come from wherever their agents run: Solana, Base, Stellar, and chains the seller has never interacted with. Without a facilitator, the seller's options are: + +1. Run wallets on every chain their buyers might use. (Operationally horrible.) +2. Only accept from chains the seller already has. (Loses buyers.) +3. Add bridge logic to their own stack. (Becomes a bridge operator.) + +`merchantId` collapses all three. The seller keeps one wallet. Buyers pay on whichever chain they are on, the Facilitator receives on the buyer's chain on the seller's behalf, bridges via CCTP V2, and mints to the seller's one wallet. The seller never sees a chain they don't operate on. + +## How it flows through the system + +1. Seller calls `POST /register` with a wallet and network. Gets back `merchantId` and `facilitatorAddresses` — one facilitator address per enabled chain. +2. Seller configures `@x402/express` with: + - `payTo` = the Facilitator's address on each accepted network. + - `extra.merchantId` = the seller's `merchantId`. +3. Buyer hits the paywalled endpoint. Gets 402 with the accepts list. Picks a chain, signs, retries. +4. Relay's `/verify` and `/settle` read `paymentRequirements.extra.merchantId` to know which seller to route to. +5. Worker looks up the seller's wallet from PostgreSQL using `merchantId`, and directs the cross-chain mint to that wallet. + +Without `merchantId` in the `extra` block, the Facilitator has no way to tell whose payment this is — `payTo` is the Facilitator, not the seller. `INVALID_PAYMENT` returns. + +## Why not use the wallet address directly? + +Three reasons. + +**Stability across wallet rotations.** A seller may rotate wallets (compromised key, migrated custody, change of hot wallet). Using the wallet address as identity would require every caller to update their config. `merchantId` can stay stable while the underlying wallet changes — though we have deliberately left the "rotate wallet" endpoint unbuilt (see below). + +**Short, portable key.** Wallet addresses on Stellar are 56 characters, on EVM are 42, on Solana are 44. A short opaque ID is friendlier in code, logs, URLs, and error messages. No chain-format awareness needed. + +**Chain-agnostic.** The wallet lives on one chain. The identity does not. An EVM seller today might accept payments from Solana tomorrow — `merchantId` is the thing that makes "which seller" independent of "which chain". + +## Why we haven't built "rotate wallet" + +Two reasons, one product, one technical. + +Product: sellers register for seconds. If the seller wants a new wallet, register again — they get a new `merchantId` and a fresh ledger. The simplicity of append-only matches how sellers actually behave. + +Technical: settlements in flight reference the old wallet. Rotating it mid-flight means either (a) failing the in-flight settlement, (b) settling to the old wallet and expecting the seller to move it, or (c) retroactively redirecting the mint. None of these are better than registering again. + +## Why `merchantId` is not an API key + +`merchantId` does not authenticate anything. It identifies the seller. Anyone who knows your `merchantId` and the relay URL can construct payments that settle to your wallet — which is fine, because those payments benefit you. + +The attack shape "someone swaps my `merchantId` for theirs in my config" is a supply-chain attack on the seller's own deployment, not a protocol-level issue. Standard supply-chain hygiene applies: keep your config in your repo, keep your repo under access control. + +## Why the naming + +The term comes from classic payment processing — a merchant ID is how Stripe / Braintree / traditional processors identify the business receiving funds. We kept the analogy to match the mental model x402 and MPP come from. + +"Seller" is the word we use for the role. `merchantId` is the id. Both terms show up in the code; we could have unified but the split is clearer — sellers are the business actors, merchant IDs are the database rows. + +## One more thing: it's the bazaar key too + +The bazaar (`/discovery/resources`, `/bazaar/*`) indexes sellers by `merchantId`. Analytics, leaderboards, cost comparisons — all keyed on `merchantId`. This is why registering is the gate to being discovered: no register, no `merchantId`, no bazaar entry. + +## Next + +- [API reference — sellers](../reference/api/sellers.md) — the `merchantId` in every endpoint. +- [Architecture overview](./architecture.md) — where the seller table fits. diff --git a/docs/explanation/non-custodial-model.md b/docs/explanation/non-custodial-model.md new file mode 100644 index 0000000..0719814 --- /dev/null +++ b/docs/explanation/non-custodial-model.md @@ -0,0 +1,73 @@ +# Non-custodial settlement (Model A) + +The facilitator never custodies seller funds. This is deliberate, and it constrains the design in ways that are worth understanding before you build on top of it. + +## What "non-custodial" means here + +A custodial settlement service sits between buyer and seller funds. It receives USDC, holds it on behalf of the seller, and pays out on request. The seller trusts the service with their balance. Traditional payment processors work this way. + +The 402md Facilitator does not. For every payment: + +1. Buyer signs an authorization — a cryptographic promise that the Facilitator can pull an exact amount from their wallet. +2. Facilitator pulls the amount. +3. Facilitator retains a fixed gas allowance (USDC). +4. Facilitator **burns the net amount on CCTP V2**. +5. Circle mints the same net amount on the destination chain, **directly to the seller's wallet**. + +The facilitator holds the net USDC for at most the duration of steps 3–4 — seconds on Stellar/Solana, minutes on EVM. The mint step is not a "payout" — the Facilitator is never the recipient. CCTP writes to the seller's address directly. + +## The `CctpForwarder` trick for Stellar + +On EVM chains, CCTP's default mint recipient is the address encoded at burn time. When the destination is Stellar, encoding a Stellar address directly does not work — the mint happens on Stellar, where the CCTP-native minter cannot call arbitrary contracts. + +So the worker uses `depositForBurnWithHook`: + +- `mintRecipient` is the Circle-deployed `CctpForwarder` contract on the destination (still an EVM-side concept for the protocol, but semantically the forwarder). +- `hookData` carries the seller's Stellar address. + +When Circle attests and the destination mint runs, `CctpForwarder` atomically mints to itself and forwards the USDC to the Stellar address. The facilitator never touches the funds. + +This is one transaction on destination, with no intermediate custody. + +## What the Facilitator **does** hold + +- **Gas allowances**, accumulated from many payments. The allowance is USDC; it is spent at the margin to fund new on-chain transactions. Think of this as the operator's working capital, not seller funds. +- **Facilitator's own funds for gas top-ups.** The operator must keep ETH/SOL/XLM on each chain to pay gas for the `pull`, `burn`, and `mint` transactions. This is operator money, not seller money. + +Neither of these is seller balance. If the Facilitator key is compromised, the blast radius is (a) any allowance accrued since the last sweep, and (b) the daily volume limit's worth of in-flight payments. See [security model](./security-model.md) for the full threat model. + +## What the Facilitator does **not** hold + +- **No seller balance, ever.** There is no concept of "your seller balance with 402md". There is no "withdraw" endpoint. Settlements flow through — they do not accumulate. +- **No buyer deposits.** Buyers do not pre-fund anything. They sign per-payment authorizations against their own wallet. + +This is why there is no KYC for sellers — regulators treat custodial money transmission very differently from non-custodial infrastructure. A non-custodial rail is closer to a router than to a bank. + +## The pre-approval step on EVM + +EVM has one quirk: USDC's `transferWithAuthorization` (EIP-3009) lets the Facilitator pull from the buyer without the buyer's explicit `approve` call. Good — zero friction for buyers. + +But the Facilitator itself needs to approve `TokenMessengerV2` to spend USDC from the Facilitator wallet. This is a one-time per-EVM-chain step, done during facilitator setup. It is a standard ERC-20 approval for `2^256 − 1` USDC. See [supported chains](../reference/chains.md#one-time-usdc-approval-per-evm-chain). + +This approval gives `TokenMessengerV2` permission to burn USDC owned by the Facilitator wallet. No other contract can access those funds. CCTP is audited by Circle and an upstream maintainer. + +## Why this shape + +Custody would let us batch payments and absorb gas into a float — the "Model B" referenced in the rules. It would also: + +- require a custody license in every jurisdiction we operate in; +- expose seller balances to a single point of failure (the operator's security posture); +- make account reconciliation a permanent operational burden; +- make the 0% fee unsustainable (custody costs money). + +Non-custodial inherits CCTP's atomicity and Circle's regulatory surface. We piggyback on both, and focus the product on the routing layer above. + +## Model B, later + +The rules reference "Model B" — a filler/pre-funding model where the Facilitator fronts USDC on the destination, settles in batches, and reconciles on source chains. This would reduce latency on EVM routes, but requires custody, reserves, and monitoring. We have deliberately not built it. If volume justifies it later, it would be a separate service — not a replacement for Model A. + +## Next + +- [Security model](./security-model.md) — what a compromise looks like and what it cannot do. +- [Supported chains](../reference/chains.md) — the one-time approval step per EVM chain. +- [Fees](../reference/fees.md) — why the gas allowance can be flat. diff --git a/docs/explanation/security-model.md b/docs/explanation/security-model.md new file mode 100644 index 0000000..a1e5ad5 --- /dev/null +++ b/docs/explanation/security-model.md @@ -0,0 +1,98 @@ +# Security model + +This document describes the threat model and the controls that bound damage when something fails. + +## Trust assumptions + +- **Circle's CCTP V2 attestation network** is trusted. If it is compromised, counterfeit USDC can be minted — but that is a Circle-level failure, not a facilitator-level one. +- **The buyer's wallet private key** is the buyer's to protect. +- **The seller's wallet private key** is the seller's to protect. The facilitator never possesses it. +- **The facilitator's private keys** are held only by the worker process, never on the relay, never in workflow input, never in logs. +- **PostgreSQL, Redis, and Temporal** are trusted infrastructure. A compromise there is a serious incident but bounded — see below. + +## Keys and signatures + +The facilitator holds three independent keys: + +| Key | Controls | Held by | +| --------------------------------- | ------------ | ----------- | +| `FACILITATOR_PRIVATE_KEY_EVM` | 7 EVM chains | Worker only | +| `FACILITATOR_PRIVATE_KEY_SOLANA` | Solana | Worker only | +| `FACILITATOR_PRIVATE_KEY_STELLAR` | Stellar | Worker only | + +Private keys are read from the environment at activity-execution time. They never appear in: + +- Workflow history (Temporal persists inputs — inputs never contain keys). +- Relay process memory or env. +- Log lines, including stack traces. + +Keys should come from a secret manager (Fly/GCP/AWS Secret Manager, Vault) — not from `.env` in production. + +**Buyer-side signatures** — EIP-3009 authorizations, Stellar payment signatures, Solana tx signatures — are included in workflow input. They are not sensitive on their own: they are single-use and nonce-bound. An attacker possessing a signature cannot reuse it (Redis replay protection + chain-level nonce). + +## What an attacker can do if the Facilitator key leaks + +Damage is bounded by three things: + +1. **Daily volume limit.** The attacker cannot pull or move more than `DAILY_VOLUME_LIMIT` of USDC in a 24-hour window. Default 10,000 USDC (on EVM base-units). Operators set this deliberately low in early access. +2. **Buyer authorizations are still required.** The facilitator key signs the Facilitator's own transactions (pulls, burns, mints). It does not, by itself, let the attacker pull from any buyer wallet. To drain a buyer, the attacker needs a valid buyer-signed authorization — which is single-use and bound to a nonce the buyer controls. +3. **Gas float is the only at-risk facilitator balance.** The facilitator holds ETH/SOL/XLM for gas, plus accrued USDC allowances. Nothing else. Seller funds are not held, ever. + +If a key compromise is suspected: + +- Toggle the pause flag (`redis-cli SET facilitator:pause 1`) immediately. +- Rotate the key on a new wallet, update `FACILITATOR_*` addresses. +- Re-register sellers if their `facilitatorAddresses` need to change. +- Investigate every workflow since the suspected compromise via Temporal's audit log. + +## Circuit breakers + +Three off-chain controls enforced at `POST /settle`: + +- **Per-tx limit (`MAX_TX_AMOUNT`)** — relay rejects single settle amounts over a threshold. +- **Daily volume limit (`DAILY_VOLUME_LIMIT`)** — Redis counter with 24-hour TTL. Shared across relay instances. +- **Global pause (`facilitator:pause` in Redis)** — blocks all settles. Intended for incident response and deploys. + +These controls are in the relay layer. They do not need signing keys, and they do not require rebooting the worker to take effect. See [trigger circuit breakers](../how-to/operations/trigger-circuit-breakers.md). + +## Replay protection + +A payment signature must settle at most once. + +- **Redis dedup.** Every successful `/settle` writes the signature hash to Redis with a long TTL. A second `/settle` with the same signature returns `REPLAY_DETECTED` without dispatching a workflow. +- **Chain-level nonce.** EIP-3009 (`nonce` field), Solana (blockhash + signer nonce), Stellar (sequence number + authorization nonce) all prevent on-chain replay at the protocol layer. This is the second line of defense if Redis is wiped. +- **Deterministic workflow IDs.** Temporal rejects workflow starts with an ID that is already running or completed — a third line of defense. + +Three layers sound excessive. They are complementary: Redis is fast (first line), the chain is authoritative (last line), and Temporal covers the case where Redis is wiped but PostgreSQL is not. + +## Seller registration has no auth + +`POST /register` is unauthenticated — anyone can register a wallet. This is intentional: the only consequence of registering is getting a `merchantId` you can use to route payments to your own wallet. An attacker registering a wallet they don't control simply creates a dead `merchantId`. + +Rate limit (3/hour per IP) prevents registration floods. + +Registering someone else's wallet as yours does not let you steal their payments — `payTo` in the x402 flow is the Facilitator's address, not the seller's. Payments to a `merchantId` always settle to the wallet registered with that `merchantId`. The attacker would be paying themselves. + +## Per-IP rate limits + +Rate limits are per IP, enforced by Redis counters. See [API overview](../reference/api/overview.md#rate-limits). Limits are generous for `/verify` and `/settle` (designed for machine-to-machine traffic) and strict for `/register` (3/hour). + +IPs are read from `X-Forwarded-For` if present, else connection IP. Operators behind multi-layer proxies must ensure only the outermost proxy writes `X-Forwarded-For`. + +## Transport + +- TLS termination at the load balancer. The relay does not terminate TLS. +- mTLS between the worker and Temporal when running across trust boundaries (e.g., Temporal Cloud). +- CORS is open by default on public endpoints so browser-based agents can call `/verify` / `/settle`. + +## What is out of scope + +- **Audit surface.** Because there are no custom smart contracts, there is no Solidity audit. The trust boundary is USDC (audited by Circle) and CCTP V2 (audited by Circle and upstream). +- **Seller-side security.** We do not protect sellers from losing their own keys. Seller key compromise is a seller problem, not a facilitator problem. +- **Buyer-side phishing.** We do not validate that a buyer's agent is not being tricked into signing bogus authorizations. That is the agent's client software's job. + +## Next + +- [Architecture overview](./architecture.md) +- [Trigger circuit breakers](../how-to/operations/trigger-circuit-breakers.md) +- [Non-custodial model](./non-custodial-model.md) diff --git a/docs/explanation/temporal-workflows.md b/docs/explanation/temporal-workflows.md new file mode 100644 index 0000000..44ee5f7 --- /dev/null +++ b/docs/explanation/temporal-workflows.md @@ -0,0 +1,136 @@ +# Temporal workflows and durability + +Every x402 settlement goes through a Temporal workflow. This document explains what that buys us and what it costs. + +## The problem without Temporal + +A cross-chain settlement is a chain of steps: + +``` +pull → burn → wait for attestation → mint → record +``` + +Each step can fail. Attestation can take 20 minutes. The worker can crash between `burn` and `mint`. Network partitions can duplicate requests. A naive implementation has to answer, for every pair of steps: + +- "Did we already do this?" +- "If we crash now, what's the safe next action?" +- "If the next step fails, what compensation do we run?" + +The state graph explodes. You either write a custom state machine with persistence and retries (months of work, many bugs), or use a workflow engine that solves it generically. + +## What Temporal gives us + +### Durable execution + +A Temporal workflow is a program whose stack is persisted. If the worker crashes halfway through `crossChainSettle`, the next worker resumes the workflow at the exact event where it crashed — with the same local variables, the same pending activity, the same retry counter. This is load-bearing: "the burn succeeded but we haven't minted yet" is always recoverable. + +### Retry policies per activity + +We declare retry policies per activity based on its shape: + +| Activity | Timeout | Max attempts | Backoff | +| ----------------------------------------------------------- | ------- | ------------ | ----------------- | +| `pullFromBuyer`, `transferToSeller`, `cctpBurn`, `cctpMint` | 2 min | 10 | 1 s → 30 s, ×2 | +| `waitAttestation` | 30 min | 20 | 5 s → 60 s, ×1.5 | +| `recordPayment` | 30 s | 5 | 500 ms → 10 s, ×2 | + +On-chain activities have short attempt windows but many attempts — if a tx doesn't land in 2 minutes, the RPC or gas estimate is probably stale. Attestation polling uses a longer window because Circle's Iris API is typically healthy but can have short hiccups. These numbers live in the worker code, auditable in one place. + +### Idempotent dispatch + +Workflow IDs are deterministic: derived from the payment signature. A duplicate `POST /settle` with the same payload finds an existing workflow and returns its ID — it does not start a second settlement. + +This is the cleanest way to handle client-side retries. The client does not need to implement dedup; Temporal handles it. + +### Query and signal + +The `status` query on each workflow returns the current step and tx hashes without waiting for termination. This powers `GET /bridge/status/:workflowId`. Sellers can poll in real time. + +### Search attributes for observability + +Every workflow is indexed by: + +- `sellerNetwork`, `buyerNetwork` — CAIP-2 strings. +- `settlementStatus` — current step. +- `protocol` — `x402` or `mpp`. + +These are first-class in Temporal's UI and CLI. "Show me all stuck attestations on Solana → Stellar in the last hour" is one query, not a log search. + +## The two workflow types + +### `sameChainSettle` + +Buyer and seller on the same chain. + +```mermaid +stateDiagram-v2 + [*] --> pulling + pulling --> transferring: pull tx confirmed + transferring --> recording: transfer tx confirmed + recording --> settled: ledger written + settled --> [*] + + pulling --> failed: pull activity fails + transferring --> failed: transfer fails + recording --> failed: ledger write fails + failed --> [*] +``` + +Three activities: `pullFromBuyer`, `transferToSeller`, `recordPayment`. Terminal in seconds. + +### `crossChainSettle` + +```mermaid +stateDiagram-v2 + [*] --> pulling + pulling --> burning: pull tx confirmed + burning --> attesting: burn tx confirmed + attesting --> minting: Circle attestation received + minting --> recording: mint tx confirmed + recording --> settled: ledger written + settled --> [*] + + pulling --> failed: PULL_FAILED (no funds moved) + burning --> failed: BURN_PENDING (operators retry) + attesting --> failed: timeout (burn final, attestation reusable) + minting --> failed: MINT_PENDING (attestation still valid) + recording --> failed: ledger write fails + failed --> [*] +``` + +Five activities: `pullFromBuyer`, `cctpBurn`, `waitAttestation`, `cctpMint`, `recordPayment`. Terminal in seconds to ~20 min depending on source chain. + +## Compensation — what happens when a step fails + +The code does not have `if error then rollback`. Instead: + +- **`pullFromBuyer` fails:** no funds moved. Workflow ends `failed`. Buyer can retry with a new authorization. +- **`cctpBurn` fails after `pullFromBuyer`:** USDC sits in the Facilitator wallet. Workflow ends `failed` with `BURN_PENDING`. Operators investigate (usually a gas or RPC issue) and either retry the burn (common) or refund the buyer off-chain. +- **`waitAttestation` times out:** burn is on-chain and final, attestation is valid indefinitely once Circle issues it. Operators retry the workflow manually, which resumes at `waitAttestation`. +- **`cctpMint` fails:** attestation is still valid. Operators retry. The buyer-side pull is final, so no buyer-side rollback is required. + +The property that makes this tractable is: **every chain call is idempotent given the same input**, because of nonces, workflow IDs, and attestation reuse. We never have to undo a successful step. + +## Cost: what Temporal makes harder + +### Workflow code is not normal code + +Temporal enforces determinism. Calling `Math.random()`, `Date.now()`, or non-deterministic side-effects inside a workflow body breaks replay. Developers must route those through activities. This is a learning curve for contributors new to Temporal. + +### Deployments must be migration-aware + +Changing a workflow definition requires versioning if in-flight workflows will outlive the deploy. This matters for EVM cross-chain settlements (up to 20 min) but rarely for same-chain (seconds). Use Temporal's `patched()` API when changing workflow code. + +### One more service to operate + +Temporal is one more moving part: PostgreSQL schema, server, UI, CLI. Docker Compose makes this trivial locally; in production, it is a real service to deploy or pay Temporal Cloud for. Operators should decide early between self-hosted and Cloud. + +## Why not a queue + idempotency key? + +We considered it. BullMQ / SQS + explicit state transitions in PostgreSQL would work for same-chain settlements. For cross-chain, the 20-minute attestation wait with retries, the multi-step compensation, and the observability requirements make custom state-machine code expensive to maintain. Temporal eats those requirements end-to-end. + +## Next + +- [Architecture overview](./architecture.md) — where workflows fit. +- [Monitor workflows in Temporal](../how-to/operations/monitor-workflows-in-temporal.md) — practical operator guide. +- [Error handling rules](../../.claude/rules/error-handling.md) — canonical retry policy per activity. diff --git a/docs/explanation/why-cctp-v2.md b/docs/explanation/why-cctp-v2.md new file mode 100644 index 0000000..48fd0fd --- /dev/null +++ b/docs/explanation/why-cctp-v2.md @@ -0,0 +1,77 @@ +# Why CCTP V2 + +CCTP V2 is Circle's second-generation Cross-Chain Transfer Protocol. It is the only protocol the Facilitator uses to move USDC across chains. This document explains that choice and what you inherit from it. + +## What CCTP V2 does + +CCTP V2 burns native USDC on a source chain and mints the same amount of native USDC on a destination chain. There is no wrapped token, no liquidity pool, and no market maker. Circle attests the burn via an off-chain signer network; the destination mint verifies the attestation and issues the USDC. + +The protocol supports 10+ chains: Ethereum, Base, Optimism, Arbitrum, Linea, Unichain, World Chain, Solana, Stellar, and more being added. + +## Why we picked it + +### 1:1, no slippage + +CCTP moves exactly `N` USDC from `A` to `B`. No fee, no spread, no variable cost. Compare this to any DEX-based bridge where `N` in at one end is `N × (1 − spread − slippage − LP fee)` at the other end. + +This is the single most important property for a cross-chain payment rail. Micropayments ($0.001) cannot tolerate a percentage-based bridging cost. + +### Native USDC everywhere + +The output is the canonical USDC issued by Circle on the destination chain — not `USDC.e`, not a wrapped variant. A seller receiving `100 USDC` via 402md holds exactly the same asset they would hold if they had been paid on that chain directly. + +### No liquidity provisioning + +Pool-based bridges require capital upfront to serve withdrawals. The facilitator has no pool. Every settlement burns and mints the payment itself — capital efficiency is 100%. + +### Attestation, not relayer trust + +Circle's attestation is a signed message anyone can verify. The worker is a relayer in the operational sense (it submits the destination-chain tx), but it does not hold trust: if our worker disappears, any party can fetch the attestation from Circle and submit the mint themselves. No funds can be locked by a facilitator outage. + +### V2 specifically + +- **`depositForBurnWithHook`** lets the source chain encode data consumed atomically at mint time. We use this with the `CctpForwarder` contract to ship EVM burns straight to Stellar addresses without an intermediate EVM-side hop. +- **Fast Transfer** on V2 reduces attestation latency on supported routes from ~15 min to ~seconds. The facilitator picks this path automatically on chains that support it. +- **Consistent `depositForBurn` signature** across all V2 chains. Adding a new V2 chain is a config entry, not an adapter rewrite. + +## What you inherit + +### Settlement time = source chain finality + attestation + +Not facilitator latency. Not a bridge's own delay. The floor is set by the source chain: + +| Source chain | Circle attestation time | Typical settlement | +| -------------- | ----------------------------------------- | ------------------ | +| Stellar | seconds (hard finality is seconds) | ~5–10 s | +| Solana | ~25 s (hard finality) | ~25–30 s | +| Base, Optimism | ~12–19 min (Ethereum L1 finality for L2s) | ~15–19 min | +| Ethereum | ~13 min (finality) | ~15 min | + +Speeding this up further is not possible without changing the attestation model — which would change the trust model. + +### We cannot do Polygon or Avalanche + +They are on CCTP V1. The `depositForBurn` signature differs, settlement is ~13 min even on fast chains, and there is no `depositForBurnWithHook`. Supporting them means a second adapter, a second test matrix, and a second codepath for the Stellar destination. We chose to wait for V2 deployments instead. + +### We depend on Circle's Iris API + +Activity `waitAttestation` polls `https://iris-api.circle.com/v2/messages/...` every 5 s with a 30-minute timeout. If Circle's API is down, attestation stalls — but the burn is on-chain and final. Recovery is automatic as soon as the API recovers; no funds are at risk. + +### Reusable attestations + +Once Circle issues an attestation, it is valid indefinitely. If our mint transaction fails, we retry with the same attestation. This is why "burn succeeded, mint failed" is not a fund-loss scenario — see [error handling](../how-to/sellers/check-settlement-status.md#when-settlement-fails). + +## What we would rebuild if CCTP disappeared + +In order of how much the design depends on it: + +1. **Native-USDC invariant.** We would have to pick between (a) wrapped tokens, accepting the lower UX bar, or (b) a liquidity-pool bridge, accepting variable cost. Both are worse. +2. **Cross-chain coverage.** Ethereum ↔ Solana ↔ Stellar via a single protocol is rare. Most rails specialize in one family. +3. **Zero-capital operation.** No pool, no reserve. Losing this means the Facilitator would need to fund every route in advance. + +CCTP V2 is load-bearing. The [architecture](./architecture.md) is shaped around its primitives, not just its endpoints. + +## Next + +- [Non-custodial model](./non-custodial-model.md) — how `CctpForwarder` makes mint-to-seller atomic. +- [Supported chains](../reference/chains.md) — the current deployment matrix. diff --git a/docs/how-to/contributors/add-a-new-evm-chain.md b/docs/how-to/contributors/add-a-new-evm-chain.md new file mode 100644 index 0000000..a159e84 --- /dev/null +++ b/docs/how-to/contributors/add-a-new-evm-chain.md @@ -0,0 +1,103 @@ +# Add a new EVM chain + +CCTP V2 is deployed on 7 EVM chains today. When Circle deploys V2 on another EVM chain, adding it to the Facilitator is a config change — no new adapter, no contract deploy, no audit. + +This guide walks through adding a hypothetical chain `Foo Chain`, CAIP-2 `eip155:4200`, CCTP domain `42`. + +## 1. Register the chain definition + +Open `packages/shared/src/networks/index.ts`. Add a new entry to the EVM chain array: + +```typescript +import { fooChain, fooChainSepolia } from 'viem/chains' + +const evmChains: ChainDefinition[] = [ + // … existing entries … + { + slug: 'fooChain', + caip2: { mainnet: 'eip155:4200', testnet: 'eip155:4201' }, + viemChain: { mainnet: fooChain, testnet: fooChainSepolia }, + envAddressKey: 'FACILITATOR_FOOCHAIN', + envRpcKey: 'FOOCHAIN_RPC_URL', + cctpDomain: 42, + usdcContract: { + mainnet: '0x...', + testnet: '0x...', + }, + tokenMessengerV2: { + mainnet: '0x...', + testnet: '0x...', + }, + }, +] +``` + +All addresses (USDC, TokenMessengerV2) are from Circle's public [CCTP V2 deployment docs](https://developers.circle.com/stablecoins/docs/cctp-protocol-contract). + +## 2. Add the gas allowance + +Open `packages/shared/src/networks/gas-schedule.ts`. Add the new chain's allowances in every direction against every other chain it can settle with. For a mainnet-only chain and three existing chains (Base, Solana, Stellar): + +```typescript +{ from: 'fooChain', to: 'fooChain', allowance: '500' }, // same-chain +{ from: 'fooChain', to: 'base', allowance: '500' }, +{ from: 'base', to: 'fooChain', allowance: '500' }, +{ from: 'fooChain', to: 'solana', allowance: '800' }, +{ from: 'solana', to: 'fooChain', allowance: '1200' }, +{ from: 'fooChain', to: 'stellar', allowance: '800' }, +{ from: 'stellar', to: 'fooChain', allowance: '800' }, +``` + +Pick allowances based on observed gas costs on the new chain. Start generous — the Facilitator absorbs the variance, so being too low hurts the operator. You can tighten numbers later based on production data. + +## 3. Update the documentation + +Two places: + +- `docs/reference/chains.md` — add the new chain to both the mainnet and testnet tables. +- The root `README.md` chain list — add an entry to keep marketing copy in sync. + +## 4. Test + +Spin up a worker with `FACILITATOR_FOOCHAIN` and `FOOCHAIN_RPC_URL` set. Register a seller on the new chain: + +```bash +curl -X POST http://localhost:3000/register \ + -H "Content-Type: application/json" \ + -d '{ "wallet": "0xSELLER...", "network": "eip155:4201" }' +``` + +Check: + +- `GET /supported` lists `eip155:4201`. +- `GET /.well-known/x402.json` includes the chain under `networks`. +- `GET /bridge/fees?from=eip155:4201&to=stellar:pubnet&amount=1000000` returns the expected allowance. + +Make a tiny payment end-to-end (source: new chain, destination: Stellar) via the demo agent. Watch the Temporal workflow in the UI to confirm every step. + +## 5. Approve USDC spend on the new chain + +One-time: the Facilitator wallet must approve `TokenMessengerV2` on the new chain: + +```bash +cast send \ + \ + "approve(address,uint256)" \ + \ + $(python3 -c "print(2**256-1)") \ + --private-key $FACILITATOR_PRIVATE_KEY_EVM \ + --rpc-url $FOOCHAIN_RPC_URL +``` + +Document it in the team's operations runbook so it's not forgotten during the next deploy. + +## Pitfalls + +- **Same EVM key, different address?** EVM uses a single key across all chains. Make sure `FACILITATOR_FOOCHAIN` is the same `0x…` as `FACILITATOR_BASE` et al. If you generate a new key you will break settlement on every other chain. +- **Chain not on CCTP V2?** Then you cannot add it here. CCTP V1 (Polygon, Avalanche) has a different signature and slower finality — out of scope until V1 is supported. +- **Using `depositForBurnWithHook` to a non-Stellar destination?** Don't. The hook is only wired for Stellar destinations. EVM → EVM and EVM → Solana use plain `depositForBurn`. + +## Next + +- [Write a network adapter](./write-a-new-network-adapter.md) — for non-EVM chains (Solana, Stellar, Aptos-like families). +- [Supported chains reference](../../reference/chains.md) diff --git a/docs/how-to/contributors/run-the-demo.md b/docs/how-to/contributors/run-the-demo.md new file mode 100644 index 0000000..0d2a3c7 --- /dev/null +++ b/docs/how-to/contributors/run-the-demo.md @@ -0,0 +1,134 @@ +# Run the end-to-end demo + +The repo ships with a demo agent and demo seller that exercise the full stack: bazaar discovery, MPP charge mode, and the Facilitator's paywall flow. Running the demo is the fastest sanity check that every piece works together. + +## The short version + +```bash +./scripts/demo.sh +``` + +That's it. It builds the monorepo, boots the relay, worker, demo seller, and demo agent in order, runs a full discovery + pay cycle, and exits cleanly. + +Before the first run, make sure you have: + +- Docker Compose running (`docker compose up -d`). +- The Temporal namespace created (see [set up dev environment](./set-up-dev-environment.md), step 2). +- At least the Stellar facilitator env configured — the demo defaults to Stellar testnet. + +## What the demo does + +### Under the hood — `scripts/demo.sh` + +``` +1. bun run build +2. Start relay (PORT=$RELAY_PORT, default 3000) + sleep 2 +3. Start worker + sleep 2 +4. Start demo-seller (DEMO_SELLER_PORT, default 4000) + sleep 3 +5. Run demo-agent (blocks until done) +6. On exit: kill relay, worker, seller +``` + +### demo-seller (`packages/demo-seller`) + +- Auto-registers with the relay: `POST /register` with `{ wallet: $SELLER_WALLET, network: $SELLER_NETWORK }`. +- Receives a `merchantId`. +- Starts an HTTP server on `$DEMO_SELLER_PORT` with two routes: + - `GET /` — metadata (name, merchantId, price, facilitator URL). + - `GET /search?q=...` — paywalled at 1,000,000 stroops (0.001 USDC) via MPP charge mode. + +MPP verification is delegated to the Facilitator's `/merchants/:id/mpp/charge` endpoint. The seller forwards the challenge/receipt dance to the relay. + +### demo-agent (`packages/demo-agent`) + +1. `GET /bazaar` on the relay to discover services. +2. For each of 5 queries (`stellar`, `cctp`, `x402`, `mpp`, `facilitator`): + - Hits the seller's `/search?q=...` with `@stellar/mpp` fetch. + - Receives 402 + challenge, signs a Soroban SAC transfer with `AGENT_SECRET_KEY`, retries. + - Prints the search results. +3. Logs a summary: queries completed, agent wallet. + +## Environment it needs + +From the demo script: + +```bash +# Optional — defaults shown +RELAY_PORT=3000 +DEMO_SELLER_PORT=4000 +FACILITATOR_URL=http://localhost:3000 + +# Required for the demo-seller and demo-agent +SELLER_WALLET=GSELLER... +SELLER_NETWORK=stellar:testnet +AGENT_SECRET_KEY=SAGENT... +``` + +Generate `SELLER_WALLET` and `AGENT_SECRET_KEY` as separate Stellar testnet keypairs. Fund both via Friendbot so they hold XLM (for Soroban fees) and — on the agent — testnet USDC (to pay for queries). + +The relay and worker pull everything else from `packages/relay/.env` and `packages/worker/.env`. + +## Running components individually + +Useful when debugging one part of the flow. + +### Just the relay + worker + +```bash +# terminal 1 +cd packages/relay && bun run dev +# terminal 2 +cd packages/worker && bun run dev +``` + +### Seller only + +```bash +cd packages/demo-seller +FACILITATOR_URL=http://localhost:3000 \ +SELLER_WALLET=GSELLER... \ +SELLER_NETWORK=stellar:testnet \ +DEMO_SELLER_PORT=4000 \ +bun run src/index.ts +``` + +Curl it: + +```bash +curl http://localhost:4000/ # metadata +curl http://localhost:4000/search?q=hello # 402 + MPP challenge +``` + +### Agent only + +```bash +cd packages/demo-agent +FACILITATOR_URL=http://localhost:3000 \ +DEMO_SELLER_URL=http://localhost:4000 \ +AGENT_SECRET_KEY=SAGENT... \ +bun run src/index.ts +``` + +## What "success" looks like + +End of run: + +``` +[agent] discovered 1 service(s) +[agent] paid for 5/5 queries +[agent] wallet: GAGENT... +``` + +Open Temporal UI at [http://localhost:8233](http://localhost:8233) — no workflows should appear for the demo, because MPP charge mode verifies the Stellar SAC transfer inline and does not go through the worker. (If you want workflows to appear, swap the demo to use x402 by giving the seller a paywalled route behind `@x402/express`.) + +## Failure modes + +- **Demo-seller fails to register** — `FACILITATOR_URL` is wrong, or the relay isn't running. Check `curl $FACILITATOR_URL/health`. +- **Demo-agent sees `discovered 0 services`** — the seller didn't register (see above), or the seller registered on a different network than the agent filtered for (agent doesn't filter today, but will in the future). +- **`@stellar/mpp` signing fails** — `AGENT_SECRET_KEY` is invalid, not funded with XLM for fees, or not funded with testnet USDC. +- **402 loop that never resolves** — `MPP_SECRET_KEY` differs between the seller and the relay's `/mpp/charge`. Both must match. + +## Next + +- [Set up dev environment](./set-up-dev-environment.md) +- [Use MPP on Stellar](../sellers/use-mpp-on-stellar.md) — the MPP flow the demo exercises. diff --git a/docs/how-to/contributors/set-up-dev-environment.md b/docs/how-to/contributors/set-up-dev-environment.md new file mode 100644 index 0000000..8b4a587 --- /dev/null +++ b/docs/how-to/contributors/set-up-dev-environment.md @@ -0,0 +1,171 @@ +# Set up a development environment + +You want to hack on the Facilitator itself. This guide gets you from a fresh clone to a running local stack with test wallets. + +## 1. Clone and install + +```bash +git clone https://github.com/402md/facilitator.git +cd facilitator + +bun install +``` + +## 2. Start infrastructure + +```bash +docker compose up -d +``` + +Wait ~10 s for Temporal to finish auto-setup, then: + +```bash +temporal operator namespace create 402md-settlement + +temporal operator search-attribute create \ + --name sellerNetwork --type Keyword \ + --name buyerNetwork --type Keyword \ + --name settlementStatus --type Keyword \ + --name protocol --type Keyword +``` + +## 3. Generate test wallets + +You need facilitator wallets on every chain you want to test. One EVM key works across all 7 EVM chains. + +**EVM:** + +```bash +# Install foundry, or use any library that outputs a keypair +cast wallet new +``` + +Save the address and private key. + +**Stellar testnet:** + +Use the [Stellar Lab](https://laboratory.stellar.org/#account-creator?network=test) account creator. Save the public key (`G…`) and secret (`S…`). + +**Solana devnet:** + +```bash +solana-keygen new --outfile ~/.config/solana/devnet-facilitator.json +cat ~/.config/solana/devnet-facilitator.json | base64 +``` + +The base64 string is what you put in `FACILITATOR_PRIVATE_KEY_SOLANA`. + +## 4. Fund the wallets + +Testnet funding options: + +- **Stellar:** [Friendbot](https://laboratory.stellar.org/#account-creator?network=test) gives you XLM. Get testnet USDC via Stellar's circles.com sandbox (ask the team for a link — there is no public faucet yet). +- **Base Sepolia:** use a public [Base Sepolia faucet](https://www.alchemy.com/faucets/base-sepolia) for ETH; use [Circle's USDC faucet](https://faucet.circle.com) for testnet USDC. +- **Solana devnet:** `solana airdrop 2
`; USDC via Circle's faucet. + +## 5. One-time USDC approval on each EVM chain + +The facilitator wallet must approve `TokenMessengerV2` to spend USDC. Do this once per EVM chain you plan to enable: + +```bash +cast send \ + \ + "approve(address,uint256)" \ + \ + $(python3 -c "print(2**256-1)") \ + --private-key $FACILITATOR_PRIVATE_KEY_EVM \ + --rpc-url $BASE_SEPOLIA_RPC_URL +``` + +Circle's docs list `USDC_CONTRACT_ADDRESS` and `TOKEN_MESSENGER_V2_ADDRESS` per chain. You only do this once per chain — the approval is for `2^256 − 1` USDC and never expires. + +## 6. Configure env files + +```bash +cp packages/relay/.env.example packages/relay/.env +cp packages/worker/.env.example packages/worker/.env +``` + +Fill in at least: + +```bash +NETWORK_ENV=testnet + +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/fourzerotwomd +REDIS_URL=redis://localhost:6379 +TEMPORAL_ADDRESS=localhost:7233 +TEMPORAL_NAMESPACE=402md-settlement + +FACILITATOR_BASE=0x... +FACILITATOR_STELLAR=G... +FACILITATOR_SOLANA=... + +# worker only: +FACILITATOR_PRIVATE_KEY_EVM=0x... +FACILITATOR_PRIVATE_KEY_STELLAR=S... +FACILITATOR_PRIVATE_KEY_SOLANA= +``` + +See [environment variables](../../reference/environment-variables.md) for the full list. + +## 7. Run database migrations + +```bash +cd packages/relay +bun run db:migrate +cd - +``` + +## 8. Run relay and worker + +```bash +# terminal 1 +cd packages/relay && bun run dev + +# terminal 2 +cd packages/worker && bun run dev +``` + +Or from the root: + +```bash +bun run dev +``` + +## 9. Verify + +```bash +curl http://localhost:3000/health +``` + +All three services (db, redis, temporal) should be `"ok"`. + +## 10. Run the demo (optional) + +```bash +./scripts/demo.sh +``` + +See [run the demo](./run-the-demo.md). + +## Development tips + +### ESLint and Prettier run on every commit + +Husky + lint-staged enforce `prettier --check` and `eslint` on staged files. A failing commit is usually a formatting issue — run `bun run format:fix` and `bun run lint --fix` and retry. + +### USDC amounts are strings + +Never `parseFloat` or `Number()`. Never use JS arithmetic on amounts. Use `decimal.js` or `BigInt`. This is enforced in review and documented in [code standards](../../../.claude/rules/code-standards.md). + +### Commit format + +Conventional commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `hotfix:`. Lowercase. Imperative mood. Never add `Co-Authored-By` lines. + +See [git workflow rules](../../../.claude/rules/git-workflow.md). + +## Next + +- [Run the demo](./run-the-demo.md) +- [Add a new EVM chain](./add-a-new-evm-chain.md) +- [Write a network adapter](./write-a-new-network-adapter.md) diff --git a/docs/how-to/contributors/write-a-new-network-adapter.md b/docs/how-to/contributors/write-a-new-network-adapter.md new file mode 100644 index 0000000..94a9b6d --- /dev/null +++ b/docs/how-to/contributors/write-a-new-network-adapter.md @@ -0,0 +1,156 @@ +# Write a new network adapter + +All 7 EVM chains share one adapter. Solana and Stellar have their own. Adding a new non-EVM family (for example, Aptos, Sui, or a new L2 with an unusual signing model) means writing a new adapter. + +This guide shows the shape of an adapter and what you have to implement. + +## The interface + +```typescript +export interface ChainAdapter { + /** Pull USDC from buyer using a signed authorization. */ + pullFromBuyer(input: PullFromBuyerInput): Promise<{ txHash: string }> + + /** Same-chain transfer to seller. */ + transferToSeller(input: TransferToSellerInput): Promise<{ txHash: string }> + + /** Cross-chain: burn USDC via CCTP V2. */ + cctpBurn(input: CctpBurnInput): Promise + + /** Cross-chain: mint USDC on destination using attestation. */ + cctpMint(input: CctpMintInput): Promise<{ txHash: string }> + + /** Inspect a chain address for validity. */ + validateAddress(address: string): boolean +} +``` + +Defined in `packages/shared/src/networks/types.ts`. Every field the input types carry is documented there. + +## 1. Scaffold + +Create `packages/shared/src/networks/-adapter.ts`: + +```typescript +import type { ChainAdapter } from './types' + +export function createFooAdapter(config: { + rpcUrl: string + facilitatorPrivateKey: string + facilitatorAddress: string + usdcContract: string + cctpTokenMessenger: string + cctpMessageTransmitter: string +}): ChainAdapter { + // initialize clients here + return { + async pullFromBuyer(input) { + /* … */ + }, + async transferToSeller(input) { + /* … */ + }, + async cctpBurn(input) { + /* … */ + }, + async cctpMint(input) { + /* … */ + }, + validateAddress(address) { + /* … */ + }, + } +} +``` + +## 2. Implement each method + +### `pullFromBuyer` + +Accept the buyer's signed authorization and submit the chain's equivalent of EIP-3009 `transferWithAuthorization`. The output is a pulled USDC balance in the Facilitator wallet. + +Things the worker guarantees: + +- The authorization has been verified against `network`, `payTo`, `amount`. +- The input includes `nonce`, `validAfter`, `validBefore`, and the signature. + +Your job is to craft and broadcast the tx. Return the tx hash once the chain confirms. + +### `transferToSeller` + +Same-chain only. Transfer `amount` USDC from the Facilitator wallet to the seller's address. Standard USDC transfer — no authorization, no CCTP. + +### `cctpBurn` + +Call the chain's CCTP `depositForBurn` equivalent. Your input includes `destinationDomain` (the Circle domain ID of the destination chain) and `mintRecipient` (padded 32-byte representation of the destination address). For EVM destinations, this is the address; for Stellar destinations, it is the `CctpForwarder` contract with the seller's Stellar address in `hookData`. + +Return `{ txHash, messageBytes }`. `messageBytes` is the CCTP message that Circle will attest over — the worker uses it to poll Iris. + +If the chain does not support CCTP V2 directly, your adapter cannot participate in cross-chain settlement. Document it clearly. + +### `cctpMint` + +Receive the CCTP attestation (the signature Circle produced over `messageBytes`) and call `receiveMessage` on the chain's `MessageTransmitter`. The chain's CCTP contract mints USDC to the encoded recipient atomically. + +Return `{ txHash }`. + +### `validateAddress` + +Cheap check: does this string look like a valid address on this chain? Used by the relay before dispatching settlement. Do not do RPC calls here — purely format validation. + +## 3. Register the adapter + +Open `packages/shared/src/networks/index.ts` and wire your adapter into the `getAdapter` lookup: + +```typescript +if (caip2.startsWith('foo:')) { + return createFooAdapter({ + rpcUrl: env.FOO_RPC_URL, + facilitatorPrivateKey: env.FACILITATOR_PRIVATE_KEY_FOO, + facilitatorAddress: env.FACILITATOR_FOO, + // … + }) +} +``` + +Add a chain definition matching the EVM pattern (see [add a new EVM chain](./add-a-new-evm-chain.md)), but point it at the new adapter instead of the EVM one. + +## 4. Add env vars + +- `FACILITATOR_FOO` — facilitator's address on the new chain. +- `FACILITATOR_PRIVATE_KEY_FOO` — worker-only signing key, whatever format the chain's SDK wants. +- `FOO_RPC_URL` — RPC endpoint. + +Update `packages/relay/.env.example`, `packages/worker/.env.example`, and `docs/reference/environment-variables.md`. + +## 5. Tests + +Adapters are tested in `packages/shared/tests/networks/-adapter.test.ts`. Mock the chain RPC with a local fixture where possible. Test: + +- `pullFromBuyer` happy path with a valid authorization. +- `pullFromBuyer` rejection with an invalid signature. +- `cctpBurn` emits the right `messageBytes`. +- `cctpMint` succeeds with a valid attestation and fails with an invalid one. +- `validateAddress` accepts valid addresses and rejects obvious junk. + +## 6. Integration test + +Add a scenario in `packages/worker/tests/workflows/cross-chain-settle.integration.test.ts` that runs a `crossChainSettle` with your new adapter on each end. Use Temporal's test environment (activities mocked) to avoid real chain calls in CI. + +## 7. Document + +- Add the chain to `docs/reference/chains.md`. +- Add the env vars to `docs/reference/environment-variables.md`. +- Add the family to the "Pull mechanism" table in the chains reference. +- Add an entry to the README's supported chains. + +## Pitfalls + +- **Authorization format drift.** Each chain's "signed authorization to pull" has different semantics. Stellar uses Soroban SAC signatures; Solana uses signed transactions; EVM uses EIP-3009. Do not try to unify these — the adapter is where the chain-specific logic lives. +- **Decimals.** EVM and Solana use 6 decimals for USDC. Stellar uses 7. A new chain might use something else. Store the value in the chain definition, not the adapter. +- **Failure modes.** Activities retry by default. Your adapter should throw on transient failures (network, rate limit) so Temporal's retry policy can catch them. Throw `NonRetryableError` for permanent failures (invalid address, insufficient funds) — those should not retry. + +## Next + +- [Temporal workflows explanation](../../explanation/temporal-workflows.md) — retry policies and compensation. +- [Supported chains reference](../../reference/chains.md). diff --git a/docs/how-to/operations/deploy-relay-and-worker.md b/docs/how-to/operations/deploy-relay-and-worker.md new file mode 100644 index 0000000..f680125 --- /dev/null +++ b/docs/how-to/operations/deploy-relay-and-worker.md @@ -0,0 +1,105 @@ +# Deploy the relay and worker + +You have a local stack working and want to ship it. This guide covers the container-based deployment path. + +## Prerequisites + +- A container platform (Fly.io, Railway, Render, ECS, GKE, your own Docker host — any works). +- A managed PostgreSQL 15+. +- A managed Redis 7+. +- A Temporal cluster: self-hosted (same Docker image as local) or Temporal Cloud. + +## 1. Build images + +Each package has its own `Dockerfile`. From the repo root: + +```bash +docker build -t 402md-relay:latest -f packages/relay/Dockerfile . +docker build -t 402md-worker:latest -f packages/worker/Dockerfile . +``` + +Both Dockerfiles use a multi-stage build and run as a non-root user. + +## 2. Provision dependencies + +| Dependency | Notes | +| ---------- | -------------------------------------------------------------------------------------------------------------------- | +| PostgreSQL | One database, shared by relay and worker. Temporal can share the same instance but uses a separate schema. | +| Redis | 7+ with persistence enabled. | +| Temporal | Create the `402md-settlement` namespace and search attributes (see [run local stack](./run-local-stack.md), step 2). | + +Run the migration once from the relay image: + +```bash +docker run --rm \ + -e DATABASE_URL=$DATABASE_URL \ + 402md-relay:latest bun run --cwd packages/relay db:migrate +``` + +## 3. Deploy the relay + +The relay is stateless behind any HTTP load balancer. Required env: + +```bash +NETWORK_ENV=mainnet +DATABASE_URL=... +REDIS_URL=... +TEMPORAL_ADDRESS=... +TEMPORAL_NAMESPACE=402md-settlement + +FACILITATOR_URL=https://api.yourhost.com +FACILITATOR_BASE=0x... +FACILITATOR_STELLAR=G... +FACILITATOR_SOLANA=... +# per-chain RPC URLs, etc. + +MPP_SECRET_KEY= +``` + +**Never set `FACILITATOR_PRIVATE_KEY_*` on the relay.** Only the worker needs signing keys. + +Scale horizontally — the relay is stateless. Redis handles replay protection shared across instances. + +## 4. Deploy the worker + +The worker polls Temporal task queues; it does not accept inbound traffic. Required env: same as relay, **plus**: + +```bash +FACILITATOR_PRIVATE_KEY_EVM=0x... +FACILITATOR_PRIVATE_KEY_SOLANA= +FACILITATOR_PRIVATE_KEY_STELLAR=S... +``` + +Read keys from a secret manager — Fly.io secrets, GCP Secret Manager, AWS Secrets Manager, HashiCorp Vault. Never bake them into the image. + +Scale horizontally — Temporal's task queue balances across workers automatically. One worker per ~50K workflows/month is a rough starting point. + +## 5. Harden + +- **TLS termination** at the load balancer. The relay itself does not terminate TLS. +- **CORS:** leave the default permissive policy unless you have a specific origin to restrict to. +- **Security headers:** add a reverse proxy with `Content-Security-Policy`, `Strict-Transport-Security`, `X-Content-Type-Options`. +- **mTLS between worker and Temporal** when using Temporal Cloud or a shared cluster. +- **Rate limits:** the built-in per-IP limits are generous. Put a WAF in front for L7 DDoS protection. + +## 6. Observability + +Set `OTEL_ENABLED=true` and `OTEL_EXPORTER_OTLP_ENDPOINT=` on both relay and worker. Traces include workflow IDs and activity spans. + +## 7. Smoke test production + +```bash +curl https://api.yourhost.com/health +curl https://api.yourhost.com/supported +``` + +Register a throwaway seller on testnet and run the demo agent against it (see [run the demo](../contributors/run-the-demo.md)) before you announce the endpoint. + +## Rollback + +Both services are stateless beyond PostgreSQL. Roll back by deploying the previous image tag. Schema migrations are additive by convention — check that a new migration is compatible with the previous code before rolling forward. + +## Next + +- [Monitor workflows in Temporal](./monitor-workflows-in-temporal.md) +- [Trigger circuit breakers](./trigger-circuit-breakers.md) diff --git a/docs/how-to/operations/monitor-workflows-in-temporal.md b/docs/how-to/operations/monitor-workflows-in-temporal.md new file mode 100644 index 0000000..bc53d6b --- /dev/null +++ b/docs/how-to/operations/monitor-workflows-in-temporal.md @@ -0,0 +1,94 @@ +# Monitor workflows in Temporal + +You want to answer: is settlement working? Where are payments stuck? What's failing? Temporal's UI and CLI give you everything. + +## 1. Open the Temporal UI + +Local: [http://localhost:8233](http://localhost:8233). Cloud or self-hosted: your cluster URL. Select the `402md-settlement` namespace. + +## 2. Filter with search attributes + +The worker writes four custom search attributes on every workflow. Use them to build dashboards or ad-hoc queries. + +| Attribute | Values | +| ------------------ | ------------------------------------------------------------------------------ | +| `sellerNetwork` | CAIP-2, e.g. `stellar:pubnet` | +| `buyerNetwork` | CAIP-2, e.g. `eip155:8453` | +| `settlementStatus` | `pulling`, `burning`, `attesting`, `minting`, `recording`, `settled`, `failed` | +| `protocol` | `x402`, `mpp` | + +Example queries (Temporal List Filter syntax): + +``` +settlementStatus = 'failed' AND StartTime > '2026-04-01T00:00:00Z' +sellerNetwork = 'stellar:pubnet' AND buyerNetwork != 'stellar:pubnet' +settlementStatus = 'attesting' AND StartTime < '2026-04-19T10:00:00Z' +``` + +The last one finds cross-chain settlements stuck in attestation for over an hour — Circle usually takes ≤ 20 min even for EVM sources. + +## 3. Read a single workflow + +Click any workflow. You will see: + +- **History** — every event: activity scheduled, started, completed, failed with retry. +- **Queries** — run the `status` query at any time to get the current step and tx hashes without waiting for the workflow to end. +- **Input / Result** — the x402 payload and the final ledger entry. + +For failed workflows the last event includes the error. Common recoveries: + +| Error | Action | +| ----------------------------------------- | -------------------------------------------------------------- | +| `pullFromBuyer` — EIP-3009 nonce conflict | None — buyer needs to sign a new authorization. | +| `cctpBurn` — insufficient gas | Top up the Facilitator's gas wallet on the source chain. | +| `waitAttestation` — Circle Iris 5xx | None — activity retries automatically with backoff. | +| `cctpMint` — revert | Usually an RPC issue. Retry the activity manually from the UI. | + +## 4. Dead-letter detection + +Schedule a job (cron, scheduled lambda, etc.) that queries: + +``` +settlementStatus = 'failed' AND StartTime > '<5m ago>' +``` + +Alert if the result is non-empty. That's your "settlement failing right now" signal. + +A second, lower-priority alert: + +``` +settlementStatus IN ('pulling','burning','attesting','minting','recording') AND StartTime < '<30m ago>' +``` + +"Stuck for longer than expected" — usually caused by an RPC outage or a blocked gas wallet. + +## 5. Temporal CLI + +For one-off queries without the UI: + +```bash +temporal workflow list \ + --namespace 402md-settlement \ + --query 'settlementStatus = "failed"' \ + --limit 20 + +temporal workflow describe \ + --namespace 402md-settlement \ + --workflow-id +``` + +## 6. OpenTelemetry + +If you set `OTEL_ENABLED=true`, the worker emits spans per activity tagged with the same attributes. Use your APM (Grafana Tempo, Honeycomb, Datadog) to chart per-route latency or per-chain failure rate. + +## Gas wallet and attestation monitoring + +The `/health` endpoint does not check gas or Circle Iris reachability. Add: + +- A cron that calls `eth_getBalance` (or chain-native equivalent) for each `FACILITATOR_*` address and alerts below threshold. +- A cron that hits `https://iris-api.circle.com/v2/health` (mainnet) or `https://iris-api-sandbox.circle.com/v2/health` (testnet) and alerts on non-200. + +## Next + +- [Trigger circuit breakers](./trigger-circuit-breakers.md) +- [API reference — settlements](../../reference/api/settlements.md) diff --git a/docs/how-to/operations/run-local-stack.md b/docs/how-to/operations/run-local-stack.md new file mode 100644 index 0000000..3c1ed8c --- /dev/null +++ b/docs/how-to/operations/run-local-stack.md @@ -0,0 +1,105 @@ +# Run the local stack + +You want the relay, the worker, and their dependencies running on your machine. + +## Prerequisites + +- [Bun](https://bun.sh) (latest). +- [Node.js](https://nodejs.org) 20+. +- [Docker](https://www.docker.com) running. +- [Temporal CLI](https://docs.temporal.io/cli). + +## 1. Start infrastructure + +```bash +docker compose up -d +``` + +| Service | Image | Host port | +| ----------- | ------------------------------ | --------- | +| PostgreSQL | `postgres:15` | 5432 | +| Redis | `redis:7` | 6379 | +| Temporal | `temporalio/auto-setup:latest` | 7233 | +| Temporal UI | `temporalio/ui:latest` | 8233 | + +## 2. Create the Temporal namespace (one-time) + +```bash +temporal operator namespace create 402md-settlement +``` + +Register the search attributes workflows use: + +```bash +temporal operator search-attribute create \ + --name sellerNetwork --type Keyword \ + --name buyerNetwork --type Keyword \ + --name settlementStatus --type Keyword \ + --name protocol --type Keyword +``` + +## 3. Install and build + +```bash +bun install +bun run build +``` + +## 4. Configure env files + +```bash +cp packages/relay/.env.example packages/relay/.env +cp packages/worker/.env.example packages/worker/.env +``` + +Both files need the same `FACILITATOR_*` addresses, `DATABASE_URL`, `REDIS_URL`, and `TEMPORAL_*`. The worker additionally needs `FACILITATOR_PRIVATE_KEY_*`. See [environment variables](../../reference/environment-variables.md). + +## 5. Migrate the database + +```bash +cd packages/relay +bun run db:migrate +cd - +``` + +## 6. Run the processes + +Two separate terminals: + +```bash +# Terminal 1 +cd packages/relay && bun run dev +``` + +```bash +# Terminal 2 +cd packages/worker && bun run dev +``` + +Or from the repo root with Turborepo: + +```bash +bun run dev +``` + +## 7. Verify + +```bash +curl http://localhost:3000/health +``` + +All three services should report `"ok"`. If not, check the output of `docker compose ps` and the logs of the failing service. + +## Stop everything + +```bash +# Ctrl+C the relay and worker, then: +docker compose down +``` + +Add `-v` to also wipe the database volumes (`docker compose down -v`). Use it when you want a clean slate. + +## Next + +- [First cross-chain payment tutorial](../../tutorials/01-first-cross-chain-payment.md) — end-to-end with the local stack. +- [Deploy relay and worker](./deploy-relay-and-worker.md) — promote this setup to a server. diff --git a/docs/how-to/operations/trigger-circuit-breakers.md b/docs/how-to/operations/trigger-circuit-breakers.md new file mode 100644 index 0000000..7b611fe --- /dev/null +++ b/docs/how-to/operations/trigger-circuit-breakers.md @@ -0,0 +1,76 @@ +# Trigger circuit breakers + +The relay has three off-chain circuit breakers. Use them to limit damage if a key is compromised, a buggy seller is draining gas, or you need a maintenance window. + +All three are enforced at `POST /settle`. When any triggers, the relay responds `503 Service Unavailable` with `error: "CIRCUIT_BREAKER"`. + +## Per-transaction limit (`MAX_TX_AMOUNT`) + +The relay rejects any settle request whose `amount` exceeds this threshold. + +- Env var: `MAX_TX_AMOUNT` (base units). +- Default: `1000000000` — i.e. 1000 USDC on EVM. +- Set lower while you are in early access, higher as volume grows. + +Change it by updating the env on the relay and restarting. No database involved. + +## Daily volume limit (`DAILY_VOLUME_LIMIT`) + +Cumulative daily settle volume across all sellers. Enforced by a Redis counter with a 24h TTL. + +- Env var: `DAILY_VOLUME_LIMIT` (base units). +- Default: `10000000000` — i.e. 10000 USDC/day on EVM. +- Scope: global (one counter for the whole relay fleet — uses Redis, so multiple relay instances share it). + +Reset: the counter auto-expires 24h after the first increment. To reset manually, delete the Redis key (`DEL volume:daily:YYYY-MM-DD` — inspect with `redis-cli KEYS 'volume:daily:*'`). + +## Global pause + +A Redis flag that the relay checks on every `POST /settle`. When set, every settle is rejected. + +Toggle it from a Redis shell: + +```bash +# Pause +redis-cli SET facilitator:pause 1 + +# Resume +redis-cli DEL facilitator:pause +``` + +An admin HTTP endpoint for this is planned but not yet shipped — exposing it requires an auth layer that is separate from the public API. + +## When to trigger each + +| Scenario | Use | +| ----------------------------------------------------------------------------------- | ---------------------------------------------------- | +| Someone guessed your relay's FACILITATOR_URL and is spamming `/settle` with garbage | Rate limit is enough. | +| Gas wallet is draining faster than expected | Lower `MAX_TX_AMOUNT` and `DAILY_VOLUME_LIMIT`. | +| Deploying a breaking change to the worker | Set pause, deploy, unpause. | +| Facilitator private key is suspected compromised | Set pause immediately, rotate keys, migrate sellers. | + +## What circuit breakers do **not** do + +- They do not stop `POST /verify`. Buyers and sellers can still negotiate payment; only settlement is blocked. This is intentional — sellers should not serve resources they cannot settle. Use the pause flag only when you can tolerate this. +- They do not pause in-flight Temporal workflows. Use Temporal's own `terminate` or `cancel` commands if you need to stop an already-dispatched settlement. + +## Verify the breaker + +```bash +curl -X POST https://api.402md.com/settle \ + -H "Content-Type: application/json" \ + -d '' +``` + +When pause is active: + +```json +{ "error": "CIRCUIT_BREAKER", "message": "Settlement is paused" } +``` + +Status code `503`. + +## Next + +- [Security model](../../explanation/security-model.md) — the full threat model. +- [API reference — settlements](../../reference/api/settlements.md) diff --git a/docs/how-to/sellers/accept-multiple-chains.md b/docs/how-to/sellers/accept-multiple-chains.md new file mode 100644 index 0000000..97d5160 --- /dev/null +++ b/docs/how-to/sellers/accept-multiple-chains.md @@ -0,0 +1,78 @@ +# Accept payments on multiple chains + +You already have a `merchantId` and want buyers on any supported chain to be able to pay your API. This guide shows how to declare every chain in your `@x402/express` config. + +## Prerequisites + +- A `merchantId` from [register a seller](./register-and-get-addresses.md). +- An Express (or compatible) HTTP server with `@x402/express` installed. + +## 1. Fetch the enabled networks at boot + +Declaring chains statically risks drifting from the relay's config. Fetch them once at startup: + +```typescript +const discover = await fetch(`${FACILITATOR_URL}/discover?merchantId=${MERCHANT_ID}`).then((r) => + r.json(), +) + +const accepts = discover.acceptedNetworks.map((n) => ({ + scheme: 'exact', + network: n.network, + payTo: n.payTo, + price: '$0.001', + extra: { merchantId: MERCHANT_ID }, +})) +``` + +Every entry in `discover.acceptedNetworks` already has `merchantId` in its `extra`. If you prefer, copy it directly. + +## 2. Apply the middleware + +```typescript +import { paymentMiddleware } from '@x402/express' + +app.use( + paymentMiddleware({ + facilitatorUrl: FACILITATOR_URL, + routes: { + 'GET /search': { accepts }, + 'POST /summarize': { accepts: accepts.map((a) => ({ ...a, price: '$0.005' })) }, + }, + }), +) +``` + +The same `accepts` list can be reused per route. Override `price` per route as needed. + +## 3. Let buyers pick + +When a buyer hits your paywalled endpoint, the middleware returns `402 Payment Required` with all `accepts` entries. The buyer's x402 client picks whichever chain it has USDC on and signs accordingly. You never branch on chain in your handler. + +## 4. Handle a new chain added later + +If the relay adds a chain after your server boots, you have two options: + +- **Restart** — `/discover` will return the new entry next boot. +- **Periodically refresh** `accepts` in memory (e.g., every 10 minutes) and reassign the middleware config if it supports runtime mutation. + +## What settlement looks like per chain + +| Buyer chain | Seller chain | How it settles | Time | +| ----------- | ------------ | ----------------------------------------------------------------------------------------------- | ---------- | +| Stellar | Stellar | Same-chain: facilitator pulls, transfers to you. | < 5 s | +| Solana | Stellar | `crossChainSettle`: Solana → CCTP V2 → Stellar. | ~25–30 s | +| Base | Stellar | `crossChainSettle`: Base → CCTP V2 (with `depositForBurnWithHook` + `CctpForwarder`) → Stellar. | ~15–19 min | + +You do not have to do anything different per chain — the workflow picks the right path. See [architecture](../../explanation/architecture.md). + +## Pitfalls + +- **Never set `payTo` to your own wallet.** The facilitator will reject the payment — `payTo` must match the relay's address on that chain. +- **Do not forget `extra.merchantId`.** The relay returns `INVALID_PAYMENT` without it. +- **Price is a human-readable string** (e.g. `"$0.001"`). The x402 middleware converts it to base units per chain decimals. + +## Next + +- [Check settlement status](./check-settlement-status.md) +- [API reference — sellers](../../reference/api/sellers.md) diff --git a/docs/how-to/sellers/check-settlement-status.md b/docs/how-to/sellers/check-settlement-status.md new file mode 100644 index 0000000..30acc94 --- /dev/null +++ b/docs/how-to/sellers/check-settlement-status.md @@ -0,0 +1,78 @@ +# Check settlement status + +A buyer paid. You want to know when the USDC arrives, and whether anything failed. This guide covers the two ways to check. + +## Option 1 — poll the relay + +Every successful `POST /settle` returns a `transaction` field containing the Temporal `workflowId`: + +```json +{ + "success": true, + "transaction": "x402-hb-a1b2c3-1700000000", + "network": "eip155:8453", + "payer": "0xBuyer..." +} +``` + +Poll: + +```bash +curl https://api.402md.com/bridge/status/x402-hb-a1b2c3-1700000000 +``` + +Response for a cross-chain settlement in progress: + +```json +{ + "step": "attesting", + "pullTxHash": "0x...", + "burnTxHash": "0x..." +} +``` + +Possible `step` values: + +| Workflow | Steps | +| ----------- | --------------------------------------------------------------------------------------- | +| Same-chain | `pulling` → `transferring` → `recording` → `settled` (or `failed`) | +| Cross-chain | `pulling` → `burning` → `attesting` → `minting` → `recording` → `settled` (or `failed`) | + +Poll every 5–10 s. Expect: + +- **Same-chain:** terminal in < 5 s. +- **Solana or Stellar source:** terminal in ~5–30 s. +- **EVM source, cross-chain:** terminal in ~15–19 min (dominated by source chain finality + Circle attestation). + +## Option 2 — subscribe via bazaar transactions + +If you want a recent-transactions feed rather than per-payment polling: + +```bash +curl "https://api.402md.com/bazaar/transactions?merchantId=hb-a1b2c3&status=settled&limit=20" +``` + +This returns settled transactions with gross/net amounts and every on-chain tx hash. Useful for a seller dashboard. Cache is 60 s, so do not hammer it. + +## What "settled" means + +- **Same-chain:** USDC is in your wallet. +- **Cross-chain:** Circle minted USDC on the destination chain and, if the destination is Stellar, the `CctpForwarder` contract forwarded it to you. The relay's ledger entry is written after the mint confirms. + +## When settlement fails + +| `step: failed` cause | What to do | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pullFromBuyer` failed | Buyer's authorization was invalid or funds insufficient. Not your problem — return a refund to them out-of-band only if you already released the resource. | +| `cctpBurn` failed | USDC sits in the Facilitator wallet. Operators investigate; you are not out of pocket. | +| `waitAttestation` timed out | Circle took > 30 min. Operators will retry; Circle attestations never expire once issued. | +| `cctpMint` failed | Operators retry. No fund loss — the burn is final and the attestation is reusable. | + +In every failure mode you, as the seller, have received nothing yet but the buyer either paid or will be refunded. The ledger is consistent — no double-debits. + +For contextual detail see [error handling rules](../../../.claude/rules/error-handling.md). + +## Next + +- [API reference — settlements](../../reference/api/settlements.md) +- [Temporal workflows explanation](../../explanation/temporal-workflows.md) diff --git a/docs/how-to/sellers/register-and-get-addresses.md b/docs/how-to/sellers/register-and-get-addresses.md new file mode 100644 index 0000000..0d76477 --- /dev/null +++ b/docs/how-to/sellers/register-and-get-addresses.md @@ -0,0 +1,72 @@ +# Register a seller and get facilitator addresses + +You want to start receiving cross-chain USDC. This guide covers the one-time registration and what to do with the response. + +## Prerequisites + +- A wallet on the chain you want to receive settlement on (Stellar, Solana, or any supported EVM). +- The relay's base URL (`https://api.402md.com` or your self-hosted instance). + +## 1. Call `POST /register` + +```bash +curl -X POST https://api.402md.com/register \ + -H "Content-Type: application/json" \ + -d '{ + "wallet": "GSELLER...", + "network": "stellar:pubnet" + }' +``` + +- `wallet` must be ≥ 10 characters and use the chain's native format. +- `network` must be one of the enabled CAIP-2s — call `GET /supported` first if unsure. + +## 2. Save the response + +```json +{ + "merchantId": "hb-a1b2c3", + "wallet": "GSELLER...", + "network": "stellar:pubnet", + "facilitatorAddresses": { + "eip155:8453": "0xFacilitator...", + "solana:mainnet": "FacilitatorSol...", + "stellar:pubnet": "GFacilitator..." + } +} +``` + +You need two things from here: + +- **`merchantId`** — goes into `extra.merchantId` in every `@x402/express` `accepts` entry. Without it the Facilitator cannot route the payment. +- **`facilitatorAddresses`** — goes into `payTo` per chain. Do not use your own wallet as `payTo`. + +## 3. Keep the `merchantId` safe but not secret + +The `merchantId` identifies you, not authorizes you. It is safe to include in HTTP responses, logs, and public repositories. However: + +- Do not let a malicious party swap it in your `accepts` config — that would route their traffic to your seller account. Treat the config file the same way you treat any deployment config. +- Rotate wallets by registering a new one (you will get a new `merchantId`). There is no "change wallet" endpoint — this keeps the settlement ledger append-only. + +## 4. Verify with `GET /discover` + +Sanity-check the registration: + +```bash +curl "https://api.402md.com/discover?merchantId=hb-a1b2c3" +``` + +The response includes the same `facilitatorAddresses`, the accepted networks, and the fee structure. + +## Common errors + +| You see | Meaning | Fix | +| ----------------------------- | --------------------------------------------- | ----------------------------------- | +| `400` validation | `wallet` < 10 chars or `network` missing | Check the body shape. | +| `404 UnsupportedNetworkError` | `network` not enabled on this relay | Call `GET /supported` and pick one. | +| `429 RATE_LIMIT` | 3 registrations from your IP in the last hour | Wait or switch IP. | + +## Next + +- [Accept multiple chains](./accept-multiple-chains.md) — use `facilitatorAddresses` across all chains in one config. +- [API reference — sellers](../../reference/api/sellers.md). diff --git a/docs/how-to/sellers/top-up-with-onramp.md b/docs/how-to/sellers/top-up-with-onramp.md new file mode 100644 index 0000000..a79f790 --- /dev/null +++ b/docs/how-to/sellers/top-up-with-onramp.md @@ -0,0 +1,52 @@ +# Top up a wallet with an on-ramp + +The facilitator does not hold USDC for you — settlement lands in your wallet. This guide helps your users (or you) buy USDC with fiat to have something to spend as a buyer. + +If you are a **seller** receiving payments, you do not need an on-ramp — USDC arrives via settlement. You may want an on-ramp if you are also a buyer, or if your product is itself an agent that pays for services. + +## 1. Ask the relay for providers + +```bash +curl "https://api.402md.com/onramp?network=stellar:pubnet&walletAddress=GSELLER..." +``` + +Response lists matching providers: + +```json +{ + "providers": [ + { "name": "Bridge.xyz", "method": "pix", "regions": ["BR"], "kycUrl": "…", "fees": "~1%" }, + { + "name": "MoneyGram Ramps", + "method": "cash", + "regions": ["BR", "MX", "CO", "AR"], + "kycUrl": "…", + "fees": "varies" + } + ], + "manual": "Send USDC directly from any exchange supporting stellar:pubnet." +} +``` + +- `network` defaults to `stellar:pubnet` if omitted. +- `walletAddress`, when supplied, may be pre-filled into each provider's `kycUrl`. + +## 2. Hand the user off to `kycUrl` + +Open `kycUrl` in a browser (or a WebView). The provider handles KYC, payment method, and delivery. USDC lands in the wallet you specified. + +## 3. Fallback: manual deposit + +If no provider fits the user's region, show `manual` as a human-readable instruction ("send USDC from any exchange supporting this network"). Most exchanges support withdrawal to the EVM and Solana families; Stellar support is more limited. + +## Testnets + +There is no on-ramp for testnets — use Stellar's Friendbot, Solana's `airdrop`, or a public Base Sepolia faucet. The `/onramp` endpoint lists mainnet providers only. + +## Adding a new provider + +The provider list is currently hard-coded in the relay at `packages/relay/src/onramp/onramp.service.ts`. Open a PR adding the provider entry and its region/method metadata. + +## Next + +- [API reference — on-ramp](../../reference/api/onramp.md) diff --git a/docs/how-to/sellers/use-mpp-on-stellar.md b/docs/how-to/sellers/use-mpp-on-stellar.md new file mode 100644 index 0000000..d6e6084 --- /dev/null +++ b/docs/how-to/sellers/use-mpp-on-stellar.md @@ -0,0 +1,112 @@ +# Use MPP on Stellar + +This guide shows how to accept Stellar-only payments using the push-mode MPP charge flow via the official `@stellar/mpp` SDK. Use this when your buyers are already on Stellar and you want to skip the x402 pull flow. + +## When to use this + +- Buyer and seller both on Stellar. +- You want the buyer to broadcast the payment (and pay the Stellar fee) directly — no CCTP bridging. +- You already know your users have a Stellar keypair. + +If any buyer is on a different chain, use x402 instead — see [accept multiple chains](./accept-multiple-chains.md). + +## Prerequisites + +- A registered seller on `stellar:pubnet` or `stellar:testnet`. +- Your `merchantId`. +- An HTTP server (any language, but `@stellar/mpp` ships a Node/Bun SDK). + +## 1. Expose the `/mpp/config` metadata + +Your API server only needs to proxy the relay's config endpoint so agents can discover how to pay: + +``` +GET https://api.402md.com/merchants/:merchantId/mpp/config +``` + +Response tells the agent which Stellar USDC contract, which recipient, which network to use. You do not have to mirror this — agents can call the relay directly. + +## 2. Wire a paywalled route with `Mppx` + +```typescript +import { Mppx } from '@stellar/mpp/charge/server' + +const mpp = new Mppx({ + network: 'STELLAR_PUBNET', + recipient: process.env.SELLER_WALLET, // your Stellar address + currency: process.env.STELLAR_USDC_CONTRACT, // USDC SAC on Stellar + secret: process.env.MPP_SECRET_KEY, // HMAC secret +}) + +app.get('/search', async (req, res) => { + const charge = await mpp.charge(req, { amount: '1000000' }) // 0.001 USDC + if (charge.type === 'challenge') { + return res.status(402).set(charge.headers).send(charge.body) + } + if (!charge.paid) { + return res.status(402).send('payment failed') + } + // charge.paid === true — serve the resource + res.json({ + query: req.query.q, + results: [ + /* ... */ + ], + }) +}) +``` + +`Mppx` handles the full state machine: issues a 402 challenge on the first hit, verifies the Soroban SAC transfer on the second. + +## 3. Or delegate to the relay + +You can skip `Mppx` in your own server entirely and forward the request to the relay's charge endpoint: + +```typescript +app.get('/search', async (req, res) => { + const upstream = await fetch( + `${FACILITATOR_URL}/merchants/${MERCHANT_ID}/mpp/charge?amount=1000000`, + { headers: req.headers }, + ) + if (upstream.status !== 200) { + return res + .status(upstream.status) + .set(Object.fromEntries(upstream.headers)) + .send(await upstream.text()) + } + // Paid — serve the resource + res.json({ + query: req.query.q, + results: [ + /* ... */ + ], + }) +}) +``` + +This is what the `demo-seller` package does in this repo. + +## 4. Point your agents at the config URL + +If your buyers use a stock `@stellar/mpp` client, publish the config URL as the entry point: + +``` +https://api.402md.com/merchants/hb-a1b2c3/mpp/config +``` + +The client will read it, sign the challenge, and retry. + +## Amount encoding + +MPP amounts are Stellar USDC **stroops** (7 decimals). `1` USDC = `10000000`. The relay's `amount` query string is the base-unit integer. + +## Pitfalls + +- **`MPP_SECRET_KEY` must match** between the relay and the library that signs challenges. If you delegate to the relay, you do not set this locally. +- **Testnet vs pubnet** is resolved from `NETWORK_ENV` on the relay. Register on the network you intend to use — you cannot mix. +- **No CCTP here.** MPP charge is Stellar-only. Do not expect cross-chain settlement from this flow. + +## Next + +- [API reference — MPP](../../reference/api/mpp.md) +- [Dual-protocol explanation](../../explanation/dual-protocol-x402-mpp.md) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..aebf31b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,57 @@ +# 402md Facilitator Documentation + +Cross-chain USDC settlement for agentic payments. Buyer pays on any supported chain, seller receives on theirs. + +| I want to… | Go to | +| ----------------------------------------------------------- | ----------------------------- | +| **Learn** by following a guided, end-to-end example | [Tutorials](./tutorials/) | +| **Solve** a specific problem with step-by-step instructions | [How-to guides](./how-to/) | +| **Look up** exact API fields, env vars, chains, error codes | [Reference](./reference/) | +| **Understand** why the system is designed this way | [Explanation](./explanation/) | + +## Quick links by audience + +**I'm a seller integrating x402 or MPP.** +Start with the [first payment tutorial](./tutorials/01-first-cross-chain-payment.md), then bookmark the [API reference](./reference/api/overview.md) and the [seller how-to guides](./how-to/sellers/). + +**I'm operating a self-hosted facilitator.** +Read [deploy relay and worker](./how-to/operations/deploy-relay-and-worker.md) and the [environment variables reference](./reference/environment-variables.md). Keep the [monitoring guide](./how-to/operations/monitor-workflows-in-temporal.md) handy. + +**I'm contributing to the repo.** +Start with [set up dev environment](./how-to/contributors/set-up-dev-environment.md) and the [architecture explanation](./explanation/architecture.md). + +**I'm evaluating the project.** +Read the [architecture explanation](./explanation/architecture.md), then [why CCTP V2](./explanation/why-cctp-v2.md) and [non-custodial model](./explanation/non-custodial-model.md). + +## Contents + +### Tutorials — learn by doing + +- [Your first cross-chain payment](./tutorials/01-first-cross-chain-payment.md) +- [Build a paywalled API with x402](./tutorials/02-paywalled-api-with-x402.md) + +### How-to — solve a specific problem + +- **For sellers:** [register](./how-to/sellers/register-and-get-addresses.md), [accept multiple chains](./how-to/sellers/accept-multiple-chains.md), [use MPP on Stellar](./how-to/sellers/use-mpp-on-stellar.md), [check settlement status](./how-to/sellers/check-settlement-status.md), [top up with on-ramp](./how-to/sellers/top-up-with-onramp.md) +- **For operators:** [run the local stack](./how-to/operations/run-local-stack.md), [deploy relay and worker](./how-to/operations/deploy-relay-and-worker.md), [monitor Temporal workflows](./how-to/operations/monitor-workflows-in-temporal.md), [trigger circuit breakers](./how-to/operations/trigger-circuit-breakers.md) +- **For contributors:** [set up dev environment](./how-to/contributors/set-up-dev-environment.md), [add a new EVM chain](./how-to/contributors/add-a-new-evm-chain.md), [write a network adapter](./how-to/contributors/write-a-new-network-adapter.md), [run the demo](./how-to/contributors/run-the-demo.md) + +### Reference — look it up + +- [API overview](./reference/api/overview.md) +- API endpoints: [sellers](./reference/api/sellers.md) · [settlements](./reference/api/settlements.md) · [MPP](./reference/api/mpp.md) · [discovery (bazaar)](./reference/api/discovery.md) · [on-ramp](./reference/api/onramp.md) · [health](./reference/api/health.md) +- [Supported chains](./reference/chains.md) +- [Fees and gas schedule](./reference/fees.md) +- [Environment variables](./reference/environment-variables.md) +- [Error codes](./reference/error-codes.md) +- [CLI and scripts](./reference/cli-scripts.md) + +### Explanation — understand the design + +- [Architecture overview](./explanation/architecture.md) +- [Why CCTP V2](./explanation/why-cctp-v2.md) +- [Non-custodial settlement (Model A)](./explanation/non-custodial-model.md) +- [x402 and MPP — dual protocol](./explanation/dual-protocol-x402-mpp.md) +- [Temporal workflows and durability](./explanation/temporal-workflows.md) +- [Security model](./explanation/security-model.md) +- [`merchantId` as a primitive](./explanation/merchant-id-as-primitive.md) diff --git a/docs/reference/api/discovery.md b/docs/reference/api/discovery.md new file mode 100644 index 0000000..4bfec26 --- /dev/null +++ b/docs/reference/api/discovery.md @@ -0,0 +1,252 @@ +# API reference — discovery and bazaar + +The bazaar is the public directory of sellers registered with this facilitator. Agents use it to find paywalled resources; humans use the analytics endpoints to understand traffic. + +All endpoints under this group share a rate limit of **200 requests per minute per IP**. + +## `GET /discovery/resources` + +List paywalled resources registered in the bazaar. This is the main discovery endpoint — agents call it to find services matching a query. + +### Query parameters + +| Param | Type | Default | Description | +| --------- | ------ | ------- | -------------------------------------------- | +| `q` | string | — | Substring to match against the resource URL. | +| `network` | string | — | Filter by CAIP-2. | +| `sort` | string | `uses` | `uses` or `volume`. | +| `limit` | string | `20` | Clamped to `[1, 100]`. | +| `offset` | string | `0` | Pagination offset. | + +### Response `200 OK` + +| Field | Type | Description | +| ----------- | ------ | ------------------------- | +| `resources` | array | One entry per resource. | +| `total` | number | Total matching resources. | +| `limit` | number | Echoed. | +| `offset` | number | Echoed. | + +Each `resources[]` entry: + +| Field | Type | Description | +| ------------- | -------------- | --------------------------------------------------------- | +| `resource` | string | URL of the paywalled endpoint. | +| `type` | string | Always `"http"` for now. | +| `description` | string \| null | Seller-supplied description. | +| `accepts` | array | `{ scheme, network, payTo, amount }` per accepted method. | +| `useCount` | number | All-time use count. | +| `totalVolume` | string | All-time volume in base units. | +| `lastUpdated` | string \| null | ISO-8601. | + +## `GET /bazaar/stats` + +Aggregate statistics for a time window. + +### Query parameters + +| Param | Default | Values | +| -------- | ------- | ----------------- | +| `window` | `7d` | `1d`, `7d`, `30d` | + +### Response `200 OK` + +| Field | Type | Description | +| ------------------ | ------ | ------------------------------------------ | +| `window` | string | Echoed. | +| `chainsActive` | number | Chains with at least one tx in the window. | +| `chainsSupported` | number | Chains enabled on the relay. | +| `uniqueRoutes` | number | Distinct buyer/seller network pairs. | +| `crossChainRoutes` | number | Of those, how many cross chains. | +| `sameChainRoutes` | number | And how many stay on-chain. | +| `totalVolume` | string | Gross volume in base units. | +| `txCount` | number | Settled transactions. | +| `protocolSplit` | object | `{ "x402": n, "mpp": m, … }`. | +| `typeSplit` | object | `{ same_chain: n, cross_chain: m }`. | + +Cached for 60 s per window. + +## `GET /bazaar/routes` + +All buyer → seller network pairs with their stats. + +### Query parameters + +| Param | Default | Values | +| -------- | ------- | ----------------- | +| `window` | `7d` | `1d`, `7d`, `30d` | + +### Response `200 OK` + +```json +{ + "window": "7d", + "routes": [ + { + "buyerNetwork": "eip155:8453", + "sellerNetwork": "stellar:pubnet", + "txCount": 128, + "volume": "128000000", + "isCrossChain": true + } + ] +} +``` + +Cached for 60 s. + +## `GET /bazaar/resources` + +Ranked resources with windowed and all-time counters. Use this instead of `/discovery/resources` when you need window-scoped rankings. + +### Query parameters + +| Param | Default | Description | +| --------- | ------- | ------------------- | +| `window` | `7d` | `1d`, `7d`, `30d`. | +| `rank` | `uses` | `uses` or `volume`. | +| `network` | — | CAIP-2 filter. | +| `q` | — | URL substring. | +| `limit` | `20` | `[1, 100]`. | +| `offset` | `0` | | + +### Response `200 OK` + +| Field | Type | Description | +| -------- | ------ | ----------------- | +| `window` | string | | +| `items` | array | Ranked resources. | +| `total` | number | | + +Each `items[]` entry adds to the shape of `/discovery/resources` two more fields: + +| Field | Type | Description | +| ------------------ | ------ | -------------------------- | +| `windowedUseCount` | number | Use count inside `window`. | +| `windowedVolume` | string | Volume inside `window`. | + +Cached for 60 s per query. + +## `GET /bazaar/sellers` + +Ranked sellers (merchants) — useful for a public leaderboard. + +### Query parameters + +| Param | Default | Description | +| --------- | -------- | ---------------------------------------------- | +| `window` | `7d` | `1d`, `7d`, `30d`. | +| `rank` | `volume` | `volume` or `tx_count`. | +| `network` | — | CAIP-2 filter on the seller's primary network. | +| `limit` | `20` | `[1, 100]`. | +| `offset` | `0` | | + +### Response `200 OK` + +```json +{ + "window": "7d", + "items": [ + { + "merchantId": "hb-a1b2c3", + "primaryNetwork": "stellar:pubnet", + "txCount": 42, + "volume": "42000000", + "resourceCount": 3, + "firstSeenAt": "2026-03-14T12:00:00.000Z" + } + ], + "total": 1 +} +``` + +Cached for 60 s. + +## `GET /bazaar/transactions` + +Filterable transaction log. Useful for a seller-facing dashboard that wants to show recent settlements. + +### Query parameters + +| Param | Description | +| --------------- | ------------------------------ | +| `window` | `1d`, `7d`, `30d` | +| `merchantId` | Filter by seller. | +| `buyerNetwork` | CAIP-2. | +| `sellerNetwork` | CAIP-2. | +| `status` | e.g. `settled`, `pending`. | +| `protocol` | e.g. `x402`, `mpp`. | +| `type` | `same_chain` or `cross_chain`. | +| `limit` | default `20`, max `100`. | +| `offset` | default `0`. | + +### Response `200 OK` + +| Field | Type | Description | +| -------- | ------ | ------------- | +| `items` | array | Transactions. | +| `total` | number | | +| `limit` | number | | +| `offset` | number | | + +Each item: + +| Field | Type | +| --------------- | --------------------------------- | +| `id` | string | +| `type` | `"same_chain"` \| `"cross_chain"` | +| `protocol` | string \| null | +| `status` | string | +| `buyerNetwork` | string | +| `sellerNetwork` | string | +| `merchantId` | string \| null | +| `grossAmount` | string | +| `netAmount` | string \| null | +| `gasAllowance` | string \| null | +| `pullTxHash` | string \| null | +| `burnTxHash` | string \| null | +| `mintTxHash` | string \| null | +| `createdAt` | ISO-8601 \| null | +| `settledAt` | ISO-8601 \| null | + +Cached for 60 s per query. + +## `GET /bazaar/cost-comparison` + +Compares the Facilitator's flat gas allowance to a percentage-based pricing model on a given route. Intended for marketing / positioning, not for runtime fee calculation — use `GET /bridge/fees` for that. + +### Query parameters + +| Param | Required | Description | +| --------------- | -------- | ----------- | +| `buyerNetwork` | yes | CAIP-2. | +| `sellerNetwork` | yes | CAIP-2. | + +### Response `200 OK` + +```json +{ + "buyerNetwork": "eip155:8453", + "sellerNetwork": "stellar:pubnet", + "tiers": [ + { + "amount": "100000000000", + "cctpAllowance": "500", + "percentAlternative": "1000000100", + "savingsVsPercent": "999999600" + } + ], + "notes": { + "cctpSource": "gas-schedule.ts", + "percentAssumption": "1% + $0.10 market-maker spread baseline" + } +} +``` + +### Errors + +| Status | `error` | Cause | +| ------ | ---------------------- | ------------------------------------------ | +| `400` | validation | Missing `buyerNetwork` or `sellerNetwork`. | +| `404` | `ROUTE_NOT_CONFIGURED` | No gas schedule for this pair. | +| `429` | `RATE_LIMIT` | | diff --git a/docs/reference/api/health.md b/docs/reference/api/health.md new file mode 100644 index 0000000..21b59ec --- /dev/null +++ b/docs/reference/api/health.md @@ -0,0 +1,45 @@ +# API reference — health + +## `GET /health` + +System health check. Returns the status of each backing service so operators and load balancers can decide whether the relay is ready to serve traffic. + +**No rate limit.** **No authentication.** + +### Response `200 OK` + +```json +{ + "status": "ok", + "services": { + "db": "ok", + "redis": "ok", + "temporal": "ok" + }, + "timestamp": "2026-04-19T12:34:56.789Z" +} +``` + +| Field | Type | Description | +| ------------------- | ------ | ----------------------------------------------------------------- | +| `status` | string | `"ok"` if all dependencies reachable, `"degraded"` if any is not. | +| `services.db` | string | `"ok"`, `"error"`, or `"unknown"`. | +| `services.redis` | string | Same. | +| `services.temporal` | string | Same. | +| `timestamp` | string | ISO-8601 server time. | + +The endpoint always returns HTTP `200` — readers inspect the `status` field. This is intentional so that a single failing dependency does not flap the load balancer; consumers who need stricter semantics should map `status: "degraded"` to their own alerting. + +### Use cases + +- **Kubernetes / Docker healthchecks:** hit `/health` and require `status === "ok"` for `readiness`; accept `"degraded"` for `liveness`. +- **Status pages:** poll `/health` and surface per-service status from `services`. +- **Local dev troubleshooting:** if any service is `"error"`, check your `docker compose` stack. + +### What is not checked + +- Chain RPC reachability — tested only lazily, at the moment a workflow activity needs it. +- The CCTP attestation endpoint (Circle Iris). +- Wallet balances and gas budgets. + +For those, see the [monitoring guide](../../how-to/operations/monitor-workflows-in-temporal.md). diff --git a/docs/reference/api/mpp.md b/docs/reference/api/mpp.md new file mode 100644 index 0000000..09d0fa3 --- /dev/null +++ b/docs/reference/api/mpp.md @@ -0,0 +1,134 @@ +# API reference — MPP (Stellar) + +MPP (Machine Payments Protocol) is Stripe and Tempo's push-mode payment protocol. On Stellar, the buyer broadcasts a Soroban SAC `transfer` directly — the Facilitator verifies and acknowledges. No CCTP bridge, no workflow — the Facilitator acts as a payment verifier only. + +Both endpoints are scoped to a single seller via the `:merchantId` path parameter. The seller must be registered on `stellar:pubnet` or `stellar:testnet`. + +## `GET /merchants/:merchantId/mpp/config` + +Returns the MPP method spec for a seller. An agent uses this to know what currency, recipient, and network to sign over. + +**Rate limit:** 100 requests per minute per IP. + +### Path params + +| Param | Description | +| ------------ | --------------------------------------- | +| `merchantId` | The `merchantId` from `POST /register`. | + +### Response `200 OK` + +```json +{ + "merchantId": "hb-a1b2c3", + "protocol": "mpp", + "methods": [ + { + "name": "stellar", + "intent": "charge", + "recipient": "GSELLER...", + "currency": "CAS3FL6SZ57...", + "network": "STELLAR_PUBNET" + } + ] +} +``` + +| Field | Type | Description | +| --------------------- | ------ | --------------------------------------------------------------------- | +| `merchantId` | string | Echoed. | +| `protocol` | string | Always `"mpp"`. | +| `methods[].name` | string | Always `"stellar"` for now. | +| `methods[].intent` | string | Always `"charge"`. | +| `methods[].recipient` | string | Seller's Stellar address (payments go directly, no bridge). | +| `methods[].currency` | string | Stellar USDC contract address on the active network. | +| `methods[].network` | string | `"STELLAR_PUBNET"` or `"STELLAR_TESTNET"` depending on `NETWORK_ENV`. | + +### Errors + +| Status | `error` | Cause | +| ------ | ------------------ | --------------------- | +| `404` | `SELLER_NOT_FOUND` | Unknown `merchantId`. | +| `429` | `RATE_LIMIT` | | + +## `* /merchants/:merchantId/mpp/charge` + +Charge mode endpoint. Accepts any HTTP method (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`) because MPP negotiates the method during the challenge. The relay delegates to the official `@stellar/mpp` server. + +**Rate limit:** 500 requests per minute per IP. + +### Path params + +| Param | Description | +| ------------ | -------------------------- | +| `merchantId` | The seller's `merchantId`. | + +### Query parameters + +| Param | Required | Default | Description | +| -------- | -------- | ------- | ------------------------------------------------------ | +| `amount` | no | `"0"` | Charge amount in Stellar USDC base units (7 decimals). | + +### Request body + +Shape depends on the MPP protocol state: + +- First request (no payment): no body required. Relay issues a `402 Payment Required` challenge. +- Second request (with payment): MPP credential object, as produced by the `@stellar/mpp` client after signing the challenge. + +### Response + +**`402 Payment Required`** — initial challenge: + +Response headers include the MPP `WWW-Authenticate` challenge. The `@stellar/mpp` client reads this and signs a Soroban SAC `transfer` from the buyer to the seller's `recipient`. + +**`200 OK`** — after successful payment: + +```json +{ + "paid": true, + "merchantId": "hb-a1b2c3" +} +``` + +Headers include `Payment-Receipt` with proof of the on-chain transfer. + +### Errors + +| Status | `error` | Cause | +| ------ | ------------------ | --------------------- | +| `404` | `SELLER_NOT_FOUND` | Unknown `merchantId`. | +| `429` | `RATE_LIMIT` | | + +### Flow diagram + +```mermaid +sequenceDiagram + participant Agent as AI Agent (Stellar) + participant Seller as Seller API (Stellar) + participant Relay as Relay + + Agent->>Seller: GET /search?q=stellar + Seller->>Relay: GET /merchants/:id/mpp/charge?amount=1000000 + Relay-->>Seller: 402 + WWW-Authenticate (MPP challenge) + Seller-->>Agent: 402 + MPP challenge + + Note over Agent: Signs Soroban SAC transfer
via @stellar/mpp client + + Agent->>Seller: GET /search + Payment credential + Seller->>Relay: GET /merchants/:id/mpp/charge + credential + Note over Relay: @stellar/mpp SDK verifies
tx on-chain + Relay-->>Seller: 200 + Payment-Receipt + Seller-->>Agent: 200 OK + results +``` + +### When to use MPP vs x402 + +| Situation | Protocol | +| --------------------------------------------------------------------- | ----------------------------------------------------------- | +| Buyer and seller both on Stellar and you want the simplest push model | MPP | +| Buyer on any chain, need cross-chain settlement | x402 (CCTP) | +| Buyer wants to delegate gas to the Facilitator | x402 (buyer signs an authorization; Facilitator broadcasts) | +| Seller wants an existing Stellar wallet and push-mode tx | MPP | + +See the [dual-protocol explanation](../../explanation/dual-protocol-x402-mpp.md) for the full trade-off. diff --git a/docs/reference/api/onramp.md b/docs/reference/api/onramp.md new file mode 100644 index 0000000..084a12a --- /dev/null +++ b/docs/reference/api/onramp.md @@ -0,0 +1,69 @@ +# API reference — on-ramp + +List fiat-to-USDC providers that will deliver USDC to a specified wallet on a specified chain. This endpoint is informational — it does not move funds or hold KYC state. It exists so agents and sellers can surface on-ramp options without embedding provider lists. + +## `GET /onramp` + +**Rate limit:** 30 requests per minute per IP. + +### Query parameters + +| Param | Default | Description | +| --------------- | ---------------- | --------------------------------------------------------------------------------- | +| `network` | `stellar:pubnet` | Target CAIP-2. | +| `walletAddress` | — | Destination wallet. When provided, providers may pre-fill it into their `kycUrl`. | + +### Response `200 OK` + +| Field | Type | Description | +| ----------- | ------ | ------------------------------------------------------------------------ | +| `providers` | array | Matching providers. | +| `manual` | string | Human-readable instructions for a manual transfer when no provider fits. | + +Each `providers[]` entry: + +| Field | Type | Description | +| --------- | ------ | ----------------------------------------------------------------------------------------------------- | +| `name` | string | Provider display name (e.g. `"Bridge.xyz"`). | +| `method` | string | `"pix"`, `"cash"`, etc. | +| `regions` | array | ISO country codes where the provider operates. | +| `kycUrl` | string | HTTPS URL to begin onboarding. May include `?dest=` when `walletAddress` was supplied. | +| `fees` | string | Human-readable fee summary (e.g. `"~1%"`). | + +### Example + +```bash +curl "https://api.402md.com/onramp?network=stellar:pubnet&walletAddress=GSELLER..." +``` + +```json +{ + "providers": [ + { + "name": "Bridge.xyz", + "method": "pix", + "regions": ["BR"], + "kycUrl": "https://bridge.xyz/onboard?dest=GSELLER...", + "fees": "~1%" + }, + { + "name": "MoneyGram Ramps", + "method": "cash", + "regions": ["BR", "MX", "CO", "AR"], + "kycUrl": "https://ramps.moneygram.com", + "fees": "varies" + } + ], + "manual": "Send USDC directly from any exchange supporting stellar:pubnet." +} +``` + +### Errors + +| Status | `error` | Cause | +| ------ | ------------ | ----- | +| `429` | `RATE_LIMIT` | | + +## Adding providers + +The provider list is currently hard-coded in the relay at `packages/relay/src/onramp/onramp.service.ts`. To propose a new provider, open a pull request updating that file. Future versions will move this to a database-backed registry. diff --git a/docs/reference/api/overview.md b/docs/reference/api/overview.md new file mode 100644 index 0000000..79beff4 --- /dev/null +++ b/docs/reference/api/overview.md @@ -0,0 +1,112 @@ +# API overview + +The 402md Facilitator exposes an HTTP API for seller registration, payment verification and settlement, discovery, and MPP charge-mode. All endpoints speak JSON unless otherwise stated. + +## Base URL + +| Environment | URL | +| ----------- | ------------------------- | +| Production | `https://api.402md.com` | +| Self-hosted | `http://:3000` | +| Local | `http://localhost:3000` | + +The relay also exposes an OpenAPI spec at `/swagger/json` and a Swagger UI at `/swagger`. + +## Endpoint groups + +| Group | Reference | +| ---------------------------------------------------------------------------------------------------- | ------------------------------- | +| Seller registration and discovery (`/register`, `/discover`, `/supported`, `/.well-known/x402.json`) | [sellers](./sellers.md) | +| x402 settlement (`/verify`, `/settle`, `/bridge/*`) | [settlements](./settlements.md) | +| MPP charge mode (`/merchants/:id/mpp/*`) | [MPP](./mpp.md) | +| Bazaar and analytics (`/discovery/resources`, `/bazaar/*`) | [discovery](./discovery.md) | +| Fiat on-ramp (`/onramp`) | [on-ramp](./onramp.md) | +| Health (`/health`) | [health](./health.md) | + +## Authentication + +None of the public endpoints require authentication. The x402 protocol authenticates the buyer's payment itself via EIP-3009 signatures (EVM), Soroban SAC signatures (Stellar), or transaction signatures (Solana). The `merchantId` identifies the seller — it is not a secret. + +Administrative endpoints (not documented here) are protected by a separate key and are only intended for operators of a self-hosted relay. + +## Request format + +- `Content-Type: application/json` for all `POST` bodies. +- Query parameters are standard `?key=value` strings. +- Path parameters are URL-escaped. + +## Response format + +Successful responses return `200 OK` (or `201 Created` for `POST /register`) with a JSON body specific to the endpoint. + +## Error envelope + +All errors share one shape: + +```json +{ + "error": "ERROR_CODE", + "message": "Human-readable explanation", + "details": { "optional": "context" } +} +``` + +| HTTP status | Common `error` codes | +| --------------------------- | ------------------------------------------ | +| `400 Bad Request` | `INVALID_PAYMENT`, validation errors | +| `404 Not Found` | `SELLER_NOT_FOUND`, `ROUTE_NOT_CONFIGURED` | +| `409 Conflict` | `REPLAY_DETECTED` | +| `429 Too Many Requests` | `RATE_LIMIT` | +| `503 Service Unavailable` | `CIRCUIT_BREAKER` | +| `500 Internal Server Error` | `INTERNAL_ERROR`, `ERROR` | + +See the [error codes reference](../error-codes.md) for the canonical list. + +## Rate limits + +Rate limits are applied per IP address (read from `X-Forwarded-For` if present, else the connection IP). Limits are enforced by Redis keys with a rolling window. + +| Endpoint | Limit | +| ----------------------------------- | --------------- | +| `POST /register` | 3 per hour | +| `POST /verify` | 1000 per minute | +| `POST /settle` | 500 per minute | +| `GET /discover` | 100 per minute | +| `GET /onramp` | 30 per minute | +| `GET /merchants/:id/mpp/config` | 100 per minute | +| `* /merchants/:id/mpp/charge` | 500 per minute | +| `/bazaar/*`, `/discovery/resources` | 200 per minute | + +When you exceed a limit you receive `429 Too Many Requests` with `error: "RATE_LIMIT"`. + +## Networks + +Networks are identified by [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) strings. + +| Network | CAIP-2 | +| ----------- | -------------------------------------------------- | +| Base | `eip155:8453` (mainnet) / `eip155:84532` (sepolia) | +| Ethereum | `eip155:1` / `eip155:11155111` | +| Optimism | `eip155:10` / `eip155:11155420` | +| Arbitrum | `eip155:42161` / `eip155:421614` | +| Linea | `eip155:59144` / `eip155:59141` | +| Unichain | `eip155:130` / `eip155:1301` | +| World Chain | `eip155:480` / `eip155:4801` | +| Solana | `solana:mainnet` / `solana:devnet` | +| Stellar | `stellar:pubnet` / `stellar:testnet` | + +Which networks are actually enabled depends on the relay's configuration. Call `GET /supported` or `GET /.well-known/x402.json` to ask the relay. + +## USDC amounts + +USDC amounts are represented as **strings in base units** (6 decimals for EVM and Solana, 7 decimals for Stellar). The API never returns USDC as a float or a number. `1.000000` USDC on EVM is the string `"1000000"`. + +Never use `parseFloat` or `Number()` on amounts. Use a decimal library (`decimal.js`) or `BigInt` for arithmetic. + +## Versioning + +The API is not yet versioned in its URL. Breaking changes will be announced with at least one deprecation cycle and will bump the first digit of the OpenAPI `version` field in `/swagger/json`. The current version is `0.1.0`. + +## CORS + +The relay enables CORS for all origins by default on public endpoints, so browser-based buyers (for example, wallet-connected web agents) can call `/verify` and `/settle` without a proxy. diff --git a/docs/reference/api/sellers.md b/docs/reference/api/sellers.md new file mode 100644 index 0000000..dcb04b2 --- /dev/null +++ b/docs/reference/api/sellers.md @@ -0,0 +1,163 @@ +# API reference — sellers + +Endpoints that sellers use to register and advertise their payment acceptance. + +## `POST /register` + +Register a seller wallet and receive a `merchantId` plus the Facilitator's receiving address on every enabled chain. + +**Rate limit:** 3 requests per hour per IP. + +### Request body + +| Field | Type | Required | Validation | Description | +| --------- | ------ | -------- | ------------------------------------ | ----------------------------------------------------------------------------- | +| `wallet` | string | yes | ≥ 10 chars | Seller's wallet address on `network`. EVM `0x…`, Solana base58, Stellar `G…`. | +| `network` | string | yes | ≥ 3 chars, must be an enabled CAIP-2 | The chain the seller will receive settlement on. | + +### Response `201 Created` + +| Field | Type | Description | +| ---------------------- | ------ | ------------------------------------------------------------------- | +| `merchantId` | string | Stable identifier for this seller. Use it in every subsequent call. | +| `wallet` | string | Echoed seller wallet. | +| `network` | string | Echoed seller network (CAIP-2). | +| `facilitatorAddresses` | object | Map of CAIP-2 → facilitator receiving address on that chain. | +| `codeSnippet` | string | Ready-to-paste JavaScript showing how to wire `@x402/express`. | + +### Errors + +| Status | `error` | Cause | +| ------ | ------------------------- | ---------------------------------------- | +| `400` | validation | Missing or invalid `wallet` / `network`. | +| `404` | `UnsupportedNetworkError` | `network` is not enabled on this relay. | +| `429` | `RATE_LIMIT` | Too many registrations from this IP. | + +### Example + +```bash +curl -X POST https://api.402md.com/register \ + -H "Content-Type: application/json" \ + -d '{ "wallet": "GSELLER...", "network": "stellar:pubnet" }' +``` + +```json +{ + "merchantId": "hb-a1b2c3", + "wallet": "GSELLER...", + "network": "stellar:pubnet", + "facilitatorAddresses": { + "eip155:8453": "0xFacilitatorBase", + "solana:mainnet": "FacilitatorSolAddr", + "stellar:pubnet": "GFacilitatorStellarAddr" + }, + "codeSnippet": "…" +} +``` + +## `GET /discover` + +Look up a seller's payment configuration. Useful as a cacheable substitute for embedding the full `accepts` array in your app. + +**Rate limit:** 100 requests per minute per IP. + +### Query parameters + +| Param | Required | Description | +| ------------ | -------- | ---------------------------------- | +| `merchantId` | yes | The `merchantId` from `/register`. | + +### Response `200 OK` + +| Field | Type | Description | +| ------------------------------ | ------- | -------------------------------------------------- | +| `merchantId` | string | | +| `acceptedNetworks` | array | One entry per enabled chain (see below). | +| `sellerNetwork` | string | The seller's own chain (where they receive). | +| `fees.platform` | string | Human-readable platform fee (e.g. `"0%"`). | +| `fees.gasAllowance` | string | Pointer to `/bridge/fees` for exact values. | +| `fees.sameChain` | string | Same-chain gas allowance note. | +| `estimatedSettlement.standard` | string | Human estimate ("~5–30s" or similar). | +| `estimatedSettlement.note` | string | Longer explanation. | +| `gasFree` | boolean | `true` if the buyer does not pay gas (x402 model). | + +Each entry of `acceptedNetworks`: + +| Field | Type | Description | +| ------------------- | ------- | -------------------------------------------------------------------------- | +| `network` | string | CAIP-2 of the accepting chain. | +| `payTo` | string | Facilitator receiving address on that chain. | +| `asset` | string | Always `"USDC"`. | +| `maxTimeoutSeconds` | number | Max age of a signed authorization. | +| `extra.merchantId` | string | Pre-filled for you — copy into the `extra` you expose via `@x402/express`. | +| `bridge` | boolean | `true` if paying on this network bridges via CCTP to the seller's chain. | + +### Errors + +| Status | `error` | Cause | +| ------ | ------------------ | --------------------- | +| `400` | validation | Missing `merchantId`. | +| `404` | `SELLER_NOT_FOUND` | Unknown `merchantId`. | +| `429` | `RATE_LIMIT` | | + +### Example + +```bash +curl "https://api.402md.com/discover?merchantId=hb-a1b2c3" +``` + +## `GET /supported` + +List every x402 version, scheme, and network the relay currently accepts. Use this at boot time to decide which `accepts` entries to declare. + +**No rate limit.** + +### Response `200 OK` + +```json +{ + "kinds": [ + { "x402Version": 2, "scheme": "exact", "network": "eip155:8453" }, + { "x402Version": 2, "scheme": "exact", "network": "solana:mainnet" }, + { "x402Version": 2, "scheme": "exact", "network": "stellar:pubnet" } + ], + "extensions": [], + "signers": {} +} +``` + +| Field | Type | Description | +| ------------ | ------ | ---------------------------------------------------------------------------------------- | +| `kinds` | array | Each entry = `{ x402Version, scheme, network }`. Currently the only scheme is `"exact"`. | +| `extensions` | array | Reserved for future x402 extensions. | +| `signers` | object | Reserved. | + +## `GET /.well-known/x402.json` + +Standard x402 capability manifest. Used by x402-aware agents to discover the Facilitator. + +**No rate limit.** + +### Response `200 OK` + +```json +{ + "version": "2", + "facilitator": { + "name": "402md", + "url": "https://api.402md.com", + "networks": ["eip155:8453", "solana:mainnet", "stellar:pubnet"], + "bridgeProvider": "circle-cctp-v2", + "crossChain": true + } +} +``` + +| Field | Type | Description | +| ---------------------------- | ------- | ------------------------------------------------ | +| `version` | string | x402 protocol version (currently `"2"`). | +| `facilitator.name` | string | Display name. | +| `facilitator.url` | string | Canonical URL of this facilitator. | +| `facilitator.networks` | array | Enabled CAIP-2 networks. | +| `facilitator.bridgeProvider` | string | Always `"circle-cctp-v2"`. | +| `facilitator.crossChain` | boolean | `true` — this facilitator bridges across chains. | diff --git a/docs/reference/api/settlements.md b/docs/reference/api/settlements.md new file mode 100644 index 0000000..84c82ba --- /dev/null +++ b/docs/reference/api/settlements.md @@ -0,0 +1,183 @@ +# API reference — settlements + +x402 verification, settlement dispatch, workflow status, and fee quotes. These endpoints are consumed by the `@x402/express` middleware (or any x402-compatible server); they are not usually called by hand. + +## `POST /verify` + +Synchronously validate an x402 payment payload before returning the resource to the buyer. This endpoint never moves funds — it only answers "is this payment authorization valid?". Target latency: p95 < 50 ms. + +**Rate limit:** 1000 requests per minute per IP. + +### Request body + +Standard x402 v2 verification payload: + +| Field | Type | Required | Description | +| -------------------------------------- | ------ | -------- | -------------------------------------------------------------------------------------- | +| `x402Version` | number | yes | `2`. | +| `paymentPayload` | object | yes | x402 payment object. | +| `paymentPayload.payload.signature` | string | yes | Buyer's signature (≥ 10 chars). | +| `paymentPayload.payload` | object | yes | Scheme-specific data (EIP-3009 authorization, Soroban tx, SPL instruction). | +| `paymentRequirements` | object | yes | The server's declared requirements. | +| `paymentRequirements.scheme` | string | yes | `"exact"`. | +| `paymentRequirements.network` | string | yes | CAIP-2. | +| `paymentRequirements.payTo` | string | yes | Facilitator address on `network`. Must match the relay's own address for that network. | +| `paymentRequirements.amount` | string | yes | Amount in base units. Must be > 0. | +| `paymentRequirements.extra.merchantId` | string | yes | Identifies the target seller. | + +### Response `200 OK` + +| Field | Type | Description | +| ---------------- | ------- | ---------------------------------------------- | +| `isValid` | boolean | `true` if the payment is acceptable. | +| `invalidReason` | string | Machine-readable reason when `isValid: false`. | +| `invalidMessage` | string | Human-readable detail. | +| `payer` | string | Buyer address, if recoverable. | + +### Validation rules + +- `extra.merchantId` must exist. +- The seller identified by `merchantId` must exist. +- `network` must be enabled. +- `payTo` must equal the relay's facilitator address on `network`. +- `payload.signature` must be present. +- `amount` must be > 0. + +### Errors + +| Status | `error` | Cause | +| ------ | ------------ | --------------- | +| `400` | validation | Malformed body. | +| `429` | `RATE_LIMIT` | | + +> Note: a valid JSON payload with a semantically invalid payment returns `200 OK` with `isValid: false`, not a `4xx`. Only request malformed-ness produces a `4xx`. + +## `POST /settle` + +Dispatch a Temporal workflow to pull USDC from the buyer and deliver it to the seller. Returns immediately with a `workflowId`; settlement happens asynchronously. + +**Rate limit:** 500 requests per minute per IP. + +### Request body + +Identical shape to `POST /verify`. + +### Response `200 OK` + +| Field | Type | Description | +| -------------- | ------- | --------------------------------------------------------------------- | +| `success` | boolean | `true` if dispatch succeeded. | +| `transaction` | string | The Temporal `workflowId` — use it with `/bridge/status/:workflowId`. | +| `network` | string | Buyer network (CAIP-2). | +| `payer` | string | Buyer address. | +| `errorReason` | string | Present when `success: false`. | +| `errorMessage` | string | | + +### Workflow selection + +- Buyer network === seller network → `sameChainSettle`. +- Different networks → `crossChainSettle`. + +### Errors + +| Status | `error` | Cause | +| ------ | ------------------ | -------------------------------------------------------------- | +| `400` | `INVALID_PAYMENT` | Missing `merchantId`, zero amount, etc. | +| `404` | `SELLER_NOT_FOUND` | `merchantId` unknown. | +| `409` | `REPLAY_DETECTED` | Same signature already settled (EIP-3009 nonce or equivalent). | +| `429` | `RATE_LIMIT` | | +| `503` | `CIRCUIT_BREAKER` | Per-tx limit, daily volume limit, or global pause is active. | + +## `GET /bridge/status/:workflowId` + +Query the current state of a settlement workflow. + +**No rate limit.** + +### Path params + +| Param | Description | +| ------------ | ------------------------------------------------------ | +| `workflowId` | The value returned as `transaction` by `POST /settle`. | + +### Response `200 OK` + +Shape depends on the workflow type. + +**`sameChainSettle`:** + +| Field | Type | Description | +| ----------------- | ------- | --------------------------------------------------------------- | +| `status` / `step` | string | `pulling`, `transferring`, `recording`, `settled`, or `failed`. | +| `pullTxHash` | string? | Populated once the pull tx lands. | +| `transferTxHash` | string? | Populated once the transfer to seller lands. | +| `error` | string? | Populated if `failed`. | + +**`crossChainSettle`:** + +| Field | Type | Description | +| ----------------- | ------- | ---------------------------------------------------------------------------------- | +| `status` / `step` | string | `pulling`, `burning`, `attesting`, `minting`, `recording`, `settled`, or `failed`. | +| `pullTxHash` | string? | | +| `burnTxHash` | string? | | +| `attestation` | string? | Circle attestation hex, once received. | +| `mintTxHash` | string? | | +| `error` | string? | | + +### Errors + +| Status | Cause | +| ------ | --------------------- | +| `404` | `workflowId` unknown. | + +## `GET /bridge/fees` + +Quote the fee breakdown for a given route and amount. This is the authoritative source for gas allowances — the static schedule in [fees](../fees.md) is for reference only. + +**Rate limit:** 200 requests per minute per IP. + +### Query parameters + +| Param | Required | Description | +| -------- | -------- | --------------------------- | +| `from` | yes | Buyer network (CAIP-2). | +| `to` | yes | Seller network (CAIP-2). | +| `amount` | yes | Gross amount in base units. | + +### Response `200 OK` + +| Field | Type | Description | +| ---------------- | ------ | ----------------------------------------------------------- | +| `platformFee` | string | Base-unit amount deducted as platform fee. Currently `"0"`. | +| `gasAllowance` | string | Base-unit amount deducted to reimburse facilitator gas. | +| `totalDeduction` | string | `platformFee + gasAllowance`. | +| `sellerReceives` | string | `amount - totalDeduction`. | +| `currency` | string | `"USDC"`. | +| `decimals` | number | `6` for EVM and Solana, `7` for Stellar. | +| `note` | string | Plain-text explanation of the allowance. | + +### Errors + +| Status | `error` | Cause | +| ------ | ---------------------- | --------------------------------- | +| `400` | validation | Missing `from` / `to` / `amount`. | +| `404` | `ROUTE_NOT_CONFIGURED` | No gas schedule for this pair. | +| `429` | `RATE_LIMIT` | | + +### Example + +```bash +curl "https://api.402md.com/bridge/fees?from=eip155:8453&to=stellar:pubnet&amount=1000000" +``` + +```json +{ + "platformFee": "0", + "gasAllowance": "500", + "totalDeduction": "500", + "sellerReceives": "999500", + "currency": "USDC", + "decimals": 6, + "note": "Gas allowance covers pull + burn + mint on this route." +} +``` diff --git a/docs/reference/chains.md b/docs/reference/chains.md new file mode 100644 index 0000000..896b1a4 --- /dev/null +++ b/docs/reference/chains.md @@ -0,0 +1,83 @@ +# Supported chains + +Every supported chain uses Circle's native USDC via CCTP V2 — burn/mint, zero slippage, no wrapped tokens. + +Chains are **opt-in**: each is enabled by setting `FACILITATOR_` plus its RPC URL. Unconfigured chains are silently excluded from `GET /supported`, `GET /discover`, and `GET /.well-known/x402.json`. + +## Mainnet + +| Chain | CAIP-2 | CCTP domain | Family | SDK | +| ----------- | ---------------- | ----------- | ------- | ---------------------- | +| Base | `eip155:8453` | 6 | EVM | `viem` | +| Ethereum | `eip155:1` | 0 | EVM | `viem` | +| Optimism | `eip155:10` | 2 | EVM | `viem` | +| Arbitrum | `eip155:42161` | 3 | EVM | `viem` | +| Linea | `eip155:59144` | 11 | EVM | `viem` | +| Unichain | `eip155:130` | 10 | EVM | `viem` | +| World Chain | `eip155:480` | 14 | EVM | `viem` | +| Solana | `solana:mainnet` | 5 | Solana | `@solana/web3.js` | +| Stellar | `stellar:pubnet` | 27 | Stellar | `@stellar/stellar-sdk` | + +## Testnet + +| Chain | CAIP-2 | CCTP domain | +| ------------------- | ----------------- | ----------- | +| Base Sepolia | `eip155:84532` | 6 | +| Ethereum Sepolia | `eip155:11155111` | 0 | +| Optimism Sepolia | `eip155:11155420` | 2 | +| Arbitrum Sepolia | `eip155:421614` | 3 | +| Linea Sepolia | `eip155:59141` | 11 | +| Unichain Sepolia | `eip155:1301` | 10 | +| World Chain Sepolia | `eip155:4801` | 14 | +| Solana devnet | `solana:devnet` | 5 | +| Stellar testnet | `stellar:testnet` | 27 | + +Select which set is active with `NETWORK_ENV=mainnet` or `NETWORK_ENV=testnet`. + +## Pull mechanism (buyer → facilitator) + +| Family | Pull mechanism | CCTP burn call | +| ------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | +| EVM | `USDC.transferWithAuthorization` (EIP-3009) | `TokenMessengerV2.depositForBurn` — or `depositForBurnWithHook` when the destination is Stellar | +| Solana | Facilitator pays fee + `TransferChecked` authorized by buyer | CCTP program `depositForBurn` | +| Stellar | Facilitator is fee source + buyer-signed payment op | Soroban `TokenMessengerMinter.deposit_for_burn` | + +The buyer **never pays gas** in the x402 flow — the Facilitator submits every transaction. In MPP charge mode, the buyer broadcasts directly and pays their own fee. + +## Cross-chain destination: Stellar + +When a buyer pays on an EVM chain and the seller is on Stellar, the EVM adapter uses `depositForBurnWithHook` on `TokenMessengerV2` with the `CctpForwarder` contract as `mintRecipient`. `hookData` carries the seller's Stellar address. When Circle attests, the worker calls `receiveMessage` on the destination's `MessageTransmitter`; `CctpForwarder` atomically mints and forwards USDC to the seller's Stellar account. + +You do not have to do anything on the seller side — registering on `stellar:pubnet` is enough. + +## EVM key sharing + +All 7 EVM chains share a single private key: set `FACILITATOR_PRIVATE_KEY_EVM` once, on the worker. The same `0x…` address serves as `FACILITATOR_BASE`, `FACILITATOR_ETHEREUM`, `FACILITATOR_OPTIMISM`, etc. — set each one to the same address. + +Solana and Stellar use separate keys: + +- `FACILITATOR_PRIVATE_KEY_SOLANA` — base64-encoded keypair. +- `FACILITATOR_PRIVATE_KEY_STELLAR` — Stellar secret key (`S…`). + +## One-time USDC approval per EVM chain + +Before the Facilitator can burn on an EVM chain, its wallet must approve `TokenMessengerV2` to spend USDC. Do this once per chain: + +```bash +cast send \ + "approve(address,uint256)" \ + \ + $(python3 -c "print(2**256-1)") \ + --private-key $FACILITATOR_PRIVATE_KEY_EVM \ + --rpc-url $BASE_RPC_URL +``` + +The exact `USDC_CONTRACT` and `TokenMessengerV2` addresses per chain are in Circle's public deployment docs. + +## Chains explicitly excluded + +Polygon and Avalanche are excluded: CCTP V1 only, which has a different `depositForBurn` signature and ~13-minute settlement. They would require a separate adapter with a separate test matrix. + +## Adding a new chain + +Any new CCTP V2 deployment fits the existing EVM adapter. See [add a new EVM chain](../how-to/contributors/add-a-new-evm-chain.md). diff --git a/docs/reference/cli-scripts.md b/docs/reference/cli-scripts.md new file mode 100644 index 0000000..958eb00 --- /dev/null +++ b/docs/reference/cli-scripts.md @@ -0,0 +1,128 @@ +# CLI and scripts + +Every `bun run` task and shell script in the repo, and what it does. + +## Root scripts (Turborepo) + +Run from the repo root. + +| Command | Purpose | +| -------------------- | ---------------------------------------------------------- | +| `bun run build` | Build every package in dependency order. | +| `bun run dev` | Run every package in watch mode (spawns relay and worker). | +| `bun run test` | Run the full test suite. | +| `bun run lint` | ESLint across all packages. | +| `bun run format` | Prettier `--check` across tracked files. | +| `bun run format:fix` | Prettier `--write` — fixes style. | + +## Relay scripts + +Run from `packages/relay`. + +| Command | Purpose | +| --------------------- | ---------------------------------------------------------------------- | +| `bun run dev` | Start the HTTP server with hot reload. | +| `bun run start` | Start without watch (production-style). | +| `bun run build` | Type-check and compile. | +| `bun run db:generate` | Generate a new Drizzle migration from schema changes. | +| `bun run db:push` | Push the current schema to the database without migrations (dev only). | +| `bun run db:migrate` | Apply pending migrations. | +| `bun run test` | Run the relay test suite. | + +## Worker scripts + +Run from `packages/worker`. + +| Command | Purpose | +| --------------- | ------------------------------------------------------------------------------- | +| `bun run dev` | Start the worker, polling both `fast-settlement` and `cross-settlement` queues. | +| `bun run start` | Start without watch. | +| `bun run build` | Type-check and compile. | +| `bun run test` | Run the worker test suite (uses Temporal's test environment). | + +## Shared scripts + +Run from `packages/shared`. + +| Command | Purpose | +| --------------- | ----------------------------------- | +| `bun run build` | Type-check and compile the library. | +| `bun run test` | Run adapter tests. | + +## Demo scripts + +### `./scripts/demo.sh` + +End-to-end demo orchestrator from the repo root. Starts relay, worker, demo-seller, and demo-agent in order, with sleeps to let each boot, then runs the agent against the seller. Cleans up on exit. + +```bash +./scripts/demo.sh +``` + +Env it honors: + +- `RELAY_PORT` (default `3000`) +- `DEMO_SELLER_PORT` (default `4000`) +- `FACILITATOR_URL` (default `http://localhost:3000`) + +### demo-seller + +```bash +cd packages/demo-seller + +FACILITATOR_URL=http://localhost:3000 \ +SELLER_WALLET=GSELLER... \ +SELLER_NETWORK=stellar:testnet \ +DEMO_SELLER_PORT=4000 \ +bun run src/index.ts +``` + +Self-registers with the relay, serves a paywalled `GET /search?q=...`. + +### demo-agent + +```bash +cd packages/demo-agent + +FACILITATOR_URL=http://localhost:3000 \ +DEMO_SELLER_URL=http://localhost:4000 \ +AGENT_SECRET_KEY=SAGENT... \ +bun run src/index.ts +``` + +Discovers services via `/bazaar`, pays via MPP, prints results. + +## Docker + +| Command | Purpose | +| --------------------------------- | ----------------------------------------------- | +| `docker compose up -d` | Start PostgreSQL, Redis, Temporal, Temporal UI. | +| `docker compose down` | Stop services, preserve volumes. | +| `docker compose down -v` | Stop services, wipe volumes (fresh state). | +| `docker compose ps` | Check service status. | +| `docker compose logs -f temporal` | Tail one service's logs. | + +## Temporal CLI + +One-time setup (after `docker compose up`): + +```bash +temporal operator namespace create 402md-settlement +temporal operator search-attribute create \ + --name sellerNetwork --type Keyword \ + --name buyerNetwork --type Keyword \ + --name settlementStatus --type Keyword \ + --name protocol --type Keyword +``` + +Ad-hoc queries: + +```bash +temporal workflow list --namespace 402md-settlement \ + --query 'settlementStatus = "failed"' + +temporal workflow describe --namespace 402md-settlement \ + --workflow-id +``` + +See [monitor workflows in Temporal](../how-to/operations/monitor-workflows-in-temporal.md). diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md new file mode 100644 index 0000000..325e395 --- /dev/null +++ b/docs/reference/environment-variables.md @@ -0,0 +1,101 @@ +# Environment variables + +Every env var read by the relay, worker, shared library, and demo packages. Required variables have bold names. + +## relay (`packages/relay`) + +| Name | Default | Purpose | +| ----------------------------- | ------------------------------------- | ---------------------------------------------------------------- | +| **`NETWORK_ENV`** | `testnet` | `testnet` or `mainnet` — selects the mainnet/testnet CAIP-2 set. | +| **`DATABASE_URL`** | — | PostgreSQL connection string. | +| `REDIS_URL` | `redis://localhost:6379` | Redis connection string. | +| `TEMPORAL_ADDRESS` | `localhost:7233` | Temporal server address. | +| `TEMPORAL_NAMESPACE` | `402md-settlement` | Temporal namespace. | +| `PORT` | `3000` | HTTP port. | +| `FACILITATOR_URL` | `https://api.402md.com` | Canonical relay URL, echoed in `/.well-known/x402.json`. | +| `PLATFORM_FEE_BPS` | `0` | Platform fee in basis points. | +| `MAX_TX_AMOUNT` | `1000000000` | Per-tx circuit breaker (base units). | +| `DAILY_VOLUME_LIMIT` | `10000000000` | Daily volume circuit breaker (base units). | +| `MPP_SECRET_KEY` | `dev-secret-key-change-in-production` | HMAC secret for MPP challenges. | +| `OTEL_ENABLED` | `false` | Enable OpenTelemetry tracing. | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4318/v1/traces` | OTLP collector endpoint. | +| `FACILITATOR_` | — | Receiving address per enabled chain. See chain table below. | +| `_RPC_URL` | Public defaults on testnet | RPC endpoint per enabled chain. | + +## worker (`packages/worker`) + +Everything the relay reads, **plus**: + +| Name | Default | Purpose | +| --------------------------------- | ------- | ------------------------------------------------ | +| `FACILITATOR_PRIVATE_KEY_EVM` | — | EVM private key, shared across all 7 EVM chains. | +| `FACILITATOR_PRIVATE_KEY_SOLANA` | — | Solana keypair, base64-encoded. | +| `FACILITATOR_PRIVATE_KEY_STELLAR` | — | Stellar secret key (`S…`). | + +Only the worker reads private keys. Never set them on the relay. + +## shared (`packages/shared`) + +No direct env — inherits from whichever process imports it. Variables it reads when loaded: + +- `NETWORK_ENV` +- `DATABASE_URL` +- `REDIS_URL` +- `OTEL_EXPORTER_OTLP_ENDPOINT` +- All `FACILITATOR_*` and `_RPC_URL` +- All `FACILITATOR_PRIVATE_KEY_*` + +## demo-seller (`packages/demo-seller`) + +Runtime-only. No `.env.example` ships. + +| Name | Default | Purpose | +| ------------------- | ----------------------- | ------------------------------------ | +| `FACILITATOR_URL` | `http://localhost:3000` | Relay endpoint to register with. | +| **`SELLER_WALLET`** | — | Seller's wallet on `SELLER_NETWORK`. | +| `SELLER_NETWORK` | `stellar:testnet` | Network the demo registers on. | +| `DEMO_SELLER_PORT` | `4000` | HTTP port. | +| `MPP_SECRET_KEY` | `demo-seller-secret` | HMAC secret matching the relay's. | + +## demo-agent (`packages/demo-agent`) + +Runtime-only. No `.env.example` ships. + +| Name | Default | Purpose | +| ---------------------- | ----------------------- | ------------------------------------------------------ | +| `FACILITATOR_URL` | `http://localhost:3000` | Relay endpoint for bazaar lookup. | +| `DEMO_SELLER_URL` | `http://localhost:4000` | Demo seller to query. | +| **`AGENT_SECRET_KEY`** | — | Stellar secret key (`S…`) used to sign MPP challenges. | + +## Per-chain variable names + +One receiving address and one RPC URL per enabled EVM chain, plus their non-EVM equivalents: + +| Chain | Address env | RPC env | +| ----------- | ------------------------ | -------------------- | +| Base | `FACILITATOR_BASE` | `BASE_RPC_URL` | +| Ethereum | `FACILITATOR_ETHEREUM` | `ETHEREUM_RPC_URL` | +| Optimism | `FACILITATOR_OPTIMISM` | `OPTIMISM_RPC_URL` | +| Arbitrum | `FACILITATOR_ARBITRUM` | `ARBITRUM_RPC_URL` | +| Linea | `FACILITATOR_LINEA` | `LINEA_RPC_URL` | +| Unichain | `FACILITATOR_UNICHAIN` | `UNICHAIN_RPC_URL` | +| World Chain | `FACILITATOR_WORLDCHAIN` | `WORLDCHAIN_RPC_URL` | +| Solana | `FACILITATOR_SOLANA` | `SOLANA_RPC_URL` | +| Stellar | `FACILITATOR_STELLAR` | `STELLAR_RPC_URL` | + +A chain becomes enabled when its `FACILITATOR_*` address is set. Unset = disabled, silently. + +All 7 EVM chains share the same `0x…` address and the same `FACILITATOR_PRIVATE_KEY_EVM` — set the same value into `FACILITATOR_BASE`, `FACILITATOR_ETHEREUM`, etc. + +## Validating your config + +The worker calls `validateNetworkEnv({ requirePrivateKeys: true })` at boot. It will fail fast if any enabled chain is missing its address or key. + +For a manual check: + +```bash +cd packages/relay +bun run -e "import('@402md/shared').then(s => console.log(s.validateNetworkEnv()))" +``` + +It prints `{ ok: true }` or `{ ok: false, missing: [...] }`. diff --git a/docs/reference/error-codes.md b/docs/reference/error-codes.md new file mode 100644 index 0000000..786f1f6 --- /dev/null +++ b/docs/reference/error-codes.md @@ -0,0 +1,69 @@ +# Error codes + +Every error returned by the relay uses the same JSON envelope: + +```json +{ + "error": "CODE", + "message": "Human-readable detail", + "details": { "optional": "context" } +} +``` + +## Canonical codes + +| `error` | HTTP | Cause | Raised by | +| ---------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `SELLER_NOT_FOUND` | `404` | Unknown `merchantId`. | `/discover`, `/settle`, `/merchants/:id/mpp/config`, `/merchants/:id/mpp/charge`, `/bazaar/transactions?merchantId=…` | +| `INVALID_PAYMENT` | `400` | Payment validation failed (missing `merchantId`, zero amount, `payTo` mismatch, invalid signature, amount over per-tx limit). | `/verify`, `/settle` | +| `REPLAY_DETECTED` | `409` | Signature / nonce already settled. | `/settle` | +| `CIRCUIT_BREAKER` | `503` | Per-tx limit, daily volume limit, or global pause is active. | `/settle` | +| `RATE_LIMIT` | `429` | Per-IP rate limit exceeded on this endpoint. | All rate-limited endpoints | +| `ROUTE_NOT_CONFIGURED` | `404` | No gas allowance schedule for the `from` / `to` pair. | `/bridge/fees`, `/bazaar/cost-comparison` | +| `ERROR` | varies (defaults 500) | Custom operational error raised by a route handler. | Any | +| `INTERNAL_ERROR` | `500` | Unhandled exception. | Any — check logs / traces. | + +There is no `UnsupportedNetworkError` in the canonical list because it is raised during registration only; it surfaces with HTTP `404` and the same envelope. + +## Validation failures + +Request-shape failures (missing required fields, wrong types, `minLength` violations) return `400 Bad Request` with Elysia's default error format — not the envelope above. You will see: + +```json +{ + "type": "validation", + "on": "body", + "property": "/wallet", + "message": "Expected string to have a length of at least 10" +} +``` + +If you write a client, handle both shapes. + +## `invalidReason` inside `/verify` + +`POST /verify` returns HTTP `200` with `isValid: false` and a machine-readable `invalidReason`. These are not top-level errors. Common values: + +| `invalidReason` | Meaning | +| ----------------------- | --------------------------------------------------- | +| `missing_merchantId` | `extra.merchantId` not set. | +| `seller_not_found` | `merchantId` unknown. | +| `network_not_supported` | `network` not enabled on this relay. | +| `payTo_mismatch` | `payTo` is not the relay's address on this network. | +| `invalid_signature` | Cryptographic check failed. | +| `amount_too_small` | Amount ≤ 0 or below scheme minimum. | +| `expired_authorization` | `validBefore` already passed. | + +`invalidMessage` is the human-readable counterpart. Surface it to the buyer's client so they can retry correctly. + +## Troubleshooting by `error` + +- **`CIRCUIT_BREAKER` on every settle** — check `redis-cli GET facilitator:pause`, `GET volume:daily:YYYY-MM-DD`, and `MAX_TX_AMOUNT`. See [trigger circuit breakers](../how-to/operations/trigger-circuit-breakers.md). +- **`REPLAY_DETECTED`** — the EIP-3009 nonce or Stellar authorization nonce has already been consumed. The buyer must sign a fresh one. There is no "release nonce" — this is a security feature. +- **`SELLER_NOT_FOUND` on a newly-registered merchant** — check you are hitting the same relay instance you registered with (PostgreSQL, not Redis, persists sellers). +- **`ROUTE_NOT_CONFIGURED`** — the pair does not have a gas schedule entry. Add one in `packages/shared/src/networks/gas-schedule.ts` and redeploy. +- **`INTERNAL_ERROR`** — always a bug. Report it with the `workflowId` (if applicable) and the request `id` from the logs. + +## No alerting on `RATE_LIMIT` + +`429`s are part of normal operation — chatty agents hit them. Do not alert on `RATE_LIMIT` at the infra level; monitor it per-endpoint for abuse patterns instead. diff --git a/docs/reference/fees.md b/docs/reference/fees.md new file mode 100644 index 0000000..db017b7 --- /dev/null +++ b/docs/reference/fees.md @@ -0,0 +1,68 @@ +# Fees and gas allowance + +**Platform fee is 0%.** The facilitator never takes a cut of the payment. The only deduction is a fixed gas allowance that reimburses the on-chain transactions the Facilitator submits on behalf of the buyer and seller. + +For runtime quotes, call [`GET /bridge/fees`](./api/settlements.md#get-bridgefees) — that endpoint is authoritative. The table below is the current static schedule and may lag. + +## How the allowance is deducted + +The buyer signs an authorization for the **gross** amount. The facilitator pulls it, retains the gas allowance in USDC, burns/transfers the net, and the seller receives the net. + +``` +Buyer pays: $1.000000 USDC +Gas allowance: -$0.000500 (fixed per route) +Platform fee: -$0.000000 (0%) + ───────── +Seller receives: $0.999500 USDC +``` + +In MPP charge mode, the buyer broadcasts the transaction directly and pays the chain's own fee — nothing is deducted on the relay. + +## Current schedule + +Values are in USDC base units (6 decimals on EVM / Solana, 7 on Stellar) and converted to USDC for readability. + +| Route | Gas allowance | Seller receives on $1.00 | +| ----------------- | ------------- | ------------------------ | +| Base → Stellar | $0.000500 | $0.999500 | +| Stellar → Base | $0.000500 | $0.999500 | +| Base → Solana | $0.000800 | $0.999200 | +| Solana → Base | $0.001200 | $0.998800 | +| Solana → Stellar | $0.000800 | $0.999200 | +| Stellar → Solana | $0.000800 | $0.999200 | +| Base → Base | $0.000400 | $0.999600 | +| Solana → Solana | $0.000800 | $0.999200 | +| Stellar → Stellar | $0.000006 | $0.999994 | + +Routes not listed inherit the cost of the source chain for same-chain settlement, or are resolved at runtime by `GET /bridge/fees` for cross-chain pairs. + +## Why fixed, not estimated + +- **No gas oracle.** Oracles add a dependency and a failure mode. +- **Predictable math.** The buyer and seller can compute the net amount locally from the schedule. +- **The facilitator takes the variance.** If actual gas is lower than the allowance, the difference stays in the Facilitator wallet. If higher, the Facilitator absorbs it. Allowances are chosen to be slightly above median gas so the Facilitator is rarely out of pocket at scale. + +CCTP itself charges nothing — burn/mint is 1:1 native USDC. + +## Platform fee (configurable, currently 0) + +The relay reads `PLATFORM_FEE_BPS` (basis points) at startup. Default `0`. Increase only if the 402md business model changes — see [product philosophy](../../CLAUDE.md) for why the default is zero. + +When non-zero, the platform fee is computed in base units as `(amount * PLATFORM_FEE_BPS) / 10000` and is deducted **in addition to** the gas allowance. + +## Cost comparison with percentage-based rails + +`GET /bazaar/cost-comparison?buyerNetwork=…&sellerNetwork=…` returns a three-tier comparison ($100K, $1M, $100M USDC) against a baseline of 1% + $0.10. At high-value tiers the flat allowance is orders of magnitude cheaper. + +| Amount | 402md (Base → Stellar) | 1% + $0.10 baseline | Savings | +| ------------ | ---------------------- | ------------------- | ----------- | +| $100,000 | $0.000500 | $1,000.10 | ~$1,000 | +| $1,000,000 | $0.000500 | $10,000.10 | ~$10,000 | +| $100,000,000 | $0.000500 | $1,000,000.10 | ~$1,000,000 | + +Same allowance for $1 or $100M — ideal for machine-to-machine micropayments and for large B2B settlements alike. + +## Next + +- [API reference — `GET /bridge/fees`](./api/settlements.md#get-bridgefees) — authoritative quote. +- [Architecture — why non-custodial](../explanation/non-custodial-model.md) — why the Facilitator can charge so little. diff --git a/docs/tutorials/01-first-cross-chain-payment.md b/docs/tutorials/01-first-cross-chain-payment.md new file mode 100644 index 0000000..8d35b6b --- /dev/null +++ b/docs/tutorials/01-first-cross-chain-payment.md @@ -0,0 +1,218 @@ +# Your first cross-chain payment + +This tutorial walks you through the full settlement path: an AI agent on one chain pays a seller on another chain, and USDC lands on the seller's wallet. By the end, you will have registered as a seller, received a payment from the demo agent, and watched the Temporal workflow settle. + +You do not need to write any code. You do not need mainnet funds. Everything runs on testnets with local infrastructure. + +> **Prefer learning by reading?** This tutorial ends with a pointer to the [architecture explanation](../explanation/architecture.md) and the [API reference](../reference/api/overview.md). + +## What you will build + +``` +AI Agent (Stellar testnet) ──► Facilitator Relay ──► Worker ──► CCTP V2 ──► Seller wallet +``` + +- A relay (HTTP API) and a worker (Temporal workflows) running locally. +- A registered seller wallet on Stellar testnet. +- A demo agent that pays for search queries via MPP. +- Settled transactions visible in the Temporal UI. + +## Before you start + +You need: + +- [Bun](https://bun.sh) (latest) and [Node.js](https://nodejs.org) 20+. +- [Docker](https://www.docker.com) running. +- [Temporal CLI](https://docs.temporal.io/cli) installed. +- A Stellar testnet keypair for the seller wallet and another for the agent. Generate both with the [Stellar Lab](https://laboratory.stellar.org/#account-creator?network=test). +- A Base Sepolia EVM wallet funded with test ETH (for the Facilitator's EVM key). + +This tutorial uses testnets only — no real money moves. + +## 1. Start the local infrastructure + +From the repo root: + +```bash +docker compose up -d +``` + +This starts: + +| Service | Port | Purpose | +| ------------- | ---- | -------------------------------------- | +| PostgreSQL 15 | 5432 | Application database | +| Redis 7 | 6379 | Replay protection and circuit breakers | +| Temporal | 7233 | Workflow orchestration | +| Temporal UI | 8233 | Watch workflows run | + +Wait ~10 seconds for Temporal to finish its auto-setup, then create the settlement namespace (one-time): + +```bash +temporal operator namespace create 402md-settlement +``` + +## 2. Configure the Facilitator + +Copy the example env files: + +```bash +cp packages/relay/.env.example packages/relay/.env +cp packages/worker/.env.example packages/worker/.env +``` + +Open both files and fill in at least these variables for Stellar testnet operation: + +```bash +NETWORK_ENV=testnet + +# Stellar — the Facilitator's Stellar wallet +FACILITATOR_STELLAR=GFACILITATOR... # your Stellar public key +FACILITATOR_PRIVATE_KEY_STELLAR=SFACILITATOR... # worker only + +# Base Sepolia — so buyers on Base can pay too +FACILITATOR_BASE=0xFacilitator... +FACILITATOR_PRIVATE_KEY_EVM=0x... # worker only + +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/fourzerotwomd +REDIS_URL=redis://localhost:6379 +TEMPORAL_ADDRESS=localhost:7233 +TEMPORAL_NAMESPACE=402md-settlement +``` + +Fund both facilitator wallets on testnet: + +- Stellar: use the [Friendbot](https://laboratory.stellar.org/#account-creator?network=test). +- Base Sepolia: use a public [Base Sepolia faucet](https://www.alchemy.com/faucets/base-sepolia) for ETH and any USDC faucet for Circle's testnet USDC. + +For a full wallet setup (including the one-time USDC approval for the CCTP `TokenMessengerV2` contract on each EVM chain), see [set up dev environment](../how-to/contributors/set-up-dev-environment.md). + +## 3. Install and migrate + +```bash +bun install +bun run build + +cd packages/relay +bun run db:migrate +cd - +``` + +## 4. Start the relay and worker + +In one terminal: + +```bash +cd packages/relay +bun run dev +``` + +You should see `relay listening on http://localhost:3000`. + +In a second terminal: + +```bash +cd packages/worker +bun run dev +``` + +You should see `worker started, polling fast-settlement and cross-settlement task queues`. + +## 5. Register your seller wallet + +You are now the "seller". Pick the Stellar testnet keypair you generated as your seller wallet. + +```bash +curl -X POST http://localhost:3000/register \ + -H "Content-Type: application/json" \ + -d '{ + "wallet": "GSELLER...", + "network": "stellar:testnet" + }' +``` + +You will get back a `merchantId` and a map of facilitator addresses per enabled chain: + +```json +{ + "merchantId": "hb-a1b2c3", + "wallet": "GSELLER...", + "network": "stellar:testnet", + "facilitatorAddresses": { + "eip155:84532": "0xFacilitator...", + "stellar:testnet": "GFACILITATOR..." + } +} +``` + +Copy the `merchantId` — you will need it. This is the primitive that lets a buyer on any chain route a payment to your single wallet. See [`merchantId` as a primitive](../explanation/merchant-id-as-primitive.md) for the rationale. + +## 6. Run the demo agent + +The repo ships with a demo agent that discovers services via the bazaar and pays for them using MPP. Before running it, you also need the demo seller running so there's something to discover. + +In a third terminal, start the demo seller: + +```bash +cd packages/demo-seller + +FACILITATOR_URL=http://localhost:3000 \ +SELLER_WALLET=GSELLER... \ +SELLER_NETWORK=stellar:testnet \ +DEMO_SELLER_PORT=4000 \ +bun run src/index.ts +``` + +The demo seller auto-registers with the relay, spins up a paywalled `GET /search?q=...` endpoint priced at `0.001` USDC, and prints its `merchantId`. + +In a fourth terminal, run the agent: + +```bash +cd packages/demo-agent + +FACILITATOR_URL=http://localhost:3000 \ +DEMO_SELLER_URL=http://localhost:4000 \ +AGENT_SECRET_KEY=SAGENT... \ +bun run src/index.ts +``` + +The agent will: + +1. `GET /bazaar` on the relay to discover services. +2. Issue 5 paid search queries to the demo seller. +3. Sign each MPP challenge with its Stellar keypair. +4. Print the search results it got back. + +## 7. Watch the settlement + +Open the Temporal UI at [http://localhost:8233](http://localhost:8233) and select the `402md-settlement` namespace. You will see one workflow per paid query, most `sameChainSettle` (both parties on Stellar). + +For each workflow you can click through: + +1. **pulling** — facilitator pulls USDC from the buyer. +2. **transferring** (same-chain) or **burning → attesting → minting** (cross-chain). +3. **recording** — writes the ledger entry. +4. **settled** — final state. + +## 8. Shortcut: one-command demo + +Instead of running steps 5–6 by hand, `scripts/demo.sh` orchestrates everything: + +```bash +./scripts/demo.sh +``` + +It builds, starts relay + worker + demo-seller + demo-agent in sequence, runs the agent, and cleans up on exit. + +## What you just learned + +- How to stand up the full stack locally. +- How a seller registers with one `POST /register`. +- How `merchantId` routes cross-chain payments to a single wallet. +- How Temporal workflows make settlement durable and observable. + +## Next steps + +- [Build a paywalled API with x402](./02-paywalled-api-with-x402.md) — turn one of your own endpoints into a paywalled resource. +- [Architecture overview](../explanation/architecture.md) — understand why the relay + worker split and where Temporal fits. +- [API reference](../reference/api/overview.md) — full details on every endpoint. diff --git a/docs/tutorials/02-paywalled-api-with-x402.md b/docs/tutorials/02-paywalled-api-with-x402.md new file mode 100644 index 0000000..c17e091 --- /dev/null +++ b/docs/tutorials/02-paywalled-api-with-x402.md @@ -0,0 +1,198 @@ +# Build a paywalled API with x402 + +In this tutorial you will turn a plain Express endpoint into a paywalled resource that accepts USDC payments from buyers on multiple chains, and receives settlement on your single wallet. + +You will use the standard `@x402/express` SDK from Coinbase — no 402md-specific libraries. The 402md Facilitator verifies payments, bridges via CCTP V2, and delivers USDC to your wallet. + +By the end, you will have an API that responds with `402 Payment Required`, negotiates payment over HTTP, and returns the resource on success. + +> If you have not completed [Your first cross-chain payment](./01-first-cross-chain-payment.md) yet, do that first. It assumes you have a running relay, worker, and registered merchant. + +## What you will build + +```mermaid +flowchart LR + A[Buyer — Base Sepolia] --> B[Your API] + B -->|POST /verify| R[Relay] + R -->|isValid| B + B -->|200 OK + resource| A + B -.->|POST /settle| R + R --> W[Worker] + W --> CCTP[CCTP V2] + CCTP --> S[Seller wallet — Stellar] + + style A fill:#1a1a2e,stroke:#3245FF,color:#fff + style B fill:#1a1a2e,stroke:#F59E0B,color:#fff + style R fill:#1a1a2e,stroke:#00D4AA,color:#fff + style W fill:#1a1a2e,stroke:#00D4AA,color:#fff + style CCTP fill:#1a1a2e,stroke:#00D4AA,color:#fff + style S fill:#1a1a2e,stroke:#7B68EE,color:#fff +``` + +A single Express app with one paywalled route. Payments from Base, Ethereum, and Stellar all land in your Stellar wallet. + +## Prerequisites + +- A running relay on `http://localhost:3000` (or a deployed one). +- A registered seller: you have a `merchantId` and the map of `facilitatorAddresses`. +- Node.js 20+ and `npm` or `bun`. + +## 1. Scaffold an Express app + +```bash +mkdir weather-api && cd weather-api +bun init -y +bun add express @x402/express +``` + +Create `index.ts`: + +```typescript +import express from 'express' +import { paymentMiddleware } from '@x402/express' + +const app = express() +const MERCHANT_ID = 'hb-a1b2c3' + +app.listen(5001, () => console.log('Weather API listening on :5001')) +``` + +Run it to confirm it boots: + +```bash +bun run index.ts +``` + +## 2. Add the x402 middleware + +The `@x402/express` middleware intercepts requests to paywalled routes, issues `402 Payment Required` with the accepted payment methods, validates the payment payload against the Facilitator, and only then lets the handler run. + +Replace the contents of `index.ts`: + +```typescript +import express from 'express' +import { paymentMiddleware } from '@x402/express' + +const app = express() +const MERCHANT_ID = 'hb-a1b2c3' +const FACILITATOR_URL = 'http://localhost:3000' + +app.use( + paymentMiddleware({ + facilitatorUrl: FACILITATOR_URL, + routes: { + 'GET /weather': { + accepts: [ + { + scheme: 'exact', + network: 'eip155:84532', + payTo: '0xFacilitatorBaseSepolia...', + price: '$0.001', + extra: { merchantId: MERCHANT_ID }, + }, + { + scheme: 'exact', + network: 'stellar:testnet', + payTo: 'GFACILITATOR...', + price: '$0.001', + extra: { merchantId: MERCHANT_ID }, + }, + ], + }, + }, + }), +) + +app.get('/weather', (_req, res) => { + res.json({ city: 'Lisbon', tempC: 19, conditions: 'partly cloudy' }) +}) + +app.listen(5001) +``` + +Three things to notice: + +- **`payTo` is the Facilitator's address on that chain** — not your wallet. Buyers pay the Facilitator, and the Facilitator settles to you. +- **`extra.merchantId` is how the Facilitator knows which seller to route the payment to.** Without it, `/verify` returns `INVALID_PAYMENT`. +- **You declare multiple `accepts` entries** — one per chain you want to receive from. You do not manage those wallets; the Facilitator does. + +Get the right `payTo` values from your `/register` response, or look them up any time: + +```bash +curl http://localhost:3000/discover?merchantId=hb-a1b2c3 +``` + +## 3. Test with the x402 client + +The buyer side uses `@x402/client` (or any x402-compliant agent). In a separate terminal: + +```bash +mkdir buyer && cd buyer +bun init -y +bun add @x402/client viem +``` + +Create `pay.ts`: + +```typescript +import { createX402Client } from '@x402/client' +import { privateKeyToAccount } from 'viem/accounts' + +const client = createX402Client({ + account: privateKeyToAccount(process.env.BUYER_PRIVATE_KEY as `0x${string}`), + preferredNetwork: 'eip155:84532', +}) + +const res = await client.fetch('http://localhost:5001/weather') +console.log(await res.json()) +``` + +Run it with a Base Sepolia key that holds testnet USDC: + +```bash +BUYER_PRIVATE_KEY=0x... bun run pay.ts +``` + +You should see the weather response. Behind the scenes: + +1. First request → `402 Payment Required` with the accepted methods. +2. Client signs an EIP-3009 `transferWithAuthorization` for `$0.001` USDC on Base Sepolia. +3. Client retries with the signed payload in the `X-PAYMENT` header. +4. Your middleware calls `POST /verify` on the relay → `{ isValid: true }`. +5. Middleware returns the resource. +6. Middleware calls `POST /settle` asynchronously. The worker pulls, burns on Base Sepolia, waits for Circle's attestation, and mints on Stellar testnet to your wallet. + +## 4. Watch settlement happen + +Check the Temporal UI at [http://localhost:8233](http://localhost:8233). You will see a `crossChainSettle` workflow moving through `pulling → burning → attesting → minting → recording → settled`. + +The attestation step is the slowest — Circle issues the CCTP V2 attestation after the source chain reaches hard finality (~15–19 min for EVM testnets, seconds on Solana or Stellar). The buyer already got their response; settlement happens in the background. + +## 5. Handle verification failures gracefully + +The middleware handles the happy path, but you may want to log verification failures. A minimal hook: + +```typescript +app.use((err, _req, res, _next) => { + if (err.status === 402) { + console.warn('payment required:', err.message) + } + res.status(err.status || 500).json({ error: err.message }) +}) +``` + +The [error codes reference](../reference/error-codes.md) lists every error the relay returns from `/verify` and `/settle`. + +## What you just learned + +- How to wire `@x402/express` with the 402md Facilitator. +- Why `extra.merchantId` is the routing key. +- How `payTo` points to the Facilitator, not your wallet. +- How to watch settlement in Temporal. + +## Next steps + +- [Accept multiple chains](../how-to/sellers/accept-multiple-chains.md) — declare all 9 supported chains at once. +- [Use MPP on Stellar](../how-to/sellers/use-mpp-on-stellar.md) — add Stellar buyers via push-mode payments. +- [Check settlement status](../how-to/sellers/check-settlement-status.md) — query `/bridge/status/:workflowId`. +- [API reference: settlements](../reference/api/settlements.md) — full `/verify` and `/settle` schemas.