Re-thinking Railgun’s official wallet architecture


TLDR

Railgun's wallet sync takes hours on first load and gigabytes of RAM because it conflates two kinds of work that don't need to live together: public deterministic work (chain scanning, Merkle reconstruction, nullifier tracking) that every client redundantly repeats, and private cryptographic work (decryption, proof generation) that genuinely must stay client-side.

This creates a broken flywheel: Railgun's privacy guarantees strengthen with more protocol usage, but the same usage makes wallets slower.

Offloading the public work to a shared indexer while keeping cryptography in the browser preserves all privacy guarantees (with a small additional trust assumption), fixes the flywheel and unlocks mobile, chatbot, and agentic wallets that weren't previously possible.

⚛️
Repo: Server + backend | Product: b402.ai

Background

Railgun is a privacy service on Ethereum. It functions like a mixer with a private account and allows a user to take 3 actions:

  • Shield
    • Allows any Ethereum address addr1 to deposit funds into its smart contract and assigns a zk-address zkaddr1to control these funds.

  • Transact
    • Allows transfer of funds between zk-addresses, or call external contracts.

  • Unshield
    • Allows funds to be withdrawn from a zk-address to any set of Ethereum addresses addr2, addr3.

 

Assuming addr1 was doxxed and addr2 & addr3 are fresh, privacy comes from the fact that nobody can tell that addr2 & addr3 are linked to addr1.

This is typically because a large number of shields and unshields take place over a given time period, creating an anonymity set. If addr1 shields 1 ETH on day 1 and unshields 0.5 ETH → addr2, 0.5 ETH → addr2 on day 4, an analyst is unlikely to find the link because:

  • many 1 ETH shields took place on day 1
  • many unshields ≤ 1 ETH took place between day 1 and day 4
 

The zk-address has 2 key properties to make this privacy preservation possible:

  1. Stealth ownership
    1. Allows the owner to control it without anyone else knowing who the owner is ie, addr1 always has full control over zkaddr1 without any on-chain observer knowing this fact.

  1. Stealth actions
    1. Keeps its state transitions hidden from everyone except the owner ie, nobody knows that zkaddr1 was funded by addr1 and unshielded to addr2 & addr3, unless the zkaddr1 secret keys (”spending key” / “viewing key”) are leaked

 

Both properties are possible because of Railgun’s (brilliant) architecture that works like this:

  • Essential on-chain private state
    • Each action produces a UTXO where the inputs we want to keep private (value, token, recipient_pubkey, randomness) (aka pre-image) get hashed into a commitment C and inserted onto Railgun's on-chain Merkle tree.

  • ZK-based spending
    • The UTXO can be spent by the owner if they are able to generate an inclusion zk-proof (SNARK) that proves:

      1. Inclusion: The merkle path P to C hashes up to the current on-chain root R, ie C is a valid leaf in the tree.
      1. Ownership: They know C's pre-image that was originally hashed to produce C.
      1. Full spend: The sum of input UTXO values equals the sum of output UTXO values (plus any fee or unshield amount).
      1. No double-spend: On spending a UTXO, a nullifier N is generated, derived as hash(C, private_spending_key). N is deterministic and unique to this UTXO. If the same UTXO is ever spent again, the same N would be produced, and the contract rejects the transaction.
  • Sufficient on-chain public state
    • The goal is for the contract to know essential information to keep the system working and for any owner to be able to re-construct their entire tx history and current private balance.

      1. UTXO: Merkle tree position and encrypted ciphertext ie, encrypted pre-image. Allows the owner to know their entire tx history.
      1. ZKP & corresponding merkle root R: Allows the contract to verify the proof is valid against a recently known state of the tree
      1. Nullifier: Allows the contract to reject double-spend
 

Problem

For a wallet to let a user spend a shielded UTXO, it needs the decrypted UTXO, the UTXO's position in the merkle tree and a current merkle root.

 

