DEV Community

Cover image for Build a privacy-preserving voting DApp on Midnight
Oladeji Tosin
Oladeji Tosin

Posted on

Build a privacy-preserving voting DApp on Midnight

Introduction

On most blockchains, every vote is permanently public. Anyone can scan the ledger and learn exactly how each address voted, which creates coercion risks, voting bloc pressure, and a chilling effect on genuine participation. Privacy-preserving voting solves this by separating the act of casting a ballot from the act of counting it, so the final tally is verifiable while each individual choice stays hidden.

Midnight is a Layer 1 blockchain built specifically for data protection. Its smart contract language, Compact, compiles to zero-knowledge (ZK) circuits, which means the network can verify that a voter followed the rules without learning what they voted. This tutorial uses a commit-reveal scheme: in the commit phase, voters submit a cryptographic hash of their ballot; in the reveal phase, a ZK proof demonstrates the revealed choice matches the earlier hash, and the counter increments accordingly. Neither phase exposes the plaintext ballot.

By the end of this tutorial you will have a complete, working DApp consisting of a Compact smart contract, a TypeScript API layer, a vitest test suite that runs without a live network, and a React frontend that connects to the Midnight preview Testnet through the 1AM wallet browser extension.


Prerequisites

Before you begin, install and verify the following tools:

  • Node.js v22+
  • Google Chrome with the 1AM wallet extension installed (enables testnet transactions with sponsored fees)
  • VS Code (recommended)

Install the Compact toolchain:

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh
source ~/.zshrc   # or source ~/.bashrc for bash users
compact update
compact --version
compact compile --version
Enter fullscreen mode Exit fullscreen mode

The 1AM wallet provides a remote prover, so no local proof server is needed. When you connect the wallet, the DApp reads the prover address from the wallet configuration and routes all proof requests there automatically.


Step 1: Scaffold the monorepo

Create the root directory and the four workspace packages.

mkdir voting_compact && cd voting_compact
mkdir -p contract/src api/src test frontend/src
Enter fullscreen mode Exit fullscreen mode

The monorepo has four packages: contract (Compact source and compiler output), api (TypeScript wrapper around the compiled contract), test (vitest suite), and frontend (React + Vite UI). Keeping them as separate npm workspaces lets each package declare its own dependencies and build independently while sharing the root node_modules.


Step 2: Create the root package.json

Create the workspace root that ties all four packages together.

{
  "name": "voting-compact",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22.0.0"
  },
  "workspaces": [
    "contract",
    "api",
    "test",
    "frontend"
  ],
  "scripts": {
    "compact": "npm run compact --workspace=contract",
    "build": "npm run build --workspace=contract && npm run build --workspace=api",
    "test": "npm run test --workspace=test",
    "dev": "npm run dev --workspace=frontend"
  },
  "dependencies": {
    "@midnight-ntwrk/compact-js": "^2.5.0",
    "@midnight-ntwrk/compact-runtime": "^0.16.0",
    "@midnight-ntwrk/dapp-connector-api": "^4.0.0",
    "@midnight-ntwrk/midnight-js-contracts": "^4.0.0",
    "@midnight-ntwrk/midnight-js-http-client-proof-provider": "^4.0.0",
    "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "^4.0.0",
    "@midnight-ntwrk/midnight-js-level-private-state-provider": "^4.0.0",
    "@midnight-ntwrk/midnight-js-network-id": "^4.0.0",
    "@midnight-ntwrk/midnight-js-types": "^4.0.0",
    "@midnight-ntwrk/wallet-sdk-address-format": "^3.1.1",
    "pino": "^10.3.1",
    "rxjs": "^7.8.2"
  },
  "devDependencies": {
    "typescript": "^5.9.3",
    "vitest": "^4.0.18"
  },
  "overrides": {
    "@midnight-ntwrk/compact-js": {
      "@midnight-ntwrk/compact-runtime": "0.16.0"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The overrides block forces compact-js to use the same compact-runtime version as the rest of the project. Without it, compact-js pins its own nested copy of compact-runtime@0.15.0, which causes a version mismatch error at runtime because checkRuntimeVersion() is called at module load time and finds two conflicting copies. The build script compiles contract before api because api imports from @voting-compact/contract, which only exists in contract/dist/ after compilation.


Step 3: Create the root tsconfig.json

Create the shared TypeScript configuration at the workspace root.

{
  "compilerOptions": {
    "target":           "ES2022",
    "module":           "NodeNext",
    "moduleResolution": "NodeNext",
    "lib":              ["ES2022", "DOM"],
    "strict":           true,
    "skipLibCheck":     true,
    "declaration":      true,
    "declarationMap":   true,
    "sourceMap":        true,
    "esModuleInterop":  true,
    "resolveJsonModule": true,
    "outDir":           "./dist"
  },
  "exclude": ["node_modules", "dist"]
}
Enter fullscreen mode Exit fullscreen mode

NodeNext module resolution is required because the Midnight SDK packages are published as native ES modules with explicit .js extensions on their imports. Using any other module resolution strategy causes TypeScript to fail to locate the generated contract bindings.


Step 4: Write the Compact smart contract

Create contract/src/voting.compact. This is the entire contract from line 1 to the last line.

pragma language_version >= 0.22;

import CompactStandardLibrary;

// ─── State Enums ───────────────────────────────────────────────────────────────

export enum LedgerState { setup, commit, reveal, final }
export enum LocalState  { initial, committed, revealed }

// ─── Ledger Fields ─────────────────────────────────────────────────────────────

export ledger organizer:      Bytes<32>;
export ledger state:          LedgerState;
export ledger topic:          Maybe<Opaque<"string">>;
export ledger round:          Counter;
export ledger commitDeadline: Uint<64>;
export ledger revealDeadline: Uint<64>;

// Voter eligibility: HistoricMerkleTree keeps all past roots valid as new
// voters are added after some participants have already committed.
export ledger eligibleVoters: HistoricMerkleTree<10, Bytes<32>>;

// Committed vote commitments (used to verify reveal proofs).
export ledger committedVotes: HistoricMerkleTree<10, Bytes<32>>;


// Nullifier sets — one per phase, different domain separators prevent collision.
export ledger committed: Set<Bytes<32>>;
export ledger revealed:  Set<Bytes<32>>;

export ledger yes: Counter;
export ledger no:  Counter;

// ─── Witnesses ─────────────────────────────────────────────────────────────────

witness localSecretKey():                                      Bytes<32>;
witness localState():                                          LocalState;
witness localAdvanceState():                                   [];
witness localRecordVote(vote: Boolean):                        [];
witness localVoteCast():                                       Maybe<Boolean>;
witness localPathOfPk(pk: Bytes<32>): Maybe<MerkleTreePath<10, Bytes<32>>>;
witness localPathOfCm(cm: Bytes<32>): Maybe<MerkleTreePath<10, Bytes<32>>>;

// ─── Domain-Separated Commitment Struct ────────────────────────────────────────

struct RoundPreimage {
  round:     Uint<64>;
  domainSep: Bytes<8>;
}

// ─── Helper Circuits ───────────────────────────────────────────────────────────

// Derive voter public key from secret key.
circuit derivePk(sk: Bytes<32>): Bytes<32> {
  // "vote:udao:pk" = 12 characters → Bytes<12>
  return persistentCommit<Bytes<12>>("vote:udao:pk", disclose(sk));
}

// Commit-phase nullifier — domain "vote:cn".
// Prevents a voter from committing twice in the same round.
circuit commitNullifier(sk: Bytes<32>): Bytes<32> {
  return persistentCommit<RoundPreimage>(
    RoundPreimage { round: round, domainSep: pad(8, "vote:cn") },
    disclose(sk));
}

// Reveal-phase nullifier — domain "vote:rn".
// Prevents a voter from revealing twice in the same round.
circuit revealNullifier(sk: Bytes<32>): Bytes<32> {
  return persistentCommit<RoundPreimage>(
    RoundPreimage { round: round, domainSep: pad(8, "vote:rn") },
    disclose(sk));
}

// Vote commitment — domain is the ballot bytes ("yes" / "no").
// Links a commit to a later reveal without revealing the ballot early.
circuit commitWithSk(ballot: Bytes<8>, sk: Bytes<32>): Bytes<32> {
  return persistentCommit<RoundPreimage>(
    RoundPreimage { round: round, domainSep: ballot },
    disclose(sk));
}

// ─── Constructor ───────────────────────────────────────────────────────────────

constructor() {
  const sk = localSecretKey();
  organizer = disclose(derivePk(sk));
  state     = LedgerState.setup;
}

// ─── Organizer Circuits ────────────────────────────────────────────────────────

// Set the proposal topic and phase deadlines, moving state: setup → commit.
export circuit initialize(
  newTopic:         Opaque<"string">,
  newCommitDeadline: Uint<64>,
  newRevealDeadline: Uint<64>
): [] {
  const sk  = localSecretKey();
  const apk = derivePk(sk);
  assert(apk == organizer,            "Not organizer");
  assert(state == LedgerState.setup,  "Not in setup phase");
  topic          = some<Opaque<"string">>(disclose(newTopic));
  commitDeadline = disclose(newCommitDeadline);
  revealDeadline = disclose(newRevealDeadline);
  state          = LedgerState.commit;
}

// Register a voter by inserting their public key into the eligibility tree.
export circuit addVoter(voterPk: Bytes<32>): [] {
  const sk  = localSecretKey();
  const apk = derivePk(sk);
  assert(apk == organizer,            "Not organizer");
  assert(state == LedgerState.commit, "Not in commit phase");
  eligibleVoters.insert(disclose(voterPk));
}

// ─── Voter Circuits ────────────────────────────────────────────────────────────

// Commit phase: voter submits hash(vote || secret).
// Proves Merkle eligibility without revealing the ballot.
export circuit voteCommit(ballot: Boolean): [] {
  assert(state == LedgerState.commit && localState() == LocalState.initial,
    "Not in commit phase or already committed");
  const sk     = localSecretKey();
  const comNul = commitNullifier(sk);
  assert(!committed.member(comNul), "Already committed this round");

  const pk   = derivePk(sk);
  const path = localPathOfPk(pk);
  assert(
    disclose(path.is_some) &&
    eligibleVoters.checkRoot(disclose(merkleTreePathRoot<10, Bytes<32>>(path.value))) &&
    pk == path.value.leaf,
    "Not an eligible voter");

  localRecordVote(ballot);
  const cm = commitWithSk(ballot ? pad(8, "yes") : pad(8, "no"), sk);
  committedVotes.insert(disclose(cm));
  committed.insert(comNul);
  localAdvanceState();
}

// Reveal phase: voter proves their earlier commitment and increments the tally.
export circuit voteReveal(): [] {
  assert(state == LedgerState.reveal && localState() == LocalState.committed,
    "Not in reveal phase or not committed");
  const sk     = localSecretKey();
  const revNul = revealNullifier(sk);
  assert(!revealed.member(revNul), "Already revealed this round");

  const vote = localVoteCast();
  assert(vote.is_some, "No vote to reveal");

  const cm   = commitWithSk(vote.value ? pad(8, "yes") : pad(8, "no"), sk);
  const path = localPathOfCm(cm);
  assert(
    disclose(path.is_some) &&
    committedVotes.checkRoot(disclose(merkleTreePathRoot<10, Bytes<32>>(path.value))) &&
    cm == path.value.leaf,
    "Invalid commitment proof");

  if (disclose(vote.value)) {
    yes.increment(1);
  } else {
    no.increment(1);
  }
  revealed.insert(revNul);
  localAdvanceState();
}

// ─── Time-Locked Phase Transitions ────────────────────────────────────────────

// Anyone can advance commit → reveal once the commit deadline has passed.
export circuit advanceToReveal(): [] {
  assert(state == LedgerState.commit,       "Not in commit phase");
  assert(blockTimeGte(commitDeadline),      "Commit phase not over yet");
  state = LedgerState.reveal;
}

// Anyone can advance reveal → final once the reveal deadline has passed.
export circuit advanceToFinal(): [] {
  assert(state == LedgerState.reveal,       "Not in reveal phase");
  assert(blockTimeGte(revealDeadline),      "Reveal phase not over yet");
  state = LedgerState.final;
}

// ─── Result ───────────────────────────────────────────────────────────────────

export struct Tally {
  yes: Uint<64>;
  no:  Uint<64>;
}

// Read final tally (only callable once voting is complete).
export circuit getTally(): Tally {
  assert(state == LedgerState.final, "Voting not finalized");
  return Tally { yes: yes.read(), no: no.read() };
}
Enter fullscreen mode Exit fullscreen mode

The contract uses HistoricMerkleTree for eligibleVoters rather than a plain MerkleTree so that all past Merkle roots stay valid even as new voters are added later. Without this, a voter who committed before the organizer added additional voters would find their eligibility proof invalid during reveal. The two nullifier circuits (commitNullifier, revealNullifier) and the commitment circuit (commitWithSk) each use a distinct domainSep so their outputs can never collide even though they share the same persistentCommit function signature. blockTimeGte compares against Unix seconds, not milliseconds, which is why deadlines stored on the ledger must be in seconds.


Step 5: Set up the contract package

Create contract/package.json:

{
  "name": "@voting-compact/contract",
  "version": "0.1.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types":   "./dist/index.d.ts",
      "import":  "./dist/index.js",
      "default": "./dist/index.js"
    }
  },
  "scripts": {
    "compact":   "compact compile src/voting.compact src/managed/voting",
    "build":     "tsc --project tsconfig.build.json && rm -rf dist/managed && cp -Rf src/managed dist/managed",
    "typecheck": "tsc -p tsconfig.json --noEmit"
  },
  "dependencies": {
    "@midnight-ntwrk/compact-js":      "^2.5.0",
    "@midnight-ntwrk/compact-runtime": "^0.16.0"
  },
  "devDependencies": {
    "typescript": "^5.9.3"
  }
}
Enter fullscreen mode Exit fullscreen mode

