Prerequisite: Zero-Knowledge Proofs for Petri Nets covers how a transition and its guard become circuit constraints; this post assumes it.
vote.bitwrap.io ran anonymous polls (the hosted app has since been archived; the code lives in stackdump/bitwrap-io). Create a poll, share a link, collect votes. Each voter proves they're eligible with a zero-knowledge proof — "I'm on the list" without saying who on the list — and the rules the proof enforces are a Petri net small enough to read in one sitting.
The diagram is the net as it was when this post went up: four places — voterRegistry (who may vote, as Merkle-backed commitments), nullifiers (who has already voted, without identities), tallies (count per choice) and pollConfig (pending, active, closed) — and three transitions. createPoll is guarded on pollConfig == 0; castVote on an active poll and an unused nullifier, and it writes the nullifier and adds to a tally; closePoll ends it. The template in the repo today, erc/vote.go, adds a fifth place and a fourth transition: registerVoter puts a token in registrySlots and castVote consumes one, so in the net castVote is disabled once every registered voter has used theirs. That template is the one the server loads (?template=vote in the editor), and it is the source the circuit and the contract are generated from.
When a voter casts a ballot, the browser builds a Groth16 proof against VoteCastCircuit, which is generated from the castVote action's declared ZK obligations. Its public inputs are the poll id, the registry's Merkle root, the nullifier, the vote commitment and the number of choices, and its Define checks four things: the voter's leaf, mimcHash(voterSecret, voterWeight), sits under that root along a depth-20 path; the nullifier equals mimcHash(voterSecret, pollId), so it is unique per poll and doesn't link a voter across polls; the choice fits in 8 bits and is below maxChoices; and the commitment equals mimcHash(voterSecret, voteChoice).
"Hasn't voted yet" is not one of them. The original version of this post listed it as a fifth thing the proof guarantees, but the circuit can't see the nullifier set — that check happens outside it, in the server's vote store (which rejects a reused nullifier) and in the generated contract's require(!nullifiers[_nullifier], "already voted"). The proof establishes that the nullifier is the right one for this voter and this poll; the state machine is what refuses it twice, which is the split you'd expect, since nullifiers is a place and the guard reads it.
The post originally quoted about 14,600 constraints and 2–5 seconds to prove in the browser. The prover reports the constraint count when it compiles a circuit, but no test in the repo pins either number, and neither was re-measured for this revision; take them as the March figures.
While a poll is open the commitment is all anyone sees. The commitment's hiding comes from voterSecret — 31 random bytes (248 bits) when no wallet is used, otherwise a 248-bit slice of a wallet signature — because with only a handful of choices, anyone who has the secret can recover the choice by hashing each option. The results endpoint returns only a vote count until the poll closes, so an observer can't diff tallies after each vote to correlate timing.
What the original post said next — that the choice never leaves the browser in plaintext and the server never learns who picked what — isn't what the code does for the first two poll schemas. In v1 and v2 the tally is built after close: each voter posts their choice and voterSecret to /api/polls/{id}/reveal, the server checks mimcHash(secret, choice) against the stored commitment, and it keeps a (nullifier, choice, secret) bundle for the tally proof (internal/server/polls.go). The server then holds nullifier→choice; whether it can get from there to a person depends on how registration ties a commitment to a wallet. v1's secret is the raw signature slice, so anyone holding that signature can recompute it; v2 mixes in a voter-chosen nonce kept in the browser and the downloaded vote backup. The homomorphic-tally spec calls this the post-close deanonymization gap.
v3 polls are how the repo closes it. A v3 voter submits one additively homomorphic ElGamal ciphertext per choice with a proof (voteCastHomomorphic_8) that the vector is one-hot and the ciphertexts well formed; the poll creator decrypts only the aggregate and publishes a decryption proof (tallyDecrypt_8); and /reveal on a v3 poll returns 404. The handlers are in internal/server/polls_v3.go. The spec's header still says "design (not yet implemented)", while the handlers, generated circuits and tests are in the tree; we read the code, but didn't run the tests here.
Every action — create, vote, close — is appended to the poll's event log, and the tallies the results endpoint returns for a closed v1/v2 poll are derived by replaying that log through a fresh runtime built from the same vote template (internal/server/pollstate.go: State(t) = fold(apply, initialState, events[0..t])). That's narrower than the original claim that poll state "isn't sitting in a database." The poll record (title, choices, status) and the vote records are stored on their own, and the replay runs with constraint checking off because guards are enforced at the API layer. What replay gives you is a tally anyone can recompute from the log, not a system whose only state is the log.
The same template generates a Solidity contract (BitwrapZKPoll.sol). Its castVote is a vote-specific case in the generator (solidity/codegen.go): it requires an unused nullifier, verifies the Groth16 proof against the same five public inputs, marks the nullifier and stores the commitment, all in one transaction. The server packages contract, verifier, tests and deploy script as a Foundry bundle at /api/bundle/vote, and the v3 settlement contract at /api/bundle/vote-v3; the public host that served them is gone, so today that means running bitwrap-io yourself.
The post said "all 8 Foundry tests pass." Two qualifications. The tests come from solidity/testgen.go and run against a MockVerifier whose verifyProof always returns true, so they exercise the state machine — a double vote reverts, a vote on an inactive poll reverts, the guards hold — not proof verification. And eight was the count for the three-transition net; reading the generator against the current template gives nine (one per action, one per guard plus a second for castVote, one for the views). Not re-run.
Snapshot delegates voting strategy to off-chain scripts. MACI uses ZK proofs but relies on a coordinator who can decrypt individual messages to tally. Vocdoni runs its own chain. The claim here is that the diagram, the circuit and the contract are three views of one declaration, and in bitwrap-io they do come from one: erc/vote.go is the schema, vote_gen.go is marked "generated by bitwrap synth" from it, and the contract is generated from it too. Two seams stay visible, though. The castVote ZK obligations aren't arcs — the template's own comment says they "aren't arc-expressible" and declares them as nullifier, commitment and range operations — and the contract's castVote is hand-shaped inside the generator. v3 also moves part of the way toward MACI: its creator holds the key that decrypts the aggregate, though not any single ballot.
VoteCastCircuit, registered in prover/circuits.goFor readers of the March version (2026-10-25):
registerVoter, registrySlots); the diagram still shows the original four and three.vote_gen.go, where VoteCastCircuit is defined.Related: Zero-Knowledge Proofs for Petri Nets · Bitwrap: Petri Nets as ZK Containers