In the official Railgun wallet, all three are derived client-side from raw chain data. On first load, the wallet must perform the following actions:

  1. Discover what's owned
    1. Scan every commitment event from genesis, try to decrypt each one with the user's viewing key to discover which UTXOs belong to them.

  1. Reconstruct the shared ledger
    1. Rebuild the full 16-depth Poseidon merkle tree in memory by replaying every leaf insertion in order and recomputing parent hashes up to the root.

  1. Compute balance and tx history
    1. Scan every nullifier event to determine which of the user's UTXOs have already been spent.

  1. Prove ownership at spend time
    1. Walk the tree to produce a sibling path for each UTXO the user wants to spend.

 

This is the correct design for a self-sovereign, minimally-trusting client. The only external dependency is an honest RPC endpoint. However, there are significant UX tradeoffs:

  1. First sync can take hours on Ethereum (increases with increased protocol activity).
  1. Every new device starts from zero.
  1. RAM usage is high (65,536 leaves × 16 levels of hashes). Mobile is not possible.
  1. The wallet/tab must stay open and synced else all state is lost.
  1. Not very composable ie, any product that wants to integrate Railgun must embed this heavy sync engine.
 

These tradeoffs prevent the realistic creations of UX friendly wallets with experiences like:

  • Auth flows, mobile-native and chatbot wallets
    • Open the app, login and find your private wallet account. Available on web, iOS, Android, etc. and also on lightweight interfaces like Telegram bots.

  • One-click flows
    • Composable multi-step transactions (like shield → swap → unshield) without waiting for a sync between each step.

  • Agentic payments
    • Agents that can pay on your behalf from shielded funds without embedding a full sync engine in its runtime.

  • Embedded privacy in existing wallets
    • Simple SDK to allow a full suite privacy features without committing to embedding a heavy engine and inheriting the above tradeoffs.

 

There’s also a structural limitation where although increased protocol activity is positively correlated with privacy guarantees (larger anonymity set), it is negatively correlated with UX (more events to scan + leaves to replay, bigger tree to rebuild)

notion image
 

Looking under the hood, we observe that there are two types of work happening:

  • Public, deterministic work
    • Scanning every Shield, Transact, CommitmentBatch, GeneratedCommitmentBatch, and Nullified event from genesis. This is network I/O bound with thousands of RPC calls.
    • Rebuilding the merkle tree (65,536 leaves × 16 levels of Poseidon hashes per tree) sequentially. This is the RAM spike.
    • Scanning and storing every Nullified event to build the tx history and balance. This is another full chain scan, and the result must be cross-referenced against owned UTXOs.
  • Private, cryptographic work
    • Attempting decryption of each commitment with the viewing key. Sounds like a lot, but fairly quick. ~50k commitments in a few seconds.
    • Computing nullifiers for owned UTXOs. For hundreds of notes, this is a few milliseconds.
    • Generating ZK proofs. This takes place at the time of spending the note (user action) and can take upto 15 seconds.
 

We notice that all tradeoffs come from the “public, deterministic work” bucket! Hence, we conclude the following problem statement:

Can we solve Railgun wallet’s UX problem by offloading the “public, deterministic work” to a managed service without breaking existing privacy guarantees or adding new trust assumptions?

 

Solution

Given the nature of the “public, deterministic work”, the solution can take the form factor of a service that runs an indexer & serves an API whilst keeping all privacy guarantees intact. The only caveat is that the trust assumption of honest RPC now becomes honest API provider using an honest RPC*¹.

 

