Skip to content
On this page

L4-2: Privacy Mixer

1. Problem

Ethereum transactions are completely transparent by default -- the sender, receiver, and amount of every transfer are permanently recorded on a public ledger. While this transparency is beneficial for auditing and security, it seriously compromises users' financial privacy. Anyone can see your wallet balance, transaction history, and the DeFi protocols you interact with. This is not only a privacy issue but also a security issue -- individuals holding large amounts of crypto assets may become targets.

The goal of a privacy mixer is to break the on-chain link between deposits and withdrawals without compromising the blockchain's decentralization and verifiability. Tornado Cash is the most famous implementation: users deposit a fixed denomination of ETH (e.g., 0.1 ETH) and receive a secret credential; later, anyone (including through a relayer) can use this credential to withdraw 0.1 ETH to a new address, and on-chain observers cannot link the deposit transaction to the withdrawal transaction.

This introduces extremely complex cryptographic challenges: how can you prove you know the credential for a certain deposit without revealing which deposit? The answer is zero-knowledge proofs (ZKP) -- specifically, committing all deposits in a Merkle Tree and then using a ZK circuit to prove knowledge of a leaf's preimage (nullifier + secret) without revealing the leaf's position.

2. Why

Privacy protection is one of the most important yet unsolved problems in the Ethereum ecosystem. Tornado Cash proved the technical feasibility (processing over $7 billion in transaction volume before sanctions), but also sparked intense debate about the legitimacy of privacy tools. From a technical perspective, the privacy mixer is a perfect case study for bringing zero-knowledge proofs from theory to practice -- it demonstrates that ZKPs can verify complex computations in smart contracts at reasonable gas costs (around 500K gas).

Understanding privacy mixers has multiple values for developers:

  • Cryptographic practice: End-to-end implementation of Merkle Tree + commitment scheme + zero-knowledge proofs
  • ZKP engineering: Understanding the complete workflow of writing Circom circuits, generating Groth16 proofs, and on-chain verification
  • Privacy design patterns: Fixed denominations, anonymity sets, nullifier-based double-spend prevention -- these patterns apply to all on-chain privacy systems
  • Regulation and philosophy: Understanding the technical necessity, legitimate use cases, and regulatory challenges of privacy tools

Although Tornado Cash faces legal challenges, its underlying technology (ZK proofs + Merkle commitments) has been widely adopted in legitimate privacy projects -- such as zkSync's private transactions, Railgun (a compliance-based privacy protocol), and the emerging Privacy Pools (a mixer that uses ZK proofs to exclude funds from illicit sources).

3. Solution

PrivacyMixer.sol implements a fixed-denomination (0.1 ETH) privacy mixer. While it uses direct key revelation (rather than full ZK proofs) as an educational simplification, its architecture fully preserves the core design of Tornado Cash:

3.1 Core Cryptographic Primitives

Commitment Scheme:

  • The user generates two random 32-byte values: secret and nullifier
  • Computes commitment: commitment = keccak256(abi.encodePacked(secret, nullifier))
  • Submits the commitment on-chain during deposit, inserting it into the Merkle Tree
  • The secret credentials (secret, nullifier) are stored off-chain and kept by the user

Merkle Tree:

  • A binary tree of height 20, supporting up to 2^20 ~ 1,048,576 deposits
  • Uses the incremental insertion algorithm (Incremental Merkle Tree):
    • Each level i maintains a "filling" subtree filledSubtrees[i]
    • Even index: the current leaf is the left half of that level's subtree; the right half is filled with the precomputed zeros[i]
    • Odd index: the current leaf is the right half of that level's subtree; hashed together with the previously stored filledSubtrees[i]
    • Each insertion updates the entire path hash up to the root
  • Zero value precomputation: zeros[0] = keccak256(bytes32(0)), zeros[i] = keccak256(zeros[i-1], zeros[i-1])

