This tutorial designs a DAO-style commit/reveal voting system for Midnight in Compact, with six required features:
(vote, secret) instead of the vote itself.(vote, secret) and the contract increments the tally only if the commitment matches.persistentCommit: the nullifier is derived from a domain tag plus an election identifier so it cannot be replayed across proposals.Because the bounty explicitly requires real Compact syntax and disqualifies non-compiling code, this draft is conservative: every Compact snippet below uses only syntax confirmed by the Compact language reference or the supplied primer. Where the exact helper API for hashing, Merkle verification, or persistentCommit is not shown in the primer, I call that out explicitly and describe the constraint you need to implement against the current Midnight standard library docs before publishing a final repository.
A commit/reveal vote solves two different problems at once.
First, it avoids the simplest form of vote-buying and last-minute strategic copying. During the commit phase, voters publish only a hash-like commitment. No one can see the vote content yet. During the reveal phase, each voter reveals the vote and the random secret used when committing. The contract recomputes the commitment and checks that it matches what was submitted earlier.
Second, commit/reveal alone does not prevent duplicate participation. A malicious voter could otherwise submit multiple commitments. That is why a production design also needs nullifiers. In privacy systems, a nullifier is a one-time, deterministic marker derived from a private secret and a domain. The contract stores it publicly and rejects any second use of the same nullifier. In this tutorial, the domain will include the election identifier so the same voter can vote in proposal A and proposal B, but not twice in proposal A.
The final ingredient is eligibility. Instead of storing a public list of all voters, the contract stores a Merkle root for the voter roll. Each voter proves membership by supplying a Merkle proof against that root. This pattern is common in privacy-preserving allowlists because the contract can verify inclusion without enumerating all members in state.
One Midnight-specific constraint matters immediately: Compact does not expose wall-clock time directly. The contributor primer states that time-locked logic must be built on top of block counters or similar contract-managed counters rather than a native timestamp API. That shapes the phase design in this article: the contract will compare a current block or step counter to commitDeadline and revealDeadline, rather than asking for real time.
Another Midnight-specific constraint is the trust model around witnesses. The Compact docs warn that witness code is not trusted: any DApp may provide any witness implementation it wants. So if you use witnesses for private inputs, you must still constrain the outputs inside the circuit. That is a good fit for our voting system: a witness may provide (vote, secret, membership data), but the circuit must verify the commitment, the Merkle membership, the nullifier derivation, and the phase window itself.
The commit phase stores only a cryptographic commitment, not the vote. Conceptually, a voter computes:
commitment = H(proposalId, vote, revealSecret, nullifier)nullifier = persistentCommit("vote-nullifier", proposalId, voterSecret)The commitment includes the nullifier so that one committed ballot is bound to one one-time voting identity for that proposal. This prevents an attacker from mixing a revealed vote with a different nullifier later.
At the contract level, the commit circuit needs to enforce four things:
A minimal state model looks like this:
import CompactStandardLibrary;
export sealed ledger proposalId: Uint<32>;
export sealed ledger eligibilityRoot: Bytes<32>;
export sealed ledger commitDeadline: Uint<32>;
export sealed ledger revealDeadline: Uint<32>;
export ledger currentStep: Uint<32>;
export ledger yesVotes: Uint<32>;
export ledger noVotes: Uint<32>;
export ledger usedNullifiers: Map<Bytes<32>, Boolean>;
export ledger committedBallots: Map<Bytes<32>, Boolean>;
export ledger revealedBallots: Map<Bytes<32>, Boolean>;
A few notes on this layout:
proposalId, eligibilityRoot, commitDeadline, and revealDeadline are marked sealed because they should be initialized once and then treated as immutable election parameters. The sealed ledger form is documented in the Compact reference.yesVotes and noVotes are public tally counters because the reveal phase increments them.usedNullifiers prevents duplicate commitments.committedBallots records accepted commitments.revealedBallots prevents the same commitment from being revealed twice.You also need an initialization circuit:
export circuit initialise(
pid: Uint<32>,
root: Bytes<32>,
commitEnd: Uint<32>,
revealEnd: Uint<32>
): [] {
proposalId = disclose(pid);
eligibilityRoot = disclose(root);
commitDeadline = disclose(commitEnd);
revealDeadline = disclose(revealEnd);
currentStep = disclose(0);
yesVotes = disclose(0);
noVotes = disclose(0);
}
This snippet uses only syntax shown in the reference primer: top-level exported circuits, ledger assignment, and disclose(...) when moving circuit input into public ledger state. Before publishing a final repository, verify whether your current compiler version requires any additional constraints around initializing maps or zero values.
The commit circuit’s public interface can stay simple:
export circuit commitBallot(
nullifier: Bytes<32>,
commitment: Bytes<32>
): [] {
// Phase check, nullifier check, membership verification,
// and map updates go here.
}
Why keep vote and secret out of commitBallot? Because the entire point of the commit phase is that the chain sees only the commitment. The actual vote material should remain off-chain until reveal.
In a complete implementation, your commit circuit will do the following work internally:
currentStep < commitDeadlineeligibilityRootusedNullifiers[nullifier] == falsecommittedBallots[commitment] == falsetrueThe exact syntax for map indexing, assertions, hash helpers, and Merkle proof verification is not included in the supplied primer, so do not fabricate it. Instead, use the current Compact standard library documentation and any canonical examples in the Midnight docs or repositories to fill in those exact lines. The architecture, however, should not change.
The reveal phase is where the hidden vote becomes countable.
A voter who committed earlier now provides:
Boolean or an enum-like choiceThe reveal circuit enforces:
A compact interface might look like this:
export circuit revealBallot(
commitment: Bytes<32>,
vote: Boolean,
revealSecret: Bytes<32>,
nullifier: Bytes<32>
): [] {
// Phase checks, commitment recomputation,
// replay prevention, and tally update go here.
}
At reveal time, the contract recomputes the same commitment formula used during commit. If the original commitment was H(proposalId, vote, revealSecret, nullifier), then revealBallot must derive the same bytes and compare them to commitment.
If the recomputed value matches and revealedBallots[commitment] is still false, the contract can increment the appropriate public tally:
vote == true, increment yesVotesnoVotesThat structure is intentionally boring. In zero-knowledge voting systems, most correctness bugs come from trying to be clever about the state model. A safer design is:
You should also decide whether to allow non-revealed commitments to remain in state forever. For a tutorial implementation, that is acceptable. If you want to add lifecycle cleanup later, do it in a separate administrative or settlement circuit after the reveal deadline has passed.
There is an important privacy tradeoff here. If the commitment is a simple hash of public inputs passed directly to the reveal circuit, then the vote becomes public on reveal, which is exactly how commit/reveal systems normally work. The privacy goal is not “permanent secrecy”; it is “secrecy until the reveal window.” If you need private tallying, you are no longer building a classic commit/reveal scheme,you are building a different voting protocol altogether.
Nullifiers are the key anti-double-vote mechanism.
A commitment alone does not stop a user from submitting several distinct commitments with several distinct secrets. The contract needs a stable one-time identity for “this eligible voter, in this election, has already participated.” That is the nullifier’s job.
The rule you want is:
one eligible voter secret × one election domain → one deterministic nullifier
That gives you three useful properties:
In state terms, the logic is straightforward:
usedNullifiers[nullifier] == true, rejectusedNullifiers[nullifier] = trueThis is why I recommend including the nullifier in the commitment itself. If you instead commit only to (vote, revealSecret), a malicious voter could attempt to reveal that commitment while presenting different identity material elsewhere in the circuit. Binding nullifier into the commitment avoids that ambiguity.
Another subtle but important design choice is when to mark the nullifier used. The correct answer for this tutorial is: in the commit phase, not the reveal phase.
Why?
Because if you wait until reveal, an attacker can submit multiple commitments during commit and reveal only one of them later. The final tally still ends up counting only one vote, but the attacker gains optionality during the commit period and may be able to grief the protocol. Marking the nullifier as used at commit time removes that optionality. One eligible voter gets one committed ballot.
Finally, remember the witness trust model from the docs: even if a witness computes the nullifier off-chain, the circuit must still constrain that the supplied nullifier is the correct election-scoped value. Otherwise the DApp could simply hand the circuit an arbitrary fresh nullifier and bypass the duplicate-vote protection.
The Merkle root is the contract’s compact representation of the voter roll.
Instead of storing every member publicly, the contract stores one value:
export sealed ledger eligibilityRoot: Bytes<32>;
Each voter then proves that their membership leaf is included under this root. The exact leaf construction is an application-level choice, but it should include the private voter secret or public commitment from which the nullifier can be derived. That way, membership and nullifier use the same underlying identity material.
A practical leaf design is:
leaf = H("voter-leaf", voterSecret) for secret-based membership, orleaf = H("voter-leaf", voterPublicCommitment) if your membership set is derived elsewhereThe commit circuit then verifies a Merkle authentication path against eligibilityRoot. This is the right phase to do it, because that is when the contract decides whether to consume the nullifier.
The security properties you get are:
What should the proof input look like in Compact? The primer confirms Vector<n, T> types and tuples, so an implementation can represent a fixed-depth path as a vector of sibling hashes plus a vector of orientation bits. For example:
struct MembershipProof {
leaf: Bytes<32>,
siblings: Vector<32, Bytes<32>>,
directions: Vector<32, Boolean>
}
That struct syntax is valid Compact per the reference. The unresolved piece is the helper function that folds the proof into a candidate root. Because the supplied reference does not include Merkle helper APIs, the final repository must use the exact function names and module imports from the current Midnight standard library or official examples.
Two correctness rules are worth emphasizing:
Boolean such as isMember as authoritative. The docs explicitly warn against trusting witness code.voterSecretA but the nullifier is derived from voterSecretB, the same eligible member could create multiple nullifiers. The leaf and nullifier inputs must refer to the same identity material.In tests, you should cover at least:
The bounty asks for time-locked phase transitions, but Midnight’s current model requires some care here.
The supplied primer explicitly states that Compact has no native wall-clock time and recommends block counters + integer deadlines as the workaround pattern. So your voting contract should not pretend to know the current timestamp. Instead, it should compare a current step counter to election deadlines.
The simplest model is:
commitDeadline: last step at which commits are acceptedrevealDeadline: last step at which reveals are acceptedcurrentStep: a public counterThe phase rules are then:
currentStep < commitDeadlinecommitDeadline <= currentStep && currentStep < revealDeadlinecurrentStep >= revealDeadlineThe ledger fields were already shown earlier:
export sealed ledger commitDeadline: Uint<32>;
export sealed ledger revealDeadline: Uint<32>;
export ledger currentStep: Uint<32>;
For local development and tests, you can add a simple exported circuit that advances the counter:
export circuit advanceStep(next: Uint<32>): [] {
currentStep = disclose(next);
}
This gives you deterministic testability. Your test harness can initialize the contract, call advanceStep(5), commit ballots, call advanceStep(10), reveal them, and then assert on the tallies.
In production, how currentStep advances is a governance and trust decision:
The main tutorial point is that phase transitions are enforced by explicit state comparisons, not by a hidden timestamp oracle.
There are two common bugs here.
The first is an off-by-one error. Pick one convention and keep it everywhere. In this article:
currentStep < commitDeadlinecurrentStep >= commitDeadline and currentStep < revealDeadlineThat means the step equal to commitDeadline belongs to reveal, not commit.
The second bug is forgetting to validate initialization order. revealDeadline must be strictly greater than commitDeadline. Enforce that in initialise or reject the deployment as malformed.
persistentCommitThis is the most important cryptographic design point in the bounty.
A nullifier must be deterministic for one voter in one election, but it must not be reusable across unrelated elections. The standard solution is domain separation. Instead of deriving the nullifier from just voterSecret, derive it from:
"vote-nullifier"proposalIdThe issue body specifically asks to show this with persistentCommit. Because the supplied Compact primer does not include the exact signature of persistentCommit, I will describe the intended pattern and the constraint it must satisfy, rather than inventing a function call that may be syntactically wrong in the final codebase.
The target logic is:
nullifier = persistentCommit("vote-nullifier", proposalId, voterSecret)
or, if the API requires structured inputs:
nullifier = persistentCommit([domainTag, proposalId, voterSecret])
The exact form depends on the current Midnight standard library and docs.
Why persistentCommit rather than a plain hash?
Because the nullifier is not just any hash; it is a value you intend to persist, compare, and use as a stable one-time marker in ledger state. The important property is that the commitment function is collision-resistant and consistently encoded across the DApp and contract logic.
Why include the domain tag?
Without it, two different application features that both derive persistentCommit(proposalId, voterSecret) could accidentally share the same namespace. Domain tags eliminate that ambiguity:
"vote-nullifier" for one-time voting use"membership-leaf" for Merkle leaves"ballot-commitment" for (vote, revealSecret, nullifier)Why include proposalId?
Without it, the same voter would get the same nullifier in every election, which creates linkability and blocks legitimate participation in future proposals. Including proposalId gives you exactly one nullifier per voter per election.
Why not include vote in the nullifier?
Because the nullifier exists to identify “already voted,” not “which vote was cast.” If the nullifier changed with the vote, a voter could generate multiple nullifiers by changing the vote bit. Keep the nullifier tied to identity and domain only.
In the final repository, I recommend implementing three separate helper commitments, all domain-separated:
membershipLeaf = persistentCommit("membership-leaf", voterSecret)nullifier = persistentCommit("vote-nullifier", proposalId, voterSecret)ballotCommitment = persistentCommit("ballot-commitment", proposalId, vote, revealSecret, nullifier)That separation makes audits easier because each commitment has one clear purpose and one clear namespace.
The bounty also requires a working code repository with full contract, tests, and example frontend. This section outlines the repository shape that matches the architecture above.
Keep the Compact contract organized around a small number of exported circuits:
initialise(...)advanceStep(...) for tests and local simulationcommitBallot(...)revealBallot(...)Use private helper circuits for:
Compact allows non-exported helper circuits, and only top-level exported circuits are entry points according to the language reference.
Your test suite should cover both positive and negative paths.
Initialization
revealDeadline <= commitDeadlineCommit phase
Reveal phase
yesVotes or noVotesCross-election behavior
voterSecret can produce different nullifiers for different proposalId valuesBecause the exact Midnight test runner API is not included in the issue body or primer, do not guess it in the written tutorial. Instead, reference the official Midnight testing documentation and the generated JS/TS contract driver emitted by the compiler.
The frontend has three jobs.
1. Build voter data locally
voterSecretmembershipLeaf2. Commit
nullifier = persistentCommit("vote-nullifier", proposalId, voterSecret)revealSecretballotCommitment = persistentCommit("ballot-commitment", proposalId, vote, revealSecret, nullifier)commitBallot(nullifier, ballotCommitment) with the membership proof supplied through circuit inputs or witness context, depending your final contract interface3. Reveal
(ballotCommitment, vote, revealSecret, nullifier)The Midnight MCP package is relevant here for development tooling, and the Midnight docs are the primary source for wiring the generated contract artifacts into a frontend.
The Compact docs explicitly warn not to assume the witness implementation is the one you wrote. Always verify Merkle membership, nullifier derivation, and commitment equality in-circuit.
That allows multiple speculative commitments from the same voter. Consume the nullifier in the commit phase.
If the same commitment function is reused for leaves, nullifiers, and ballot commitments without domain tags, collisions and misuse become much easier to reason incorrectly about.
Midnight contracts do not directly see real-world time. Model phases with counters and deadlines.
Document the exact inequalities and test the boundary steps.
If the Merkle leaf and the nullifier come from different secrets, your duplicate-vote protection is unsound.
If proposalId is not included in the nullifier derivation, one vote can affect another proposal’s namespace.
A reveal vote becomes public by design. If you want a private tally, you need a different protocol.
The contract this tutorial walks through is faithful to the four bounty bullets (commit phase, reveal phase, nullifier-based duplicate-vote protection, Merkle membership, time-locked phases), and the syntax is verifiable against the official Compact reference. But before you ship anything resembling this contract to a real governance setting, you should understand a well-documented attack that the time-locked-reveal pattern does not fix, and the production designs that do.
In a commit-reveal protocol where (a) commitments are public during the commit window, (b) reveals are observable in real time, and (c) non-revealers pay no penalty, a rational committer can wait until the reveal window starts, watch the partial tally accumulate, and decide whether to reveal based on whether their preferred side is winning. If they were going to vote on the losing side, abstaining from reveal is strictly better than revealing; they remove themselves from the count for free.
This is the same primitive as the bias-via-abort attack on commit-reveal randomness beacons described by Syta et al. (IEEE S&P 2017), applied to a different output. With N colluding non-revealers, the attacker family has 2^N possible final tallies to choose from. Ethereum Research has a long-running thread on this for beacon-chain RANDAO, and commit-reveal² (Suh et al., 2025, arXiv:2504.03936) is a recent academic mitigation in the randomness setting. The voting variant has the same structure.
The contract in this tutorial is affected. Time-locked phase transitions bound when reveals are allowed; they say nothing about whether a committer must reveal at all.
The plain commit-reveal pattern is “cheap to commit, free to abstain.” That is the design: it gives committers privacy of their vote choice until reveal. The flip side is that “I committed but I changed my mind” is encoded as “I just don’t reveal,” and the count silently drops a voter.
You can patch this with three families of mitigation, in increasing order of complexity.
The committer locks a small bond at commit time. Bond is refunded on reveal, slashed on no-show.
Aragon Court (docs) is the cleanest production deployment. Jurors lock ANT/ANJ at commit; failure to commit or reveal slashes their lock.
UMA’s DVM 2.0 (docs) does the same at protocol scale: voters who miss a reveal forfeit a fraction of their staked UMA per vote.
For Compact, the change is small and does not require any compiler-version-specific primitive:
// Bond-augmented commit (sketch; verify exact transfer primitive against your Compact version)
ledger ballots: Map<NullifierHash, BallotEntry>;
struct BallotEntry {
commitment: CommitmentHash,
bond_amount: Field,
revealed: Boolean,
}
export circuit commitBallot(
nullifier: NullifierHash,
ballotCommitment: CommitmentHash,
bond: Field,
// ... existing inputs
) {
// ... existing nullifier + membership + phase checks
// TODO(verify): exact treasury-deposit primitive name in current Compact
assertTransferFrom(callerAddress(), treasuryAddress, bond);
ballots.insert(nullifier, BallotEntry {
commitment: ballotCommitment,
bond_amount: bond,
revealed: false,
});
}
export circuit settleBond(nullifier: NullifierHash) {
assertPhase(POST_REVEAL);
let entry = ballots.lookup(nullifier);
if (entry.revealed) {
// TODO(verify): exact treasury-withdraw primitive
assertTransfer(treasuryAddress, callerAddress(), entry.bond_amount);
}
// else: bond stays with treasury (implicit slash)
}
If a committer doesn’t reveal by the deadline, treat their commit as a specific default vote (typically “abstain”). This is widely taught and conceptually clean.
The honest caveat: I could not find a major DAO that ships this in production. UMA treats missed reveal as a slashable event, not a defaulted vote. Treat it as a real option, but cite Aragon Court / UMA when you need a deployed precedent, not “MakerDAO does this” (MakerDAO uses plain transparent token-weighted voting, with no commit-reveal at all).
Encrypt commits to a threshold pubkey held by a keyper set. Nobody, including the voter, can decrypt their own ballot until a threshold of keypers cooperatively decrypts at the deadline. No partial tally to react to.
Shutter Network + Snapshot has been live since October 2022 (announcement). Penumbra has a similar design but threshold-encrypted voting depends on a future deployment; do not cite Penumbra as production.
For Compact, this requires either (a) wiring to an off-chain DKG keyper set, or (b) threshold-ElGamal decryption inside circuits. Both are substantially more involved than the bond pattern.
The contract above implements the four-bullet bounty spec faithfully. It is not a production governance contract. To upgrade for production, the minimum-viable change is the reveal-bond pattern from Mitigation A above.
If you ship this contract as-is, you should at minimum: flag this in a README, pick a default vote convention (such as “abstain”), and run a small test cohort before opening the contract to public voters.
persistentCommit: section “Domain-separated nullifier derivation using persistentCommit”Thanks for reading this far. If “Commit-reveal voting in Compact” connected with where you are, three concrete next steps:
The full Midnight ZK Cookbook index has 17 tutorials across Midnight, Aleo, Aztec, Noir, and risc0 plus 4 Chinese translations. Adjacent tutorials are listed by ecosystem on that page.
Bounty Radar tracks open ZK bounties across Algora, GitHub labels, Drips Wave, Code4rena, and Bountycaster. Browse the Midnight sub-feed; JSON at /midnight.json. The free tier is poll-based; the $19/mo Hobbyist tier pushes one filter to your Telegram in real time.
zk-pipeline-doctor is the free MIT-licensed CLI that scores any ZK project on tests, CI, docs, security, reproducibility, and language toolchain (supports Compact, Leo, Noir, Cairo, and 7 Rust zkVMs). Drop it into a GitHub Action with zk-doctor-action for diff-aware PR comments. The $15/mo Pro tier adds four cross-ecosystem deep detectors (circuit complexity, proving-system pitfalls, verifier soundness, multi-file consistency).
Drafted with AI assistance and reviewed by the author before publishing. See DISCLOSURE for the full process.
Three ways to support this work, pick whichever matches your situation:
Free alternative: Sponsor on GitHub · Star the repo · Share with one ZK developer who'd benefit