Building with ZKVAULT - Private Voting System
This guide walks you through building a complete zero-knowledge application using ZKVAULT.
Use Case: Private Voting System
We'll build a voting system where:
- Voters prove they're eligible without revealing their identity
- Votes are encrypted and counted without being revealed individually
- Final tallies are verifiable on-chain
Step 1: Design the Circuit
First, define what we need to prove:
Circuit: EligibleVote
Public Inputs:
- vote_commitment: Hash(vote, salt)
- eligibility_root: Merkle root of eligible voters
Private Inputs:
- vote: 0 or 1 (candidate choice)
- salt: Random nonce
- voter_id: Unique voter identifier
- merkle_proof: Proof of inclusion in eligible voters tree
Constraints:
1. vote ∈ {0, 1} (vote is boolean)
2. vote_commitment == Hash(vote, salt) (commitment is correct)
3. VerifyMerkleProof(voter_id, merkle_proof, eligibility_root) (voter is eligible)Step 2: Implement the Circuit
import { Circuit, Hash, MerkleProof } from '@zkvault/sdk'
const votingCircuit = new Circuit()
// Public inputs
const voteCommitment = votingCircuit.publicInput('vote_commitment')
const eligibilityRoot = votingCircuit.publicInput('eligibility_root')
// Private inputs
const vote = votingCircuit.privateInput('vote')
const salt = votingCircuit.privateInput('salt')
const voterId = votingCircuit.privateInput('voter_id')
const merkleProof = votingCircuit.privateInput('merkle_proof')
// Constraint 1: Vote is boolean
votingCircuit.assertBoolean(vote)
// Constraint 2: Commitment is correct
const computedCommitment = votingCircuit.hash([vote, salt])
votingCircuit.assertEqual(voteCommitment, computedCommitment)
// Constraint 3: Voter is eligible
const isEligible = votingCircuit.verifyMerkleProof(
voterId,
merkleProof,
eligibilityRoot
)
votingCircuit.assertTrue(isEligible)
// Compile circuit
const compiled = await votingCircuit.compile()
console.log(`Circuit has ${compiled.numConstraints} constraints`)Step 3: Setup Solana Program Integration
import { ZKVault } from '@zkvault/sdk'
import { Connection, Keypair } from '@solana/web3.js'
// Initialize connection
const connection = new Connection('https://api.devnet.solana.com')
const admin = Keypair.generate()
// Initialize ZKVAULT
const zkvault = new ZKVault({
connection,
wallet: admin,
cluster: 'devnet'
})
// Create voting vault
const eligibilityRoot = '0x...' // Merkle root of eligible voters
const votingVault = await zkvault.vault.create({
owner: admin.publicKey,
circuit: compiled,
metadata: {
name: 'Presidential Election 2025',
eligibilityRoot
}
})
console.log('Voting vault created:', votingVault.toString())Step 4: Voter Submission
The following function demonstrates how a voter would submit their vote.
// Voter side
async function submitVote(
voterId: string,
vote: 0 | 1,
merkleProof: string[]
) {
// Generate random salt
const salt = crypto.randomBytes(32).toString('hex')
// Compute vote commitment
const voteCommitment = Hash.poseidon([vote, salt])
// Create proof inputs
const inputs = {
public: {
vote_commitment: voteCommitment,
eligibility_root: eligibilityRoot
},
private: {
vote,
salt,
voter_id: voterId,
merkle_proof: merkleProof
}
}
// Generate proof
console.log('Generating proof...')
const proof = await zkvault.prover.createProof(compiled, inputs)
// Submit to blockchain
console.log('Submitting vote on-chain...')
const signature = await zkvault.solana.verifyOnchain(proof, votingVault)
// Store encrypted vote details (for auditing)
const encrypted = await zkvault.vault.encryptVaultData(
JSON.stringify({ vote, salt, timestamp: Date.now() }),
admin.publicKey // Only admin can decrypt
)
// Store on-chain or off-chain storage (IPFS, Arweave, etc.)
await storeEncryptedVote(signature, encrypted)
console.log('Vote submitted:', signature)
return signature
}Step 5: Vote Tallying
This section outlines how the administrator can tally the votes after the voting period has ended.
// Admin tallying (happens after voting period ends)
async function tallyVotes(votingVault: PublicKey) {
// Fetch all verified proofs from vault
const proofs = await zkvault.vault.getVerifiedProofs(votingVault)
console.log(`Total verified votes: ${proofs.length}`)
// For each proof, decrypt the vote (admin only)
let tallyFor = 0
let tallyAgainst = 0
for (const proofId of proofs) {
const encrypted = await fetchEncryptedVote(proofId)
const decrypted = await zkvault.vault.decryptVaultData(
encrypted,
admin.secretKey
)
const { vote } = JSON.parse(Buffer.from(decrypted).toString())
if (vote === 1) {
tallyFor++
} else {
tallyAgainst++
}
}
console.log(`Results:`)
console.log(` For: ${tallyFor}`)
console.log(` Against: ${tallyAgainst}`)
// Publish final tally on-chain
await publishResults(votingVault, {
totalVotes: proofs.length,
tallyFor,
tallyAgainst
})
return { tallyFor, tallyAgainst }
}Step 6: Frontend Integration
This is a simplified React component demonstrating how to integrate the voting functionality into a frontend application.
// React component
import { useWallet } from '@solana/wallet-adapter-react'
import { ZKVault } from '@zkvault/sdk'
import { useState } from 'react'
function VotingApp() {
const wallet = useWallet()
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleVote(choice: 0 | 1) {
if (!wallet.publicKey) {
alert('Please connect wallet')
return
}
setIsSubmitting(true)
try {
// Check if user is eligible (fetch Merkle proof)
const { voterId, merkleProof } = await fetchEligibilityProof(
wallet.publicKey.toString()
)
if (!merkleProof) {
alert('You are not eligible to vote')
return
}
// Submit vote
const signature = await submitVote(voterId, choice, merkleProof)
alert(`Vote submitted successfully! TX: ${signature}`)
} catch (error) {
console.error('Vote submission failed:', error)
alert('Failed to submit vote')
} finally {
setIsSubmitting(false)
}
}
return (
<div>
<h1>Private Voting</h1>
<button
onClick={() => handleVote(1)}
disabled={isSubmitting}
>
Vote For
</button>
<button
onClick={() => handleVote(0)}
disabled={isSubmitting}
>
Vote Against
</button>
</div>
)
}Security Considerations
Double Voting Prevention
To prevent double voting, you can use on-chain state to track if a voter has already cast a vote.
// Use on-chain state to track if voter has already voted
// Store hash of voter_id (without revealing identity)
const voterIdHash = Hash.sha256(voterId)
// Check if already voted
const hasVoted = await program.account.voteRecord.fetch(voterIdHash)
if (hasVoted) {
throw new Error('Already voted')
}
// Mark as voted
await program.methods.recordVote(voterIdHash).rpc()Eligibility Updates
This section explains how to update the eligibility list for voters.
// To add/remove eligible voters, update Merkle tree and root
// All existing proofs remain valid (or invalid) based on new root
async function updateEligibility(newVoters: string[]) {
// Build new Merkle tree
const tree = new MerkleTree(newVoters)
const newRoot = tree.getRoot()
// Update vault with new root
await program.methods
.updateEligibilityRoot(newRoot)
.accounts({ vault: votingVault, admin: admin.publicKey })
.rpc()
}Testing
Unit tests are crucial for verifying the correctness of your zero-knowledge application.
import { expect } from 'chai'
describe('Private Voting', () => {
it('should accept valid vote from eligible voter', async () => {
const proof = await zkvault.prover.createProof(compiled, validInputs)
const tx = await zkvault.solana.verifyOnchain(proof, votingVault)
expect(tx).to.be.a('string')
})
it('should reject vote from ineligible voter', async () => {
await expect(
zkvault.prover.createProof(compiled, invalidInputs)
).to.be.rejectedWith('Constraint not satisfied')
})
it('should prevent double voting', async () => {
await submitVote(voterId, 1, merkleProof)
await expect(
submitVote(voterId, 0, merkleProof)
).to.be.rejectedWith('Already voted')
})
})