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')
  })
})

Next Steps