Canopy Facilitator
A production x402 facilitator for Robinhood Chain. It verifies user-signed USDG payments against live chain state and settles them on-chain via Permit2 — non-custodial, fail-closed, idempotent.
Overview
In the x402 protocol, a facilitator is the neutral server a seller (resource server) delegates payment handling to. The seller responds 402 Payment Required; the buyer signs a payment; the seller forwards it here for verification and settlement. This facilitator speaks the standard @x402/core V2 HTTP interface (/supported, /verify, /settle with V2 request/response shapes), so HTTPFacilitatorClient can talk to it directly.
| Property | Value |
|---|---|
| Base URL | https://facilitator.canopyfinance.io |
| Network (CAIP-2) | eip155:4663 — Robinhood Chain, an Arbitrum Orbit L2 |
| Scheme | permit2 — exact-amount transfers via Permit2 SignatureTransfer |
| Asset | 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 — USDG (Global Dollar), 6 decimals |
| Settlement contract | 0x000000000022D473030F116dDEE9F6B43aC78BA3 — canonical Uniswap Permit2 |
| x402 version | 2 (v1 payloads also accepted on verification) |
exact scheme is defined on EIP-3009 transferWithAuthorization. USDG on Robinhood Chain was proven on-chain (bytecode selector scan of the UUPS implementation) to implement neither EIP-3009 nor EIP-2612. Canonical Permit2 is deployed on chain 4663, so this facilitator settles through permitWitnessTransferFrom, preserving the same properties: one off-chain signature, relayer-paid gas, no custody — plus a witness that binds the recipient into the signature.Architecture
- Boot guard — the server refuses to start unless
config/usdg-verified.jsonexists (written only by the on-chain preflight) and the signer policy is satisfied. - Verify path — pure function over the request plus live RPC reads. No state is written.
- Settle path — re-runs the full verification, then dedupes, submits, and classifies the outcome.
Seller quickstart
Point an x402 resource server at this facilitator and accept USDG on Robinhood Chain in three steps.
1. Register the facilitator
// npm i @x402/core import { x402ResourceServer } from "@x402/core"; import { HTTPFacilitatorClient } from "@x402/core/http"; const facilitator = new HTTPFacilitatorClient({ url: "https://facilitator.canopyfinance.io", }); const server = new x402ResourceServer(facilitator); await server.initialize(); // fetches /supported
2. Declare the payment option on your route
{
scheme: "permit2",
network: "eip155:4663",
payTo: "0xYourReceivingAddress",
price: { asset: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", amount: "10000" } // 0.01 USDG
}
3. Verify, serve, settle
On a paid request, forward the decoded payment to verifyPayment, serve the resource on isValid, then call settlePayment. Settlement is safe to retry: duplicates resolve to the recorded result or AUTHORIZATION_USED, never a double-spend.
Client quickstart
A buyer needs two things: a one-time on-chain approval of Permit2 on USDG, and a per-payment EIP-712 signature (off-chain, free).
1. One-time: approve Permit2
// once per wallet — allows Permit2 to move USDG when presented a valid signature await walletClient.writeContract({ address: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", // USDG abi: erc20Abi, functionName: "approve", args: ["0x000000000022D473030F116dDEE9F6B43aC78BA3", maxUint256], // Permit2 });
2. Per payment: build a payment with the SDK
Use the Canopy buyer SDK so you never hand-roll the witness. It discovers the spender/asset from /supported, signs the permit with your wallet, and returns the body to POST to /verify and /settle.
import { getSupported, createPermit2Payment, post } from "canopy-facilitator/sdk"; const url = "https://facilitator.canopyfinance.io"; const kind = await getSupported(url); // spender, asset, permit2 const body = await createPermit2Payment({ chainId: 4663, kind, owner: account.address, payTo: SELLER_PAY_TO, amount: 10000n, // 0.01 USDG signTypedData: (a) => walletClient.signTypedData({ account, ...a }), }); await post(url, "verify", body, API_KEY); const settled = await post(url, "settle", body, API_KEY);
Under the hood: the raw signature
const signature = await walletClient.signTypedData({ account, domain: { name: "Permit2", chainId: 4663, verifyingContract: "0x000000000022D473030F116dDEE9F6B43aC78BA3", }, types: { PermitWitnessTransferFrom: [ { name: "permitted", type: "TokenPermissions" }, { name: "spender", type: "address" }, { name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" }, { name: "witness", type: "X402Witness" }, ], TokenPermissions: [ { name: "token", type: "address" }, { name: "amount", type: "uint256" }, ], X402Witness: [{ name: "to", type: "address" }], }, primaryType: "PermitWitnessTransferFrom", message: { permitted: { token: USDG, amount: 10000n }, spender: RELAYER, // from GET /supported → extra.spender nonce: randomUint256(), // unordered; never reuse deadline: BigInt(Math.floor(Date.now()/1000) + 600), witness: { to: SELLER_PAY_TO }, // = requirements.payTo }, });
requirements.payTo is not transmitted inside your payload — the facilitator reconstructs the witness from the seller's requirements at verification. If anyone alters the destination, ECDSA recovery no longer yields your address and verification fails with INVALID_SIGNATURE.GET /supported public
Advertises the payment kinds this facilitator settles, in the exact shape @x402/core clients parse. The extra object carries everything needed to construct a payload without hardcoding.
{
"kinds": [{
"x402Version": 2,
"scheme": "permit2",
"network": "eip155:4663",
"extra": {
"permit2": "0x000000000022D473030F116dDEE9F6B43aC78BA3",
"spender": "0xed05353C543a9A880c6a1d538425A6Fea8eDE6E4",
"asset": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
"witnessTypeString": "X402Witness witness)TokenPermissions(address token,uint256 amount)X402Witness(address to)"
}
}],
"extensions": [],
"signers": { "eip155": ["0xed05353C543a9A880c6a1d538425A6Fea8eDE6E4"] }
}
POST /verify read-only
Validates a payment without writing any state. Runs the full 13-step pipeline including live on-chain reads. Content type application/json.
Request
{
"x402Version": 2,
"paymentPayload": { /* see Payload specification */ },
"paymentRequirements": {
"scheme": "permit2",
"network": "eip155:4663",
"amount": "10000",
"asset": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
"payTo": "0xSellerAddress",
"maxTimeoutSeconds": 600,
"extra": {}
}
}
Response — 200
// valid { "isValid": true, "payer": "0xOwnerAddress" } // invalid — reason is a stable enum, never a raw error { "isValid": false, "invalidReason": "INSUFFICIENT_BALANCE" }
Malformed request bodies return 400 with invalidReason: "MALFORMED_PAYLOAD".
POST /settle state-changing
Same request body as /verify. Re-runs the entire verification first — an invalid payment is never submitted — then settles on-chain and waits for the receipt.
Response — 200
// success — transaction is the on-chain settlement hash { "success": true, "transaction": "0x…", "network": "eip155:4663", "payer": "0xOwnerAddress" } // failure { "success": false, "errorReason": "AUTHORIZATION_USED", "transaction": "", "network": "eip155:4663" }
See Settlement & idempotency for replay, race, and retry semantics.
GET /health · /status monitoring
Constant-time liveness probe: chain, scheme, relayer address, signer type, verified asset. Suitable as a platform healthcheck.
Everything in /health plus a live RPC read of the relayer's native gas balance (relayerGasEth) — poll it to alert before the relayer runs dry.
Authentication & rate limits
The public read paths (/supported, /health, /status) are open. /verify and /settle are rate-limited per caller, and /settle can require an API key.
API keys
Present a key as a bearer token (or X-API-Key):
curl https://facilitator.canopyfinance.io/settle \ -H "Authorization: Bearer cf_live_…" \ -H "Content-Type: application/json" \ -d '{ "x402Version": 2, "paymentPayload": …, "paymentRequirements": … }'
Keys are issued by the operator and carry their own per-minute limit. Only a SHA-256 hash is stored, so keys are shown exactly once at creation and are never recoverable. When SETTLE_REQUIRE_API_KEY is enabled, /settle returns 401 UNAUTHORIZED without a valid key.
Rate limiting
Fixed-window per minute, keyed by API key (or client IP when unauthenticated). Exceeding it returns 429 with a Retry-After header and reason RATE_LIMITED. Defaults: /settle 60/min, /verify 600/min (configurable; per-key overrides apply).
Operator: managing keys
Admin endpoints are disabled unless ADMIN_TOKEN is set, then guarded by it:
# mint a key (returned once) curl -X POST https://facilitator.canopyfinance.io/admin/api-keys \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "label": "acme-corp", "ratePerMin": 120 }' # list (prefixes only, no secrets) · revoke curl https://facilitator.canopyfinance.io/admin/api-keys -H "Authorization: Bearer $ADMIN_TOKEN" curl -X DELETE https://facilitator.canopyfinance.io/admin/api-keys/1 -H "Authorization: Bearer $ADMIN_TOKEN"
ADMIN_TOKEN, mint keys for your partners, then set SETTLE_REQUIRE_API_KEY=true. Reads stay open; settlement becomes key-gated and metered.Payload specification
The payload field of a V2 PaymentPayload for scheme permit2:
{
"signature": "0x…",
"owner": "0x…",
"spender": "0xed05353C543a9A880c6a1d538425A6Fea8eDE6E4",
"permit": {
"permitted": { "token": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", "amount": "10000" },
"nonce": "8621…",
"deadline": "1767139200"
}
}
| Field | Type | Description |
|---|---|---|
| signature | hex (65 bytes) | EIP-712 signature over PermitWitnessTransferFrom, made by owner. EIP-2098 compact form also accepted. |
| owner | address | The payer. Signature recovery must yield exactly this address. |
| spender | address | Must equal the relayer from /supported. Currently 0xed05353C543a9A880c6a1d538425A6Fea8eDE6E4. |
| permit.permitted.token | address | Must equal verified USDG: 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168. |
| permit.permitted.amount | uint256 (base-10 string) | Atomic units, 6 decimals. "10000" = 0.01 USDG. Must equal requirements.amount exactly. |
| permit.nonce | uint256 (base-10 string) | Permit2 unordered nonce. Pick a random 256-bit value per payment; word = nonce >> 8, bit = nonce & 255. |
| permit.deadline | uint256 (base-10 string) | Unix seconds. Verification requires now + safety margin (default 6s) to be strictly before it. |
requirements.payTo and is enforced via the witness.EIP-712 & witness
Domain
Permit2's domain has no version field. Proven at preflight against the contract's DOMAIN_SEPARATOR():
| Field | Value |
|---|---|
| name | Permit2 |
| chainId | 4663 |
| verifyingContract | 0x000000000022D473030F116dDEE9F6B43aC78BA3 |
| DOMAIN_SEPARATOR | 0x448684463b1f7965c1ec7c249cee11520df24c07242efc2b20f6e54c85614fad |
Typed data
PermitWitnessTransferFrom( TokenPermissions permitted, // { address token, uint256 amount } address spender, // the relayer uint256 nonce, // unordered uint256 deadline, X402Witness witness // { address to } — the recipient ) TokenPermissions(address token, uint256 amount) X402Witness(address to)
Witness encoding (on-chain side)
At settlement the facilitator passes Permit2 the witness hash and the exact type string suffix Permit2 splices into its own typehash. Both must match what the wallet signed, or the contract rejects the signature:
witness = keccak256(abi.encode(
keccak256("X402Witness(address to)"), payTo))
witnessTypeString = "X402Witness witness)TokenPermissions(address token,uint256 amount)X402Witness(address to)"
Note the type string starts mid-declaration (X402Witness witness)…): Permit2 concatenates it after its stub PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline, — and nested types must follow in alphabetical order. This is the exact string wallets and libraries must reproduce.
Verification pipeline
Ordered and fail-closed: the first failing check returns its code and nothing later runs. Checks 1–9 are pure (no RPC); 10–13 read live chain state.
| # | Check | Failure code |
|---|---|---|
| 1 | scheme === "permit2" (requirements + accepted) | UNSUPPORTED_SCHEME |
| 2 | network === "eip155:4663" | UNSUPPORTED_NETWORK |
| 3 | x402Version ∈ {1, 2} | UNSUPPORTED_X402_VERSION |
| 4 | payload decodes against the permit2 schema | MALFORMED_PAYLOAD |
| 5 | asset === verified USDG === permitted.token | INVALID_ASSET |
| 6 | spender === relayer address | SPENDER_MISMATCH |
| 7 | permitted.amount === requirements.amount (exact) | AMOUNT_MISMATCH |
| 8 | now + safetyMargin < deadline | EXPIRED |
| 9 | EIP-712 recovery(sig, witnessed permit) === owner | INVALID_SIGNATURE |
| 10 | Permit2 nonceBitmap bit unset | AUTHORIZATION_USED |
| 11 | balanceOf(owner) ≥ amount | INSUFFICIENT_BALANCE |
| 12 | allowance(owner → Permit2) ≥ amount | PERMIT2_ALLOWANCE_INSUFFICIENT |
| 13 | isFrozen / paused (when token supports them) | PAYER_FROZEN · RECIPIENT_FROZEN · ASSET_PAUSED |
The deadline check applies a settlement safety margin (default 6s, env SETTLEMENT_SAFETY_MARGIN_SECONDS) so a permit that would expire between verification and inclusion is rejected upfront rather than reverting on-chain.
Settlement & idempotency
Execution
After re-verification, the relayer calls:
Permit2.permitWitnessTransferFrom( permit, // { permitted, nonce, deadline } { to: payTo, requestedAmount: amount }, owner, witness, // keccak256 hash binding payTo witnessTypeString, signature )
USDG moves directly from payer to payee inside this call; the relayer's only expenditure is gas (observed ≈112k per settlement).
Idempotency, replays, races
- Key:
(owner, nonce). An in-process map collapses concurrent duplicates to a single transaction; completed results are cached and returned verbatim on repeat. - Authoritative source: the Permit2
nonceBitmap(owner, nonce >> 8)word — checked before submission and re-checked to classify failures. This survives facilitator restarts. - Race behavior: if another actor settles the same permit first, the transaction reverts, the bitmap re-check finds the bit set, and the response is a clean
AUTHORIZATION_USED— no crash, no double-submit. - Retry guidance: retrying
/settlewith the same payload is always safe. OnSETTLEMENT_TIMEOUT, poll the returned state or re-settle — the bitmap prevents duplication.
Error codes
invalidReason (verify) and errorReason (settle) come from one stable enum — string values never change and raw errors never leak. Settle can additionally surface any verify code, since it re-verifies first.
| Code | Stage | Meaning |
|---|---|---|
| UNSUPPORTED_SCHEME | static | Scheme is not permit2 in requirements or accepted terms. |
| UNSUPPORTED_NETWORK | static | Network is not eip155:4663. |
| UNSUPPORTED_X402_VERSION | static | x402Version is not 1 or 2. |
| INVALID_ASSET | static | Asset in requirements, accepted terms, or the permitted token is not the verified USDG address. |
| MALFORMED_PAYLOAD | static | The scheme payload failed schema validation (bad address, non-decimal uint, bad signature length…). |
| SPENDER_MISMATCH | static | The signed spender is not this facilitator's relayer. The signature was made for a different facilitator. |
| RECIPIENT_MISMATCH | static | requirements.payTo is not a valid address. |
| AMOUNT_MISMATCH | static | permitted.amount ≠ requirements.amount. The scheme is exact-amount; over/under-payment is rejected. |
| EXPIRED | static | now + safetyMargin ≥ deadline. The margin (default 6s) rejects permits that would expire mid-settlement. |
| NOT_YET_VALID | static | Reserved for schemes with a validAfter. Unused by permit2 (no lower bound). |
| INVALID_SIGNATURE | static | EIP-712 recovery over the witnessed permit did not yield owner. Any tampered field lands here. |
| AUTHORIZATION_USED | on-chain | The Permit2 unordered nonce bit is already set — the permit was settled (by anyone), or replayed. |
| INSUFFICIENT_BALANCE | on-chain | balanceOf(owner) < amount at verification time. |
| PERMIT2_ALLOWANCE_INSUFFICIENT | on-chain | allowance(owner → Permit2) < amount. The payer has not done the one-time approve. |
| PAYER_FROZEN / RECIPIENT_FROZEN | on-chain | Token-level compliance freeze (only enforced when the token exposes isFrozen). |
| ASSET_PAUSED | on-chain | Token is paused (only when the token exposes paused). |
| VERIFICATION_ERROR | on-chain | An RPC read failed. Fail-closed: the payment is rejected rather than assumed valid. |
| SETTLEMENT_REVERTED | settle | The settlement transaction reverted and the nonce is still unused. |
| SETTLEMENT_TIMEOUT | settle | Submitted but no receipt within the wait window. Check the transaction before retrying. |
| RELAYER_ERROR | settle | Signer/submission infrastructure failure. |
Security model
- Non-custodial by construction. The facilitator is never in the token flow — Permit2 transfers payer→payee atomically. A fully compromised facilitator could at worst settle valid, signed payments (moving funds only to their signed recipients) or refuse service.
- Recipient binding. The witness commits the payer's signature to the destination. The facilitator cannot redirect funds.
- Spender binding. Signatures name this relayer as
spender; a payload signed for another facilitator fails withSPENDER_MISMATCH. - Exact amounts.
requestedAmountalways equals the signedpermitted.amount; partial pulls are not possible in this scheme. - Asset verification gate. USDG starts unverified. Only
scripts/preflight.ts— which proves the EIP-712 domain against the on-chainDOMAIN_SEPARATORand probes the token's real bytecode — writes the verified config. Without it the server refuses to boot. - Signer policy.
local(raw env key) is refused in production;secret(runtime-injected) boots with a loud in-memory warning;kms(AWS KMS) keeps the key out of process memory entirely. - Fail-closed everywhere. Schema failures, RPC failures, unknown state — all reject the payment.
Relayer operations
| Item | Detail |
|---|---|
| Address | 0xed05353C543a9A880c6a1d538425A6Fea8eDE6E4 (also at GET /health) |
| Holds | Native ETH for gas only. It never holds or needs USDG. |
| Spend per settlement | ≈112,700 gas (observed on mainnet 4663). |
| Monitoring | Poll GET /status → relayerGasEth. Alert below ~0.0005 ETH. |
| Failure mode | Out of gas → settlements fail closed (no fund risk); verification keeps working. |
| Rotation | Swap the key in your secret store, fund the new address, redeploy. /supported updates automatically; in-flight signatures naming the old spender fail SPENDER_MISMATCH, so drain traffic first. |
Why is the relayer address public?
It has to be — and it is safe. Every payer signs the relayer as the permit's spender, so clients must learn the address before signing; that is exactly what /supported → extra.spender is for. It is also inherently public on-chain as the from of every settlement transaction. Publishing it leaks nothing: the address is not the key (which lives in your secret store or KMS), it custodies no user funds, and it holds only a small gas balance. Knowing it lets an attacker do exactly two things — send it gas, or watch it work. The spender binding even works in your favor: a signature naming this relayer cannot be settled through anyone else's facilitator.
Chain configuration
| Parameter | Value |
|---|---|
| Chain | Robinhood Chain (Arbitrum Orbit L2), id 4663 |
| CAIP-2 | eip155:4663 |
| Public RPC | https://rpc.mainnet.chain.robinhood.com (rate-limited; use a dedicated provider in production) |
| Explorer | robinhoodchain.blockscout.com |
| USDG | 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 |
| Permit2 | 0x000000000022D473030F116dDEE9F6B43aC78BA3 |
| Permit2 DOMAIN_SEPARATOR | 0x448684463b1f7965c1ec7c249cee11520df24c07242efc2b20f6e54c85614fad |
| Native gas token | ETH |
The verified USDG config (address, decimals, Permit2 domain proof, token capability flags) is produced by the preflight and shipped with the server as config/usdg-verified.json. Re-run the preflight to refresh it; the server refuses to boot on a missing or mismatched config.
Trust, status & disclosure
Straight facts about maturity — documentation describes intent; the items below are what is independently verifiable today.
| Item | Status |
|---|---|
| Live mainnet settlements | Yes — verifiable on-chain. Example: 0x8a62b0c…4098c2 (Permit2 settlement via this facilitator). |
| Live status / telemetry | /status (JSON) and the dashboard (volume, success rate, relayer gas, recent settlements). |
| Source code | github.com/canopyfinance/canopy-facilitator |
| Buyer SDK | Ships in the repo under src/sdk (see the client quickstart). |
| Rate limits | Documented: /settle 60/min, /verify 600/min by default; per-key overrides. |
| Fees | None currently — the facilitator settles for free and pays gas. Any future fee will be disclosed here. |
| Security audit | Not yet independently audited. The verify/settle logic is covered by automated tests; treat as pre-audit and size exposure accordingly. |
| Responsible disclosure | Report vulnerabilities to security@canopyfinance.io. Please do not open public issues for security matters. |
| Uptime history | Not yet published. Monitor /status; a public uptime page is planned. |