Skip to main content

Relayer API

The relayer submits FeeVault transactions from its hot key. It never sees stealth keys, 0zk addresses or social identities.

  • Claims pay it through Claim.relayerFee, in the claimed asset.
  • Other owner actions (consolidate, shield-templates, rotate-owner, ping) pay it with an attached fee-only claim signed by the owner (see below).
  • Bind actions (cancel-bind, propose-bind, finalize-bind) are unpaid. They need valid owner or attestor signatures, or a ready pending bind, to pass simulation, and they are gas-capped like everything else.

The normative wire types are the zod schemas in packages/sdk/src/api.ts; packages/sdk/API.md is the long form of this page.

Endpoints​

MethodPathBody / queryResponse
GET/v1/health{ ok: true, chainId, block }
GET/v1/metricsAuthorization: Bearer <METRICS_TOKEN>Prometheus text. Operator-only: 404 when METRICS_TOKEN is unset, 401 without the token
GET/v1/configRelayerConfig
GET/v1/quote?asset&adapter[&action][&templates][&consolidate=1][&register=1] (no amount)QuoteResponse
POST/v1/claimClaimRequest{ txHash }
POST/v1/consolidate{ id, consolidate, register?, fee }{ txHash }
POST/v1/shield-templates{ id, templates, deadline, signature, register?, fee }{ txHash }
POST/v1/rotate-owner{ id, newOwner, deadline, signature, register?, fee }{ txHash }
POST/v1/ping{ id, deadline, signature, register?, fee }{ txHash }
POST/v1/cancel-bind{ id, deadline, signature }{ txHash }
POST/v1/propose-bind{ id, newOwner, deadline, signatures[], register? }{ txHash }
POST/v1/finalize-bind{ id }{ txHash }
POST/v1/tx-status{ txHash }TxStatus: pending, success, reverted or unknown
GET/v1/accountsAccountsResponse (fetch-everything index)

Nothing account-specific travels in a URL, because URLs end up in proxy and CDN access logs: quotes carry no amount, tx status is a POST, and ids only appear in POST bodies.

GET /v1/config​

{
"chainId": 56,
"feeVault": "0x…",
"relayer": "0x…",
"settlementAsset": "0x55d398326f99059fF775485246999027B3197955",
"adapters": { "direct": "0x0000000000000000000000000000000000000000", "railgun": "0x…" },
"minDeadlineSeconds": 120,
"quoteTtlSeconds": 60
}

Put relayer in Claim.relayer (or 0x0 to let anyone submit a claim; fee-only claims must name the relayer). Example values are illustrative.

GET /v1/quote​

relayerFee = gasEstimate × gasPrice × margin (FEE_MARGIN_BPS, default 1.3×), converted into asset units with a configured static price, Chainlink or a PancakeSwap V3 quote, in that order.

The fee depends only on asset, adapter, action and the flags, so the query has no amount: an exact balance in a URL would fingerprint the account in access logs. An amount sent by an old client is accepted, ignored and not echoed.

ParameterMeaning
assetThe claimed asset, or for a non-claim action the asset the fee-only claim pays in
adapter0x0 (direct) or the Railgun adapter (USDT only). Must be 0x0 for non-claim actions
actionclaim (default), consolidate, shield-templates, rotate-owner or ping
templatesNumber of templates (1..32), required for shield-templates
consolidate=1The claim carries a consolidation step
register=1The request carries a registration step

Response: { chainId, feeVault, asset, adapter, action, relayer, relayerFee, gasEstimate, gasPrice, validUntil }. Sign with relayerFee >= quote.relayerFee before validUntil. gasEstimate is also the request's gas budget (see limits).

POST /v1/claim​

{
"claim": { "beneficiaryId", "asset", "amount", "adapter", "dataHash", "relayer", "relayerFee", "nonce", "deadline" },
"adapterData": "0x…", // keccak256(adapterData) must equal claim.dataHash
"signature": "0x…", // EIP-712 Claim by the owner (65 bytes for an EOA, 1..2048 for ERC-1271)
"register": { // optional: account not registered yet
"kind": 1, "owner": "0x…", "salt": "0x…", "fallbackRecipient": "0x…", "fallbackDelay": "0"
// or { "kind": 2, "handleCommitment": "0x…", "fallbackRecipient": "0x…", "fallbackDelay": "0" }
},
"consolidate": { // optional: beneficiary-signed Consolidate executed first
"assetIn": "0x…", "amountIn": "…", "minOut": "…", "deadline": "…", "signature": "0x…"
}
}

