Cover photo

ZK Anonymous Voting Protocol with Circom

TLDR - Guide content:

Step-by-step guide/tutorial for an anonymous voting protocol based on Circom language.

⚠️ DISCLAIMER ⚠️

This is “first tutorial attempt”. Remember first time on a bike? Exactly.

The guide is incomplete → it has all the code, but execution is on hold because the Poseidon hash generated in Circom doesn’t match the one calculated using the JavaScript libraries

I’m digging into it and investigating to figure out the issue—stay tuned!

→ For a silly overview of this guide, go to Context. To jump straight to the implementation, skip ahead to the Step-by-Step guide - The Why.

Context

“Build a portfolio” - they said.

It’s clear in the web3 space, especially if you are a developer, or a tech person, you will probably end up hearing those words, and needing a portfolio. A visual proof of work.

Being and working in the crypto means having a portfolio, a GitHub repo for developers. Now, I’m one of those engineers who just doesn’t have the time. My extra time is reserved for offline activities. And if you don’t exactly have the best storytelling skills and your written output isn’t all that impressive either—jackpot! I also barely tweet. And when I do tweet, not counting photos of sunsets or food, it’s probably just to appease the “build a portfolio” mantra and scrape by with a passing grade.

Just acknowledging someone physically attends an hackathon feels like a portfolio itself. I mean, between spending a 4am Berlin Friday night deploying a smart contract at ETHBerlin or going to a party — are those two things even comparable? Exactly. Just participating is portfolio-worthy. The proof-of-work-est pow thing ever. But reality check says it’s a no. Gotta need the material proof.

Also engineers are engineers for a reason. I guess the engineer-to-storyteller connection is not bidirectional. Sometimes A implies B but B doesn’t imply A. That said, I’m a huge fan of optimisation and automation, and since not being at Berghain at 4 a.m. on a Friday night is not enough, here a gamified way for the proof of portfolio. A combination of mirror.xyz master, as a pro DevRel, the line by line documentation as the most skilled and meticulous developer and the vain satisfaction and pleasure of developing a ZK guide with a Hello Kitty cover image and every single implementation step included. Build a portfolio they said.

This is a kind invite to join this “engineer-to-storyteller” bridge attempt. Welcome.

Step-by-step guide - The Why

This guide is born from my interest in privacy-preserving tools applied to potential mundane use cases (legal contracts, voting, official identity documentation) and revisiting decentralization concepts through the lens of a non-crypto person. One of those which might be useful in the future, is using a mathematical zero knowledge proof to verify vote (whatever type of vote, politics, initiatives, events, etc).

While researching Noir, a general purpose programming language for privacy-preserving ZK programs, I found this Circom Multiplexer. The maxi of automation I am asked Claude AI (Remember the maxi of automation) to tailor the Mux code and sketch the initial draft of this privacy-preserving voting system.

The general explanation:

post image

The context explanation:

post image

The voting Circom code:

pragma circom 2.0.0;

include "node_modules/circomlib/circuits/poseidon.circom";
include "node_modules/circomlib/circuits/comparators.circom";

template PrivateVoteSelection(candidates) {
    // Private inputs 
    signal input voter_secret;        // Voter's private key
    signal input vote_commitment;     // Cryptographic commitment of vote
    signal input selected_candidate_index;  // Index of selected candidate

    // Public inputs
    signal input candidate_list[candidates];  // List of candidates
    signal output vote_valid;                 // Validation output

    // Commitment verification component
    component hasher = Poseidon(2);  // Cryptographic hash function

    // Candidate index validation
    component index_check = LessThan(candidates);

    // Commitment generation
    hasher.inputs[0] <== voter_secret;
    hasher.inputs[1] <== selected_candidate_index;

    // Verify commitment matches generated hash
    vote_commitment === hasher.out;

    // Validate candidate index is within range
    index_check.in[0] <== selected_candidate_index;
    index_check.in[1] <== candidates;
    index_check.out === 1;

    // Ensure single candidate selection
    signal one_hot[candidates]; // Predeclare the array of signals
    signal temp_sum[candidates]; // Temporary signal array for sum
    signal one_hot_sum;

    // Initialize signals
    for (var i = 0; i < candidates; i++) {
        one_hot[i] <== 0; // Explicitly initialize each signal

        // Enforce (selected_candidate_index - i) * one_hot[i] = 0
        (selected_candidate_index - i) * one_hot[i] === 0;

        // Enforce one_hot[i] is binary (0 or 1)
        one_hot[i] * (one_hot[i] - 1) === 0;
    }

    // Compute the cumulative sum
    temp_sum[0] <== one_hot[0];
    for (var i = 1; i < candidates; i++) {
        temp_sum[i] <== temp_sum[i - 1] + one_hot[i];
    }

    // Assign the final sum
    one_hot_sum <== temp_sum[candidates - 1];

    // Ensure exactly one candidate is selected
    one_hot_sum === 1;

    // Output validation flag
    vote_valid <== 1;
}


// Example usage template
template VotingProtocol() {
    var NUM_CANDIDATES = 3;
    signal input candidates[NUM_CANDIDATES];
    signal input voter_secret;
    signal input vote_commitment;
    signal input selected_candidate_index;

    component voteCircuit = PrivateVoteSelection(NUM_CANDIDATES);
    
    // Wire up inputs
    for (var i = 0; i < NUM_CANDIDATES; i++) {
        voteCircuit.candidate_list[i] <== candidates[i];
    }
    voteCircuit.voter_secret <== voter_secret;
    voteCircuit.vote_commitment <== vote_commitment;
    voteCircuit.selected_candidate_index <== selected_candidate_index;
}

