Challenge: ๐Ÿ”’ ZK Voting

Skills you'll gain
  • Design a commitment + nullifier scheme to enforce one-person-one-vote

  • Register voters in a Lean Incremental Merkle Tree (LeanIMT) and prove membership

  • Write Noir ZK circuits and generate a Solidity verifier with Barretenberg

  • Wire proofs to the contract and submit votes from a burner wallet to avoid linkage

Skill level
Advanced
Time to complete
6 - 16 hours
Completed by
143 builders

This challenge ships with context-aware AI support. Open it in your preferred AI coding tool and ask questions, request hints, or get explanations at any point along the way.

If you prefer an AI-guided experience, you can run /start in Claude Code or Cursor.

Create a private, Sybil-resistant voting system where anyone can prove theyโ€™re eligible and vote exactly once without revealing who they are. Youโ€™ll use zero-knowledge proofs to keep votes unlinkable to identities, while keeping results publicly verifiable on-chain.

โ“ Wondering how ZK voting works?

In traditional voting, a central authority verifies identities and counts the ballots. On-chain, we aim for decentralization, privacy, and verifiability, without exposing votersโ€™ identities. Thereโ€™s still a centralized authority that defines who can vote by adding addresses to the allowlist, but once registered, voters can cast their ballots privately.

Normally, on-chain voting makes every choice public. With ZK proofs, users can prove theyโ€™re on the voter list and havenโ€™t voted yet, without revealing their address or how they voted. That is done by breaking the chain of ownership of two addresses.

  • The contract owner maintains an allowlist and decides who are the voters
  • Voters register themselves to a Merkle tree on-chain with a commitment
  • They generate a ZK proof of membership using the commitment secret to prove they registered
  • The proof is sent to a verifier contract to check validity. If it passes, the vote is accepted
  • A nullifier ensures one-person-one-vote by preventing double voting, but without linking the vote to the registered voter address

๐Ÿค” Why ZK voting?

Traditional on-chain voting exposes every voterโ€™s address and choice, which breaks privacy and can lead to coercion or retaliation. Off-chain voting hides identities but usually relies on centralized authorities that must be trusted to count votes correctly.

ZK voting combines the best of both worlds:

  • Voters prove eligibility and uniqueness (one-person-one-vote)
  • No one can see who they are or how they voted
  • The process remains verifiable on-chain
  • Ensures integrity + privacy for every voter

๐ŸŒŸ Final Deliverable An app where anyone can create a Yes/No question, and registered voters can cast their votes anonymously. Results remain fully transparent and visible live on-chain.

  • Deploy your contracts to a testnet
  • Build & upload your app to a public web server
  • Submit the URL on SpeedRunEthereum.com! ๐Ÿš€

๐Ÿ’ฌ Meet other builders working on this challenge and get help in the ZK Voting Challenge Telegram Group.

๐Ÿš€ Checkpoint 0: Environment

Before you begin, you need to install the following tools:

๐Ÿšจ Windows Users: Noir (nargo, bb) isnโ€™t natively supported on Windows. Please install and run Noir inside WSL (Windows Subsystem for Linux) using Ubuntu 24.04.. ๐Ÿšจ

๐Ÿ“ฆ Install nargo and bb

โšก Use nargo version = 1.0.0-beta.3 for this challenge.

Install with:

curl -L https://raw.githubusercontent.com/noir-lang/noirup/refs/heads/main/install | bash
noirup -v 1.0.0-beta.3
nargo --version

โšก Use bb version = 0.82.2 (works with this challenge).

Install with:

# Mac (Apple Silicon):
mkdir -p ~/.bb && curl -L https://github.com/AztecProtocol/aztec-packages/releases/download/v0.82.2/barretenberg-arm64-darwin.tar.gz | tar -xzC ~/.bb
# Mac (Intel):
# mkdir -p ~/.bb && curl -L https://github.com/AztecProtocol/aztec-packages/releases/download/v0.82.2/barretenberg-amd64-darwin.tar.gz | tar -xzC ~/.bb
# Linux (x86_64):
# mkdir -p ~/.bb && curl -L https://github.com/AztecProtocol/aztec-packages/releases/download/v0.82.2/barretenberg-amd64-linux.tar.gz | tar -xzC ~/.bb

# Add to PATH (add this to your ~/.zshrc or ~/.bashrc to make it permanent)
export PATH="$HOME/.bb:$PATH"
bb --version

If you are using vscode you may want to install the Noir Language Support extension.

Then download the challenge to your computer and install dependencies by running:

npx create-eth@2.0.20 -e scaffold-eth/se-2-challenges:challenge-zk-voting challenge-zk-voting
cd challenge-zk-voting

When prompted, choose your preferred solidity framework (Hardhat or Foundry)

In the same terminal, start your local network (a blockchain emulator in your computer):

yarn chain

In a second terminal window, ๐Ÿ›ฐ deploy your contract (locally):

cd challenge-zk-voting
yarn deploy

In a third terminal window, start your ๐Ÿ“ฑ frontend:

cd challenge-zk-voting
yarn start

๐Ÿ‘ฉโ€๐Ÿ’ป Rerun yarn deploy whenever you want to deploy contract changes to the frontend. Run yarn deploy --reset for a completely fresh deploy, even when contracts are unchanged.


โš ๏ธ We've disabled Cursor auto-suggestions (Tab completions and predictions) via .vscode/settings.json to reduce distractions while you code. AI chat and agent features are still enabled, and we've included AGENTS.md and CLAUDE.md files with project context to help AI assistants understand the codebase.

๐Ÿ”’ Want to disable AI and do everything yourself? (Recommended for deeper learning):

  • Cursor: add * to a .cursorignore file in the root of your project
  • VSCode: set chat.disableAIFeatures to true in .vscode/settings.json file

๐Ÿค– AI-Guided Learning Mode (Optional)

Want an interactive tutor that teaches you the concepts while you code? This challenge supports AI-guided learning mode!

  1. Open this project in an AI coding tool like Claude Code or Cursor
  2. Run the /start command
  3. The AI tutor will teach you each concept, then give you a coding task
  4. You write the code, say "check", and the AI runs the tests
  5. Say "hint" for help, or /skip if you want the AI to show you the solution
  6. Your progress is saved โ€” use /start to resume anytime

The AI won't just give you the answers โ€” it teaches first, then has you implement the code yourself. Tests validate your work, and the AI helps you debug if something doesn't pass.


Checkpoint 1: ๐Ÿ—ณ๏ธ๐Ÿ”’ Structure of the Challenge and Voting Contract

๐Ÿ’ฌ What youโ€™ll build

A zk-powered voting flow with three phases:

  1. Registration: Users submit a commitment that gets added to an incremental Merkle tree (youโ€™ll learn more about this later).
  2. Proof Generation: Users locally generate a ZK proof based on their secret and the Merkle tree.
  3. Vote: With the proof, users call the voting function to cast their vote anonymously from a separate address that is not linked to their registered address.

To make this flow possible, weโ€™ll combine:

  • Noir โ†’ for building ZK circuits and producing a verifier contract
  • Solidity โ†’ to extend the voting contract and connect it with the verifier
  • Next.js/TypeScript โ†’ to build the frontend where users generate proofs and cast votes seamlessly

๐Ÿ› ๏ธ Core Features of the Voting Dapp

Our contract will support three main functions:

  1. Allowlisting โœ… (already implemented)
  2. Voter Registration (to be built using a Merkle tree)
  3. Voting (validated with ZK proofs)

๐Ÿ‘‰ Registration is where weโ€™ll start: we need a Merkle tree to prove a user is registered (has a commitment in the tree) and hasnโ€™t voted yet.

๐Ÿ‘€ Explore the Frontend

๐Ÿ“ฑ Open http://localhost:3000 to spin up your app.

๐Ÿ–ฅ๏ธ Head over to the Voting page and take a look at the frontend youโ€™ll soon bring to life.

overview-zk

๐Ÿ” Inspect the Contract

๐Ÿ” Next, switch to the Debug Contracts page. For now, you should see just one contract there โ€” Voting.

๐Ÿ“ The contract lives in packages/hardhat/contracts/Voting.sol

๐Ÿ” Open it up and check out the placeholder functions. Each of them represents a key piece of the voting logic. If you can already explain what theyโ€™re supposed to do, youโ€™re ahead of the game! ๐Ÿ˜Ž

๐Ÿ’ก The Verifier.sol contract is currently just a placeholder and will be replaced with the actual implementation later.