The relayer:

  1. validates the body (claimRequestSchema); checks deadline − now ≥ minDeadlineSeconds, relayer ∈ {self, 0x0}, 0 < amount, relayerFee ≤ amount, keccak256(adapterData) == dataHash and that the adapter is allowed (Railgun: USDT only);
  2. screens explicit direct recipients, and a registration's fallback recipient, against its OFAC / deny list;
  3. recomputes the EIP-712 digest and recovers the signer. The expected owner is register.owner when registering, else accountOf(id).owner. nonce must equal nonces(id). With consolidate, the consolidate signature uses nonces(id) and the claim must carry nonces(id) + 1. ERC-1271 owners (code at the owner address) are verified by the simulation;
  4. checks relayerFee against its current quote (minus QUOTE_TOLERANCE_BPS, default 0);
  5. builds multicall([register?, consolidate?, claim]) (a single call is sent unwrapped), simulates it with eth_call, applies the gas cap and sends it, optionally through a private RPC.

Full-balance claims​

With consolidate, the claim's asset is USDT and claim.amount must not exceed the USDT balance after consolidation (existing + minOut). To take everything, sign the full-balance forms, which the vault resolves at execution:

  • consolidate.amountIn = 2^255 | quotedAmountIn (SDK consolidateAllAmountIn), with minOut sized for quotedAmountIn. The vault swaps the whole balance and scales minOut by balance / quotedAmountIn when less is left, so the signed rate holds.
  • claim.amount = 2^256 − 1: the whole USDT balance at execution, with the signed, fixed relayerFee. The relayer then only checks relayerFee ≤ existing + minOut.

A launch creator's consolidation landing first can therefore not make the combined transaction revert. A plain consolidate.amountIn = 2^256 − 1 also means "the whole balance" but keeps minOut absolute, so a creator consolidation larger than the slippage margin still breaks it; clients use the quoted form. The FeeVault of the superseded 2026-10-04 deployment (which never held funds) predates the quoted form: it reads 2^255 | quotedAmountIn as a literal amount and reverts, so requests for that vault would need 2^256 − 1 or an exact amount (see deployment).

consolidate, shield-templates, rotate-owner and ping carry a fee field:

"fee": { "claim": { /* Claim */ }, "signature": "0x…" } // SDK buildFeeClaim + actionFeeToJson

It is a fee-only claim: adapter = 0x0, amount == relayerFee > 0, dataHash = keccak256(0x), relayer = this relayer (never 0x0, so it cannot be split off and front-run), nonce = nonces(id) + 1 (the action consumes nonces(id)), for beneficiaryId = id. It is signed by the account owner, and for rotate-owner by the new owner, because the fee claim runs after the rotation. The vault pays relayerFee of asset to the relayer and sends nothing else. The relayer appends it to the action's multicall, so the action and its payment succeed or revert together.

relayerFee must cover GET /v1/quote?action=<action>&asset=<fee asset>&adapter=0x0[&register=1][&templates=n]. A missing fee is refused with 402 fee_too_low, unless the operator runs a private deployment with REQUIRE_ACTION_FEE=false (then the operator pays).

POST /v1/consolidate​

{ id, consolidate: { assetIn, amountIn, minOut, deadline, signature }, register?, fee }. Consolidates without claiming; the USDT stays in the vault. assetIn may not be USDT, and amountIn and minOut must be non-zero (the full-balance forms above are accepted).

POST /v1/shield-templates​

{ id, templates: bytes[] (1..32, each exactly 320 bytes, unique), deadline, signature, register?, fee }. signature is an EIP-712 RegisterShieldTemplates with templatesHash = keccak256(keccak256(t_0) ‖ … ‖ keccak256(t_n)) and nonce nonces(id). Each template must be the canonical RailgunShieldAdapter payload abi.encode(bytes32 id, ShieldRequest) of a USDT note (SDK buildShieldTemplates); anything else is rejected before simulation.

