1/5
## Initializing Your ZK Mixer Project with Foundry Welcome to this lesson on building a Zero-Knowledge (ZK) Mixer. Our primary goal is to create a system that enhances user privacy by breaking the on-chain link between fund deposits and withdrawals. We'll start by setting up our project environment and initializing the smart contract part using Foundry. First, we'll organize our project files. Open your terminal and execute the following commands: 1. Create a new directory for the project: ```bash mkdir zk-mixer ``` 2. Navigate into this new directory: ```bash cd zk-mixer ``` 3. (Optional) Open this directory in your preferred code editor, such as VS Code: ```bash code . ``` With the main project directory set up, we'll now focus on the smart contracts. We're tackling the contract logic first as it often provides a clearer understanding before diving into the complexities of ZK circuits. 4. Create a subdirectory specifically for the smart contracts: ```bash mkdir contracts ``` 5. Navigate into the `contracts` directory: ```bash cd contracts ``` 6. Initialize a new Foundry project within this `contracts` subdirectory: ```bash forge init ``` This `forge init` command establishes the standard Foundry project structure, including directories like `lib` (for dependencies), `script` (for deployment scripts), `src` (for your smart contract source code), `test` (for tests), and essential configuration files like `foundry.toml`. ## Defining the Scope and Cleaning the Foundry Slate Before we begin writing our `Mixer` contract, let's clean up the default files generated by `forge init`. Remove the `Counter.sol` file from the `src` directory, `Counter.s.sol` from `script`, and `Counter.t.sol` from `test`. This gives us a clean slate. Next, we'll update the `README.md` file located inside the `contracts` directory to clearly define the scope and objectives of our ZK Mixer project: ```markdown # ZK Mixer Project - Deposit: users can deposit ETH into the mixer to break the connection between depositor and withdrawer. - Withdraw: users will withdraw using a ZK proof (Noir - generated off-chain) of knowledge of their deposit. - We will only allow users to deposit a fixed amount of ETH (0.001 ETH) ``` This README introduces several key concepts fundamental to our ZK Mixer: * **Mixer Functionality:** The core purpose is to sever the direct, traceable link between the address depositing Ether and the address withdrawing it, thereby enhancing transaction privacy. * **Zero-Knowledge Proofs (ZKPs):** For withdrawals, users must prove they have knowledge of a valid prior deposit. This is achieved using a ZK proof, specifically mentioning Noir as a potential tool for generating these proofs off-chain. The proof asserts knowledge of a secret and a nullifier associated with a deposit, without revealing the original deposit transaction itself. * **Fixed Denomination:** To simplify the mixer's design and significantly improve the anonymity set, all deposits (and subsequent withdrawals) will be of a fixed amount, for example, 0.001 ETH. When all mixed amounts are identical, it becomes much harder to correlate specific deposits and withdrawals. ## Structuring the Mixer Smart Contract (`Mixer.sol`) With the project set up and scope defined, let's create the initial structure for our `Mixer.sol` smart contract. Create a new file named `Mixer.sol` inside the `contracts/src/` directory. The basic skeleton of the contract will be as follows: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; // Forward declaration or import for IVerifier would be needed later // interface IVerifier { function verifyProof(bytes calldata proof, uint256[] calldata publicInputs) external view returns (bool); } contract Mixer { constructor() { // Constructor logic will be added later } function deposit(bytes32 _commitment) external payable { // Deposit logic will be implemented here } function withdraw( bytes memory _proof, bytes32 _merkleRoot, bytes32 _nullifierHash, address payable _recipient ) external { // Withdrawal logic will be implemented here } } ``` This initial structure includes: * An SPDX license identifier. * A Solidity pragma defining the compiler version. * An empty `contract Mixer` block. * An empty `constructor`. * An empty `deposit` function, which will handle incoming funds. * An empty `withdraw` function, which will handle outgoing funds based on ZK proofs. We've preemptively added parameters to `withdraw` that will be necessary for ZK proof verification. ## Implementing the `deposit` Function The `deposit` function is where users send ETH to the mixer, along with a cryptographic commitment. Let's define its behavior and add the necessary checks. The `deposit` function takes one parameter: * `bytes32 _commitment`: This is a hash (e.g., a Poseidon hash, which is efficient for ZK circuits) of two values: a `nullifier` and a `secret`. Both the `nullifier` and `secret` are generated off-chain by the depositor. The `secret` is kept private by the user and is essential for generating the ZK proof needed for withdrawal. The `nullifier` is also kept private until withdrawal, at which point its hash is revealed to prevent double-spending the same deposit. The function must also be `payable` to receive Ether. ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; // interface IVerifier { function verifyProof(bytes calldata proof, uint256[] calldata publicInputs) external view returns (bool); } contract Mixer { // IVerifier public immutable i_verifier; // Will be uncommented later // Stores whether a specific commitment has been deposited. // Used to prevent adding the same commitment hash directly if it was already processed. mapping(bytes32 => bool) public s_commitments; // The fixed amount of ETH for deposits/withdrawals. uint256 public constant DENOMINATION = 0.001 ether; // Custom error for attempting to add an existing commitment. error Mixer__CommitmentAlreadyAdded(bytes32 commitment); // Custom error for depositing an incorrect ETH amount. error Mixer__DepositAmountNotCorrect(uint256 amountSent, uint256 expectedAmount); /* constructor(IVerifier _verifierAddress) { // Will be uncommented later i_verifier = _verifierAddress; } */ constructor() {} // Placeholder constructor for now /// @notice Deposit funds into the mixer /// @param _commitment The Poseidon hash of the user's (off-chain generated) nullifier and secret. function deposit(bytes32 _commitment) external payable { // 1. Prevent Replay/Double Deposit of the same Commitment Hash // Check if this exact commitment hash has been processed before. if (s_commitments[_commitment]) { revert Mixer__CommitmentAlreadyAdded(_commitment); } // 2. Fixed Denomination Check // Ensure the amount of ETH sent matches the mixer's required denomination. if (msg.value != DENOMINATION) { revert Mixer__DepositAmountNotCorrect(msg.value, DENOMINATION); } // 3. Store Commitment (Initial Step) // Mark this commitment hash as processed to prevent direct replay. s_commitments[_commitment] = true; // TODO: Add _commitment to the on-chain Incremental Merkle Tree. // This data structure will hold all valid, deposited commitments and its root // will be used in the withdrawal process. // Example: _insertIntoMerkleTree(_commitment); // emit DepositEvent(_commitment, msg.sender, block.timestamp); // Event for off-chain monitoring } function withdraw( bytes memory _proof, bytes32 _merkleRoot, bytes32 _nullifierHash, address payable _recipient ) external { // Withdrawal logic will be implemented here } } ``` Key elements introduced for the `deposit` function: 1. **State Variable for Commitments:** `mapping(bytes32 => bool) public s_commitments;` is used to track if a specific commitment hash has already been processed. This prevents the same commitment from being added multiple times, which could disrupt the Merkle tree logic. 2. **Fixed Denomination Constant:** `uint256 public constant DENOMINATION = 0.001 ether;` defines the exact amount of ETH users must deposit. 3. **Custom Errors:** * `error Mixer__CommitmentAlreadyAdded(bytes32 commitment);` for attempts to deposit an already existing commitment. * `error Mixer__DepositAmountNotCorrect(uint256 amountSent, uint256 expectedAmount);` if `msg.value` does not match `DENOMINATION`. 4. **Logic Steps:** * The function first checks if the `_commitment` has already been added using the `s_commitments` mapping. * It then verifies that `msg.value` (the amount of ETH sent with the transaction) equals `DENOMINATION`. * If both checks pass, `s_commitments[_commitment]` is set to `true`. * **Crucially**, the `_commitment` must also be added to a more sophisticated data structure (like an on-chain Merkle tree) that stores all valid commitments. This structure is essential for users to prove membership during withdrawal without revealing their specific commitment directly. This step is marked as a TODO for now. ## Designing the `withdraw` Function The `withdraw` function allows users to retrieve their funds privately by submitting a ZK-SNARK proof. This proof demonstrates they know a secret and nullifier corresponding to a commitment within the set of all deposited commitments, without revealing which one. The parameters for `withdraw` are: * `bytes memory _proof`: The ZK-SNARK proof generated off-chain. * `bytes32 _merkleRoot`: The Merkle root of the commitments tree against which the proof was generated. This ensures the proof is relevant to the current state of the mixer. * `bytes32 _nullifierHash`: A hash unique to the deposit, derived from the user's secret nullifier. This is revealed during withdrawal to prevent double-spending the same deposit. * `address payable _recipient`: The address to which the withdrawn funds will be sent. This is specified by the user during proof generation. To verify the ZK proof, our `Mixer` contract will need to call an external `Verifier` contract. The address of this `Verifier` contract will be provided when the `Mixer` contract is deployed and stored in an `immutable` state variable for gas efficiency. An interface, `IVerifier`, will define how our `Mixer` interacts with the `Verifier`. Let's update our `Mixer.sol` to include the verifier logic: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; interface IVerifier { function verifyProof( bytes calldata proof, uint256[] calldata publicInputs // Array of public inputs for the ZK circuit ) external view returns (bool); } contract Mixer { IVerifier public immutable i_verifier; mapping(bytes32 => bool) public s_commitments; // Mapping to track used nullifiers to prevent double-spending mapping(bytes32 => bool) public s_usedNullifiers; uint256 public constant DENOMINATION = 0.001 ether; error Mixer__CommitmentAlreadyAdded(bytes32 commitment); error Mixer__DepositAmountNotCorrect(uint256 amountSent, uint256 expectedAmount); // Custom errors for withdrawal error Mixer__InvalidZKProof(); error Mixer__NullifierAlreadyUsed(bytes32 nullifierHash); // error Mixer__StaleMerkleRoot(); // If checking against a contract-stored root constructor(address _verifierAddress) { // Verifier address passed during deployment i_verifier = IVerifier(_verifierAddress); } function deposit(bytes32 _commitment) external payable { if (s_commitments[_commitment]) { revert Mixer__CommitmentAlreadyAdded(_commitment); } if (msg.value != DENOMINATION) { revert Mixer__DepositAmountNotCorrect(msg.value, DENOMINATION); } s_commitments[_commitment] = true; // TODO: Add _commitment to the on-chain Incremental Merkle Tree // emit DepositEvent(_commitment, msg.sender, block.timestamp); } /// @notice Withdraw funds from the mixer in a private way /// @param _proof The ZK-SNARK proof. /// @param _merkleRoot The Merkle root against which the proof was generated (public input to ZK proof). /// @param _nullifierHash A hash unique to the deposit, revealed to prevent double-spending (public input to ZK proof). /// @param _recipient The address to send the withdrawn funds to (public input to ZK proof). function withdraw( bytes memory _proof, bytes32 _merkleRoot, // This should match a known valid root in the contract bytes32 _nullifierHash, address payable _recipient ) external { // TODO: Logic for withdraw: // 1. Check Merkle Root: // require(_merkleRoot == currentMerkleRootInContract, "Stale Merkle root"); // (The currentMerkleRootInContract would be managed by the Incremental Merkle Tree) // 2. Prevent Double Spending (Nullifier Check): // if (s_usedNullifiers[_nullifierHash]) { // revert Mixer__NullifierAlreadyUsed(_nullifierHash); // } // 3. Verify ZK Proof: // The public inputs for the ZK proof typically include _merkleRoot, _nullifierHash, // _recipient, DENOMINATION, and potentially chain ID or contract address for replay protection. // uint256[] memory publicInputs = new uint256[](/* size */); // publicInputs[0] = uint256(_merkleRoot); // publicInputs[1] = uint256(_nullifierHash); // publicInputs[2] = uint256(uint160(_recipient)); // Cast recipient to uint256 // publicInputs[3] = DENOMINATION; // // ... other public inputs // // bool isValid = i_verifier.verifyProof(_proof, publicInputs); // if (!isValid) { // revert Mixer__InvalidZKProof(); // } // 4. Mark Nullifier as Used: // s_usedNullifiers[_nullifierHash] = true; // 5. Send Funds: // (bool success, ) = _recipient.call{value: DENOMINATION}(""); // require(success, "Transfer failed"); // emit WithdrawalEvent(_recipient, _nullifierHash, block.timestamp); } } ``` **Key aspects of the `withdraw` function's logic (to be fully implemented):** 1. **Merkle Root Check:** The `_merkleRoot` provided with the proof must match a known, valid Merkle root maintained by the contract (this root is updated by the Incremental Merkle Tree during deposits). 2. **Nullifier Check:** The contract verifies that the `_nullifierHash` has not been used in a previous withdrawal. The `s_usedNullifiers` mapping is introduced for this purpose. If it has been used, the transaction reverts to prevent double-spending. 3. **ZK Proof Verification:** The `i_verifier.verifyProof()` function is called with the `_proof` and an array of public inputs. These public inputs (like `_merkleRoot`, `_nullifierHash`, `_recipient`, `DENOMINATION`) are critical for the verifier to confirm the proof's validity in the context of the current transaction. 4. **Mark Nullifier:** If the proof is valid, the `_nullifierHash` is marked as used in `s_usedNullifiers`. 5. **Send Funds:** The contract transfers the `DENOMINATION` amount of ETH to the `_recipient` address. **Important Privacy Note:** The `withdraw` function *does not* take the original `_commitment` as a parameter. Passing the commitment would link the withdrawal to the deposit, defeating the mixer's privacy goal. The ZK proof and its public inputs handle the validation without this direct link. ## Managing Commitments with Merkle Trees To efficiently manage the set of all deposited commitments and enable private proof of membership, a **Merkle tree** is essential. * Each `_commitment` value from a successful deposit becomes a leaf in this Merkle tree. * The **Merkle root** of this tree is a single hash that represents the entire set of commitments. This root needs to be stored or calculable on-chain. * When a user wishes to withdraw, their ZK proof will implicitly include a Merkle proof. This demonstrates that their specific (secret) commitment is part of the tree that hashes to the current on-chain `Merkle root`, without revealing which leaf is theirs. The `_merkleRoot` parameter in the `withdraw` function is this reference root. **The Challenge with On-Chain Updates:** Standard Merkle trees are often constructed off-chain. For a smart contract like a mixer, where new deposits (and thus new leaves) are continuously added on-chain, rebuilding the entire tree off-chain and updating the root for every deposit can be inefficient or introduce centralization. **The Solution: Incremental Merkle Trees** To address this, we will use an **Incremental Merkle Tree**. * Incremental Merkle Trees are a special type of Merkle tree designed to allow efficient on-chain addition of new leaves. * When a new commitment is deposited, it can be inserted into the tree, and the Merkle root can be recalculated and updated on-chain with relatively low gas costs. The "TODO" step in our `deposit` function, `Add _commitment to the on-chain Incremental Merkle Tree`, refers to this process. The implementation of this Incremental Merkle Tree will be a crucial component of our `Mixer` contract, ensuring that the set of commitments is managed securely and efficiently on-chain. The next lesson will delve into the specifics of how Incremental Merkle Trees work and how we can integrate one into our ZK Mixer smart contract.
A foundational introduction to Initializing Your ZK Mixer Project with Foundry - Begin building your ZK Mixer by setting up the project environment with Foundry and creating the initial `Mixer.sol` smart contract. This lesson guides you through implementing the `deposit` function and outlines the design for the `withdraw` function, incorporating ZK proof verification and Merkle tree concepts.
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