@cheny56/zk-voting
v1.0.0
Published
Privacy-preserving voting system using Zero-Knowledge Proofs with multiple tally methods
Maintainers
Readme
@pqc/zk-voting
Privacy-preserving voting system using Zero-Knowledge Proofs with multiple tallying methods.
Features
- 🔒 Anonymous Voting - ZK proofs hide voter identity
- 🎯 Double-Vote Prevention - Nullifiers prevent voting twice
- 📊 Multiple Tally Methods - Choose between reveal-based and homomorphic
- 🔗 EVM Compatible - Works on Ethereum, Quorum, and other EVM chains
- 📦 Modular Design - Use components independently
Installation
npm install @pqc/zk-votingQuick Start
const {
RevealTally,
HomomorphicTally,
VotingClient
} = require('@pqc/zk-voting');
// Simple reveal-based tallying
const tally = new RevealTally(3); // 3 candidates
tally.revealVote(commitment, 1, salt); // Vote for candidate 1
console.log(tally.getResults());Table of Contents
- Overview
- Architecture
- Tallying Methods
- Smart Contracts
- ZK Circuits
- API Reference
- Examples
- Privacy Analysis
- Security Considerations
Overview
This package implements a complete privacy-preserving voting system:
┌─────────────────────────────────────────────────────────────────────────┐
│ VOTING FLOW │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. REGISTRATION │
│ ┌───────────┐ ┌───────────────────┐ │
│ │ Voter │ --> │ Identity │ --> Stored in │
│ │ Secret │ │ Commitment │ Merkle Tree │
│ └───────────┘ └───────────────────┘ │
│ │
│ 2. VOTING │
│ ┌───────────┐ ┌───────────────────┐ ┌───────────────────┐ │
│ │ Vote + │ --> │ ZK Proof │ --> │ On-chain: │ │
│ │ Secret │ │ Generation │ │ - Nullifier │ │
│ └───────────┘ └───────────────────┘ │ - Vote Commitment │ │
│ └───────────────────┘ │
│ │
│ 3. TALLYING (choose one) │
│ ┌─────────────────────┐ ┌─────────────────────────────────┐ │
│ │ REVEAL-BASED │ OR │ HOMOMORPHIC │ │
│ │ - Voters reveal │ │ - Encrypted aggregation │ │
│ │ - Simple │ │ - Max privacy │ │
│ │ - Votes public │ │ - Requires trusted tallier │ │
│ └─────────────────────┘ └─────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘Architecture
Components
| Component | Description | File |
|-----------|-------------|------|
| RevealTally | Reveal-based vote tallying | lib/tally/reveal-tally.js |
| HomomorphicTally | Paillier homomorphic tallying | lib/tally/homomorphic-tally.js |
| PaillierCrypto | Paillier encryption primitives | lib/crypto/paillier.js |
| VotingClient | High-level client for contracts | client/voting-client.js |
| VoterRegistry | Solidity contract for registration | contracts/VoterRegistry.sol |
| ZKVoting | Main voting contract | contracts/ZKVoting.sol |
| vote.circom | ZK circuit for vote proofs | circuits/vote.circom |
Tallying Methods
Reveal-Based Tallying
In this method, voters reveal their votes after voting ends. Simple and requires no trusted party.
const { RevealTally, prepareRevealData } = require('@pqc/zk-voting');
// Initialize tally for 3 candidates
const tally = new RevealTally(3);
// After voting ends, each voter reveals their vote
// commitment = Poseidon(choice, salt)
const reveal = prepareRevealData(voteChoice, salt);
tally.revealVote(reveal.commitment, reveal.choice, reveal.salt);
// Get results
console.log(tally.getResults());
// [{ candidate: 0, votes: 10, percentage: "33.33" }, ...]
console.log(tally.getWinners());
// [0] or [0, 1] if tiePrivacy Trade-off
| Phase | Voter Identity | Vote Choice | |-------|---------------|-------------| | During voting | 🔒 Hidden (ZK proof) | 🔒 Hidden (commitment) | | After tally | 🔒 Hidden | ⚠️ Revealed |
Homomorphic Tallying
Individual votes are never decrypted. Only the final totals are revealed.
const { HomomorphicTally } = require('@pqc/zk-voting');
// Initialize with candidate count
const tally = new HomomorphicTally(3);
// Generate Paillier keys (admin does this once)
await tally.generateKeys(2048); // Use 2048 bits for production
// Share public key with voters
const pubKey = tally.exportPublicKey();
// Each voter encrypts their vote
const encryptedVote = tally.encryptVote(1); // Vote for candidate 1
tally.addEncryptedVote(encryptedVote);
// After all votes, decrypt only the totals (admin only)
const results = tally.decryptTally();
console.log(results); // [10, 15, 5] - vote counts per candidatePrivacy Trade-off
| Phase | Voter Identity | Vote Choice | |-------|---------------|-------------| | During voting | 🔒 Hidden (ZK proof) | 🔒 Hidden (encrypted) | | After tally | 🔒 Hidden | 🔒 Never revealed |
Comparison
| Aspect | Reveal-Based | Homomorphic | |--------|--------------|-------------| | Privacy Level | Moderate | High | | Complexity | Low | Medium | | Trusted Party | Not required | Required (holds decryption key) | | Voter Action After Voting | Must reveal | None | | Individual Votes | Public after tally | Never revealed | | Use Case | Public elections | Sensitive corporate votes |
Smart Contracts
VoterRegistry.sol
Manages eligible voters using a Merkle tree of identity commitments.
// Register a voter (admin only)
registry.registerVoter(identityCommitment);
// Close registration
registry.closeRegistration();
// Get Merkle root for ZK proofs
bytes32 root = registry.merkleRoot();ZKVoting.sol
Main voting contract that verifies ZK proofs.
// Cast a vote with ZK proof
ballot.castVote(proof, nullifier, voteCommitment);
// After voting ends, reveal votes (reveal-based)
ballot.revealVote(voteIndex, choice, salt);
// Get results
uint256[] memory results = ballot.getResults();ZK Circuits
The voting circuit (circuits/vote.circom) proves:
- Membership: Voter is in the Merkle tree of eligible voters
- Non-double-voting: Nullifier is correctly derived
- Valid vote: Choice is within valid range
Public Inputs:
- merkleRoot: Root of voter registry
- nullifier: Unique identifier (prevents double voting)
- voteCommitment: Hash of (choice, salt)
- candidateCount: Number of candidates
Private Inputs:
- voterSecret: Voter's secret key
- merklePath: Proof of membership
- voteChoice: Actual vote
- voteSalt: Random salt for commitmentCompiling the Circuit
# Install circom
npm install -g circom
# Compile circuit
npm run compile:circuit
# Generate trusted setup keys
npm run setup:keysAPI Reference
RevealTally
const tally = new RevealTally(candidateCount);
// Reveal a vote
tally.revealVote(commitment, choice, salt);
// Batch reveal
tally.revealVotesBatch([{ commitment, choice, salt }, ...]);
// Get results
tally.getTally(); // [count0, count1, ...]
tally.getResults(); // [{ candidate, votes, percentage }, ...]
tally.getWinners(); // [winnerIndex, ...]
tally.getTotalRevealed(); // number
tally.isRevealed(commitment); // boolean
// Serialization
const json = tally.toJSON();
const restored = RevealTally.fromJSON(json);HomomorphicTally
const tally = new HomomorphicTally(candidateCount, publicKey?, privateKey?);
// Generate keys (admin)
await tally.generateKeys(bitLength);
// Encrypt a vote (voter)
const encryptedVote = tally.encryptVote(choice);
// Add to tally (aggregator)
tally.addEncryptedVote(encryptedVote);
// Cast and add in one step
tally.castVote(choice);
// Decrypt final tally (admin with private key)
const totals = tally.decryptTally();
// Get results
tally.getResults(); // With percentages
tally.getWinners(); // Winner indices
tally.getVoteCount(); // Total votes
// Key management
tally.exportPublicKey();
tally.importPublicKey(exported);PaillierCrypto
const { PaillierCrypto } = require('@pqc/zk-voting/crypto/paillier');
const paillier = new PaillierCrypto();
await paillier.generateKeys(2048);
// Encrypt
const ciphertext = paillier.encrypt(42);
// Decrypt
const plaintext = paillier.decrypt(ciphertext);
// Homomorphic addition: Enc(a) + Enc(b) = Enc(a + b)
const sum = paillier.add(ciphertext1, ciphertext2);Examples
Basic Voting Flow
const { buildPoseidon } = require('circomlibjs');
const { RevealTally } = require('@pqc/zk-voting');
async function basicVotingExample() {
const poseidon = await buildPoseidon();
const hash = (inputs) => poseidon.F.toString(poseidon(inputs));
const candidates = ['Alice', 'Bob', 'Charlie'];
const tally = new RevealTally(candidates.length);
// Simulate 10 voters
const votes = [];
for (let i = 0; i < 10; i++) {
const choice = Math.floor(Math.random() * candidates.length);
const salt = BigInt(Math.floor(Math.random() * 1e18));
const commitment = hash([BigInt(choice), salt]);
votes.push({ commitment, choice, salt });
}
// Reveal phase
for (const vote of votes) {
tally.revealVote(vote.commitment, vote.choice, vote.salt);
}
// Results
console.log('Results:');
for (const result of tally.getResults()) {
console.log(` ${candidates[result.candidate]}: ${result.votes} (${result.percentage}%)`);
}
const winners = tally.getWinners();
console.log(`Winner: ${winners.map(w => candidates[w]).join(', ')}`);
}
basicVotingExample();Homomorphic Voting
const { HomomorphicTally } = require('@pqc/zk-voting');
async function homomorphicVotingExample() {
const candidates = ['Yes', 'No', 'Abstain'];
const tally = new HomomorphicTally(candidates.length);
// Admin generates keys
console.log('Generating encryption keys...');
await tally.generateKeys(512); // Use 2048 for production
// Public key shared with voters
const publicKey = tally.exportPublicKey();
console.log(`Public key (n): ${publicKey.n.slice(0, 30)}...`);
// Voters cast encrypted votes
const voterChoices = [0, 1, 0, 0, 2, 1, 0]; // Individual choices hidden!
for (let i = 0; i < voterChoices.length; i++) {
const encryptedVote = tally.encryptVote(voterChoices[i]);
tally.addEncryptedVote(encryptedVote);
console.log(`Voter ${i + 1}: Cast encrypted vote`);
}
// Admin decrypts only the totals
console.log('\nDecrypting final tally...');
const results = tally.getResults();
console.log('\nResults (individual votes were NEVER revealed):');
for (const result of results) {
console.log(` ${candidates[result.candidate]}: ${result.votes} (${result.percentage}%)`);
}
}
homomorphicVotingExample();Contract Interaction
const { ethers } = require('ethers');
const { VotingClient } = require('@pqc/zk-voting/client');
async function contractExample() {
const provider = new ethers.JsonRpcProvider('http://localhost:8545');
const signer = new ethers.Wallet(PRIVATE_KEY, provider);
const client = new VotingClient({
provider,
signer,
registryAddress: '0x...',
ballotAddress: '0x...'
});
// Register voters (admin)
await client.registerVoter(identityCommitment);
// Cast vote with ZK proof
await client.castVote(proof, nullifier, voteCommitment);
// Get ballot info
const info = await client.getBallotInfo();
console.log(`Total votes: ${info.totalVotes}`);
}Privacy Analysis
See docs/PRIVACY-ANALYSIS.md for a detailed analysis of:
- What information is hidden at each phase
- Potential metadata leaks
- Recommendations for maximum privacy
Summary
| Information | Registration | Voting | Tallying (Reveal) | Tallying (Homomorphic) | |-------------|-------------|--------|-------------------|------------------------| | Voter identity | 🔒 Hidden | 🔒 Hidden | 🔒 Hidden | 🔒 Hidden | | Who voted | ⚠️ Visible | ⚠️ Visible | ⚠️ Visible | ⚠️ Visible | | Vote choice | N/A | 🔒 Hidden | ⚠️ Revealed | 🔒 Hidden | | Vote totals | N/A | N/A | ✅ Public | ✅ Public |
Security Considerations
Trusted Setup: The ZK circuit requires a trusted setup. Use a multi-party computation (MPC) ceremony for production.
Key Security: For homomorphic tallying, the private key must be kept secure. Consider threshold decryption.
Nullifier Uniqueness: Ensure nullifiers are derived correctly to prevent double voting.
Timing Attacks: Use a relayer service to hide which Ethereum address casts which vote.
Front-running: Consider commit-reveal schemes or private mempools to prevent front-running attacks.
Development
# Install dependencies
npm install
# Compile ZK circuit
npm run compile:circuit
# Run tests
npm test
# Run examples
npm run example
npm run example:homomorphicLicense
MIT
Related Packages
- @pqc/zk-voting-native - Optimized for PQC-Quorum with native precompiles
- @pqc/zk-client - ZK RPC client for PQC-Quorum nodes
