DocsRun your own relay
One relay, one bundler, one deposit.
The relay is a small Node service with six jobs: the bundler API your app talks to, the stamp issuer, the co-signer, the deposit ledger, the keeper that rolls epochs, and the postal keeper that prices stamps, burns the markup and collects the creator fees. It is open source; run your own to stop trusting ours with your IP address.
The one-bundler-per-deposit rule#
Co-signed ops never go to a third-party bundler, not even as a fallback. Only your bundler can land them: the relay never returns the co-signature to the client, and the chain has no public mempool.
JSON-RPC API#
ERC-7769 method names where they exist, plus stamp_* and pow_* methods. CORS allows only the web origin. The full
description is the OpenRPC document, /openrpc.json.
| Method | Behaviour |
|---|---|
| eth_chainId, eth_supportedEntryPoints | Answered locally (EntryPoint v0.9 only). |
| stamp_getIssuer | The issuer key, the metadata (info), the window and the PostOffice price booklets are bought under now. Address-free; clients compare key and window with the published pins before blinding anything. |
| stamp_issue([{ orderTx, info, blinded }]) | Blind-signs the 1 to 100 messages a mined PostOffice purchase committed to. Deterministic: asking again returns the same signatures. Rate class issue (10/min). |
| stamp_sendUserOperation(op, ep, { stamp, delaySec? }) | A prepaid-stamp op (paymasterData version 2) with { info, serial, signature }: the relay verifies the stamp and that its nullifier is neither cancelled nor in flight, runs the same op checks without proof of work, co-signs userOpHash and the nullifier. |
| eth_sendUserOperation | A mined stamp: full check order, then co-sign, then forward to the relay's own bundler. Errors: -32501 (paymaster rejected) or -32503 (time range) with data { reason, failMask?, retryAt? }. |
| pow_sendUserOperation(op, ep, { delaySec }) | Same checks and co-signature; the op is held for delaySec ≤ validUntil − now − 90 s, then forwarded. It counts in the ledger while held. |
| eth_getUserOperationReceipt, eth_getUserOperationByHash | Served from chain logs (UserOperationEvent) plus the ledger for pending ops, so they work while the bundler is down. |
| pow_getUserOperationStatus | held / submitted / included / rejected / expired. |
| pow_getEpoch | Current epoch fields (flags, stampPostOpGasLimit included), acceptUntil, nextRollAt, headroomWei, budgetRemainingWei, gasProfiles, pvgQuotes, cosigner. Cached per block. Optional [{ wait: true }] only while waiting to send: an on-demand relay then rolls a due epoch. |
| pow_getAccountState(address) | Delegation, EOA nonce, EntryPoint nonce (key 0), ETH and the non-zero balances of the listed tokens. Not logged. The SDK calls it only at import and after failures. |
| pow_checkRecipient(address) | Whether an ETH send to this recipient would be sponsored (no code, or delegated to Simple7702Account v0.9; not a reserved address). The SDK asks it once before an ETH send, so a refused recipient costs nothing. Reads the recipient only; not logged. |
| eth_estimateUserOperationGas | Debugging and third-party tooling only; the SDK never calls it. Rate-limited, 4 concurrent, bodies ≤ 8 KB. |
Check order for a send#
eth_sendUserOperation and pow_sendUserOperation take a mined stamp, stamp_sendUserOperation a prepaid one; the
paymasterData version must match the method.
- EntryPoint v0.9,
op.paymasteris this paymaster, size limits; the version matches the method. - The epoch exists and is current, or the previous one within
STALE_GRACEafter a roll (EPOCH_STALE); at least 60 s left beforevalidUntil(EPOCH_EXPIRED); a prepaid stamp needs the epoch'sSTAMPSflag (STAMP_LANE_CLOSED). - EntryPoint nonce key 0 (
NONCE_KEY). - Authorization shape (
AUTH_INVALID). - Recompute
userOpHashlocally, including the authorization. A client-supplied hash is never trusted. - Mined stamp: verify the 8 PoW hashes (
POW_INVALID). Prepaid stamp: verify the stamp under its window's derived key and its expiry (STAMP_INVALID,STAMP_EXPIRED), that the op's nullifier is the stamp's, and that the stamp is not in flight (STAMP_IN_FLIGHT). Then mirror every on-chain soft check (fees, cost, gas caps, paymaster gas). - The account signature and the authorization's signer, then the delegation state read from the chain
(
SIGNATURE_INVALID,AUTH_INVALID,DELEGATE_MISMATCH). - Template, allowlist, exact gas profile, PVG bounds and fees; the recipient rule for ETH sends.
- At most 4 pending ops per sender; a prepaid stamp must not be cancelled on chain yet (
cancelledAt,STAMP_SPENT). - Admission: a mined stamp against headroom, the free budget and pacing (
BUDGET_EXHAUSTED,PACING, withretryAt); a prepaid stamp against the deposit's headroom only. - Co-sign
cosignDigest(userOpHash), orstampDigest(userOpHash, nullifier), with the epoch's co-signer key and replace the stub inpaymasterSignature. - Add the op to the ledger and forward it to the bundler, or hold it. If the bundler rejects it, remove it and return the bundler's reason.
No bundler or provider call happens before the proof or the stamp is verified, so sends are protected by the postage
itself, and refused sends cost the client tokens of its invalidSend budget.
Issuance#
stamp_issue reads the purchase's receipt, requires STAMP_CONFIRMATIONS, finds StampsBought from the PostOffice,
checks that the count and keccak256(keccak256(info) ‖ blinded…) are the ones paid for and that the purchase's block time
falls in the window info names (plus STAMP_ISSUE_GRACE_S), then blind-signs under that window's key. The relay stores
nothing; asking again returns the same signatures. Generate the master keys with pnpm relay:issuer-key: the private
key goes to the keyring file only, and the public part is the pin to publish in contracts/deployments/<chainId>.json
under stamps.issuers. Clients refuse any key or window that is not pinned.
| reason | JSON-RPC code | Meaning |
|---|---|---|
| POW_INVALID | -32501 | Mined stamp: the proof or a mirrored on-chain check failed; failMask says which. |
| EPOCH_STALE | -32503 | The op's epoch is older than the grace window after a roll. The SDK rebuilds. |
| EPOCH_EXPIRED | -32503 | Less than 60 s left in the op's validity window. The SDK rebuilds. |
| NONCE_KEY | -32501 | The EntryPoint nonce key is not 0. |
| AUTH_INVALID | -32501 | The EIP-7702 authorization is wrong (delegate, chain id, nonce or signer). |
| DELEGATE_MISMATCH | -32501 | No authorization, and the sender is not delegated to Simple7702Account v0.9. |
| TEMPLATE / POLICY | -32501 | The call is not one of the sponsored templates, or the op fails the paymaster policy. |
| GAS_PROFILE | -32501 | Gas limits differ from the published profile. |
| PVG_TOO_LOW / PVG_TOO_HIGH | -32501 | preVerificationGas is outside the quote bounds. The SDK rebuilds on TOO_LOW. |
| FEE_MISMATCH / FEE_TOO_LOW | -32501 | Fees differ from the epoch's rule, or the bundler's floor moved. |
| RECIPIENT / INSUFFICIENT_BALANCE | -32501 | An ETH send to a contract, the sender itself or a reserved address; or more ETH than the sender holds. |
| SIGNATURE_INVALID | -32501 | The account signature does not recover to the sender, so the op could never validate. |
| PENDING_LIMIT | -32501 | The sender already has 4 ops pending. |
| BUDGET_EXHAUSTED / PACING | -32501 | Mined stamp: the epoch budget is used up, or paced; retryAt says when to try again. |
| HOLD_LIMIT | -32501 | Held ops already reserve their share of the budget; retryAt is the next held op's forward time. |
| STAMP_INVALID | -32501 | The stamp does not verify under its window's key, is not one of this issuer's windows, or its nullifier is not the op's. |
| STAMP_SPENT | -32501 | The paymaster already recorded this stamp's nullifier (cancelledAt ≠ 0). |
| STAMP_IN_FLIGHT | -32501 | The same stamp already pays for an operation the relay has not seen land yet. |
| STAMP_EXPIRED | -32501 | The stamp's validity ends within the relay's 60 s margin. |
| STAMP_LANE_CLOSED | -32501 | The epoch has no STAMPS flag, or this relay redeems no stamps. |
| ISSUER_UNAVAILABLE | -32501 | This relay sells no stamps, or no issuer key is active yet. |
| ORDER_UNKNOWN / ORDER_PENDING | -32501 | stamp_issue: the purchase is not mined, or not confirmed yet. Retry shortly. |
| ORDER_MISMATCH | -32602 | stamp_issue: the transaction bought nothing, reverted, or the blinded messages, count or window are not the ones it paid for. |
| BUNDLER_REJECTED | bundler's | The bundler refused the op after co-signing. data carries bundlerCode (and aaCode); its text is never forwarded. |
| BUNDLER_UNAVAILABLE | -32603 | The bundler did not confirm the op. It may still land; the reservation is kept until it resolves. |
Configuration#
| Variable | Purpose |
|---|---|
| RPC_URL, CHAIN_ID | Provider RPC for the relay (not the public one) and the chain id: 4663 on mainnet, 31337 for the local fork. |
| ALTO_URL | Your bundler, on a private network only. |
| PAYMASTER, ENTRYPOINT, DELEGATE | The paymaster instance this relay co-signs for, EntryPoint v0.9 and Simple7702Account v0.9. |
| COSIGNER_KEYS | JSON map of co-signer address → key (a KMS or HSM on mainnet). Keep old keys until held ops expire. |
| KEEPER_PK | A plain EOA that calls roll() and runs the postal jobs; only direct EOA rolls top up the deposit. |
| KEEPER_ROLL | always (roll every epoch once due) or on-demand (roll only while clients use the relay; the mainnet default, since every roll costs ETH). |
| QUOTE_ACCOUNT_PK | Relay-owned dummy account for the PVG quotes (every 120 s on mainnet, 30 s elsewhere), never a user's. |
| SYNC_INTERVAL_MS, SYNC_IDLE_INTERVAL_MS | Chain-sync periods with and without ops in flight (2 s / 10 s on Robinhood Chain): an idle sync reads no logs, so the provider bill stays small. |
| ALLOWLIST_PATH, GAS_PROFILES_PATH | Sponsored templates (allowlist.mainnet.json) and the published gas profiles (deployments/4663.json). |
| STALE_GRACE_S, PACING_SLACK, BATCH_WINDOW_S | Old-epoch grace (120 s; 30 s on the local fork), budget pacing slack of the free lane (0.1), batch window (20 s on mainnet). |
| CORS_ORIGIN, PORT | The web origin allowed to call the relay, and the listen port. |
| POST_OFFICE | The PostOffice. Without it the relay neither issues nor redeems prepaid stamps. |
| STAMP_ISSUER_KEYS / STAMP_ISSUER_KEYS_FILE | The issuer keyring {"keys":[{keyId, activeFrom, jwk}]}: RSA 2048 with safe primes, made by pnpm relay:issuer-key. Never in the repository; publish only the public parts as pins. |
| STAMP_WINDOW_S / STAMP_VALIDITY_S | Issuance window and validity after it: 2,592,000 / 10,368,000 (30 and 120 days). They must equal the published pins. |
| STAMP_ISSUE_GRACE_S / STAMP_CONFIRMATIONS | A purchase mined up to 3,600 s after its window ends is still issued under it; confirmations before issuance (1). |
| DEAD_LETTER_OFFICE / CREATOR_FEE_SINK | Targets of the postal keeper: the buyback and burn of the markup, and the creator-fee collection. |
| POSTAL_INTERVAL_S | Postal keeper period (600 s): pricing, burning, collecting; 0 = off. |
| PRICE_SAFETY_BPS / PRICE_DRIFT_BPS | Stamp price = gas quote × safety (15,000 bps: × 1.5) × (1 + markup); re-priced past a 1,000 bps (10%) drift. |
| BUYBACK_MIN_WEI / BUYBACK_SLIPPAGE_BPS / COLLECT_MIN_WEI | Burn once the DeadLetterOffice holds 0.002 ETH, with a 3% minimum-output margin; collect once 0.002 ETH of creator fees are pending. |
| RATE_LIMITS | Requests per minute per class and client network: epoch 60, receipt 60, account 10, estimate 5, issue 10, invalidSend 60. |
The keeper checks every 10 s and calls roll() from its EOA once an epoch is over (on mainnet, only while clients use the
relay). It refreshes the PVG quotes with the relay's own dummy accounts, never a user's. Every POSTAL_INTERVAL_S it also
runs the postal jobs, each skipped when its contract is not configured: it re-prices the PostOffice when the gas quote
drifts, burns the DeadLetterOffice's balance into $STAMP at 0x…dEaD with a minimum output, and calls
CreatorFeeSink.collect() once enough creator fees are pending.
The bundler#
Self-hosted Alto 0.0.21, reachable only from the relay. The non-secret flags live in a config file; the secrets come
from the environment (Alto reads ALTO_<OPTION> variables), so no key is ever on a command line:
{
"entrypoints": "0x433709009B8330FDa32311DF1C2AFA402eD8D009",
"chain-type": "arbitrum",
"safe-mode": false,
"deploy-simulations-contract": true,
"min-entity-stake": 0,
"min-entity-unstake-delay": 86400,
"ceiling-max-priority-fee-per-gas": "0.001",
"gas-price-expiry": 1200,
"default-api-version": "v2",
"bundle-mode": "manual",
"enable-debug-endpoints": true,
"enable-cors": false,
"min-executor-balance": "5000000000000000",
"port": 4337
}ALTO_RPC_URL="$RH_RPC" ALTO_EXECUTOR_PRIVATE_KEYS="$EXECUTOR_PK" ALTO_UTILITY_PRIVATE_KEY="$UTILITY_PK" \
npx -y @pimlico/alto@0.0.21 --config infra/alto/alto.mainnet.jsonceiling-max-priority-fee-per-gasis read in gwei:0.001is 1e6 wei, the epoch's priority cap.min-entity-stakemust be an integer number of ETH and is read only by the safe-mode reputation manager: 0 here.chain-type arbitrumlets Alto price the L1 data part of each op through the NodeInterface; the executor gets each bundle's compensation back, and the utility account refills it belowmin-executor-balance(0.005 ETH).- Use a provider RPC for the bundler and the relay; the public RPC is rate-limited and has no debug API, which is why the bundler runs in unsafe mode. Prefer an endpoint that authenticates by header or allowlist over one with the API key in its path: Alto's error text quotes the URL it called (the relay never forwards it, but logs and other tools might).
- The executor must be a plain EOA (EntryPoint v0.9
handleOpsis non-reentrant and requires one). Give its address to the relay asBUNDLER_EXECUTORS: after a restart, the relay accepts sends only once no bundle transaction is in flight. - Debug endpoints stay on the private network between the relay and the bundler.
Privacy posture#
- No IP logging and no request-body logging. Metrics are aggregated only.
- Zero retention: the ledger holds hashes and amounts, dropped when the op resolves. The issuer stores no blinded message, no serial and no signature.
- Put it behind a reverse proxy that keeps
X-Forwarded-Forout of its logs. - Rate limits live in memory and are never written anywhere: one bucket per client network (IPv4 address or IPv6 /64) and method class.
- Bundler and provider error text is never returned to clients; refusals carry a fixed message and a reason.
- The internal admin listener refuses browser requests (any
Origin) and foreignHostnames; setADMIN_TOKENto require a bearer token for the drain switch. - The issuer keyring never enters the repository or a log line; errors name keyring entries by position only.
Point the web app at it#
The app only connects to the endpoints its Content-Security-Policy allows. To use your relay from the app, build your own copy
with NEXT_PUBLIC_RELAY_URL (and optionally NEXT_PUBLIC_RPC_URL and NEXT_PUBLIC_ALLOWED_ENDPOINTS) set; the privacy
options in the app then offer it. SDK users just pass the URL to createRelayClient. Your relay issues its own stamps under
its own keys: stamps bought from one relay are redeemed by that relay.