Building a Commit/Reveal Voting System in Compact

TL;DR

This tutorial designs a DAO-style commit/reveal voting system for Midnight in Compact, with six required features:

  1. Commit phase: voters submit a commitment to (vote, secret) instead of the vote itself.
  2. Reveal phase: voters later reveal (vote, secret) and the contract increments the tally only if the commitment matches.
  3. Nullifiers: each eligible voter derives a unique election-scoped nullifier so they cannot commit twice.
  4. Merkle tree eligibility: the contract stores a Merkle root for the voter roll and verifies membership against that root.
  5. Time-locked phase transitions: the contract accepts commits only before the commit deadline and reveals only during the reveal window.
  6. Domain-separated nullifiers using 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.

Context

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.

Commit phase: voters submit hash of vote + secret

The commit phase stores only a cryptographic commitment, not the vote. Conceptually, a voter computes:

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:

  1. The current step is still before the commit deadline.
  2. The submitted nullifier has not been used before.
  3. The voter is eligible under the Merkle root.
  4. The submitted commitment is recorded exactly once.

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:

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:

The 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.

Reveal phase: voters reveal and increment tally

The reveal phase is where the hidden vote becomes countable.

A voter who committed earlier now provides:

The reveal circuit enforces:

  1. The commit phase has ended.
  2. The reveal phase is still open.
  3. The commitment being revealed actually exists.
  4. That commitment has not already been revealed.
  5. The recomputed commitment matches the stored one.
  6. The tally increments exactly once.

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:

That 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 preventing double voting

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:

  1. Determinism: the same voter cannot produce a second nullifier for the same proposal.
  2. Uniqueness per election: the contract can mark that nullifier as used.
  3. Cross-election unlinkability: by domain-separating with the proposal identifier, the same voter gets a different nullifier in different elections.

In state terms, the logic is straightforward:

This 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.

Merkle tree for voter eligibility

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:

The 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:

  1. Verify membership in-circuit. Do not treat a witness-produced Boolean such as isMember as authoritative. The docs explicitly warn against trusting witness code.
  2. Bind the nullifier identity to the Merkle leaf identity. If the leaf is built from 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:

Time-locked phase transitions

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:

The phase rules are then:

The 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:

That 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.

Domain-separated nullifier derivation using persistentCommit

This 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:

The 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:

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:

  1. membershipLeaf = persistentCommit("membership-leaf", voterSecret)
  2. nullifier = persistentCommit("vote-nullifier", proposalId, voterSecret)
  3. ballotCommitment = persistentCommit("ballot-commitment", proposalId, vote, revealSecret, nullifier)

That separation makes audits easier because each commitment has one clear purpose and one clear namespace.

Working example: contract structure, tests, and frontend flow

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.

Contract structure

Keep the Compact contract organized around a small number of exported circuits:

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.

Test plan

Your test suite should cover both positive and negative paths.

Initialization

Commit phase

Reveal phase

Cross-election behavior

Because 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.

Example frontend flow

The frontend has three jobs.

1. Build voter data locally

2. Commit

3. Reveal

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.

Pitfalls and common errors

1. Trusting witness output directly

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.

2. Marking nullifiers on reveal instead of commit

That allows multiple speculative commitments from the same voter. Consume the nullifier in the commit phase.

3. Failing to domain-separate commitments

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.

4. Using wall-clock language in the contract

Midnight contracts do not directly see real-world time. Model phases with counters and deadlines.

5. Off-by-one phase windows

Document the exact inequalities and test the boundary steps.

6. Not binding identity, leaf, and nullifier together

If the Merkle leaf and the nullifier come from different secrets, your duplicate-vote protection is unsound.

7. Forgetting replay resistance across proposals

If proposalId is not included in the nullifier derivation, one vote can affect another proposal’s namespace.

8. Treating commit/reveal as permanent secrecy

A reveal vote becomes public by design. If you want a private tally, you need a different protocol.

Production limits: the commit-reveal-and-walk-away problem

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.

The attack: bias-via-abort applied to voting

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.

Why this is structural, not a bug

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.

Mitigation A: Reveal bonds (the production-proven path)

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

Mitigation B: Default-to-commitment fallback (theoretically clean, weak production support)

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).

Mitigation C: Threshold encryption (the strongest, most complex)

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.

What this tutorial actually ships, and what to do next

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.

References

Bounty spec coverage


Where to go next

Thanks for reading this far. If “Commit-reveal voting in Compact” connected with where you are, three concrete next steps:

Learn more in Midnight

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.

Find paid work in Midnight

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.

Audit your own ZK pipeline

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.

If this saved you time

Three ways to support this work, pick whichever matches your situation:

$15 · One-time
All 17 tutorials as one Markdown + companion code repos. Offline-readable.
Get the Bundle →
$19 / month
Real-time bounty alerts pushed to your Telegram, filtered by ecosystem.
Try Radar →
$99 · One-time
Pre-flight audit of your ZK repo. See sample.
Order Audit →

Free alternative: Sponsor on GitHub · Star the repo · Share with one ZK developer who'd benefit

Related projects