Commit/Reveal Voting on Midnight: Building a Private DAO Ballot
Most on-chain voting systems have a timing problem. Voters can see how others have voted before casting their own. In a public chain, this creates influence effects — latecomers adjust their votes based on the current tally, quorum chasers wait for a majority to form, and early signal-senders get outsized influence over the outcome. Some proposals fail not because they lack support, but because early "no" votes discouraged later participants from bothering.
Commit/reveal is the standard solution. Voters first commit to their choice without revealing it, then later reveal. The tally only becomes visible after the reveal phase closes. Midnight's ZK system makes this cleaner than it is on other chains: you don't just hide votes during the commit phase, you can prove vote validity without ever revealing the underlying choice.
This tutorial builds a working commit/reveal voting contract in Compact with:
- A commit phase where voters submit a hash of (vote + secret)
- A reveal phase where voters prove their hash and increment tallies
- Nullifiers preventing double voting
- A Merkle tree for voter eligibility
- Time-locked phase transitions using block height
- TypeScript client code for the full flow
The Architecture
Before writing any code, let's map out what each layer does.
Compact contract responsibilities:
- Store the commitment Merkle tree for eligible voters
- Track submitted vote commitments (to prevent changing your commit)
- Manage nullifiers (to prevent double-revealing)
- Store the phase (commit/reveal/closed) and transition times
- Accumulate the public tally during the reveal phase
TypeScript client responsibilities:
- Generate the secret and commitment hash for each voter
- Persist the secret locally between commit and reveal
- Build the reveal transaction, including the Merkle proof of eligibility
- Query tallies after the reveal phase ends
What ZK proofs buy us:
- Voters prove they're on the eligibility list without revealing which leaf they are
- The commitment-to-vote linkage is proven inside the circuit, not exposed on-chain
- Nullifier derivation is deterministic but domain-separated — you can't link a nullifier to a voter without the secret
Contract State Design
The public ledger stores:
- Voter eligibility tree — a Merkle tree of voter public keys
- Commitment registry — mapping of voter nullifier commitment to submitted vote commitment
- Nullifier set — which voters have already revealed (prevents double voting)
- Tally — per-option vote counts
- Phase and timing — current phase, block height cutoffs for transitions
pragma language_version >= 0.22;
import CompactStandardLibrary;
export enum Phase {
COMMIT,
REVEAL,
CLOSED
}
export ledger phase: Phase;
// Block heights at which phases transition
export ledger commitDeadline: Field;
export ledger revealDeadline: Field;
// Merkle tree of eligible voter public keys
// The depth determines max voters (2^TREE_DEPTH)
export ledger voterTree: MerkleTree<Bytes<32>, 16>;
// Maps each voter's nullifier to their submitted commitment
// Prevents voters from changing their vote between commit and reveal
export ledger commitments: Map<Bytes<32>, Bytes<32>>;
// Nullifiers mark voters who have revealed — prevents double voting
export ledger nullifiers: Set<Bytes<32>>;
// Vote tallies — supports up to 4 options
export ledger tallyYes: Field;
export ledger tallyNo: Field;
export ledger tallyAbstain: Field;
// Total participation stats
export ledger totalCommits: Counter;
export ledger totalReveals: Counter;
One thing to note about the commitments Map: it's keyed by a voter-derived nullifier commitment (a hash of the voter's secret and some domain separator), not by their identity directly. This way an observer can't easily correlate on-chain commitments to known voter addresses without knowledge of the secret.
The Compact Contract
Constructor
constructor(
commitDeadlineBlock: Field,
revealDeadlineBlock: Field,
voterTreeRoot: Bytes<32>
) {
phase = Phase.COMMIT;
commitDeadline = commitDeadlineBlock;
revealDeadline = revealDeadlineBlock;
// Caller provides the pre-built Merkle tree root
// The tree itself is stored for proof verification
voterTree.setRoot(voterTreeRoot);
tallyYes = 0 as Field;
tallyNo = 0 as Field;
tallyAbstain = 0 as Field;
}
Witness Functions
// Voter's long-term secret key (persisted locally, never revealed)
witness voterSecretKey(): Bytes<32>;
// Vote value: 0=yes, 1=no, 2=abstain
witness voteValue(): Uint<8>;
// The per-vote random blinding factor
witness voteBlinder(): Bytes<32>;
// Merkle path from voter's leaf to the tree root
witness merkleProofPath(): Vector<16, Bytes<32>>;
// Leaf index in the voter tree
witness merkleLeafIndex(): Uint<32>;
Phase Transition Circuits
Phase transitions are driven by block height. Any caller can trigger a transition once the deadline passes — no privileged admin needed.
export circuit advancePhase(): [] {
const currentBlock = blockHeight() as Field;
if (phase == Phase.COMMIT) {
assert(currentBlock >= commitDeadline, "Commit phase not yet ended");
phase = Phase.REVEAL;
} else if (phase == Phase.REVEAL) {
assert(currentBlock >= revealDeadline, "Reveal phase not yet ended");
phase = Phase.CLOSED;
} else {
assert(false, "Voting is already closed");
}
}
The blockHeight() function is a Compact built-in that returns the current block's height as a Field. This gives you a reliable, on-chain time reference for deadlines — not perfect wall-clock time, but predictable within block time variance.
Commit Circuit
During the commit phase, voters submit a hash of their vote and blinding factor. They also derive their nullifier — a commitment that proves they voted without revealing who they are.
export circuit commitVote(
// The commitment: persistentHash(voteValue, voteBlinder)
// Computed by the voter client-side; verified in ZK here
voteCommitment: Bytes<32>
): [] {
assert(phase == Phase.COMMIT, "Not in commit phase");
// Derive the voter's public key from their secret
// This is what appears in the eligibility Merkle tree
const voterPubKey = persistentHash<Vector<2, Bytes<32>>>(
[pad(32, "vote:pubkey:"), voterSecretKey()]
);
// Verify the voter is in the eligibility tree
// merkleVerify checks the Merkle proof against the stored root
assert(
voterTree.verify(
merkleLeafIndex(),
voterPubKey,
merkleProofPath()
),
"Voter not in eligibility tree"
);
// Derive a domain-separated nullifier commitment
// This maps to the voter uniquely but can't be linked back
// to voterPubKey without knowing the secret
const nullifierCommitment = persistentCommit<Vector<3, Bytes<32>>>(
[pad(32, "vote:nullifier:"), voterSecretKey(), voteCommitment]
);
// Ensure this voter hasn't already committed
assert(
!commitments.contains(disclose(nullifierCommitment)),
"Already committed"
);
// Store commitment keyed by nullifier commitment
commitments.insert(disclose(nullifierCommitment), disclose(voteCommitment));
totalCommits.increment(1);
}
Why persistentCommit instead of persistentHash? In Midnight's ZK system, persistentCommit produces a hiding commitment — given only the commitment value, you can't determine the inputs without the secret. persistentHash is binding but not hiding. For nullifier derivation where you want unlinkability, persistentCommit is the right choice.
Reveal Circuit
The reveal circuit is where the ZK magic happens. The voter proves they committed to a specific vote, that their vote is valid, and that they haven't already revealed — all without revealing which voter they are.
export circuit revealVote(): [] {
assert(phase == Phase.REVEAL, "Not in reveal phase");
// Reconstruct the commitment from the witness values
// This is what the voter originally submitted in commitVote
const recomputedCommitment = persistentHash<Vector<2, Bytes<32>>>(
[voteBlinder(), pad(8, voteValue() as Field as Bytes<8>)]
);
// Reconstruct the nullifier commitment (same derivation as commit phase)
const nullifierCommitment = persistentCommit<Vector<3, Bytes<32>>>(
[pad(32, "vote:nullifier:"), voterSecretKey(), recomputedCommitment]
);
// Verify the voter actually submitted this commitment
const publicNullifierCommitment = disclose(nullifierCommitment);
assert(
commitments.get(publicNullifierCommitment) == recomputedCommitment,
"Commitment mismatch — wrong secret or vote value"
);
// Derive the nullifier itself from the secret
// Uses persistentCommit so it's one-way
const nullifier = persistentCommit<Vector<2, Bytes<32>>>(
[pad(32, "vote:spent:"), voterSecretKey()]
);
// Check nullifier hasn't been used
const publicNullifier = disclose(nullifier);
assert(!nullifiers.contains(publicNullifier), "Already revealed");
// Mark as spent
nullifiers.insert(publicNullifier);
totalReveals.increment(1);
// Increment the appropriate tally
const vote = voteValue();
if (vote == 0) {
tallyYes = tallyYes + (1 as Field);
} else if (vote == 1) {
tallyNo = tallyNo + (1 as Field);
} else {
tallyAbstain = tallyAbstain + (1 as Field);
}
}
Note the separation between nullifierCommitment (used to look up the vote commitment) and nullifier (used to prevent double-spending). These are two different values derived from the same secret but for different purposes. Using persistentCommit for both prevents anyone from correlating them to the underlying voter without the secret.
TypeScript Client
The TypeScript layer handles vote secret management and transaction construction.
Managing Vote Secrets
// voting-client/src/vote-secret.ts
import { persistentCommit } from '@midnight-ntwrk/compact-runtime';
export type VoteSecret = {
secretKey: Uint8Array;
blinder: Uint8Array;
vote: 0 | 1 | 2; // yes, no, abstain
};
export function generateVoteSecret(vote: 0 | 1 | 2): VoteSecret {
return {
secretKey: crypto.getRandomValues(new Uint8Array(32)),
blinder: crypto.getRandomValues(new Uint8Array(32)),
vote,
};
}
export function computeCommitment(secret: VoteSecret): Uint8Array {
// Mirror the Compact computation:
// persistentHash([blinder, pad(8, voteValue)])
const voteBytes = new Uint8Array(8);
new DataView(voteBytes.buffer).setBigUint64(0, BigInt(secret.vote), false);
return persistentCommit([secret.blinder, voteBytes]);
}
export function saveVoteSecret(contractAddress: string, secret: VoteSecret): void {
const key = `vote-secret:${contractAddress}`;
localStorage.setItem(key, JSON.stringify({
secretKey: Array.from(secret.secretKey),
blinder: Array.from(secret.blinder),
vote: secret.vote,
}));
}
export function loadVoteSecret(contractAddress: string): VoteSecret | null {
const key = `vote-secret:${contractAddress}`;
const stored = localStorage.getItem(key);
if (!stored) return null;
const parsed = JSON.parse(stored);
return {
secretKey: new Uint8Array(parsed.secretKey),
blinder: new Uint8Array(parsed.blinder),
vote: parsed.vote,
};
}
This is one of those places where you really need to think about storage. The vote secret is the only thing that lets a voter reveal their vote later. Lose it, and your vote is permanently committed but never counted. In a production app you'd want:
- Encrypted local storage
- Optional backup to a personal encrypted store (not cloud — that would compromise your voting privacy)
- A clear UI warning that the secret can't be recovered
Witnesses Implementation
// contract/src/witnesses.ts
import { WitnessContext } from '@midnight-ntwrk/compact-runtime';
import { Ledger } from './managed/voting/contract/index.js';
export type VotingPrivateState = {
readonly secretKey: Uint8Array;
readonly vote: number;
readonly blinder: Uint8Array;
readonly merklePath: Uint8Array[];
readonly leafIndex: number;
};
export const createVotingPrivateState = (
secretKey: Uint8Array,
vote: number,
blinder: Uint8Array,
merklePath: Uint8Array[],
leafIndex: number
): VotingPrivateState => ({ secretKey, vote, blinder, merklePath, leafIndex });
export const witnesses = {
voterSecretKey: ({
privateState,
}: WitnessContext<Ledger, VotingPrivateState>): [VotingPrivateState, Uint8Array] =>
[privateState, privateState.secretKey],
voteValue: ({
privateState,
}: WitnessContext<Ledger, VotingPrivateState>): [VotingPrivateState, number] =>
[privateState, privateState.vote],
voteBlinder: ({
privateState,
}: WitnessContext<Ledger, VotingPrivateState>): [VotingPrivateState, Uint8Array] =>
[privateState, privateState.blinder],
merkleProofPath: ({
privateState,
}: WitnessContext<Ledger, VotingPrivateState>): [VotingPrivateState, Uint8Array[]] =>
[privateState, privateState.merklePath],
merkleLeafIndex: ({
privateState,
}: WitnessContext<Ledger, VotingPrivateState>): [VotingPrivateState, number] =>
[privateState, privateState.leafIndex],
};
Voting API
// api/src/voting-api.ts
import { deployContract, findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';
import { type Observable, map, combineLatest } from 'rxjs';
import type { ContractAddress } from '@midnight-ntwrk/compact-runtime';
import * as Contract from '../../contract/src/managed/voting/contract/index.js';
import { CompiledVotingContract } from '../../contract/src/index.js';
import { createVotingPrivateState, type VotingPrivateState } from '../../contract/src/witnesses.js';
import { generateVoteSecret, computeCommitment, saveVoteSecret, loadVoteSecret } from './vote-secret.js';
import { buildProviders } from './providers.js';
export type VotingResult = {
phase: 'COMMIT' | 'REVEAL' | 'CLOSED';
totalCommits: bigint;
totalReveals: bigint;
tally: { yes: bigint; no: bigint; abstain: bigint };
};
const PRIVATE_STATE_KEY = 'voting-state';
export class VotingAPI {
readonly contractAddress: ContractAddress;
readonly state$: Observable<VotingResult>;
private constructor(private deployedContract: any, providers: any) {
this.contractAddress = deployedContract.deployTxData.public.contractAddress;
this.state$ = providers.publicDataProvider
.contractStateObservable(this.contractAddress, { type: 'latest' })
.pipe(
map((contractState: any) => {
const ledger = Contract.ledger(contractState.data);
return {
phase: ['COMMIT', 'REVEAL', 'CLOSED'][ledger.phase] as VotingResult['phase'],
totalCommits: ledger.totalCommits,
totalReveals: ledger.totalReveals,
tally: {
yes: ledger.tallyYes,
no: ledger.tallyNo,
abstain: ledger.tallyAbstain,
},
};
})
);
}
async commit(
vote: 0 | 1 | 2,
voterSecretKey: Uint8Array,
merklePath: Uint8Array[],
leafIndex: number
): Promise<void> {
const secret = generateVoteSecret(vote);
const commitment = computeCommitment(secret);
// Save secret before submitting — if the tx fails we can retry
saveVoteSecret(this.contractAddress, secret);
const privateState = createVotingPrivateState(
voterSecretKey,
vote,
secret.blinder,
merklePath,
leafIndex
);
await this.deployedContract.callTx.commitVote(commitment, {
override: { privateState, privateStateId: PRIVATE_STATE_KEY }
});
}
async reveal(voterSecretKey: Uint8Array): Promise<void> {
const secret = loadVoteSecret(this.contractAddress);
if (!secret) throw new Error('No vote secret found — did you commit first?');
const privateState = createVotingPrivateState(
voterSecretKey,
secret.vote,
secret.blinder,
[], // merklePath not needed for reveal
0
);
await this.deployedContract.callTx.revealVote({
override: { privateState, privateStateId: PRIVATE_STATE_KEY }
});
}
async advancePhase(): Promise<void> {
await this.deployedContract.callTx.advancePhase();
}
static async deploy(
networkId: string,
params: {
commitDeadlineBlock: bigint;
revealDeadlineBlock: bigint;
voterTreeRoot: Uint8Array;
deployerSecretKey: Uint8Array;
}
): Promise<VotingAPI> {
const providers = await buildProviders(networkId as any);
const initialPrivateState = createVotingPrivateState(
params.deployerSecretKey, 0, new Uint8Array(32), [], 0
);
const deployed = await deployContract(providers, {
compiledContract: CompiledVotingContract,
privateStateId: PRIVATE_STATE_KEY,
initialPrivateState,
});
return new VotingAPI(deployed, providers);
}
}
Building the Voter Eligibility Tree
Before deploying, you need to build the Merkle tree from the list of eligible voter public keys. This happens off-chain.
// scripts/build-voter-tree.ts
import { persistentHash } from '@midnight-ntwrk/compact-runtime';
import { MerkleTree } from '@midnight-ntwrk/midnight-js-utils';
const DOMAIN_PREFIX = 'vote:pubkey:';
const TREE_DEPTH = 16;
export function buildVoterTree(voterSecretKeys: Uint8Array[]): {
root: Uint8Array;
leaves: Uint8Array[];
tree: MerkleTree;
} {
// Derive public keys for each voter
const leaves = voterSecretKeys.map((sk) => {
const prefix = new TextEncoder().encode(DOMAIN_PREFIX.padEnd(32, '\0'));
return persistentHash([prefix, sk]);
});
// Pad to tree size
const emptyLeaf = new Uint8Array(32);
while (leaves.length < 2 ** TREE_DEPTH) {
leaves.push(emptyLeaf);
}
const tree = new MerkleTree(leaves);
return { root: tree.root, leaves, tree };
}
export function getMerklePath(tree: MerkleTree, leafIndex: number): Uint8Array[] {
return tree.getProof(leafIndex);
}
In a real deployment, you'd collect voter public keys through some registration process — maybe a snapshot of DAO token holders at a specific block, or a governance-gated registration period. The tree root gets passed to the contract constructor and locked in.
Testing the Flow
A full integration test covers the happy path and the double-vote case:
import { describe, it, expect, beforeAll } from 'vitest';
import { VotingAPI } from '../src/voting-api.js';
import { buildVoterTree, getMerklePath } from './build-voter-tree.js';
describe('commit/reveal voting', () => {
const voter1Key = crypto.getRandomValues(new Uint8Array(32));
const voter2Key = crypto.getRandomValues(new Uint8Array(32));
let api: VotingAPI;
beforeAll(async () => {
const { root, tree } = buildVoterTree([voter1Key, voter2Key]);
api = await VotingAPI.deploy('testnet', {
commitDeadlineBlock: 100n,
revealDeadlineBlock: 200n,
voterTreeRoot: root,
deployerSecretKey: crypto.getRandomValues(new Uint8Array(32)),
});
// Store merkle paths for later use
const path1 = getMerklePath(tree, 0);
const path2 = getMerklePath(tree, 1);
await api.commit(0, voter1Key, path1, 0); // voter1 votes YES
await api.commit(1, voter2Key, path2, 1); // voter2 votes NO
});
it('shows two commits after commit phase', async () => {
const state = await new Promise((resolve) => api.state$.subscribe(resolve));
expect((state as any).totalCommits).toBe(2n);
});
it('tallies correctly after reveal', async () => {
// Advance to reveal phase
await api.advancePhase();
await api.reveal(voter1Key);
await api.reveal(voter2Key);
const state = await new Promise((resolve) => api.state$.subscribe(resolve));
expect((state as any).tally.yes).toBe(1n);
expect((state as any).tally.no).toBe(1n);
expect((state as any).tally.abstain).toBe(0n);
});
it('rejects double reveal', async () => {
await expect(api.reveal(voter1Key)).rejects.toThrow('Already revealed');
});
});
What This Pattern Buys You
Compared to a plain on-chain vote:
- Front-running resistance. Votes are invisible until the reveal phase. No one can watch the tally and vote strategically at the last second.
- Coercion resistance (partial). Without a receipt mechanism, voters can claim they voted any way. A sophisticated coercer could demand the secret though — this model doesn't prevent secret-selling.
- Double-vote prevention with privacy. The nullifier proves "I have already voted" without saying who "I" is.
- Ineligible voter rejection. The Merkle tree check inside the ZK circuit proves voter eligibility without revealing which voter is submitting.
The main limitation is social: people need to keep their secrets for the duration of the voting period and remember to come back during the reveal window. These are UX problems, not protocol problems. A good client app makes both easy.
Next Steps
From here, a few directions worth exploring:
- Weighted voting. Include token balances in the Merkle tree leaves and scale tallies by weight during reveal.
- Turnout thresholds. Add a minimum participation quorum — the contract rejects a CLOSED phase with insufficient reveals.
- Delegation. Let voters delegate their Merkle tree position to another address. This requires some thought around nullifier design to avoid linking delegator and delegate.
- Multiple proposals. Generalize the contract to handle N proposals in parallel, with a shared eligibility tree.
The Midnight forum has discussions on the ZK aspects of each of these. Worth reading before implementing — some have non-obvious edge cases around the nullifier scheme.
Top comments (0)