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.
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.
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:
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.
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:
_root
: The Merkle root of the commitments set at the time the user's note was valid.
_nullifierHash
: The unique hash derived from the user's secret, used to prevent double-spending.
_recipient
: The address designated to receive the withdrawn funds, which needs to be converted to a bytes32
type.
Code for publicInputs
:
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.
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:
The Mixer__InvalidProof
custom error is defined at the contract level for use here:
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:
The Mixer__PaymentFailed
custom error, which includes the recipient and amount for better diagnostics, is defined at the contract level:
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.
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:
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.
withdraw
Function FlowTo summarize, a complete and secure withdraw
function in our ZK-Mixer would follow these logical steps:
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).
Nullifier Check: Ensure the _nullifierHash
has not already been used to prevent double-spending (e.g., require(!s_nullifierHashes[_nullifierHash], "Nullifier already spent");
).
Construct Public Inputs: Assemble the publicInputs
array in the correct order: _root
, _nullifierHash
, and the _recipient
(converted to bytes32
).
Verify ZK Proof: Call i_verifier.verifyProof(_proof, publicInputs)
. If the proof is invalid, revert the transaction (e.g., revert Mixer__InvalidProof()
).
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.
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)
).
Emit Event: Log the successful transaction by emitting the Withdrawal
event, including the _recipient
and _nullifierHash
.
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.
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