5/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.
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.
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.
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:
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
).
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.
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.
IncrementalMerkleTree.sol
The IncrementalMerkleTree.sol
library, which manages the Merkle tree's structure and root updates, requires changes to support historical roots.
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.
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.
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.
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.
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.
The modulo operator (% ROOT_HISTORY_SIZE
) ensures that newRootIndex
wraps around, effectively creating a circular buffer of size ROOT_HISTORY_SIZE
.
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.
The check _root != s_root
is replaced by a call to a new function isKnownRoot(bytes32 _root)
in the IncrementalMerkleTree
contract.
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.
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
.
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.
_currentRootIndex
(4) is cached. The iterator i
is initialized to 4.
Iteration 1: s_roots[4]
is compared with _root
. If they match, true
is returned.
If no match, i
is decremented.
i
becomes 3. Loop continues (3 != 4).
Iteration 2: s_roots[3]
is checked.
i
becomes 2. Loop continues (2 != 4).
Iteration 3: s_roots[2]
is checked.
i
becomes 1. Loop continues (1 != 4).
Iteration 4: s_roots[1]
is checked.
i
becomes 0. Loop continues (0 != 4).
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).
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).
Iteration 7: s_roots[6]
is checked.
i
becomes 5. Loop continues (5 != 4).
Iteration 8: s_roots[5]
is checked.
i
becomes 4.
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.
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.
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