component main = VotingProtocol();

Intro part is over, let’s start with the real implementation. (I’d 100% rather run a thousand Tau ceremonies than write intro—also I won’t even pretend I’m okay with it)

Step-by-step guide - Installation

Required Tools

  1. Circom Compiler

  2. Snarkjs (JavaScript library for zk-SNARKs)

  3. Node.js

  4. Trusted Setup Tools

1. Circom Compiler

For the Circom compiler, Claude provided a deprecated version with npm. I then followed the official Circom guide:

  • I create main folder for the whole project: mkdir voting-circuit-test

  • Locate myself in that directory: cd voting-circuit-test

post image
  • Run the first command from the official guide: curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

  • Install circom: git clone https://github.com/iden3/circom.git

    • This command created a subdirectory called 'circom' inside the main folder.

post image
  • Enter circom directory: cd circom

  • Execute cargo build --release

    • This will generate the folder “circom” in the directory target/release

post image
  • At this point you might encounter this terminal error “zsh: command not found: cargo”, as it happened to me. I solved it by following this suggestion:

    • If for any reason you still encounter 'zsh: command not found: cargo,' it's probably a path issue. I recommend checking on developer platforms (Stackoverflow, etc) or asking an AI

post image
  • Install this binary with command cargo install --path circom , it will install the circom binary in the directory $HOME/.cargo/bin

  • Install snarkjs with npm install -g snarkjs

    • This command requires “npm” and Node.js installed. If you don’t have, Node.js website has a handy guide in their download section.

Time to test the voting system!

Step-by-step guide - Testing

  • In the main folder voting-circuit-test, I saved the voting code in a file called private_vote_selection.circom, feel free to adopt your preferred naming convention

post image
  • Compile circom private_vote_selection.circom --r1cs --wasm --sym

    • Generated file private_vote_selection.r1cs, private_vote_selection.sym and a folder private_vote_selection_js with file private_vote_selection.wasm

post image
  • I started the power of tau ceremony: snarkjs powersoftau new bn128 12 pot12_0000.ptau -v and generated pot12_0000.ptau

post image
post image
  • Power of tau ceremony part 2: snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau --name="First contribution" -v.

  • This command will require a random text for entropy, I choose “sea”. This generates file pot12_0001.ptau

  • Setup phase of power of tau ceremony: snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau -v.

    This creates file pot12_final.ptau for Groth16 circuit setup

  • Generate the proving and verification keys using the finalised pot12_final.ptau file: snarkjs groth16 setup private_vote_selection.r1cs pot12_final.ptau circuit_0000.zkey

  • I added randomness at the initial .zkey file circuit_0000.zkey with this command: snarkjs zkey contribute circuit_0000.zkey circuit_final.zkey --name="Second contribution" -v , which asked another random text. I typed “sea2”. This generates file circuit_final.zkey

  • After finalizing the zkey file, extract the verification key required for verifying proofs by creating verification_key.json with this command: snarkjs zkey export verificationkey circuit_final.zkey verification_key.json

Time to generate a proof for testing!

Now that it’s all set, a proof has to be generated to test the circuit, as explained in computing-the-witness section of Circom docs.

  • Prepare an input file (e.g., input.json) as unit test with the inputs required by your circuit. The format of input.json should match the signals defined in your initial private_vote_selection.circom file.

Based on our voting circom code we defined at the beginning, the circuite had these four requirements:

post image

and it’s expecting a match between the voter_secret - selected_candidate_index Poseidon hash and the vote_commitment:

post image
  • To define the vote_commitment, we need to calculate the Poseidon hash of the two input signals. Both vote_signers and select_candidate_index were picked randomly. Hash them through website poseidon-hash.online.

    • vote_signers = 12345

    • select_candidates_index = 1

post image
  • Combine all these data into a input.json file. This is how it should look like:

    {
      "candidates": [111, 222, 333],
      "voter_secret": 12345,
      "vote_commitment": 4213355460611018654523924795294902999663126022355729006200928612083214729114,
      "selected_candidate_index": 1
    }
    

    We now have everything to proceed with the witness.

  • Use circuit's WASM to generate witness:

    node private_vote_selection_js/generate_witness.js private_vote_selection_js/private_vote_selection.wasm input.json witness.wtns

  • After a few attempts of witness calculation, the hash generated by Circom doesn’t match the one from the online generator. This hash inconsistency raises this assert error:

Hash inconsistency error
Hash inconsistency error

Investigating Potential Causes of hash mismatch

  • To troubleshoot the mismatches, I explored the solutions raised from main developer platforms:

    1. Endianess Handling: This involves how the circuit expects inputs—either little-endian or big-endian.

      1. I systematically tested every possible combination: converting signals from big-endian to little-endian (and vice versa), before and after hashing, as well as transforming the output itself. Despite thorough testing, none of these combinations has proven to be the correct solution so far.

    2. Computing the Witness with C++: Instead of Node.js, using C++ as an alternative approach.

    Further investigation is needed to pinpoint the exact cause.

    This guide, more than a ready-to-use tutorial, aims to offer fresh insights and support the advancement of research around zero-knowledge proofs, with the hope of fostering collaboration on novel perspectives and facilitating dynamic exchanges of ideas 💡

  • For now this step-by-step walkthrough is paused here. Investigation still going, as soon as the issue will be fixed, guide will be further developed. Stay tuned!