Skip to content

Latest commit

 

History

History
149 lines (111 loc) · 7.83 KB

File metadata and controls

149 lines (111 loc) · 7.83 KB

StarkVote Architecture

Anonymous voting on Starknet using Semaphore v4 zero-knowledge proofs, verified on-chain via Garaga's Groth16 BN254 verifier.

System Overview

  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           │
 └─────────────────┘           └──────────────────────────────────────┘

Contracts

VoterSetRegistry (contracts/src/voter_set_registry.cairo)

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 given poll_id becomes 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.

Poll (contracts/src/poll.cairo)

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 that poll_id must 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:
    1. Verifies the proof via Semaphore30Verifier
    2. Extracts public inputs: [root, nullifier_hash, signal_hash, scope_hash]
    3. Checks root == snapshot_root
    4. Checks scope_hash == semaphore_hash(poll_id)
    5. Checks signal_hash == semaphore_hash(option)
    6. Checks nullifier not already used (prevents double voting)
    7. Increments tally[(poll_id, option)]
  • finalize(poll_id) — Anyone can call after end_time to 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).

Semaphore30Verifier (contracts/src/verifier.cairo)

Thin wrapper around Garaga's Groth16 verifier. Exposes a single function:

  • verify_groth16_proof_bn254(full_proof_with_hints: Span<felt252>) — Returns Option<Span<u256>> with the 4 verified public inputs, or None if verification fails.

Groth16VerifierBN254 (contracts/src/groth16_verifier.cairo)

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.

Cryptographic Details

Identity

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.

Merkle Tree

  • Depth: 30 (supports up to ~1 billion voters)
  • Hash function: Poseidon (matching Semaphore v4 circuits)
  • Empty leaves are zero-padded

Scope and Signal Hashing

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
}

Groth16 Proof Format

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 prefix

Nullifier

Each 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.

Security Model

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

Deployed Contracts (Sepolia)

Contract Address
Groth16VerifierBN254 0x72bd332d5895391fae78ee48279f1b9872ab94fec6aec11ca1cd638f2630b57
Semaphore30Verifier 0x73f3e38f7f1f2083d3f7d8c39a413aab3ae0a2a7e3888af5f3496648eb5d569
VoterSetRegistry 0x2b202ccd6375fdf026d407d2921dfa2c71244fff42889c8cba482ff7cfa910b
Poll 0x5e22619ec8cb23c0bc0bc3984198de8165d0ea32798e50decc229a86729deea