5/5
## Understanding Nullifier Hashes in ZK Mixer Withdrawals Zero-Knowledge (ZK) Mixers provide a powerful way to enhance transaction privacy on blockchains. A critical component enabling this privacy, especially during the withdrawal process, is the "nullifier hash." This lesson delves into what nullifier hashes are, why they are necessary, and how they function within a ZK Mixer's smart contract to allow private withdrawals while robustly preventing double-spending. ## The Challenge of Private Withdrawals When a user deposits funds into a ZK Mixer, they typically create a "commitment"—a hash of a secret and a nullifier known only to them. This commitment is added to a Merkle tree managed by the mixer's smart contract. The challenge arises during withdrawal. If a user were to simply reveal their original commitment to withdraw funds, anyone observing the blockchain could link the withdrawal address to the deposit address (or at least to the specific deposit transaction). This would entirely negate the privacy benefits the mixer aims to provide. To overcome this, users must submit a Zero-Knowledge proof. This proof cryptographically demonstrates that they possess the necessary information (the secret and nullifier for a valid commitment within the Merkle tree) to authorize a withdrawal, all *without* revealing the commitment itself or the underlying secret and nullifier. This ensures the link between deposit and withdrawal remains obscured. ## Crafting the Withdrawal: ZK Proof Inputs The ZK proof is generated off-chain by the user. This generation process takes both private and public inputs. The resulting proof, along with the public inputs, is then submitted to the `withdraw` function of the mixer's smart contract. **Private Inputs to the ZK Circuit (known only to the user):** * `secret`: A random number generated by the user during the deposit phase. * `nullifier`: Another random number (or a value derived from the `secret`), unique to this specific deposit. Its primary role is in preventing double-spending. * `merkleProofPath`: The set of sibling nodes in the Merkle tree that, along with the commitment, can be used to reconstruct the Merkle root. This proves the commitment's existence in the tree. * `merkleProofIndices`: The indices indicating the position (left or right) of each sibling node in the `merkleProofPath`. **Public Inputs to the ZK Circuit (and subsequently to the `withdraw` smart contract function):** * `_proof` (bytes): The actual ZK-SNARK (Zero-Knowledge Succinct Non-Interactive Argument of Knowledge) proof generated by the user. * `_root` (bytes32): The Merkle root of the commitments tree against which the user generated their proof. This root must match a known, valid root on-chain at the time of withdrawal. * `_nullifierHash` (bytes32): A hash of the user's private `nullifier`. This is made public during withdrawal to prevent the same deposit from being withdrawn multiple times. * Other common public inputs: While not detailed in every simplified example, practical implementations often include `recipient` (the address to send the withdrawn funds to), `relayerFee` (if a relayer is used to submit the transaction for enhanced privacy), and `relayerAddress`. The `withdraw` function in the `Mixer.sol` smart contract would have a signature similar to this, accepting the proof and the public inputs: ```solidity // contracts/src/Mixer.sol /// @notice Withdraw funds from the mixer in a private way /// @param _proof the proof that the user has the right to withdraw (they know a valid commitment) /// @param _root the root that was used in the proof matches the root on-chain /// @param _nullifierHash the hash of the nullifier to prevent double spending function withdraw( bytes memory _proof, bytes32 _root, bytes32 _nullifierHash // Other public inputs like recipient, relayerFee etc. would also be here ) external { // 1. check that the root that was used in the proof matches the root on-chain // 2. check that the nullifier has not yet been used to prevent double spending // 3. check that the proof is valid by calling the verifier contract // 4. send them the funds } ``` ## The Nullifier Hash: Preventing Double-Spending Anonymously The `nullifier` is a unique piece of secret data associated with each deposit. To withdraw, a user must prove, via the ZK proof, that they know the `nullifier` corresponding to a legitimate, unspent deposit. However, revealing the `nullifier` itself directly on-chain during withdrawal could potentially leak information or create linkage if, for example, the nullifier was derived in a predictable way from other public data. To mitigate this, its hash, the `_nullifierHash`, is used publicly. Here's how it works: 1. The user's ZK proof generation process takes the private `nullifier` as an input. 2. Inside the ZK circuit, this `nullifier` is hashed to produce the `_nullifierHash`. This `_nullifierHash` is declared as a public output of the circuit. 3. The smart contract receives this `_nullifierHash` and records it to mark the underlying deposit as spent. Crucially, the hash function used *within the ZK circuit* to derive `_nullifierHash` from `nullifier` must be ZK-friendly. Standard Ethereum Virtual Machine (EVM) hash functions like Keccak256 (used in `abi.encodePacked` or `keccak256()`) are very computationally expensive to implement within ZK proof systems. Therefore, specialized, arithmetic-circuit-friendly hash functions like **Poseidon** are typically used for such in-circuit hashing. The ZK proof itself will contain a statement verifying that the private `nullifier` indeed hashes to the public `_nullifierHash` using this agreed-upon ZK-friendly hash function. ## On-Chain Verification: The Smart Contract's Role The `withdraw` function in the smart contract executes a series of critical checks to ensure the validity and security of the withdrawal process: **1. Merkle Root Verification:** The `_root` submitted by the user (which was a public input to their ZK proof) must correspond to a known and valid state of the mixer's Merkle tree. The contract typically stores the current Merkle root (e.g., `s_root` if using an `IncrementalMerkleTree` contract). ```solidity // In Mixer.sol // s_root is assumed to be the current root from the IncrementalMerkleTree contract error Mixer__UnknownRoot(bytes32 root); // ... inside the withdraw function: // 1. check that the root that was used in the proof matches the root on-chain if (_root != s_root) { revert Mixer__UnknownRoot(_root); } ``` This check ensures that the proof of membership (i.e., that the user's commitment is in the tree) is being validated against an authentic and up-to-date state of all deposits. **2. Double-Spending Prevention (Nullifier Hash Check):** To prevent a user from withdrawing the same deposit multiple times, the contract maintains a record of all `_nullifierHash` values that have been used. A mapping is suitable for this: ```solidity // mapping(bytes32 => bool) public s_commitments; // For tracking deposits mapping(bytes32 => bool) public s_nullifierHashes; // For tracking spent nullifiers during withdrawals ``` Before proceeding, the contract checks if the submitted `_nullifierHash` has already been recorded: ```solidity // In Mixer.sol error Mixer__NullifierAlreadyUsed(bytes32 nullifierHash); // ... inside the withdraw function, after root check: // 2. check that the nullifier has not yet been used to prevent double spending if (s_nullifierHashes[_nullifierHash]) { revert Mixer__NullifierAlreadyUsed(_nullifierHash); } ``` If `s_nullifierHashes[_nullifierHash]` is `true`, it means this nullifier (and thus the associated deposit) has already been spent, and the transaction reverts. **3. Zero-Knowledge Proof Verification:** This is the core of the privacy-preserving withdrawal. The contract calls a separate `Verifier` contract (or an internal library) to validate the `_proof` provided by the user. The `_root` and `_nullifierHash` (and any other public inputs like `recipient` or `relayerFee`) are passed to this verifier as the public statement against which the proof is checked. The `IVerifier` contract contains the logic specific to the ZK-SNARK system being used (e.g., Groth16, PLONK). A successful verification of the `_proof` by the `Verifier` contract implicitly confirms that: 1. The prover (user) knows a `secret` and a `nullifier`. 2. These `secret` and `nullifier`, when hashed together (using the mixer's commitment hash function, e.g., Poseidon), form a `commitment`. 3. This `commitment` is a member of a Merkle tree whose root is the provided `_root`. This is verified using the private `merkleProofPath` and `merkleProofIndices`. 4. The private `nullifier` correctly hashes (using the ZK-friendly hash function, e.g., Poseidon) to the publicly supplied `_nullifierHash`. A conceptual call to the verifier might look like this (the actual arguments and structure depend on the specific ZK-SNARK verifier interface): ```solidity // In Mixer.sol // Assume i_verifier is an instance of the IVerifier contract // error Mixer__InvalidProof(); // Define a custom error for invalid proofs // ... inside the withdraw function, after nullifier check: // 3. check that the proof is valid by calling the verifier contract // The public inputs (_root, _nullifierHash, recipient, etc.) are typically packed into an array. // For simplicity, showing them as direct arguments here, but often it's bytes _proof, uint256[] memory _publicInputs // The verifier.verifyProof function will need _root and _nullifierHash (and others) to be part of its public_inputs array. bool isValid = i_verifier.verifyProof( _proof, // This part is simplified; typically, public inputs are an array: // e.g., [_root, _nullifierHash, recipientAddress, fee, ...] // The exact order and content depend on the circuit's public input definition. // For this lesson's context, let's assume _root and _nullifierHash are primary. buildPublicInputsArray(_root, _nullifierHash /*, other public inputs */) ); if (!isValid) { revert("Invalid proof"); // Or: revert Mixer__InvalidProof(); } ``` *(Note: `buildPublicInputsArray` is a placeholder for the logic that correctly formats the public inputs for the verifier.)* **4. Mark Nullifier as Used and Send Funds:** If all the above checks pass (root is valid, nullifier hash is not used, and ZK proof is valid), the contract can proceed. Crucially, *before* transferring funds, the `_nullifierHash` must be marked as used to prevent re-entrancy attacks or issues if the fund transfer fails. ```solidity // ... inside the withdraw function, after successful proof verification: s_nullifierHashes[_nullifierHash] = true; // 4. send them the funds // (Logic to transfer the fixed denomination amount to the recipient address) // emit Withdrawal(recipient, amount); // Emit an event ``` Finally, the contract transfers the denomination amount of assets to the `recipient` address (which was also a public input to the ZK proof and the `withdraw` function). An event, say `Withdrawal`, is typically emitted to log the successful withdrawal on-chain (without revealing the link to the deposit). ## What the Zero-Knowledge Proof Guarantees The ZK proof is at the heart of the private withdrawal mechanism. It makes several assertions simultaneously: * **Commitment Membership:** The proof asserts that a commitment, derived from the user's secret knowledge, exists within the Merkle tree identified by the `_root` (public input). This involves checking the Merkle proof path (private inputs) against the `_root`. * **Nullifier Integrity:** The proof asserts that the private `nullifier` (known only to the user) correctly hashes to the public `_nullifierHash`. This links the withdrawal attempt to a specific deposit's potential for being spent, without revealing the deposit itself. The array of public inputs that the `Verifier.verifyProof` function checks against will typically include at least: 1. `_root`: The Merkle root against which membership is proven. 2. `_nullifierHash`: The publicly revealed hash unique to the deposit being spent. 3. Other inputs part of the public statement: `recipient`, `relayerFee`, `externalData`, etc., ensuring these are also bound to the proof. ## Why the Nullifier Hash is Crucial The nullifier hash is a cornerstone of privacy-preserving withdrawal systems in ZK Mixers. It acts as a unique "spent receipt" for a deposit, but one that cannot be linked back to the original deposit by an outside observer. * **Privacy:** By only revealing a hash of the nullifier, and by having the ZK proof ensure this hash corresponds to the secret nullifier tied to a valid deposit, user privacy is maintained. The actual link between the deposit's commitment and this nullifier hash is only known to the user and proven in zero-knowledge. * **Double-Spend Prevention:** The smart contract's simple check of whether a `_nullifierHash` has already been seen and recorded is a robust way to prevent the same funds from being withdrawn multiple times. * **Efficiency:** The choice of a ZK-friendly hash function (like Poseidon) for generating the `_nullifierHash` within the circuit is critical. This ensures that the proof generation and verification processes remain efficient, which is vital for the practicality of ZK systems. In summary, the nullifier hash, in conjunction with ZK proofs, provides an elegant solution to the problem of enabling private withdrawals from a mixer while upholding the fundamental security requirement of preventing double-spending.
Zero-Knowledge (ZK) Mixers provide a powerful way to enhance transaction privacy on blockchains. A critical component enabling this privacy, especially during the withdrawal process, is the "nullifier hash." This lesson delves into what nullifier hashes are, why they are necessary, and how they function within a ZK Mixer's smart contract to allow private withdrawals while robustly preventing double-spending.
When a user deposits funds into a ZK Mixer, they typically create a "commitment"—a hash of a secret and a nullifier known only to them. This commitment is added to a Merkle tree managed by the mixer's smart contract.
The challenge arises during withdrawal. If a user were to simply reveal their original commitment to withdraw funds, anyone observing the blockchain could link the withdrawal address to the deposit address (or at least to the specific deposit transaction). This would entirely negate the privacy benefits the mixer aims to provide.
To overcome this, users must submit a Zero-Knowledge proof. This proof cryptographically demonstrates that they possess the necessary information (the secret and nullifier for a valid commitment within the Merkle tree) to authorize a withdrawal, all without revealing the commitment itself or the underlying secret and nullifier. This ensures the link between deposit and withdrawal remains obscured.
The ZK proof is generated off-chain by the user. This generation process takes both private and public inputs. The resulting proof, along with the public inputs, is then submitted to the withdraw
function of the mixer's smart contract.
Private Inputs to the ZK Circuit (known only to the user):
secret
: A random number generated by the user during the deposit phase.
nullifier
: Another random number (or a value derived from the secret
), unique to this specific deposit. Its primary role is in preventing double-spending.
merkleProofPath
: The set of sibling nodes in the Merkle tree that, along with the commitment, can be used to reconstruct the Merkle root. This proves the commitment's existence in the tree.
merkleProofIndices
: The indices indicating the position (left or right) of each sibling node in the merkleProofPath
.
Public Inputs to the ZK Circuit (and subsequently to the withdraw
smart contract function):
_proof
(bytes): The actual ZK-SNARK (Zero-Knowledge Succinct Non-Interactive Argument of Knowledge) proof generated by the user.
_root
(bytes32): The Merkle root of the commitments tree against which the user generated their proof. This root must match a known, valid root on-chain at the time of withdrawal.
_nullifierHash
(bytes32): A hash of the user's private nullifier
. This is made public during withdrawal to prevent the same deposit from being withdrawn multiple times.
Other common public inputs: While not detailed in every simplified example, practical implementations often include recipient
(the address to send the withdrawn funds to), relayerFee
(if a relayer is used to submit the transaction for enhanced privacy), and relayerAddress
.
The withdraw
function in the Mixer.sol
smart contract would have a signature similar to this, accepting the proof and the public inputs:
The nullifier
is a unique piece of secret data associated with each deposit. To withdraw, a user must prove, via the ZK proof, that they know the nullifier
corresponding to a legitimate, unspent deposit.
However, revealing the nullifier
itself directly on-chain during withdrawal could potentially leak information or create linkage if, for example, the nullifier was derived in a predictable way from other public data. To mitigate this, its hash, the _nullifierHash
, is used publicly.
Here's how it works:
The user's ZK proof generation process takes the private nullifier
as an input.
Inside the ZK circuit, this nullifier
is hashed to produce the _nullifierHash
. This _nullifierHash
is declared as a public output of the circuit.
The smart contract receives this _nullifierHash
and records it to mark the underlying deposit as spent.
Crucially, the hash function used within the ZK circuit to derive _nullifierHash
from nullifier
must be ZK-friendly. Standard Ethereum Virtual Machine (EVM) hash functions like Keccak256 (used in abi.encodePacked
or keccak256()
) are very computationally expensive to implement within ZK proof systems. Therefore, specialized, arithmetic-circuit-friendly hash functions like Poseidon are typically used for such in-circuit hashing. The ZK proof itself will contain a statement verifying that the private nullifier
indeed hashes to the public _nullifierHash
using this agreed-upon ZK-friendly hash function.
The withdraw
function in the smart contract executes a series of critical checks to ensure the validity and security of the withdrawal process:
1. Merkle Root Verification:
The _root
submitted by the user (which was a public input to their ZK proof) must correspond to a known and valid state of the mixer's Merkle tree. The contract typically stores the current Merkle root (e.g., s_root
if using an IncrementalMerkleTree
contract).
This check ensures that the proof of membership (i.e., that the user's commitment is in the tree) is being validated against an authentic and up-to-date state of all deposits.
2. Double-Spending Prevention (Nullifier Hash Check):
To prevent a user from withdrawing the same deposit multiple times, the contract maintains a record of all _nullifierHash
values that have been used. A mapping is suitable for this:
Before proceeding, the contract checks if the submitted _nullifierHash
has already been recorded:
If s_nullifierHashes[_nullifierHash]
is true
, it means this nullifier (and thus the associated deposit) has already been spent, and the transaction reverts.
3. Zero-Knowledge Proof Verification:
This is the core of the privacy-preserving withdrawal. The contract calls a separate Verifier
contract (or an internal library) to validate the _proof
provided by the user. The _root
and _nullifierHash
(and any other public inputs like recipient
or relayerFee
) are passed to this verifier as the public statement against which the proof is checked.
The IVerifier
contract contains the logic specific to the ZK-SNARK system being used (e.g., Groth16, PLONK). A successful verification of the _proof
by the Verifier
contract implicitly confirms that:
1. The prover (user) knows a secret
and a nullifier
.
2. These secret
and nullifier
, when hashed together (using the mixer's commitment hash function, e.g., Poseidon), form a commitment
.
3. This commitment
is a member of a Merkle tree whose root is the provided _root
. This is verified using the private merkleProofPath
and merkleProofIndices
.
4. The private nullifier
correctly hashes (using the ZK-friendly hash function, e.g., Poseidon) to the publicly supplied _nullifierHash
.
A conceptual call to the verifier might look like this (the actual arguments and structure depend on the specific ZK-SNARK verifier interface):
(Note: buildPublicInputsArray
is a placeholder for the logic that correctly formats the public inputs for the verifier.)
4. Mark Nullifier as Used and Send Funds:
If all the above checks pass (root is valid, nullifier hash is not used, and ZK proof is valid), the contract can proceed. Crucially, before transferring funds, the _nullifierHash
must be marked as used to prevent re-entrancy attacks or issues if the fund transfer fails.
Finally, the contract transfers the denomination amount of assets to the recipient
address (which was also a public input to the ZK proof and the withdraw
function). An event, say Withdrawal
, is typically emitted to log the successful withdrawal on-chain (without revealing the link to the deposit).
The ZK proof is at the heart of the private withdrawal mechanism. It makes several assertions simultaneously:
Commitment Membership: The proof asserts that a commitment, derived from the user's secret knowledge, exists within the Merkle tree identified by the _root
(public input). This involves checking the Merkle proof path (private inputs) against the _root
.
Nullifier Integrity: The proof asserts that the private nullifier
(known only to the user) correctly hashes to the public _nullifierHash
. This links the withdrawal attempt to a specific deposit's potential for being spent, without revealing the deposit itself.
The array of public inputs that the Verifier.verifyProof
function checks against will typically include at least:
_root
: The Merkle root against which membership is proven.
_nullifierHash
: The publicly revealed hash unique to the deposit being spent.
Other inputs part of the public statement: recipient
, relayerFee
, externalData
, etc., ensuring these are also bound to the proof.
The nullifier hash is a cornerstone of privacy-preserving withdrawal systems in ZK Mixers. It acts as a unique "spent receipt" for a deposit, but one that cannot be linked back to the original deposit by an outside observer.
Privacy: By only revealing a hash of the nullifier, and by having the ZK proof ensure this hash corresponds to the secret nullifier tied to a valid deposit, user privacy is maintained. The actual link between the deposit's commitment and this nullifier hash is only known to the user and proven in zero-knowledge.
Double-Spend Prevention: The smart contract's simple check of whether a _nullifierHash
has already been seen and recorded is a robust way to prevent the same funds from being withdrawn multiple times.
Efficiency: The choice of a ZK-friendly hash function (like Poseidon) for generating the _nullifierHash
within the circuit is critical. This ensures that the proof generation and verification processes remain efficient, which is vital for the practicality of ZK systems.
In summary, the nullifier hash, in conjunction with ZK proofs, provides an elegant solution to the problem of enabling private withdrawals from a mixer while upholding the fundamental security requirement of preventing double-spending.
A crucial look into Understanding Nullifier Hashes in ZK Mixer Withdrawals - Uncover why nullifier hashes are vital for private ZK Mixer withdrawals and robust double-spend prevention. Learn their ZK-friendly generation, on-chain verification steps, and how they secure anonymous transactions with ZK proofs.
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