But this time you wonโ€™t just be working on the smart contract ๐Ÿ™‚

๐Ÿฅ… Goals

  • ๐Ÿ“ Review Voting.sol functions for an overall understanding

Checkpoint 2: ๐Ÿ“‹๐ŸŒฒ Register with a Smart Contract Merkle Tree

Time to let users actually register!

Here weโ€™ll implement a register function that:

  1. Takes a commitment from the caller
  2. Verifies theyโ€™re on the allowlist
  3. Checks uniqueness
  4. Inserts the commitment into a lean Incremental Merkle Tree (LeanIMT)

๐Ÿ’ก LeanIMT in Short: The tree is pre-filled with zeros. New leaves replace them, merging upward like a binary counter. Index parity (even = left, odd = right) ensures correct order. Only the frontier is stored, making LeanIMT cheap, scalable, and perfect for ZK apps like private voting.

๐Ÿง  Whatโ€™s LeanIMT, and Why Use It?

LeanIMT (Lean Incremental Merkle Tree) is an on-chain data structure that keeps a Merkle root up-to-date cheaply and efficiently.

๐Ÿงฑ Normal Merkle Trees

A standard Merkle tree stores every node. As more leaves are added, recomputing and storing all these hashes becomes very costly in gas and storage.

โœ‚๏ธ LeanIMTโ€™s Optimization

LeanIMT avoids this by:

  • Fixing the tree depth from the start (e.g. 16 levels โ†’ 65,536 leaves)
  • Prepopulating all positions with zero values (hashes of zero)
  • Storing only a handful of values called the frontier (at most one active node per level)

New leaves replace zeros, and only the frontier updates. Everything else is assumed to remain zero, so the root can always be recomputed.

๐Ÿ”„ Updating the Tree (Binary Counter Pattern)

Adding leaves works like a binary counter:

  • If a slot is empty โ†’ put the new leaf there.
  • If a slot is full โ†’ hash the two values, clear the slot, and carry the result up.
  • If the next slot is also full โ†’ repeat until an empty slot is found.

๐Ÿ’ก This is just like 0111 + 1 = 1000 in binary: lower bits reset, and the carry moves up.

โš–๏ธ Index Parity (Order Matters)

Since Hash(left, right) โ‰  Hash(right, left), parity decides position:

  • Even index leaves (0, 2, 4, โ€ฆ) go on the left
  • Odd index leaves (1, 3, 5, โ€ฆ) go on the right, triggering a merge with the left sibling

๐ŸŒฑ Example with 4 Leaves

  1. Leaf 0 (even) โ†’ slot 0, left child (Frontier: [L0, 0, 0โ€ฆ])

  2. Leaf 1 (odd) โ†’ slot 0 full โ†’ H01 = Hash(L0, L1) (L0 left, L1 right) โ†’ carried to slot 1 (Frontier: [0, H01, 0โ€ฆ])

  3. Leaf 2 (even) โ†’ slot 0, left child (Frontier: [L2, H01, 0โ€ฆ])

  4. Leaf 3 (odd) โ†’ slot 0 full โ†’ H23 = Hash(L2, L3) (L2 left, L3 right) โ†’ carried to slot 1 Slot 1 full โ†’ H0123 = Hash(H01, H23) (H01 left, H23 right) โ†’ carried to slot 2 (Frontier: [0, 0, H0123โ€ฆ])

๐Ÿ‘‰ At this point, the frontier has just one value (H0123 at slot 2), but together with zeros it defines the full root.

โšก Why Use LeanIMT?

  • Cheap & predictable: only the frontier updates
  • Append-only: leaves can be added but not removed
  • ZK-friendly: Poseidon roots work well in zero-knowledge proofs

To understand more about the LeanIMT, take a look at this visual explanation.

Merkle trees arenโ€™t required for ZK proofs, but theyโ€™re the most efficient way to prove membership in a large set. In our voting app, they let us cheaply and scalably prove โ€œIโ€™m registeredโ€ without showing who you are.

๐Ÿ’ก In Checkpoint 3, weโ€™ll dive deeper into what commitments are and how theyโ€™re used. For now, think of them as placeholders that users submit during registration.

โœ… Registration Rules

When a user registers, we enforce two key checks:

  1. Address hasnโ€™t registered before
  2. Commitment is unique (no duplicates)

๐Ÿ‘‰ If either fails โ†’ revert. ๐Ÿ‘‰ If another address tries to reuse the same commitment โ†’ reject as well.

๐Ÿ’ก Donโ€™t forget to implement the necessary state variables.

๐ŸŒฑ Inserting Into the Merkle Tree

Once checks pass:

  • Insert the new leaf into the incremental Merkle tree (LeanIMT) using the commitment as leaf value (library is already imported in Voting.sol). Call insert() directly on it.
  • Store the Merkle tree in a state variable: s_tree.
  • Emit an event with the leaf index + value:
    • Index = leafโ€™s position when inserted
    • Value = the commitment

๐Ÿ’ก Explore the LeanIMT library itself to see exactly what happens inside insert().

๐Ÿง  ๐Ÿฆ‰ Guiding Questions
โ“ Question 1

What conditions must be true for someone to register? (Hint: allowlist + not registered before)

โ“ Question 2

How will you detect and reject a reused commitment, even from another address?

โ“ Question 3

Which state variables do you need to track both users and commitments?

โ“ Question 4

When emitting NewLeaf, how will you determine the correct index? (๐Ÿ’ก Try tree.size(), but adjust carefully)

After thinking through the guiding questions, have a look at the solution code!

๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿซ Solution Code
///////////////////////
/// State Variables ///
///////////////////////

/// Checkpoint 2 //////
mapping(address => bool) private s_hasRegistered;
mapping(uint256 => bool) private s_commitments;

LeanIMTData private s_tree;

//////////////////
/// Functions ///
//////////////////

function register(uint256 _commitment) public {
  /// Checkpoint 2 //////
  if (!s_voters[msg.sender] || s_hasRegistered[msg.sender]) {
    revert Voting__NotAllowedToVote();
  }
  if (s_commitments[_commitment]) {
    revert Voting__CommitmentAlreadyAdded(_commitment);
  }
  s_commitments[_commitment] = true;
  s_hasRegistered[msg.sender] = true;
  s_tree.insert(_commitment);
  emit NewLeaf(s_tree.size - 1, _commitment);
}

๐Ÿ”ง Before Testing

Scroll down to the functions getVotingData() and getVoterData(address _voter) in your contract.

๐Ÿ‘‰ Uncomment everything below: /// Checkpoint 2 ///

Then run:

yarn test --grep "Checkpoint2"

๐Ÿš€ Tests Passed? Youโ€™re Almost There!

Great job! If your tests are passing, youโ€™re just one step away from deployment! ๐Ÿš€

Before deploying, make one important change:

  1. Open 00_deploy_your_voting_contract.ts
  2. Set your address as the ownerAddress
  3. Uncomment deployment of both poseidon3 and leanIMT
  4. Set LeanIMT library address (leanIMT.address) at line 62

๐Ÿ’ก Poseidon3 is the hash function we use. More on that later.

Once thatโ€™s done, youโ€™re ready to deploy! ๐Ÿ”—

Run yarn deploy and check out the front-end

๐Ÿฅ… Goals

  • Understand how lean incremental Merkle trees work
  • Implement the register function in Voting.sol

Checkpoint 3: โœ๏ธ๐Ÿ”’ Write Your First ZK Circuit โ€“ Commitment Scheme

Welcome to the Noir world! ๐ŸŽ‰

This is where weโ€™ll write our very first ZK circuit, the building block for generating an on-chain verifier.

The goal here: let a user prove they are registered to vote (their commitment is in the Merkle tree) without revealing their address.

๐Ÿ‘‰ Youโ€™ll use Noir, a DSL (domain-specific language) for ZK circuits, to generate a Solidity verifier contract โ€” something that would be extremely difficult to implement by hand.

๐Ÿง  What Are Zero-Knowledge Circuits?

A circuit is essentially a very complex math equation that represents a program. Noir lets us write a program and compile it into that math equation.

  • ๐Ÿ‘‰ Noir lets us write the โ€œprogramโ€, compile it into ZK math, and export a Solidity verifier contract.
  • Zero-knowledge magic makes it possible to prove and verify we know the solution without revealing the solution itself.

Why is this powerful?

  • ๐Ÿ”’ Preserves privacy
  • โšก Moves heavy computation away from expensive places (Ethereum blockchain) to cheap ones (your CPU/GPU)

