It’s a decentralized, non-custodial privacy protocol built on Ethereum that breaks the on-chain link between source and destination addresses to enhance transaction anonymity
Here is the user flow:
We deposit some fixed cryptocurrency into the Tornado Cash smart contract → this generates a note that is stored locally on the user’s machine
As users deposit into the contract, individual deposits become indistinguishable when mixed with multiple users’ data, and later this value is withdrawn to the depositor’s account

Q Why do we need to deposit fixed amounts like 0.1 ETH, 1 ETH?
A If variable amounts were deposited, withdrawals could be tracked. Fixed denominations prevent this. Also, these fixed denominations each have different smart contract instances that are orchestrated and run separately

The protection increases with an increase in the number of people in the pool, which we call the “anonymity set”

There are two implementations of this: ETH and ERC20 (a standard technical protocol for creating and issuing fungible tokens on the Ethereum blockchain)

Deposit flow

The user deposits into Tornado by sending a fixed amount equal to the denomination when they call the deposit function, which takes a commitment as a parameter
Here, commitment is composed of secret deposit information. It is a cryptographic technique that allows a user to commit to a chosen value (or values) while keeping it hidden, with the ability to reveal the value later

Here, “to commit a value” means to create a cryptographic output that locks in your chosen value without revealing what that value is, it’s similar to sealing your choice in an envelope. These commitments must satisfy:

  1. Binding – the committer cannot change the committed value after the fact
  2. Hiding – the commitment reveals nothing about the original value being committed to

Tornado Cash uses something called a Pedersen commitment, which provides:

  1. Information-theoretic hiding: unlimited computational power cannot determine the original value
  2. Computational binding: security holds as long as computational hardness assumptions, like the discrete logarithm problem, hold

These use elliptic curve points, which are calculated as:
Commitment = value * G + randomness * H
Operations are performed using scalar multiplication and addition

function deposit(bytes32 _commitment) external payable nonReentrant {
    require(!commitments[_commitment], "The commitment has been submitted");
 
    uint32 insertedIndex = _insert(_commitment);
    commitments[_commitment] = true;
    _processDeposit();
 
    emit Deposit(_commitment, insertedIndex, block.timestamp);
}
  1. Commitment uniqueness check: Prevents duplicate commitments; safeguards against collisions (commitment reuse)
  2. _insert(_commitment): Adds a leaf into the Merkle tree structure and returns the leaf index, which is used in proofs
  3. emit Deposit(...): Stores partial metadata without revealing the secret or nullifier.

Here, the value is the secret, and the randomness is the nullifier. This makes the deposit secret and prevents storing sensitive data on-chain
We store this information in an incremental Merkle tree, which helps us prove membership via zero-knowledge proofs

Tornado Cash uses the MiMC Sponge hash function, but newer applications use the Poseidon hash function, which helps create more efficient zk proofs

Every deposit updates the global state of the Tornado Cash Merkle Trees(specifically Incremental Merkle Tree), which later enables zero-knowledge membership proofs

  • We need a way to prove that a deposit exists without revealing which deposit it is
  • A Merkle tree allows the contract to commit to many deposits with a single root hash
  • Users only need the root and a Merkle path to later prove inclusion in zero knowledge

Q How the Merkle Tree is Updated when a user deposits ?
A When a new commitment is inserted into the Tornado Cash contract, the Merkle tree is updated incrementally

  1. Leaf replacement
    • The tree is initialized with empty (zero) leaves
    • The next available zero leaf is replaced with the commitment hash
    • This position becomes the leaf index returned during deposit
  2. Hash re-computation up the tree
    • Starting from the leaf level, hashes are recomputed upward toward the root.
    • At each level:
      • The current node hash is combined with its left or right sibling
      • If the sibling node does not yet exist, a precomputed zero hash is used
    • This guarantees deterministic tree structure even with sparse data
  3. Parent node updates
    • Each recomputed hash becomes the parent node for the next level
    • This continues level by level until the root is reached
  4. Root computation and storage
    • The final hash at the top becomes the new Merkle root
    • This root is stored in the contract’s root history
    • Multiple roots are kept so users can withdraw even if newer deposits occur after theirs

Q Why this matters for privacy and withdrawals ?
A

  • The contract never stores secrets or nullifiers, rather it stores only commitment hashes
  • During withdrawal:
    • The user proves their commitment exists in one of the stored roots
    • The Merkle path (siblings + indices) is supplied privately in the zk proof
  • Observers cannot tell:
    • which leaf belongs to which user
    • when a specific deposit is withdrawn
Withdraw flow

When a user withdraws, they need to provide a zero-knowledge proof that is generated off-chain using a circuit and public inputs. This proof is verified on-chain

To generate this zk proof, we use the Tornado frontend or CLI, which generates a witness. This witness is then used to generate the zk proof (random bytes). For this, the user must provide private input secrets

The inputs look like this:

// Verifies that the commitment corresponding to a given secret and nullifier
// is included in the Merkle tree of deposits
template Withdraw(levels) {
	signal input root;
	signal input nullifierHash;
	signal input recipient; // not taking part in any computations
	signal input relayer;   // not taking part in any computations
	signal input fee;       // not taking part in any computations
	signal input refund;    // not taking part in any computations
 
	signal private input nullifier;
	signal private input secret;
	signal private input pathElements [levels];
	signal private input pathIndices [levels];
}
  • Prove membership: commitment (k || r) is in the Merkle tree with root root
  • Prove knowledge of the secret and nullifier without revealing them
  • Include recipient, relayer, fee, and refund into the proof, binding them so they cannot be manipulated after proof generation

There is an privacy component called RMEM - revealing none of the secret data, only that it exists and is valid. The CLI uses the circom-js library to create the proof

function withdraw(
    bytes calldata _proof,
    bytes32 _root,
    bytes32 _nullifierHash,
    address payable _recipient,
    address payable _relayer,
    uint256 _fee,
    uint256 _refund
) external payable nonReentrant {
    ...
    require(isKnownRoot(_root), "Cannot find your merkle root");
    ...
    require(
      verifier.verifyProof(_proof, [uint256(_root), uint256(_nullifierHash), ...]),
      "Invalid withdraw proof"
    );
    ...
}
  1. Proof verification: Verifies the zk-SNARK proof that:
    • the user knows the secret and nullifier corresponding to a leaf in the Merkle tree
    • that leaf hashes to the provided Merkle root
    • the proof includes certain public inputs (recipient, relayer, fee), binding them so they cannot be tampered with
  2. Root validation: _root must exist in the stored Merkle root history, allowing old deposit proofs to still be used
  3. Nullifier check: _nullifierHash ensures a deposit hasn’t been withdrawn already. Once used, it is marked as spent
  4. Double-spend protection: Prevents reuse of the same leaf more than once, which is critical for privacy

Q Why include the recipient and relayer addresses?
A Including the recipient address prevents front-running, a blockchain vulnerability where a mempool observer could see a pending withdrawal with a valid proof and resubmit the transaction first, redirecting funds to a different address. By including the recipient in the proof, this attack is prevented

Q Why include a relayer address?
A This enables gasless transactions. The relayer submits the transaction and pays the gas fee, and is compensated after the transaction is completed