OverviewSellersClientsAPIPayloadEIP-712VerificationErrorsOpsChain
Documentation

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.

PropertyValue
Base URLhttps://facilitator.canopyfinance.io
Network (CAIP-2)eip155:4663 — Robinhood Chain, an Arbitrum Orbit L2
Schemepermit2 — exact-amount transfers via Permit2 SignatureTransfer
Asset0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 — USDG (Global Dollar), 6 decimals
Settlement contract0x000000000022D473030F116dDEE9F6B43aC78BA3 — canonical Uniswap Permit2
x402 version2 (v1 payloads also accepted on verification)
Why Permit2 and not EIP-3009? The standard x402 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

┌─────────┐ 1. GET resource ┌─────────┐ │ Buyer │ ───────────────────────────▶ │ Seller │ │ (wallet) │ ◀─────────────────────────── │ (x402 RS)│ └────┬─────┘ 2. 402 + PaymentRequired └────┬─────┘ │ │ │ 3. sign EIP-712 │ 4. POST /verify │ PermitWitnessTransferFrom │ 5. POST /settle │ (off-chain, no gas) ▼ │ ┌──────────────┐ │ │ Facilitator │ │ │ verify: 13 │ │ │ checks vs │ │ │ chain state │ │ └──────┬───────┘ │ │ 6. permitWitnessTransferFrom │ ▼ (relayer pays gas) │ ┌──────────────┐ └──────────── USDG moves ──────────▶│ Permit2 │──▶ payee payer → payee │ (chain 4663)│ └──────────────┘
  • Boot guard — the server refuses to start unless config/usdg-verified.json exists (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
  },
});
The witness is the recipient lock. 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

GET/supported

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

POST/verify

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

POST/settle

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

GET/health

Constant-time liveness probe: chain, scheme, relayer address, signer type, verified asset. Suitable as a platform healthcheck.

GET/status

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"
Locking down a public deployment: set 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"
  }
}
FieldTypeDescription
signaturehex (65 bytes)EIP-712 signature over PermitWitnessTransferFrom, made by owner. EIP-2098 compact form also accepted.
owneraddressThe payer. Signature recovery must yield exactly this address.
spenderaddressMust equal the relayer from /supported. Currently 0xed05353C543a9A880c6a1d538425A6Fea8eDE6E4.
permit.permitted.tokenaddressMust equal verified USDG: 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168.
permit.permitted.amountuint256 (base-10 string)Atomic units, 6 decimals. "10000" = 0.01 USDG. Must equal requirements.amount exactly.
permit.nonceuint256 (base-10 string)Permit2 unordered nonce. Pick a random 256-bit value per payment; word = nonce >> 8, bit = nonce & 255.
permit.deadlineuint256 (base-10 string)Unix seconds. Verification requires now + safety margin (default 6s) to be strictly before it.
All uint256 values are base-10 strings. JSON numbers lose precision above 253; hex is rejected. The recipient is intentionally absent from the payload — it comes from 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():

FieldValue
namePermit2
chainId4663
verifyingContract0x000000000022D473030F116dDEE9F6B43aC78BA3
DOMAIN_SEPARATOR0x448684463b1f7965c1ec7c249cee11520df24c07242efc2b20f6e54c85614fad

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.

#CheckFailure code
1scheme === "permit2" (requirements + accepted)UNSUPPORTED_SCHEME
2network === "eip155:4663"UNSUPPORTED_NETWORK
3x402Version ∈ {1, 2}UNSUPPORTED_X402_VERSION
4payload decodes against the permit2 schemaMALFORMED_PAYLOAD
5asset === verified USDG === permitted.tokenINVALID_ASSET
6spender === relayer addressSPENDER_MISMATCH
7permitted.amount === requirements.amount (exact)AMOUNT_MISMATCH
8now + safetyMargin < deadlineEXPIRED
9EIP-712 recovery(sig, witnessed permit) === ownerINVALID_SIGNATURE
10Permit2 nonceBitmap bit unsetAUTHORIZATION_USED
11balanceOf(owner) ≥ amountINSUFFICIENT_BALANCE
12allowance(owner → Permit2) ≥ amountPERMIT2_ALLOWANCE_INSUFFICIENT
13isFrozen / 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 /settle with the same payload is always safe. On SETTLEMENT_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.