POST /v1/rotate-owner and POST /v1/ping​

EIP-712 RotateOwner / Ping with nonce nonces(id). A rotation also discards the account's unused shield templates on-chain (they were authorized by the old key), so re-register templates from the new key afterwards.

Binds​

  • /v1/cancel-bind: EIP-712 CancelBind by the current owner, only while a bind is pending.
  • /v1/propose-bind: the relayer recovers the attestor signatures (all over the same deadline), drops duplicates, checks isAttestor and attestorThreshold, and sorts them by signer address. register is the kind: 2 payload if the handle account is not registered yet.
  • /v1/finalize-bind: permissionless after the timelock.

POST /v1/tx-status​

{ txHash } → { txHash, status, blockNumber? }. The hash is the one returned by a write endpoint. If the relayer replaced a stuck transaction with a higher gas price, the status follows the replacement. A POST, so the claim's hash never lands in an access-logged URL.

GET /v1/accounts (fetch everything)​

{
"chainId": 56, "feeVault": "0x…", "block": 123,
"accounts": [{
"id": "0x…", "kind": 1, "owner": "0x…", "fallbackRecipient": null, "fallbackDelay": null,
"pendingOwner": null, "pendingReadyAt": null,
"balances": { "<asset>": "<uint>" }, "totalDeposited": { "<asset>": "<uint>" },
"lastActivityBlock": 123, "shieldTemplates": 0,
"nonce": "3", "bindNonce": "0"
}]
}

Built by indexing FeeVault events (Deposited, Registered, Claimed, OwnerRotated, Pinged, BindProposed, BindFinalized, BindCancelled, SweptToFallback, plus Consolidated, ShieldTemplatesRegistered, ShieldTemplatesCleared and ShieldedFromTemplate, which debits USDT because a template shield emits no Claimed). nonce and bindNonce are nonces(id) and bindNonces(id) as of block, counted from the nonce-consuming events and reconciled with on-chain reads. Sign owner actions with them instead of reading nonces(id) from a public RPC, which would link your IP to the id; after your own relayed transaction, wait for block ≥ receipt.blockNumber (SDK RelayerClient.accountNonces(id, { minBlock })).

Clients download the whole list and filter locally. Served with ETag and Cache-Control: public, max-age=15. With the indexer disabled the route answers 400 unsupported.

Limits​

LimitDefaultSetting
Request body64 KiB (413 bad_request above)MAX_BODY_BYTES
Per-IP token bucket: each POST costs 1, a quote or tx-status poll 0.230 burst, 30 / minRATE_LIMIT_IP_*
Per-id token bucket, charged only after a successful eth_call simulation (valid signatures), so junk naming an id never drains it6 burst, 6 / minRATE_LIMIT_ID_*
Gas cap: refuse a transaction whose eth_estimateGas exceeds its gas budget (the quoted gasEstimate) × this2×GAS_CAP_BPS (20,000)
Gas limit sent = estimate × this1.2×GAS_LIMIT_MULTIPLIER_BPS
Minimum deadline headroom120 sMIN_DEADLINE_SECONDS

Configuration from a deployment file​

Set DEPLOYMENT_FILE=contracts/deployments/<chainId>.json and every address left empty (FEE_VAULT, RAILGUN_ADAPTER, SETTLEMENT_ASSET, the indexer start block) comes from it. The attestor accepts the same variable (FEE_VAULT, FACTORY, START_BLOCK).

Operational notes​

  • Screening. Railgun's PPOI screens the transaction sender, which is the relayer's address, and the RailgunShieldAdapter can refuse shields submitted by a sanctioned tx.origin. Operators should use dedicated, clean submitter addresses.
  • Logs. No IPs, ids, recipients or signatures are logged; /v1/metrics holds aggregate counters only and is not public.
  • Liveness. If a relayer refuses or is down, any other relayer, or the beneficiary from any address, can submit the same signed claim (if relayer was 0x0) or a re-signed one.