5/5
## Finalizing the Merkle Tree Insertion Logic To complete the functionality of our ZK-mixer, we first need to finalize the `_insert` function within the `IncrementalMerkleTree.sol` contract. This function is responsible for adding a new leaf (commitment) to our Merkle tree and updating its structure. After the core loop that calculates hashes up the tree levels, two crucial steps remain: storing the new Merkle root and managing the leaf index. ### Storing the New Merkle Root Once a new leaf is inserted and its impact has been propagated up the tree through iterative hashing, the `currentHash` variable holds the newly computed root of the entire Merkle tree. This new root must be persisted in the contract's state. We achieve this by assigning `currentHash` to the state variable `s_root`. This `s_root` represents the most up-to-date state of our Merkle tree, incorporating all previously deposited commitments. ```solidity // src/IncrementalMerkleTree.sol (within _insert function, after the for loop) // ... // store the root in storage s_root = currentHash; ``` ### Managing and Returning the Leaf Index For each new leaf, we need to track its position in the tree. The `s_nextLeafIndex` state variable stores the index where the *next* leaf will be inserted. Initially, within the `_insert` function, we capture the current value of `s_nextLeafIndex` into a local variable, let's call it `_insertedLeafIndex`. This `_insertedLeafIndex` represents the index at which the current leaf is being inserted. ```solidity // src/IncrementalMerkleTree.sol (within _insert function) uint32 _insertedLeafIndex = s_nextLeafIndex; // Index for the current insertion // ... (rest of the insertion logic, including s_root = currentHash;) ``` After successfully inserting the leaf and updating the root, we must increment `s_nextLeafIndex` so it's ready for the subsequent insertion: ```solidity // src/IncrementalMerkleTree.sol (within _insert function, after storing the root) // increment the next leaf index s_nextLeafIndex = _insertedLeafIndex + 1; ``` Crucially, the `Mixer` contract (which will call this `_insert` function) needs to know the index at which the current leaf was actually inserted. This information is vital for the `Deposit` event, which helps off-chain services reconstruct the tree. Therefore, the `_insert` function is modified to return the `_insertedLeafIndex` *before* it was incremented to become `s_nextLeafIndex`. The updated function signature and return statement look like this: ```solidity // src/IncrementalMerkleTree.sol function _insert(bytes32 _leaf) internal returns (uint32) { uint32 _insertedLeafIndex = s_nextLeafIndex; // Capture index for this insertion // ... (existing leaf insertion and Merkle root calculation logic) ... // store the root in storage s_root = currentHash; // increment the next leaf index for future insertions s_nextLeafIndex = _insertedLeafIndex + 1; // Return the index of the leaf that was just inserted return _insertedLeafIndex; } ``` This ensures that the caller receives the exact index of the leaf that was processed in that particular call to `_insert`. ## Integrating the Merkle Tree into the Mixer Contract With the `IncrementalMerkleTree.sol` contract's `_insert` function finalized, we can now integrate this Merkle tree logic into our main `Mixer.sol` contract. This involves importing necessary contracts, setting up inheritance, and modifying the constructor. ### Importing Dependencies The `Mixer.sol` contract needs to be aware of `IncrementalMerkleTree` and the hashing library it uses (e.g., `Poseidon2`). We also include `IVerifier` for context, as it's typically part of a ZK-mixer system. ```solidity // src/Mixer.sol pragma solidity ^0.8.0; import {IncrementalMerkleTree} from "./IncrementalMerkleTree.sol"; import {Poseidon2} from "./poseidon2-evm/Poseidon2.sol"; // Assuming Poseidon2 is the hasher import {IVerifier} from "./Verifier.sol"; ``` ### Establishing Inheritance To directly use the Merkle tree functionalities, the `Mixer` contract will inherit from `IncrementalMerkleTree`. This makes all `public` and `internal` functions and state variables of `IncrementalMerkleTree` available within `Mixer`. ```solidity // src/Mixer.sol contract Mixer is IncrementalMerkleTree { // ... Mixer contract state variables and functions will follow } ``` ### Modifying the Constructor The `IncrementalMerkleTree` contract has its own constructor that requires parameters like the tree depth and an instance of the hasher. When `Mixer` inherits from `IncrementalMerkleTree`, its constructor must call the parent contract's constructor with the necessary arguments. The `Mixer` constructor will now accept `_merkleTreeDepth` and a `Poseidon2 _hasher` instance, passing them along to the `IncrementalMerkleTree` constructor. ```solidity // src/Mixer.sol contract Mixer is IncrementalMerkleTree { IVerifier public i_verifier; uint256 public constant DENOMINATION = 1 ether; // Example denomination mapping(bytes32 => bool) public s_commitments; // To track used commitments event Deposit(bytes32 indexed commitment, uint32 insertedIndex, uint256 timestamp); constructor(IVerifier _verifier, uint32 _merkleTreeDepth, Poseidon2 _hasher) IncrementalMerkleTree(_merkleTreeDepth, _hasher) // Call parent constructor { i_verifier = _verifier; } // ... other functions like deposit and withdraw } ``` This setup ensures that when a `Mixer` contract is deployed, the underlying `IncrementalMerkleTree` is also properly initialized. ## Completing the Deposit Function in the Mixer Contract The `deposit` function is central to the mixer's operation, allowing users to add their commitments (shielded notes) to the Merkle tree. We'll now finalize this function by incorporating the Merkle tree insertion and emitting a crucial event for off-chain processes. ### Inserting the Commitment into the Merkle Tree When a user calls `deposit` with their `_commitment`, this commitment needs to be inserted as a leaf into the Merkle tree. Since `Mixer` now inherits from `IncrementalMerkleTree`, we can directly call the `_insert` function (which is `internal`). This function will handle the hashing, update the tree structure, store the new root, and return the index at which the commitment was inserted. ```solidity // src/Mixer.sol (within deposit function) // ... (pre-checks for commitment uniqueness and denomination) ... // 3. Add the commitment to the on-chain incremental Merkle tree // This calls the _insert function inherited from IncrementalMerkleTree uint32 insertedIndex = _insert(_commitment); ``` The `insertedIndex` variable now holds the leaf index of the newly deposited commitment. ### Emitting the Deposit Event After successfully inserting the commitment, it's vital to emit an event. Smart contract events are the primary mechanism for off-chain services to listen to on-chain activity and synchronize their state. The `Deposit` event will provide the necessary information for users or relayers to reconstruct the Merkle tree off-chain, which is essential for generating withdrawal proofs. The event definition should include the commitment itself, its index in the tree, and a timestamp. ```solidity // src/Mixer.sol (event definition, usually at the contract level) event Deposit(bytes32 indexed commitment, uint32 insertedIndex, uint256 timestamp); ``` Within the `deposit` function, after the `_insert` call, we emit this event: ```solidity // src/Mixer.sol (within deposit function) // ... // uint32 insertedIndex = _insert(_commitment); // s_commitments[_commitment] = true; // Mark commitment as used // 5. Emit an event with the commitment, its index, and timestamp emit Deposit(_commitment, insertedIndex, block.timestamp); ``` **Rationale for Event Parameters:** * `_commitment`: This is the actual leaf data added to the tree. Off-chain clients collect these commitments. Directly iterating through on-chain mappings (like `s_filledSubtrees` or a conceptual `s_leaves` array if it existed for all leaves) from an off-chain application is inefficient or impossible. Events provide a direct data feed. * `insertedIndex`: This tells the off-chain client the exact position (index) of the `_commitment` within the Merkle tree's leaves. This is crucial for correctly placing the leaf when reconstructing the tree off-chain. * `block.timestamp`: Including the timestamp helps in correctly ordering deposits, especially if events are fetched or processed in batches or if there's any ambiguity in transaction ordering from the event consumer's perspective. ### Full `deposit` Function Structure Putting it all together, the `deposit` function in `Mixer.sol` will look like this: ```solidity // src/Mixer.sol function deposit(bytes32 _commitment) external payable { // 1. Check if the commitment has already been used if (s_commitments[_commitment]) { revert("Mixer: Commitment already added."); // Or use custom error } // 2. Check if the amount of ETH sent is the correct denomination if (msg.value != DENOMINATION) { revert("Mixer: Deposit amount not correct."); // Or use custom error } // 3. Add the commitment to the on-chain incremental Merkle tree // This calls the _insert function inherited from IncrementalMerkleTree uint32 insertedIndex = _insert(_commitment); // 4. Mark the commitment as used to prevent re-depositing the same commitment s_commitments[_commitment] = true; // 5. Emit an event with the commitment, its index, and timestamp emit Deposit(_commitment, insertedIndex, block.timestamp); } ``` This function now correctly handles deposits, updates the on-chain Merkle tree, and provides the necessary information for off-chain proof generation via the `Deposit` event. ## Understanding Key Concepts for ZK-Mixer Deposits Several core concepts underpin the functionality of the `deposit` process in a ZK-mixer. Understanding these is crucial for appreciating the design and security of the system. * **Incremental Merkle Tree:** This data structure is at the heart of the mixer. It allows for efficient addition of new leaves (commitments) and recalculation of the Merkle root. Instead of recomputing the entire tree from scratch with each new deposit, only the path from the new leaf to the root needs updating, significantly saving gas. * **Off-Chain Merkle Tree Reconstruction:** Users (or relayers acting on their behalf) need to generate zero-knowledge proofs to withdraw their funds. These proofs typically require demonstrating knowledge of a secret corresponding to a commitment within the Merkle tree, along with the Merkle path to that commitment. Building and maintaining a full copy of the Merkle tree off-chain is necessary because storing and retrieving all tree data on-chain for every user's proof generation would be prohibitively expensive. * **Events for Off-Chain Data Synchronization:** Smart contract storage, particularly mappings, are not easily iterable from external applications. Events (like our `Deposit` event) serve as a broadcast mechanism. Off-chain services listen for these events to collect all deposited commitments and their indices, allowing them to accurately reconstruct and maintain their local copy of the Merkle tree in sync with the on-chain state. * **Fixed Denomination:** The `DENOMINATION` check (`msg.value == DENOMINATION`) is a critical privacy feature. If users could deposit and withdraw arbitrary amounts, these amounts could become a linking factor, potentially deanonymizing transactions. By enforcing a fixed denomination (e.g., 1 ETH, 10 ETH), all deposits and withdrawals within that pool are fungible from an amount perspective, enhancing privacy. * **Poseidon Hash Function:** The choice of hash function is vital in ZK-SNARK applications. Poseidon is specifically designed to be "SNARK-friendly," meaning it's efficient to compute within a zero-knowledge circuit. This is essential because the Merkle tree computations (hashing pairs of nodes) will be part of the ZK proof for withdrawals. ## Important Considerations and Best Practices A few additional notes and tips are relevant when working with the `deposit` function and the Merkle tree integration: * **`_insert` Function Visibility:** The `_insert` function in `IncrementalMerkleTree.sol` is marked `internal`. This visibility means it can be called by the contract itself and by contracts that inherit from it, such as our `Mixer.sol`. It cannot be called directly by external accounts or other non-derived contracts. * **`s_commitments` Mapping for Uniqueness:** The `mapping(bytes32 => bool) public s_commitments;` in `Mixer.sol` plays a crucial role in preventing replay attacks or accidental double-deposits of the same commitment. Before a new commitment is added to the Merkle tree, the `deposit` function checks this mapping. If `s_commitments[_commitment]` is `true`, it means the commitment has already been processed, and the transaction reverts. After a successful insertion, `s_commitments[_commitment]` is set to `true`. * **Error Handling:** Using `revert` with descriptive messages or, even better, custom errors (e.g., `revert Mixer_CommitmentAlreadyAdded(_commitment);`) improves the developer and user experience by providing clear reasons for transaction failures. With the `deposit` function now complete, including robust Merkle tree integration and event emission, the ZK-mixer is ready to accept user commitments. The next logical step involves developing the `withdraw` function, which will leverage the Merkle tree and require users to provide zero-knowledge proofs.
To complete the functionality of our ZK-mixer, we first need to finalize the _insert
function within the IncrementalMerkleTree.sol
contract. This function is responsible for adding a new leaf (commitment) to our Merkle tree and updating its structure. After the core loop that calculates hashes up the tree levels, two crucial steps remain: storing the new Merkle root and managing the leaf index.
Once a new leaf is inserted and its impact has been propagated up the tree through iterative hashing, the currentHash
variable holds the newly computed root of the entire Merkle tree. This new root must be persisted in the contract's state. We achieve this by assigning currentHash
to the state variable s_root
. This s_root
represents the most up-to-date state of our Merkle tree, incorporating all previously deposited commitments.
For each new leaf, we need to track its position in the tree. The s_nextLeafIndex
state variable stores the index where the next leaf will be inserted.
Initially, within the _insert
function, we capture the current value of s_nextLeafIndex
into a local variable, let's call it _insertedLeafIndex
. This _insertedLeafIndex
represents the index at which the current leaf is being inserted.
After successfully inserting the leaf and updating the root, we must increment s_nextLeafIndex
so it's ready for the subsequent insertion:
Crucially, the Mixer
contract (which will call this _insert
function) needs to know the index at which the current leaf was actually inserted. This information is vital for the Deposit
event, which helps off-chain services reconstruct the tree. Therefore, the _insert
function is modified to return the _insertedLeafIndex
before it was incremented to become s_nextLeafIndex
.
The updated function signature and return statement look like this:
This ensures that the caller receives the exact index of the leaf that was processed in that particular call to _insert
.
With the IncrementalMerkleTree.sol
contract's _insert
function finalized, we can now integrate this Merkle tree logic into our main Mixer.sol
contract. This involves importing necessary contracts, setting up inheritance, and modifying the constructor.
The Mixer.sol
contract needs to be aware of IncrementalMerkleTree
and the hashing library it uses (e.g., Poseidon2
). We also include IVerifier
for context, as it's typically part of a ZK-mixer system.
To directly use the Merkle tree functionalities, the Mixer
contract will inherit from IncrementalMerkleTree
. This makes all public
and internal
functions and state variables of IncrementalMerkleTree
available within Mixer
.
The IncrementalMerkleTree
contract has its own constructor that requires parameters like the tree depth and an instance of the hasher. When Mixer
inherits from IncrementalMerkleTree
, its constructor must call the parent contract's constructor with the necessary arguments.
The Mixer
constructor will now accept _merkleTreeDepth
and a Poseidon2 _hasher
instance, passing them along to the IncrementalMerkleTree
constructor.
This setup ensures that when a Mixer
contract is deployed, the underlying IncrementalMerkleTree
is also properly initialized.
The deposit
function is central to the mixer's operation, allowing users to add their commitments (shielded notes) to the Merkle tree. We'll now finalize this function by incorporating the Merkle tree insertion and emitting a crucial event for off-chain processes.
When a user calls deposit
with their _commitment
, this commitment needs to be inserted as a leaf into the Merkle tree. Since Mixer
now inherits from IncrementalMerkleTree
, we can directly call the _insert
function (which is internal
). This function will handle the hashing, update the tree structure, store the new root, and return the index at which the commitment was inserted.
The insertedIndex
variable now holds the leaf index of the newly deposited commitment.
After successfully inserting the commitment, it's vital to emit an event. Smart contract events are the primary mechanism for off-chain services to listen to on-chain activity and synchronize their state. The Deposit
event will provide the necessary information for users or relayers to reconstruct the Merkle tree off-chain, which is essential for generating withdrawal proofs.
The event definition should include the commitment itself, its index in the tree, and a timestamp.
Within the deposit
function, after the _insert
call, we emit this event:
Rationale for Event Parameters:
_commitment
: This is the actual leaf data added to the tree. Off-chain clients collect these commitments. Directly iterating through on-chain mappings (like s_filledSubtrees
or a conceptual s_leaves
array if it existed for all leaves) from an off-chain application is inefficient or impossible. Events provide a direct data feed.
insertedIndex
: This tells the off-chain client the exact position (index) of the _commitment
within the Merkle tree's leaves. This is crucial for correctly placing the leaf when reconstructing the tree off-chain.
block.timestamp
: Including the timestamp helps in correctly ordering deposits, especially if events are fetched or processed in batches or if there's any ambiguity in transaction ordering from the event consumer's perspective.
deposit
Function StructurePutting it all together, the deposit
function in Mixer.sol
will look like this:
This function now correctly handles deposits, updates the on-chain Merkle tree, and provides the necessary information for off-chain proof generation via the Deposit
event.
Several core concepts underpin the functionality of the deposit
process in a ZK-mixer. Understanding these is crucial for appreciating the design and security of the system.
Incremental Merkle Tree: This data structure is at the heart of the mixer. It allows for efficient addition of new leaves (commitments) and recalculation of the Merkle root. Instead of recomputing the entire tree from scratch with each new deposit, only the path from the new leaf to the root needs updating, significantly saving gas.
Off-Chain Merkle Tree Reconstruction: Users (or relayers acting on their behalf) need to generate zero-knowledge proofs to withdraw their funds. These proofs typically require demonstrating knowledge of a secret corresponding to a commitment within the Merkle tree, along with the Merkle path to that commitment. Building and maintaining a full copy of the Merkle tree off-chain is necessary because storing and retrieving all tree data on-chain for every user's proof generation would be prohibitively expensive.
Events for Off-Chain Data Synchronization: Smart contract storage, particularly mappings, are not easily iterable from external applications. Events (like our Deposit
event) serve as a broadcast mechanism. Off-chain services listen for these events to collect all deposited commitments and their indices, allowing them to accurately reconstruct and maintain their local copy of the Merkle tree in sync with the on-chain state.
Fixed Denomination: The DENOMINATION
check (msg.value == DENOMINATION
) is a critical privacy feature. If users could deposit and withdraw arbitrary amounts, these amounts could become a linking factor, potentially deanonymizing transactions. By enforcing a fixed denomination (e.g., 1 ETH, 10 ETH), all deposits and withdrawals within that pool are fungible from an amount perspective, enhancing privacy.
Poseidon Hash Function: The choice of hash function is vital in ZK-SNARK applications. Poseidon is specifically designed to be "SNARK-friendly," meaning it's efficient to compute within a zero-knowledge circuit. This is essential because the Merkle tree computations (hashing pairs of nodes) will be part of the ZK proof for withdrawals.
A few additional notes and tips are relevant when working with the deposit
function and the Merkle tree integration:
_insert
Function Visibility: The _insert
function in IncrementalMerkleTree.sol
is marked internal
. This visibility means it can be called by the contract itself and by contracts that inherit from it, such as our Mixer.sol
. It cannot be called directly by external accounts or other non-derived contracts.
s_commitments
Mapping for Uniqueness: The mapping(bytes32 => bool) public s_commitments;
in Mixer.sol
plays a crucial role in preventing replay attacks or accidental double-deposits of the same commitment. Before a new commitment is added to the Merkle tree, the deposit
function checks this mapping. If s_commitments[_commitment]
is true
, it means the commitment has already been processed, and the transaction reverts. After a successful insertion, s_commitments[_commitment]
is set to true
.
Error Handling: Using revert
with descriptive messages or, even better, custom errors (e.g., revert Mixer_CommitmentAlreadyAdded(_commitment);
) improves the developer and user experience by providing clear reasons for transaction failures.
With the deposit
function now complete, including robust Merkle tree integration and event emission, the ZK-mixer is ready to accept user commitments. The next logical step involves developing the withdraw
function, which will leverage the Merkle tree and require users to provide zero-knowledge proofs.
A constructive guide to Finalizing the Merkle Tree Insertion Logic - Master the completion of the Merkle tree's `_insert` function, covering new root storage and leaf index handling. See this logic integrated into the Mixer contract, enabling the `deposit` function and event emission for off-chain tree reconstruction.
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