CodeStageMeaning
UNSUPPORTED_SCHEMEstaticScheme is not permit2 in requirements or accepted terms.
UNSUPPORTED_NETWORKstaticNetwork is not eip155:4663.
UNSUPPORTED_X402_VERSIONstaticx402Version is not 1 or 2.
INVALID_ASSETstaticAsset in requirements, accepted terms, or the permitted token is not the verified USDG address.
MALFORMED_PAYLOADstaticThe scheme payload failed schema validation (bad address, non-decimal uint, bad signature length…).
SPENDER_MISMATCHstaticThe signed spender is not this facilitator's relayer. The signature was made for a different facilitator.
RECIPIENT_MISMATCHstaticrequirements.payTo is not a valid address.
AMOUNT_MISMATCHstaticpermitted.amount ≠ requirements.amount. The scheme is exact-amount; over/under-payment is rejected.
EXPIREDstaticnow + safetyMargin ≥ deadline. The margin (default 6s) rejects permits that would expire mid-settlement.
NOT_YET_VALIDstaticReserved for schemes with a validAfter. Unused by permit2 (no lower bound).
INVALID_SIGNATUREstaticEIP-712 recovery over the witnessed permit did not yield owner. Any tampered field lands here.
AUTHORIZATION_USEDon-chainThe Permit2 unordered nonce bit is already set — the permit was settled (by anyone), or replayed.
INSUFFICIENT_BALANCEon-chainbalanceOf(owner) < amount at verification time.
PERMIT2_ALLOWANCE_INSUFFICIENTon-chainallowance(owner → Permit2) < amount. The payer has not done the one-time approve.
PAYER_FROZEN / RECIPIENT_FROZENon-chainToken-level compliance freeze (only enforced when the token exposes isFrozen).
ASSET_PAUSEDon-chainToken is paused (only when the token exposes paused).
VERIFICATION_ERRORon-chainAn RPC read failed. Fail-closed: the payment is rejected rather than assumed valid.
SETTLEMENT_REVERTEDsettleThe settlement transaction reverted and the nonce is still unused.
SETTLEMENT_TIMEOUTsettleSubmitted but no receipt within the wait window. Check the transaction before retrying.
RELAYER_ERRORsettleSigner/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 with SPENDER_MISMATCH.
  • Exact amounts. requestedAmount always equals the signed permitted.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-chain DOMAIN_SEPARATOR and 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

ItemDetail
Address0xed05353C543a9A880c6a1d538425A6Fea8eDE6E4 (also at GET /health)
HoldsNative ETH for gas only. It never holds or needs USDG.
Spend per settlement≈112,700 gas (observed on mainnet 4663).
MonitoringPoll GET /statusrelayerGasEth. Alert below ~0.0005 ETH.
Failure modeOut of gas → settlements fail closed (no fund risk); verification keeps working.
RotationSwap 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

ParameterValue
ChainRobinhood Chain (Arbitrum Orbit L2), id 4663
CAIP-2eip155:4663
Public RPChttps://rpc.mainnet.chain.robinhood.com (rate-limited; use a dedicated provider in production)
Explorerrobinhoodchain.blockscout.com
USDG0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168
Permit20x000000000022D473030F116dDEE9F6B43aC78BA3
Permit2 DOMAIN_SEPARATOR0x448684463b1f7965c1ec7c249cee11520df24c07242efc2b20f6e54c85614fad
Native gas tokenETH

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.

ItemStatus
Live mainnet settlementsYes — 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 codegithub.com/canopyfinance/canopy-facilitator
Buyer SDKShips in the repo under src/sdk (see the client quickstart).
Rate limitsDocumented: /settle 60/min, /verify 600/min by default; per-key overrides.
FeesNone currently — the facilitator settles for free and pays gas. Any future fee will be disclosed here.
Security auditNot yet independently audited. The verify/settle logic is covered by automated tests; treat as pre-audit and size exposure accordingly.
Responsible disclosureReport vulnerabilities to security@canopyfinance.io. Please do not open public issues for security matters.
Uptime historyNot yet published. Monitor /status; a public uptime page is planned.