Anonymous voting on Starknet using Semaphore v4 zero-knowledge proofs, verified on-chain via Garaga's Groth16 BN254 verifier.
Voter (off-chain) Starknet Sepolia (on-chain)
┌─────────────────┐ ┌──────────────────────────────────────┐
│ Generate │ │ VoterSetRegistry │
│ identity │ │ stores commitments as leaves │
│ (Poseidon) │ │ freeze() → immutable │
│ │ │ │
│ Build Merkle │ │ Poll │
│ tree from │◄─────────│ snapshots frozen root │
│ on-chain │ fetch │ verifies ZK proof via Verifier │
│ leaves │ leaves │ checks scope, signal, nullifier │
│ │ │ records tally per option │
│ Generate │ │ │
│ Semaphore │ │ Semaphore30Verifier │
│ proof │ │ wraps Groth16VerifierBN254 │
│ (snarkjs) │ │ returns verified public inputs │
│ │ │ │
│ Format calldata │─────────►│ Groth16VerifierBN254 (Garaga) │
│ (Garaga) │ submit │ BN254 pairing check │
│ │ vote tx │ generated by garaga CLI │
└─────────────────┘ └──────────────────────────────────────┘
Manages per-poll voter eligibility and commitment self-registration. Each poll has its own independent voter set.
add_eligible_batch(poll_id, addresses)— The first caller for a givenpoll_idbecomes its admin. Only the admin can add eligible addresses for that poll.register_commitment(poll_id, commitment)— An eligible voter self-registers their Semaphore identity commitment. Each address can register exactly once per poll.freeze(poll_id)— Only the poll admin can freeze. Locks the voter set permanently for that poll.get_poll_admin(poll_id)— Returns the admin for a poll's voter set.is_eligible(poll_id, address) / has_registered(poll_id, address)— Check eligibility and registration status.get_leaf(poll_id, index) / get_leaf_count(poll_id)— Anyone can read all leaves and reconstruct the Merkle tree.
There is no global admin. Anyone can create a voter set for a new poll_id — the first caller becomes its admin. Each poll has a completely different voter set. Anonymity is preserved because voters submit their vote from a different address using ZK proofs.
Manages polls, verifies proofs, and tallies votes.
create_poll(poll_id, options_count, start_time, end_time, merkle_root, option_labels)— Anyone can create a poll with a Merkle root and a list of candidate name strings (one per option). The voter set for thatpoll_idmust be frozen in the registry. Labels are stored on-chain for UI use only — they are not part of the ZK proof.vote(poll_id, option, full_proof_with_hints)— Voter submits a Groth16 proof. The contract:- Verifies the proof via Semaphore30Verifier
- Extracts public inputs:
[root, nullifier_hash, signal_hash, scope_hash] - Checks
root == snapshot_root - Checks
scope_hash == semaphore_hash(poll_id) - Checks
signal_hash == semaphore_hash(option) - Checks nullifier not already used (prevents double voting)
- Increments
tally[(poll_id, option)]
finalize(poll_id)— Anyone can call afterend_timeto compute and store the winner.get_tally(poll_id, option)— Read vote count for any option.get_option_label(poll_id, option)— Read the candidate name string for a single option.get_option_labels(poll_id)— Read all candidate name strings for a poll as an ordered array (index = option number).
Thin wrapper around Garaga's Groth16 verifier. Exposes a single function:
verify_groth16_proof_bn254(full_proof_with_hints: Span<felt252>)— ReturnsOption<Span<u256>>with the 4 verified public inputs, orNoneif verification fails.
Generated by garaga gen for the Semaphore depth-30 verification key. Performs the BN254 elliptic curve pairing check. This is a ~2MB contract due to precomputed constants.
Semaphore v4 identities are generated from a random private key. The identity commitment is derived via Poseidon hash and serves as the leaf in the Merkle tree.
- Depth: 30 (supports up to ~1 billion voters)
- Hash function: Poseidon (matching Semaphore v4 circuits)
- Empty leaves are zero-padded
Semaphore v4 uses semaphoreHash to bind the scope (poll_id) and signal (option) to the proof:
semaphoreHash(value) = keccak256(zeroPadValue(toBeHex(value), 32)) >> 8
The Cairo implementation must account for keccak endianness:
fn semaphore_hash(value: u256) -> u256 {
let raw = core::keccak::keccak_u256s_be_inputs(array![value].span());
// Cairo keccak returns bytes in little-endian; Ethereum uses big-endian
let hash = u256 {
low: core::integer::u128_byte_reverse(raw.high),
high: core::integer::u128_byte_reverse(raw.low),
};
hash / 256_u256 // right-shift by 8 bits
}Garaga's groth16_calldata_from_vk_and_proof produces a calldata array with a length prefix as the first element. Since starknet.js adds its own Span length when serializing, the prefix must be stripped before sending:
const calldataNoPrefix = calldata.slice(1); // strip Garaga's length prefixEach proof produces a deterministic nullifier_hash = f(identity_secret, scope). The contract stores used_nullifiers[(poll_id, nullifier_hash)] to prevent double voting. The nullifier reveals nothing about the voter's identity.
What the poll creator controls:
- Which wallet addresses are eligible to register as voters for their poll
- Poll parameters (options, timing)
What the poll creator cannot do:
- Register a commitment on behalf of a voter (each voter must self-register)
- Forge votes (requires voter's private identity)
- Change the voter set after freezing
- Link a vote to a specific voter
Public auditability:
- All leaves are on-chain — anyone can verify the voter set
- The snapshot root is fixed at poll creation — eligibility cannot change mid-poll
- Tallies are readable by anyone
- Nullifier hashes are emitted as events
Privacy guarantees:
- On-chain state never contains the voter's identity
- Only the nullifier hash (scoped to poll_id) is revealed
- Different polls produce different nullifiers for the same voter
| Contract | Address |
|---|---|
| Groth16VerifierBN254 | 0x72bd332d5895391fae78ee48279f1b9872ab94fec6aec11ca1cd638f2630b57 |
| Semaphore30Verifier | 0x73f3e38f7f1f2083d3f7d8c39a413aab3ae0a2a7e3888af5f3496648eb5d569 |
| VoterSetRegistry | 0x2b202ccd6375fdf026d407d2921dfa2c71244fff42889c8cba482ff7cfa910b |
| Poll | 0x5e22619ec8cb23c0bc0bc3984198de8165d0ea32798e50decc229a86729deea |