The service takes on 4 responsibilities, each lifting one piece of work out of the browser into a shared, persistent server:

  1. Discover what's owned → Indexed lookup
    1. Before: every browser / device / session scanned every Shield event on the chain from genesis, attempting decryption on each one to find the ones it owned.

      With server:

      • Shield events are indexed by the depositor's EOA address. A client asking "what did I deposit?" makes a single indexed query and gets back its candidate set with encrypted bundles attached.
      • This leaks nothing as the depositor address is emitted in plaintext in the Shield event anyway. The indexer is just serving public data.
      • The client still decrypts the bundles locally to determine ownership.

      This eliminates the full-chain scan. Any new device gets its candidate set in a single API call.

       
  1. Shield discovery from full-chain scan to indexed lookup
    1. Before: every browser re-ran Railgun's insertLeaves from genesis on every cold start, holding the entire 16-depth Poseidon tree in RAM until the tab closed.

      With server:

      • The indexer re-implements insertLeaves exactly, processes every Shield, GeneratedCommitmentBatch, Transact, and CommitmentBatch event in chronological order, and persists every node of every tree in Postgres keyed by (treeNumber, level, position).
      • The computed root is validated against the on-chain snapshot. A client requesting a proof by (treeNumber, position) gets a 16-level sibling path back in one round trip.

      This eliminates the hours-long sync, the RAM spike, and the must-stay-open requirement.

       
  1. Compute balance and tx history → Batched nullifier lookup
    1. Before: every browser pulled every Nullified event on the chain and cross-referenced against its owned UTXOs to find spent notes and had to scan and decrypt every TRANSACT output to find change from partial unshields.

      With server:

      • From locally decrypted Shield commitments, the client computes nullifier hashes locally (poseidon(nullifyingKey, position)) for its owned commitments and sends a batch to the server. The server returns which are spent and no ownership information is revealed.
      • For spent nullifiers linked to a TRANSACT, the server also returns the output commitments with full ciphertext, so the client can decrypt change notes and walk the next iteration.

      This eliminates the remaining cold-start cost and enables full tx history reconstruction through iterative API calls.

 
  1. Prove ownership at spend time → One-shot merkle proof
    1. Before: the client walked its in-memory tree to build a 16-level sibling path for each UTXO it wanted to spend. If the tree wasn't synced (tab closed, new device), the user had to redo the full sync before spending anything.

      With server:

      • The client requests a proof by (treeNumber, position) and gets back the 16-level sibling path and root in one API call. The server verifies the proof before responding; the client re-verifies locally.
      • The client generates the ZKP in-browser (spending key never leaves), signs the calldata, and submits it to the server for broadcast. The user can close the tab immediately after submission.

      This eliminates the "must be synced to spend" requirement. A user on a fresh device can go from signature to submitted transaction without ever rebuilding the tree.

notion image

The Architecture: Client-side flow

Phase 0: Setup identity

The Same signature & keys on any device are derived from user’s doxxed EOA addr1 in one-click signing known dedicated message creating signature. From this, two identity layers are derived deterministically and offline:

  1. Incognito EOA
    1. privateKey = keccak256(signature) → standard EOA. This EOA owns the incognito smart wallets addr2 & addr3 but never holds funds directly and never appears on the depositor side.

  1. Railgun keys
      • entropy = keccak256(signature)[0:16 bytes] → BIP-39 mnemonic → Railgun's deriveNodes(mnemonic, 0)
        • viewingKeyPair: decrypts commitment bundles to discover owned UTXOs.
        • spendingKeyPair: proves ownership inside the ZK circuit.
        • nullifyingKey = poseidon(viewingPrivateKey): computes nullifiers for spent UTXOs.
        • masterPublicKey = getMasterPublicKey(spendingPubkey, nullifyingKey): the public identity tied to zkaddr1.
 

No seed phrase management required by the user and no separate wallet to back up since the main EOA wallet is the seed for the private identity.

The recipient of every unshield is a Nexus ERC-4337 smart account computed deterministically via CREATE2:

salt = keccak256("example-salt-" + lowercase(incognitoEOA))
init = abi.encode(NEXUS_BOOTSTRAP, initNexusWithDefaultValidator(incognitoEOA))
addr = NexusAccountFactory.computeAccountAddress(init, salt)

The factory and bootstrap addresses are pinned constants shared between client and server, so both sides arrive at the same address with no on-chain lookup. The wallet is deployed lazily by the unshield broadcaster when the first unshield lands.

 

