STAMP

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.

Relay JSON-RPC methods
MethodBehaviour
eth_chainId, eth_supportedEntryPointsAnswered locally (EntryPoint v0.9 only).
stamp_getIssuerThe 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_sendUserOperationA 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_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 (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_estimateUserOperationGasDebugging 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.

  1. EntryPoint v0.9, op.paymaster is this paymaster, size limits; the version matches the method.
  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); a prepaid stamp needs the epoch's STAMPS flag (STAMP_LANE_CLOSED).
  3. EntryPoint nonce key 0 (NONCE_KEY).
  4. Authorization shape (AUTH_INVALID).
  5. Recompute userOpHash locally, including the authorization. A client-supplied hash is never trusted.
  6. 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).
  7. The account signature and the authorization's signer, then the delegation state read from the chain (SIGNATURE_INVALID, AUTH_INVALID, DELEGATE_MISMATCH).
  8. Template, allowlist, exact gas profile, PVG bounds and fees; the recipient rule for ETH sends.
  9. At most 4 pending ops per sender; a prepaid stamp must not be cancelled on chain yet (cancelledAt, STAMP_SPENT).
  10. Admission: a mined stamp against headroom, the free budget and pacing (BUDGET_EXHAUSTED, PACING, with retryAt); a prepaid stamp against the deposit's headroom only.
  11. Co-sign cosignDigest(userOpHash), or stampDigest(userOpHash, nullifier), with the epoch's co-signer key and replace the stub in paymasterSignature.
  12. 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.

Relay rejection reasons (error.data.reason)
reasonJSON-RPC codeMeaning
POW_INVALID-32501Mined stamp: the 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 rebuilds.
EPOCH_EXPIRED-32503Less than 60 s left in the op's validity window. The SDK rebuilds.
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, or the op fails the paymaster policy.
GAS_PROFILE-32501Gas limits differ from the published profile.
PVG_TOO_LOW / PVG_TOO_HIGH-32501preVerificationGas is outside the quote bounds. The SDK rebuilds on TOO_LOW.
FEE_MISMATCH / FEE_TOO_LOW-32501Fees differ from the epoch's rule, or the bundler's floor moved.
RECIPIENT / INSUFFICIENT_BALANCE-32501An ETH send to a contract, the sender itself or a reserved address; or more ETH than the sender holds.
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-32501Mined stamp: the 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.
STAMP_INVALID-32501The 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-32501The paymaster already recorded this stamp's nullifier (cancelledAt ≠ 0).
STAMP_IN_FLIGHT-32501The same stamp already pays for an operation the relay has not seen land yet.
STAMP_EXPIRED-32501The stamp's validity ends within the relay's 60 s margin.
STAMP_LANE_CLOSED-32501The epoch has no STAMPS flag, or this relay redeems no stamps.
ISSUER_UNAVAILABLE-32501This relay sells no stamps, or no issuer key is active yet.
ORDER_UNKNOWN / ORDER_PENDING-32501stamp_issue: the purchase is not mined, or not confirmed yet. Retry shortly.
ORDER_MISMATCH-32602stamp_issue: the transaction bought nothing, reverted, or the blinded messages, count or window are not the ones it paid for.
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: 4663 on mainnet, 31337 for the local fork.
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 or HSM on mainnet). Keep old keys until held ops expire.
KEEPER_PKA plain EOA that calls roll() and runs the postal jobs; only direct EOA rolls top up the deposit.
KEEPER_ROLLalways (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_PKRelay-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_MSChain-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_PATHSponsored templates (allowlist.mainnet.json) and the published gas profiles (deployments/4663.json).
STALE_GRACE_S, PACING_SLACK, BATCH_WINDOW_SOld-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, PORTThe web origin allowed to call the relay, and the listen port.
POST_OFFICEThe PostOffice. Without it the relay neither issues nor redeems prepaid stamps.
STAMP_ISSUER_KEYS / STAMP_ISSUER_KEYS_FILEThe 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_SIssuance 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_CONFIRMATIONSA 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_SINKTargets of the postal keeper: the buyback and burn of the markup, and the creator-fee collection.
POSTAL_INTERVAL_SPostal keeper period (600 s): pricing, burning, collecting; 0 = off.
PRICE_SAFETY_BPS / PRICE_DRIFT_BPSStamp 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_WEIBurn once the DeadLetterOffice holds 0.002 ETH, with a 3% minimum-output margin; collect once 0.002 ETH of creator fees are pending.
RATE_LIMITSRequests 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:

infra/alto/alto.mainnet.json
{
  "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
}
shell
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.json
  • ceiling-max-priority-fee-per-gas is read in gwei: 0.001 is 1e6 wei, the epoch's priority cap.
  • min-entity-stake must be an integer number of ETH and is read only by the safe-mode reputation manager: 0 here.
  • chain-type arbitrum lets 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 below min-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 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. The issuer stores no blinded message, no serial and no signature.
  • Put it behind a reverse proxy that keeps X-Forwarded-For out 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 foreign Host names; set ADMIN_TOKEN to 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.