Nullifier Double-Spend Prevention:

  • nullifierHash = keccak256(abi.encodePacked(nullifier))
  • Each withdrawal publicly reveals the nullifierHash and marks it as spent
  • The same nullifier cannot be used for two withdrawals -- preventing the same deposit from being withdrawn multiple times

3.2 Deposit Flow

  1. User generates secret and nullifier off-chain (32 random bytes each)
  2. Computes commitment = keccak256(secret, nullifier)
  3. Calls deposit(commitment) and sends exactly 0.1 ETH
  4. The contract inserts the commitment into the Merkle Tree
  5. Stores commitments[commitment] = leafIndex + 1 (1-indexed, 0 means unused)
  6. Records the new Merkle root: roots[depositCount] = currentRoot
  7. User saves (secret, nullifier, leafIndex) as withdrawal credentials

3.3 Withdrawal Flow

  1. User provides (secret, nullifier, recipient, relayer, fee)
  2. Contract reconstructs commitment = keccak256(secret, nullifier) -- verifies it has been deposited
  3. Computes nullifierHash = keccak256(nullifier) -- verifies it has not been spent
  4. Validates fee < DENOMINATION (relayer fee cannot exceed denomination)
  5. Marks nullifierSpent[nullifierHash] = true (before transfer, following the CEI pattern)
  6. Transfers: recipient receives DENOMINATION - fee, relayer receives fee
  7. The relayer mechanism allows the withdrawer (recipient) to differ from the transaction initiator -- further enhancing privacy

3.4 ZK Verifier Interface (IVerifier)

The contract includes an IVerifier interface defining the verifyProof() method. In the full Tornado Cash implementation:

  • The withdrawal does not directly submit secret and nullifier
  • Instead, it submits a Groth16 ZK proof demonstrating: "I know a commitment equal to MiMC(nullifier, secret) that is in the Merkle Tree, and the nullifier hash is a specific public value"
  • The Verifier contract verifies the proof, the Merkle root, and the nullifierHash

3.5 Incremental Merkle Tree Algorithm in Detail

The _insertLeaf() function implements efficient incremental insertion:

function _insertLeaf(leaf):
    index = depositCount
    currentHash = leaf
    
    for i in 0..MAX_DEPTH-1:
        if index % 2 == 0:  // Even index -- current leaf is on the left
            filledSubtrees[i] = currentHash
            currentHash = hash(currentHash, zeros[i])
        else:                 // Odd index -- current leaf is on the right
            currentHash = hash(filledSubtrees[i], currentHash)
        index = index / 2
    
    currentRoot = currentHash

The key insight of this algorithm is that it only needs to store one "filling" subtree value per level, rather than all nodes of the entire tree. For a tree of height 20, storage complexity is reduced from O(2^h) to O(h). This allows the contract to efficiently maintain a Merkle Tree of up to millions of deposits on-chain.

Reference source file: src/level4/PrivacyMixer.sol

3.6 Complete Tornado Cash ZK Circuit (Conceptual Level)

In actual Tornado Cash, withdrawals do not directly expose secret and nullifier; instead, proofs are generated through a Circom ZK circuit:

text
template Mixer(levels) {
    // Private inputs (not exposed on-chain)
    signal input nullifier;
    signal input secret;
    signal input pathElements[levels];   // Merkle proof
    signal input pathIndices[levels];    // 0=left, 1=right

    // Public inputs (exposed on-chain, verified by contract)
    signal input root;          // Merkle root
    signal input nullifierHash; // Double-spend prevention
    signal input recipient;     // Recipient address

    // Commitment = MiMC(nullifier, secret)
    component hasher = MiMC7(2, 91);
    hasher.in[0] <== nullifier;
    hasher.in[1] <== secret;

    // Verify commitment is in the Merkle Tree
    signal leaf = hasher.out;
    for (var i = 0; i < levels; i++) {
        // Hash layer by layer to root
        hasher[i] = MiMC7(2, 91);
        // Decide left/right based on pathIndices
        hasher[i].in[0] <== pathIndices[i] == 0 ? leaf : pathElements[i];
        hasher[i].in[1] <== pathIndices[i] == 1 ? leaf : pathElements[i];
        leaf <== hasher[i].out;
    }
    root === leaf;  // Final hash must match the publicly input root

    // nullifierHash = MiMC(nullifier)
    component nullifierHasher = MiMC7(1, 91);
    nullifierHasher.in[0] <== nullifier;
    nullifierHash === nullifierHasher.out;
}