๐Ÿ’ก ZK proofs let someone prove something is true without revealing any other details about it.

๐Ÿ”‘ Step 1: Understand Commitment Schemes

Before we dive into the Merkle root, we need to build the commitment.

Commitment schemes allow someone to โ€œlock inโ€ a value without revealing it, while keeping the option to reveal it later.

In our case:

  • We combine a nullifier and a secret (both private).
  • Hash them together โ†’ this is the commitment stored on-chain as a Merkle leaf when the user registers.

The nullifier plays a special role:

  • It lets the Solidity contract track if a proof has already been used.
  • If the same nullifier appears again, the vote is rejected (prevents double-voting).

๐Ÿ’ก Commitment schemes allow a user to commit to a chosen value (or values) while keeping it hidden, with the ability to reveal the value later.

๐Ÿ”‘ Step 2: Set Up Your Noir Project

Head to the folder packages/circuits and open src/main.nr. This is the entry point of your Noir circuit. Everything we want to prove โ€” and therefore all constraints โ€” must happen inside main.

  • Want to create the same structure in another project? Run nargo init in a new project. This creates the full folder structure along with a main.nr file.

Nargo is Noirโ€™s command-line tool. It lets you:

  • start new projects
  • compile circuits
  • run them
  • and test directly from the terminal

At the top of the file, you can see that there are already the two Poseidon hash functions imported:

  • hash_1 โ†’ hashes a single value
  • hash_2 โ†’ hashes two values

๐Ÿ’ก Poseidon3 is the hash function used to combine left/right child pairs in the LeanIMT binary Merkle tree. Itโ€™s chosen over SHA256/Keccak256 because itโ€™s much cheaper and faster inside ZK circuits, while Keccak256 is computationally very expensive to implement in proof systems.

๐Ÿ”‘ Step 3: Inputs

The main function already lists three parameters (others are commented for later checkpoints).

๐Ÿ” Private vs. Public Inputs

Public inputs (marked with the pub keyword) are visible to the verifier/on-chain and become part of the statement being proven.

Private inputs (also called witnesses) stay hidden. They never leave the proverโ€™s machine. Theyโ€™re only used locally to build the proof.

The verifier then checks this proof against the public inputs, which guarantees that the hidden private inputs existed and satisfied the circuitโ€™s constraints โ€” all without revealing them.

๐Ÿ“– Learn more about input visibility in the Noir docs.

Public input (revealed to verifier):

  • nullifier_hash: pub Field โ†’ the public hash of the private nullifier, tracked on-chain to enforce one-time voting

๐Ÿง  We hash the nullifier because it isnโ€™t exposed as a public input, otherwise everyone would see it. Using the hashed version adds an extra layer of privacy and ensures thereโ€™s no direct link between the nullifier and the commitment.

Private inputs (hidden witness values):

  • nullifier: Field โ†’ private value used both for nullifier_hash and commitment
  • secret: Field โ†’ private value combined with nullifier to form the commitment

๐Ÿ’ก The Field type is Noirโ€™s default number type. Itโ€™s like uint in Solidity, but always restricted to a finite range (a โ€œfieldโ€), so math stays consistent inside ZK circuits. See the docs here.

๐Ÿ”‘ Step 4: Assert and Re-Hash

Inside the circuit:

  1. Recompute the nullifier_hash by hashing the private nullifier.
  2. Use assert to check it equals the public nullifier_hash.

This guarantees:

  • The prover really knows the secret nullifier.
  • The nullifier itself stays hidden (only the hash is public).

๐Ÿ’ก assert ensures a condition must hold true for the proof to be valid. Itโ€™s the main check we do in circuits.

๐Ÿ”‘ Step 5: Build the Commitment

Now, hash the nullifier and secret together โ€” this is the commitment. Here we use hash_2.

  • The commitment is the leaf of the Merkle tree.
  • In the next checkpoint, weโ€™ll use it to calculate the Merkle root.

This setup ensures:

  • Privacy: the nullifier itself is never public.
  • Security: the nullifier_hash prevents someone from submitting the same proof multiple times (we track them inside our smart contract).

โœ… Well done!

๐Ÿง  At its core, a circuit is just a set of assertions, enforcing conditions that must always hold true. Thatโ€™s the heart of it.

You just wrote your first Noir circuit! ๐ŸŽ‰

๐Ÿฆ‰ Guiding Questions
โ“ Question 1

How can you re-create the nullifier_hash inside the circuit to check it matches the public input?

โ“ Question 2

How will you combine the nullifier and secret to generate the commitment?

After thinking through the guiding questions, have a look at the solution code!

๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿซ Solution Code
use std::hash::poseidon::bn254::hash_1;
use std::hash::poseidon::bn254::hash_2;

fn main(
    // public inputs
    nullifier_hash: pub Field,
    // private inputs
    nullifier: Field,
    secret: Field,
) {
    /// Checkpoint 3 //////
    let computed_nullifier_hash: Field = hash_1([nullifier]);
    assert(computed_nullifier_hash == nullifier_hash);

    let commitment: Field = hash_2([nullifier, secret]);
}

In here we wonโ€™t run any code. That will be done later.

๐Ÿฅ… Goals

  • Understand commitment schemes
  • Write your first Noir circuit and understand the assert function

Checkpoint 4: ๐ŸŒณโœ… Implement Root Check in ZK Circuit

Time to unlock the core of our circuit โ€” proving that the user really registered and has a commitment in the Merkle tree.

If the root you compute inside the circuit matches the public root on-chain, youโ€™re in and allowed to vote! ๐Ÿ—ณ๏ธ

๐Ÿ”‘ Whatโ€™s New Here?

We extend our circuit to handle Merkle proofs. That means:

  1. Traverse up the tree using your leaf index and siblings
  2. Recompute the root inside the circuit
  3. Compare it against the public root

If they match โ†’ โœ… youโ€™re a valid voter.

๐Ÿงฉ Step 1: Import the Merkle Helper

At the top of your circuit, uncomment the import of binary_merkle_root.

๐Ÿ‘‰ This comes from zk-kit, the Noir counterpart to the Solidity Merkle library used in your contract. Itโ€™s the tool weโ€™ll use to recompute the root inside the circuit.

๐Ÿงฉ Step 2: Extend the Inputs

We now need more inputs to make Merkle proofs work.

Public (visible / on-chain):

  • root: pub Field โ†’ the Merkle root of the tree state at the current moment.
    • Stored on-chain and used as the reference point.
    • The circuit recomputes a root and checks it matches this one.
  • vote: pub bool โ†’ the chosen option Yes or No (0 or 1).
    • It binds the proof to the voterโ€™s choice.
    • Even if someone front-runs the proof, the outcome would still be the same, since the vote is baked into the circuit.
  • depth: pub u32 โ†’ the Merkle treeโ€™s depth (number of levels).

Private (hidden witness values):

  • index: Field โ†’ the leafโ€™s position in the Merkle tree.
    • Must stay private, otherwise the proof would reveal which leaf belongs to which voter.
  • siblings: [Field; 16] โ†’ the array of neighbor hashes needed to climb from the leaf to the root.
    • Only siblings are included; parent nodes are recomputed inside the circuit.
    • Since Noir doesnโ€™t allow dynamic arrays, we use a fixed length of 16.
    • This supports up to 2^16 leaves (65,536 voters).

๐Ÿง  Arrays in Noir must be fixed length โ€” no dynamic arrays.

๐Ÿงฉ Step 3: Count Siblings

The siblings array is set to length 16, but not all entries are used.

  • Count the non-zero entries โ†’ this gives you the real path length, siblings_num.
  • Add a safety bound: assert that the claimed depth canโ€™t exceed the arrayโ€™s length of 16 (prevents out-of-bounds).

๐Ÿงฉ Step 4: Convert Index โ†’ Bits

Convert the leafโ€™s index into 16 little-endian bits using index.to_le_bits().

This mirrors how the Merkle tree is built from the bottom up. The bit array must align with the fixed siblings: [Field; 16].

  • Each bit tells if the node is on the left (0) or right (1) at that level, and controls the hashing order with siblings[i].

Example:

  • Leaf index 5 โ†’ [1,0,1,0,โ€ฆ] = right, left, right, left.

๐Ÿ’ก Sketch the Merkle tree to see how the index bits guide the path.

๐Ÿง  Little-endian means writing the binary digits starting with the rightmost bit (the smallest part of the number). For example, 4 in binary is 100, but in little-endian it becomes [0,0,1,โ€ฆ].