Phase 1: Balance and transaction history

  1. deriveRailgunKeys(signature): all keys derived locally from the cached signature.
  1. GET /commitments?eoa=: server returns all shields for this depositor.
  1. For each shield, the client computes sharedKey = getSharedSymmetricKey(viewingPrivateKey, shieldKey), decrypts the encrypted bundle to recover the random, computes expectedNPK = getNotePublicKey(masterPublicKey, random), and discards commitments whose NPK doesn't match.
  1. For each owned commitment, compute nullifier = poseidon(nullifyingKey, position). Batch all candidate nullifiers into POST /nullifiers the server checks each one against its index of on-chain Nullified events and returns which have been spent. The unmatched ones are the spendable UTXOs.
  1. For each spent nullifier:
      • Spent as UNSHIELD → terminal node, funds left the shielded pool.
      • Spent as TRANSACT → the API returns output commitments with full ciphertext. The client decrypts these to discover change notes (value returned to self). Each decrypted output is a new owned commitment. Repeat from step 4.
  1. The walk terminates when every leaf is either unspent (current balance) or terminal (unshield or failed decryption ie, sent to someone else).

The result is a complete tree:

Shield (10 ETH)
└─ TRANSACT → change note (7 ETH) + sent (3 ETH)
     └─ TRANSACT → change note (2 ETH) + sent (5 ETH)
          └─ UNSHIELDaddr2 (2 ETH)
 

Phase 2: Proof generation and spend

  1. For each selected UTXO, GET /merkle-proof?treeNumber=&position=. Verify the proof locally against the returned root.
  1. Assemble snarkjs witness inputs (note values, nullifiers, merkle paths, recipient, amounts).
  1. Generate the Groth16 proof in the browser. Proof artifacts are cached in IndexedDB — first run takes 25–60 seconds (download + prove), subsequent runs take 5–15 seconds.
  1. Format the Railgun calldata and POST /unshield with the signed transaction.

The spending key never leaves the browser.

 

The Architecture: Server-side setup

notion image

The Indexer

Backend that performs 3 functions:

  1. Data pipeline
      • Cron that indexes Railgun contracts events and stores the following in a structured DB: Shields, Transacts, Unshields, Commitments, Nullifiers (from the parent Transact or Unshield)
  1. Merkle tree materialization
      • A MerkleTreeBuilder per tree replays leaves in (blockNumber, transactionIndex, position) order using the same algorithm as Railgun's Commitments.sol (filled-subtree accumulator, pair-and-hash level by level).
      • Nodes are bulk upserted. After backfill, the computed root at level 16 is compared to the on-chain snapshot root to make sure it matches.
      • Tree depth is 16 (65,536 leaves per tree). The zero hash is bytes32(uint256(keccak256("Railgun")) % SNARK_SCALAR_FIELD), Railgun's canonical constant.
  1. Unshield processor
      • Cron that pulls pending unshields and broadcasts the pre-signed Railgun calldata.
      • In case PPOI (Private Proof of Innocence) compliance is required, unshields are a two-step process:
        • Submit: user generates ZK proof, signs calldata, and submits via POST /unshield. The server queues it.
        • Complete: after >1 hour (when the PPOI proof becomes valid), the user calls again to trigger the actual broadcast.
 

The API

EndpointWhat it does
GET /commitments?eoa=Returns all shield events for that depositor, including encrypted bundles and shield keys.
POST /nullifiersAccepts n nullifier hashes. Returns which are spent and, for each, the linked output commitments with full ciphertext (for change-note discovery).
GET /merkle-proof?treeNumber=&position=Returns the 16-level sibling path, root, and leaf. Server verifies the proof before responding.
POST /unshieldAccepts pre-signed Railgun calldata. Validates contract target and nullifier freshness. Queues the unshield.
GET /unshield/:idReturns unshield status and metadata.

The Product

I put this concept into action and led the launch of b402 wallet while at Bless. At the time of writing, the wallet has seen over $100M of shield + unshield flow. Here’s the architecture diagram of our managed wallet (more info found in our docs)

notion image
 

The product continues to be live and my teammate Mayur continues to ship new features! Here’s a video of the product by Mayur:

 

Refs:

 

The API provider can observe query patterns ie, which nullifiers and merkle proofs are requested in a single session. Users can mitigate this by masking their IP (VPN/Tor)