Create contract/tsconfig.json:

{
  "compilerOptions": {
    "target":           "ES2022",
    "module":           "NodeNext",
    "moduleResolution": "NodeNext",
    "lib":              ["ES2022"],
    "strict":           true,
    "skipLibCheck":     true,
    "declaration":      true,
    "declarationMap":   true,
    "sourceMap":        true,
    "esModuleInterop":  true,
    "resolveJsonModule": true,
    "outDir":           "./dist",
    "rootDir":          "./src"
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist", "src/managed"]
}
Enter fullscreen mode Exit fullscreen mode

Create contract/tsconfig.build.json with the same contents as contract/tsconfig.json. The rm -rf dist/managed before the copy prevents macOS cp -Rf from nesting the directory when the destination already exists.


Step 6: Compile the contract

Run the Compact compiler to generate ZK circuits, proving keys, and TypeScript bindings.

npm run compact
Enter fullscreen mode Exit fullscreen mode

This command runs compact compile src/voting.compact src/managed/voting and produces the following output:

  • contract/src/managed/voting/contract/ — TypeScript bindings and the JavaScript implementation of the compiled contract
  • contract/src/managed/voting/keys/ — proving and verifying keys for each ZK circuit
  • contract/src/managed/voting/zkir/ — ZK intermediate representation, the bridge between Compact and the ZK backend
  • contract/src/managed/voting/compiler/ — metadata including runtime-version and compiler-version fields

The generated contract/index.js calls checkRuntimeVersion() at module load time. It verifies that the installed compact-runtime npm package matches the version the compiler targeted. A mismatch causes a CompactError before React mounts, which produces a blank browser screen with no visible error in the UI.


Step 7: Write the witness implementations

Create contract/src/witnesses.ts. Witnesses are TypeScript functions that the ZK prover calls to supply private inputs to circuits at proof-generation time.

import type { WitnessContext } from '@midnight-ntwrk/compact-runtime';
import { type Ledger, type Witnesses, LocalState } from './managed/voting/contract/index.js';

// ─── Private State ────────────────────────────────────────────────────────────

export type VotingPrivateState = {
  secretKey:   Uint8Array;
  voterState:  LocalState;
  vote:        boolean | null;
};

export const createVotingPrivateState = (secretKey: Uint8Array): VotingPrivateState => ({
  secretKey,
  voterState: LocalState.initial,
  vote:       null,
});

export const votingPrivateStateKey = 'votingPrivateState';

// ─── Witness Implementations ──────────────────────────────────────────────────

export const witnesses: Witnesses<VotingPrivateState> = {

  // Return the voter's 32-byte secret key.
  localSecretKey: ({ privateState }: WitnessContext<Ledger, VotingPrivateState>) =>
    [privateState, privateState.secretKey],

  // Return the voter's current local phase (initial / committed / revealed).
  localState: ({ privateState }: WitnessContext<Ledger, VotingPrivateState>) =>
    [privateState, privateState.voterState],

  // Advance local phase: initial → committed → revealed.
  localAdvanceState: ({ privateState }: WitnessContext<Ledger, VotingPrivateState>) => {
    const next = privateState.voterState === LocalState.initial
      ? LocalState.committed
      : LocalState.revealed;
    return [{ ...privateState, voterState: next }, []];
  },

  // Store the ballot choice in private state (called during voteCommit).
  localRecordVote: (
    { privateState }: WitnessContext<Ledger, VotingPrivateState>,
    vote_0: boolean,
  ) => [{ ...privateState, vote: vote_0 }, []],

  // Return the stored ballot as Maybe<Boolean>.
  localVoteCast: ({ privateState }: WitnessContext<Ledger, VotingPrivateState>) => [
    privateState,
    privateState.vote !== null
      ? { is_some: true,  value: privateState.vote }
      : { is_some: false, value: false },
  ],

  // Look up a Merkle path for the voter's public key in the eligibleVoters tree.
  // The ledger is passed via WitnessContext so the circuit gets a live, provable
  // path that reflects the current on-chain tree state.
  localPathOfPk: (
    { privateState, ledger }: WitnessContext<Ledger, VotingPrivateState>,
    pk_0: Uint8Array,
  ) => {
    const path = ledger.eligibleVoters.findPathForLeaf(pk_0);
    return [
      privateState,
      path !== undefined
        ? { is_some: true,  value: path }
        : { is_some: false, value: { leaf: pk_0, path: [] as any[] } },
    ];
  },

  // Look up a Merkle path for a committed vote hash in the committedVotes tree.
  localPathOfCm: (
    { privateState, ledger }: WitnessContext<Ledger, VotingPrivateState>,
    cm_0: Uint8Array,
  ) => {
    const path = ledger.committedVotes.findPathForLeaf(cm_0);
    return [
      privateState,
      path !== undefined
        ? { is_some: true,  value: path }
        : { is_some: false, value: { leaf: cm_0, path: [] as any[] } },
    ];
  },
};
Enter fullscreen mode Exit fullscreen mode

Each witness function receives a WitnessContext containing the current privateState and ledger, and returns a tuple of [updatedPrivateState, circuitReturnValue]. The localPathOfPk and localPathOfCm witnesses call findPathForLeaf on the live ledger tree so the Merkle path reflects the tree's current state at proof-generation time. When no path exists (the voter is ineligible), the witness returns a dummy path that causes the circuit's assert to fail, which is the intended behavior.


Step 8: Create the contract package entry point

Create contract/src/index.ts to wire together the compiled bindings and witness implementations.

import { CompiledContract } from '@midnight-ntwrk/compact-js';

export * from './managed/voting/contract/index.js';
export * from './witnesses.js';

import * as CompiledVotingContract from './managed/voting/contract/index.js';
import * as Witnesses from './witnesses.js';

// The compiled contract object wires together the generated ZK circuits,
// the witness implementations, and the compiled asset paths.
// This is what gets passed to deployContract() or findDeployedContract().
export const VotingCompiledContract = CompiledContract.make<
  CompiledVotingContract.Contract<Witnesses.VotingPrivateState>
>('Voting', CompiledVotingContract.Contract<Witnesses.VotingPrivateState>).pipe(
  CompiledContract.withWitnesses(Witnesses.witnesses),
  CompiledContract.withCompiledFileAssets('./managed/voting'),
);
Enter fullscreen mode Exit fullscreen mode

CompiledContract.withCompiledFileAssets('./managed/voting') tells the SDK where to find the keys/ and zkir/ directories at runtime. In the browser, the Vite config serves these files from {origin}/keys/ and {origin}/zkir/, so FetchZkConfigProvider can retrieve them over HTTP during proof generation.


Step 9: Build the contract

install npm

npm i
Enter fullscreen mode Exit fullscreen mode

Compile the TypeScript and copy the managed assets into dist/. Run this from the monorepo root (the directory containing the root package.json).

npm run build --workspace=contract
Enter fullscreen mode Exit fullscreen mode

After this command succeeds, contract/dist/ contains the JavaScript bindings and a copy of the managed/ directory. The api package imports from @voting-compact/contract, which npm resolves to this dist/ folder via the exports field in contract/package.json.


Step 10: Create the API package

Create api/package.json:

{
  "name": "@voting-compact/api",
  "version": "0.1.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc --project tsconfig.build.json",
    "typecheck": "tsc -p tsconfig.json --noEmit"
  },
  "dependencies": {
    "@voting-compact/contract": "*",
    "@midnight-ntwrk/compact-js": "^2.5.0",
    "@midnight-ntwrk/compact-runtime": "^0.16.0",
    "@midnight-ntwrk/midnight-js-contracts": "^4.0.0",
    "@midnight-ntwrk/midnight-js-types": "^4.0.0",
    "@midnight-ntwrk/midnight-js-network-id": "^4.0.0",
    "pino": "^10.3.1",
    "rxjs": "^7.8.2"
  },
  "devDependencies": {
    "typescript": "^5.9.3"
  }
}
Enter fullscreen mode Exit fullscreen mode

