Secret Voting
Secret voting is one of the most natural applications of zero-knowledge proofs. A voter proves they are eligible to vote and casts a valid ballot — without revealing their identity or which option they chose. A public nullifier prevents the same voter from voting twice.
Concepts
Section titled “Concepts”The circuit combines four components:
- Voter commitment — each voter registers a commitment
poseidon(secret, 0)derived from a private secret. The commitment is public; the secret is not. - Merkle membership — all voter commitments are leaves in a Merkle tree. The voter proves their commitment is in the tree without revealing which leaf it is.
- Nullifier —
poseidon(secret, election_id)produces a unique, deterministic value tied to both the voter and the election. Publishing the nullifier lets anyone detect duplicates without learning who voted. - Vote validity —
range_check(vote, 1)enforces the vote is 0 or 1.
Circuit Definition
Section titled “Circuit Definition”Create secret_vote.ach:
circuit secret_vote(merkle_root: Public, nullifier: Public, vote: Public, election_id: Public, secret: Witness, path: Witness Field[2], indices: Witness Bool[2]) { // 1. Voter commitment (hidden — computed from private secret) let commitment = poseidon(secret, 0)
// 2. Merkle membership: voter is in the registered voter tree merkle_verify(merkle_root, commitment, path, indices)
// 3. Nullifier correctness: prevents double-voting let expected_nullifier = poseidon(secret, election_id) assert_eq(expected_nullifier, nullifier)
// 4. Vote validity: must be 0 or 1 range_check(vote, 1)}The commitment is computed inside the circuit from the witness secret. It never appears as a public input — the verifier sees only the Merkle root, the nullifier, the vote, and the election ID.
Building the Voter Registry
Section titled “Building the Voter Registry”Before running the circuit, you need a Merkle tree of voter commitments. Here is a 4-voter tree (depth 2) built in VM mode:
// Four voter secretslet s0 = 0p42let s1 = 0p111let s2 = 0p222let s3 = 0p333
// Commitmentslet c0 = poseidon(s0, 0)let c1 = poseidon(s1, 0)let c2 = poseidon(s2, 0)let c3 = poseidon(s3, 0)
// Level 0: pair hasheslet n0 = poseidon(c0, c1)let n1 = poseidon(c2, c3)
// Level 1: rootlet root = poseidon(n0, n1)print(root)Running this with ach run produces the root:
ach run registry.ach# Field(16562627490493722277540343453474560507943355785745140792129356826951042972366)Computing the Proof Path
Section titled “Computing the Proof Path”Voter 0 (secret 42) sits at index 0 in the tree (binary 00). Their Merkle proof contains the sibling at each level:
- Level 0:
c0is the left child, sibling isc1(voter 1’s commitment). Index = 0. - Level 1:
n0is the left child, sibling isn1. Index = 0.
So path = [c1, n1] and indices = [0, 0].
Compiling the Circuit
Section titled “Compiling the Circuit”ach circuit secret_vote.ach \ --inputs "merkle_root=16562627490493722277540343453474560507943355785745140792129356826951042972366,\nullifier=4027913667401648903638418705764660665764112454358309045410324429160920395813,\vote=1,election_id=1001,secret=42,\path_0=6742193431752037917634653485837689273334250178444557194345979079134234961755,\path_1=2479855382401079998356559563096754868958560665915964078751529288374953894653,\indices_0=0,indices_1=0"Constraint Cost
Section titled “Constraint Cost”| Component | Constraints |
|---|---|
poseidon(secret, 0) — commitment | 361 |
merkle_verify (depth 2) | ~726 |
poseidon(secret, election_id) — nullifier | 361 |
assert_eq — nullifier check | 1 |
range_check(vote, 1) — vote validity | 2 |
| Total | ~1,451 |
At depth 20 (~1M voters), the total rises to roughly 8,100 constraints — still very fast to prove.
Using prove {} Instead
Section titled “Using prove {} Instead”You can generate the proof inline using a prove {} block. The outer scope computes the Merkle tree in VM mode, then the prove block generates the ZK proof:
let secret: Field = 0p42let election_id: Field = 0p1001let vote: Field = 0p1
// Build voter registrylet commitment: Field = poseidon(secret, 0)let voter1: Field = poseidon(0p111, 0p0)let voter2: Field = poseidon(0p222, 0p0)let voter3: Field = poseidon(0p333, 0p0)
let n0: Field = poseidon(commitment, voter1)let n1: Field = poseidon(voter2, voter3)let merkle_root: Field = poseidon(n0, n1)
let nullifier: Field = poseidon(secret, election_id)
// Merkle proof for voter 0let path_0: Field = voter1let path_1: Field = n1let indices_0: Field = 0p0let indices_1: Field = 0p0
prove(merkle_root: Public, nullifier: Public, vote: Public, election_id: Public) { let commitment: Field = poseidon(secret, 0) merkle_verify(merkle_root, commitment, path, indices)
let expected_nullifier: Field = poseidon(secret, election_id) assert_eq(expected_nullifier, nullifier)
range_check(vote, 1)}Run with ach run secret_vote.ach. The prove {} block compiles the circuit, generates the witness, verifies the constraints, and produces a proof — all in one step.
Scaling
Section titled “Scaling”- More voters: increase the Merkle tree depth. Depth 20 supports ~1M voters at ~8,100 constraints.
- Multiple candidates: replace
range_check(vote, 1)withrange_check(vote, N)where N = ceil(log2(candidates)). - Tallying: publish all
(nullifier, vote)pairs. Anyone can verify no duplicate nullifiers and sum the votes, without learning who cast which ballot.
Applications
Section titled “Applications”- DAO governance — token holders vote without revealing their position
- Anonymous surveys — collect honest feedback with guaranteed uniqueness
- On-chain elections — submit proofs to a Solidity verifier (
ach circuit --solidity)