On-chain voting has a timing problem. When votes are cast publicly, participants can watch early results and adjust their choices accordingly. In a close race, seeing 60% favor "yes" influences how undecided voters behave. In adversarial contexts, it's worse: coercers can observe and manipulate votes when choices are visible on-chain in real time.
The commit/reveal pattern addresses this by splitting voting into two phases. During the commit phase, voters submit a cryptographic commitment to their vote — something that binds them to a choice without revealing it. Once the commit window closes, the reveal phase opens: voters prove their commitment matches a vote, the ZK circuit tallies without trusting the voter's self-reported value, and nobody can change their vote retroactively.
Midnight's Compact makes this unusually clean. persistentCommit gives a proper hiding commitment scheme (not just a hash). HistoricMerkleTree makes concurrent voter registration race-condition-free. Witness functions keep all private key material off-chain while ZK proofs verify correctness without exposing secrets.
This tutorial builds the full thing: commit and reveal circuits, a block-height phase state machine, Merkle-tree eligibility checks, domain-separated nullifiers, TypeScript witnesses, Vitest tests, and a minimal React frontend. All Compact code compiles against the latest compiler.
Prerequisites: Midnight toolchain, basic Compact familiarity, TypeScript.
persistentHash vs persistentCommit: the core distinction
This is the one thing to get right before writing any circuit. Get it wrong and vote privacy doesn't hold.
persistentHash<T>(v: T): Bytes<32> is deterministic. Same input, same output, always. For a three-option vote (no / yes / abstain), that's a problem: an observer can crack any commitment in three tries.
const hashNo = persistentHash<Uint<8>>(0);
const hashYes = persistentHash<Uint<8>>(1);
const hashAbstain = persistentHash<Uint<8>>(2);
// Three hashes covers the entire vote space
A hash commitment is binding (can't change your vote after submitting) but not hiding (vote is discoverable by exhaustive check). For a small domain like vote choices, that's not a commitment scheme — it's just delayed disclosure.
persistentCommit<T>(v: T, opening: Bytes<32>): Bytes<32> adds a 32-byte random blinding factor. An attacker would need to find an opening such that persistentCommit(guess, opening) matches the stored value. With 256 bits of randomness, that's not happening.
Use persistentHash for nullifiers, where binding is what you need. Use persistentCommit for vote commitments, where you need both.
Contract architecture
The contract manages five things: phase state, voter eligibility, commitment storage, nullifier tracking, and vote tallies.
pragma language_version >= 0.20;
import CompactStandardLibrary;
// 0 = COMMIT — voters submit hashed votes
// 1 = REVEAL — voters prove and tally
// 2 = CLOSED — finalized, results available
export ledger phase: Uint<8>;
export ledger commitDeadline: Uint<64>;
export ledger revealDeadline: Uint<64>;
// HistoricMerkleTree keeps a full history of past roots.
// Voters registering concurrently won't invalidate each other's eligibility proofs.
export ledger voterTree: HistoricMerkleTree<16, Bytes<32>>;
// nullifierHash → voteCommitment
// Key: persistentHash(voterSecret) — ties commit to voter, doesn't reveal identity
// Value: persistentCommit(vote, blinder) — hides vote until reveal
export ledger commitments: Map<Bytes<32>, Bytes<32>>;
// Spent nullifiers — prevents any voter from revealing twice
export ledger spentNullifiers: Map<Bytes<32>, Boolean>;
// Tallies and participation tracking
export ledger yesVotes: Counter;
export ledger noVotes: Counter;
export ledger abstainVotes: Counter;
export ledger totalCommits: Counter;
export ledger totalReveals: Counter;
Why HistoricMerkleTree and not MerkleTree
Easy to overlook, but it matters as soon as you have more than a handful of voters registering around the same time.
A standard MerkleTree proof is only valid against the current root. Voter A generates their eligibility proof when the root is R1. Before they submit, voter B registers, changing the root to R2. Voter A's proof is now invalid. They regenerate it, but by then voter C has registered. Under any real load this becomes a liveness problem.
HistoricMerkleTree keeps a history of every root the tree has ever had. A voter who generated their eligibility proof against root R1 can still submit their transaction even after other voters have registered and moved the root to R2 — the runtime accepts proofs against any root in the history. Concurrent registrations never invalidate outstanding proofs.
// Standard tree — proofs only valid against current root
export ledger voterTree: MerkleTree<16, Bytes<32>>;
// Historic tree — runtime keeps a history of all past roots
// Both use checkRoot() in circuits; HistoricMerkleTree allows the
// TypeScript client to regenerate proofs against any past root
export ledger voterTree: HistoricMerkleTree<16, Bytes<32>>;
Both MerkleTree and HistoricMerkleTree use checkRoot() in Compact circuits — the method name is identical. The difference is entirely in the TypeScript layer: HistoricMerkleTree exposes historical roots through the ledger API, letting client-side proof generation pick whichever past root is still valid for the voter's Merkle path. (Note: checkRootInHistory is not a valid method — it causes a compile error. See pitfall #9.)
Witnesses
Private inputs — voter secrets, vote values, blinding factors, Merkle paths — are supplied off-chain by the TypeScript client. They never appear on-chain.
witness getVoterSecret(): Bytes<32>;
witness getVote(): Uint<8>; // 0 = NO, 1 = YES, 2 = ABSTAIN
witness getBlinder(): Bytes<32>; // randomness for the vote commitment
witness getVoterPath(): MerkleTreePath<16, Bytes<32>>;
Constructor
constructor(
commitDeadlineBlock: Uint<64>,
revealDeadlineBlock: Uint<64>
) {
phase = disclose(0);
commitDeadline = disclose(commitDeadlineBlock);
revealDeadline = disclose(revealDeadlineBlock);
}
The constructor takes absolute block heights rather than durations. Compact's range-typed arithmetic means adding two Uint<64> values produces a Uint<0..2^65> — wider than Uint<64> — which can't be assigned back without an explicit downcast. Doing the deadline arithmetic off-chain in TypeScript (where overflow is easier to handle) and passing the results in is cleaner.
// Off-chain: compute absolute deadlines before deploying
const currentBlock = await getBlockHeight();
const commitDeadline = currentBlock + 100n; // 100 blocks for commit phase
const revealDeadline = commitDeadline + 100n; // 100 blocks for reveal phase
Phase transitions
Phase transitions are permissionless. No admin key, no privileged actor. Anyone can advance the phase once the deadline passes. The current block height is passed as a public parameter, and the Midnight network verifies it matches the actual block height at transaction submission time.
// Advance from COMMIT to REVEAL once the commit window closes
export circuit openRevealPhase(currentBlock: Uint<64>): [] {
assert(phase == 0, "Not in commit phase");
assert(disclose(currentBlock) >= commitDeadline, "Commit period not over");
phase = disclose(1);
}
// Seal the results once the reveal window closes
export circuit closeVoting(currentBlock: Uint<64>): [] {
assert(phase == 1, "Not in reveal phase");
assert(disclose(currentBlock) >= revealDeadline, "Reveal period not over");
phase = disclose(2);
}
Two circuits rather than one advancePhase keeps each transition's preconditions explicit. No branching on phase state inside the circuit body.
Voter registration
export circuit registerVoter(voterKey: Bytes<32>): [] {
assert(phase == 0, "Registration only during commit phase");
voterTree.insert(disclose(voterKey));
}
In a production DAO, this would be gated by governance logic — a multisig approval, a token-weighted vote, or a KYC attestation. Here the only gate is the commit phase deadline. Once the commit window closes, the voter tree is locked.
The commit circuit
During the commit phase, each voter submits two opaque 32-byte values: a nullifierHash (persistentHash(voterSecret)) that ties this commitment to the voter without revealing who they are, and a voteCommitment (persistentCommit(vote, blinder)) that hides the actual vote choice.
Neither reveals the vote. The nullifier doesn't expose voter identity. The commitment can't be reversed without the blinding factor.
The circuit also verifies voter eligibility via a Merkle path witness — the voter proves they're in the eligible voter set without disclosing which leaf is theirs.
export circuit commitVote(
nullifierHash: Bytes<32>,
voteCommitment: Bytes<32>
): [] {
assert(phase == 0, "Not in commit phase");
// Prove eligibility — the Merkle path stays private in the witness
const path = getVoterPath();
const computed = merkleTreePathRoot<16, Bytes<32>>(path);
assert(
voterTree.checkRoot(disclose(computed)),
"Voter not in eligibility tree"
);
// One commitment per voter
assert(!commitments.member(disclose(nullifierHash)), "Already committed");
commitments.insert(disclose(nullifierHash), disclose(voteCommitment));
totalCommits.increment(1);
}
Two disclose() calls worth explaining. checkRoot(disclose(computed)) — computed comes from a private witness path, so passing it into a ledger method requires explicit disclosure. This isn't about making the value "public" in any meaningful sense; it's telling the compiler you're aware a witness-derived value is entering a ledger operation.
commitments.member(disclose(nullifierHash)) — even though nullifierHash was supplied by the caller as a public circuit parameter, Compact treats exported parameters as potentially private until disclosed. disclose() is required before any ledger operation, regardless of how the value got there.
The reveal circuit
At reveal time, the vote becomes public — that's the point of the reveal phase. What stays private is how the voter knows their vote matches the stored commitment.
The ZK proof demonstrates: "I know a (voterSecret, blinder) such that persistentHash(voterSecret) maps to a stored commitment, and persistentCommit(vote, blinder) matches the stored vote commitment for that key." Neither voterSecret nor blinder appears in the transaction. Only vote is revealed.
export circuit revealVote(vote: Uint<8>): [] {
assert(phase == 1, "Not in reveal phase");
// disclose(vote) required: even in an assert, the compiler flags comparisons
// on exported parameters that could branch circuit execution differently
assert(disclose(vote) < 3, "Invalid vote: must be 0 (NO), 1 (YES), or 2 (ABSTAIN)");
// Private inputs — supplied by TypeScript witness, never on-chain
const secret = getVoterSecret();
const blinder = getBlinder();
// Derive nullifier hash from private secret
const nullifierHash = persistentHash<Bytes<32>>(secret);
// Reconstruct commitment from the revealed vote and private blinder
const derivedCommitment = persistentCommit<Uint<8>>(disclose(vote), blinder);
// Verify a commitment exists for this voter's nullifier
assert(
commitments.member(disclose(nullifierHash)),
"No commitment found for this voter"
);
// Verify the vote matches what was committed
assert(
derivedCommitment == commitments.lookup(disclose(nullifierHash)),
"Vote commitment mismatch — wrong vote, secret, or blinder"
);
// Mark nullifier spent — prevents the same voter from revealing twice
assert(!spentNullifiers.member(disclose(nullifierHash)), "Vote already revealed");
spentNullifiers.insert(disclose(nullifierHash), disclose(true));
// Tally — disclose(vote) is required in if/else conditions:
// branching on an exported parameter and executing different ledger
// operations per branch discloses which branch was taken.
// Compact requires explicit disclose() to acknowledge this is intentional.
if (disclose(vote) == 0) { noVotes.increment(1); }
else if (disclose(vote) == 1) { yesVotes.increment(1); }
else { abstainVotes.increment(1); }
totalReveals.increment(1);
}
The assert(disclose(vote) < 3, ...) at the top catches invalid vote values early — they'd fail the commitment check anyway, but this gives a cleaner error message. And yes, disclose() is required in the assert too. See pitfall #10.
Domain-separated nullifiers
There's a quiet privacy leak in nullifierHash = persistentHash(voterSecret): persistentHash is deterministic, so a voter using the same secret across multiple proposals produces identical nullifier hashes in every contract. Anyone watching the chain can see "this nullifier appears in proposals A, B, and C — same voter." Not ideal for a privacy-preserving system.
The fix is domain separation: mix a unique proposal identifier into the nullifier before hashing. Compact doesn't expose arbitrary byte concatenation in circuits, so this is cleanest in the TypeScript layer.
First, store the proposal ID as a sealed ledger field:
sealed ledger proposalId: Bytes<32>;
// Note: in practice, pass absolute block heights (not durations) to avoid
// the Uint<64> arithmetic range issue described in pitfall #8.
constructor(
id: Bytes<32>,
commitDeadlineBlock: Uint<64>,
revealDeadlineBlock: Uint<64>
) {
proposalId = disclose(id);
phase = disclose(0);
commitDeadline = disclose(commitDeadlineBlock);
revealDeadline = disclose(revealDeadlineBlock);
}
Then derive the nullifier off-chain with the proposal ID mixed in:
function computeNullifierHash(
voterSecret: Uint8Array,
proposalId: Uint8Array,
): Uint8Array {
// XOR the secret with the proposal ID for domain separation
// In production: use a proper KDF (HKDF or similar)
const combined = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
combined[i] = voterSecret[i] ^ proposalId[i];
}
return persistentHash(combined);
}
Each contract instance has a unique proposalId. Voters using the same secret across proposals produce different nullifier hashes — no cross-proposal correlation.
TypeScript integration
The witness implementations supply private state during proof generation:
import type { WitnessContext } from '@midnight-ntwrk/compact-runtime';
export interface VotePrivateState {
voterSecret: Uint8Array;
vote: number; // 0 | 1 | 2
blinder: Uint8Array;
}
export const witnesses = {
getVoterSecret: (
context: WitnessContext<Ledger, VotePrivateState>,
) => [context.privateState, context.privateState.voterSecret] as const,
getVote: (
context: WitnessContext<Ledger, VotePrivateState>,
) => [context.privateState, BigInt(context.privateState.vote)] as const,
getBlinder: (
context: WitnessContext<Ledger, VotePrivateState>,
) => [context.privateState, context.privateState.blinder] as const,
getVoterPath: (
context: WitnessContext<Ledger, VotePrivateState>,
) => {
const voterKey = deriveVoterKey(context.privateState.voterSecret);
const path = context.ledger.voterTree.findPathForLeaf(voterKey);
if (!path) throw new Error('Voter not found in eligibility tree');
return [context.privateState, path] as const;
},
};
A VotingAPI class wraps the circuit calls and manages private state:
export class VotingAPI {
constructor(
private readonly contract: Contract<typeof witnesses>,
private context: CircuitContext,
private readonly proposalId: Uint8Array,
) {}
registerVoter(voterKey: Uint8Array) {
({ context: this.context } =
this.contract.impureCircuits.registerVoter(this.context, voterKey));
}
commitVote(voterSecret: Uint8Array, vote: 0 | 1 | 2, blinder: Uint8Array) {
const nullifierHash = computeNullifierHash(voterSecret, this.proposalId);
const voteCommitment = computeVoteCommitment(vote, blinder);
({ context: this.context } =
this.contract.impureCircuits.commitVote(
this.context,
nullifierHash,
voteCommitment,
));
// Store locally — needed for the reveal transaction
localStorage.setItem('voteSecret', JSON.stringify(Array.from(voterSecret)));
localStorage.setItem('voteBlinder', JSON.stringify(Array.from(blinder)));
localStorage.setItem('voteChoice', String(vote));
}
revealVote() {
const secret = new Uint8Array(JSON.parse(localStorage.getItem('voteSecret')!));
const blinder = new Uint8Array(JSON.parse(localStorage.getItem('voteBlinder')!));
const vote = Number(localStorage.getItem('voteChoice')) as 0 | 1 | 2;
({ context: this.context } =
this.contract.impureCircuits.revealVote(this.context, BigInt(vote)));
}
advancePhase(currentBlock: bigint) {
const state = ledger(this.context.currentQueryContext.state);
if (state.phase === 0n) {
({ context: this.context } =
this.contract.impureCircuits.openRevealPhase(this.context, currentBlock));
} else if (state.phase === 1n) {
({ context: this.context } =
this.contract.impureCircuits.closeVoting(this.context, currentBlock));
}
}
getResults() {
const state = ledger(this.context.currentQueryContext.state);
return {
phase: Number(state.phase),
yes: Number(state.yesVotes),
no: Number(state.noVotes),
abstain: Number(state.abstainVotes),
totalCommits: Number(state.totalCommits),
totalReveals: Number(state.totalReveals),
};
}
}
Tests
import { describe, it, expect, beforeEach } from 'vitest';
import {
createConstructorContext,
createCircuitContext,
sampleContractAddress,
} from '@midnight-ntwrk/compact-runtime';
import { Contract, ledger } from '../managed/voting/contract/index.js';
import { witnesses } from './witnesses.js';
const proposalId = new Uint8Array(32).fill(42);
function makeSecret(seed: number) { return new Uint8Array(32).fill(seed); }
function makeBlinder(seed: number) { return new Uint8Array(32).fill(seed + 100); }
function makeVoterKey(seed: number) { return new Uint8Array(32).fill(seed + 200); }
function createSimulator() {
const contract = new Contract(witnesses);
const constructorCtx = createConstructorContext({}, new Uint8Array(32));
const { currentPrivateState, currentContractState, currentZswapLocalState } =
contract.initialState(constructorCtx, 1000n, 100n, 100n);
const circuitContext = createCircuitContext(
sampleContractAddress(),
currentZswapLocalState,
currentContractState,
currentPrivateState,
);
return { contract, circuitContext };
}
describe('commit/reveal voting', () => {
it('accepts a valid commit then reveal', () => {
let { contract, circuitContext } = createSimulator();
const secret = makeSecret(1);
const blinder = makeBlinder(1);
const voterKey = makeVoterKey(1);
// Register voter
({ context: circuitContext } =
contract.impureCircuits.registerVoter(circuitContext, voterKey));
// Commit YES
const nullifierHash = computeNullifierHash(secret, proposalId);
const voteCommitment = computeVoteCommitment(1, blinder);
({ context: circuitContext } =
contract.impureCircuits.commitVote(circuitContext, nullifierHash, voteCommitment));
// Advance to reveal phase
({ context: circuitContext } =
contract.impureCircuits.openRevealPhase(circuitContext, 1101n));
// Reveal YES
({ context: circuitContext } =
contract.impureCircuits.revealVote(circuitContext, 1n));
const state = ledger(circuitContext.currentQueryContext.state);
expect(state.yesVotes).toBe(1n);
expect(state.totalReveals).toBe(1n);
});
it('rejects double-commit from same voter', () => {
let { contract, circuitContext } = createSimulator();
const voterKey = makeVoterKey(1);
({ context: circuitContext } =
contract.impureCircuits.registerVoter(circuitContext, voterKey));
const nullifierHash = computeNullifierHash(makeSecret(1), proposalId);
const voteCommitment = computeVoteCommitment(1, makeBlinder(1));
({ context: circuitContext } =
contract.impureCircuits.commitVote(circuitContext, nullifierHash, voteCommitment));
expect(() =>
contract.impureCircuits.commitVote(circuitContext, nullifierHash, voteCommitment)
).toThrow('Already committed');
});
it('rejects double-reveal', () => {
let { contract, circuitContext } = createSimulator();
// ... register, commit, advance phase, first reveal succeeds
// second reveal throws 'Vote already revealed'
});
it('rejects reveal with wrong blinder', () => {
let { contract, circuitContext } = createSimulator();
// ... commit with blinder A, attempt reveal with blinder B
// throws 'Vote commitment mismatch'
});
it('rejects commit from non-eligible voter', () => {
let { contract, circuitContext } = createSimulator();
// Voter NOT in voterTree attempts to commit
// throws 'Voter not in eligibility tree'
});
it('tallies multiple voters correctly', () => {
let { contract, circuitContext } = createSimulator();
// Register 3 voters, commit YES/YES/NO, advance, reveal all
// expect yesVotes = 2n, noVotes = 1n
});
});
Example frontend
The bounty requires an example frontend. Here's a minimal React component that walks a voter through all three phases:
import React, { useState, useEffect } from 'react';
const PHASES = ['Commit', 'Reveal', 'Closed'];
const VOTE_LABELS: Record<number, string> = { 0: 'No', 1: 'Yes', 2: 'Abstain' };
export function VotingPanel({ api }: { api: VotingAPI }) {
const [results, setResults] = useState({ phase: 0, yes: 0, no: 0, abstain: 0 });
const [status, setStatus] = useState('');
const refresh = () => setResults(api.getResults());
useEffect(() => { refresh(); }, []);
async function handleCommit(vote: 0 | 1 | 2) {
setStatus('Generating ZK proof…');
const secret = crypto.getRandomValues(new Uint8Array(32));
const blinder = crypto.getRandomValues(new Uint8Array(32));
try {
api.commitVote(secret, vote, blinder);
setStatus(`Committed! Your ${VOTE_LABELS[vote]} vote is hidden until the reveal phase.`);
} catch (e) {
setStatus(`Error: ${(e as Error).message}`);
}
refresh();
}
async function handleReveal() {
setStatus('Generating reveal proof…');
try {
api.revealVote();
setStatus('Vote revealed and tallied!');
} catch (e) {
setStatus(`Error: ${(e as Error).message}`);
}
refresh();
}
const { phase, yes, no, abstain } = results;
return (
<div style={{ fontFamily: 'monospace', padding: 24 }}>
<h2>Phase: {PHASES[phase]}</h2>
{phase === 0 && (
<div>
<p>Cast your vote. Your choice is hidden until the reveal phase.</p>
{([0, 1, 2] as const).map(v => (
<button key={v} onClick={() => handleCommit(v)}
style={{ marginRight: 8 }}>
{VOTE_LABELS[v]}
</button>
))}
</div>
)}
{phase === 1 && (
<div>
<p>The commit window has closed. Reveal your vote to have it counted.</p>
<button onClick={handleReveal}>Reveal My Vote</button>
</div>
)}
{phase === 2 && (
<div>
<h3>Final Results</h3>
<p>Yes: {yes}</p>
<p>No: {no}</p>
<p>Abstain: {abstain}</p>
</div>
)}
{status && <p style={{ marginTop: 16, color: '#666' }}>{status}</p>}
</div>
);
}
The component checks phase on mount and after each action. In production you'd poll or subscribe to contract state updates. The ZK proof generation happens inside api.commitVote() and api.revealVote() — the UI just triggers the call and waits.
Security model
Before deploying anything, know what you're getting.
Vote privacy during the commit phase holds because persistentCommit with a random blinder is computationally hiding. Voter identity during the reveal phase holds because persistentHash(voterSecret) doesn't identify anyone without the secret. Double-voting is prevented by nullifiers — one nullifier per voter, marked spent on first reveal. Tally integrity is enforced by ZK proof; the reveal circuit checks that the vote matches the stored commitment before incrementing anything.
What this doesn't do: receipt-freeness. A voter can prove how they voted after the fact by sharing their voterSecret and blinder. That's inherent to commit/reveal — there's no way around it in this design. Participation is also observable: totalCommits and totalReveals are public counters, so anyone can see turnout even if they can't see individual choices. Without domain separation (see above), a voter using the same secret across multiple proposals has linkable nullifier hashes.
None of these are bugs. They're trade-offs. Any scheme that needs to tally votes must eventually reveal them. The commit phase buys temporal privacy: nobody knows what anyone voted until the window opens. After that, votes are public by design.
Common pitfalls
1. persistentHash for vote commitments — not hiding
Three-option vote, three hashes. Any observer can crack a persistentHash commitment before the reveal phase opens. Use persistentCommit(vote, blinder) for anything that needs to stay hidden. Save persistentHash for nullifiers, where binding is all that matters.
2. checkRoot needs disclose() on the computed digest
checkRoot is a ledger method. Any value derived from a witness that passes into a ledger method requires disclose():
const path = getVoterPath();
const computed = merkleTreePathRoot<16, Bytes<32>>(path);
// Fails — computed is derived from the private witness path
voterTree.checkRoot(computed)
// Correct
voterTree.checkRoot(disclose(computed))
Compiler error: potential witness-value disclosure must be declared but is not — ledger operation might disclose a hash of the witness value
This applies to both MerkleTree and HistoricMerkleTree — both use checkRoot() in circuits. See also pitfall #9.
3. MerkleTree instead of HistoricMerkleTree for voter registration
Every new voter registration changes the root. Use a standard MerkleTree and any voter who generated their proof before the latest registration now has an invalid proof. HistoricMerkleTree validates against any past root, so concurrent registrations don't race with each other.
4. lookup() without a prior member() check panics
commitments.lookup(key) panics at proof generation if the key is missing. Always check first:
assert(commitments.member(disclose(nullifierHash)), "No commitment found");
const stored = commitments.lookup(disclose(nullifierHash));
5. Missing disclose() on exported parameters in ledger operations
Exported circuit parameters — including publicly supplied values like nullifierHash — need disclose() before any ledger write or method call:
// Fails
commitments.insert(nullifierHash, voteCommitment);
// Correct
commitments.insert(disclose(nullifierHash), disclose(voteCommitment));
6. sealed export ledger is a parse error
sealed must come directly before ledger — the export modifier between them is invalid:
sealed export ledger proposalId: Bytes<32>; // Parse error
sealed ledger proposalId: Bytes<32>; // Correct
7. pure circuits cannot access ledger fields
Even a read-only ledger access makes a circuit impure in Compact. This is stricter than Solidity's view:
export pure circuit getPhase(): Uint<8> { return phase; } // Compile error
export circuit getPhase(): Uint<8> { return phase; } // Correct
8. Uint<64> + Uint<64> can't be assigned back to Uint<64>
Compact's integer types carry range information. Adding two Uint<64> values produces a Uint<0..2^65> — wider than Uint<64> — and assigning that back to a Uint<64> field fails at compile time:
expected right-hand side of = to have type Uint<64>
but received Uint<0..36893488147419103231>
Do the deadline arithmetic off-chain in TypeScript and pass absolute block heights as constructor parameters. Don't compute startBlock + duration inside the contract.
9. checkRootInHistory doesn't exist — use checkRoot
It seems like it should exist. It doesn't. Both MerkleTree and HistoricMerkleTree use checkRoot() in Compact circuits:
operation checkRootInHistory undefined for ledger field type HistoricMerkleTree<16, Bytes<32>>
Use checkRoot(disclose(computed)) for both tree types. The distinction between the two is in the TypeScript client API — HistoricMerkleTree exposes past roots so off-chain proof generation can pick one that's still valid.
10. Branching on exported parameters without disclose()
Compact treats exported circuit parameters as potentially private until explicitly disclosed. If you branch on a parameter and execute different ledger operations per branch, the compiler flags it: which branch ran reveals the parameter's value.
// Fails — branching on `vote` discloses which increment runs
if (vote == 0) { noVotes.increment(1); }
else if (vote == 1) { yesVotes.increment(1); }
else { abstainVotes.increment(1); }
Exception: voting.compact line 158 char 16:
potential witness-value disclosure must be declared but is not:
the value of parameter vote of exported circuit revealVote
performing this ledger operation might disclose the boolean value
of the result of a comparison involving the witness value
via: the comparison at line 157, the conditional branch at line 157
The fix is disclose() on the parameter inside the condition, explicitly acknowledging that the branch is intentional:
// Correct — vote is intentionally public at reveal time
if (disclose(vote) == 0) { noVotes.increment(1); }
else if (disclose(vote) == 1) { yesVotes.increment(1); }
else { abstainVotes.increment(1); }
This applies to assert comparisons too: assert(disclose(vote) < 3, ...). The rule is consistent — any expression involving an exported parameter that drives different execution paths requires disclose().
11. Not separating nullifiers across proposals
persistentHash(voterSecret) is deterministic across every contract that uses it. Same voter, same secret, same nullifier hash in every proposal — and an observer can link them all. Mix the proposal ID into the nullifier derivation off-chain using the pattern described in the domain separation section above.
Compile and test
compact compile voting.compact managed/voting
npm install --save-dev vitest @midnight-ntwrk/compact-runtime
npx vitest run
The full contract is in the companion repository. A GitHub Actions workflow compiles it on every push and uploads the artifacts — the run linked below shows a clean compile with all 11 pitfalls already fixed:
https://github.com/IamHarrie-Labs/compact-voting-guide/actions/runs/25685662818
Resources
- Compact Language Reference — full ledger type spec, circuit constraints
-
Standard Library Exports —
persistentHash,persistentCommit,MerkleTreePath,HistoricMerkleTree - Bulletin Board Tutorial — canonical reference for witness patterns
- Compact Release Notes
All Compact examples in this article were compiled and verified against the latest Compact compiler via GitHub Actions. Source and CI run: IamHarrie-Labs/compact-voting-guide
Top comments (0)