DEV Community

Tosh
Tosh

Posted on

Commit/Reveal Voting on Midnight: Building a Private DAO Ballot

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:

  1. Voter eligibility tree — a Merkle tree of voter public keys
  2. Commitment registry — mapping of voter nullifier commitment to submitted vote commitment
  3. Nullifier set — which voters have already revealed (prevents double voting)
  4. Tally — per-option vote counts
  5. 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;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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");
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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],
};
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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)