@sweefi/facilitator
v0.2.0
Published
Self-hostable s402 payment verification and settlement service for SweeFi — runnable as `npx @sweefi/facilitator start`
Maintainers
Readme
@sweefi/facilitator
Self-hostable s402 payment verification and settlement service for SweeFi — runnable in one command.
npx @sweefi/facilitator startPart of the SweeFi platform.
What it is
The facilitator is the off-chain settlement server in SweeFi's s402 payment flow. When a client wants to pay for a resource, it builds and signs a Sui (or Solana) transaction locally, then sends that signed payload here. The facilitator verifies the signature and payment requirements, broadcasts the transaction, and returns a settlement receipt.
Open source, Apache 2.0. The source is fully auditable. You can run it as a CLI (npx), self-host it in Docker, deploy it to Fly.io, or fork and embed it in your own service. SweeFi also operates a managed facilitator at https://s402.sweefi.com — @sweefi/sui and @sweefi/hono point at it by default, so most integrators get working settlement without any deployment. Override facilitatorUrl in createS402Client (@sweefi/sui) or s402Gate (@sweefi/hono) to point at your own instance.
Supported payment schemes: exact, prepaid, stream, escrow.
How it fits: the sign-first model
Client
│
│ 1. Resource server returns HTTP 402 with payment requirements
│ (amount, payTo address, scheme, network)
│
│ 2. Client builds + signs a Sui PTB locally
│ (private key never leaves the client)
│
│ 3. Client POSTs the signed payload to the facilitator
│
▼
Facilitator ←── this service
│
│ 4. Verifies: signature validity, amount correctness,
│ payTo address match, scheme rules
│
│ 5. Broadcasts the transaction to Sui
│
▼
Sui Network
│
│ 6. Move contract executes: transfers funds, emits event,
│ enforces fee split at the contract level
│
▼
Resource Server
│
│ 7. Client presents tx digest as proof of payment
│ Resource server verifies on-chain and unlocks contentWhy sign-first? The client's private key never travels over the network. The facilitator cannot redirect funds — it can only broadcast a transaction the client already constructed and signed. Move's balance conservation rules enforce the fee split on-chain; the facilitator cannot manipulate what the contract does.
Quickstart
Run it directly with npx
# Generate a strong API key
export API_KEYS=$(openssl rand -hex 32)
# Start the facilitator (defaults to port 4022)
npx @sweefi/facilitator startThat's it. The server is now listening on http://localhost:4022. Point an s402 client at it:
import { createS402Client } from "@sweefi/sui";
const client = createS402Client({ facilitatorUrl: "http://localhost:4022", apiKey: process.env.API_KEYS });Use npx @sweefi/facilitator help to see all subcommands. Use npx @sweefi/facilitator version to print the version.
Run from a .env file
The CLI doesn't read .env itself — Node's built-in --env-file flag does, on Node ≥20.6:
# Edit .env with your API_KEYS, FEE_MICRO_PERCENT, etc.
node --env-file=.env $(which npx) @sweefi/facilitator startFor a fully containerized workflow, see Docker / Fly.io below.
Prerequisites
- Node.js 20+ (22+ recommended)
Embed it in your own Node service
Skip the CLI and use the createApp factory directly:
import { serve } from "@hono/node-server";
import { createApp, loadConfig } from "@sweefi/facilitator";
const { app } = createApp(loadConfig());
serve({ fetch: app.fetch, port: 4022 });Docker build
# Build from the platform monorepo root (workspace context required)
docker build -f products/sweefi/facilitator/Dockerfile -t sweefi-facilitator .
# Run it
docker run -p 4022:4022 \
-e API_KEYS="your-secret-key" \
-e FEE_MICRO_PERCENT="5000" \
sweefi-facilitatorThe Dockerfile uses a multi-stage build: deps → build → slim runtime image. The final image runs node dist/cli.mjs start on port 4022.
Fly.io deploy
The included fly.toml is pre-configured for a shared-cpu-1x 512 MB machine in sjc. It auto-stops when idle and auto-starts on incoming requests.
# One-time setup (from the platform monorepo root)
fly launch --no-deploy --config products/sweefi/facilitator/fly.toml \
--dockerfile products/sweefi/facilitator/Dockerfile
# Set secrets (never commit these)
fly secrets set API_KEYS="your-secret-key-1,your-secret-key-2"
fly secrets set FEE_MICRO_PERCENT="5000"
# Optional: custom RPC
fly secrets set SUI_MAINNET_RPC="https://your-rpc-provider.example.com"
# Deploy
fly deploy --config products/sweefi/facilitator/fly.toml \
--dockerfile products/sweefi/facilitator/DockerfileAfter deployment your facilitator URL will be something like https://swee-facilitator.fly.dev. Point your s402 resource server's facilitatorUrl config at that address.
Environment variables
Validated at startup with Zod. The server will refuse to start if required variables are missing or malformed.
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| API_KEYS | Yes | — | Comma-separated list of bearer token API keys. Clients must present one of these in the Authorization header. Each key must be ≥ 16 characters — shorter keys are rejected at startup. Generate with openssl rand -hex 32. |
| PORT | No | 4022 | Port to listen on. |
| FEE_MICRO_PERCENT | No | 5000 | Protocol fee in micro-percent (5000 = 0.5%, 1000000 = 100%). Included in the /.well-known/s402.json discovery document and passed to scheme handlers for PTB construction. |
| FACILITATOR_KEYPAIR | No | — | Base64-encoded Ed25519 keypair for gas sponsorship (reserved for future use). |
| SUI_MAINNET_RPC | No | Mysten default | Custom RPC URL for sui:mainnet. Use this to point at a dedicated node or RPC provider. |
| SUI_TESTNET_RPC | No | Mysten default | Custom RPC URL for sui:testnet. |
| SWEEFI_PACKAGE_ID | No | — | SweeFi Move package ID. When set, scheme handlers verify that on-chain events originate from this package, preventing spoofed events from attacker-deployed contracts. |
| LOG_LEVEL | No | info | Log verbosity: debug, info, warn, or error. |
API endpoints
All endpoints except /health and /.well-known/s402.json require an Authorization: Bearer <key> header.
Request bodies are capped at 256 KB. The server returns 429 if a client exceeds the rate limit (token bucket: 100 requests max, refills at 10 per second, keyed per API key).
GET /health
Liveness check. No authentication required.
Response
{ "status": "ok", "timestamp": "2026-02-16T00:00:00.000Z" }GET /.well-known/s402-facilitator
Facilitator identity document. Returns a signed fee schedule describing who this facilitator is, what it charges, and the networks and schemes it supports. No authentication required.
Response
{
"version": "1",
"feeMicroPercent": 5000,
"feeRecipient": "0xabc...",
"minFeeUsd": "0.001",
"supportedSchemes": ["exact", "prepaid", "stream", "escrow"],
"supportedNetworks": ["sui:testnet", "sui:mainnet"],
"validUntil": "2026-03-23T00:00:00.000Z"
}validUntil is a rolling 30-day window updated at each deploy. The feeRecipient field is null if FACILITATOR_FEE_RECIPIENT is not configured.
Note on signatures: The
signaturefield is reserved for a future release. OnceFACILITATOR_KEYPAIRis configured, this payload will be signed over canonical JSON so clients can cryptographically verify the fee schedule before trusting it.
Difference from /.well-known/s402.json: /.well-known/s402.json describes the resource server (who charges). /.well-known/s402-facilitator describes the facilitator (who settles). These are two distinct actors in the s402 protocol — a single deployment may run both, but conceptually they serve different roles.
GET /.well-known/s402.json
Discovery document. Clients and resource servers can query this to learn what schemes and networks this facilitator supports, and what fee rate it charges. No authentication required.
Response
{
"s402Version": "0.1.0",
"schemes": ["exact", "prepaid", "stream", "escrow"],
"networks": ["sui:testnet", "sui:mainnet"],
"assets": [],
"directSettlement": true,
"mandateSupport": false,
"protocolFeeMicroPercent": 5000
}GET /supported
Lists supported networks and their available schemes. Authentication required.
Response
{
"s402Version": "0.1.0",
"networks": {
"sui:testnet": ["exact", "prepaid", "stream", "escrow"],
"sui:mainnet": ["exact", "prepaid", "stream", "escrow"]
}
}POST /verify
Verifies a signed payment payload against requirements without broadcasting to Sui. Use this to pre-check a payment before committing.
Request body
{
"paymentPayload": {
"scheme": "exact",
"payload": {
"transaction": "<base64-encoded Sui PTB bytes>",
"signature": "<base64-encoded signature>"
}
},
"paymentRequirements": {
"network": "sui:testnet",
"amount": "1000000000",
"payTo": "0xb0b..."
}
}Response — shape depends on the scheme implementation, but always includes a success boolean and an optional error field on failure.
POST /settle
Verifies and broadcasts the transaction to Sui. This is the primary settlement endpoint.
Same request body shape as /verify. On success, returns the Sui transaction digest and settlement details. The facilitator records the settlement in its usage tracker keyed by API key.
Error responses
| Status | Reason |
|--------|--------|
| 400 | Missing or invalid fields in the request body |
| 401 | Missing or malformed Authorization header |
| 403 | Invalid API key |
| 429 | Rate limit exceeded |
| 500 | Sui RPC error or scheme-level failure |
POST /s402/process
Atomic verify + settle in a single call. Equivalent to calling /verify then /settle, but the facilitator does both in one round trip. Preferred for production use — fewer network hops, and the verification and broadcast are coupled so a successful verify cannot race with a concurrent settle.
Same request body and response shape as /settle.
Security
API key authentication
All settlement endpoints are protected by bearer token authentication. The middleware uses constant-time comparison (SHA-256 hash both sides, then crypto.timingSafeEqual) to prevent timing side-channel attacks. It also iterates all valid keys without short-circuiting, so key position and set size are not leaked via response time.
Minimum key entropy: Each key in API_KEYS must be at least 16 characters. The server refuses to start with shorter keys — they are trivially brute-forceable. The recommendation is 32+ hex characters from a CSPRNG:
openssl rand -hex 32Rotate keys by updating API_KEYS and redeploying. The comma-separated format lets you do zero-downtime rotation by temporarily including both the old and new key.
Rate limiting
Token bucket limiter keyed by API key (100 requests max, refills at 10/sec). For authenticated requests the API key is used as the bucket identifier, which is not spoofable. For unauthenticated routes (health, discovery), the limiter falls back to x-forwarded-for / x-real-ip headers — reliable behind a trusted reverse proxy (Cloudflare, nginx, Fly.io's proxy) but trivially spoofable if the service is directly exposed to the internet without a proxy in front.
Package ID anti-spoofing
Set SWEEFI_PACKAGE_ID in production. Without it, scheme handlers that verify on-chain events cannot confirm those events came from the legitimate SweeFi Move package. An attacker could deploy a contract that emits structurally identical events and fool a facilitator that does not check the originating package. With the package ID set, the scheme handlers reject events from any other contract address.
Warning at startup: If SWEEFI_PACKAGE_ID is not set, the facilitator logs a prominent warning on startup explaining that event anti-spoofing is disabled. This is intentional — the variable is optional for local development but should always be set before handling real payments on mainnet.
Body size limit
All requests are capped at 256 KB. Oversized payloads are rejected before any parsing occurs.
Trust boundary
Unknown fields in paymentRequirements are stripped at the route layer (pickRequirementsFields from s402/http) before being passed to scheme handlers. This prevents injection of unexpected fields that could influence downstream Move PTB construction.
Fee enforcement
The facilitator does not independently verify that protocolFeeMicroPercent in the payment requirements matches what the Sui PTB actually collects. This is intentional: each scheme's settle handler builds the PTB with the fee split baked in, and Sui's execution enforces balance conservation. A dishonest resource server cannot reduce fees by lying about protocolFeeMicroPercent in the requirements, because the facilitator — not the resource server — controls PTB construction. Operators deploying custom scheme implementations must ensure their Move contracts enforce fee collection.
Architecture
products/sweefi/facilitator/
├── src/
│ ├── index.ts # Library entry — re-exports createApp + loadConfig
│ ├── cli.ts # CLI entry — `start | version | help` subcommands
│ ├── app.ts # createApp() — middleware stack and route mounting
│ ├── config.ts # Zod-validated environment config
│ ├── facilitator.ts # createFacilitator() — registers Sui scheme handlers
│ ├── routes.ts # HTTP route handlers (verify, settle, s402/process, supported)
│ ├── auth/
│ │ └── api-key.ts # Constant-time bearer token auth middleware
│ ├── middleware/
│ │ ├── rate-limiter.ts # Token bucket rate limiter per API key
│ │ └── logger.ts # Request logging middleware
│ └── metering/
│ ├── usage-tracker.ts # In-memory settlement tracking (Phase 1)
│ ├── hooks.ts # recordSettlement() — called after successful settle
│ ├── fee-calculator.ts # Fee math utilities
│ └── metrics.ts # IMetrics interface + NoopMetrics (seam for Prometheus/OTEL)
├── Dockerfile # Multi-stage build: deps → build → slim runtime
└── fly.toml # Fly.io config (shared-cpu-1x, 512mb, sjc, auto-stop)Middleware execution order (from app.ts):
bodyLimit— rejects oversized requests before any parsingrequestLogger— structured request/response loggingapiKeyAuth— validates bearer token for protected routesmeteringContext— propagates API key for inline settlement recordingrateLimiter— token bucket, keyed by API key or IP fallback
Scheme registration (facilitator.ts): The s402Facilitator from the s402 package manages a registry of scheme implementations per network. At startup, ExactSuiFacilitatorScheme, PrepaidSuiFacilitatorScheme, StreamSuiFacilitatorScheme, and EscrowSuiFacilitatorScheme are registered for both sui:testnet and sui:mainnet. Each scheme knows how to verify a signed PTB and broadcast it to the appropriate Sui RPC endpoint.
Metering — Phase 1: Successful settlements are recorded in memory keyed by API key (UsageTracker). This is a Phase 1 design — the IUsageTracker interface makes it straightforward to swap in a persistent backend (Redis, Postgres, or an on-chain contract) without touching the route handlers.
Observability seam: The IMetrics interface in metering/metrics.ts defaults to NoopMetrics (zero overhead). Pass a PrometheusMetrics or OtelMetrics implementation to createApp() when you need instrumentation.
Testability: app.ts exports createApp(config) separately from cli.ts so the application can be constructed in tests without starting a real server. Integration tests can pass a custom config and call app.fetch directly. The library entry (index.ts) re-exports createApp and loadConfig for embedders.
Running tests
# Unit tests
pnpm --filter @sweefi/facilitator test
# Integration tests (requires Sui testnet RPC access)
pnpm --filter @sweefi/facilitator test:integration
# Type check only
pnpm --filter @sweefi/facilitator typecheckLicense
Apache 2.0 — see LICENSE.
Source: github.com/sweeinc/platform