๐Ÿงฉ Step 5: Compute the Root

Now compute the Merkle tree root. Use binary_merkle_root from the imported zk-kit library.

๐Ÿ’ก See the function here for expected parameters. When passing the Merkle path depth, use siblings_num (the number of non-zero sibling hashes). This is safer since it reflects the actual path length rather than the full array size.

๐Ÿงฉ Step 6: Compare Roots

Finally, assert that your computed_root equals the public root input.

If they are the same โ†’ โœ… your commitment is valid and included in the tree.

๐Ÿงฉ Step 7: Bind the Vote

Before wrapping up, bind the vote to the proof:

  • The vote input is declared as a public boolean, so itโ€™s already restricted to 0 or 1.
  • But if we donโ€™t use it in a constraint, the compiler will warn that itโ€™s unused.

To fix this:

  1. Cast vote into a Field.
  2. Enforce the equation xยฒ = x.

โœ… Well done!

๐Ÿฆ‰ Guiding Questions
โ“ Question 1

How can you loop through the siblings array to count only the non-zero entries and store that number as siblings_num?

โ“ Question 2

What assert can you add to make sure the given depth never exceeds the maximum length of siblings (16)?

โ“ Question 3

How will you turn the leafโ€™s index into 16 little-endian bits with to_le_bits() so you know left vs. right at each level?

โ“ Question 4

Which inputs do you pass into binary_merkle_root to recompute the path up to the root? (Hint: The first input is the hash function โ€” you want to hash 2 values)

โ“ Question 5

How will you compare your computed_root against the public root to finish the membership proof?

โ“ Question 6

How do you cast and constrain vote so it becomes part of the circuit and avoids the โ€œunused variableโ€ warning?

After thinking through the guiding questions, have a look at the solution code!

๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿซ Solution Code
use std::hash::poseidon::bn254::hash_1;
use std::hash::poseidon::bn254::hash_2;
/// Checkpoint 4 //////
use binary_merkle_root::binary_merkle_root;

fn main(
    // public inputs
    nullifier_hash: pub Field,
    // private inputs
    nullifier: Field,
    secret: Field,
    /// Checkpoint 4 //////
    // public inputs
    root: pub Field,
    vote: pub bool,
    depth: pub u32,
    // private inputs
    index: Field,
    // max of 2^16 leaves --> 65536 leaves
    siblings: [Field; 16],
) {
    /// Checkpoint 3 //////
    let computed_nullifier_hash: Field = hash_1([nullifier]);
    assert(computed_nullifier_hash == nullifier_hash);

    let commitment: Field = hash_2([nullifier, secret]);

    /// Checkpoint 4 //////
    let mut siblings_num = 0;
    for i in 0..siblings.len() {
        if siblings[i] != 0 {
            siblings_num += 1;
        }
    }
    assert(depth <= siblings.len());

    let index_bits: [u1; 16] = index.to_le_bits();

    let computed_root = binary_merkle_root(hash_2, commitment, siblings_num, index_bits, siblings);

    assert(computed_root == root);

    // just vote binding, to prevent compiler warnings
    let vote_field = vote as Field;
    assert((vote_field * vote_field) == vote_field);
}

In here we wonโ€™t run again any code. We will do that later.

๐Ÿฅ… Goals

  • Recreate the Merkle root inside the circuit
  • Understand bit manipulation in Noir with little-endian

Checkpoint 5: ๐Ÿ“ Creating the Solidity Verifier Contract

Now that your circuit is set up and running, itโ€™s time to bring it to life on-chain. The end goal of this checkpoint is to generate the Solidity verifier contract, the bridge that lets Ethereum validate your zero-knowledge proofs.

Testing your Circuit inside your Noir folder (optional)
  1. cd into your circuits folder.
  2. Run nargo check. This generates a Prover.toml file where you can paste in the arguments you want the circuit to run with. Keep in mind: there can be quite a lot of inputs, and in our case itโ€™s not entirely straightforward, youโ€™ll need to calculate all the necessary hashes upfront before you can pass them in.
  3. Run nargo execute. This compiles your circuit and executes it locally using the inputs from Prover.toml. If everything works, it produces a witness file. For debugging, you can also log values from inside the circuit by calling std::println().

๐Ÿ’ก A witness is the full set of variable assignments that satisfy your circuitโ€™s constraints. It includes:

  • All public inputs
  • All private inputs
  • All intermediate values computed during execution

The witness file is the actual input the prover consumes in order to construct a proof.

Creating a proof with Barretenberg (bb)

After testing your circuit and producing a witness, the next step is to actually create a zero-knowledge proof.

๐Ÿ”น What is Barretenberg (bb)?

Barretenberg is the zero-knowledge proving system that Noir is built on.

  • Think of Noir (nargo) as the language and compiler: it takes your high-level program (main.nr) and compiles it into a mathematical object called an ACIR circuit.
  • Barretenberg (bb) is the engine: it runs the heavy cryptography to generate and verify proofs from those circuits.

Without bb, your Noir code is just a description of constraints. With bb, those constraints get turned into cryptographic proofs that others can trust.

๐Ÿ”น Step 1: Prove with bb prove

bb prove -b <bytecode> -w <witness> -o <output>

Hereโ€™s what happens:

  • -b โ†’ the circuit bytecode in circuits.json
  • -w โ†’ the witness file you created with nargo execute
  • -o โ†’ specifies the file path where the generated proof will be saved

bb takes the circuit + the solution (witness) and runs the UltraHonK proving scheme.

๐Ÿ’ก UltraHonK is the default proving scheme used by Barretenberg, a modern, highly optimized successor to PLONK-style systems.

Output: a proof file โ€” a tiny, verifiable object that says:

โ€œI know private inputs that satisfy the circuitโ€™s constraints, given these public inputs.โ€

Importantly, the proof reveals nothing about your private inputs.

๐Ÿ”น Step 2: Generate the Verification Key (vk)

Before you (or anyone else) can verify a proof, you need a verification key (vk).

  • The vk is a compact summary of the circuit: it encodes the rules and constraints of your program in a form the verifier can understand.
  • It is generated once per circuit (from the circuitโ€™s ACIR bytecode) and can then be reused for verifying any number of proofs created for that circuit, as long as the circuit doesnโ€™t change.

To generate it:

bb write_vk -b <bytecode> -o <output>
  • -b โ†’ the same bytecode from nargo compile (you'll find it in the circuits.json file)
  • -o โ†’ the file path where the verification key will be saved

๐Ÿ’ก When you run bb verify, the verifier must know what circuit the proof claims to satisfy. The vk is that reference. Without the vk, the proof file is meaningless โ€” it would be like having a lock but no keyhole to check against.

๐Ÿ”น Step 3: Verify the Proof

With both the proof file and the vk, anyone can check validity

bb verify -k <vk> -p <proof>
  • -k โ†’ the verification key
  • -p โ†’ the proof file

โœ… Off-chain, this runs instantly. ๐Ÿ”— On-chain, the vk is embedded into the Solidity verifier contract (see below).

๐Ÿ”น Step 1: Compile Your Circuit

๐Ÿšจ For all Noir (nargo or bb) commands, first cd into the packages/circuits directory.

Make sure all recent changes are applied:

nargo compile

๐Ÿ”น Step 2: Generate the Verification Key (vk)

Proof generation requires a verification key (vk). Itโ€™s the verifierโ€™s compact โ€œrulebookโ€ for your circuit.

Youโ€™ll need to generate a new vk any time you make a change to your circuit.

bb write_vk --oracle_hash keccak -b ./target/circuits.json -o ./target/

The vk summarizes your circuitโ€™s constraints (all those asserts you added!) and is reused for verifying all proofs (see Creating a proof with Barretenberg above for more context).

โš ๏ธ Use --oracle_hash keccak when creating a verification key so the hashing matches Ethereumโ€™s Keccak standard.

๐Ÿ”น Step 3: Generate the Solidity Verifier Contract

Now letโ€™s build the contract itself:

bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol

This creates a Verifier.sol file in packages/circuits/target. The vk is embedded into this contract, enabling Ethereum to check proofs generated for your circuit.

๐Ÿ” Inspecting the Contract

The verifier contract may look overwhelming at first, but hereโ€™s what to focus on:

  • โœ… Check that values like NUMBER_OF_PUBLIC_INPUTS match what you expect for your proof.
  • ๐Ÿšช The main entry point is the verify function:
interface IVerifier {
    function verify(bytes calldata _proof, bytes32[] calldata _publicInputs) external view returns (bool);
}

This is the function your application will call to confirm whether a proof is valid.

๐Ÿšจ๐Ÿšจ Always delete the files in the target folder when you change your circuit or inputs to ensure a clean setup. Whenever the circuit changes, you must also regenerate and replace the verifier smart contract in your Solidity project. ๐Ÿšจ๐Ÿšจ

โœ… Verifier Contract Created!

Youโ€™ve now successfully generated the Solidity verifier contract. This is the critical piece that connects your off-chain proof generation with on-chain proof verification.

Next, weโ€™ll integrate it into your project and actually put it to work.

๐Ÿฅ… Goals

  • Understand how witness, vk, and proof work together
  • Create the Solidity verifier contract

Checkpoint 6: ๐Ÿ—ณ๏ธ Enable Voting โ€“ Bring in the Verifier Contract

Youโ€™ve built the circuit, created the verifier contract โ€” now itโ€™s time to plug it into our Voting.sol and enable voting! ๐Ÿ—ณ๏ธ

๐Ÿ”น Step 1: Bring in the Verifier Contract

  1. Replace the placeholder verifier contract Verifier.sol in packages/hardhat/contracts with the newly generated contract located in packages/circuits/target.
  2. Open 00_deploy_your_voting_contract.ts and:
    • Uncomment the verifier deployment
    • Comment out the verifierAddress
    • Update the args to match your setup
  1. In Voting.sol:
    • At the top, import the verifier contract (just uncomment the existing line)
    • In the constructor, initialize the verifier and store it in a variable called i_verifier

๐Ÿ”น Step 2: Build the vote Function

Inside the vote function, voters will send their:

  • proof โ†’ the cryptographic proof (later built in the frontend)
  • public inputs โ†’ nullifierHash, root, vote, and tree depth

Before counting votes, we enforce some rules in the following order:

1. Validate the Root ๐Ÿ”

  • Check for empty tree root: this prevents voting when no one has registered yet.
  • Validate root matches current root state (s_tree.root()): This ensures the proof was generated against the actual on-chain Merkle tree, not some arbitrary tree.

Why this matters: Without root validation, an attacker could:

  • Create their own fake Merkle tree with commitments they control
  • Generate a mathematically valid ZK proof against that fake tree
  • Submit the proof with their fake root to the contract
  • The verifier would accept it (the proof is valid for that root!)
  • Result: They vote without ever registering on-chain, completely bypassing the allowlist system

๐Ÿ‘‰ The root check is what binds the proof to the actual on-chain registration tree. Without it, anyone could vote by creating fake trees. This is your first line of defense!

2. Verify the Proof ๐Ÿ”’

  • Call the verify() function on i_verifier and pass in the proof + public inputs.
  • The verifier expects public inputs as a bytes32[] array, in exactly the same order as in your circuit file.
  • If verification fails โ†’ revert the transaction.

3. Prevent Double-Voting ๐Ÿ›‘

  • Check if the _nullifierHash has already been used.
  • If yes โ†’ revert the transaction.
  • Track used nullifiers with a mapping: s_nullifierHashes.

๐Ÿ‘‰ Without this safeguard, anyone could replay the same proof/inputs over and over to vote multiple times. That's why nullifiers are the cornerstone of privacy-preserving voting.

๐Ÿ‘‰ Important: This check happens after proof verification. If the proof is invalid, we want to fail fast without wasting gas on state changes.

โœ… Once both checks pass:

  • Increment s_yesVotes or s_noVotes accordingly
  • Emit the VoteCast event
๐Ÿฆ‰ Guiding Questions
โ“ Question 1

What two root validation checks must you perform before any other logic, and why are they essential for security?

โ“ Question 2

Before writing the voting logic, how can you stop a _nullifierHash from being reused so no one can vote twice? Make sure to revert with the correct error.

โ“ Question 3

When passing inputs to the verifier, how do you build the bytes32[] array and in what order should you place _nullifierHash, _root, _vote, and _depth?

โ“ Question 4

After calling i_verifier.verify(_proof, publicInputs), what condition should you check, and what should happen if it fails?

โ“ Question 5

Once the proof is verified, how do you decide whether to increment s_yesVotes or s_noVotes and then emit the VoteCast event? (Hint: _vote comes as 0 or 1)

After thinking through the guiding questions, have a look at the solution code:

๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿซ Solution Code
///////////////////////
/// State Variables ///
///////////////////////

/// Checkpoint 6 //////
IVerifier public immutable i_verifier;
mapping(bytes32 => bool) private s_nullifierHashes;

//////////////////
/// Constructor ///
//////////////////

constructor(address _owner, address _verifier, string memory _question) Ownable(_owner) {
    s_question = _question;
    /// Checkpoint 6 //////
    i_verifier = IVerifier(_verifier);
}

//////////////////
/// Functions ///
//////////////////

function vote(bytes memory _proof, bytes32 _nullifierHash, bytes32 _root, bytes32 _vote, bytes32 _depth) public {
        /// Checkpoint 6 //////
        if (_root == bytes32(0)) {
            revert Voting__EmptyTree();
        }

        if (_root != bytes32(s_tree.root())) {
            revert Voting__InvalidRoot();
        }

        bytes32[] memory publicInputs = new bytes32[](4);
        publicInputs[0] = _nullifierHash;
        publicInputs[1] = _root;
        publicInputs[2] = _vote;
        publicInputs[3] = _depth;

        if (!i_verifier.verify(_proof, publicInputs)) {
            revert Voting__InvalidProof();
        }

        if (s_nullifierHashes[_nullifierHash]) {
            revert Voting__NullifierHashAlreadyUsed(_nullifierHash);
        }
        s_nullifierHashes[_nullifierHash] = true;

        if (_vote == bytes32(uint256(1))) {
            s_yesVotes++;
        } else {
            s_noVotes++;
        }

        emit VoteCast(_nullifierHash, msg.sender, _vote == bytes32(uint256(1)), block.timestamp, s_yesVotes, s_noVotes);
    }

Once implemented, run your tests to make sure everything works:

yarn test --grep "Checkpoint6"

โœ… Tests Passed? You're So Close!

If your tests are green, congratulations โ€” youโ€™ve just completed the core Voting.sol contract! ๐ŸŽ‰

That means you now have a fully private voting system where:

  • โœ… Eligibility is proven via ZK proofs
  • โœ… Double-voting is prevented
  • โœ… Results are transparently tracked

Run yarn deploy and then weโ€™ll move on to integrating the front-end so users can interact with your voting app.

๐Ÿฅ… Goals

  • Understand how nullifiers help with preventing double-voting
  • Correctly pass proof and public inputs into the proof verification

Checkpoint 7: ๐ŸŒฑ Create Your Commitment โ€“ The First Step to Registration

Youโ€™ve got your Voting.sol contract ready, and the verifier is hooked in.

Now itโ€™s time to let real users step into the process and bring the register button to life.

But hereโ€™s the deal: before anyone can vote, they need to prove theyโ€™re registered.

That starts right here, with creating a commitment.

Think of this as the secret handshake for your voting system:

  • Each user makes their own nullifier (to prevent double voting)
  • Pairs it with a secret (their private ticket)
  • And from these, they compute a commitment (the only thing that goes on-chain)

๐Ÿšจ Double-check that your circuit and front-end use the same Noir and bb versions. Mismatches will cause errors. In our nextjs/package.json, youโ€™ll see: "@aztec/bb.js": "0.82.0" and "@noir-lang/noir_js": "1.0.0-beta.3". Make sure these match the versions shown when you run bb --version and nargo --version.

๐Ÿ›  Set Up the Commitment Function

๐Ÿ“ Open packages/nextjs/app/voting/_challengeComponents/CreateCommitment.tsx.

The component is already written for you. Your task: implement the generateCommitment function.

Right now, it returns dummy values for commitment, nullifier, and secret. Those placeholders must go โ€” replace them with real logic!

๐Ÿ’ก Fr is the finite field type of BN254, ensuring all numbers (like nullifiers and secrets) stay within the valid scalar field used by ZK circuits.

Steps to Implement

  1. Uncomment the imports at the top.
  2. Generate fresh values: create a random nullifier and secret with Fr.random().
    • Cast them into string, then into BigInt.
  3. Hash with Poseidon: compute the commitment using Poseidon (2-input), in the exact order [nullifier, secret].

    This mirrors the same hashing logic you used in the circuit.

  4. Format for Solidity: convert your commitment into a bytes32 hex string.
    • Hint: use toHex from viem.
  5. Return all three values: commitment, nullifier, and secret.

๐Ÿง  Key Reminder: The commitment is public, but your nullifier and secret are private. Theyโ€™re the keys to proving your right to vote. Weโ€™ll store them in local storage here so you can reuse them later for proof generation. โš ๏ธ If they leak, someone else could vote on your behalf!

๐Ÿฆ‰ Guiding Questions
โ“ Question 1

How can you use Fr.random() to generate values that stay inside the BN254 field, and why do you need to cast them to BigInt before further use?

โ“ Question 2

What happens if you swap the input order in Poseidon (e.g., [secret, nullifier] instead of [nullifier, secret])?

โ“ Question 3

How will you ensure that your commitment, nullifier, and secret are all formatted as valid bytes32 hex strings that Solidity will accept?

After thinking through the guiding questions, have a look at the solution code:

๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿซ Solution Code
const generateCommitment = async (): Promise<CommitmentData> => {
  /// Checkpoint 7 //////
  const nullifier = BigInt(Fr.random().toString());
  const secret = BigInt(Fr.random().toString());
  const commitment = poseidon2([nullifier, secret]);

  const commitmentHex = toHex(commitment, { size: 32 });
  const nullifierHex = toHex(nullifier, { size: 32 });
  const secretHex = toHex(secret, { size: 32 });

  return {
    commitment: commitmentHex,
    nullifier: nullifierHex,
    secret: secretHex,
  };
};

โœ… Implemented?! Great job!

Head over to localhost:3000 and add yourself to the allowlist. Then go ahead and register.

If everything went smoothly, you should now see:

registered-zk

๐ŸŽ‰ Now your secret and nullifier are hidden in the smart contract's Merkle root. Only you know them โ€” or better, your browser does ๐Ÿ™‚ (theyโ€™re stored in local storage)!

๐Ÿšจ Each time you restart your local chain with yarn chain, scroll down to the bottom of the Voting page and hit Clear Local Storage. Otherwise, since the contracts reuse the same addresses, the front-end will mistakenly think youโ€™ve already created a commitment for that contract.

๐Ÿ’ก You can inspect the commitment saved in localStorage. Just scroll to the bottom of the page and click Log Local Storage.

loglocalstorage-zk

๐Ÿฅ… Goals

  • Learn how to format values into the correct types expected by the verifier contract
  • Understand how to perform hashing with Poseidon in TypeScript

Checkpoint 8: ๐Ÿ” Generate Your Proof

Youโ€™ve created your commitment and registered successfully.

Now comes the magic moment: creating the proof that youโ€™re in the Merkle tree.

This step is all about producing a zero-knowledge proof that says:

โ€œIโ€™m registered, I know my nullifier + secret, and therefore I can show my membership in the tree.โ€

๐Ÿ›  Set Up the Proof Generation Function

๐Ÿ“ Open packages/nextjs/app/voting/_challengeComponents/GenerateProof.tsx.

๐Ÿ’ก In this step we create two proofs:

  1. Merkle inclusion proof (to gather the siblings we need),
  2. ZK proof that gets sent to the Solidity verifier.

The Merkle proof is like a little trick, it supplies the path, and the ZK proof wraps this together with nullifier, secret, and vote.

๐ŸŽฏ Your goal: finish the generateProof function. The rest is already in place.

  1. Uncomment imports

  2. Remove the void + return statement and start implementing

  3. Compute the nullifierHash (Hint: poseidon1)

  4. Rebuild the Merkle tree

    • Initialize a new tree with Poseidon2 hashing (Hint: new LeanIMT)
    • Extract values from events โ†’ _leaves is an array of contract events. Each event holds a value (the on-chain commitment). The map pulls out just those values into a clean array leaves.
    • Reverse the order โ†’ Events are emitted newest-last, but our Merkle tree builds from oldest-first. Reversing ensures the tree is reconstructed correctly.
    • Insert into LeanIMT to reconstruct the exact tree state (Hint: .insertMany())

      ๐Ÿ’ก We imported the same zk-kit/lean-imt library, but this time for TypeScript.

  5. Create Merkle tree inclusion proof

    • Call .generateProof(_index) on the calculatedTree.
    • From the result, access .siblings and turn it into an array.
    • Add "0" placeholders so the siblings array always has the fixed length expected by the circuit (16).
  6. Prepare circuit inputs (input)

    • Ensure all values are in the same order as in your circuit.
    • Convert everything into strings (siblings should be a string array).
  7. Create the witness

    • Initialize Noir circuit instance: const noir = new Noir(_circuitData);. This loads your compiled ZK circuit into JavaScript so you can run it.
    • Run: const witness = await noir.execute(input);. This returns the witness.
    • Use console.log if you want to inspect how it looks.
  8. Generate the ZK proof

    • Initialize the proving backend: const honk = new UltraHonkBackend(_circuitData.bytecode, { threads: 1 });

    • Generate the proof: honk.generateProof(witness, {keccak: true});. This takes the witness from Noir and produces the actual ZK proof plus its public inputs, using Keccak hashing for consistency with the verifier.

    • For debugging, you can console.log your proof (make sure the silence logs around the .generateProof, temporarily override)

    ๐Ÿ’ก The UltraHonk backend is the proving engine that takes your circuitโ€™s bytecode + witness to generate the zero-knowledge proof. It produces the same format your Solidity verifier expects. The threads option controls how many CPU cores are used (1 is enough, more threads = faster).

  9. Format the result for Solidity

    • Encode [proof, publicInputs] with encodeAbiParameters from viem. Make sure each value is in hex format.
    • (Hint: check your Voting.sol contract for the expected format.)
๐Ÿฆ‰ Guiding Questions
โ“ Question 1

How will you compute nullifierHash with poseidon1, and what input format must _nullifier be in? (Hint: BigInt)

โ“ Question 2

Events are emitted newest-last on chain. How will you transform _leaves so the IMT receives them oldest-first?

โ“ Question 3

How do you make sure your siblings array has a length of 16?

โ“ Question 4

In what exact order and types does your circuit declare inputs?

After thinking through the guiding questions, have a look at the solution code:

๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿซ Solution Code
const generateProof = async (
  _root: bigint,
  _vote: boolean,
  _depth: number,
  _nullifier: string,
  _secret: string,
  _index: number,
  _leaves: any[],
  _circuitData: any,
) => {
  /// Checkpoint 8 //////
  const nullifierHash = poseidon1([BigInt(_nullifier)]);
  const calculatedTree = new LeanIMT((a: bigint, b: bigint) =>
    poseidon2([a, b]),
  );
  const leaves = _leaves.map((event) => {
    return event?.args.value;
  });
  const leavesReversed = leaves.reverse();
  calculatedTree.insertMany(leavesReversed as bigint[]);
  const calculatedProof = calculatedTree.generateProof(_index);
  const sibs = calculatedProof.siblings.map((sib) => {
    return sib.toString();
  });

  const lengthDiff = 16 - sibs.length;
  for (let i = 0; i < lengthDiff; i++) {
    sibs.push("0");
  }
  const input = {
    nullifier_hash: nullifierHash.toString(),
    nullifier: BigInt(_nullifier).toString(),
    secret: BigInt(_secret).toString(),
    root: _root.toString(),
    vote: _vote,
    depth: _depth.toString(),
    index: _index.toString(),
    siblings: sibs,
  };
  try {
    const noir = new Noir(_circuitData);
    const { witness } = await noir.execute(input);
    console.log("witness", witness);
    const honk = new UltraHonkBackend(_circuitData.bytecode, { threads: 1 });
    const originalLog = console.log;
    console.log = () => {};
    const { proof, publicInputs } = await honk.generateProof(witness, {
      keccak: true,
    });
    console.log = originalLog;
    console.log("proof", proof);
    const proofHex = toHex(proof);
    const inputsHex = publicInputs.map((x) =>
      typeof x === "string"
        ? (x as `0x${string}`)
        : toHex(x as Uint8Array, { size: 32 }),
    );
    const result = encodeAbiParameters(
      [{ type: "bytes" }, { type: "bytes32[]" }],
      [proofHex, inputsHex],
    );
    console.log("result", result);
    return { proof, publicInputs };
  } catch (error) {
    console.log(error);
    throw error;
  }
};

โœ… Implemented?! Well done!

Fire up localhost:3000, pick your voting option, and hit Generate Proof.

generateproof-zk

Once the proof is created, the button will update to show Proof already exists.

proofgenerated-zk

๐Ÿ‘‰ If you added a console.log, check your dev console to see the raw proof output. ๐Ÿ‘‰ Alternatively, scroll to the bottom of the page and click Log Local Storage to print out the proof data saved in your browser.

๐Ÿฅ… Goals

  • Rebuild the Merkle tree and generate the siblings proof
  • Format inputs correctly for the circuit
  • Generate a valid ZK proof with Noir + UltraHonk

Checkpoint 9: ๐Ÿ”ฅ Create a Burner Wallet on Hardhat to Send Your Vote

Youโ€™ve got your ZK proof ready โ€” now itโ€™s time to actually cast your vote.

But โš ๏ธ hereโ€™s the catch: if you use the same address you registered with, everyone can see on-chain which address voted and how they voted. That would undo all the effort we just put into Noir, Merkle trees, and proof generation.

So instead, weโ€™ll send the vote from a fresh burner wallet, an address that has no link to your registration wallet or any past transactions. That way, only the proof ties you to your vote, nothing else.

๐Ÿง ๐Ÿ’ก The key idea: break the link between registration address and voting address. The proof is the only bridge, and only you know it, preserving your privacy.

๐Ÿ›  Set Up the Burner Wallet Voting

๐Ÿ“ Open packages/nextjs/app/voting/_components/VoteWithBurnerHardhat.tsx

๐Ÿ’ก Remove the placeholders and implement the logic below.

Youโ€™ll be working in two functions:

  • generateBurnerWallet
  • sendVoteWithBurner

1. Generate a fresh burner wallet

  • In generateBurnerWallet, create a brand-new wallet.
  • Use viem to generate this new keypair.

2. Fund the burner (local Hardhat only)

  • Inside sendVoteWithBurner, fund your wallet within the local Hardhat environment.
  • Send ETH from the first signer account so the burner has enough gas to vote.

3. Call the vote function

  • With the burner funded, call the vote function on your Voting.sol contract.
  • Pass in both the proof and the public inputs.
  • Retrieve them directly from proofData in localStorage.
  • Format the proof correctly (Hint: uint8[], you can use the helper function uint8ArrayToHexString).
  • Ensure the public inputs are passed in the exact order the circuit expects.
๐Ÿฆ‰ Guiding Questions
โ“ Question 1

How do you generate a fresh wallet using viem?

โ“ Question 2

How can you fund the generated wallet with viem?

โ“ Question 3

Where in localStorage can you find proofData, and how do you access it in your code?

โ“ Question 4

How do you ensure the public inputs are passed in the exact order the circuit expects? (Hint: not much to do ๐Ÿ™‚)

After thinking through the guiding questions, have a look at the solution code:

๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿซ Solution Code
const sendVoteWithBurner = async ({
  viemContract,
  publicClient,
  walletAddress,
  proofData,
}: {
  viemContract: any;
  publicClient: ReturnType<typeof createPublicClient>;
  walletAddress: `0x${string}`;
  proofData: LocalProofData;
}): Promise<string> => {
  /// Checkpoint 9 //////
  const needed = parseEther("0.01");
  const bal = await publicClient.getBalance({ address: walletAddress });
  if (bal < needed) {
    const testClient = createTestClient({
      chain: hardhat,
      mode: "hardhat",
      transport: http("http://localhost:8545"),
    });
    await testClient.setBalance({ address: walletAddress, value: needed });
  }

  const hash = await viemContract.write.vote([
    uint8ArrayToHexString(proofData.proof),
    proofData.publicInputs[0],
    proofData.publicInputs[1],
    proofData.publicInputs[2],
    proofData.publicInputs[3],
  ]);
  const receipt = await publicClient.waitForTransactionReceipt({ hash });
  return receipt.transactionHash;
};

const generateBurnerWallet = () => {
  /// Checkpoint 9 //////
  const privateKey = generatePrivateKey();
  const account = privateKeyToAccount(privateKey);
  const wallet = {
    privateKey: privateKey as `0x${string}`,
    address: account.address as `0x${string}`,
  };
  setBurnerWallet(wallet);

  const effectiveContractAddress = contractAddress || contractInfo?.address;
  if (effectiveContractAddress && userAddress) {
    saveBurnerWalletToLocalStorage(
      wallet.privateKey,
      wallet.address,
      effectiveContractAddress,
      userAddress,
    );
  }

  return wallet;
};

๐ŸŽ‰ Implemented?! Letโ€™s Vote!

Letโ€™s click Vote and send your vote out. Your voting decision was already defined when you created the proof โ€” itโ€™s bound to that proof, so no one can front-run or alter it.

Click Vote and cast your ballot.

voted-zk

If everything went well, you should now see your vote counted (in this example: Yes = 1).

votingstats-zk

๐ŸŽŠ Congrats, youโ€™ve just cast a private vote!

๐Ÿฅ… Goals

  • Create and fund a burner wallet with viem
  • Use it to submit your vote with proof + public inputs
  • Understand why a burner wallet is needed

Checkpoint 10: ๐ŸŒ Prepare to Vote on a Real Network

You proved inclusion and voted locally. Now letโ€™s make it real: Send the same privacy-preserving vote on the Sepolia testnet.

Here it gets a bit trickier to fund the account with a neutral address that isnโ€™t linked to you. Thatโ€™s why we use ERC-4337 (Account Abstraction) with a verifying paymaster to cover gas costs.

๐Ÿ’ก To keep things simple, weโ€™re using Pimlico, a third-party ERC-4337 provider. It handles your UserOperations through a bundler and covers gas with a paymaster. Setup in TypeScript is straightforward.

๐Ÿšจ First, sign up at Pimlico, grab your API key, and drop it into your .env file. On Sepolia, you can use it for free.

Youโ€™ll be working in two functions:

  • createSmartAccount
  • voteOnSepolia

๐Ÿ“ Open packages/nextjs/app/voting/_components/VoteWithBurnerSepolia.tsx

๐Ÿ›  Create the Smart Account Wallet

  1. Generate a new private key and wallet (fresh, never used).

  2. Set up a public client connected to Sepolia.

  3. Use toSafeSmartAccount from the permissionless library to create your smart account.

const account = await toSafeSmartAccount({
  client: publicClient,
  owners: [wallet],
  version: "1.4.1",
});
  1. Build a smartAccountClient with createSmartAccountClient, this is what youโ€™ll use later to send your vote.
const smartAccountClient = createSmartAccountClient({
  account,
  chain: CHAIN_USED,
  bundlerTransport: http(pimlicoUrl),
  paymaster: pimlicoClient,
  userOperation: {
    estimateFeesPerGas: async () => {
      return (await pimlicoClient.getUserOperationGasPrice()).fast;
    },
  },
});
  1. Finally return smartAccountClient, smartAccount, walletOwner

๐Ÿ›  Cast the Vote

  1. Before sending the transaction to the bundler, first build the calldata using viemโ€™s encodeFunctionData (with the same args as in the previous checkpoint).

  2. Next, use your smartAccountClient to send the transaction with .sendTransaction and capture the resulting UserOpHash.

  3. Finally, return this hash.

๐Ÿฆ‰ Guiding Questions
Question 1

How do you generate a brand-new private key and wallet in your setup so itโ€™s never linked to your registration address?

Question 2

What role does the Safe smart account (via toSafeSmartAccount) play compared to a normal EOA?

Question 3

Why do we need a bundler and a paymaster when sending the transaction on Sepolia, instead of just sending it directly?

Question 4

Once you send the transaction, where can you look up the UserOpHash to confirm that your vote was included on-chain?

After thinking through the guiding questions, have a look at the solution code!

๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿซ Solution Code
const createSmartAccount = async (): Promise<{
  smartAccountClient: any;
  smartAccount: `0x${string}`;
  walletOwner: `0x${string}`;
}> => {
  try {
    /// Checkpoint 10 //////
    const privateKey = generatePrivateKey();
    const wallet = privateKeyToAccount(privateKey);
    const publicClient = createPublicClient({
      chain: CHAIN_USED,
      transport: http(RPC_URL),
    });
    const account = await toSafeSmartAccount({
      client: publicClient,
      owners: [wallet],
      version: "1.4.1",
    });
    const smartAccountClient = createSmartAccountClient({
      account,
      chain: CHAIN_USED,
      bundlerTransport: http(pimlicoUrl),
      paymaster: pimlicoClient,
      userOperation: {
        estimateFeesPerGas: async () => {
          return (await pimlicoClient.getUserOperationGasPrice()).fast;
        },
      },
    });
    return {
      smartAccountClient,
      smartAccount: account.address as `0x${string}`,
      walletOwner: wallet.address as `0x${string}`,
    };
  } catch (error) {
    console.error("Error creating smart account:", error);
    throw error;
  }
};

const voteOnSepolia = async ({
  proofData,
  contractInfo,
  contractAddress,
  smartAccountClient,
}: {
  proofData: any;
  contractInfo: any;
  contractAddress: any;
  smartAccountClient: any;
}): Promise<{ userOpHash: `0x${string}` }> => {
  if (!contractInfo && !contractAddress) throw new Error("Contract not found");
  /// Checkpoint 10 //////
  const callData = encodeFunctionData({
    abi: (contractInfo?.abi as any) || ([] as any),
    functionName: "vote",
    args: [
      toHex(proofData.proof),
      proofData.publicInputs[0], // _nullifierHash
      proofData.publicInputs[1], // _root
      proofData.publicInputs[2], // _vote
      proofData.publicInputs[3], // _depth
    ],
  });

  const userOpHash = await smartAccountClient.sendTransaction({
    to: (contractAddress || contractInfo?.address) as `0x${string}`,
    data: callData,
    value: 0n,
  });

  return { userOpHash };
};

๐Ÿš€ Checkpoint Accomplished!

Great work! Now head to the next checkpoint where weโ€™ll deploy everything to Sepolia and finally cast your vote on-chain for real.

๐Ÿฅ… Goals

  • Learn how to set up a smart account with ERC-4337 (via Pimlico)
  • Use a bundler + paymaster (via Pimlico) to send a sponsored transaction

Checkpoint 11: ๐Ÿ’พ Deploy your contracts and vote on Sepolia! ๐Ÿ›ฐ

Configuration of Third-Party Services for Production-Grade Apps

By default, ๐Ÿ— Scaffold-ETH 2 provides predefined API keys for popular services such as Alchemy and Etherscan. This makes it easy to start building and complete your Speedrun Ethereum without additional setup.

For production-grade apps, you should generate your own API keys to avoid hitting rate limits and and to ensure integrations like Pimlico work properly.

Configure your keys here:

๐Ÿ’ฌ Hint: Store environment variables for Next.js in Vercel/system env config for live apps, and use .env.local for local testing.

Deploying Your Smart Contracts

๐Ÿ” You will need to generate a deployer address using yarn generate. This creates a mnemonic and saves it locally.

๐Ÿ‘ฉโ€๐Ÿš€ Use yarn account to view your deployer account balances.

โ›ฝ๏ธ You will need to send ETH to yourย deployer addressย with your wallet, or obtain it from a public faucet of your chosen network.

๐Ÿšจ Don't forget to set the owner address inside the 00_deploy_your_voting_contract.ts.

๐Ÿš€ Run yarn deploy --network sepolia to deploy your smart contract to Sepolia.

๐Ÿ’ฌ Hint: You can set the defaultNetwork in hardhat.config.ts to sepolia OR you can yarn deploy --network sepolia.

๐Ÿ’ป Inside scaffold.config.ts change the targetNetwork to chains.sepolia. View your front-end atย http://localhost:3000 and verify you see the correct network Sepolia.

Deploying Your Frontend to Vercel

โš ๏ธ Vercel/static deploys: copy the circuit bytecode into the Next.js public folder so the frontend can fetch it at runtime.

Run this before deploying:

cp packages/circuits/target/circuits.json packages/nextjs/public/circuits.json

๐Ÿ“ฆ Runย yarn vercelย to package up your front-end and deploy.

You might need to log in to Vercel first by running: yarn vercel:login. Once you log in (via email, GitHub, etc.), the default options should work. If you want to redeploy to the same production URL: yarn vercel --prod. If you omit the --prod flag it will deploy to a preview/test URL. Follow the steps to deploy to Vercel โ€” youโ€™ll get a public URL.

๐Ÿš€ ๐Ÿ”ฅ Challenge Conquered!

Now vote! Register yourself, create the proof, and hit the Vote button.

sepoliavote-zk

๐ŸŽ‰ Congrats! Youโ€™ve just pulled it off โ€” an entire privacy-preserving voting app running fully on-chain.

Take a moment to celebrate, this is big! ๐Ÿฅณ Give yourself a pat on the back (or two ๐Ÿ‘๐Ÿ‘).

Checkpoint 12: ๐Ÿ“œ Contract Verification

Run the yarn verify --network your_network command to verify your contracts on etherscan ๐Ÿ›ฐ

๐Ÿ‘‰ Search this address on Sepolia Etherscan to get the URL you submit to ๐Ÿƒโ€โ™€๏ธSpeedRunEthereum.com.

Checkpoint 13: ๐Ÿง ๐Ÿ” Final Considerations & Food for Thought

You now have a working ZK voting dApp, a big milestone! ๐Ÿš€

This last checkpoint isnโ€™t about more code, but about thinking like a builder shipping to production.

โš ๏ธ Root history matters

  • Using only the latest root works for demos, but in production it causes stale proof failures: a user generates a proof โ†’ another registers โ†’ the root changes โ†’ their vote reverts.
  • Fix: keep a ring buffer of recent roots (e.g., last 30โ€“100). New roots overwrite the oldest.
  • When verifying, accept proofs against any root in history.
  • Benefit: fewer failed txs, smoother UX, resilience against congestion.
  • Trade-off: slightly higher storage/gas costs, but worth it for reliability.

๐Ÿ” Privacy depends on people

  • In ZK systems, your privacy comes from the anonymity set โ†’ the group of all participants you could plausibly be.
  • With only 1โ€“2 voters, itโ€™s trivial to guess who voted what. True privacy emerges only as more commitments join the set.
  • Larger set = stronger privacy. Observers canโ€™t tell which registered user cast a given vote.
  • Good UX: show current anonymity set size (e.g., โ€œ12 registered votersโ€) so participants understand their privacy.
  • Some apps even enforce a minimum set size before voting begins.

๐Ÿ—ณ Registration strategy

  • In production, define a clear registration period before voting starts.
  • If users can register + vote immediately, observers may correlate actions, especially with low participation.
  • A dedicated registration window lets commitments accumulate โ†’ stronger anonymity set.
  • Once registration ends, voting opens โ†’ privacy improves since votes canโ€™t be tied to registration timing.

๐Ÿ›  Indexing is key

  • For demos, rebuilding the Merkle tree in-browser works, but itโ€™s slow/unreliable.
  • In production, run a dedicated indexer:
    • Listen to contract events
    • Rebuild the tree off-chain
    • Store roots + siblings
  • Benefits:
    • No heavy browser computations
    • Correct data even during reorgs
    • Faster, smoother UX + single trusted source of truth

โ›ฝ๏ธ Gas sponsorship

  • In this challenge, Pimlico made gasless voting easy.
  • In production, consider running your own verifying paymaster:
    • Define rules โ†’ per-poll budgets, rate limits, or policies
    • Avoid reliance on third parties
    • Full visibility into costs
  • Result: customizable, reliable, decentralized gas sponsorship.

โš ๏ธ Limitations, not MACI

This challenge does not implement the full MACI (Minimal Anti-Collusion Infrastructure) framework. While it showcases commitment + nullifierโ€“based privacy, it does not provide coercion resistance.

In this version, a voterโ€™s choice is baked into the calldata, and by revealing their proof, they can prove how they voted afterwards.

That means vote selling or coercion is still possible.

True coercion resistance (as provided by MACI) requires an extra mechanism, typically a key-change step or coordinator, so that voters canโ€™t prove their actual choice even if they try.

If you want to go deeper into why this matters, and how systems like MACI and multi-party computation solve it, read Vitalik Buterinโ€™s essay.

If you want to see a real-world project that implements MACI, check out maci.pse.dev.

๐ŸŒฑ Beyond voting

You now understand the core mechanics of ZK voting:

โœ” Commitments & nullifiers โœ” Merkle proofs & trees โœ” Noir circuits & constraints โœ” Solidity verifiers โœ” Anonymous vote casting

But this is just the beginning. The same commitment + nullifier pattern that powers your voting system also unlocks a world of other privacy-preserving applications:

  • Mixers (unlink deposits and withdrawals)
  • Shielded ERC-20 transfers
  • Quadratic voting
  • New governance models

This challenge is your entry point into a new design space. ๐Ÿ’ฅ

What will you build with Noir and ZK circuits? ๐Ÿงชโœจ

๐Ÿš€ Ready to submit your challenge?

You're viewing this challenge as a guest. Want to start building your onchain portfolio?