← Home
petri-net zk voting groth16 gnark governance solidity

ZK Polls: Voting as a Visible State Machine

Every voting system is a state machine — register voters, accept ballots, prevent doubles, tally results. The lifecycle is always the same. The difference is whether the rules are visible.

The bitwrap capstone post showed how Petri nets compile to ZK circuits and Solidity contracts from a single model. bitwrap.io/poll applies that pipeline to voting: four states, three transitions, one model that produces both the proof system and the on-chain contract.

The Model

ZK Poll Petri Net Model

A ZK poll is four states and three transitions:

States:
  voterRegistry   map[uint256]uint256   (commitment → weight)
  nullifiers      map[uint256]bool      (prevents double-voting)
  tallies         map[uint256]uint256   (choice → count)
  pollConfig      uint256               (0=pending, 1=active, 2=closed)

Transitions:
  createPoll      guard: pollConfig == 0
  castVote        guard: pollConfig == 1 && nullifiers[nullifier] == false
  closePoll       guard: pollConfig == 1

That's the entire voting protocol. We can load it in the editor, drag the pieces around, simulate vote flows, and watch tokens move through the net. The model is stored as JSON-LD with a content-addressed identity — it's its own specification document.

What the ZK Circuit Proves

The VoteCast circuit has five public inputs and proves five things:

Constraint What it means
leaf = mimcHash(voterSecret, weight) Voter constructed a valid commitment
merkleRoot == voterRegistryRoot Voter is in the registry (20-level Merkle proof)
nullifier == mimcHash(voterSecret, pollId) Nullifier is correctly bound (unique per poll, unlinkable across polls)
voteChoice < maxChoices Choice is within the poll's valid range
voteCommitment == mimcHash(voterSecret, voteChoice) Choice is committed without being revealed

The circuit compiles to ~14,600 Groth16 constraints on BN254. Proving takes 2-5 seconds client-side via WASM.

VoteCast Proof Flow

Secret Ballots

The vote choice is hidden behind a blinded commitment: mimcHash(voterSecret, voteChoice). Since voterSecret is ~248 bits of entropy and never leaves the browser, the commitment can't be brute-forced — even though there are only 256 possible choices.

Neither the server nor the chain ever sees the plaintext choice. We tally at vote time by extracting the choice transiently during proof re-verification, incrementing an aggregate counter, and discarding the value. On disk, individual vote records contain only the nullifier and the blinded commitment. The tally file has totals with no voter linkage.

On-chain, the contract stores voteCommitments[nullifier] — a mapping from nullifier to blinded hash. The choice is mathematically hidden inside the commitment.

Event Sourcing Through the Net

Poll state isn't tracked in an ad-hoc database. Each action — createPoll, castVote, closePoll — is appended to an event log. We derive the current state by replaying the log through the vote template's execution runtime:

State(t) = fold(apply, initialState, events[0..t])

This is the same pattern every project in the bitwrap ecosystem uses. The Petri net runtime processes arcs (consuming from input states, producing at output states) and the resulting snapshot contains the tallies, nullifier set, and poll lifecycle state. No separate counter, no derived table — just the model executing its own transitions.

The On-Chain Contract

The Solidity contract is generated from the same schema. For the castVote function, the codegen produces a ZK-verified variant:

function castVote(
    uint256[2] calldata _pA,
    uint256[2][2] calldata _pB,
    uint256[2] calldata _pC,
    uint256 _nullifier,
    uint256 _voteCommitment,
    uint256 _pollId
) external {
    require(pollConfig == 1, "poll not active");
    require(!nullifiers[_nullifier], "already voted");

    uint256[5] memory pubSignals;
    pubSignals[0] = _pollId;
    pubSignals[1] = voterRegistryRoot;
    pubSignals[2] = _nullifier;
    pubSignals[3] = _voteCommitment;
    pubSignals[4] = maxChoices;
    require(
        verifier.verifyProof(_pA, _pB, _pC, pubSignals),
        "invalid ZK proof"
    );

    nullifiers[_nullifier] = true;
    voteCommitments[_nullifier] = _voteCommitment;
}

The verifier is a Groth16 verifier contract auto-generated by gnark from the circuit's verifying key. Download the complete Foundry bundle from bitwrap.io/api/bundle/vote — contract, verifier, tests, and deploy script. All 8 Foundry tests pass.

Two Paths, One Model

The same Petri net model produces two deployment paths:

Off-chain polls — create at bitwrap.io/poll, share a link, voters prove eligibility via ZK proof, server verifies and tallies. No blockchain required.

On-chain governance — download the Foundry bundle, deploy to any EVM chain. The contract enforces the same rules with on-chain proof verification.

Both paths trace back to the same four states and three transitions. The model is the single source of truth.

Most voting systems separate the specification from the implementation. Snapshot delegates strategy logic to off-chain code. MACI uses ZK proofs but requires a trusted coordinator to decrypt and tally. Vocdoni runs its own L2 chain with protocol-level voting rules.

The difference here is structural: the Petri net model is both the specification and the implementation. The visual graph, the ZK circuit, and the Solidity contract are three renderings of the same four states and three transitions. There is no translation layer where the spec and the code can diverge.

Try It


Voting is a state machine — four places, three transitions, one topology. The ZK circuit proves the transition was valid. The Solidity contract enforces it on-chain. The model is the spec, and the spec is the implementation.

×

Follow on Mastodon