5/5
## Finalizing the ZK-Mixer Withdraw Function This lesson focuses on implementing the crucial final steps of the `withdraw` function within a Solidity smart contract for a Zero-Knowledge Mixer. Our primary objectives are to: correctly verify a user-provided zero-knowledge proof, securely transfer funds to the intended recipient, and meticulously log the transaction using a smart contract event. These steps are vital for ensuring the privacy and security of the mixer. ## Interfacing with the ZK Proof Verifier To verify zero-knowledge proofs, our `Mixer` contract needs to communicate with a dedicated `Verifier` smart contract. This `Verifier` contract houses the complex cryptographic logic required to validate proofs. To enable this interaction, we first import its interface, `IVerifier`. * **Code:** ```solidity // Mixer.sol import {IVerifier} from "./Verifier.sol"; // Assumes Verifier.sol contains the IVerifier interface definition ``` This import statement makes the `IVerifier` interface available within our `Mixer.sol` file. The `Mixer` contract will then use an instance of this interface, typically stored in an `immutable` state variable named `i_verifier` (initialized in the constructor with the deployed `Verifier` contract's address), to call the necessary verification functions, most notably `verifyProof`. Using an immutable variable for the verifier's address is a good practice for security and gas efficiency, as its value is set once at deployment and cannot be changed. ## Crafting Public Inputs for Proof Verification Zero-knowledge proof verification isn't just about the proof itself; it also requires a set of `publicInputs`. These inputs are crucial pieces of information that the proof attests to, and they must be provided to the `Verifier` contract in the exact order and format expected by the underlying ZK circuit (e.g., a `main.nr` file for a Noir-based circuit). Within the `withdraw` function, we'll construct an array named `publicInputs` of type `bytes32[]` in memory. For our ZK-Mixer, the typical public inputs are: 1. `_root`: The Merkle root of the commitments set at the time the user's note was valid. 2. `_nullifierHash`: The unique hash derived from the user's secret, used to prevent double-spending. 3. `_recipient`: The address designated to receive the withdrawn funds, which needs to be converted to a `bytes32` type. * **Code for `publicInputs`:** ```solidity // Inside the withdraw function, before proof verification bytes32[] memory publicInputs = new bytes32[](3); // Array for 3 public inputs publicInputs[0] = _root; publicInputs[1] = _nullifierHash; // Convert recipient address to bytes32 publicInputs[2] = bytes32(uint256(uint160(_recipient))); ``` It's critical to consult the ZK circuit's definition (e.g., the `main.nr` file) to confirm the precise order and expected data types of these public inputs. As shown, the `_recipient` address undergoes a type conversion: `address` -> `uint160` -> `uint256` -> `bytes32` to match the circuit's expectations. ## Validating the Zero-Knowledge Proof With the `publicInputs` prepared, we can now proceed to the core security check: verifying the zero-knowledge proof provided by the user. This is done by calling the `verifyProof` function on our `i_verifier` contract instance. * **Action:** The `verifyProof` function is invoked, passing the user-supplied `_proof` (a `bytes` argument) and the `publicInputs` array we just constructed. * **Error Handling:** If the `verifyProof` function returns `false`, it signifies that the proof is invalid. In this scenario, the transaction must be reverted to prevent unauthorized withdrawal. We use a custom error, `Mixer__InvalidProof`, for clarity and gas efficiency. * **Code for Proof Verification:** ```solidity // Inside the withdraw function if (!i_verifier.verifyProof(_proof, publicInputs)) { revert Mixer__InvalidProof(); } ``` The `Mixer__InvalidProof` custom error is defined at the contract level for use here: ```solidity // At the contract level error Mixer__InvalidProof(); ``` ## Securely Transferring Funds to the Recipient Once the zero-knowledge proof has been successfully validated, and after ensuring the nullifier has been marked as used (e.g., `s_nullifierHashes[_nullifierHash] = true;` – a step assumed to be completed just before or after proof verification to prevent reentrancy issues related to the nullifier check itself), we can confidently send the funds to the specified `_recipient`. For sending Ether, instead of using `_recipient.transfer(DENOMINATION)`, which has gas limitations (2300 gas stipend) and is generally discouraged for its potential to break composability or fail due to recipient contract logic, we employ a low-level `call`. * **Parameters for `call`:** * `value: DENOMINATION`: Specifies the amount of Ether to send, which is a fixed `DENOMINATION` for the mixer. * `""`: An empty byte string for `calldata`, as this is a simple Ether transfer with no function execution intended on the recipient's side. * **Error Handling:** The low-level `call` returns a boolean `success` flag. It's imperative to check this flag. If `success` is `false`, the Ether transfer failed, and the transaction must be reverted. We use another custom error, `Mixer__PaymentFailed`, for this. * **Code for Sending Funds:** ```solidity // Inside the withdraw function, after proof verification and marking nullifier as used (bool success, ) = _recipient.call{value: DENOMINATION}(""); // Send DENOMINATION ETH to _recipient if (!success) { revert Mixer__PaymentFailed(_recipient, DENOMINATION); } ``` The `Mixer__PaymentFailed` custom error, which includes the recipient and amount for better diagnostics, is defined at the contract level: ```solidity // At the contract level error Mixer__PaymentFailed(address recipient, uint256 amount); ``` ## Logging Withdrawals with Events To enable off-chain services and user interfaces to track successful withdrawals, we emit an event. Events are a crucial mechanism for smart contracts to log significant actions on the blockchain in an efficient and queryable manner. * **Event Definition:** We define a `Withdrawal` event that includes key information about the transaction. ```solidity // At the contract level event Withdrawal(address indexed recipient, bytes32 indexed nullifierHash); ``` Notice the `indexed` keyword used for both `recipient` and `nullifierHash`. Indexing event parameters allows clients (like block explorers or dApp frontends) to filter and search for these events much more efficiently based on the values of these indexed fields. For comparison, a `Deposit` event might look like this: ```solidity event Deposit(bytes32 indexed commitment, uint32 insertedIndex, uint256 timestamp); // In some designs, `insertedIndex` might initially be considered for indexing, // but later changed to non-indexed if filtering by it is less common or if gas costs for indexing are a concern. ``` The decision to index depends on the expected query patterns for the event. * **Emitting the Event:** After the funds have been successfully sent, we emit the `Withdrawal` event. ```solidity // At the end of the withdraw function, after funds are successfully sent emit Withdrawal(_recipient, _nullifierHash); ``` ## Recap: The Complete `withdraw` Function Flow To summarize, a complete and secure `withdraw` function in our ZK-Mixer would follow these logical steps: 1. **Root Check:** Verify that the `_root` provided as part of the public inputs for the proof matches a known valid Merkle root currently recognized by the contract (e.g., `require(_root == s_root, "Root not valid");` or a more sophisticated check against a list of historic roots if applicable). 2. **Nullifier Check:** Ensure the `_nullifierHash` has not already been used to prevent double-spending (e.g., `require(!s_nullifierHashes[_nullifierHash], "Nullifier already spent");`). 3. **Construct Public Inputs:** Assemble the `publicInputs` array in the correct order: `_root`, `_nullifierHash`, and the `_recipient` (converted to `bytes32`). 4. **Verify ZK Proof:** Call `i_verifier.verifyProof(_proof, publicInputs)`. If the proof is invalid, revert the transaction (e.g., `revert Mixer__InvalidProof()`). 5. **Mark Nullifier Used:** Record the `_nullifierHash` as spent to prevent its reuse (e.g., `s_nullifierHashes[_nullifierHash] = true;`). This step is crucial and its placement (before or after fund transfer) needs careful consideration regarding reentrancy. Typically, state changes like this are done before external calls. 6. **Send Funds:** Transfer the `DENOMINATION` amount of ETH to the `_recipient` using a low-level `call`. If the transfer fails, revert the transaction (e.g., `revert Mixer__PaymentFailed(_recipient, DENOMINATION)`). 7. **Emit Event:** Log the successful transaction by emitting the `Withdrawal` event, including the `_recipient` and `_nullifierHash`. ## Key Concepts in Review This lesson touched upon several fundamental concepts critical to understanding ZK-Mixers and secure Solidity development: * **Zero-Knowledge Proofs (ZKPs):** The cornerstone of the mixer's privacy, allowing users to prove they possess a valid, unspent deposit note without revealing the specific deposit, thus breaking the link between deposit and withdrawal addresses. * **Verifier Contract:** An external smart contract dedicated to the computationally intensive task of verifying ZKPs, separating cryptographic concerns from the main mixer logic. * **Public Inputs:** Data that is publicly known and provided alongside a ZK proof. The proof attests to a statement involving these public inputs and some private (witness) data. Correctness and order are paramount. * **Nullifiers:** Unique identifiers derived from a user's secret deposit information. Publishing a nullifier hash during withdrawal prevents the same deposit note from being spent multiple times. * **Low-Level `call`:** A flexible method in Solidity for sending Ether and interacting with other contracts. It provides more control than `transfer()` or `send()` but requires explicit checking of the return status to handle potential failures. * **Custom Errors:** Introduced in Solidity 0.8.4, custom errors (`error MyError();`) are a more gas-efficient and expressive way to signal failure conditions compared to `require` statements with string messages. * **Events and Indexing:** Events provide a mechanism for smart contracts to log significant actions, making this data accessible to off-chain applications. Indexing event parameters significantly improves the performance of querying and filtering these logs. * **Immutability:** Using `immutable` for variables like the `Verifier` contract address (`i_verifier`) ensures they are set only once at deployment, enhancing security and saving gas on subsequent reads. ## A Teaser: Identifying Potential Issues While the steps outlined above construct a largely functional `withdraw` mechanism, advanced smart contract development always involves scrutinizing for potential vulnerabilities or areas for improvement. The current implementation, as described, might harbor a "little problem." Consider potential issues such as reentrancy vulnerabilities (especially around the order of operations like nullifier marking and external calls), front-running opportunities, or other subtle security or design flaws. Identifying and addressing these is key to building robust and truly secure smart contracts. These considerations will often be explored in subsequent, more advanced discussions.
An essential guide to Finalizing the ZK-Mixer Withdraw Function - Learn to implement critical ZK-Mixer withdrawal steps in Solidity: verifying proofs, using low-level calls for fund transfers, and emitting events. This lesson details crafting public inputs for the Verifier contract and implementing robust custom error handling.
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