Why MiMC instead of keccak256: keccak256 has extremely high execution costs in ZK circuits (requiring many constraints/gates). MiMC (Minimal Multiplicative Complexity) is a hash function specifically designed to be ZK-friendly -- its implementation in finite fields requires very few multiplication operations (multiplication is the most expensive constraint in ZK circuits). The Tornado Cash circuit uses MiMC7 (7 rounds), with each round requiring only about 250 constraints on the BN254 curve, whereas keccak256 would require hundreds of thousands of constraints.

4. Pitfalls Encountered

  • Computational cost of zero-knowledge proof generation: Generating a Groth16 proof in a browser takes 10-30 seconds, yielding a poor user experience for small withdrawals
  • Anonymity set too small: If only a few people use the mixer (e.g., only 10 deposits), the link between deposits and withdrawals can easily be inferred through statistical analysis
  • Fixed vs. flexible denominations: Fixed denominations (all deposits equal) enhance anonymity but degrade user experience (exact amounts are required); flexible denominations are convenient but break the anonymity set (different amounts can be used for linkage)
  • Risk of nullifier loss: If the user's stored (secret, nullifier) is lost, the deposit is permanently locked in the contract -- there is no recovery mechanism
  • Storage management of the incremental Merkle Tree: The roots mapping stores the root after each insertion; withdrawals need to check whether a root is known -- iterating through all historical roots incurs gas costs that grow linearly with the number of deposits
  • MiMC vs keccak256 ZK discrepancy: PrivacyMixer.sol uses keccak256 as the hash function (educational simplification), but the real ZK circuit uses MiMC -- if the contract and circuit use different hash functions, verification will fail
  • Frontend privacy leakage: Even if users use a mixer, their privacy can still be compromised if their browser exposes their IP address, uses the same RPC node, or reveals transaction information on social media
  • Groth16 trusted setup: Tornado Cash's ZK circuit requires a CRS (Common Reference String) generated by the Powers of Tau ceremony. If a malicious participant is present during the setup and their toxic waste is not properly destroyed, proofs can be forged

5. Why the Pitfalls Occur

An anonymity set that is too small is the most fundamental challenge of mixers -- it is a "chicken and egg" problem. With only a few deposits, anyone can enumerate all possible deposit-withdrawal correspondences. For example, with 3 deposits and 3 withdrawals, there are theoretically only 3! = 6 possible correspondences, and privacy is severely compromised. As the number of deposits grows to thousands or millions, the number of possible correspondences grows exponentially, and anonymity greatly increases.

Tornado Cash creates independent anonymity sets of different sizes through fixed denominations (0.1 ETH, 1 ETH, 10 ETH, 100 ETH). All deposits in the same denomination pool are indistinguishable. However, this also means using different pools for different amounts, reducing the anonymity set size for each amount. Privacy Pools (the successor to Tornado Cash, proposed by Ameen Soleimani) introduces the concept of "association sets" -- users can prove that their deposit is not in a specific set of flagged addresses, thereby preserving privacy while meeting legal compliance requirements.

