on Robinhood Chain · testnet

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.

Relay JSON-RPC methods
MethodBehaviour
eth_chainId, eth_supportedEntryPointsAnswered locally (EntryPoint v0.9 only).
eth_sendUserOperationFull 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_getUserOperationByHashServed from chain logs (UserOperationEvent) plus the ledger for pending ops, so they work while the bundler is down.
pow_getUserOperationStatusheld / submitted / included / rejected / expired.
pow_getEpochCurrent 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_estimateUserOperationGasDebugging and third-party tooling only; the SDK never calls it. Rate-limited, 4 concurrent, bodies ≤ 8 KB.

Check order for eth_sendUserOperation#

  1. EntryPoint v0.9, op.paymaster is this paymaster, size limits.
  2. The epoch exists and is current, or the previous one within STALE_GRACE after a roll (EPOCH_STALE); at least 60 s left before validUntil (EPOCH_EXPIRED).
  3. EntryPoint nonce key 0 (NONCE_KEY).
  4. Authorization and delegate rules (AUTH_INVALID, DELEGATE_MISMATCH).
  5. Recompute userOpHash locally, including the authorization. A client-supplied hash is never trusted.
  6. Verify the 8 PoW hashes and mirror every on-chain soft check (fees, cost, gas caps, paymaster gas).
  7. Template, allowlist, exact gas profile and PVG bounds.
  8. At most 4 pending ops per sender.
  9. Headroom, budget and pacing (BUDGET_EXHAUSTED, PACING, with retryAt).
  10. Co-sign cosignDigest(userOpHash) with the epoch's co-signer key and replace the stub in paymasterSignature.
  11. 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.

Relay rejection reasons (error.data.reason)
reasonJSON-RPC codeMeaning
POW_INVALID-32501The proof or a mirrored on-chain check failed; failMask says which.
EPOCH_STALE-32503The op's epoch is older than the grace window after a roll. The SDK re-mines.
EPOCH_EXPIRED-32503Less than 60 s left in the op's validity window. The SDK re-mines.
NONCE_KEY-32501The EntryPoint nonce key is not 0.
AUTH_INVALID-32501The EIP-7702 authorization is wrong (delegate, chain id, nonce or signer).
DELEGATE_MISMATCH-32501No authorization, and the sender is not delegated to Simple7702Account v0.9.
TEMPLATE / POLICY-32501The call is not one of the sponsored templates.
GAS_PROFILE-32501Gas limits differ from the published profile.
PVG_TOO_LOW / PVG_TOO_HIGH-32501preVerificationGas is outside the quote bounds. The SDK re-mines on TOO_LOW.
FEE_MISMATCH / FEE_TOO_LOW-32501Fees differ from the epoch's rule, or the bundler's floor moved.
SIGNATURE_INVALID-32501The account signature does not recover to the sender, so the op could never validate.
PENDING_LIMIT-32501The sender already has 4 ops pending.
BUDGET_EXHAUSTED / PACING-32501The epoch budget is used up, or paced; retryAt says when to try again.
HOLD_LIMIT-32501Held ops already reserve their share of the budget; retryAt is the next held op's forward time.
BUNDLER_REJECTEDbundler'sThe bundler refused the op after co-signing. data carries bundlerCode (and aaCode); its text is never forwarded.
BUNDLER_UNAVAILABLE-32603The bundler did not confirm the op. It may still land; the reservation is kept until it resolves.

Configuration#

Relay configuration (environment)
VariablePurpose
RPC_URL, CHAIN_IDProvider RPC for the relay (not the public one) and the chain id.
ALTO_URLYour bundler, on a private network only.
PAYMASTER, ENTRYPOINT, DELEGATEThe paymaster instance this relay co-signs for, EntryPoint v0.9 and Simple7702Account v0.9.
COSIGNER_KEYSJSON map of co-signer address → key (a KMS on mainnet). Keep old keys until held ops expire.
KEEPER_PKA plain EOA that calls roll(); only direct EOA rolls top up the deposit.
QUOTE_ACCOUNT_PKRelay-owned dummy account for the 30 s PVG quotes, never a user's.
ALLOWLIST_PATH, GAS_PROFILES_PATHSponsored templates and the published gas profiles.
STALE_GRACE_S, PACING_SLACK, BATCH_WINDOW_SOld-epoch grace (120 s on testnet), budget pacing slack (0.1), optional batch window (20 s).
CORS_ORIGIN, PORTThe 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:

shell
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-gas is read in gwei: 0.001 is 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 handleOps is non-reentrant and requires one). Give its address to the relay as BUNDLER_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-For out 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 foreign Host names; set ADMIN_TOKEN to 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.