Secret Voting
Build a private voting circuit with voter eligibility, vote privacy, and double-vote prevention.
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
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
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
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 secrets
let s0 = 0p42
let s1 = 0p111
let s2 = 0p222
let s3 = 0p333
// Commitments
let c0 = poseidon(s0, 0)
let c1 = poseidon(s1, 0)
let c2 = poseidon(s2, 0)
let c3 = poseidon(s3, 0)
// Level 0: pair hashes
let n0 = poseidon(c0, c1)
let n1 = poseidon(c2, c3)
// Level 1: root
let root = poseidon(n0, n1)
print(root)
Running this with ach run produces the root:
ach run registry.ach
# Field(16562627490493722277540343453474560507943355785745140792129356826951042972366)
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
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
| 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
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 = 0p42
let election_id: Field = 0p1001
let vote: Field = 0p1
// Build voter registry
let 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 0
let path_0: Field = voter1
let path_1: Field = n1
let indices_0: Field = 0p0
let 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
- 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
- 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)