I'm now playing around with this new basic blog template from Next.js examples --> available on Next.js GitHub.

Luciano Lupo Notes.

Anonymous Voting on Ethereum with Semaphore V4

Cover Image for Anonymous Voting on Ethereum with Semaphore V4
·8 min read

DAO governance has a privacy problem. When you vote on a Snapshot proposal or cast an on-chain vote, your choice is permanently linked to your wallet. In theory, this is "transparency." In practice, it enables vote buying, social pressure, and self-censorship. People vote differently when they know they're being watched.

I wanted to understand how zero-knowledge proofs could fix this, so I built a full-stack anonymous voting dApp using Semaphore V4. It's deployed on Base Sepolia and lets you create polls, register voters, and cast completely anonymous votes — all verified by ZK proofs on-chain.

The contract is live at 0xeEDb8266961AAbB80B5aA416d6F3DE1fE9C41a34 on Base Sepolia. Here's how it works and what I learned.

What Semaphore Actually Does

Semaphore is a ZK protocol built by the PSE (Privacy & Scaling Explorations) team at the Ethereum Foundation. It solves a specific problem: proving you're a member of a group without revealing which member you are.

The protocol has four concepts:

  1. Identity — A private key you generate locally. From it, you derive a public "identity commitment" (a hash). This commitment is what gets registered on-chain.

  2. Group — A Merkle tree of identity commitments. When you're added to a group, your commitment becomes a leaf in the tree.

  3. Proof — A ZK proof that says "I know the private key behind one of the commitments in this group." The proof is valid but reveals nothing about which commitment is yours.

  4. Nullifier — A unique value derived from your identity and the signal you're sending. It prevents you from proving membership twice for the same signal (double-voting), without revealing your identity.

The Three-Phase Voting Lifecycle

My contract implements a strict lifecycle for each poll:

┌─────────────┐      ┌──────────┐      ┌─────────┐
│ Registration │ ──→  │  Voting  │ ──→  │  Ended  │
│  (1 hour)    │      │ (2 hours)│      │         │
└─────────────┘      └──────────┘      └─────────┘
  Add voters          Cast votes        Read results

Registration phase — Anyone can register voters by submitting their identity commitment. The contract adds the commitment to a Semaphore group (which is a Merkle tree under the hood).

Voting phase — Registered voters generate ZK proofs client-side and submit their votes. The contract verifies each proof against the group's Merkle tree root and checks the nullifier hasn't been used.

Ended — Results are public and final. No more votes accepted.

The time boundaries are enforced with modifiers:

modifier duringRegistration(uint256 pollId) {
    if (
        block.timestamp < polls[pollId].startTime ||
        block.timestamp >= polls[pollId].registrationEndTime
    ) revert NotInRegistrationPhase();
    _;
}

This is stricter than most DAO voting systems, where there's typically no separation between registration and voting. The separation matters for privacy — if you could register and vote in the same transaction, the timing correlation would be a privacy leak.

The Smart Contract

The core contract is around 300 lines. Here's the voting function — the most interesting part:

function vote(
    uint256 pollId,
    uint256 voteOption,
    ISemaphore.SemaphoreProof calldata proofs
) external pollExists(pollId) duringVoting(pollId) {
    Poll storage poll = polls[pollId];

    if (voteOption >= poll.options.length) revert InvalidVoteOption();
    if (usedNullifiers[pollId][proofs.nullifier]) revert VoteAlreadyCast();

    // Verify ZK proof through Semaphore
    semaphore.validateProof(poll.groupId, proofs);

    // Record vote
    votes[pollId][voteOption]++;
    usedNullifiers[pollId][proofs.nullifier] = true;
    poll.totalVotes++;

    emit VoteCast(pollId, voteOption, proofs.nullifier);
}

The semaphore.validateProof() call does the heavy lifting — it verifies the ZK proof on-chain. If the proof is invalid (wrong group, invalid nullifier, bad math), the transaction reverts. If it's valid, we know the voter is a legitimate group member without knowing which one.

The nullifier check is critical: usedNullifiers[pollId][proofs.nullifier] maps poll IDs to nullifiers. Each identity generates a deterministic nullifier for each poll, so you can vote once per poll but the nullifier is different for each poll. This means voting in poll #1 reveals nothing about your vote in poll #2.

Client-Side Proof Generation

The frontend is Next.js 15 with RainbowKit for wallet connection. The most complex part is client-side proof generation using @semaphore-protocol/core:

import { Identity, Group, generateProof } from "@semaphore-protocol/core";

// Step 1: Generate identity (stored in localStorage)
const identity = new Identity();  // Random private key
// identity.commitment → goes on-chain during registration

// Step 2: Build the group (fetch commitments from contract events)
const group = new Group();
group.addMembers([commitment1, commitment2, commitment3, ...]);

// Step 3: Generate proof
const proof = await generateProof(
    identity,      // Your private identity
    group,         // The full group (Merkle tree)
    voteOption,    // Your vote (the "signal")
    group.root,    // Current Merkle root
    20             // Tree depth
);
// proof → send to contract