The computational cost of ZK proof generation comes from the characteristics of the Groth16 proof system. Proof generation consists of three steps: (1) witness computation (running the circuit in JavaScript/WASM); (2) proof generation (multi-scalar multiplication and pairing computations); (3) proof serialization into Solidity calldata format. Step (2) is the most time-consuming. Newer proof systems (such as PLONK, Halo2, Nova) are improving this by eliminating trusted setups or supporting recursive proofs, but they have not yet fully replaced Groth16's dominance in on-chain verification.

The incremental Merkle Tree root checking problem: when a withdrawer submits a Merkle root, the contract needs to verify that this root exists among the historical roots. _isKnownRoot() iterates over roots[1..depositCount], and the gas cost of this loop grows linearly with the number of deposits. For pools with millions of deposits, this leads to prohibitive gas costs. The solution is to maintain a root -> bool mapping, optimizing the query from O(n) to O(1).

6. How to Resolve the Pitfalls

Growing the Anonymity Set: Maximize pool size by standardizing denominations and enabling cross-pool interaction. Allow multiple applications to share the same mixer contract (such as Tornado Cash Nova's ETH + ERC-20 multi-asset pool).

Proof Generation Optimization:

  • Use Web Workers to generate proofs in background threads without blocking the UI
  • Precompute witnesses (start computation before user interaction)
  • Explore newer proof systems -- such as Leo's online proof generator or RISC Zero's ZK VM
  • Deploy mixers on L2s, leveraging faster block confirmation times and lower gas fees to reduce overall latency

Root Query Optimization:

solidity
mapping(bytes32 => bool) public isKnownRoot;

function _insertLeaf(bytes32 leaf) internal {
    // ... update tree ...
    isKnownRoot[currentRoot] = true; // O(1) marking
}

function _isKnownRoot(bytes32 root) internal view returns (bool) {
    return isKnownRoot[root];
}

Note: this mapping grows indefinitely with the number of deposits -- 20 million deposits * 32 bytes = approximately 640MB of storage, exceeding Ethereum's feasible range. For extremely large scales, a rollup approach can be used -- storing roots on L2 and only verifying ZK proofs on L1.

Frontend Privacy Protection:

  • Use Tor or VPN to connect to RPC nodes
  • Use the relayer pattern -- transactions are submitted by a third party, unlinked to the user's IP
  • Timed withdrawals -- wait a random interval (days to weeks) after deposit before withdrawing
  • Do not send withdrawals directly to addresses with interaction history with the deposit address
  • Use browser privacy mode, clear cookies, disable tracking

Trusted Setup Risk Mitigation:

  • The security of the Powers of Tau ceremony depends on at least one participant honestly destroying their random data
  • Use community-widely-verified setups (such as the Ethereum Foundation's Perpetual Powers of Tau)
  • Consider using transparent setup or setup-free proof systems such as STARKs

7. Technical Highlights

TechniqueDescription
Commitment Schemecommitment = hash(secret, nullifier); keys are not exposed at deposit time
Merkle TreeHeight 20, supports 1M+ deposits, incremental insertion algorithm
Nullifier Double-Spend PreventionnullifierHash marks as spent; the same key can only withdraw once
Fixed DenominationAll deposits are 0.1 ETH, enhancing anonymity set uniformity
Relayer PatternWithdrawal initiator != recipient, breaking transaction graph linkage
CEI PatternMark nullifier before transfer, preventing reentrancy
IVerifier InterfaceAbstraction layer for ZK proof verification, can interface with different proof systems
Groth16The proof system used by Tornado Cash; small proofs (~128 bytes), fast verification
MiMC HashZK-friendly hash function with low multiplicative complexity
CircomZK circuit DSL; compiles to R1CS then generates proofs
Incremental Merkle TreeO(h) storage, O(h) insertion, no need to store the entire tree
Zero-Value Precomputationzeros[i] = hash(zeros[i-1], zeros[i-1]), replacing empty subtrees
Anonymity SetMore deposits in the pool = stronger privacy
Trusted SetupGroth16 requires a CRS from the Powers of Tau ceremony

Built with AiAda