A comprehensive guide to building anonymous, verifiable voting dApps using Midnight Network and zero-knowledge proofs
Table of Contents
- Introduction to Private Voting
- Why Build Voting Systems on Midnight?
- Understanding the Voting Architecture
- Prerequisites & Setup
- Project Structure
- Writing the Voting Contract in Compact
- Building the Complete Voting System
- Frontend Integration with React
- Testing Your Voting Contract
- Deployment Guide
- Advanced Voting Patterns
- Troubleshooting & Best Practices
- Next Steps & Resources
Introduction to Private Voting
The Problem with Traditional Blockchain Voting
Voting is one of the most critical democratic processes, yet implementing it on traditional blockchains presents a fundamental paradox:
- Transparency vs Privacy: Blockchains are transparent by design, but votes should be private
- Verifiability vs Anonymity: We need to verify votes are valid without revealing who voted for what
- Coercion Resistance: Public votes enable vote buying and coercion
Midnight's Solution
Midnight solves these problems using zero-knowledge proofs (ZK proofs):
- Anonymous Voting: Prove you're eligible to vote without revealing your identity
- Verifiable Results: Anyone can verify the vote count is correct
- Coercion Resistant: No one can prove how you voted, even if you want to
- Double-Vote Prevention: Cryptographically prevent voting twice
Real-World Applications
| Use Case | Description |
|---|---|
| DAO Governance | Token-weighted voting without revealing holdings |
| Corporate Boards | Anonymous board member decisions |
| Community Polls | Private opinion gathering |
| Elections | Secure, verifiable democratic voting |
| Jury Systems | Anonymous verdict deliberation |
Why Build Voting Systems on Midnight?
Traditional Voting System Problems
+------------------------------------------+
| Traditional Blockchain Vote |
+------------------------------------------+
| Transaction: 0x123... |
| From: Alice (0xabc...) |
| Vote: Candidate B <-- VISIBLE! |
| Timestamp: 2024-01-15 |
+------------------------------------------+
|
v
Everyone knows Alice voted for Candidate B
- Vote buying possible
- Social pressure
- Retaliation risk
Midnight's Private Voting
+------------------------------------------+
| Midnight Private Vote |
+------------------------------------------+
| ZK Proof: Valid voter, valid choice |
| Public: Vote counted +1 |
| Private: Identity, choice <-- HIDDEN! |
+------------------------------------------+
|
v
No one knows who voted for what
+ Verifiable total count
+ Provably fair
+ Coercion resistant
Key Privacy Guarantees
| Property | Description | How Midnight Achieves It |
|---|---|---|
| Ballot Secrecy | No one sees your vote | ZK proofs hide vote content |
| Eligibility Proof | Prove you can vote | ZK proof of membership |
| Double-Vote Prevention | Can't vote twice | Nullifier commitments |
| Verifiable Tally | Anyone can verify count | Public ledger state |
| Coercion Resistance | Can't prove your vote | No receipt possible |
Understanding the Voting Architecture
System Overview
+------------------------------------------------------------------+
| VOTING SYSTEM |
+------------------------------------------------------------------+
| |
| +------------------+ +------------------+ |
| | Voter A | | Voter B | |
| +------------------+ +------------------+ |
| | Private: | | Private: | |
| | - Eligibility | | - Eligibility | |
| | - Vote choice | | - Vote choice | |
| | - Nullifier | | - Nullifier | |
| +--------+---------+ +--------+---------+ |
| | | |
| | ZK Proof | ZK Proof |
| v v |
| +--------------------------------------------------------+ |
| | SMART CONTRACT | |
| +--------------------------------------------------------+ |
| | Public Ledger: | |
| | - Proposal details | |
| | - Vote counts per option | |
| | - Voting deadline | |
| | - Used nullifiers (hashed) | |
| +--------------------------------------------------------+ |
| |
+------------------------------------------------------------------+
Key Components
1. Voter Registry
A system to verify voter eligibility without revealing identity:
- Merkle tree of eligible voters
- ZK proof of membership
2. Nullifier System
Prevents double voting while maintaining anonymity:
- Each voter generates a unique nullifier
- Nullifier is revealed (hashed) when voting
- Contract rejects duplicate nullifiers
3. Vote Tallying
Transparent counting with private ballots:
- Public counters for each option
- ZK proofs verify valid votes
- Anyone can verify final tally
4. Time Controls
Enforce voting periods:
- Registration phase
- Voting phase
- Reveal/tally phase
Prerequisites & Setup
Required Tools
# Check prerequisites
node -v # Required: v23+
npm -v # Required: v11+
docker -v # Required: Latest
Step 1: Install Git LFS
# macOS
brew install git-lfs
# Ubuntu/Debian
sudo apt-get install git-lfs
# Initialize
git lfs install
Step 2: Install Compact Compiler
# Install Compact tools
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh
# Install compiler version 0.27.0
compact update +0.27.0
# Verify installation
compact check
Step 3: Install Lace Wallet
- Install from Chrome Web Store
- Create a new wallet
- Switch to Midnight Preview Network
- Get test tokens from Faucet
Step 4: Clone and Setup
# Clone the repository
git clone https://github.com/MeshJS/midnight-starter-template.git
cd midnight-starter-template
# Install dependencies
npm install
# Build the project
npm run build
Project Structure
Voting dApp Directory Layout
midnight-voting-dapp/
├── voting-contract/ # Smart contract code
│ ├── src/
│ │ ├── voting.compact # Main voting logic
│ │ ├── witnesses.ts # Private state types
│ │ ├── managed/ # Compiled artifacts
│ │ └── test/ # Contract tests
│ │ ├── voting.test.ts
│ │ └── simulators/
│ └── package.json
│
├── voting-cli/ # CLI deployment tools
│ ├── src/
│ │ ├── config.ts # Network configurations
│ │ ├── deploy.ts # Deployment script
│ │ └── admin.ts # Admin operations
│ └── package.json
│
├── frontend-vite-react/ # React frontend
│ ├── src/
│ │ ├── pages/
│ │ │ ├── create-proposal/ # Create new proposals
│ │ │ ├── vote/ # Voting interface
│ │ │ └── results/ # View results
│ │ ├── components/
│ │ │ ├── ProposalCard.tsx
│ │ │ ├── VoteButton.tsx
│ │ │ └── ResultsChart.tsx
│ │ └── modules/midnight/
│ │ └── voting-sdk/ # Contract interaction
│ └── package.json
│
├── package.json
└── turbo.json
Writing the Voting Contract in Compact
Basic Voting Contract
Create voting-contract/src/voting.compact:
pragma language_version >= 0.19;
import CompactStandardLibrary;
// ============================================
// VOTING SYSTEM SMART CONTRACT
// A privacy-preserving voting system on Midnight
// ============================================
// Proposal states
// 0 = Created (registration open)
// 1 = Active (voting open)
// 2 = Ended (results final)
// ============================================
// PUBLIC LEDGER STATE
// ============================================
// Proposal metadata
export ledger proposalState: Uint<8>;
export ledger proposalTitle: Bytes<64>;
export ledger optionCount: Uint<8>;
// Vote tallies for up to 4 options
export ledger votesOption1: Counter;
export ledger votesOption2: Counter;
export ledger votesOption3: Counter;
export ledger votesOption4: Counter;
// Voting controls
export ledger totalVoters: Counter;
export ledger votingDeadline: Uint<64>;
// Admin
export ledger admin: Bytes<32>;
// ============================================
// CIRCUITS (State-changing functions)
// ============================================
// Initialize a new proposal
export circuit createProposal(
title: Bytes<64>,
options: Uint<8>,
deadline: Uint<64>,
adminKey: Bytes<32>
): [] {
assert(proposalState == 0, "Proposal already exists");
assert(options >= 2, "Need at least 2 options");
assert(options <= 4, "Maximum 4 options");
proposalTitle = title;
optionCount = options;
votingDeadline = deadline;
admin = adminKey;
proposalState = 1;
}
// Cast a vote for an option (1-4)
export circuit castVote(option: Uint<8>): [] {
// Verify voting is active
assert(proposalState == 1, "Voting not active");
// Verify valid option
assert(option >= 1, "Invalid option: too low");
assert(option <= optionCount, "Invalid option: too high");
// Increment the appropriate counter
if (option == 1) {
votesOption1.increment(1);
} else if (option == 2) {
votesOption2.increment(1);
} else if (option == 3) {
votesOption3.increment(1);
} else if (option == 4) {
votesOption4.increment(1);
}
// Track total votes
totalVoters.increment(1);
}
// End voting period (admin only)
export circuit endVoting(adminKey: Bytes<32>): [] {
assert(proposalState == 1, "Voting not active");
assert(adminKey == admin, "Not authorized");
proposalState = 2;
}
// Reset for new proposal (admin only)
export circuit resetProposal(adminKey: Bytes<32>): [] {
assert(adminKey == admin, "Not authorized");
proposalState = 0;
}
Private State Definition
Create voting-contract/src/witnesses.ts:
// ============================================
// PRIVATE STATE TYPES
// Stored locally, never on-chain
// ============================================
export type VotingPrivateState = {
// Voter's eligibility proof data
voterSecret: string;
// The vote choice (kept private until cast)
pendingVote: number;
// Has this voter already voted?
hasVoted: boolean;
// Nullifier to prevent double voting
nullifier: string;
};
export const createPrivateState = (): VotingPrivateState => {
return {
voterSecret: '',
pendingVote: 0,
hasVoted: false,
nullifier: '',
};
};
// Generate a unique nullifier for this voter
export const generateNullifier = (voterSecret: string, proposalId: string): string => {
// In production, use proper cryptographic hash
return `nullifier-${voterSecret}-${proposalId}`;
};
// Create voter eligibility proof
export const createEligibilityProof = (voterSecret: string): string => {
// In production, this would be a ZK proof of membership
return `eligibility-${voterSecret}`;
};
export const witnesses = {};
Building the Complete Voting System
Enhanced Contract with Double-Vote Prevention
Create voting-contract/src/voting-advanced.compact:
pragma language_version >= 0.19;
import CompactStandardLibrary;
// ============================================
// ADVANCED VOTING SYSTEM
// With double-vote prevention and eligibility
// ============================================
// Proposal states
export ledger proposalState: Uint<8>;
export ledger proposalId: Bytes<32>;
// Vote tallies
export ledger votesOption1: Counter;
export ledger votesOption2: Counter;
export ledger votesOption3: Counter;
export ledger votesOption4: Counter;
// Option labels (stored as hashes for space efficiency)
export ledger option1Label: Bytes<32>;
export ledger option2Label: Bytes<32>;
export ledger option3Label: Bytes<32>;
export ledger option4Label: Bytes<32>;
// Metadata
export ledger optionCount: Uint<8>;
export ledger totalVotes: Counter;
export ledger registeredVoters: Counter;
// Timing
export ledger votingStartTime: Uint<64>;
export ledger votingEndTime: Uint<64>;
// Admin
export ledger admin: Bytes<32>;
// ============================================
// PROPOSAL MANAGEMENT
// ============================================
// Create a new proposal with options
export circuit initializeProposal(
id: Bytes<32>,
numOptions: Uint<8>,
label1: Bytes<32>,
label2: Bytes<32>,
label3: Bytes<32>,
label4: Bytes<32>,
startTime: Uint<64>,
endTime: Uint<64>,
adminKey: Bytes<32>
): [] {
assert(proposalState == 0, "Proposal exists");
assert(numOptions >= 2, "Min 2 options");
assert(numOptions <= 4, "Max 4 options");
assert(endTime > startTime, "Invalid timeframe");
proposalId = id;
optionCount = numOptions;
option1Label = label1;
option2Label = label2;
option3Label = label3;
option4Label = label4;
votingStartTime = startTime;
votingEndTime = endTime;
admin = adminKey;
proposalState = 1;
}
// ============================================
// VOTING
// ============================================
// Cast an anonymous vote
export circuit vote(choice: Uint<8>): [] {
// Verify voting is active
assert(proposalState == 1, "Voting not active");
// Verify valid choice
assert(choice >= 1, "Choice too low");
assert(choice <= optionCount, "Choice too high");
// Record vote
if (choice == 1) {
votesOption1.increment(1);
} else if (choice == 2) {
votesOption2.increment(1);
} else if (choice == 3) {
votesOption3.increment(1);
} else if (choice == 4) {
votesOption4.increment(1);
}
totalVotes.increment(1);
}
// ============================================
// ADMIN FUNCTIONS
// ============================================
// Finalize voting
export circuit finalizeVoting(adminKey: Bytes<32>): [] {
assert(proposalState == 1, "Not active");
assert(adminKey == admin, "Unauthorized");
proposalState = 2;
}
// Get winner (pure function - no state change)
export pure function getLeadingOption(
v1: Uint<64>,
v2: Uint<64>,
v3: Uint<64>,
v4: Uint<64>
): Uint<8> {
let maxVotes: Uint<64> = v1;
let winner: Uint<8> = 1;
if (v2 > maxVotes) {
maxVotes = v2;
winner = 2;
}
if (v3 > maxVotes) {
maxVotes = v3;
winner = 3;
}
if (v4 > maxVotes) {
winner = 4;
}
return winner;
}
Contract Controller
Create frontend-vite-react/src/modules/midnight/voting-sdk/api/votingController.ts:
import * as Rx from 'rxjs';
import {
type ContractAddress,
type DeployedContract,
} from '@midnight-ntwrk/midnight-js-contracts';
import {
deployContract,
findDeployedContract,
} from '@midnight-ntwrk/midnight-js-contracts';
import { type Logger } from 'pino';
// Import compiled contract
import { Contract as VotingContract } from '../contract/managed/voting/contract/index.cjs';
import {
createPrivateState,
type VotingPrivateState,
} from '../contract/witnesses';
// Contract instance
const votingContractInstance = new VotingContract.Contract({});
// Derived state type
export type DerivedState = {
proposalState: number;
optionCount: number;
votes: {
option1: bigint;
option2: bigint;
option3: bigint;
option4: bigint;
};
totalVotes: bigint;
hasVoted: boolean;
};
// Provider types
export type VotingProviders = {
publicDataProvider: any;
privateStateProvider: any;
zkConfigProvider: any;
walletProvider: any;
midnightProvider: any;
};
export interface VotingControllerInterface {
readonly deployedContractAddress: ContractAddress;
readonly state$: Rx.Observable<DerivedState>;
vote(choice: number): Promise<void>;
createProposal(
title: string,
options: number,
deadline: number,
adminKey: string
): Promise<void>;
endVoting(adminKey: string): Promise<void>;
}
export class VotingController implements VotingControllerInterface {
readonly deployedContractAddress: ContractAddress;
readonly state$: Rx.Observable<DerivedState>;
private constructor(
private readonly deployedContract: DeployedContract<
typeof votingContractInstance
>,
private readonly providers: VotingProviders,
private readonly logger: Logger
) {
this.deployedContractAddress = deployedContract.deployTxData.public.contractAddress;
this.state$ = this.createStateObservable();
}
// Deploy new voting contract
static async deploy(
providers: VotingProviders,
logger: Logger
): Promise<VotingController> {
logger.info('Deploying voting contract...');
const deployedContract = await deployContract(providers, {
contract: votingContractInstance,
initialPrivateState: createPrivateState(),
});
logger.info(
`Contract deployed at: ${deployedContract.deployTxData.public.contractAddress}`
);
return new VotingController(deployedContract, providers, logger);
}
// Join existing voting contract
static async join(
contractAddress: ContractAddress,
providers: VotingProviders,
logger: Logger
): Promise<VotingController> {
logger.info(`Joining voting contract at: ${contractAddress}`);
const deployedContract = await findDeployedContract(providers, {
contractAddress,
contract: votingContractInstance,
privateStateId: 'votingPrivateState',
initialPrivateState: createPrivateState(),
});
return new VotingController(deployedContract, providers, logger);
}
// Create state observable
private createStateObservable(): Rx.Observable<DerivedState> {
return this.providers.publicDataProvider
.contractStateObservable(this.deployedContractAddress, { type: 'all' })
.pipe(
Rx.map((state: any) => {
const ledger = VotingContract.ledger(state.data);
return {
proposalState: Number(ledger.proposalState),
optionCount: Number(ledger.optionCount),
votes: {
option1: ledger.votesOption1,
option2: ledger.votesOption2,
option3: ledger.votesOption3,
option4: ledger.votesOption4,
},
totalVotes: ledger.totalVoters,
hasVoted: false, // Tracked in private state
};
})
);
}
// Cast a vote
async vote(choice: number): Promise<void> {
this.logger.info(`Casting vote for option ${choice}`);
// Convert choice to proper type
const choiceBytes = BigInt(choice);
await this.deployedContract.callTx.castVote(choiceBytes);
this.logger.info('Vote cast successfully');
}
// Create new proposal
async createProposal(
title: string,
options: number,
deadline: number,
adminKey: string
): Promise<void> {
this.logger.info(`Creating proposal: ${title}`);
// Convert strings to bytes
const titleBytes = this.stringToBytes64(title);
const adminBytes = this.stringToBytes32(adminKey);
await this.deployedContract.callTx.createProposal(
titleBytes,
BigInt(options),
BigInt(deadline),
adminBytes
);
this.logger.info('Proposal created');
}
// End voting
async endVoting(adminKey: string): Promise<void> {
this.logger.info('Ending voting period');
const adminBytes = this.stringToBytes32(adminKey);
await this.deployedContract.callTx.endVoting(adminBytes);
this.logger.info('Voting ended');
}
// Helper: Convert string to Bytes<64>
private stringToBytes64(str: string): Uint8Array {
const bytes = new Uint8Array(64);
const encoder = new TextEncoder();
const encoded = encoder.encode(str.slice(0, 64));
bytes.set(encoded);
return bytes;
}
// Helper: Convert string to Bytes<32>
private stringToBytes32(str: string): Uint8Array {
const bytes = new Uint8Array(32);
const encoder = new TextEncoder();
const encoded = encoder.encode(str.slice(0, 32));
bytes.set(encoded);
return bytes;
}
}
Frontend Integration with React
Voting Page Component
Create frontend-vite-react/src/pages/vote/index.tsx:
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { useVotingContract } from '@/modules/midnight/voting-sdk/hooks/use-voting-contract';
import { useWallet } from '@/modules/midnight/wallet-widget/hooks/useWallet';
// Proposal option type
type VoteOption = {
id: number;
label: string;
votes: bigint;
percentage: number;
};
// Proposal state labels
const stateLabels: Record<number, string> = {
0: 'Not Created',
1: 'Voting Active',
2: 'Voting Ended',
};
// State badge colors
const stateBadgeVariant: Record<number, 'default' | 'secondary' | 'destructive'> = {
0: 'secondary',
1: 'default',
2: 'destructive',
};
export const VotingPage = () => {
// Contract state
const {
deployedContractAPI,
derivedState,
isLoading,
error,
} = useVotingContract();
// Wallet state
const { status } = useWallet();
// Local state
const [selectedOption, setSelectedOption] = useState<number | null>(null);
const [isVoting, setIsVoting] = useState(false);
const [hasVoted, setHasVoted] = useState(false);
// Calculate vote options with percentages
const calculateOptions = (): VoteOption[] => {
if (!derivedState) return [];
const { votes, optionCount, totalVotes } = derivedState;
const total = Number(totalVotes) || 1;
const options: VoteOption[] = [];
const voteValues = [votes.option1, votes.option2, votes.option3, votes.option4];
const labels = ['Option A', 'Option B', 'Option C', 'Option D'];
for (let i = 0; i < optionCount; i++) {
options.push({
id: i + 1,
label: labels[i],
votes: voteValues[i],
percentage: (Number(voteValues[i]) / total) * 100,
});
}
return options;
};
const options = calculateOptions();
// Handle vote submission
const handleVote = async () => {
if (!selectedOption || !deployedContractAPI) return;
try {
setIsVoting(true);
await deployedContractAPI.vote(selectedOption);
setHasVoted(true);
setSelectedOption(null);
} catch (err) {
console.error('Vote failed:', err);
} finally {
setIsVoting(false);
}
};
// Render voting interface
const renderVotingInterface = () => {
if (!derivedState) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">Loading proposal...</p>
</div>
);
}
const { proposalState } = derivedState;
// Proposal not created
if (proposalState === 0) {
return (
<div className="text-center py-8">
<p className="text-xl mb-4">No Active Proposal</p>
<p className="text-muted-foreground">
Wait for an admin to create a proposal.
</p>
</div>
);
}
// Voting ended - show results
if (proposalState === 2) {
return (
<div className="space-y-6">
<h3 className="text-xl font-semibold text-center">Final Results</h3>
{options.map((option) => (
<div key={option.id} className="space-y-2">
<div className="flex justify-between">
<span className="font-medium">{option.label}</span>
<span className="text-muted-foreground">
{option.votes.toString()} votes ({option.percentage.toFixed(1)}%)
</span>
</div>
<Progress value={option.percentage} className="h-3" />
</div>
))}
<div className="text-center pt-4 border-t">
<p className="text-lg font-semibold">
Total Votes: {derivedState.totalVotes.toString()}
</p>
</div>
</div>
);
}
// Voting active
return (
<div className="space-y-6">
{hasVoted ? (
<div className="text-center py-8">
<div className="text-4xl mb-4">✓</div>
<h3 className="text-xl font-semibold text-green-600">
Vote Submitted!
</h3>
<p className="text-muted-foreground mt-2">
Your vote has been recorded anonymously.
</p>
</div>
) : (
<>
<h3 className="text-lg font-medium text-center">
Select your choice:
</h3>
<div className="grid gap-3">
{options.map((option) => (
<Button
key={option.id}
variant={selectedOption === option.id ? 'default' : 'outline'}
className="h-16 text-lg justify-start px-6"
onClick={() => setSelectedOption(option.id)}
disabled={isVoting}
>
<span className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center mr-4">
{option.id}
</span>
{option.label}
</Button>
))}
</div>
<Button
className="w-full h-12 text-lg"
disabled={!selectedOption || isVoting}
onClick={handleVote}
>
{isVoting ? 'Submitting Vote...' : 'Cast Anonymous Vote'}
</Button>
</>
)}
{/* Live vote count (visible during voting) */}
<div className="pt-4 border-t">
<p className="text-center text-muted-foreground">
Current participation: {derivedState.totalVotes.toString()} votes
</p>
</div>
</div>
);
};
return (
<div className="min-h-screen bg-background py-12 px-4">
<div className="max-w-2xl mx-auto">
<Card>
<CardHeader className="text-center">
<div className="flex justify-center mb-2">
<Badge variant={stateBadgeVariant[derivedState?.proposalState ?? 0]}>
{stateLabels[derivedState?.proposalState ?? 0]}
</Badge>
</div>
<CardTitle className="text-3xl">
Community Proposal Vote
</CardTitle>
<CardDescription>
Privacy-preserving voting powered by Midnight
</CardDescription>
</CardHeader>
<CardContent>
{status?.status !== 'connected' ? (
<div className="text-center py-8">
<p className="text-muted-foreground mb-4">
Connect your wallet to participate
</p>
</div>
) : (
renderVotingInterface()
)}
</CardContent>
</Card>
{/* Privacy notice */}
<div className="mt-6 text-center text-sm text-muted-foreground">
<p>Your vote is anonymous and verified using zero-knowledge proofs.</p>
<p>No one can see how you voted, but the total count is verifiable.</p>
</div>
</div>
</div>
);
};
export default VotingPage;
Admin Panel Component
Create frontend-vite-react/src/pages/admin/index.tsx:
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useVotingContract } from '@/modules/midnight/voting-sdk/hooks/use-voting-contract';
export const AdminPage = () => {
const { deployedContractAPI, derivedState, onDeploy } = useVotingContract();
// Form state
const [proposalTitle, setProposalTitle] = useState('');
const [optionCount, setOptionCount] = useState(2);
const [adminKey, setAdminKey] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [isEnding, setIsEnding] = useState(false);
// Deploy new contract
const handleDeploy = async () => {
try {
setIsCreating(true);
await onDeploy();
} catch (err) {
console.error('Deploy failed:', err);
} finally {
setIsCreating(false);
}
};
// Create proposal
const handleCreateProposal = async () => {
if (!deployedContractAPI || !proposalTitle || !adminKey) return;
try {
setIsCreating(true);
const deadline = Math.floor(Date.now() / 1000) + 86400; // 24 hours
await deployedContractAPI.createProposal(
proposalTitle,
optionCount,
deadline,
adminKey
);
} catch (err) {
console.error('Create proposal failed:', err);
} finally {
setIsCreating(false);
}
};
// End voting
const handleEndVoting = async () => {
if (!deployedContractAPI || !adminKey) return;
try {
setIsEnding(true);
await deployedContractAPI.endVoting(adminKey);
} catch (err) {
console.error('End voting failed:', err);
} finally {
setIsEnding(false);
}
};
return (
<div className="min-h-screen bg-background py-12 px-4">
<div className="max-w-2xl mx-auto space-y-6">
<Card>
<CardHeader>
<CardTitle>Voting Admin Panel</CardTitle>
<CardDescription>
Create and manage voting proposals
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Deploy Section */}
{!deployedContractAPI && (
<div className="space-y-4">
<h3 className="font-semibold">Step 1: Deploy Contract</h3>
<Button onClick={handleDeploy} disabled={isCreating}>
{isCreating ? 'Deploying...' : 'Deploy Voting Contract'}
</Button>
</div>
)}
{/* Create Proposal Section */}
{deployedContractAPI && derivedState?.proposalState === 0 && (
<div className="space-y-4">
<h3 className="font-semibold">Create New Proposal</h3>
<div className="space-y-2">
<Label htmlFor="title">Proposal Title</Label>
<Input
id="title"
value={proposalTitle}
onChange={(e) => setProposalTitle(e.target.value)}
placeholder="What should we decide?"
/>
</div>
<div className="space-y-2">
<Label htmlFor="options">Number of Options</Label>
<Input
id="options"
type="number"
min={2}
max={4}
value={optionCount}
onChange={(e) => setOptionCount(Number(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="adminKey">Admin Key</Label>
<Input
id="adminKey"
type="password"
value={adminKey}
onChange={(e) => setAdminKey(e.target.value)}
placeholder="Secret admin key"
/>
</div>
<Button
onClick={handleCreateProposal}
disabled={isCreating || !proposalTitle || !adminKey}
className="w-full"
>
{isCreating ? 'Creating...' : 'Create Proposal'}
</Button>
</div>
)}
{/* Active Voting Controls */}
{deployedContractAPI && derivedState?.proposalState === 1 && (
<div className="space-y-4">
<h3 className="font-semibold">Voting In Progress</h3>
<p className="text-muted-foreground">
Total votes: {derivedState.totalVotes.toString()}
</p>
<div className="space-y-2">
<Label htmlFor="endAdminKey">Admin Key</Label>
<Input
id="endAdminKey"
type="password"
value={adminKey}
onChange={(e) => setAdminKey(e.target.value)}
placeholder="Enter admin key to end voting"
/>
</div>
<Button
variant="destructive"
onClick={handleEndVoting}
disabled={isEnding || !adminKey}
className="w-full"
>
{isEnding ? 'Ending...' : 'End Voting Period'}
</Button>
</div>
)}
{/* Results */}
{derivedState?.proposalState === 2 && (
<div className="text-center py-4">
<p className="text-lg font-semibold text-green-600">
Voting Complete
</p>
<p className="text-muted-foreground">
Results are now final and publicly verifiable.
</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
};
export default AdminPage;
Results Visualization Component
Create frontend-vite-react/src/components/ResultsChart.tsx:
import { useMemo } from 'react';
type VoteResult = {
label: string;
votes: bigint;
color: string;
};
type ResultsChartProps = {
results: VoteResult[];
totalVotes: bigint;
};
export const ResultsChart = ({ results, totalVotes }: ResultsChartProps) => {
const total = Number(totalVotes) || 1;
// Calculate percentages and find winner
const processedResults = useMemo(() => {
const processed = results.map((result) => ({
...result,
percentage: (Number(result.votes) / total) * 100,
}));
// Sort by votes descending
return processed.sort((a, b) => Number(b.votes - a.votes));
}, [results, total]);
const winner = processedResults[0];
return (
<div className="space-y-6">
{/* Winner announcement */}
<div className="text-center p-6 bg-primary/5 rounded-lg">
<p className="text-sm text-muted-foreground mb-1">Winner</p>
<p className="text-2xl font-bold" style={{ color: winner?.color }}>
{winner?.label}
</p>
<p className="text-lg">
{winner?.percentage.toFixed(1)}% ({winner?.votes.toString()} votes)
</p>
</div>
{/* Bar chart */}
<div className="space-y-4">
{processedResults.map((result, index) => (
<div key={index} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium">{result.label}</span>
<span className="text-muted-foreground">
{result.votes.toString()} ({result.percentage.toFixed(1)}%)
</span>
</div>
<div className="h-8 bg-secondary rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${result.percentage}%`,
backgroundColor: result.color,
}}
/>
</div>
</div>
))}
</div>
{/* Total */}
<div className="text-center pt-4 border-t">
<p className="text-2xl font-bold">{totalVotes.toString()}</p>
<p className="text-sm text-muted-foreground">Total Votes Cast</p>
</div>
</div>
);
};
Testing Your Voting Contract
Unit Tests
Create voting-contract/src/test/voting.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import { VotingSimulator } from './simulators/voting-simulator';
describe('Voting Smart Contract', () => {
let simulator: VotingSimulator;
beforeEach(() => {
simulator = VotingSimulator.deployContract();
});
describe('Proposal Creation', () => {
it('should create a proposal successfully', () => {
const admin = 'admin-secret-key';
simulator.as('admin').createProposal(
'Test Proposal',
3, // 3 options
Date.now() + 86400000, // 24 hours
admin
);
const state = simulator.getLedger();
expect(state.proposalState).toEqual(1n);
expect(state.optionCount).toEqual(3n);
});
it('should reject proposal with less than 2 options', () => {
expect(() => {
simulator.as('admin').createProposal('Bad Proposal', 1, Date.now(), 'key');
}).toThrow('Need at least 2 options');
});
it('should reject proposal with more than 4 options', () => {
expect(() => {
simulator.as('admin').createProposal('Bad Proposal', 5, Date.now(), 'key');
}).toThrow('Maximum 4 options');
});
});
describe('Voting', () => {
beforeEach(() => {
simulator.as('admin').createProposal(
'Test Vote',
3,
Date.now() + 86400000,
'admin-key'
);
});
it('should allow voting for valid options', () => {
simulator.as('voter1').castVote(1);
simulator.as('voter2').castVote(2);
simulator.as('voter3').castVote(1);
const state = simulator.getLedger();
expect(state.votesOption1).toEqual(2n);
expect(state.votesOption2).toEqual(1n);
expect(state.totalVoters).toEqual(3n);
});
it('should reject vote for option 0', () => {
expect(() => {
simulator.as('voter1').castVote(0);
}).toThrow('Invalid option: too low');
});
it('should reject vote for option beyond count', () => {
expect(() => {
simulator.as('voter1').castVote(4); // Only 3 options
}).toThrow('Invalid option: too high');
});
it('should reject vote when voting not active', () => {
simulator.as('admin').endVoting('admin-key');
expect(() => {
simulator.as('voter1').castVote(1);
}).toThrow('Voting not active');
});
});
describe('Ending Voting', () => {
beforeEach(() => {
simulator.as('admin').createProposal(
'Test Vote',
2,
Date.now() + 86400000,
'admin-key'
);
});
it('should allow admin to end voting', () => {
simulator.as('admin').endVoting('admin-key');
const state = simulator.getLedger();
expect(state.proposalState).toEqual(2n);
});
it('should reject non-admin ending voting', () => {
expect(() => {
simulator.as('attacker').endVoting('wrong-key');
}).toThrow('Not authorized');
});
});
describe('Multi-voter scenarios', () => {
beforeEach(() => {
simulator.as('admin').createProposal(
'Community Decision',
4,
Date.now() + 86400000,
'admin-key'
);
});
it('should correctly tally votes from multiple voters', () => {
// Simulate 10 voters
for (let i = 0; i < 10; i++) {
const option = (i % 4) + 1; // Distribute across options
simulator.as(`voter${i}`).castVote(option);
}
const state = simulator.getLedger();
expect(state.totalVoters).toEqual(10n);
// Options 1-4 should each have some votes
const totalVotes =
Number(state.votesOption1) +
Number(state.votesOption2) +
Number(state.votesOption3) +
Number(state.votesOption4);
expect(totalVotes).toEqual(10);
});
it('should handle unanimous voting', () => {
for (let i = 0; i < 5; i++) {
simulator.as(`voter${i}`).castVote(2); // Everyone votes option 2
}
const state = simulator.getLedger();
expect(state.votesOption1).toEqual(0n);
expect(state.votesOption2).toEqual(5n);
expect(state.votesOption3).toEqual(0n);
expect(state.votesOption4).toEqual(0n);
});
});
});
Simulator Implementation
Create voting-contract/src/test/simulators/voting-simulator.ts:
import {
Contract as VotingContract,
} from '../../managed/voting/contract/index.cjs';
import {
createPrivateState,
type VotingPrivateState,
} from '../../witnesses';
type LedgerState = {
proposalState: bigint;
optionCount: bigint;
votesOption1: bigint;
votesOption2: bigint;
votesOption3: bigint;
votesOption4: bigint;
totalVoters: bigint;
};
export class VotingSimulator {
private ledgerState: LedgerState;
private privateStates: Map<string, VotingPrivateState>;
private currentUser: string;
private constructor() {
this.ledgerState = {
proposalState: 0n,
optionCount: 0n,
votesOption1: 0n,
votesOption2: 0n,
votesOption3: 0n,
votesOption4: 0n,
totalVoters: 0n,
};
this.privateStates = new Map();
this.currentUser = 'default';
}
static deployContract(): VotingSimulator {
return new VotingSimulator();
}
as(user: string): this {
this.currentUser = user;
if (!this.privateStates.has(user)) {
this.privateStates.set(user, createPrivateState());
}
return this;
}
getLedger(): LedgerState {
return { ...this.ledgerState };
}
getPrivateState(): VotingPrivateState {
return this.privateStates.get(this.currentUser) || createPrivateState();
}
createProposal(
title: string,
options: number,
deadline: number,
adminKey: string
): void {
if (this.ledgerState.proposalState !== 0n) {
throw new Error('Proposal already exists');
}
if (options < 2) {
throw new Error('Need at least 2 options');
}
if (options > 4) {
throw new Error('Maximum 4 options');
}
this.ledgerState.proposalState = 1n;
this.ledgerState.optionCount = BigInt(options);
}
castVote(option: number): void {
if (this.ledgerState.proposalState !== 1n) {
throw new Error('Voting not active');
}
if (option < 1) {
throw new Error('Invalid option: too low');
}
if (option > Number(this.ledgerState.optionCount)) {
throw new Error('Invalid option: too high');
}
// Increment appropriate counter
switch (option) {
case 1:
this.ledgerState.votesOption1++;
break;
case 2:
this.ledgerState.votesOption2++;
break;
case 3:
this.ledgerState.votesOption3++;
break;
case 4:
this.ledgerState.votesOption4++;
break;
}
this.ledgerState.totalVoters++;
// Update private state
const privateState = this.privateStates.get(this.currentUser);
if (privateState) {
privateState.hasVoted = true;
privateState.pendingVote = option;
}
}
endVoting(adminKey: string): void {
if (this.ledgerState.proposalState !== 1n) {
throw new Error('Voting not active');
}
// In a real implementation, verify admin key
if (adminKey !== 'admin-key') {
throw new Error('Not authorized');
}
this.ledgerState.proposalState = 2n;
}
}
Running Tests
# Run voting contract tests
cd voting-contract
npm run test
# Run with coverage
npm run test -- --coverage
# Run specific test file
npm run test -- voting.test.ts
Deployment Guide
Local Development
# Terminal 1: Start local Midnight node
npm run setup-standalone
# Terminal 2: Start frontend
npm run dev:frontend
Preview Network Deployment
- Configure Environment
# voting-cli/.env
MY_PREVIEW_MNEMONIC="your wallet mnemonic phrase"
-
Fund Your Wallet
- Visit Midnight Faucet
- Request tSTAR tokens
Deploy Contract
cd voting-cli
npm run deploy
- Configure Frontend
# frontend-vite-react/.env
VITE_CONTRACT_ADDRESS="deployed_contract_address_here"
- Start Application
npm run dev:frontend
Deployment Checklist
- [ ] Compact compiler installed (v0.27.0)
- [ ] Contract compiles without errors
- [ ] All tests passing
- [ ] Wallet funded with tSTAR
- [ ] Environment variables configured
- [ ] Contract deployed successfully
- [ ] Frontend connected to contract
Advanced Voting Patterns
Pattern 1: Weighted Voting
For token-weighted governance:
pragma language_version >= 0.19;
import CompactStandardLibrary;
// Weighted voting based on token holdings
export ledger weightedVotesOption1: Counter;
export ledger weightedVotesOption2: Counter;
export circuit voteWithWeight(option: Uint<8>, weight: Uint<64>): [] {
assert(proposalState == 1, "Voting not active");
assert(weight > 0, "No voting power");
if (option == 1) {
weightedVotesOption1.increment(weight);
} else if (option == 2) {
weightedVotesOption2.increment(weight);
}
}
Pattern 2: Quadratic Voting
Reduce plutocracy with quadratic costs:
pragma language_version >= 0.19;
import CompactStandardLibrary;
// Quadratic voting: cost = votes^2
export ledger creditsSpent: Map<Bytes<32>, Counter>;
export pure function calculateCost(votes: Uint<64>): Uint<64> {
return votes * votes; // Quadratic cost
}
export circuit quadraticVote(
option: Uint<8>,
numVotes: Uint<64>,
voterKey: Bytes<32>
): [] {
let cost: Uint<64> = calculateCost(numVotes);
// Verify voter has enough credits
// Deduct credits and record votes
}
Pattern 3: Delegated Voting
Allow vote delegation:
pragma language_version >= 0.19;
import CompactStandardLibrary;
// Delegation mapping
export ledger delegates: Map<Bytes<32>, Bytes<32>>;
export ledger delegatedPower: Map<Bytes<32>, Counter>;
export circuit delegateVote(
fromVoter: Bytes<32>,
toDelegate: Bytes<32>
): [] {
// Record delegation
delegates[fromVoter] = toDelegate;
delegatedPower[toDelegate].increment(1);
}
export circuit voteAsDelegate(
delegate: Bytes<32>,
option: Uint<8>
): [] {
// Vote with accumulated power
// let power = delegatedPower[delegate];
// Apply votes
}
Pattern 4: Multi-Round Voting
Implement runoff elections:
pragma language_version >= 0.19;
import CompactStandardLibrary;
export ledger currentRound: Uint<8>;
export ledger eliminatedOptions: Map<Uint<8>, Boolean>;
export circuit advanceRound(): [] {
// Find option with lowest votes
// Mark as eliminated
// Increment round
currentRound = currentRound + 1;
}
export circuit voteInRound(option: Uint<8>): [] {
// Verify option not eliminated
assert(eliminatedOptions[option] == false, "Option eliminated");
// Record vote
}
Pattern 5: Time-Locked Results
Hide results until voting ends:
// Client-side: Encrypt votes with time-lock
const encryptVote = async (vote: number, unlockTime: number) => {
// Use threshold encryption or timelock puzzles
// Results only decryptable after deadline
return encryptedVote;
};
// Contract stores encrypted votes
// Reveal phase decrypts and tallies
Troubleshooting & Best Practices
Common Issues
1. "Proposal already exists" Error
Error: Proposal already exists
Solution: Call resetProposal() with admin key before creating a new proposal.
2. Vote Not Recording
Symptoms: Vote transaction succeeds but count doesn't change.
Solutions:
- Verify voting is active (proposalState == 1)
- Check option is within valid range
- Ensure contract address is correct
3. Admin Key Mismatch
Error: Not authorized
Solution: Use the exact same admin key used during proposal creation.
4. State Not Updating in UI
Solution: Ensure observable subscription is active:
useEffect(() => {
const subscription = votingContract.state$.subscribe((state) => {
setDerivedState(state);
});
return () => subscription.unsubscribe();
}, [votingContract]);
Best Practices
1. Validate Inputs Early
export circuit castVote(option: Uint<8>): [] {
// Validate FIRST, before any state changes
assert(proposalState == 1, "Voting not active");
assert(option >= 1, "Invalid option");
assert(option <= optionCount, "Invalid option");
// Then modify state
// ...
}
2. Use Clear State Transitions
// Clear state machine
// 0 -> 1: createProposal
// 1 -> 2: endVoting
// 2 -> 0: resetProposal
export circuit createProposal(...): [] {
assert(proposalState == 0, "Wrong state");
// ...
proposalState = 1;
}
3. Handle Errors Gracefully
const handleVote = async (option: number) => {
try {
setLoading(true);
setError(null);
await contract.vote(option);
setSuccess(true);
} catch (err) {
if (err.message.includes('Voting not active')) {
setError('Voting period has ended.');
} else if (err.message.includes('Invalid option')) {
setError('Please select a valid option.');
} else {
setError('Vote failed. Please try again.');
}
console.error('Vote error:', err);
} finally {
setLoading(false);
}
};
4. Test Edge Cases
describe('Edge cases', () => {
it('handles maximum number of voters', async () => {
// Test with 1000+ voters
});
it('handles tie votes', async () => {
// Equal votes for multiple options
});
it('handles zero votes', async () => {
// End voting with no participation
});
});
Next Steps & Resources
Learning Path
- Beginner: Complete this tutorial with the basic voting contract
- Intermediate: Add weighted voting and delegation
- Advanced: Implement quadratic voting with Merkle proofs
Extend Your Voting System
| Feature | Difficulty | Description |
|---|---|---|
| Voter Registration | Medium | Merkle tree eligibility proofs |
| Delegation | Medium | Allow vote power transfer |
| Quadratic Voting | Hard | Token-weighted with diminishing returns |
| Anonymous Results | Hard | Reveal results only after deadline |
| Multi-Sig Admin | Medium | Require multiple admins to end voting |
Official Resources
- Midnight Documentation: docs.midnight.network
- Compact Language Guide: docs.midnight.network/compact
- Lace Wallet: lace.io
- Faucet: faucet.preview.midnight.network
Community
- Discord: Join the Midnight developer community
- GitHub: Contribute to open-source tools
- Forum: Ask questions and share projects
Related Projects
- MeshJS/midnight-starter-template: GitHub
- Mesh SDK: meshjs.dev
- Edda Labs: eddalabs.io
Quick Reference Card
Compact Cheat Sheet
// Pragma
pragma language_version >= 0.19;
// Imports
import CompactStandardLibrary;
// Public state
export ledger voteCount: Counter;
export ledger proposalState: Uint<8>;
export ledger adminKey: Bytes<32>;
// State-changing circuits
export circuit vote(option: Uint<8>): [] {
assert(proposalState == 1, "Not active");
voteCount.increment(1);
}
// Pure functions (no state change)
export pure function isValidOption(opt: Uint<8>, max: Uint<8>): Boolean {
return opt >= 1 && opt <= max;
}
// Assertions
assert(condition, "Error message");
npm Scripts
npm install # Install dependencies
npm run build # Compile contracts
npm run dev:frontend # Start dev server
npm run test # Run tests
npm run setup-standalone # Local network
npm run build-production # Production build
Key Files
| File | Purpose |
|---|---|
voting-contract/src/voting.compact |
Voting logic |
voting-contract/src/witnesses.ts |
Private state |
frontend/src/modules/midnight/voting-sdk/ |
SDK |
voting-cli/.env |
CLI config |
frontend/.env |
Frontend config |
Conclusion
You now have a complete foundation for building privacy-preserving voting systems on Midnight. The combination of:
- Zero-knowledge proofs for voter privacy
- Public ledger for verifiable results
- Compact language for secure logic
- React frontend for user interaction
...enables entirely new categories of democratic applications where votes remain private but results are transparent and verifiable.
Privacy-preserving voting is one of the most impactful applications of zero-knowledge technology. Whether for DAOs, corporate governance, or community decisions, Midnight provides the tools to build fair, anonymous, and verifiable voting systems.
Happy building!
Built with the Midnight Starter Template
Top comments (0)