Skip to main content
import "github.com/populist/protocol/zkp"
The ZK embodiment is the high-assurance optional tier. The preferred non-ZK tier uses a per-poll voter Ed25519 device keypair with Didit device attestation, which provides oracle prevention without ZK overhead and supports biometric recovery on any device. Use the ZK embodiment when even a read-only linkage store operator must be unable to correlate identity to participation.

What the circuit proves

The NullifierCircuit is a Groth16 circuit over BN254 that proves two statements simultaneously. person_secret is the private witness — it never leaves the voter’s device.
witness (private): person_secret, commitment, didit_commitment_sig
public:            nullifier, poll_id

constraints:
  MiMC(person_secret)          == commitment   // Didit-attested binding
  MiMC(person_secret, poll_id) == nullifier    // per-poll dedup key
Critical: commitment and didit_commitment_sig are private circuit witness inputs. They must never appear in the transaction payload or on-chain in any form. Placing them in the transaction allows the IDV provider to correlate nullifier → commitment → identity, defeating the ZK anonymity guarantee. Only nullifier, the Groth16 proof bytes, voter_pub_key, voter_sig, ballot_nonce, and choices travel on-chain.

Voter enrollment

// On voter device
commitment, err := zkp.ComputeCommitment(personSecret)
// commitment = MiMC(personSecret)

// Send commitment to Didit for biometric attestation
// Didit returns Ed25519 sig over SHA-256(commitment ‖ poll_id)
// Store didit_commitment_sig locally — it becomes a private circuit witness at vote time

Generating a proof

// On voter device, at vote time
nullifier, err := zkp.ComputeNullifier(personSecret, pollID)
// nullifier = MiMC(personSecret, pollID)

// Generate Groth16 proof
// private witnesses: personSecret, commitment, didit_commitment_sig
// public inputs: nullifier, pollID
// proving key loaded from nullifier_pk.bin
The proof and nullifier are assembled into tx.BallotCastData. commitment and didit_commitment_sig stay in the circuit — they are not included in the transaction struct.

Verifying a proof (canonical runtime)

verifier, err := zkp.NewVerifier(vkFile) // load from nullifier_vk.bin
err = verifier.Verify(proofBytes, nullifierHex, pollID)
verifier.Verify returns nil if the proof is valid, an error otherwise. Called by state.validateBallotCast before any ballot state mutation. The canonical runtime never sees commitment or didit_commitment_sig.

Trusted setup

# Development (single-party, NOT production)
go run ./cmd/zk-setup
# → nullifier_pk.bin  (proving key)
# → nullifier_vk.bin  (verifying key)
Production deployments must perform a Groth16 multi-party computation (MPC) ceremony to generate the proving and verifying keys. Single-party setup allows proof forgery by anyone who knows the toxic waste. Use snarkjs phase2 or the Hermez ceremony infrastructure.

Circuit compilation

cs, err := zkp.Compile()
// cs is the compiled R1CS constraint system over BN254
// used as input to the Groth16 trusted setup

Recovery trade-off

The ZK embodiment is device-bound: person_secret must be present to generate a proof. If the voter loses their device, re-enrollment requires an out-of-band backup mechanism (encrypted keychain export, hardware key backup, etc.). The preferred device-keypair embodiment does not have this limitation — recovery is anchored to biometric re-authentication with the IDV provider on any device, with no secret to remember or backup.