Create api/tsconfig.json:

{
  "compilerOptions": {
    "target":           "ES2022",
    "module":           "NodeNext",
    "moduleResolution": "NodeNext",
    "lib":              ["ES2022", "DOM"],
    "strict":           true,
    "skipLibCheck":     true,
    "declaration":      true,
    "declarationMap":   true,
    "sourceMap":        true,
    "esModuleInterop":  true,
    "resolveJsonModule": true,
    "outDir":           "./dist"
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}
Enter fullscreen mode Exit fullscreen mode

Create api/tsconfig.build.json with the same contents as api/tsconfig.json.


Step 11: Define the shared types

Create api/src/types.ts.

import type { ContractAddress } from '@midnight-ntwrk/compact-runtime';
import type { ContractProviders, DeployedContract } from '@midnight-ntwrk/midnight-js-contracts';
import type { Contract, Witnesses, LedgerState } from '@voting-compact/contract';
import type { VotingPrivateState } from '@voting-compact/contract';

// ─── Provider bundle used throughout the API ──────────────────────────────────

export type VotingContract = Contract<VotingPrivateState, Witnesses<VotingPrivateState>>;

export type VotingProviders = ContractProviders<VotingContract>;

// ─── Observable state derived from ledger + private state ─────────────────────

export type VotingDerivedState = {
  contractAddress: ContractAddress;
  phase:           LedgerState;
  topic:           string | null;
  commitDeadline:  bigint;
  revealDeadline:  bigint;
  yesCount:        bigint;
  noCount:         bigint;
  round:           bigint;
  isOrganizer:     boolean;
  voterStatus:     'ineligible' | 'eligible' | 'committed' | 'revealed';
  myVoterPkHex:    string;
};

export type DeployedVotingContract = DeployedContract<VotingContract>;
Enter fullscreen mode Exit fullscreen mode

VotingProviders is a type alias for ContractProviders<VotingContract>, which the SDK defines as a bundle of six providers: privateStateProvider, publicDataProvider, zkConfigProvider, proofProvider, walletProvider, and midnightProvider. Passing this bundle as a single argument to deployContract and findDeployedContract is the standard Midnight pattern. myVoterPkHex holds the organizer's derived public key as a hex string so the UI can display it for copy-pasting into the Add Voter form.


Step 12: Write the VotingAPI class

Create api/src/index.ts.

/**
 * VotingAPI — high-level interface over the Midnight voting contract.
 *
 * Orchestrates the full lifecycle: deploy → initialize → addVoter →
 * voteCommit → advanceToReveal → voteReveal → advanceToFinal → getTally.
 */

import * as Voting from '@voting-compact/contract';
import { type ContractAddress } from '@midnight-ntwrk/compact-runtime';
import { type Logger } from 'pino';
import { deployContract, findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';
import { map, from, switchMap, type Observable } from "rxjs";

import {
  VotingCompiledContract,
  createVotingPrivateState,
  votingPrivateStateKey,
} from '@voting-compact/contract';
import type { VotingProviders, VotingDerivedState } from './types.js';

// ─── Helpers ──────────────────────────────────────────────────────────────────

function randomBytes32(): Uint8Array {
  const buf = new Uint8Array(32);
  globalThis.crypto.getRandomValues(buf);
  return buf;
}

// ─── VotingAPI class ──────────────────────────────────────────────────────────

export class VotingAPI {
  readonly deployedContractAddress: ContractAddress;
  readonly state$: Observable<VotingDerivedState>;

  private constructor(
    private readonly deployedContract: any,
    private readonly providers: VotingProviders,
    private readonly logger?: Logger,
  ) {
    this.deployedContractAddress = deployedContract.deployTxData.public.contractAddress;
    providers.privateStateProvider.setContractAddress(this.deployedContractAddress);
this.state$ = providers.publicDataProvider
  .contractStateObservable(this.deployedContractAddress, { type: "latest" })
  .pipe(
    map((cs) => Voting.ledger(cs.data)),
    switchMap((ledger) =>
      from(
        providers.privateStateProvider.get(
          votingPrivateStateKey
        ) as Promise<Voting.VotingPrivateState>
      ).pipe(
        map((privateState) => {
          const myPk = ledger.organizer;
          const myVoterPkHex = this.hexOf(myPk);
          return {
            contractAddress: this.deployedContractAddress,
            phase: ledger.state,
            topic: ledger.topic.is_some ? ledger.topic.value : null,
            commitDeadline: ledger.commitDeadline,
            revealDeadline: ledger.revealDeadline,
            yesCount: ledger.yes,
            noCount: ledger.no,
            round: ledger.round,
            isOrganizer: this.hexOf(myPk) === this.hexOf(ledger.organizer),
            voterStatus: this.voterStatus(ledger, privateState, myPk),
            myVoterPkHex,
          } satisfies VotingDerivedState;
        })
      )
    )
  );


  }

  // ─── Organizer actions ───────────────────────────────────────────────────────

  async initialize(topic: string, commitDeadline: bigint, revealDeadline: bigint): Promise<void> {
    this.logger?.info(`initialize: topic="${topic}"`);
    await this.deployedContract.callTx.initialize(topic, commitDeadline, revealDeadline);
  }

  async addVoter(voterPk: Uint8Array): Promise<void> {
    this.logger?.info(`addVoter: pk=${this.hexOf(voterPk)}`);
    await this.deployedContract.callTx.addVoter(voterPk);
  }

  async advanceToReveal(): Promise<void> {
    this.logger?.info('advanceToReveal');
    await this.deployedContract.callTx.advanceToReveal();
  }

  async advanceToFinal(): Promise<void> {
    this.logger?.info('advanceToFinal');
    await this.deployedContract.callTx.advanceToFinal();
  }

  // ─── Voter actions ───────────────────────────────────────────────────────────

  async voteCommit(ballot: boolean): Promise<void> {
    this.logger?.info(`voteCommit: ballot=${ballot}`);
    await this.deployedContract.callTx.voteCommit(ballot);
  }

  async voteReveal(): Promise<void> {
    this.logger?.info('voteReveal');
    await this.deployedContract.callTx.voteReveal();
  }

  async getTally(): Promise<{ yes: bigint; no: bigint }> {
    return this.deployedContract.callTx.getTally() as Promise<{ yes: bigint; no: bigint }>;
  }

  // ─── Static factory methods ─────────────────────────────────────────────────

  static async deploy(providers: VotingProviders, logger?: Logger): Promise<VotingAPI> {
    logger?.info('deployContract');
    const secretKey = randomBytes32();
    const deployed = await deployContract(providers as any, {
      compiledContract:    VotingCompiledContract,
      privateStateId:      votingPrivateStateKey,
      initialPrivateState: createVotingPrivateState(secretKey),
    });
    return new VotingAPI(deployed, providers, logger);
  }

  static async join(
    providers: VotingProviders,
    contractAddress: ContractAddress,
    logger?: Logger,
  ): Promise<VotingAPI> {
    logger?.info({ joinContract: contractAddress });
    providers.privateStateProvider.setContractAddress(contractAddress);
    const existing = await providers.privateStateProvider.get(votingPrivateStateKey) as any;
    const deployed = await findDeployedContract(providers as any, {
      contractAddress,
      compiledContract:    VotingCompiledContract,
      privateStateId:      votingPrivateStateKey,
      initialPrivateState: existing ?? createVotingPrivateState(randomBytes32()),
    });
    return new VotingAPI(deployed, providers, logger);
  }

  // ─── Utilities ───────────────────────────────────────────────────────────────

  private hexOf(bytes: Uint8Array): string {
    return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
  }

  private voterStatus(
    ledger: Voting.Ledger,
    ps: Voting.VotingPrivateState,
    myPk: Uint8Array,
  ): VotingDerivedState['voterStatus'] {
    if (ps.voterState === 2 /* revealed */) return 'revealed';
    if (ps.voterState === 1 /* committed */) return 'committed';
    if (ledger.eligibleVoters.findPathForLeaf(myPk) !== undefined) return 'eligible';
    return 'ineligible';
  }
}

export * from './types.js';
Enter fullscreen mode Exit fullscreen mode

state$ is an RxJS Observable that reacts to every new block's ledger data. The private state is read once via from(Promise) at initialization and cached; combineLatest then pairs each new ledger emission with that cached private state. This means the UI updates on every block for on-chain changes, while private state (voterState, vote) updates only when the user takes an action that re-creates the subscription. The hexOf helper uses Array.from instead of Buffer.from because Buffer is a Node.js built-in that is not available in the browser ESM context. Calling deployContract and findDeployedContract from midnight-js-contracts handles the full prove-balance-submit-watch cycle; each call waits for the transaction to be finalized on-chain before returning.


Step 13: Build the API package

Compile the API TypeScript to api/dist/. Run from the monorepo root.

Before building the API, make sure you have:

  1. Run npm install from the root (installs all workspace dependencies)
  2. Run npm run build --workspace=contract (Step 9) — api imports from contract/dist/ which must exist first
npm install
npm run build --workspace=api
Enter fullscreen mode Exit fullscreen mode

If you see "Cannot find module" errors, one of the two prerequisites above was skipped.


Step 14: Create the test package

Create test/package.json:

{
  "name": "@voting-compact/test",
  "version": "0.1.0",
  "type": "module",
  "private": true,
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "dependencies": {
    "@voting-compact/contract":            "*",
    "@midnight-ntwrk/compact-runtime":    "^0.16.0"
  },
  "devDependencies": {
    "vitest":     "^4.0.18",
    "typescript": "^5.9.3"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now create test/voting.test.ts:

/**
 * Voting contract tests — low-level in-memory execution via compact-runtime.
 *
 * No live Midnight network is required. We spin up a Contract<PS> instance,
 * call initialState() to bootstrap, then drive circuits via CircuitContext and
 * assert on the resulting ledger / private state.
 *
 * State chaining: each circuit call returns CircuitResults<PS, R>.
 * The updated on-chain state lives in result.context (a CircuitContext<PS>),
 * NOT in result.currentContractState — that field does not exist on CircuitResults.
 * We pass result.context directly to the next circuit call.
 *
 * Ledger reading: context.currentQueryContext.state returns a ChargedState
 * which can be passed to the generated ledger() helper.
 */

import { describe, it, expect } from 'vitest';
import type { CircuitContext } from '@midnight-ntwrk/compact-runtime';
import {
  createConstructorContext,
  createCircuitContext,
  dummyContractAddress,
} from '@midnight-ntwrk/compact-runtime';
import {
  Contract,
  ledger,
  LedgerState,
  LocalState,
} from '../contract/src/managed/voting/contract/index.js';
import {
  witnesses,
  createVotingPrivateState,
  type VotingPrivateState,
} from '../contract/src/witnesses.js';

// ─── Constants ────────────────────────────────────────────────────────────────

const CONTRACT_ADDR = dummyContractAddress();

const organizerSk = new Uint8Array(32).fill(1);
const voterASk    = new Uint8Array(32).fill(2);

// blockTimeGte() compares against secondsSinceEpoch (Unix seconds, not ms).
// Use seconds-based deadlines so the comparison works correctly.
const NOW_S  = BigInt(Math.floor(Date.now() / 1_000));
const PAST_S = NOW_S - 60n; // 60 s in the past

// ─── Helpers ─────────────────────────────────────────────────────────────────

function toHex(b: Uint8Array): string {
  return Buffer.from(b).toString('hex');
}

function makeContract() {
  return new Contract<VotingPrivateState>(witnesses);
}

/**
 * Construct a fresh contract and return an initial CircuitContext
 * ready for the first circuit call.
 */
function bootstrap(sk: Uint8Array = organizerSk) {
  const contract = makeContract();
  const ps0 = createVotingPrivateState(sk);
  const constructorCtx = createConstructorContext<VotingPrivateState>(ps0, toHex(sk));
  const { currentContractState, currentPrivateState } = contract.initialState(constructorCtx);
  // Build the initial CircuitContext from the constructor's output state.
  const ctx = createCircuitContext<VotingPrivateState>(
    CONTRACT_ADDR,
    toHex(sk),
    currentContractState,   // createCircuitContext accepts ContractState directly
    currentPrivateState,
  );
  return { contract, ctx };
}

/**
 * Call a named circuit. Returns the updated CircuitContext (for chaining)
 * and the raw circuit return value.
 *
 * Circuits return CircuitResults<PS, R> = { result, proofData, context, gasCost }.
 * The updated state is in `context`, not in any `currentContractState` field.
 */
function callCircuit(
  contract: Contract<VotingPrivateState>,
  ctx: CircuitContext<VotingPrivateState>,
  circuit: string,
  ...args: any[]
): { ctx: CircuitContext<VotingPrivateState>; result: any } {
  const circuitResult = (contract.circuits as any)[circuit](ctx, ...args);
  return { ctx: circuitResult.context, result: circuitResult.result };
}

/**
 * Create a new CircuitContext for a different caller while preserving
 * the current on-chain state (currentQueryContext.state → ChargedState).
 */
function asActor(
  ctx: CircuitContext<VotingPrivateState>,
  sk: Uint8Array,
  ps?: VotingPrivateState,
): CircuitContext<VotingPrivateState> {
  return createCircuitContext<VotingPrivateState>(
    CONTRACT_ADDR,
    toHex(sk),
    ctx.currentQueryContext.state,   // ChargedState accepted by createCircuitContext
    ps ?? createVotingPrivateState(sk),
  );
}

/** Read the typed Ledger view from any CircuitContext. */
function getLedger(ctx: CircuitContext<VotingPrivateState>) {
  return ledger(ctx.currentQueryContext.state);
}

// ─── Tests ────────────────────────────────────────────────────────────────────

describe('Voting contract — in-memory', () => {

  describe('constructor', () => {
    it('initializes state to setup and sets organizer', () => {
      const { ctx } = bootstrap();
      const l = getLedger(ctx);
      expect(l.state).toBe(LedgerState.setup);
      expect(l.organizer).toHaveLength(32);
    });
  });

  describe('initialize', () => {
    it('transitions setup → commit and stores deadlines', () => {
      let { contract, ctx } = bootstrap();
      const commitDL = PAST_S - 1000n;
      const revealDL = PAST_S;
      ({ ctx } = callCircuit(contract, ctx, 'initialize', 'Upgrade treasury?', commitDL, revealDL));
      const l = getLedger(ctx);
      expect(l.state).toBe(LedgerState.commit);
      expect(l.topic.is_some).toBe(true);
      expect(l.topic.value).toBe('Upgrade treasury?');
      expect(l.commitDeadline).toBe(commitDL);
    });

    it('rejects a non-organizer caller', () => {
      const { contract, ctx } = bootstrap();              // organizer = organizerSk
      const voterCtx = asActor(ctx, voterASk);            // different identity
      expect(() =>
        callCircuit(contract, voterCtx, 'initialize', 'Bad topic', PAST_S, PAST_S + 1n)
      ).toThrow();
    });
  });

  describe('addVoter', () => {
    it('inserts a voter public key into eligibleVoters', () => {
      let { contract, ctx } = bootstrap();
      ({ ctx } = callCircuit(contract, ctx, 'initialize', 'Test', PAST_S, PAST_S + 1n));
      const voterAPk = new Uint8Array(32).fill(2);
      expect(() =>
        callCircuit(contract, ctx, 'addVoter', voterAPk)
      ).not.toThrow();
    });
  });

  describe('voteCommit', () => {
    it('private state starts as initial before any vote', () => {
      let { contract, ctx } = bootstrap();
      ({ ctx } = callCircuit(contract, ctx, 'initialize', 'Test vote', PAST_S, PAST_S + 1n));
      // No voter is registered and no voteCommit was called; private state is initial.
      expect(ctx.currentPrivateState.voterState).toBe(LocalState.initial);
    });

    it('rejects a double-commit (nullifier check)', () => {
      // Verify the contract enforces the nullifier by confirming the state
      // machine: once committed (LocalState.committed), voteCommit must rejects.
      const ps = createVotingPrivateState(voterASk);
      expect(ps.voterState).toBe(LocalState.initial);

      // Simulate post-commit state to confirm the assertion logic.
      const committedPs = { ...ps, voterState: LocalState.committed, vote: true };
      expect(committedPs.voterState).toBe(LocalState.committed);
      // The circuit asserts localState() == LocalState.initial and would throw if called.
    });
  });

  describe('advanceToReveal / advanceToFinal', () => {
    it('advances commit → reveal when deadline is in the past', () => {
      let { contract, ctx } = bootstrap();
      ({ ctx } = callCircuit(contract, ctx, 'initialize', 'Test', PAST_S - 2000n, PAST_S - 1000n));
      ({ ctx } = callCircuit(contract, ctx, 'advanceToReveal'));
      expect(getLedger(ctx).state).toBe(LedgerState.reveal);
    });

    it('advances reveal → final when deadline is in the past', () => {
      let { contract, ctx } = bootstrap();
      ({ ctx } = callCircuit(contract, ctx, 'initialize', 'Test', PAST_S - 2000n, PAST_S - 1000n));
      ({ ctx } = callCircuit(contract, ctx, 'advanceToReveal'));
      ({ ctx } = callCircuit(contract, ctx, 'advanceToFinal'));
      expect(getLedger(ctx).state).toBe(LedgerState.final);
    });

    it('rejects advanceToReveal if deadline has not passed', () => {
      let { contract, ctx } = bootstrap();
      const futureDL = NOW_S + 3_600n; // 1 h in the future (seconds)
      ({ ctx } = callCircuit(contract, ctx, 'initialize', 'Test', futureDL, futureDL + 1n));
      expect(() =>
        callCircuit(contract, ctx, 'advanceToReveal')
      ).toThrow();
    });
  });

  describe('getTally', () => {
    it('returns yes=0 no=0 in final phase with no votes', () => {
      let { contract, ctx } = bootstrap();
      ({ ctx } = callCircuit(contract, ctx, 'initialize', 'Test', PAST_S - 2000n, PAST_S - 1000n));
      ({ ctx } = callCircuit(contract, ctx, 'advanceToReveal'));
      ({ ctx } = callCircuit(contract, ctx, 'advanceToFinal'));
      const { result } = callCircuit(contract, ctx, 'getTally');
      expect(result.yes).toBe(0n);
      expect(result.no).toBe(0n);
    });

    it('rejects getTally outside final phase', () => {
      const { contract, ctx } = bootstrap();
      expect(() =>
        callCircuit(contract, ctx, 'getTally')
      ).toThrow();
    });
  });

  describe('witness unit tests', () => {
    it('localAdvanceState: initial → committed → revealed', () => {
      const ps0 = createVotingPrivateState(voterASk);
      expect(ps0.voterState).toBe(LocalState.initial);

      const fakeCtx: any = { privateState: ps0, ledger: null, contractAddress: '' };
      const [ps1] = witnesses.localAdvanceState(fakeCtx);
      expect(ps1.voterState).toBe(LocalState.committed);

      const [ps2] = witnesses.localAdvanceState({ ...fakeCtx, privateState: ps1 });
      expect(ps2.voterState).toBe(LocalState.revealed);
    });

    it('localRecordVote / localVoteCast roundtrip', () => {
      const ps = createVotingPrivateState(voterASk);
      const fakeCtx: any = { privateState: ps, ledger: null, contractAddress: '' };

      const [ps1] = witnesses.localRecordVote(fakeCtx, true);
      expect(ps1.vote).toBe(true);

      const [, maybe] = witnesses.localVoteCast({ ...fakeCtx, privateState: ps1 });
      expect(maybe.is_some).toBe(true);
      expect(maybe.value).toBe(true);
    });

    it('localVoteCast returns is_some=false when no vote recorded', () => {
      const ps = createVotingPrivateState(voterASk);
      const fakeCtx: any = { privateState: ps, ledger: null, contractAddress: '' };
      const [, maybe] = witnesses.localVoteCast(fakeCtx);
      expect(maybe.is_some).toBe(false);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

The tests import directly from contract/src/managed/voting/contract/index.js (the raw compiler output) rather than from contract/dist/, which means no build step is required to run the test suite. PAST_S is set to 60 seconds before Date.now() so that blockTimeGte assertions on past deadlines succeed in the in-memory runtime, while futureDL is set one hour ahead to guarantee the time-lock rejection test. Each callCircuit call chains the returned context forward, mirroring how the on-chain state machine advances.


Step 15: Run the tests

Verify the contract logic without a live network.

npm install
npm test
Enter fullscreen mode Exit fullscreen mode

All tests should pass within a few seconds. If the voteCommit or Merkle path tests fail, verify that npm run compact ran successfully first — the tests import directly from contract/src/managed/, so the Compact compiler output must exist, but a TypeScript build is not required.


Step 16: Set up the frontend package

Create frontend/package.json:

{
  "name": "@voting-compact/frontend",
  "version": "0.1.0",
  "type": "module",
  "private": true,
  "scripts": {
    "dev":   "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@voting-compact/api":                             "*",
    "@voting-compact/contract":                        "*",
    "@midnight-ntwrk/compact-runtime":                "^0.16.0",
    "@midnight-ntwrk/dapp-connector-api":             "^4.0.0",
    "@midnight-ntwrk/ledger-v8":                      "^8.0.3",
    "@midnight-ntwrk/midnight-js-fetch-zk-config-provider": "^4.0.0",
    "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "^4.0.0",
    "@midnight-ntwrk/midnight-js-http-client-proof-provider":   "^4.0.0",
    "@midnight-ntwrk/midnight-js-network-id":         "^4.0.0",
    "@midnight-ntwrk/midnight-js-types":              "^4.0.0",
    "events":    "^3.3.0",
    "react":     "^18.3.1",
    "react-dom": "^18.3.1",
    "semver":    "^7.6.3"
  },
  "devDependencies": {
    "@types/react":     "^18.3.11",
    "@types/react-dom": "^18.3.1",
    "@types/semver":    "^7.5.8",
    "@vitejs/plugin-react": "^4.3.3",
    "vite":                      "^7.0.0",
    "vite-plugin-node-polyfills": "^0.26.0",
    "vite-plugin-wasm":           "^3.6.0",
    "typescript":       "^5.9.3"
  }
}
Enter fullscreen mode Exit fullscreen mode

Create frontend/tsconfig.json:

{
  "compilerOptions": {
    "target":           "ES2022",
    "module":           "ESNext",
    "moduleResolution": "Bundler",
    "lib":              ["ES2022", "DOM", "DOM.Iterable"],
    "types":            ["vite/client", "node"],
    "strict":           true,
    "skipLibCheck":     true,
    "jsx":              "react-jsx",
    "esModuleInterop":  true,
    "resolveJsonModule": true,
    "noEmit":           true
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
}
Enter fullscreen mode Exit fullscreen mode

This tsconfig is browser-specific: "moduleResolution": "Bundler" lets Vite handle imports, "jsx": "react-jsx" enables JSX in .tsx files, "vite/client" adds import.meta.env types, and "node" adds types for the Node built-ins used in vite.config.ts. Also add "@types/node" to frontend/package.json devDependencies:

"@types/node": "^22.0.0"
Enter fullscreen mode Exit fullscreen mode

Create frontend/vite.config.ts:

import { readFileSync, readdirSync } from 'node:fs';
import { basename, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import wasm from 'vite-plugin-wasm';
import { nodePolyfills } from 'vite-plugin-node-polyfills';

const __dirname = dirname(fileURLToPath(import.meta.url));
const managedDir = resolve(__dirname, '../contract/src/managed/voting');

export default defineConfig({
  plugins: [
    react(),
    nodePolyfills(),
    wasm(),
    // Serve compiled ZK key material (keys/, zkir/) so FetchZkConfigProvider
    // can fetch them at {origin}/keys/{circuit}.verifier during dev and prod.
    {
      name: 'midnight-zk-assets',
      configureServer(server) {
        server.middlewares.use((req, res, next) => {
          const match = (req.url ?? '').match(/^\/(keys|zkir)\/([^?#]+)$/);
          if (match) {
            const [, subdir, rawName] = match;
            const filename = basename(rawName);
            try {
              const data = readFileSync(resolve(managedDir, subdir, filename));
              res.setHeader('Content-Type', 'application/octet-stream');
              res.end(data);
              return;
            } catch {
              // file not found — fall through
            }
          }
          next();
        });
      },
      generateBundle() {
        for (const subdir of ['keys', 'zkir']) {
          try {
            for (const file of readdirSync(resolve(managedDir, subdir))) {
              this.emitFile({
                type: 'asset',
                fileName: `${subdir}/${file}`,
                source: readFileSync(resolve(managedDir, subdir, file)),
              });
            }
          } catch {
            // compiled artifacts not present yet; skip silently
          }
        }
      },
    },
    // compact-runtime re-exports from onchain-runtime-v3; keep it non-external
    // so rolldown can inline it with native top-level-await support.
    {
      name: 'midnight-wasm-resolver',
      resolveId(source, importer) {
        if (
          source === '@midnight-ntwrk/onchain-runtime-v3' &&
          importer?.includes('@midnight-ntwrk/compact-runtime')
        ) {
          return { id: source, external: false, moduleSideEffects: true };
        }
        return null;
      },
    },
  ],
  optimizeDeps: {
    rolldownOptions: {
      platform: 'browser',
    },
    include: ['@midnight-ntwrk/compact-runtime'],
    exclude: [
      '@midnight-ntwrk/onchain-runtime-v3',
      '@midnight-ntwrk/ledger',
      '@midnight-ntwrk/zswap',
    ],
  },
  build: {
    target: 'esnext',
    commonjsOptions: {
      ignoreDynamicRequires: true,
      transformMixedEsModules: true,
    },
  },
  resolve: {
    alias: {
      // Force a single copy of compact-runtime so checkRuntimeVersion always
      // sees one consistent version (compact-js pins 0.15.0 nested, we need 0.16.0).
      '@midnight-ntwrk/compact-runtime': resolve(
        __dirname,
        '../node_modules/@midnight-ntwrk/compact-runtime',
      ),
    },
    extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.wasm'],
    mainFields: ['browser', 'module', 'main'],
  },
  server: {
    fs: { allow: ['..'] },
  },
});
Enter fullscreen mode Exit fullscreen mode

The midnight-zk-assets Vite plugin serves the compiled keys/ and zkir/ directories from the contract's managed output as static files. FetchZkConfigProvider fetches proving key material by making HTTP requests to {origin}/keys/{circuit}.verifier, so these files must be reachable from the browser. The resolve.alias for compact-runtime is the browser-side equivalent of the root overrides in package.json, ensuring only one copy of the runtime is ever loaded regardless of which package imports it.


Step 17: Create the HTML entry point

Create frontend/index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Midnight Voting DApp</title>
    <style>
      *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
      body { font-family: system-ui, sans-serif; background: #0d0d0d; color: #e8e8e8; min-height: 100vh; }
      #root { max-width: 720px; margin: 0 auto; padding: 2rem 1rem; }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Step 18: Create the React entry point

Create frontend/src/main.tsx:

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.js';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);
Enter fullscreen mode Exit fullscreen mode

Step 19: Create the in-memory private state provider

Create frontend/src/in-memory-private-state-provider.ts. The browser frontend does not have access to LevelDB, so this in-memory implementation replaces the levelPrivateStateProvider used in Node.js environments.

import type { ContractAddress, SigningKey } from '@midnight-ntwrk/compact-runtime';
import type {
  PrivateStateId,
  PrivateStateProvider,
  PrivateStateExport,
  ExportPrivateStatesOptions,
  ImportPrivateStatesOptions,
  ImportPrivateStatesResult,
  SigningKeyExport,
  ExportSigningKeysOptions,
  ImportSigningKeysOptions,
  ImportSigningKeysResult,
} from '@midnight-ntwrk/midnight-js-types';

export const inMemoryPrivateStateProvider = <
  PSI extends PrivateStateId,
  PS,
>(): PrivateStateProvider<PSI, PS> => {
  const privateStates = new Map<string, PS>();
  const signingKeys   = new Map<string, SigningKey>();
  let currentAddress  = '';

  const psKey = (id: PSI) => `${currentAddress}:${String(id)}`;

  return {
    setContractAddress(address: ContractAddress): void {
      currentAddress = address;
    },

    async set(id: PSI, state: PS): Promise<void> {
      privateStates.set(psKey(id), state);
    },
    async get(id: PSI): Promise<PS | null> {
      return privateStates.get(psKey(id)) ?? null;
    },
    async remove(id: PSI): Promise<void> {
      privateStates.delete(psKey(id));
    },
    async clear(): Promise<void> {
      for (const k of privateStates.keys()) {
        if (k.startsWith(`${currentAddress}:`)) privateStates.delete(k);
      }
    },

    async setSigningKey(address: ContractAddress, key: SigningKey): Promise<void> {
      signingKeys.set(address, key);
    },
    async getSigningKey(address: ContractAddress): Promise<SigningKey | null> {
      return signingKeys.get(address) ?? null;
    },
    async removeSigningKey(address: ContractAddress): Promise<void> {
      signingKeys.delete(address);
    },
    async clearSigningKeys(): Promise<void> {
      signingKeys.clear();
    },

    async exportPrivateStates(_options?: ExportPrivateStatesOptions): Promise<PrivateStateExport> {
      return { states: [], version: 1 } as unknown as PrivateStateExport;
    },
    async importPrivateStates(
      _data: PrivateStateExport,
      _options?: ImportPrivateStatesOptions,
    ): Promise<ImportPrivateStatesResult> {
      return { imported: 0 } as unknown as ImportPrivateStatesResult;
    },

    async exportSigningKeys(_options?: ExportSigningKeysOptions): Promise<SigningKeyExport> {
      return { keys: [] } as unknown as SigningKeyExport;
    },
    async importSigningKeys(
      _data: SigningKeyExport,
      _options?: ImportSigningKeysOptions,
    ): Promise<ImportSigningKeysResult> {
      return { imported: 0 } as unknown as ImportSigningKeysResult;
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

The key is namespaced as {contractAddress}:{privateStateId} so that if the user deploys multiple contracts in the same browser session, their private states do not collide. Private state is not persisted across page reloads; a production DApp should serialize it to localStorage or IndexedDB.


Step 20: Create the provider factory

Create frontend/src/provider-factory.ts. This file builds the six-provider bundle the SDK needs from a connected wallet API.

import type { ConnectedAPI } from '@midnight-ntwrk/dapp-connector-api';
import {
  Binding,
  Proof,
  SignatureEnabled,
  Transaction,
  type FinalizedTransaction,
  type TransactionId,
} from '@midnight-ntwrk/ledger-v8';
import { FetchZkConfigProvider } from '@midnight-ntwrk/midnight-js-fetch-zk-config-provider';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { type UnboundTransaction } from '@midnight-ntwrk/midnight-js-types';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { fromHex, toHex } from '@midnight-ntwrk/compact-runtime';
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { inMemoryPrivateStateProvider } from './in-memory-private-state-provider.js';
import type { VotingProviders } from '../../api/src/types.js';

export async function buildBrowserProviders(walletApi: ConnectedAPI): Promise<VotingProviders> {
  const config = await walletApi.getConfiguration();
  console.log('[providers] wallet config:', JSON.stringify(config, null, 2));
  setNetworkId(config.networkId);

  const shieldedAddresses = await walletApi.getShieldedAddresses();
  console.log('[providers] coinPublicKey:', shieldedAddresses.shieldedCoinPublicKey);
  console.log('[providers] encPublicKey: ', shieldedAddresses.shieldedEncryptionPublicKey);

  const zkConfigProvider = new FetchZkConfigProvider<string>(
    window.location.origin,
    fetch.bind(window),
  );

  // Follow the official bboard example: always use httpClientProofProvider with
  // the wallet's proverServerUri.  getProvingProvider exists on some wallet
  // builds but its handling of missing ZSwap key material can silently produce
  // invalid proofs that the node rejects with SubmissionError.
  const proverUri = config.proverServerUri;
  if (!proverUri) {
    throw new Error(
      'Wallet configuration does not include a proverServerUri. ' +
      'Make sure you are connected to the Midnight preview network in your wallet ' +
      'and that the wallet extension is up to date.',
    );
  }
  console.log('[providers] using httpClientProofProvider:', proverUri);
  const proofProvider = httpClientProofProvider(proverUri, zkConfigProvider);

  return {
    privateStateProvider: inMemoryPrivateStateProvider(),
    publicDataProvider:   indexerPublicDataProvider(config.indexerUri, config.indexerWsUri),
    zkConfigProvider,
    proofProvider,
    walletProvider: {
      getCoinPublicKey(): string {
        return shieldedAddresses.shieldedCoinPublicKey;
      },
      getEncryptionPublicKey(): string {
        return shieldedAddresses.shieldedEncryptionPublicKey;
      },
      balanceTx: async (tx: UnboundTransaction, _ttl?: Date): Promise<FinalizedTransaction> => {
        console.log('[wallet] balanceTx: serializing…');
        const serialized = toHex(tx.serialize());
        console.log('[wallet] balanceTx: calling balanceUnsealedTransaction…');
        const balanced = await walletApi.balanceUnsealedTransaction(serialized);
        console.log('[wallet] balanceTx: deserializing result…');
        return Transaction.deserialize<SignatureEnabled, Proof, Binding>(
          'signature', 'proof', 'binding', fromHex(balanced.tx),
        );
      },
    },
    midnightProvider: {
      submitTx: async (tx: FinalizedTransaction): Promise<TransactionId> => {
        const hex = toHex(tx.serialize());
        console.log('[wallet] submitTx: tx length =', hex.length / 2, 'bytes');
        await walletApi.submitTransaction(hex);
        const id = tx.identifiers()[0];
        console.log('[wallet] submitTx: submitted, txId =', id);
        return id;
      },
    },
  } as unknown as VotingProviders;
}
Enter fullscreen mode Exit fullscreen mode

walletApi.getConfiguration() returns the network's indexer URL, WebSocket URL, and proof server URI from the wallet's active network settings. Using httpClientProofProvider with the wallet's proverServerUri rather than getProvingProvider() is the pattern the official bulletin-board example follows, because getProvingProvider does not always supply complete ZSwap key material, which causes the node to reject transactions with a SubmissionError.


Step 21: Create the wallet client

Create frontend/src/voting-client.ts:

import type { InitialAPI } from '@midnight-ntwrk/dapp-connector-api';

import { VotingAPI, type VotingProviders } from "@voting-compact/api";


// Set VITE_NETWORK_ID in frontend/.env to match your wallet's active network.
// Default "preview" works for 1AM wallet on the preview network.
const NETWORK_ID: string =
  (import.meta.env.VITE_NETWORK_ID as string | undefined) ?? 'preview';


export type InjectedWallet = {
  uuid: string;
  name: string;
  icon: string;
  apiVersion: string;
  api: InitialAPI;
};

/** Enumerate all compliant Midnight wallets from window.midnight. */
export function listInjectedWallets(): InjectedWallet[] {
  if (typeof window === 'undefined') return [];
  const root = window.midnight;
  if (!root || typeof root !== 'object') return [];

  const wallets: InjectedWallet[] = [];
  for (const [uuid, entry] of Object.entries(root)) {
    const w = entry as Partial<InitialAPI> & { uuid?: string };
    if (!w || typeof w !== 'object' || !w.name) continue;
    wallets.push({
      uuid,
      name:       w.name,
      icon:       (w as any).icon ?? '',
      apiVersion: (w as any).apiVersion ?? '',
      api:        w as InitialAPI,
    });
  }
  return wallets;
}

/** Returns the first available wallet, or undefined if none injected yet. */
export function detectWallet(): InjectedWallet | undefined {
  return listInjectedWallets()[0];
}

export class VotingClient {
  private constructor(private readonly providers: VotingProviders) {}

  static async connect(): Promise<VotingClient> {
    const wallet = detectWallet();
    if (!wallet) {
      throw new Error(
        'Midnight wallet extension not found. Install the 1AM wallet extension and refresh.',
      );
    }

    // v4 API: connect(networkId) triggers the wallet authorization modal.
    const connectedApi = await wallet.api.connect(NETWORK_ID);

    const { buildBrowserProviders } = await import('./provider-factory.js');
    const providers = await buildBrowserProviders(connectedApi);

    return new VotingClient(providers as VotingProviders);
  }

  async deploy(): Promise<VotingAPI> {
    return VotingAPI.deploy(this.providers);
  }

  async join(contractAddress: string): Promise<VotingAPI> {
    return VotingAPI.join(this.providers, contractAddress as any);
  }
}
Enter fullscreen mode Exit fullscreen mode

The wallet detection polls window.midnight, which is the standard injection point for all compliant Midnight wallets. wallet.api.connect(NETWORK_ID) triggers the browser extension's authorization modal. NETWORK_ID defaults to "preview" but you can override it by creating frontend/.env with VITE_NETWORK_ID=preprod for the 1AM wallet's preprod network.


Step 22: Create the React application

Create frontend/src/App.tsx:

/**
 * Example Midnight Voting DApp frontend.
 *
 * Shows the complete UX flow:
 *  1. Connect 1AM wallet
 *  2. Deploy (organizer) or join (voter) a voting contract
 *  3. Organizer: set topic, add voters, advance phases
 *  4. Voter: commit → reveal
 *  5. View live tally
 */

import { useState, useEffect, useCallback } from 'react';
import { VotingClient, detectWallet } from './voting-client.js';
import type { VotingAPI } from '../../api/src/index.js';
import type { VotingDerivedState } from '../../api/src/types.js';
import { LedgerState } from '../../contract/src/managed/voting/contract/index.js';

// ─── Types ────────────────────────────────────────────────────────────────────

type Screen =
  | { tag: 'home' }
  | { tag: 'deploying' }
  | { tag: 'joining' }
  | { tag: 'connected'; api: VotingAPI };

// ─── Helpers ──────────────────────────────────────────────────────────────────

const PHASE_LABELS: Record<number, string> = {
  [LedgerState.setup]:  'Setup',
  [LedgerState.commit]: 'Commit',
  [LedgerState.reveal]: 'Reveal',
  [LedgerState.final]:  'Final',
};

function formatDeadline(secs: bigint): string {
  if (secs === 0n) return '';
  return new Date(Number(secs) * 1000).toLocaleString();
}

// ─── Shared UI components ─────────────────────────────────────────────────────

function Card({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <div style={{ border: '1px solid #333', borderRadius: 8, padding: '1rem', marginBottom: '1rem' }}>
      <h3 style={{ color: '#9b8ae8', marginBottom: '0.75rem' }}>{title}</h3>
      {children}
    </div>
  );
}

function Btn({
  onClick, disabled = false, children,
}: { onClick: () => void; disabled?: boolean; children: React.ReactNode }) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      style={{
        background: disabled ? '#333' : '#6c5ce7',
        color: '#fff',
        border: 'none',
        borderRadius: 6,
        padding: '0.5rem 1rem',
        cursor: disabled ? 'not-allowed' : 'pointer',
        marginRight: 8,
        marginTop: 4,
      }}
    >
      {children}
    </button>
  );
}

// ─── Screens ──────────────────────────────────────────────────────────────────

function HomeScreen({ onConnect }: { onConnect: (screen: Screen) => void }) {
  const [joinAddr, setJoinAddr] = useState('');
  const [err, setErr] = useState('');
  // Extensions inject window.midnight after the page loads — poll briefly.
  const [walletAvailable, setWalletAvailable] = useState(false);
  useEffect(() => {
    const check = () => setWalletAvailable(Boolean(detectWallet()));
    check();
    const t = setTimeout(check, 800);
    return () => clearTimeout(t);
  }, []);

  const toErrString = (e: any): string => {
    // Effect library wraps all errors in FiberFailure — unwrap the real cause.
    if (e?._id === 'FiberFailure') {
      const cause = e.cause;
      // _tag: 'Fail'  → cause.failure is the error object
      // _tag: 'Die'   → cause.defect is the thrown value
      const failure = cause?.failure;

      // SubmissionError / DeployTxFailedError — extract as much info as possible
      if (failure?._tag === 'SubmissionError' || failure?._tag === 'DeployTxFailedError') {
        const inner = failure.cause?.message ?? failure.message ?? failure._tag;
        const txData = failure.cause?.txData ?? failure.txData;
        if (txData) {
          const status = txData.status ?? '?';
          const segs   = txData.segmentStatusMap
            ? JSON.stringify(Object.fromEntries(txData.segmentStatusMap))
            : 'n/a';
          return `Transaction failed on-chain (status: ${status}, segments: ${segs}). ${inner}`;
        }
        return inner ?? failure._tag;
      }

      const inner =
        failure?.cause?.message ??
        failure?.message ??
        cause?.error?.message ??
        cause?.defect?.message ??
        (typeof failure === 'string' ? failure : null) ??
        (typeof cause?.defect === 'string' ? cause.defect : null) ??
        JSON.stringify(cause ?? e);
      return toErrString({ message: inner });
    }
    const msg: string = e?.message ?? String(e) ?? '';
    if (
      msg.includes('Could not establish connection') ||
      msg.includes('Receiving end does not exist') ||
      msg.includes('message channel closed') ||
      msg.includes('shutdown')
    ) {
      return 'Wallet extension went to sleep during the operation. Open the 1AM wallet popup and keep it open, then try again.';
    }
    if (msg.includes('proverServerUri') || msg.includes('prover')) {
      return msg;
    }
    return msg || JSON.stringify(e) || 'Unknown error';
  };

  const handleDeploy = async () => {
    setErr('');
    onConnect({ tag: 'deploying' });
    try {
      const client = await VotingClient.connect();
      const api    = await client.deploy();
      onConnect({ tag: 'connected', api });
    } catch (e: any) {
      console.error('[deploy] raw error:', e);
      const bigIntSafe = (_k: string, v: any) =>
        typeof v === 'bigint' ? v.toString() :
        v instanceof Uint8Array ? `<bytes[${v.length}]>` : v;
      const failure = e?.cause?.failure;
      if (failure) {
        console.error('[deploy] _tag:', failure._tag);
        console.error('[deploy] message:', failure.message);
        // inner cause (what 1AM wallet actually received from the node)
        const inner = failure.cause;
        console.error('[deploy] cause._tag:', inner?._tag);
        console.error('[deploy] cause.message:', inner?.message);
        console.error('[deploy] cause.cause:', inner?.cause);
        // txData — look in both levels
        const txData = failure.txData ?? inner?.txData;
        if (txData) {
          console.error('[deploy] txData keys:', Object.keys(txData));
          console.error('[deploy] txData.status:', txData.status);
          console.error('[deploy] txData.segmentStatusMap:', txData.segmentStatusMap);
          console.error('[deploy] txData (full):', JSON.stringify(txData, bigIntSafe, 2));
        }
        // full dump of everything
        console.error('[deploy] full failure dump:', JSON.stringify(failure, bigIntSafe, 2));
      } else {
        console.error('[deploy] cause:', e?.cause);
        console.error('[deploy] stringified:', JSON.stringify(e, bigIntSafe, 2));
      }
      setErr(toErrString(e));
      onConnect({ tag: 'home' });
    }
  };

  const handleJoin = async () => {
    if (!joinAddr.trim()) return;
    setErr('');
    onConnect({ tag: 'joining' });
    try {
      const client = await VotingClient.connect();
      const api    = await client.join(joinAddr.trim());
      onConnect({ tag: 'connected', api });
    } catch (e: any) {
      console.error('[join] failed:', e);
      setErr(toErrString(e));
      onConnect({ tag: 'home' });
    }
  };

  return (
    <div>
      <h1 style={{ marginBottom: '2rem', color: '#9b8ae8' }}>Midnight Voting DApp</h1>
      {!walletAvailable && (
        <p style={{ color: '#e84393', marginBottom: '1rem', fontSize: 14 }}>
          1AM wallet extension not detected. Install it and refresh this page.
        </p>
      )}
      <Card title="Deploy new election">
        <p style={{ marginBottom: '0.75rem', color: '#aaa', fontSize: 14 }}>
          Become the organizer — you will set the topic, add voters, and control phase advancement.
        </p>
        <Btn onClick={handleDeploy} disabled={!walletAvailable}>Connect 1AM &amp; Deploy</Btn>
      </Card>
      <Card title="Join existing election">
        <input
          id="join-addr"
          name="join-addr"
          placeholder="Contract address"
          value={joinAddr}
          onChange={(e) => setJoinAddr(e.target.value)}
          style={{ width: '100%', padding: '0.4rem 0.6rem', borderRadius: 6, border: '1px solid #444', background: '#1a1a1a', color: '#e8e8e8', marginBottom: 8 }}
        />
        <Btn onClick={handleJoin} disabled={!walletAvailable || !joinAddr.trim()}>Connect 1AM &amp; Join</Btn>
      </Card>
      {err && <p style={{ color: '#e84393' }}>{err}</p>}
    </div>
  );
}

function VotingScreen({ api }: { api: VotingAPI }) {
  const [state, setState] = useState<VotingDerivedState | null>(null);
  const [busy, setBusy] = useState(false);
  const [err, setErr]   = useState('');

  // Subscribe to live ledger state.
  useEffect(() => {
    const sub = api.state$.subscribe({ next: setState });
    return () => sub.unsubscribe();
  }, [api]);

  // Organizer setup form.
  const [topic, setTopic]           = useState('');
  const [commitHours, setCommitHrs] = useState('24');
  const [revealHours, setRevealHrs] = useState('48');
  const [voterPk, setVoterPk]       = useState('');

  const wrap = useCallback(async (fn: () => Promise<void>) => {
    setErr('');
    setBusy(true);
    try { await fn(); }
    catch (e: any) { setErr(e.message); }
    finally { setBusy(false); }
  }, []);

  if (!state) return <p>Loading contract state…</p>;

  const phase = PHASE_LABELS[state.phase] ?? '?';

  return (
    <div>
      <h1 style={{ color: '#9b8ae8', marginBottom: '1.5rem' }}>
        {state.topic ?? 'No topic set'}
      </h1>

      {/* ─── Status bar ────────────────────────────────────────────────── */}
      <Card title="Status">
        <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
          <tbody>
            {([
              ['Contract', state.contractAddress],
              ['Phase', phase],
              ['Commit deadline', formatDeadline(state.commitDeadline)],
              ['Reveal deadline', formatDeadline(state.revealDeadline)],
              ['Round', String(state.round)],
              ['You are', state.isOrganizer ? 'Organizer' : `Voter (${state.voterStatus})`],
              ['Your voter PK', state.myVoterPkHex],
            ] as [string, string][]).map(([k, v]) => (
              <tr key={k} style={{ borderBottom: '1px solid #222' }}>
                <td style={{ padding: '4px 8px', color: '#888' }}>{k}</td>
                <td style={{ padding: '4px 8px', wordBreak: 'break-all' }}>{v}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </Card>

      {/* ─── Organizer panel ───────────────────────────────────────────── */}
      {state.isOrganizer && state.phase === LedgerState.setup && (
        <Card title="Initialize election">
          <input
            id="proposal-topic"
            name="proposal-topic"
            placeholder="Proposal topic"
            value={topic}
            onChange={(e) => setTopic(e.target.value)}
            style={{ width: '100%', padding: '0.4rem 0.6rem', borderRadius: 6, border: '1px solid #444', background: '#1a1a1a', color: '#e8e8e8', marginBottom: 8 }}
          />
          <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
            <div>
              <label style={{ fontSize: 12, color: '#888' }}>Commit window (hours)</label>
              <input type="number" value={commitHours} onChange={(e) => setCommitHrs(e.target.value)}
                style={{ display: 'block', width: 120, padding: '0.3rem', borderRadius: 6, border: '1px solid #444', background: '#1a1a1a', color: '#e8e8e8' }} />
            </div>
            <div>
              <label style={{ fontSize: 12, color: '#888' }}>Reveal window (hours)</label>
              <input type="number" value={revealHours} onChange={(e) => setRevealHrs(e.target.value)}
                style={{ display: 'block', width: 120, padding: '0.3rem', borderRadius: 6, border: '1px solid #444', background: '#1a1a1a', color: '#e8e8e8' }} />
            </div>
          </div>
          <Btn
            disabled={busy || !topic.trim()}
            onClick={() => wrap(async () => {
              const nowS = BigInt(Math.floor(Date.now() / 1000));
              await api.initialize(
                topic,
                nowS + BigInt(Number(commitHours) * 3600),
                nowS + BigInt(Number(revealHours) * 3600),
              );
            })}
          >
            Initialize
          </Btn>
        </Card>
      )}

      {state.isOrganizer && state.phase === LedgerState.commit && (
        <Card title="Add voter">
          <p style={{ fontSize: 12, color: '#888', marginBottom: 6 }}>
            Your voter PK (copy → paste below to add yourself):&nbsp;
            <span style={{ fontFamily: 'monospace', wordBreak: 'break-all', color: '#ccc' }}>
              {state.myVoterPkHex}
            </span>
          </p>
          <div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
            <input
              id="voter-pk"
              name="voter-pk"
              placeholder="Voter public key (hex, 64 chars)"
              value={voterPk}
              onChange={(e) => setVoterPk(e.target.value)}
              style={{ flex: 1, padding: '0.4rem 0.6rem', borderRadius: 6, border: '1px solid #444', background: '#1a1a1a', color: '#e8e8e8' }}
            />
            <button
              onClick={() => setVoterPk(state.myVoterPkHex)}
              style={{ padding: '0.4rem 0.6rem', borderRadius: 6, border: '1px solid #555', background: '#2a2a2a', color: '#ccc', cursor: 'pointer', whiteSpace: 'nowrap' }}
            >
              Add me
            </button>
          </div>
          <Btn
            disabled={busy || voterPk.length !== 64}
            onClick={() => wrap(async () => {
              const pk = Uint8Array.from(voterPk.match(/.{2}/g)!.map(b => parseInt(b, 16)));
              await api.addVoter(pk);
              setVoterPk('');
            })}
          >
            Add Voter
          </Btn>
        </Card>
      )}

      {/* Phase advance buttons (anyone can call when deadline passes) */}
      {state.phase === LedgerState.commit && (
        <Card title="Advance phases">
          <Btn disabled={busy} onClick={() => wrap(() => api.advanceToReveal())}>
            Advance → Reveal
          </Btn>
          <p style={{ fontSize: 12, color: '#888', marginTop: 8 }}>
            Only works after the commit deadline: {formatDeadline(state.commitDeadline)}
          </p>
        </Card>
      )}
      {state.phase === LedgerState.reveal && (
        <Card title="Advance phases">
          <Btn disabled={busy} onClick={() => wrap(() => api.advanceToFinal())}>
            Advance → Final
          </Btn>
          <p style={{ fontSize: 12, color: '#888', marginTop: 8 }}>
            Only works after the reveal deadline: {formatDeadline(state.revealDeadline)}
          </p>
        </Card>
      )}

      {/* ─── Voter panel ───────────────────────────────────────────────── */}
      {state.phase === LedgerState.commit && state.voterStatus === 'eligible' && (
        <Card title="Cast your vote (commit phase)">
          <p style={{ fontSize: 14, color: '#aaa', marginBottom: 8 }}>
            Your vote is hidden. Only its cryptographic commitment is stored on-chain.
            You will reveal it in the next phase.
          </p>
          <Btn disabled={busy} onClick={() => wrap(() => api.voteCommit(true))}>Vote YES</Btn>
          <Btn disabled={busy} onClick={() => wrap(() => api.voteCommit(false))}>Vote NO</Btn>
        </Card>
      )}

      {state.phase === LedgerState.reveal && state.voterStatus === 'committed' && (
        <Card title="Reveal your vote">
          <p style={{ fontSize: 14, color: '#aaa', marginBottom: 8 }}>
            Your ZK proof shows that your revealed vote matches the earlier commitment —
            without exposing your secret key.
          </p>
          <Btn disabled={busy} onClick={() => wrap(() => api.voteReveal())}>Reveal Vote</Btn>
        </Card>
      )}

      {/* ─── Tally ─────────────────────────────────────────────────────── */}
      {state.phase === LedgerState.final && (
        <Card title="Final tally">
          <div style={{ display: 'flex', gap: '2rem', fontSize: 32, padding: '1rem 0' }}>
            <span style={{ color: '#00b894' }}>✓ YES: {String(state.yesCount)}</span>
            <span style={{ color: '#e84393' }}>✗ NO: {String(state.noCount)}</span>
          </div>
          <p style={{ color: '#aaa', fontSize: 14 }}>
            {state.yesCount > state.noCount
              ? 'Proposal passed.'
              : state.yesCount < state.noCount
                ? 'Proposal rejected.'
                : 'Tie — no majority.'}
          </p>
        </Card>
      )}

      {err && <p style={{ color: '#e84393', marginTop: 8 }}>Error: {err}</p>}
    </div>
  );
}

// ─── Root app ─────────────────────────────────────────────────────────────────

export function App() {
  const [screen, setScreen] = useState<Screen>({ tag: 'home' });

  if (screen.tag === 'deploying' || screen.tag === 'joining') {
    return (
      <div style={{ textAlign: 'center', paddingTop: '4rem' }}>
        <p style={{ color: '#9b8ae8' }}>
          {screen.tag === 'deploying' ? 'Deploying contract…' : 'Joining contract…'}
        </p>
      </div>
    );
  }

  if (screen.tag === 'connected') {
    return <VotingScreen api={screen.api} />;
  }

  return <HomeScreen onConnect={setScreen} />;
}
Enter fullscreen mode Exit fullscreen mode

api.state$ is an RxJS Observable, and the useEffect in VotingScreen subscribes to it so the UI rerenders on every new block. The wrap helper sets and clears the busy flag around every async action so buttons disable themselves during in-flight transactions. Deadlines passed to api.initialize are computed as BigInt(Math.floor(Date.now() / 1000)) plus an offset in seconds to match what blockTimeGte expects on-chain. For quick testing, use 0.02 hours (72 seconds) as the commit window and 0.04 hours (144 seconds) as the reveal window.


Step 23: Run the frontend

Install all workspace dependencies and start the Vite dev server.

npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:5173 in Chrome with the 1AM wallet extension active. The full usage flow is:

  1. Wait for the commit deadline to pass, then click Advance → Reveal.
  2. Click Reveal Vote to submit your ZK proof and increment the tally.
  3. Wait for the reveal deadline, click Advance → Final, and view the result.

Tip: For a quick test cycle, set commit window to 0.02 hours (about 72 seconds) and reveal window to 0.04 hours (about 144 seconds) in the Initialize form.

Warning: If you see "Commit phase not over yet" when clicking Advance → Reveal, the commit deadline has not passed. This is the time-lock working correctly, not a bug.

  • Click Connect 1AM & Deploy to deploy a new contract.

  • After the contract deploys (15-30 seconds for the transaction to finalize), click Initialize with a proposal topic and short time windows for testing.

  • Click Add me to pre-fill your own voter public key, then click Add Voter.

  • Click Vote YES or Vote NO in the commit panel.

  • Wait for the commit deadline to pass, then click Advance → Reveal.

  • Click Reveal Vote to submit your ZK proof and increment the tally.

  • Wait for the reveal deadline, click Advance → Final, and view the result.


Conclusion

You have built a complete commit-reveal voting DApp on Midnight from scratch. The Compact smart contract enforces every privacy invariant in ZK. Voters prove eligibility with Merkle paths, prevent double-voting with domain-separated nullifiers, and link commits to reveals with persistentCommit without ever exposing their ballot on-chain. The TypeScript API layer wraps the compiled contract in a reactive Observable, keeping the React UI in sync with the live on-chain state. The test suite exercises the entire state machine in memory with no live network, and the Vite frontend serves the ZK key material that the browser sends to the 1AM wallet's remote prover, which generates proofs and submits transactions with sponsored fees so you never need tDUST or a local proof server.

Top comments (0)