Technical Deep Dive

Architecture

Technical overview of RAIL20's per-token privacy pools, ZK circuit, and data flow on Base, Robinhood Chain, Arbitrum, and BNB Smart Chain.

System Overview

RAIL20 is a zero-knowledge privacy pool system live on Base (chain 8453), Robinhood Chain (chain 4663), Arbitrum (chain 42161), and BNB Smart Chain (chain 56). There is one independent ZK pool contract per token per chain - not a single shared vault. Value lives inside a pool as private UTXO notes; nothing on-chain reveals amounts or recipients.

Data Flow

The client signs a message to derive its account keys, then calls the relayer. The relayer scans the pool for the user's notes, builds the zk-SNARK proof, and broadcasts the transaction. Proof generation happens on the relayer, not in the browser.

text
User (browser)
  |
  +-- 1. Sign the RAIL20 message (derives account keys locally)
  +-- 2. POST { signature, pool, amount, recipient } to the relayer
  v
Relayer (api.rail20.org)
  |
  +-- 3. Derive keys from the signature; scan the pool for the user's notes
  +-- 4. Build the zk-SNARK proof (2 inputs, 2 outputs)
  +-- 5. Submit the transact() call (relayer pays gas, takes flat + 0.35% fee)
  v
Privacy Pool (on-chain, per token)
  |
  +-- 6. Verify the proof against a recent Merkle root
  +-- 7. Reject if any input nullifier is already spent
  +-- 8. Record the nullifiers as spent
  +-- 9. Insert the new output commitments into the tree
  +-- 10. Settle any public withdraw amount to the recipient
  v
Trust note: because the relayer builds the proof, it derives your account keys from your signature and can see your note amounts and recipients for the transactions it relays. It cannot move funds without your signature, and on-chain observers still learn nothing. Self-hosting a relayer (or a client-side prover) removes this trust in the hosted relayer.

ZK Circuit Design

RAIL20 uses a fixed 2-input / 2-output JoinSplit circuit (in the style of Light Protocol's UTXO design). Each spend consumes up to 2 input notes and creates up to 2 output notes. The circuit proves, in zero knowledge: each input note exists in the Merkle tree, each input nullifier is correctly derived, the sum of inputs equals the sum of outputs plus the public amount (deposit/withdraw/fee), and each output commitment is well-formed. All notes in one transaction share a single mintAddress (the token), so a pool only ever mixes its own asset.

ParameterValue
Proof systemGroth16 (zk-SNARK)
CircuitTransaction(26, 2, 2) - JoinSplit, 2 in / 2 out
Hash functionPoseidon (poseidon-lite / circomlib)
CurveBN254 (alt_bn128)
Merkle tree depth26 levels (up to 33,554,432 leaves)
Note encryptionAES-256-GCM (key derived from wallet signature)
Double-spend guardNullifier set (append-only)

The circuit's public signals are: root (the Merkle root proven against), publicAmount (external amount minus fee - positive on deposit, negative on withdraw, zero on an internal transfer), extDataHash (binds recipient, relayer, and fee so they cannot be tampered with), the input nullifiers, and the output commitments.

Merkle Tree

Each pool maintains an append-only Poseidon Merkle tree of note commitments. Spending a note reveals its nullifier - not its leaf - so the tree grows monotonically while spent notes are tracked separately by the nullifier set.

javascript
commitment    = Poseidon(amount, pubkey, blinding, mintAddress)
nullifier     = Poseidon(commitment, merklePath, signature)
                // signature = Poseidon(privKey, commitment, merklePath)
pathElements[] // sibling hashes for Merkle inclusion
pathIndices[]  // 0=left, 1=right

Security Model