DEV Community

rim dinov
rim dinov

Posted on

Building a Privacy Mixer in Solidity: Why My Merkle Proofs Were Failing

Privacy in Web3 is often seen as a "black box" of complex zero-knowledge proofs. However, the heart of most privacy protocols like Tornado Cash is a much older and simpler structure: the Merkle Tree.

Recently, while building a research-based ETH Mixer, I ran into a classic synchronization issue between on-chain verification and off-chain proof generation. Here is what I learned about Merkle Tree stability and how to avoid the "Invalid Proof" trap.

The Architecture
The goal was simple:

Deposit: A user sends 1 ETH and a hash (commitment).

Mix: The commitment is added to a Merkle Tree.

Withdraw: The user provides a Merkle Proof to withdraw 1 ETH to a new address without revealing the original deposit.

The Pitfall: Sorted Hashes
Initially, I used a sorted-hash Merkle Tree. In this approach, nodes are sorted before hashing:

Solidity
// Vulnerable logic for manual proof generation
computedHash = a <= b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a));
The Problem: While this saves some gas on-chain (you don't need to pass "left/right" flags), it makes manual proof generation in a test environment a nightmare. If even one leaf changes or is empty (bytes32(0)), the entire path's sorting can flip.

In my Foundry tests, I kept getting Invalid Merkle proof because my off-chain proof generator couldn't perfectly sync with the contract's sorting logic for empty slots.

The Fix: Index-Based Stability
I refactored the protocol to use a Deterministic Indexed Tree. Instead of sorting by value, we use the leaf's index to determine its position.

Even Index: You are the Left child.

Odd Index: You are the Right child.

Solidity
// Stable verification logic
if (index % 2 == 0) {
computedHash = keccak256(abi.encodePacked(computedHash, proofElement));
} else {
computedHash = keccak256(abi.encodePacked(proofElement, computedHash));
}
index /= 2;
Lessons for Security Researchers
Predictability over Gas (sometimes): Sorting saves a few hundred gas, but increases the risk of integration bugs between the frontend and the smart contract.

The "Empty Leaf" Trap: Always be consistent with how you handle padding. Using bytes32(0) for empty leaves is standard, but your proof generator must mirror this exactly.

Foundry is a lifesaver: Using forge test -vv allowed me to trace the hash updates step-by-step and find exactly where the test and the contract diverged.

Check out the Code
You can find the full implementation, including the fixed test suite and the Post-Mortem, in my repository:
👉 github.com/rdin777/merkle-mixer-research

#solidity #blockchain #web3 #security #foundry

Top comments (0)