The ZK Tic-Tac-Toe post showed how to add privacy to a game. This post goes deeper into the proof system itself: how any Petri net—not just tic-tac-toe—can be proven valid in zero knowledge.
The key insight: the circuit doesn't know anything about the application. It proves valid Petri net transitions. The game logic, workflow rules, or token transfer semantics live entirely in the net topology.
For an interactive introduction: Zero-Knowledge Proofs for Petri Nets
Every ZK proof in this system proves one statement:
"I know a valid marking that, when transition T fires, produces the claimed next state."
More precisely:
The verifier sees only the two state roots and the transition ID. The actual token counts remain hidden.
| What | Who Sees It | |
|---|---|---|
| Public | Pre-state root (hash) | Everyone |
| Public | Post-state root (hash) | Everyone |
| Public | Transition ID | Everyone |
| Private | Pre-marking (all token counts) | Prover only |
| Private | Post-marking (all token counts) | Prover only |
The state roots are commitments. They bind the prover to a specific marking without revealing it.
The gnark circuit for a Petri net transition has five steps. Each step adds constraints that the prover must satisfy.
Compute the MiMC hash of every place's token count and assert it equals the public pre-state root:
preRoot := petriMimcHash(api, c.PreMarking[:])
api.AssertIsEqual(preRoot, c.PreStateRoot)
This binds the private marking to the public commitment. The prover can't lie about what state they started from—the hash locks them in.
Same check for the post-state:
postRoot := petriMimcHash(api, c.PostMarking[:])
api.AssertIsEqual(postRoot, c.PostStateRoot)
Now both states are committed.
From the transition ID, look up the Petri net topology to build a delta vector. For each transition in the net, conditionally apply its effect:
for t := 0; t < NumTransitions; t++ {
isThis := api.IsZero(api.Sub(c.Transition, t))
for _, p := range Topology[t].Inputs {
deltas[p] = api.Sub(deltas[p], isThis)
}
for _, p := range Topology[t].Outputs {
deltas[p] = api.Add(deltas[p], isThis)
}
}
The isThis variable is 1 for the selected transition and 0 for all others. This multiplexes over all transitions without branching—arithmetic circuits can't branch, so we compute all possibilities and select.
For every place, check that the post-marking equals the pre-marking plus the delta:
for p := 0; p < NumPlaces; p++ {
expected := api.Add(c.PreMarking[p], deltas[p])
api.AssertIsEqual(c.PostMarking[p], expected)
}
This ensures the marking changed exactly as the topology dictates. No extra tokens appeared. No tokens vanished. The transition fired correctly.
For every input place of the selected transition, the pre-marking must have at least one token:
diff := api.Sub(c.PreMarking[p], isInput)
api.ToBinary(diff, 8)
The trick: ToBinary decomposes a value into bits. If the value were negative (insufficient tokens), it would wrap to a huge field element and the bit decomposition would fail—the proof can't be generated.
This is a standard gnark pattern for range checks: decompose into bits to prove non-negativity.
The hash function matters. SHA-256 needs thousands of boolean operations inside a circuit. MiMC (Minimal Multiplicative Complexity) is designed for arithmetic circuits—it's defined over the same finite field the circuit uses.
func petriMimcHash(api frontend.API, values []frontend.Variable) frontend.Variable {
h, _ := mimc.NewMiMC(api)
for _, v := range values {
h.Write(v)
}
return h.Sum()
}
MiMC costs roughly 300 constraints per hash, compared to ~25,000 for SHA-256. For a circuit that hashes the marking twice (pre and post), this difference is significant.
The state root is:
stateRoot = MiMC(marking[0], marking[1], ..., marking[N])
To find the actual token counts from a state root, an attacker would need to invert MiMC—computationally infeasible.
The ZK Tic-Tac-Toe implementation uses two circuits, both encoding the full 33-place Petri net:
PetriTransitionCircuit proves a move is legal. It's the general circuit described above—works for any transition in the net. Used for every move.
PetriWinCircuit proves a player has won. The win condition is already in the Petri net topology (win-detection transitions fire when three cells align). The circuit checks that the win_x or win_o place has a token:
func (c *PetriWinCircuit) Define(api frontend.API) error {
root := petriMimcHash(api, c.Marking[:])
api.AssertIsEqual(root, c.StateRoot)
winTokens := frontend.Variable(0)
for p := 0; p < NumPlaces; p++ {
isWinnerPlace := api.IsZero(api.Sub(c.Winner, p))
winTokens = api.Add(winTokens, api.Mul(c.Marking[p], isWinnerPlace))
}
api.ToBinary(api.Sub(winTokens, 1), 8) // >= 1 token
return nil
}
Because the game logic lives in the net topology, the circuit doesn't know anything about tic-tac-toe. Change the topology constants and you get ZK proofs for a different game.
The arcnet project uses the same pattern for blockchain token transfers. An ERC-20 transfer is a Petri net transition:
balances[from] is an input place (tokens consumed)balances[to] is an output place (tokens produced)balances[from] >= amount is the enabledness checkThe circuit proves the transfer is valid using a Merkle tree for account balances:
func (c *TransferCircuit) Define(api frontend.API) error {
// Guard: balance >= amount
diff := api.Sub(c.BalanceFrom, c.Amount)
api.ToBinary(diff, 64)
// Verify Merkle inclusion
leaf := mimcHash(api, c.From, c.BalanceFrom)
current := leaf
for i := 0; i < 20; i++ {
api.AssertIsBoolean(c.PathIndices[i])
left := api.Select(c.PathIndices[i], c.PathElements[i], current)
right := api.Select(c.PathIndices[i], current, c.PathElements[i])
current = mimcHash(api, left, right)
}
api.AssertIsEqual(current, c.PreStateRoot)
return nil
}
The 20-level Merkle proof means the full state doesn't need to live on-chain. Only state roots are published. The bridge contract verifies the proof and updates the root.
| Application | Places | Transition | Enabledness |
|---|---|---|---|
| Tic-Tac-Toe | 33 board+game states | Player move | Cell empty, correct turn |
| ERC-20 Transfer | Account balances | Transfer | Sufficient balance |
| Workflow | Process stages | State change | Required approvals |
| Voting | Ballot states | Cast vote | Eligible, hasn't voted |
Same circuit structure. Different topology.
A complete proof cycle:
Groth16 is the proving system used by gnark. It requires a one-time trusted setup per circuit but produces:
The trusted setup is performed once when the circuit is compiled. The resulting proving and verification keys are reused for every proof.
| Property | Groth16 | PLONK | STARKs |
|---|---|---|---|
| Proof size | ~128 B | ~400 B | ~50 KB |
| Verification | ~2ms | ~5ms | ~50ms |
| Trusted setup | Per-circuit | Universal | None |
| Quantum-safe | No | No | Yes |
For our use case—small circuits with many proofs—Groth16's compact proofs and fast verification dominate.
The circuit size determines proving time. For the tic-tac-toe Petri net (33 places, 35 transitions):
| Component | Constraints | Purpose |
|---|---|---|
| MiMC hash (pre) | ~300 | State root verification |
| MiMC hash (post) | ~300 | State root verification |
| Transition delta | ~2,300 | Topology multiplexing |
| Marking assertion | ~33 | post = pre + delta |
| Enabledness | ~264 | Bit decomposition per place |
| Total | ~3,200 | Full transition proof |
Proof generation takes ~100ms on commodity hardware. Verification takes ~2ms. The circuit compiles once; proofs are generated per-move.
The most important property of this approach: the Petri net topology is baked into the circuit as constants. The Topology array maps each transition to its input and output places:
var Topology = [NumTransitions]struct {
Inputs []int
Outputs []int
}{
{Inputs: []int{0, 1}, Outputs: []int{2}}, // bind
{Inputs: []int{2}, Outputs: []int{0, 1}}, // unbind
{Inputs: []int{2}, Outputs: []int{3, 1}}, // catalyze
// ...
}
This means:
Define() method works for any net.The Petri net is the specification. The circuit is the verifier. The proof is the attestation that the specification was followed.
A common misconception: ZK doesn't make the transition ID private. The verifier sees which transition fired. What's hidden is the state—the full marking of every place.
In tic-tac-toe, the opponent knows a move was made (transition X_center, say) but doesn't see the full board state. In a token transfer, the network knows a transfer happened but doesn't see individual balances.
For applications where even the transition must be hidden, an additional layer of indirection is needed—proving that some valid transition fired without revealing which one. This is possible but increases circuit complexity significantly.
Play the ZK Tic-Tac-Toe demo to see proofs generated in real time. Each move fires a Petri net transition, generates a gnark proof, and displays the proof structure. You can inspect state roots, verify the chain of proofs, and export Solidity calldata for on-chain verification.
For the interactive explainer: Zero-Knowledge Proofs for Petri Nets
| Concept | How It Works |
|---|---|
| State commitment | MiMC hash of marking hides token counts |
| Topology as constants | Petri net structure baked into circuit |
| Enabledness via bits | ToBinary fails on negative values |
| Transition multiplexing | Compute all, select one with IsZero |
| Groth16 proofs | ~128 bytes, ~2ms verification |
| Application-agnostic | Same circuit for any Petri net |
A Petri net is already a formal specification of valid state transitions. A ZK circuit turns that specification into a cryptographic proof system. The prover demonstrates they followed the rules; the verifier confirms it without seeing the state.
The circuit doesn't encode tic-tac-toe, or token transfers, or workflow rules. It encodes Petri net semantics: hash the marking, compute the delta from topology, assert the change, check enabledness. The application logic is in the net. The privacy is in the proof.
One circuit structure. Any Petri net. Provably correct, privately verified.
For gnark documentation: docs.gnark.consensys.io
For the base game model: Tic-Tac-Toe Model
For the arcnet bridge: github.com/pflow-xyz/arcnet