Docs · 05 / 07 · Run your own relay
One relay, one bundler, one deposit.
The relay is a small Node service with four jobs: the bundler API your app talks to, the co-signer, the deposit ledger and the keeper that rolls epochs. 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 pow_* methods. CORS allows only the web origin.
| Method | Behaviour |
|---|---|
| eth_chainId, eth_supportedEntryPoints | Answered locally (EntryPoint v0.9 only). |
| eth_sendUserOperation | 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, acceptUntil, nextRollAt, headroomWei, budgetRemainingWei, gasProfiles, pvgQuotes, cosigner. Cached per block. |
| pow_getAccountState(address) | Delegation, EOA nonce, EntryPoint nonce (key 0) and allowlisted token balances. Not logged. The SDK calls it only at import and after failures. |
| eth_estimateUserOperationGas | Debugging and third-party tooling only; the SDK never calls it. Rate-limited, 4 concurrent, bodies ≤ 8 KB. |
Check order for eth_sendUserOperation#
- EntryPoint v0.9,
op.paymasteris this paymaster, size limits. - 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). - EntryPoint nonce key 0 (
NONCE_KEY). - Authorization and delegate rules (
AUTH_INVALID,DELEGATE_MISMATCH). - Recompute
userOpHashlocally, including the authorization. A client-supplied hash is never trusted. - Verify the 8 PoW hashes and mirror every on-chain soft check (fees, cost, gas caps, paymaster gas).
- Template, allowlist, exact gas profile and PVG bounds.
- At most 4 pending ops per sender.
- Headroom, budget and pacing (
BUDGET_EXHAUSTED,PACING, withretryAt). - Co-sign
cosignDigest(userOpHash)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 is verified, so sends are protected by the PoW itself.
| reason | JSON-RPC code | Meaning |
|---|---|---|
| POW_INVALID | -32501 | 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 re-mines. |
| EPOCH_EXPIRED | -32503 | Less than 60 s left in the op's validity window. The SDK re-mines. |
| 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. |
| 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 re-mines on TOO_LOW. |
| FEE_MISMATCH / FEE_TOO_LOW | -32501 | Fees differ from the epoch's rule, or the bundler's floor moved. |
| 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 | 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. |
| 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. |
| 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 on mainnet). Keep old keys until held ops expire. |
| KEEPER_PK | A plain EOA that calls roll(); only direct EOA rolls top up the deposit. |
| QUOTE_ACCOUNT_PK | Relay-owned dummy account for the 30 s PVG quotes, never a user's. |
| ALLOWLIST_PATH, GAS_PROFILES_PATH | Sponsored templates and the published gas profiles. |
| STALE_GRACE_S, PACING_SLACK, BATCH_WINDOW_S | Old-epoch grace (120 s on testnet), budget pacing slack (0.1), optional batch window (20 s). |
| CORS_ORIGIN, PORT | The web origin allowed to call the relay, and the listen port. |
The keeper checks every 10 s and calls roll() from its EOA once an epoch is over. It also refreshes the PVG quotes every
30 s with the relay's own dummy accounts, never a user's.
The bundler#
Self-hosted Alto 0.0.21, reachable only from the relay:
npx -y @pimlico/alto@0.0.21 \
--network-name robinhood-testnet --rpc-url "$RH_TESTNET_RPC" \
--entrypoints 0x433709009B8330FDa32311DF1C2AFA402eD8D009 \
--executor-private-keys "$EXECUTOR_PK" --utility-private-key "$UTILITY_PK" \
--chain-type arbitrum --safe-mode false --deploy-simulations-contract true \
--min-entity-stake 0.1 --min-entity-unstake-delay 86400 \
--ceiling-max-priority-fee-per-gas 0.001 \
--gas-price-expiry 1200 \
--bundle-mode manual --enable-debug-endpoints true \
--port 4337 --enable-cors false--ceiling-max-priority-fee-per-gasis read in gwei:0.001is 1e6 wei, the epoch's priority cap.- 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.
- Put it behind a reverse proxy that keeps
X-Forwarded-Forout of its logs. - Rate limits for reads 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.
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.