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
| Method | Path | Body / query | Response |
|---|---|---|---|
| GET | /v1/health | { ok: true, chainId, block } | |
| GET | /v1/metrics | Authorization: Bearer <METRICS_TOKEN> | Prometheus text. Operator-only: 404 when METRICS_TOKEN is unset, 401 without the token |
| GET | /v1/config | RelayerConfig | |
| GET | /v1/quote | ?asset&adapter[&action][&templates][&consolidate=1][®ister=1] (no amount) | QuoteResponse |
| POST | /v1/claim | ClaimRequest | { 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/accounts | AccountsResponse (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.
| Parameter | Meaning |
|---|---|
asset | The claimed asset, or for a non-claim action the asset the fee-only claim pays in |
adapter | 0x0 (direct) or the Railgun adapter (USDT only). Must be 0x0 for non-claim actions |
action | claim (default), consolidate, shield-templates, rotate-owner or ping |
templates | Number of templates (1..32), required for shield-templates |
consolidate=1 | The claim carries a consolidation step |
register=1 | The 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:
- validates the body (
claimRequestSchema); checksdeadline − now ≥ minDeadlineSeconds,relayer ∈ {self, 0x0},0 < amount,relayerFee ≤ amount,keccak256(adapterData) == dataHashand that the adapter is allowed (Railgun: USDT only); - screens explicit direct recipients, and a registration's fallback recipient, against its OFAC / deny list;
- recomputes the EIP-712 digest and recovers the signer. The expected owner is
register.ownerwhen registering, elseaccountOf(id).owner.noncemust equalnonces(id). Withconsolidate, the consolidate signature usesnonces(id)and the claim must carrynonces(id) + 1. ERC-1271 owners (code at the owner address) are verified by the simulation; - checks
relayerFeeagainst its current quote (minusQUOTE_TOLERANCE_BPS, default 0); - builds
multicall([register?, consolidate?, claim])(a single call is sent unwrapped), simulates it witheth_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(SDKconsolidateAllAmountIn), withminOutsized forquotedAmountIn. The vault swaps the whole balance and scalesminOutbybalance / quotedAmountInwhen less is left, so the signed rate holds.claim.amount = 2^256 − 1: the whole USDT balance at execution, with the signed, fixedrelayerFee. The relayer then only checksrelayerFee ≤ 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).
Paid owner actions
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[®ister=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-712CancelBindby the current owner, only while a bind is pending./v1/propose-bind: the relayer recovers the attestor signatures (all over the samedeadline), drops duplicates, checksisAttestorandattestorThreshold, and sorts them by signer address.registeris thekind: 2payload 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
| Limit | Default | Setting |
|---|---|---|
| Request body | 64 KiB (413 bad_request above) | MAX_BODY_BYTES |
Per-IP token bucket: each POST costs 1, a quote or tx-status poll 0.2 | 30 burst, 30 / min | RATE_LIMIT_IP_* |
Per-id token bucket, charged only after a successful eth_call simulation (valid signatures), so junk naming an id never drains it | 6 burst, 6 / min | RATE_LIMIT_ID_* |
Gas cap: refuse a transaction whose eth_estimateGas exceeds its gas budget (the quoted gasEstimate) × this | 2× | GAS_CAP_BPS (20,000) |
| Gas limit sent = estimate × this | 1.2× | GAS_LIMIT_MULTIPLIER_BPS |
| Minimum deadline headroom | 120 s | MIN_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
RailgunShieldAdaptercan refuse shields submitted by a sanctionedtx.origin. Operators should use dedicated, clean submitter addresses. - Logs. No IPs, ids, recipients or signatures are logged;
/v1/metricsholds 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
relayerwas0x0) or a re-signed one.