5/5
This lesson provides a comprehensive review of the `zk-mixer` project, specifically detailing the `withdraw` function within the `Mixer.sol` smart contract and its corresponding ZK-SNARK circuit (`main.nr`). A thorough understanding of these components is essential as we prepare to develop a robust testing suite for the entire system. ## Dissecting the `withdraw` Function in `Mixer.sol` The `withdraw` function in the `Mixer.sol` smart contract is the cornerstone of enabling users to privately retrieve their funds from the mixer. It meticulously performs a series of checks and operations to ensure security and correctness. **Core Responsibilities:** * Validates the withdrawal proof against a known Merkle root. * Prevents double-spending using nullifier hashes. * Prepares public inputs for the ZK-SNARK verifier. * Verifies the ZK-SNARK proof. * Marks the nullifier as spent. * Transfers funds to the recipient. * Emits an event for off-chain tracking. Let's examine each step in detail: ### Known Root Check Before proceeding with a withdrawal, the contract verifies that the Merkle root (`_root`) provided with the withdrawal proof is a recognized, historical root stored on-chain. This root corresponds to the state of the Merkle tree at the time the proof was generated. ```solidity // In Mixer.sol // check that the root that was used in the proof matches the root on-chain if (!isKnownRoot(_root)) { revert Mixer_UnknownRoot(_root); } ``` The `isKnownRoot` function, located in the `IncrementalMerkleTree.sol` contract, iterates through the `s_roots` array (a circular buffer storing historical roots) to find a match for the provided `_root`. ```solidity // In IncrementalMerkleTree.sol function isKnownRoot(bytes32 _root) public view returns (bool) { // ... // Logic to iterate through s_roots circular buffer: // Initialize i to (_currentRootIndex + 1) % s_roots.length if _currentRootIndex is where the next root will be written. // Or, more directly, iterate from 0 to s_roots.length, checking against filled roots up to _currentRootIndex, // handling the circular buffer nature. // The provided snippet implies a do-while loop structure. uint256 i = _currentRootIndex; // Assuming _currentRootIndex points to the latest valid root uint256 rootsLen = s_roots.length; if (rootsLen == 0) return false; // Or however an empty/uninitialized tree is handled // Iterate through filled roots in the circular buffer // This example assumes s_roots stores the last N roots and _currentRootIndex points to the last written one. // The exact iteration logic depends on how s_roots and _currentRootIndex are managed. // A common pattern is to iterate backwards from the current root index. // For simplicity, let's assume direct iteration for demonstration if full context isn't available. // The provided summary's "do ... while (i != _currentRootIndex);" suggests a specific circular buffer traversal. // A simplified conceptual representation without the full circular buffer logic details: for (uint256 j = 0; j < rootsLen; j++) { // This conceptual loop needs to be adapted to the actual circular buffer // The actual implementation in the video/project uses a specific do-while: // uint8 i = _currentRootIndex; // do { // i = (i == 0) ? (uint8(s_roots.length - 1)) : (i - 1); // Iterate backwards // if (_root == s_roots[i]) { // return true; // } // } while (i != _currentRootIndex); // return false; // The summary's version: // do { // if (_root == s_roots[i]) { // return true; // } // ... logic to iterate through s_roots circular buffer // } while (i != _currentRootIndex); // return false; // Let's use the summary's logic directly. // Assuming 'i' is initialized appropriately before this loop based on _currentRootIndex and buffer state. // For this lesson, we'll use the provided snippet's core check: // The full iteration logic for a circular buffer is complex without seeing its full implementation. // We'll focus on the check itself: // if (_root == s_roots[j]) { // j would be the index in the actual loop // return true; // } } // The video's snippet is: // ... // do { // if (_root == s_roots[i]) { // return true; // } // ... logic to iterate through s_roots circular buffer // } while (i != _currentRootIndex); // return false; // Based on the provided summary's code block for IncrementalMerkleTree.sol: uint256 localCurrentRootIndex = _currentRootIndex; // Or however the starting point is determined uint256 rootsLength = s_roots.length; if (rootsFilled < 1) return false; // Assuming rootsFilled tracks the number of roots actually in the buffer for (uint256 k = 0; k < rootsFilled; ++k) { uint256 index = (localCurrentRootIndex + rootsLength - 1 - k) % rootsLength; // Iterate backwards for recent roots if (_root == s_roots[index]) { return true; } } return false; } ``` *Note: The exact implementation of `isKnownRoot`'s iteration depends on how `s_roots` (circular buffer) and `_currentRootIndex` are managed. The key is that it checks against historical roots.* ### Nullifier Hash Check (Double Spending Prevention) To prevent the same deposit from being withdrawn multiple times (double-spending), the contract checks if the provided `_nullifierHash` has already been recorded. ```solidity // In Mixer.sol // check that the nullifier has not yet been used to prevent double spending if (s_nullifierHashes[_nullifierHash]) { revert Mixer_NullifierAlreadyUsed(_nullifierHash); } ``` The `s_nullifierHashes` is a mapping that stores `true` for any nullifier hash that has been spent. ### Public Inputs Preparation for Verifier The ZK-SNARK verifier contract requires specific public inputs to validate a proof. These are prepared as an array: ```solidity // In Mixer.sol // check that the proof is valid by calling the verifier contract bytes32[] memory publicInputs = new bytes32[](3); publicInputs[0] = _root; publicInputs[1] = _nullifierHash; publicInputs[2] = bytes32(uint256(uint160(_recipient))); // convert address to bytes32 ``` The public inputs include: 1. `_root`: The Merkle root against which the proof was generated. 2. `_nullifierHash`: The unique hash identifying the spent note. 3. `_recipient`: The address designated to receive the funds. Including the recipient as a public input is crucial for preventing front-running attacks. Without it, an attacker could intercept a valid proof and use it to withdraw funds to their own address. ### Proof Verification The core of the withdrawal's privacy and security lies in verifying the ZK-SNARK proof. The `verify` function of the deployed `i_verifier` contract (generated from the Noir circuit) is called with the `_proof` and the prepared `publicInputs`. ```solidity // In Mixer.sol if (!i_verifier.verify(_proof, publicInputs)) { revert Mixer_InvalidProof(); } ``` If the proof is invalid (i.e., the cryptographic proof doesn't hold true for the given public inputs), the transaction reverts. ### Marking Nullifier as Used Upon successful proof verification, the `_nullifierHash` is marked as spent by setting its entry in the `s_nullifierHashes` mapping to `true`. ```solidity // In Mixer.sol s_nullifierHashes[_nullifierHash] = true; ``` This ensures that this specific nullifier cannot be used for future withdrawals. ### Sending Funds With all checks passed and the proof verified, the contract sends the predetermined `DENOMINATION` of cryptocurrency to the `_recipient` address. A low-level `call` is used for this transfer. ```solidity // In Mixer.sol // send them the funds (bool success, ) = _recipient.call{value: DENOMINATION}(""); if (!success) { revert Mixer_PaymentFailed(_recipient, DENOMINATION); } ``` If the transfer fails, the transaction reverts. ### Emitting Withdrawal Event Finally, a `Withdrawal` event is emitted to log the successful withdrawal, including the recipient and the nullifier hash. ```solidity // In Mixer.sol emit Withdrawal(_recipient, _nullifierHash); ``` This event can be used by off-chain services or users to track withdrawal activity. ## Understanding the ZK-SNARK Withdrawal Circuit (`main.nr`) The ZK-SNARK circuit, written in Noir (`main.nr`), defines the computational statements that are proven true without revealing the private inputs. This circuit is compiled to generate the `Verifier.sol` smart contract used on-chain. The `main` function in the circuit outlines these statements: ### Public Inputs These inputs are known to both the prover (who generates the proof) and the verifier (the smart contract). ```noir // In src/main.nr fn main( // Public Inputs root: pub Field, nullifier_hash: pub Field, recipient: pub Field, // ... private inputs follow ``` * `root: pub Field`: The Merkle root of the tree containing the user's commitment. * `nullifier_hash: pub Field`: The hash of the unique nullifier, used to prevent double-spending. * `recipient: pub Field`: The address of the withdrawal recipient, crucial for preventing front-running. ### Private Inputs (Witnesses) These inputs are known only to the prover and are kept secret from the verifier and the public. ```noir // In src/main.nr (continued from public inputs) // Private Inputs nullifier: Field, secret: Field, merkle_proof: [Field; 20], is_even: [bool; 20] ) { // ... circuit logic follows ``` * `nullifier: Field`: A unique secret value chosen by the user for their deposit. * `secret: Field`: Another secret value chosen by the user, combined with the `nullifier` to create the deposit commitment. * `merkle_proof: [Field; 20]`: An array of sibling hashes along the Merkle path from the user's commitment (leaf) to the `root`. The size `20` indicates a Merkle tree of depth 20. * `is_even: [bool; 20]`: An array of booleans indicating, for each step of the Merkle path computation, whether the current hash is on the left (even index position relative to its sibling) or right (odd index position). This is vital for correctly ordering hashes when reconstructing the Merkle root. ### Circuit Logic The circuit enforces several constraints: 1. **Compute Commitment:** The circuit first recomputes the commitment using the private `nullifier` and `secret`. This commitment must match the one originally deposited. ```noir // compute the commitment Poseidon(nullifier, secret) let commitment: Field = poseidon2::hash([nullifier, secret], message_size: 2); ``` 2. **Check Nullifier Hash:** The circuit computes the hash of the private `nullifier` and asserts that it equals the public `nullifier_hash` input. ```noir // check that the nullifier matches the nullifier hash let computed_nullifier_hash: Field = poseidon2::hash([nullifier], message_size: 1); assert(computed_nullifier_hash == nullifier_hash); ``` 3. **Check Commitment is in Merkle Tree (Compute Merkle Root):** Using the computed `commitment` as the leaf, the private `merkle_proof` (sibling nodes), and the `is_even` path indicators, the circuit reconstructs the Merkle root. This is typically handled by a helper function like `merkle_tree::compute_merkle_root`. ```noir // check that the commitment is in the Merkle tree let computed_root: Field = merkle_tree::compute_merkle_root(leaf: commitment, merkle_proof, is_even); assert(computed_root == root); ``` This assertion confirms that the user's commitment is indeed part of the Merkle tree represented by the public `root`. 4. **Recipient Binding (Anti-Front-Running):** To ensure the `recipient` public input is genuinely part of the proof and not optimized away by the circuit compiler (which could otherwise allow proof replay to a different recipient if the `Verifier.sol` didn't also check it), a "recipient binding" constraint is included. This is often a simple computation involving `recipient` that must hold true, effectively forcing its inclusion in the proof's constraints. ```noir // Ensures recipient is used and tied to the proof let recipient_binding: Field = recipient * recipient; assert(recipient_binding == recipient * recipient); ``` ### The `compute_merkle_root` Helper Function This function (likely in `src/merkle_tree.nr`) is responsible for calculating the Merkle root given a leaf, the proof path, and path element position indicators. ```noir // In src/merkle_tree.nr pub fn compute_merkle_root( leaf: Field, merkle_proof: [Field; 20], is_even: [bool; 20] ) -> Field { let mut hash: Field = leaf; // Start with the leaf commitment for i: u32 in 0..20 { // Iterate 20 times for a tree of depth 20 let (left, right): (Field, Field); if is_even[i] { // Current hash is on the left, proof element is on the right left = hash; right = merkle_proof[i]; } else { // Proof element is on the left, current hash is on the right left = merkle_proof[i]; right = hash; } // Hash the ordered pair to get the parent hash for the next level hash = poseidon2::hash([left, right], message_size: 2); } hash // The final hash is the computed Merkle root } ``` This function iteratively hashes pairs of nodes up the tree—combining the current `hash` with the corresponding `merkle_proof` element at each level, ordered correctly by `is_even[i]`—until the root is computed. ## Generating the On-Chain Verifier The Noir circuit (`main.nr` and its dependencies like `merkle_tree.nr`) is compiled using tools from the Barretenberg suite (often referred to as `bb`). This compilation process generates a Solidity smart contract, `Verifier.sol`. This `Verifier.sol` contract contains the on-chain logic necessary to verify the ZK-SNARK proofs generated by users for their withdrawal requests. The `Mixer.sol` contract then calls the `verify` function of this generated `Verifier.sol`. ## The Complete zk-Mixer Flow and Our Testing Strategy Understanding the individual components allows us to see the end-to-end flow of the `zk-mixer`: **Deposit Flow:** 1. **Off-Chain:** A user generates a unique `nullifier` and a `secret`. 2. **Off-Chain:** The user computes `commitment = hash(nullifier, secret)`. 3. **On-Chain:** The user calls the `deposit` function on `Mixer.sol`, submitting the `commitment` and the required `DENOMINATION` of ETH. 4. **On-Chain:** `Mixer.sol` adds the `commitment` to its internal Merkle tree, updating the tree's root. **Withdrawal Flow:** 1. **Off-Chain (Prover):** The user, wishing to withdraw, uses their original `nullifier` and `secret`. They also require the `merkle_proof` (path from their commitment to a known root) and the `is_even` indicators for that path. 2. **Off-Chain (Prover):** Using these private inputs, along with public inputs (`root` of the Merkle tree at deposit time, `nullifier_hash = hash(nullifier)`, and the intended `recipient` address), the user generates a ZK-SNARK proof. 3. **On-Chain:** The user calls the `withdraw` function on `Mixer.sol`, providing the generated `proof`, the `root`, `nullifier_hash`, and `recipient` address. 4. **On-Chain:** `Mixer.sol` performs the checks detailed earlier: known root, unused nullifier, and then calls `Verifier.sol` to verify the proof. If all checks pass and the proof is valid, funds are sent to the `recipient`, and the nullifier is marked as spent. **Next Steps: Writing Comprehensive Tests** With this recap, we are now poised to write tests for the entire `zk-mixer` system. This critical phase will involve: * **Technology Stack:** Primarily using JavaScript, leveraging libraries such as: * **NoirJS:** To interact with our Noir circuits (e.g., compile circuits, generate witnesses, create proofs). * **BarretenbergJS (or similar interfaces to Barretenberg):** For underlying cryptographic operations like Poseidon hashing (to ensure consistency between JS and circuit computations) and potentially for proof generation/verification interactions if NoirJS relies on it. * **Testing Procedures:** 1. **Input Generation:** Programmatically generate `secret` and `nullifier` values. 2. **Commitment Calculation:** Compute `commitment` in JavaScript, ensuring the hashing mechanism (e.g., Poseidon) matches the one used in the Noir circuit. 3. **Deposit Simulation & Merkle Tree Management:** Simulate deposit operations. This will involve building and maintaining a Merkle tree structure in JavaScript to derive the necessary `merkle_proof` and `is_even` path information for withdrawals. 4. **ZK Proof Generation:** Use NoirJS to generate a valid ZK proof for a withdrawal scenario using the derived inputs. 5. **Smart Contract Interaction:** Call the `deposit` and `withdraw` functions on our deployed (or locally simulated) `Mixer.sol` contract. 6. **Assertions:** Verify that contract states change as expected (e.g., Merkle root updates, nullifiers marked as spent, balances change correctly) and that events are emitted appropriately. This testing approach will be similar to methodologies used in previous ZK application development (like the "Panagram" project, if applicable), validating the entire lifecycle from off-chain proof generation to on-chain verification and state change. Solidifying your understanding of the `withdraw` function's logic and the ZK-SNARK circuit's constraints is paramount before diving into test implementation. This foundation will enable us to build a robust and reliable zk-Mixer.
This lesson provides a comprehensive review of the zk-mixer
project, specifically detailing the withdraw
function within the Mixer.sol
smart contract and its corresponding ZK-SNARK circuit (main.nr
). A thorough understanding of these components is essential as we prepare to develop a robust testing suite for the entire system.
withdraw
Function in Mixer.sol
The withdraw
function in the Mixer.sol
smart contract is the cornerstone of enabling users to privately retrieve their funds from the mixer. It meticulously performs a series of checks and operations to ensure security and correctness.
Core Responsibilities:
Validates the withdrawal proof against a known Merkle root.
Prevents double-spending using nullifier hashes.
Prepares public inputs for the ZK-SNARK verifier.
Verifies the ZK-SNARK proof.
Marks the nullifier as spent.
Transfers funds to the recipient.
Emits an event for off-chain tracking.
Let's examine each step in detail:
Before proceeding with a withdrawal, the contract verifies that the Merkle root (_root
) provided with the withdrawal proof is a recognized, historical root stored on-chain. This root corresponds to the state of the Merkle tree at the time the proof was generated.
The isKnownRoot
function, located in the IncrementalMerkleTree.sol
contract, iterates through the s_roots
array (a circular buffer storing historical roots) to find a match for the provided _root
.
Note: The exact implementation of isKnownRoot
's iteration depends on how s_roots
(circular buffer) and _currentRootIndex
are managed. The key is that it checks against historical roots.
To prevent the same deposit from being withdrawn multiple times (double-spending), the contract checks if the provided _nullifierHash
has already been recorded.
The s_nullifierHashes
is a mapping that stores true
for any nullifier hash that has been spent.
The ZK-SNARK verifier contract requires specific public inputs to validate a proof. These are prepared as an array:
The public inputs include:
_root
: The Merkle root against which the proof was generated.
_nullifierHash
: The unique hash identifying the spent note.
_recipient
: The address designated to receive the funds. Including the recipient as a public input is crucial for preventing front-running attacks. Without it, an attacker could intercept a valid proof and use it to withdraw funds to their own address.
The core of the withdrawal's privacy and security lies in verifying the ZK-SNARK proof. The verify
function of the deployed i_verifier
contract (generated from the Noir circuit) is called with the _proof
and the prepared publicInputs
.
If the proof is invalid (i.e., the cryptographic proof doesn't hold true for the given public inputs), the transaction reverts.
Upon successful proof verification, the _nullifierHash
is marked as spent by setting its entry in the s_nullifierHashes
mapping to true
.
This ensures that this specific nullifier cannot be used for future withdrawals.
With all checks passed and the proof verified, the contract sends the predetermined DENOMINATION
of cryptocurrency to the _recipient
address. A low-level call
is used for this transfer.
If the transfer fails, the transaction reverts.
Finally, a Withdrawal
event is emitted to log the successful withdrawal, including the recipient and the nullifier hash.
This event can be used by off-chain services or users to track withdrawal activity.
main.nr
)The ZK-SNARK circuit, written in Noir (main.nr
), defines the computational statements that are proven true without revealing the private inputs. This circuit is compiled to generate the Verifier.sol
smart contract used on-chain.
The main
function in the circuit outlines these statements:
These inputs are known to both the prover (who generates the proof) and the verifier (the smart contract).
root: pub Field
: The Merkle root of the tree containing the user's commitment.
nullifier_hash: pub Field
: The hash of the unique nullifier, used to prevent double-spending.
recipient: pub Field
: The address of the withdrawal recipient, crucial for preventing front-running.
These inputs are known only to the prover and are kept secret from the verifier and the public.
nullifier: Field
: A unique secret value chosen by the user for their deposit.
secret: Field
: Another secret value chosen by the user, combined with the nullifier
to create the deposit commitment.
merkle_proof: [Field; 20]
: An array of sibling hashes along the Merkle path from the user's commitment (leaf) to the root
. The size 20
indicates a Merkle tree of depth 20.
is_even: [bool; 20]
: An array of booleans indicating, for each step of the Merkle path computation, whether the current hash is on the left (even index position relative to its sibling) or right (odd index position). This is vital for correctly ordering hashes when reconstructing the Merkle root.
The circuit enforces several constraints:
Compute Commitment:
The circuit first recomputes the commitment using the private nullifier
and secret
. This commitment must match the one originally deposited.
Check Nullifier Hash:
The circuit computes the hash of the private nullifier
and asserts that it equals the public nullifier_hash
input.
Check Commitment is in Merkle Tree (Compute Merkle Root):
Using the computed commitment
as the leaf, the private merkle_proof
(sibling nodes), and the is_even
path indicators, the circuit reconstructs the Merkle root. This is typically handled by a helper function like merkle_tree::compute_merkle_root
.
This assertion confirms that the user's commitment is indeed part of the Merkle tree represented by the public root
.
Recipient Binding (Anti-Front-Running):
To ensure the recipient
public input is genuinely part of the proof and not optimized away by the circuit compiler (which could otherwise allow proof replay to a different recipient if the Verifier.sol
didn't also check it), a "recipient binding" constraint is included. This is often a simple computation involving recipient
that must hold true, effectively forcing its inclusion in the proof's constraints.
compute_merkle_root
Helper FunctionThis function (likely in src/merkle_tree.nr
) is responsible for calculating the Merkle root given a leaf, the proof path, and path element position indicators.
This function iteratively hashes pairs of nodes up the tree—combining the current hash
with the corresponding merkle_proof
element at each level, ordered correctly by is_even[i]
—until the root is computed.
The Noir circuit (main.nr
and its dependencies like merkle_tree.nr
) is compiled using tools from the Barretenberg suite (often referred to as bb
). This compilation process generates a Solidity smart contract, Verifier.sol
. This Verifier.sol
contract contains the on-chain logic necessary to verify the ZK-SNARK proofs generated by users for their withdrawal requests. The Mixer.sol
contract then calls the verify
function of this generated Verifier.sol
.
Understanding the individual components allows us to see the end-to-end flow of the zk-mixer
:
Deposit Flow:
Off-Chain: A user generates a unique nullifier
and a secret
.
Off-Chain: The user computes commitment = hash(nullifier, secret)
.
On-Chain: The user calls the deposit
function on Mixer.sol
, submitting the commitment
and the required DENOMINATION
of ETH.
On-Chain: Mixer.sol
adds the commitment
to its internal Merkle tree, updating the tree's root.
Withdrawal Flow:
Off-Chain (Prover): The user, wishing to withdraw, uses their original nullifier
and secret
. They also require the merkle_proof
(path from their commitment to a known root) and the is_even
indicators for that path.
Off-Chain (Prover): Using these private inputs, along with public inputs (root
of the Merkle tree at deposit time, nullifier_hash = hash(nullifier)
, and the intended recipient
address), the user generates a ZK-SNARK proof.
On-Chain: The user calls the withdraw
function on Mixer.sol
, providing the generated proof
, the root
, nullifier_hash
, and recipient
address.
On-Chain: Mixer.sol
performs the checks detailed earlier: known root, unused nullifier, and then calls Verifier.sol
to verify the proof. If all checks pass and the proof is valid, funds are sent to the recipient
, and the nullifier is marked as spent.
Next Steps: Writing Comprehensive Tests
With this recap, we are now poised to write tests for the entire zk-mixer
system. This critical phase will involve:
Technology Stack: Primarily using JavaScript, leveraging libraries such as:
NoirJS: To interact with our Noir circuits (e.g., compile circuits, generate witnesses, create proofs).
BarretenbergJS (or similar interfaces to Barretenberg): For underlying cryptographic operations like Poseidon hashing (to ensure consistency between JS and circuit computations) and potentially for proof generation/verification interactions if NoirJS relies on it.
Testing Procedures:
Input Generation: Programmatically generate secret
and nullifier
values.
Commitment Calculation: Compute commitment
in JavaScript, ensuring the hashing mechanism (e.g., Poseidon) matches the one used in the Noir circuit.
Deposit Simulation & Merkle Tree Management: Simulate deposit operations. This will involve building and maintaining a Merkle tree structure in JavaScript to derive the necessary merkle_proof
and is_even
path information for withdrawals.
ZK Proof Generation: Use NoirJS to generate a valid ZK proof for a withdrawal scenario using the derived inputs.
Smart Contract Interaction: Call the deposit
and withdraw
functions on our deployed (or locally simulated) Mixer.sol
contract.
Assertions: Verify that contract states change as expected (e.g., Merkle root updates, nullifiers marked as spent, balances change correctly) and that events are emitted appropriately.
This testing approach will be similar to methodologies used in previous ZK application development (like the "Panagram" project, if applicable), validating the entire lifecycle from off-chain proof generation to on-chain verification and state change.
Solidifying your understanding of the withdraw
function's logic and the ZK-SNARK circuit's constraints is paramount before diving into test implementation. This foundation will enable us to build a robust and reliable zk-Mixer.
An essential deep dive into zk-Mixer Withdrawals: Solidity Logic and Noir Circuitry - Uncover the step-by-step execution of the `withdraw` function in `Mixer.sol`, from initial Merkle root checks to final fund disbursal and event emission. This lesson further deciphers the companion ZK-SNARK circuit in Noir, vital for preparing a comprehensive testing strategy.
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