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