4/5
## Mitigating Stale Proofs in ZK-Mixers with Historical Merkle Roots This lesson explores a common challenge in Zero-Knowledge (ZK) mixer smart contracts: the issue of "stale Merkle roots" and how to address it by storing a history of recent roots. This enhancement significantly improves the user experience by reducing transaction failures due to timing discrepancies. ### The Problem: Stale Merkle Roots In a ZK-mixer, users deposit funds and can later withdraw them anonymously. The anonymity is achieved by proving membership in a set of deposited commitments (leaves) without revealing which specific commitment is theirs. This set is typically managed using a Merkle tree. 1. **Proof Generation:** To withdraw, a user generates a cryptographic proof off-chain. This proof attests to their ownership of a commitment within the Merkle tree. Crucially, the Merkle root of the tree at the time of proof generation (`_root`) is included as a public input to the ZK-SNARK circuit. For instance, in a Noir circuit (`main.nr`), the root is declared public: ```noir // fn main( // Public inputs root: pub Field, nullifier_hash: pub Field, recipient: pub Field, // ... Private inputs // ) ``` 2. **On-Chain Verification:** The mixer's smart contract (e.g., `Mixer.sol`) contains a `withdraw` function. This function verifies the submitted proof. A key step in this verification is to ensure that the Merkle root used to generate the proof (`_root`) matches the current Merkle root stored on-chain (`s_root`). ```solidity // Inside Mixer.sol -> withdraw function (original version) function withdraw(bytes calldata _proof, bytes32 _root, ...) { // ... // Check that the root that was used in the proof matches the root on-chain if (_root != s_root) { // s_root is the single, current Merkle root revert Mixer_UnknownRoot(_root); } // ... } ``` 3. **The Race Condition:** A race condition arises if new `deposit` transactions are mined *after* a user generates their withdrawal proof but *before* their `withdraw` transaction is mined. * User A generates a proof using the current on-chain Merkle root, let's call it `root_A`. * Before User A's `withdraw` transaction (containing `root_A`) is processed, User B makes a deposit. * User B's deposit updates the Merkle tree, changing the on-chain Merkle root to `root_B`. * When User A's `withdraw` transaction is finally processed, the contract compares `_root` (which is `root_A`) with the current `s_root` (which is now `root_B`). Since `root_A != root_B`, the transaction reverts with an error like `Mixer_UnknownRoot`. This is a "stale root" problem – the proof was generated against a root that is no longer the most current one on-chain. ### The Solution: Storing Historical Merkle Roots To mitigate this stale root issue, the smart contract can be modified to store not just the single latest Merkle root, but a history of the most recent roots. This allows proofs generated against slightly older, but still recent, tree states to be considered valid. A common approach is to store a fixed number of recent roots, for example, the last 30. ### Implementation Deep Dive: Modifying `IncrementalMerkleTree.sol` The `IncrementalMerkleTree.sol` library, which manages the Merkle tree's structure and root updates, requires changes to support historical roots. 1. **State Variable Changes:** The single `s_root` state variable is replaced. We introduce a mapping to store historical roots and variables to manage this history. ```solidity // Old: // bytes32 public s_root; // New: mapping(uint256 => bytes32) public s_roots; // Stores historical roots uint32 public constant ROOT_HISTORY_SIZE = 30; // Max number of roots to store uint32 public s_currentRootIndex; // Index of the most recent root in s_roots ``` * `s_roots`: A mapping where the key is an index and the value is a Merkle root. * `ROOT_HISTORY_SIZE`: A constant defining how many historical roots to keep (e.g., 30). * `s_currentRootIndex`: Tracks the index in `s_roots` where the most recent Merkle root is stored. This helps implement a circular buffer. 2. **Constructor Update:** The constructor needs to initialize the historical roots structure. Typically, the first root stored is the root of an empty (or zeroed) tree. ```solidity // Inside IncrementalMerkleTree.sol -> constructor constructor(uint32 _depth, Poseidon2 _hasher) { // ... (other initializations like i_depth, i_hasher) i_depth = _depth; i_hasher = _hasher; // Initialize the tree with zeros (precompute all the zero subtrees) // Store the initial root in storage s_roots[0] = zeros(0); // Store the ID 0 root (initial root of the empty/zero tree) // s_currentRootIndex is implicitly 0 by default } ``` The `zeros(0)` function call refers to a helper that returns precomputed hash values for empty subtrees at different levels of the Merkle tree. `zeros(0)` typically represents the hash of a leaf node if it were empty or filled with a default zero value, or more generally, the root of the smallest possible (level 0) zeroed subtree. The initial state of the mixer is an empty tree, and its root is stored at index 0 of `s_roots`. `s_currentRootIndex` will be 0 initially. 3. **Modifying the `_insert` function:** Whenever a new leaf is inserted and the Merkle tree's root is recomputed, this new root must be added to the `s_roots` historical buffer. This buffer operates circularly: once full, new roots overwrite the oldest ones. ```solidity // Inside IncrementalMerkleTree.sol -> _insert function // ... (logic to compute the new Merkle root, 'currentHash') ... // Store the root in storage uint32 newRootIndex = (s_currentRootIndex + 1) % ROOT_HISTORY_SIZE; // Calculate next index with wrap-around s_currentRootIndex = newRootIndex; // Update the current root index to the new position s_roots[newRootIndex] = currentHash; // Store the new root at this new index // ... // return _nextLeafIndex; // _nextLeafIndex tracks the next available leaf slot, separate from root history ``` The modulo operator (`% ROOT_HISTORY_SIZE`) ensures that `newRootIndex` wraps around, effectively creating a circular buffer of size `ROOT_HISTORY_SIZE`. ### Implementation Deep Dive: Modifying `Mixer.sol` The main `Mixer.sol` contract's `withdraw` function also needs an update. Instead of checking against a single `s_root`, it will query the `IncrementalMerkleTree` contract to see if the provided `_root` is one of the known historical roots. ```solidity // Inside Mixer.sol -> withdraw function (new version) // ... // Let i_incrementalMerkleTree be the instance of the IncrementalMerkleTree contract if (!i_incrementalMerkleTree.isKnownRoot(_root)) { revert Mixer_UnknownRoot(_root); } // ... ``` The check `_root != s_root` is replaced by a call to a new function `isKnownRoot(bytes32 _root)` in the `IncrementalMerkleTree` contract. ### The `isKnownRoot` Function in `IncrementalMerkleTree.sol` This new public view function is responsible for checking if a given `_root` exists within the `s_roots` historical buffer. ```solidity // Inside IncrementalMerkleTree.sol function isKnownRoot(bytes32 _root) public view returns (bool) { // 1. Prevent matching uninitialized/default zero slots in the mapping. // The actual root of an empty tree is a specific hash, not bytes32(0). if (_root == bytes32(0)) { // Explicit cast to bytes32 for comparison return false; } // 2. Iterate through the stored historical roots. uint32 _currentRootIndex = s_currentRootIndex; // Cache s_currentRootIndex from storage for gas efficiency uint32 i = _currentRootIndex; // Start iterator from the most recent root's index do { if (s_roots[i] == _root) { return true; // Root found in history } // Decrement 'i' to check the previous root, handling wrap-around for the circular buffer. // If 'i' is 0, it needs to wrap around to the end of the buffer (ROOT_HISTORY_SIZE - 1). if (i == 0) { i = ROOT_HISTORY_SIZE; // Set 'i' to the size of the buffer } i--; // Decrement 'i' to get the previous index } while (i != _currentRootIndex); // Continue until all historical roots are checked (i.e., 'i' loops back to start) return false; // Root not found in history } ``` **Understanding the `isKnownRoot` Logic:** The `do-while` loop iterates backwards through the `s_roots` buffer, starting from the most recent root (`s_currentRootIndex`). Let's trace its execution with an example: Assume `ROOT_HISTORY_SIZE` is 8 (valid indices are 0-7 for `s_roots`). Suppose the most recent root is at `s_currentRootIndex = 4`. 1. The function first checks if `_root` is `bytes32(0)`. If so, it returns `false` to prevent matching default zero values in unwritten slots of the `s_roots` mapping. 2. `_currentRootIndex` (4) is cached. The iterator `i` is initialized to 4. 3. **Iteration 1:** `s_roots[4]` is compared with `_root`. If they match, `true` is returned. 4. If no match, `i` is decremented. * `i` becomes 3. Loop continues (3 != 4). 5. **Iteration 2:** `s_roots[3]` is checked. * `i` becomes 2. Loop continues (2 != 4). 6. **Iteration 3:** `s_roots[2]` is checked. * `i` becomes 1. Loop continues (1 != 4). 7. **Iteration 4:** `s_roots[1]` is checked. * `i` becomes 0. Loop continues (0 != 4). 8. **Iteration 5:** `s_roots[0]` is checked. * Now, `i` is 0. The `if (i == 0)` condition becomes true. * `i` is set to `ROOT_HISTORY_SIZE` (8). * `i--` then decrements `i` to 7. Loop continues (7 != 4). 9. **Iteration 6:** `s_roots[7]` is checked (this is the oldest entry if the buffer has wrapped around completely). * `i` becomes 6. Loop continues (6 != 4). 10. **Iteration 7:** `s_roots[6]` is checked. * `i` becomes 5. Loop continues (5 != 4). 11. **Iteration 8:** `s_roots[5]` is checked. * `i` becomes 4. 12. The `do-while` condition `(i != _currentRootIndex)` (i.e., `4 != 4`) is now false. The loop terminates. If no match was found throughout these checks, the function returns `false`. This logic ensures that all `ROOT_HISTORY_SIZE` stored roots are checked, starting from the most recent and going backward, correctly handling the circular nature of the buffer. ### Key Concepts Recap * **Stale Proofs:** Proofs generated against an older Merkle tree state that is no longer the absolute latest on-chain root, potentially leading to transaction failures. * **Historical Roots:** A mechanism to maintain a list (often in a circular buffer) of recent, valid Merkle roots. This provides a grace period, allowing withdrawals using proofs generated against these slightly older roots. * **Circular Buffer:** The `s_roots` mapping, combined with `s_currentRootIndex` and the modulo operator (`%`) for insertions and careful indexing for reads (`isKnownRoot`), creates a fixed-size buffer where new entries overwrite the oldest ones. This is crucial for efficiently managing the `ROOT_HISTORY_SIZE` roots without unbounded storage growth. * **Zero Value Consideration:** In Solidity, uninitialized slots in mappings default to zero. The `isKnownRoot` function includes an explicit check for `_root == bytes32(0)` to ensure a submitted zero root doesn't falsely match an unwritten (and therefore zeroed) slot in the `s_roots` array. The legitimate initial root of an empty tree is a non-zero hash value. ### Conclusion & Next Steps Implementing historical root storage makes the ZK-mixer more robust against the inherent timing issues between off-chain proof generation and on-chain transaction submission. This significantly improves the user experience by reducing an entire class of frustrating transaction reversions. The next logical step after implementing these contract changes is to write comprehensive tests. This typically involves creating JavaScript or TypeScript test scripts to simulate deposits, withdrawals with current roots, and withdrawals with historical (but valid) roots, ensuring the `isKnownRoot` logic and the circular buffer behave as expected under various conditions.
An essential deep-dive into Mitigating Stale Proofs in ZK-Mixers with Historical Merkle Roots - Understand why 'stale Merkle roots' cause issues in ZK-mixers and how implementing a historical root buffer offers a robust fix. This lesson walks through modifying contracts like `IncrementalMerkleTree.sol` and `Mixer.sol` to improve transaction reliability and user experience.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Noir syntax
Create a witness, a proof, and Solidity verifier contracts
Use the Poseidon commitment scheme
Create ZK circuits and build a full ZK protocol
ZK Merkle trees and hashing in Noir
Verify signatures without revealing the signer
Build the backend for a full-stack ZK application with noir.js and bb.js
How to create proofs and verify them in a front-end
Last updated on June 12, 2025
Duration: 6min
Duration: 1h 11min
Duration: 2h 12min
Duration: 3h 19min
Course Overview
About the course
Noir syntax
Create a witness, a proof, and Solidity verifier contracts
Use the Poseidon commitment scheme
Create ZK circuits and build a full ZK protocol
ZK Merkle trees and hashing in Noir
Verify signatures without revealing the signer
Build the backend for a full-stack ZK application with noir.js and bb.js
How to create proofs and verify them in a front-end
Last updated on June 12, 2025