The proof generation happens entirely in the browser using WASM. It takes 2-5 seconds on a modern machine. The identity never leaves the client — only the proof goes on-chain.

One thing that tripped me up: you need to rebuild the full group client-side to generate the proof. This means fetching all VoterRegistered events from the contract and reconstructing the Merkle tree. For a poll with thousands of voters, this could be slow. Production systems use subgraph indexers or dedicated APIs for this.

Testing: Where the Learning Happened

The tests taught me more than the implementation. Here's the fixture that sets up a realistic scenario:

const members = Array.from({ length: 3 }, (_, i) =>
    new Identity(i.toString())
).map(({ commitment }) => commitment);

const identity = new Identity("0");
const group = new Group();
group.addMembers([identity.commitment, identity2.commitment, identity3.commitment]);

const proof = await generateProof(
    identity, group, message, group.root, MERKLE_TREE_DEPTH
);

Testing ZK proofs with Hardhat required a mock Semaphore contract. The real Semaphore verifier uses a Groth16 SNARK verification circuit — too heavy for local testing. The mock accepts any correctly structured proof, letting me test the voting logic independently.

Key tests I wrote:

  • Phase enforcement — Can't register during voting, can't vote during registration
  • Double-vote prevention — Same nullifier is rejected
  • Option validation — Can't vote for option 5 in a 3-option poll
  • Poll isolation — Voting in poll #1 doesn't affect poll #2

Privacy Analysis

What's actually private in this system?

Private:

  • Which commitment is yours (Merkle proof hides this)
  • Your identity (private key never leaves your browser)
  • Who voted for what (votes are unlinkable to identities)

Public:

  • That a valid group member voted (the proof verifies this)
  • The nullifier (used for double-vote prevention, but unlinkable to identity)
  • The vote choice (it's a public input to the proof)
  • The total results

Semi-private (timing leaks):

  • When you registered (on-chain transaction from your wallet)
  • When you voted (on-chain transaction, but could use a relayer)

The registration step is the biggest privacy weakness. Your wallet address is linked to your identity commitment when you call registerVoter(). A sophisticated attacker who controls the registration could try to correlate commitments with voters.

In a production system, you'd want:

  1. Batch registration — An admin registers all voters at once
  2. Relayer for voting — A third party submits the vote transaction so the voter's address isn't revealed
  3. Longer time windows — More voters between your registration and vote means better anonymity

What I'd Improve

The identity storage is too fragile. Right now, the Semaphore identity is stored in localStorage. If you clear your browser data, you lose your identity and can't vote — even though you're registered. A better approach would be deriving the identity from a wallet signature (deterministic identity).

No relayer support. The voter currently submits the transaction themselves, which links their wallet to the voting action (though not to their specific vote). Adding a simple relayer would fix this.

The frontend rebuilds the group on every page load. For large polls, this is slow. A backend indexer or The Graph subgraph would be more efficient.

Hardcoded time durations. Registration and voting periods are set at poll creation and can't be extended. An admin override would be useful for real governance.

Deploying to Base

I deployed to Base Sepolia (an L2) because:

  • ZK proof verification is expensive (~300k gas)
  • L2 gas costs are 10-100x cheaper than mainnet
  • Base has native Semaphore deployments available

The deployment script handles both the Semaphore dependency and the voting contract:

npm run compile
npm run deploy:base-sepolia

The deployed contract references Semaphore at 0x8A1fd199516489B0Fb7153EB5f075cDAC83c693D on Base Sepolia.

The Bigger Picture

Building this convinced me that ZK-based governance is not just a theoretical improvement — it's practically achievable with today's tools. Semaphore V4 abstracts away most of the ZK complexity. You write a normal Solidity contract, call validateProof(), and you get anonymous group membership verification.

The pattern extends beyond voting:

  • Anonymous whistleblowing — Prove you're an employee of a company without revealing who
  • Private attestations — "Someone in this group of auditors approved this contract"
  • Anonymous feedback — Rate your manager without fear of retaliation
  • Proof of membership — Access gated content by proving NFT ownership without revealing your wallet

Any system where you need to prove "I'm one of these people" without saying which one is a Semaphore use case.

Try It

The full code is at github.com/LucianoLupo/zk-voting-semaphore-001:

git clone https://github.com/LucianoLupo/zk-voting-semaphore-001.git
cd zk-voting-semaphore-001
npm install && cd app && npm install && cd ..

# Deploy contracts
cp .env.example .env  # Add your keys
npm run compile
npm run deploy:base-sepolia

# Run frontend
cd app && cp .env.example .env.local  # Add contract address
npm run dev

You'll need Base Sepolia testnet ETH (free from faucets) and MetaMask configured for Base Sepolia.


Deployed contract: 0xeEDb8266961AAbB80B5aA416d6F3DE1fE9C41a34 on Base Sepolia. Source: github.com/LucianoLupo/zk-voting-semaphore-001.