5/5
## Understanding and Building a ZK Mixer Protocol for Enhanced On-Chain Privacy This lesson explores the construction of a mixer protocol that leverages zero-knowledge proofs (ZKPs), commitment schemes, and Merkle trees. The primary objective is to enable users to deposit cryptographic funds and subsequently withdraw them to a different address, all while breaking the direct on-chain link between the deposit and withdrawal transactions, significantly enhancing user privacy. ## Core Components of a ZK Mixer Protocol To achieve this privacy, several cryptographic concepts work in concert. Understanding each is crucial to grasping the protocol's mechanics. **1. Commitment Scheme:** The process begins when a user decides to deposit funds. Instead of sending funds directly, they first generate a cryptographic **commitment** off-chain. This commitment serves as a placeholder for their deposit without revealing its sensitive details. The commitment is typically a hash of two random values known only to the user: a `secret` and a `nullifier`. The formula is: `commitment = H(nullifier, secret)` For `H`, the **Poseidon hash function** is employed due to its efficiency within ZK-SNARK (Zero-Knowledge Succinct Non-Interactive Argument of Knowledge) circuits, which are a common type of ZKP. Once generated, the user sends this `commitment` to the mixer's smart contract along with the funds to be deposited. The `secret` and `nullifier` are kept private by the user. **2. Nullifier:** The `nullifier` plays a critical role in preventing a user from withdrawing the same deposit multiple times (double-spending). When a user wishes to withdraw their funds, they must reveal a `nullifier_hash`, which is the hash of their private `nullifier`: `nullifier_hash = H(nullifier)` The mixer's smart contract maintains a record of all `nullifier_hash`es that have been used for withdrawals. If a submitted `nullifier_hash` is already in this record, the withdrawal attempt is rejected. Since the original `nullifier` was part of the commitment calculation, this mechanism ensures that each unique deposit can only be withdrawn once. **3. Merkle Tree:** The smart contract organizes all valid deposited `commitments` into an **Incremental Merkle Tree**. Each commitment forms a leaf in this tree. A Merkle tree allows for efficient proof that a particular piece of data (a commitment, in this case) is part of a larger dataset without revealing the entire dataset or the specific data element. When a user initiates a withdrawal, they must prove that their `commitment` is indeed a valid one present in the tree. This is achieved by providing a Merkle proof (the path of hashes from their commitment leaf to the Merkle root) as a private input to the zero-knowledge proof. The smart contract stores historical Merkle roots, and the ZKP verifies the user's proof against one of these known, valid roots. **4. Zero-Knowledge Proof (ZKP):** For withdrawal, the user generates a ZKP off-chain. This powerful cryptographic tool allows them to prove certain statements are true without revealing the underlying secret information. The ZKP for the mixer attests to the following: * The user possesses a `secret` and a `nullifier` which, when hashed together, produce a specific `commitment`. * This `commitment` is a leaf in the Merkle tree whose root (`root`) is publicly known and provided as a public input to the proof. * The publicly provided `nullifier_hash` (also a public input) is the correct hash of the private `nullifier` associated with the commitment. * The withdrawn funds are to be sent to a specified `recipient` address (another public input). The smart contract's role is then simplified to verifying this ZKP. If the proof is valid, the contract proceeds with the withdrawal. **5. Achieving Privacy:** The combination of these elements provides privacy. Consider this scenario: Address A deposits funds into the mixer. The on-chain transaction shows ETH moving from Address A and a commitment being added to the Merkle tree. Later, the user (who could be operating from any context) generates a ZKP and initiates a withdrawal to a completely new, unlinked Address B. The ZKP proves the legitimacy of the withdrawal (i.e., it corresponds to a valid, unspent deposit) without revealing *which* specific deposit it links to. An external observer sees funds entering the mixer from various addresses and funds exiting to other addresses, but the ZKP-based mechanism breaks the direct, traceable link between individual deposit and withdrawal transactions. ## Smart Contract Implementation: `Mixer.sol` The on-chain logic of the mixer is encapsulated in a Solidity smart contract, `Mixer.sol`. This contract manages deposits, stores commitments in a Merkle tree, and verifies ZKPs for withdrawals. **`deposit(bytes32 _commitment)` function:** This function handles incoming deposits. * It accepts a `_commitment` (the `H(nullifier, secret)`) generated off-chain by the depositor. * It verifies that the `msg.value` (the amount of ETH sent with the transaction) matches a predefined `DENOMINATION`. Mixers often operate on fixed denominations to improve the anonymity set. * It inserts the `_commitment` into an on-chain `IncrementalMerkleTree` structure. This tree is often implemented as a separate library or inherited contract. * Optionally, it might store a flag (e.g., `s_commitments[_commitment] = true;`) to quickly check for commitment existence, though the Merkle tree itself serves this purpose. A simplified representation of the deposit function: ```solidity // Simplified structure contract Mixer is IncrementalMerkleTree, ReentrancyGuard { // ... state variables for verifier, hasher, denomination, s_commitments, s_nullifierHashes ... uint256 public DENOMINATION; IVerifier public i_verifier; mapping(bytes32 => bool) public s_commitments; mapping(bytes32 => bool) public s_nullifierHashes; // ... other necessary state variables and constructor ... event Deposit(bytes32 indexed commitment, uint32 leafIndex, uint256 timestamp); function deposit(bytes32 _commitment) external payable nonReentrant { if (msg.value != DENOMINATION) { revert("Mixer: Incorrect deposit amount."); } // Additional checks like commitment not already deposited might be needed // depending on how s_commitments is used or if Merkle tree handles duplicates. uint32 insertedIndex = _insert(_commitment); // From IncrementalMerkleTree s_commitments[_commitment] = true; // Or rely on Merkle tree structure for existence emit Deposit(_commitment, insertedIndex, block.timestamp); } } ``` **`withdraw(bytes memory _proof, bytes32 _root, bytes32 _nullifierHash, address payable _recipient)` function:** This function processes withdrawal requests. * It takes the ZK `_proof`, the Merkle `_root` against which the proof was generated, the public `_nullifierHash`, and the `_recipient` address for the funds. * It first checks if the provided `_root` is a known historical root of the contract's Merkle tree using a function like `isKnownRoot(_root)`. This ensures the proof is relevant to a past state of deposits. * Crucially, it checks if the `_nullifierHash` has already been used (`s_nullifierHashes[_nullifierHash]`). If true, it means this deposit has already been withdrawn, preventing double-spending. * It constructs an array of `publicInputs` for the ZKP verifier. These typically include the `_root`, `_nullifierHash`, and `_recipient` (and potentially other public parameters like `chain_id` or `fee`). * It calls the `verify` function of a separate Verifier contract ( `i_verifier.verify(_proof, publicInputs)`). This Verifier contract contains the logic to validate the ZK proof. * If the proof is valid, the contract marks the `_nullifierHash` as used (`s_nullifierHashes[_nullifierHash] = true;`). * Finally, it transfers the `DENOMINATION` amount of ETH to the specified `_recipient`. A simplified representation of the withdraw function: ```solidity // Inside Mixer.sol contract Mixer is IncrementalMerkleTree, ReentrancyGuard { // ... (assuming previous declarations and IVerifier interface) ... mapping(bytes32 => bool) public s_nullifierHashes; // Tracks used nullifiers uint256 public DENOMINATION; IVerifier public i_verifier; // Interface to the ZKP verifier contract // ... (constructor, deposit function, isKnownRoot logic) ... event Withdrawal(address to, bytes32 nullifierHash); error Mixer_UnknownRoot(bytes32 root); error Mixer_NullifierAlreadyUsed(bytes32 nullifierHash); error Mixer_InvalidProof(); error Mixer_PaymentFailed(address recipient, uint256 amount); function withdraw( bytes memory _proof, bytes32 _root, bytes32 _nullifierHash, address payable _recipient // address _relayer, // Optional for gas abstraction // uint256 _fee // Optional for gas abstraction ) external nonReentrant { // Add payable if relayer fee is deducted from contract if (!isKnownRoot(_root)) { revert Mixer_UnknownRoot(_root); } if (s_nullifierHashes[_nullifierHash]) { revert Mixer_NullifierAlreadyUsed(_nullifierHash); } // Construct public inputs for the ZKP verifier // The order and content must exactly match what the circuit expects bytes32[] memory publicInputs = new bytes32[](3); // Example: root, nullifierHash, recipient publicInputs[0] = _root; publicInputs[1] = _nullifierHash; publicInputs[2] = bytes32(uint256(uint160(address(_recipient)))); // If relayer/fee is used, they also become public inputs and are checked in the circuit if (!i_verifier.verifyProof( _proof, publicInputs )) { revert Mixer_InvalidProof(); } s_nullifierHashes[_nullifierHash] = true; // Logic for handling fees if a relayer is involved would go here (bool success, ) = _recipient.call{value: DENOMINATION}(""); // Subtract fee if applicable if (!success) { revert Mixer_PaymentFailed(_recipient, DENOMINATION); } emit Withdrawal(_recipient, _nullifierHash); } // isKnownRoot would check if _root is one of the historical roots stored by IncrementalMerkleTree function isKnownRoot(bytes32 _root) internal view returns (bool) { // Implementation depends on how IncrementalMerkleTree stores roots // For example, it might have a mapping or array of historical roots return roots[_root]; // Assuming 'roots' is a mapping in IncrementalMerkleTree } } ``` ## Off-Chain Operations: Generating Commitments and Proofs Significant computation occurs off-chain, handled by the user's client-side scripts. This keeps gas costs on-chain manageable and protects user secrets. **Generating Commitments (`js-scripts/generateCommitment.ts`)** Before depositing, the user runs a script to generate their commitment. * This script typically uses a JavaScript library like **Barretenberg.js (`bb.js`)** from Aztec, which provides cryptographic primitives. * It generates cryptographically random values for the `nullifier` and `secret` (e.g., using `Fr.random()` where `Fr` represents an element in a finite field). * It then computes the commitment using the Poseidon hash function: `commitment = await bb.poseidon2Hash([nullifier, secret]);`. * The script then ABI encodes the `commitment` (for sending to the smart contract) and the `nullifier` and `secret` (for the user to store securely for later withdrawal). A conceptual TypeScript example: ```typescript // Example structure: js-scripts/generateCommitment.ts import { Barretenberg, Fr, Poseidon2 } from "@aztec/bb.js"; // Assuming Poseidon2 is available or through bb directly import { ethers } from "ethers"; // For ABI encoding export default async function generateCommitment(): Promise<string> { const bb = await Barretenberg.new(); // Initialize Barretenberg await bb.acirInitProvingKey(); // May be needed depending on bb.js version for certain ops const poseidon = new Poseidon2(bb); const nullifier = Fr.random(); const secret = Fr.random(); // Compute commitment: H(nullifier, secret) const commitmentBuffer: Buffer = await poseidon.hashToField([nullifier.toBuffer(), secret.toBuffer()]); const commitment = Fr.fromBuffer(commitmentBuffer); // Prepare for output or storage // User must securely store nullifier and secret // Commitment is sent to the deposit function const result = ethers.AbiCoder.defaultAbiCoder.encode( ["bytes32", "bytes32", "bytes32"], [commitment.toBuffer(), nullifier.toBuffer(), secret.toBuffer()] ); // User would typically parse this: use commitment for deposit, store nullifier & secret await bb.destroy(); return result; // Returns ABI encoded string of [commitment, nullifier, secret] } ``` **Crafting the Zero-Knowledge Circuit (`circuits/main.nr`)** The logic that the ZKP proves is defined in a circuit, commonly written in a domain-specific language like **Noir**. * The `main.nr` file defines the circuit's structure, including its public and private inputs. * **Public inputs:** These are known to both the prover (user) and verifier (smart contract). They include `root` (Merkle root), `nullifier_hash`, and `recipient` address. * **Private inputs:** These are known only to the prover. They include the `nullifier`, `secret`, `merkle_proof` (the path elements from the commitment leaf to the root), and `is_even` (or path indices, indicating the position of sibling hashes at each level of the Merkle proof). * **Circuit Logic:** The circuit performs several critical verifications: 1. It re-computes the `commitment` from the private `nullifier` and `secret` using the same Poseidon hash function used during commitment generation. 2. It computes the `computed_nullifier_hash` from the private `nullifier` and asserts that it equals the public `nullifier_hash` input. This confirms the link between the revealed nullifier hash and the secret nullifier used in the commitment. 3. It computes the `computed_root` by taking the re-computed `commitment`, the private `merkle_proof` elements, and the `is_even` path selectors. This process effectively reconstructs the Merkle root from the leaf. It then asserts that this `computed_root` equals the public `root` input. This proves that the user's commitment is a valid member of the specified Merkle tree. A simplified Noir circuit: ```noir // In circuits/main.nr // Note: Actual Poseidon usage might involve specific libraries like from aztec_noir_libs // and the number of inputs to poseidon2 might vary based on implementation (e.g., array vs. fixed args) // For simplicity, poseidon2 is shown as taking an array. use dep::std; // Standard library // Assuming a merkle_tree module is available or functions are imported // e.g., use dep::aztec_noir_libs::merkle_tree; // The depth of the Merkle tree dictates the size of merkle_proof and is_even // Example: const MERKLE_TREE_DEPTH: u32 = 20; fn main( // Public inputs root: pub Field, nullifier_hash: pub Field, recipient: pub Field, // This might be constrained further (e.g., to be an Ethereum address) // Private inputs nullifier: Field, secret: Field, merkle_proof: [Field; 20], // Example for depth 20; size should match tree depth path_indices: [Field; 20] // Or `is_even: [bool; 20]` - represents left/right path ) { // 1. Re-compute commitment from secret and nullifier // let commitment = std::hash::poseidon2::hash([nullifier, secret]); // Assuming poseidon2 takes an array of fields. Actual API might differ. let commitment_inputs = [nullifier, secret]; let commitment = std::hash::pedersen_hash(commitment_inputs,0); // Or Poseidon // 2. Compute nullifier_hash from nullifier and check against public input // let computed_nullifier_hash = std::hash::poseidon2::hash([nullifier]); let nullifier_hash_inputs = [nullifier]; let computed_nullifier_hash = std::hash::pedersen_hash(nullifier_hash_inputs,0); // Or Poseidon assert(computed_nullifier_hash == nullifier_hash, "Nullifier hash mismatch"); // 3. Verify Merkle proof // The merkle_tree::compute_merkle_root function would take the leaf (commitment), // the proof path (merkle_proof), and path indices (which side each proof element was on) // and compute the root. // let computed_root = merkle_tree::compute_merkle_root_from_leaf_and_path_for_unknown_depth( // commitment, // MERKLE_TREE_DEPTH, // Constant for tree depth // merkle_proof, // path_indices // ); // For a fixed-depth tree, a simpler function might be used: // let computed_root = merkle_tree::compute_merkle_root(commitment, merkle_proof, path_indices); // For this example, let's assume a hypothetical `verify_merkle_proof` function for clarity: // This function would internally hash up the tree. // The exact implementation details for Merkle proof verification vary. // A common pattern is to iteratively hash: let mut current_hash = commitment; for i in 0..merkle_proof.len() { let sibling = merkle_proof[i]; let index_is_even = path_indices[i]; // 0 for left, 1 for right, or bool true/false if index_is_even == 0 { // current_hash is left sibling // current_hash = std::hash::poseidon2::hash([current_hash, sibling]); let hash_inputs = [current_hash, sibling]; current_hash = std::hash::pedersen_hash(hash_inputs,0); // Or Poseidon } else { // current_hash is right sibling // current_hash = std::hash::poseidon2::hash([sibling, current_hash]); let hash_inputs = [sibling, current_hash]; current_hash = std::hash::pedersen_hash(hash_inputs,0); // Or Poseidon } } let computed_root = current_hash; assert(computed_root == root, "Merkle root mismatch"); // Constraints on recipient can also be added if needed, e.g., ensuring it fits in an address type. // std::println(recipient); // To ensure recipient is used and not optimized out if not constrained } ``` **Generating Withdrawal Proofs (`js-scripts/generateProof.ts`)** When the user wants to withdraw, another off-chain script generates the ZK proof. * This script takes the user's stored `nullifier` and `secret`, the desired `recipient` address, and a current list of `leaves` (all commitments) from the mixer's Merkle tree. * It initializes **Noir** (e.g., from `@noir-lang/noir_js`) with the compiled circuit artifact and Barretenberg's **Honk prover** (or another proving system like Plonk, via `bb.js`). * It recomputes the `commitment` from the `nullifier` and `secret`. * It constructs an off-chain representation of the Merkle tree from the `leaves` to derive the `merkleProof` for the user's specific `commitment` and to find the current `root` of this tree. It also computes the `nullifierHash`. * It prepares all necessary public (`root`, `nullifierHash`, `recipient`) and private (`nullifier`, `secret`, `merkleProof`, path indices) inputs for the Noir circuit. * It generates the witness: `witness = await noir.execute(circuitInputs);`. The witness is an assignment of values to all wires in the circuit that satisfies the circuit's constraints. * It then generates the actual proof: `{ proof, publicInputs: proofPublicInputs } = await honk.generateProof(witness, { keccek: true });`. The `keccek: true` option might be used if keccak is involved in how public inputs are formatted for the verifier contract. * Finally, it ABI encodes the `proof` and `proofPublicInputs` into a format suitable for calling the `withdraw` function on the smart contract. A conceptual TypeScript example: ```typescript // Example structure: js-scripts/generateProof.ts import { Barretenberg, Fr, Poseidon2, Honk } from "@aztec/bb.js"; // Or specific proving system import { Noir } from '@noir-lang/noir_js'; // Import compiled circuit (e.g., from a .json file generated by nargo build) import { main as compiledCircuit } from '../circuits/target/main.json'; // Adjust path import { ethers } from "ethers"; import { MerkleTree } from 'fixed-merkle-tree'; // Example Merkle tree library // Assume these are provided or fetched: // userNullifier: Fr, userSecret: Fr, recipientAddress: string, allLeaves: Fr[] // treeDepth: number export default async function generateProof( userNullifier: Fr, userSecret: Fr, recipientAddress: string, allLeavesHex: string[], // All commitments in the tree as hex strings treeDepth: number ): Promise<string> { const bb = await Barretenberg.new(); await bb.acirInitProvingKey(); const poseidon = new Poseidon2(bb); const honk = new Honk(bb); // Or Plonk, etc. // 1. Recompute commitment const commitmentBuffer: Buffer = await poseidon.hashToField([userNullifier.toBuffer(), userSecret.toBuffer()]); const commitment = Fr.fromBuffer(commitmentBuffer); // 2. Compute nullifier hash const nullifierHashBuffer: Buffer = await poseidon.hashToField([userNullifier.toBuffer()]); const nullifierHash = Fr.fromBuffer(nullifierHashBuffer); // 3. Construct Merkle tree and get proof for the commitment // Convert hex leaves to Fr elements if necessary const leavesAsFr = allLeavesHex.map(leafHex => Fr.fromString(leafHex)); const tree = new MerkleTree(treeDepth, leavesAsFr, { hashFunction: (l: Fr, r: Fr) => Fr.fromBuffer(poseidon.hashToFieldSync([l.toBuffer(), r.toBuffer()])) }); const commitmentIndex = leavesAsFr.findIndex(leaf => leaf.equals(commitment)); if (commitmentIndex === -1) { throw new Error("Commitment not found in leaves"); } const merkleProof = tree.proof(commitmentIndex); // { root, pathElements, pathIndices } // 4. Prepare inputs for the Noir circuit const circuitInputs = { root: merkleProof.root.toNoirFieldValue(), // Ensure proper formatting for Noir nullifier_hash: nullifierHash.toNoirFieldValue(), recipient: Fr.fromString(ethers.utils.hexZeroPad(ethers.utils.getAddress(recipientAddress), 32)).toNoirFieldValue(), // Ensure recipient is a Field nullifier: userNullifier.toNoirFieldValue(), secret: userSecret.toNoirFieldValue(), merkle_proof: merkleProof.pathElements.map((p: Fr) => p.toNoirFieldValue()), path_indices: merkleProof.pathIndices.map((idx: number) => new Fr(idx).toNoirFieldValue()) // Or boolean if circuit expects bool }; // 5. Initialize Noir and generate witness const noir = new Noir(compiledCircuit); // Pass compiled artifact await noir.init(); // Initialize ACVM const witness = await noir.generateFinalProof(circuitInputs); // Generates witness, which is then used by backend // 6. Generate proof using Barretenberg backend // Note: Noir.js can also directly call a backend to generate proof. // The specific API might differ based on Noir.js and bb.js versions. // Example using Honk directly (or Plonk): // const proof = await honk.generateProof(witness.witness); // Or similar API // The `generateFinalProof` from Noir.js often returns the proof directly if backend is configured. // Let's assume `witness` here is what's needed for `bb.js` or `witness` object has the proof. // A more common flow with newer Noir.js might be: // const proof = await noir.generateFinalProof(circuitInputs); // This returns the proof bytes // For this example, let's assume `witness` contains the proof bytes after `generateFinalProof` // Or if `noir.generateFinalProof` actually returns an object with `{ proof, publicInputs }` // const { proof, publicInputs: proofPublicInputs } = await noir.generateFinalProof(circuitInputs); // The example uses `honk.generateProof(witness, { keccek: true })` implying `witness` is a raw witness array. // This step can be complex and depends on the exact integration between Noir.js and bb.js. // The provided snippet `honk.generateProof(witness, { keccek: true })` suggests `witness` is the raw witness. // So, let's assume `noir.execute(circuitInputs)` gives the witness array. const executedWitness = await noir.execute(circuitInputs); // Gets the witness assignments const { proof, publicInputs: rawPublicInputs } = await honk.createProof(compiledCircuit.bytecode, executedWitness.witness); // Using a more plausible Honk API // Convert raw public inputs (Fields) back to bytes32 hex strings for the smart contract const formattedPublicInputs = rawPublicInputs.map(pi => Fr.fromBuffer(pi).toBuffer32()); const result = ethers.AbiCoder.defaultAbiCoder.encode( ["bytes", "bytes32[]"], [proof, formattedPublicInputs] ); await bb.destroy(); return result; } ``` ## Testing the Mixer Protocol with Foundry Thorough testing is paramount. The **Foundry** development toolkit is used for testing Solidity smart contracts. * The test file, often named `Mixer.t.sol`, contains test cases for the mixer contract. * A key feature of Foundry used in this context is `vm.ffi` (Foreign Function Interface). This allows Solidity tests to execute external commands, such as running the `generateCommitment.ts` and `generateProof.ts` JavaScript/TypeScript scripts. * By calling these off-chain scripts from within the Solidity test environment, developers can simulate the entire end-to-end user flow: generating a commitment, depositing, generating a proof, and withdrawing. * Successful execution of tests, indicated by output like `Ran 1 test suite in 9.20s (9.15s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)`, provides confidence in the protocol's implementation. ## Essential Tools and Libraries The development of such a ZK mixer relies on a suite of specialized tools and libraries: * **Solidity:** The primary language for writing smart contracts on Ethereum-compatible blockchains. * **Foundry:** A fast, portable, and modular toolkit for Ethereum application development, used here for compiling, testing, and deploying Solidity contracts. * **Noir:** A domain-specific language (DSL) Rust-like syntax for writing zero-knowledge circuits that can be compiled into formats usable by various ZK proving systems. * **Barretenberg.js (`bb.js`):** Aztec's WebAssembly library, providing C++ cryptographic primitives (like Poseidon hash, Pedersen commitments, and the Honk/Plonk proving systems) accessible from JavaScript/TypeScript. * **Noir JS (`@noir-lang/noir_js` or similar):** JavaScript/TypeScript libraries that allow developers to compile Noir circuits, generate witnesses, and interact with proving backends (like Barretenberg) from a JavaScript environment. * **Ethers.js:** A comprehensive JavaScript library for interacting with Ethereum blockchains, used here for tasks like ABI encoding/decoding and communication with smart contracts. * **TypeScript/JavaScript:** Used for writing the off-chain scripts that handle commitment generation, proof generation, and test orchestration. ## Important Considerations and Best Practices When working with or building ZK mixer protocols, several points are crucial: * **Demonstrative Nature:** The code examples presented are primarily for educational and demonstrative purposes. Production deployment requires rigorous security audits, extensive testing (including fuzz testing and achieving high code coverage), and potentially more advanced cryptographic considerations. * **Separate Addresses for Privacy:** To maximize privacy, users *must* withdraw funds to a completely new address, different from any address used for depositing or any other identifiable on-chain activity. The protocol breaks the link between the deposit *transaction* and the withdrawal *transaction*, but user behavior (like reusing addresses) can still compromise anonymity. * **Off-Chain Data Management:** The user bears full responsibility for securely storing their private `secret` and `nullifier`. Loss of these values will result in the inability to withdraw the deposited funds. * **Historical Merkle Roots:** The `isKnownRoot` check in the smart contract implies a need for an efficient mechanism to store and verify against historical Merkle roots. As the tree grows with more deposits, managing these roots becomes important for ensuring withdrawals can be made against any valid past state of the tree. ## Further Learning Resources For hands-on experience and deeper exploration of the concepts and code discussed, the **GitHub repository associated with the course** (from which this lesson is derived) is the primary resource. It contains the complete codebase, setup instructions, and potentially further exercises or explanations. Engaging with this material will provide a practical understanding of building ZK-powered privacy solutions.
An insightful walkthrough of Understanding and Building a ZK Mixer Protocol for Enhanced On-Chain Privacy - Discover how to build a ZK mixer protocol leveraging commitment schemes, Merkle trees, and zero-knowledge proofs to significantly enhance on-chain privacy. This lesson covers the core cryptographic mechanisms, smart contract development in Solidity, and off-chain scripting for proof generation using Noir.
Previous lesson
Previous
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