Introducing Achronyme — a language for zero-knowledge proofs. Read the announcement

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.
  • Nullifierposeidon(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 validityrange_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: c0 is the left child, sibling is c1 (voter 1’s commitment). Index = 0.
  • Level 1: n0 is the left child, sibling is n1. 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

ComponentConstraints
poseidon(secret, 0) — commitment361
merkle_verify (depth 2)~726
poseidon(secret, election_id) — nullifier361
assert_eq — nullifier check1
range_check(vote, 1) — vote validity2
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) with range_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)
Navigation