5/5
This lesson provides a comprehensive review of the `zk-mixer` project, specifically detailing the `withdraw` function within the `Mixer.sol` smart contract and its corresponding ZK-SNARK circuit (`main.nr`). A thorough understanding of these components is essential as we prepare to develop a robust testing suite for the entire system. ## Dissecting the `withdraw` Function in `Mixer.sol` The `withdraw` function in the `Mixer.sol` smart contract is the cornerstone of enabling users to privately retrieve their funds from the mixer. It meticulously performs a series of checks and operations to ensure security and correctness. **Core Responsibilities:** * Validates the withdrawal proof against a known Merkle root. * Prevents double-spending using nullifier hashes. * Prepares public inputs for the ZK-SNARK verifier. * Verifies the ZK-SNARK proof. * Marks the nullifier as spent. * Transfers funds to the recipient. * Emits an event for off-chain tracking. Let's examine each step in detail: ### Known Root Check Before proceeding with a withdrawal, the contract verifies that the Merkle root (`_root`) provided with the withdrawal proof is a recognized, historical root stored on-chain. This root corresponds to the state of the Merkle tree at the time the proof was generated. ```solidity // In Mixer.sol // check that the root that was used in the proof matches the root on-chain if (!isKnownRoot(_root)) { revert Mixer_UnknownRoot(_root); } ``` The `isKnownRoot` function, located in the `IncrementalMerkleTree.sol` contract, iterates through the `s_roots` array (a circular buffer storing historical roots) to find a match for the provided `_root`. ```solidity // In IncrementalMerkleTree.sol function isKnownRoot(bytes32 _root) public view returns (bool) { // ... // Logic to iterate through s_roots circular buffer: // Initialize i to (_currentRootIndex + 1) % s_roots.length if _currentRootIndex is where the next root will be written. // Or, more directly, iterate from 0 to s_roots.length, checking against filled roots up to _currentRootIndex, // handling the circular buffer nature. // The provided snippet implies a do-while loop structure. uint256 i = _currentRootIndex; // Assuming _currentRootIndex points to the latest valid root uint256 rootsLen = s_roots.length; if (rootsLen == 0) return false; // Or however an empty/uninitialized tree is handled // Iterate through filled roots in the circular buffer // This example assumes s_roots stores the last N roots and _currentRootIndex points to the last written one. // The exact iteration logic depends on how s_roots and _currentRootIndex are managed. // A common pattern is to iterate backwards from the current root index. // For simplicity, let's assume direct iteration for demonstration if full context isn't available. // The provided summary's "do ... while (i != _currentRootIndex);" suggests a specific circular buffer traversal. // A simplified conceptual representation without the full circular buffer logic details: for (uint256 j = 0; j < rootsLen; j++) { // This conceptual loop needs to be adapted to the actual circular buffer // The actual implementation in the video/project uses a specific do-while: // uint8 i = _currentRootIndex; // do { // i = (i == 0) ? (uint8(s_roots.length - 1)) : (i - 1); // Iterate backwards // if (_root == s_roots[i]) { // return true; // } // } while (i != _currentRootIndex); // return false; // The summary's version: // do { // if (_root == s_roots[i]) { // return true; // } // ... logic to iterate through s_roots circular buffer // } while (i != _currentRootIndex); // return false; // Let's use the summary's logic directly. // Assuming 'i' is initialized appropriately before this loop based on _currentRootIndex and buffer state. // For this lesson, we'll use the provided snippet's core check: // The full iteration logic for a circular buffer is complex without seeing its full implementation. // We'll focus on the check itself: // if (_root == s_roots[j]) { // j would be the index in the actual loop // return true; // } } // The video's snippet is: // ... // do { // if (_root == s_roots[i]) { // return true; // } // ... logic to iterate through s_roots circular buffer // } while (i != _currentRootIndex); // return false; // Based on the provided summary's code block for IncrementalMerkleTree.sol: uint256 localCurrentRootIndex = _currentRootIndex; // Or however the starting point is determined uint256 rootsLength = s_roots.length; if (rootsFilled < 1) return false; // Assuming rootsFilled tracks the number of roots actually in the buffer for (uint256 k = 0; k < rootsFilled; ++k) { uint256 index = (localCurrentRootIndex + rootsLength - 1 - k) % rootsLength; // Iterate backwards for recent roots if (_root == s_roots[index]) { return true; } } return false; } ``` *Note: The exact implementation of `isKnownRoot`'s iteration depends on how `s_roots` (circular buffer) and `_currentRootIndex` are managed. The key is that it checks against historical roots.* ### Nullifier Hash Check (Double Spending Prevention) To prevent the same deposit from being withdrawn multiple times (double-spending), the contract checks if the provided `_nullifierHash` has already been recorded. ```solidity // In Mixer.sol // check that the nullifier has not yet been used to prevent double spending if (s_nullifierHashes[_nullifierHash]) { revert Mixer_NullifierAlreadyUsed(_nullifierHash); } ``` The `s_nullifierHashes` is a mapping that stores `true` for any nullifier hash that has been spent. ### Public Inputs Preparation for Verifier The ZK-SNARK verifier contract requires specific public inputs to validate a proof. These are prepared as an array: ```solidity // In Mixer.sol // check that the proof is valid by calling the verifier contract bytes32[] memory publicInputs = new bytes32[](3); publicInputs[0] = _root; publicInputs[1] = _nullifierHash; publicInputs[2] = bytes32(uint256(uint160(_recipient))); // convert address to bytes32 ``` The public inputs include: 1. `_root`: The Merkle root against which the proof was generated. 2. `_nullifierHash`: The unique hash identifying the spent note. 3. `_recipient`: The address designated to receive the funds. Including the recipient as a public input is crucial for preventing front-running attacks. Without it, an attacker could intercept a valid proof and use it to withdraw funds to their own address. ### Proof Verification The core of the withdrawal's privacy and security lies in verifying the ZK-SNARK proof. The `verify` function of the deployed `i_verifier` contract (generated from the Noir circuit) is called with the `_proof` and the prepared `publicInputs`. ```solidity // In Mixer.sol if (!i_verifier.verify(_proof, publicInputs)) { revert Mixer_InvalidProof(); } ``` If the proof is invalid (i.e., the cryptographic proof doesn't hold true for the given public inputs), the transaction reverts. ### Marking Nullifier as Used Upon successful proof verification, the `_nullifierHash` is marked as spent by setting its entry in the `s_nullifierHashes` mapping to `true`. ```solidity // In Mixer.sol s_nullifierHashes[_nullifierHash] = true; ``` This ensures that this specific nullifier cannot be used for future withdrawals. ### Sending Funds With all checks passed and the proof verified, the contract sends the predetermined `DENOMINATION` of cryptocurrency to the `_recipient` address. A low-level `call` is used for this transfer. ```solidity // In Mixer.sol // send them the funds (bool success, ) = _recipient.call{value: DENOMINATION}(""); if (!success) { revert Mixer_PaymentFailed(_recipient, DENOMINATION); } ``` If the transfer fails, the transaction reverts. ### Emitting Withdrawal Event Finally, a `Withdrawal` event is emitted to log the successful withdrawal, including the recipient and the nullifier hash. ```solidity // In Mixer.sol emit Withdrawal(_recipient, _nullifierHash); ``` This event can be used by off-chain services or users to track withdrawal activity. ## Understanding the ZK-SNARK Withdrawal Circuit (`main.nr`) The ZK-SNARK circuit, written in Noir (`main.nr`), defines the computational statements that are proven true without revealing the private inputs. This circuit is compiled to generate the `Verifier.sol` smart contract used on-chain. The `main` function in the circuit outlines these statements: ### Public Inputs These inputs are known to both the prover (who generates the proof) and the verifier (the smart contract). ```noir // In src/main.nr fn main( // Public Inputs root: pub Field, nullifier_hash: pub Field, recipient: pub Field, // ... private inputs follow ``` * `root: pub Field`: The Merkle root of the tree containing the user's commitment. * `nullifier_hash: pub Field`: The hash of the unique nullifier, used to prevent double-spending. * `recipient: pub Field`: The address of the withdrawal recipient, crucial for preventing front-running. ### Private Inputs (Witnesses) These inputs are known only to the prover and are kept secret from the verifier and the public. ```noir // In src/main.nr (continued from public inputs) // Private Inputs nullifier: Field, secret: Field, merkle_proof: [Field; 20], is_even: [bool; 20] ) { // ... circuit logic follows ``` * `nullifier: Field`: A unique secret value chosen by the user for their deposit. * `secret: Field`: Another secret value chosen by the user, combined with the `nullifier` to create the deposit commitment. * `merkle_proof: [Field; 20]`: An array of sibling hashes along the Merkle path from the user's commitment (leaf) to the `root`. The size `20` indicates a Merkle tree of depth 20. * `is_even: [bool; 20]`: An array of booleans indicating, for each step of the Merkle path computation, whether the current hash is on the left (even index position relative to its sibling) or right (odd index position). This is vital for correctly ordering hashes when reconstructing the Merkle root. ### Circuit Logic The circuit enforces several constraints: 1. **Compute Commitment:** The circuit first recomputes the commitment using the private `nullifier` and `secret`. This commitment must match the one originally deposited. ```noir // compute the commitment Poseidon(nullifier, secret) let commitment: Field = poseidon2::hash([nullifier, secret], message_size: 2); ``` 2. **Check Nullifier Hash:** The circuit computes the hash of the private `nullifier` and asserts that it equals the public `nullifier_hash` input. ```noir // check that the nullifier matches the nullifier hash let computed_nullifier_hash: Field = poseidon2::hash([nullifier], message_size: 1); assert(computed_nullifier_hash == nullifier_hash); ``` 3. **Check Commitment is in Merkle Tree (Compute Merkle Root):** Using the computed `commitment` as the leaf, the private `merkle_proof` (sibling nodes), and the `is_even` path indicators, the circuit reconstructs the Merkle root. This is typically handled by a helper function like `merkle_tree::compute_merkle_root`. ```noir // check that the commitment is in the Merkle tree let computed_root: Field = merkle_tree::compute_merkle_root(leaf: commitment, merkle_proof, is_even); assert(computed_root == root); ``` This assertion confirms that the user's commitment is indeed part of the Merkle tree represented by the public `root`. 4. **Recipient Binding (Anti-Front-Running):** To ensure the `recipient` public input is genuinely part of the proof and not optimized away by the circuit compiler (which could otherwise allow proof replay to a different recipient if the `Verifier.sol` didn't also check it), a "recipient binding" constraint is included. This is often a simple computation involving `recipient` that must hold true, effectively forcing its inclusion in the proof's constraints. ```noir // Ensures recipient is used and tied to the proof let recipient_binding: Field = recipient * recipient; assert(recipient_binding == recipient * recipient); ``` ### The `compute_merkle_root` Helper Function This function (likely in `src/merkle_tree.nr`) is responsible for calculating the Merkle root given a leaf, the proof path, and path element position indicators. ```noir // In src/merkle_tree.nr pub fn compute_merkle_root( leaf: Field, merkle_proof: [Field; 20], is_even: [bool; 20] ) -> Field { let mut hash: Field = leaf; // Start with the leaf commitment for i: u32 in 0..20 { // Iterate 20 times for a tree of depth 20 let (left, right): (Field, Field); if is_even[i] { // Current hash is on the left, proof element is on the right left = hash; right = merkle_proof[i]; } else { // Proof element is on the left, current hash is on the right left = merkle_proof[i]; right = hash; } // Hash the ordered pair to get the parent hash for the next level hash = poseidon2::hash([left, right], message_size: 2); } hash // The final hash is the computed Merkle root } ``` This function iteratively hashes pairs of nodes up the tree—combining the current `hash` with the corresponding `merkle_proof` element at each level, ordered correctly by `is_even[i]`—until the root is computed. ## Generating the On-Chain Verifier The Noir circuit (`main.nr` and its dependencies like `merkle_tree.nr`) is compiled using tools from the Barretenberg suite (often referred to as `bb`). This compilation process generates a Solidity smart contract, `Verifier.sol`. This `Verifier.sol` contract contains the on-chain logic necessary to verify the ZK-SNARK proofs generated by users for their withdrawal requests. The `Mixer.sol` contract then calls the `verify` function of this generated `Verifier.sol`. ## The Complete zk-Mixer Flow and Our Testing Strategy Understanding the individual components allows us to see the end-to-end flow of the `zk-mixer`: **Deposit Flow:** 1. **Off-Chain:** A user generates a unique `nullifier` and a `secret`. 2. **Off-Chain:** The user computes `commitment = hash(nullifier, secret)`. 3. **On-Chain:** The user calls the `deposit` function on `Mixer.sol`, submitting the `commitment` and the required `DENOMINATION` of ETH. 4. **On-Chain:** `Mixer.sol` adds the `commitment` to its internal Merkle tree, updating the tree's root. **Withdrawal Flow:** 1. **Off-Chain (Prover):** The user, wishing to withdraw, uses their original `nullifier` and `secret`. They also require the `merkle_proof` (path from their commitment to a known root) and the `is_even` indicators for that path. 2. **Off-Chain (Prover):** Using these private inputs, along with public inputs (`root` of the Merkle tree at deposit time, `nullifier_hash = hash(nullifier)`, and the intended `recipient` address), the user generates a ZK-SNARK proof. 3. **On-Chain:** The user calls the `withdraw` function on `Mixer.sol`, providing the generated `proof`, the `root`, `nullifier_hash`, and `recipient` address. 4. **On-Chain:** `Mixer.sol` performs the checks detailed earlier: known root, unused nullifier, and then calls `Verifier.sol` to verify the proof. If all checks pass and the proof is valid, funds are sent to the `recipient`, and the nullifier is marked as spent. **Next Steps: Writing Comprehensive Tests** With this recap, we are now poised to write tests for the entire `zk-mixer` system. This critical phase will involve: * **Technology Stack:** Primarily using JavaScript, leveraging libraries such as: * **NoirJS:** To interact with our Noir circuits (e.g., compile circuits, generate witnesses, create proofs). * **BarretenbergJS (or similar interfaces to Barretenberg):** For underlying cryptographic operations like Poseidon hashing (to ensure consistency between JS and circuit computations) and potentially for proof generation/verification interactions if NoirJS relies on it. * **Testing Procedures:** 1. **Input Generation:** Programmatically generate `secret` and `nullifier` values. 2. **Commitment Calculation:** Compute `commitment` in JavaScript, ensuring the hashing mechanism (e.g., Poseidon) matches the one used in the Noir circuit. 3. **Deposit Simulation & Merkle Tree Management:** Simulate deposit operations. This will involve building and maintaining a Merkle tree structure in JavaScript to derive the necessary `merkle_proof` and `is_even` path information for withdrawals. 4. **ZK Proof Generation:** Use NoirJS to generate a valid ZK proof for a withdrawal scenario using the derived inputs. 5. **Smart Contract Interaction:** Call the `deposit` and `withdraw` functions on our deployed (or locally simulated) `Mixer.sol` contract. 6. **Assertions:** Verify that contract states change as expected (e.g., Merkle root updates, nullifiers marked as spent, balances change correctly) and that events are emitted appropriately. This testing approach will be similar to methodologies used in previous ZK application development (like the "Panagram" project, if applicable), validating the entire lifecycle from off-chain proof generation to on-chain verification and state change. Solidifying your understanding of the `withdraw` function's logic and the ZK-SNARK circuit's constraints is paramount before diving into test implementation. This foundation will enable us to build a robust and reliable zk-Mixer.
An essential deep dive into zk-Mixer Withdrawals: Solidity Logic and Noir Circuitry - Uncover the step-by-step execution of the `withdraw` function in `Mixer.sol`, from initial Merkle root checks to final fund disbursal and event emission. This lesson further deciphers the companion ZK-SNARK circuit in Noir, vital for preparing a comprehensive testing strategy.
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