It's for those who have heard of ZKP but never known the real usecase.
Before We Start
Zero-Knowledge Proof (ZKP) is a technology that proves the truth of a statement without revealing the underlying information. A common example is proving you know a password without showing it.
This article will use the Rust's fast recursive SNARK framework Plonky2 to build 3 ZKP circuits step by step. By the end of this article, you will understand the following:
- How ZKP Circuits Work β Constraints, Witness, Public Inputs
- Hash Chain Proof β Proving the order of data processing
- State Transition Proof β Proving the update of Merkle tree
π§© Core Model: Circuit, Prover, Verifier
Let's understand the core 3 models of ZKP:
| Entity | What | Example |
|---|---|---|
| Circuit π | Define mathematical constraints | Template of Exam Questions β Define how the answer looks like |
| Prover π§βπ» | Generate proof with secret inputs | Student who solves the exam |
| Verifier β | Verify validity of proof with public inputs | Grader who checks the answer without seeing the solution |
Core Concept: The circuit only defines the rules β it doesn't solve them. The prover supplies the secret answer. The verifier checks that the rules were followed without ever seeing that answer.
π Real World Flow of ZKP: Who Proves, Who Verifies
Let's see how ZKP is used in the real world:
Flow Chart: Outline of Proof and Verification
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 1. Build Circuit (Developer does it once) β
β β
β Define constraints with CircuitBuilder β build() β CircuitData β
β β
β Output: β
β γ»CircuitData (circuit definition) β
β γ»VerifierCircuitData (verifier key) β made public β
ββββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββ΄βββββββββββββββ
βΌ βΌ
βββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββ
β 2. Prove β β 4. Verify β
β β β β
β = The party who holds β β Verifier = Only sees β
β the data β β the result β
β β β β
β γ»Has secret inputs β β γ»Receives only the proof β
β γ»Sets secrets in β β γ»Sees only public inputs β
β PartialWitness β β γ»Knows nothing about β
β γ»data.prove(pw) β β secret inputs β
β generates proof β β γ»data.verify(proof) β
β β β β Ok / Error β
βββββββββββββββ¬ββββββββββββββ βββββββββββββββ¬ββββββββββββββ
β β
β 3. Send proof β
β proof + public_inputs β
ββββββββββββββββΊβββββββββββββββββ
Concrete Example: Private Database Update
βββββββββββββββββββββ βββββββββββββββββββββ
β Prover β β Verifier β
β (Data owner) β β (Auditor/Contract)β
ββββββββββ¬βββββββββββ ββββββββββ¬βββββββββββ
β β
β 1. Update the data β
β (e.g. change a balance) β
β * This is secret β
β β
β 2. Generate ZK proof β
β prove(secret inputs) β
β β obtain proof β
β β
β 3. Send proof ββββββββββββββββββββββββΊ β
β (contains no secret information) β
β β
β β 4. verify(proof)
β β Public inputs only:
β β γ»old_root β new_root
β β γ»No idea what changed
β β β Ok β
β β
β β 5. Update state
β β (record new_root)
π Point: What Is Public Or Not
| Information | Prover knows | Verifier sees |
|---|---|---|
| What changed (value, position) | β | β |
| Merkle path (sibling nodes) | β | β |
| Old state root (old_root) | β | β |
| New state root (new_root) | β | β |
| Proof | β | β |
The verifier only confirms that the state root transition is correct. It never learns what was updated. π€«
βοΈ Setup: Plonky2 Basic Structure
All Plonky2 circuits starts from same 3 type definitions.
use plonky2::field::goldilocks_field::GoldilocksField;
use plonky2::plonk::config::PoseidonGoldilocksConfig;
const D: usize = 2; // Extension degree (always 2 in Plonky2)
type F = GoldilocksField; // Finite field for arithmetic
type C = PoseidonGoldilocksConfig; // Hash config (Poseidon over Goldilocks)
And all circuits follows the pattern of the same 3 steps
1. Build: CircuitBuilder β Define constraints β builder.build() β CircuitData
2. Prove: PartialWitness β Set secret inputs β data.prove(pw) β Proof
3. Verify: data.verify(proof) β Ok(()) or Error
Let's make it run! π
π Example 1: "Hello World" Circuit
This first circuit proves "I know two numbers that add up to a specific sum"
Sum is public (Verifier can see it). Two numbers are secret (Verifier can't see them).
use plonky2::{
field::{goldilocks_field::GoldilocksField, types::Field},
iop::{target::Target, witness::{PartialWitness, WitnessWrite}},
plonk::{
circuit_builder::CircuitBuilder,
circuit_data::{CircuitConfig, CircuitData},
config::PoseidonGoldilocksConfig,
proof::ProofWithPublicInputs,
},
};
const D: usize = 2;
type F = GoldilocksField;
type C = PoseidonGoldilocksConfig;
pub struct SimpleCircuit {
data: CircuitData<F, C, D>,
a: Target, // Secret input slot
b: Target, // Secret input slot
sum: Target, // Made public
}
impl SimpleCircuit {
pub fn new() -> Self {
let config = CircuitConfig::standard_recursion_config();
let mut builder = CircuitBuilder::<F, D>::new(config);
// Create slots for secret inputs (called "Targets" in Plonky2)
let a = builder.add_virtual_target();
let b = builder.add_virtual_target();
// Define constraint: sum = a + b
let sum: Target = builder.add(a, b);
// Make sum visible to the verifier
builder.register_public_input(sum);
// Compile the circuit
let data = builder.build::<C>();
Self { data, a, b, sum }
}
pub fn prove(&self, a: u64, b: u64) -> ProofWithPublicInputs<F, C, D> {
let mut pw = PartialWitness::new();
pw.set_target(self.a, F::from_canonical_u64(a));
pw.set_target(self.b, F::from_canonical_u64(b));
self.data.prove(pw).unwrap()
}
pub fn verify(&self, proof: ProofWithPublicInputs<F, C, D>) -> anyhow::Result<()> {
Ok(self.data.verify(proof)?)
}
}
UsageοΌ
let circuit = SimpleCircuit::new();
// Prover: "I know two numbers that add up to 8"
let proof = circuit.prove(3, 5);
// Verifier: "Is this proof valid?" (can't see 3 and 5 β only sees 8)
assert_eq!(proof.public_inputs[0], F::from_canonical_u64(8));
assert!(circuit.verify(proof).is_ok());
π What Happened?
Build Phase Prove Phase Verify Phase
ββββββββββββββββββ ββββββββββββββββββ ββββββββββββββββ
β builder.add β β pw.set_target β β data.verify β
β (a, b) β β (a=3, b=5) β β (proof) β
β β β β β β
β Define β β β Set secret β β β Verify proof β
β constraint: β β inputs β β β
β "sum = a + b" β β β generate β β Visible: β
β β β proof β β sum=8 only β
β * No compute β β β β β
β Rules only β β a=3, b=5 are β β Hidden: β
β β β NOT in proof β β a=3, b=5 β
ββββββββββββββββββ ββββββββββββββββββ ββββββββββββββββ
-
Build Phase:
builder.add(a, b)does not computea + b. It creates a constraint that says "sum must equal a + b". The circuit is a template of rules. -
Prove Phase: When the prover sets
a=3, b=5, Plonky2 generates a cryptographic proof that these values satisfy the constraints. -
Verify Phase: The verifier checks the proof. It sees
sum=8in the public inputs, but has no way of knowinga=3orb=5.
Key point: A
Targetin Plonky2 is not a variable β it's a "wire" in the circuit.builder.add(a, b)computes nothing. It just connects two inputs to an addition gate.
π Example 2: Hashchain β Proving Ordered Processing
Alright let's create something more practical now.
It proves that the final hash is calculated from the underlying items in the right processing order.
Concept
state_0 = hash(0)
state_1 = hash(state_0 || item_0)
state_2 = hash(state_1 || item_1)
state_3 = hash(state_2 || item_2) β Final hash (public)
Each step feeds the previous hash into the next.
If any item in the chain is modified, the final hash changes.
The prover proves the chain was computed correctly; the verifier only checks the final hash.
Proof and Verification Flow
Prover Verifier
ββββββ ββββββββ
Holds secret items:
items = [item_0, item_1, item_2]
β
Set secret inputs in circuit:
pw.set_target(items[0], item_0)
pw.set_target(items[1], item_1)
pw.set_target(items[2], item_2)
β
Generate proof:
proof = data.prove(pw)
β
Send proof βββββββββββββββββββββββββββΊ Receive proof
(items are NOT included in proof) β
Extract final hash from
proof.public_inputs
β
data.verify(proof) β Ok β
β
"Does the final hash match
the expected value?"
β This is business logic (outside the circuit)
Code
use crate::utils::poseidon_hash_out::PoseidonHashOutTarget;
pub struct HashChainCircuit {
pub data: CircuitData<F, C, D>,
pub items: Vec<Target>,
}
impl HashChainCircuit {
pub fn new(chain_length: usize) -> Self {
let config = CircuitConfig::standard_recursion_config();
let mut builder = CircuitBuilder::<F, D>::new(config);
// Create a target (slot) for each item in the chain
let items: Vec<Target> = (0..chain_length)
.map(|_| builder.add_virtual_target())
.collect();
// Start from hash(0)
let zero = builder.zero();
let mut state = PoseidonHashOutTarget::hash_inputs(&mut builder, &[zero]);
// Chain: for each item, state = hash(state || item)
for item in &items {
let inputs = [state.to_vec(), vec![*item]].concat();
state = PoseidonHashOutTarget::hash_inputs(&mut builder, &inputs);
}
// Only expose the final hash β items stay secret
builder.register_public_inputs(&state.to_vec());
let data = builder.build::<C>();
Self { data, items }
}
pub fn prove(&self, items: &[u32]) -> ProofWithPublicInputs<F, C, D> {
let mut pw = PartialWitness::new();
for (target, item) in self.items.iter().zip(items.iter()) {
pw.set_target(*target, F::from_canonical_u32(*item));
}
self.data.prove(pw).unwrap()
}
}
UsageοΌ
// Build a circuit for a chain of length 3
let circuit = HashChainCircuit::new(3);
// Prove: "I processed items [1, 2, 3] in this exact order"
let proof = circuit.prove(&[1, 2, 3]);
// Verify: proof is valid and the final hash is in proof.public_inputs
assert!(circuit.data.verify(proof).is_ok());
π‘ Why is this useful?
Imagine a database with 10,000 records.
The prover generates a single proof for the entire hash chain. The verifier can confirm that all records were processed correctly by checking just that one proof β no need to recalculate anything.
Usecase ExampleοΌ
- π Watch Log: Proof that logs are not manipulated
- π¦ Batch Process: Proof that bulk transactions are processed in the right order
- π Data Pipeline: Proof that the ETL process is processed correctly.
Circuit is nothing but a calculator β hash(hash(hash(0, item0), item1), item2) = final_hash.
The verifier judges whether the final hash matches the expected value.
This separation of concerns β mathematical rules inside the circuit, business logic outside β is the fundamental pattern of ZKP design.
π³ Example 3: State Transition β Proves Merkle Tree Update
Many systems store data in a Merkle tree.
State Transition Proof proves "I updated a leaf of the tree and the value of the root has changed from old_root to new_root"
The verifier only sees the two root values.
Which leaf was changed, and what the old or new value was, is never revealed.
Merkle Tree Update Root
Merkle Tree (height=2, 4 leaves)
Before Update After Update
old_root new_root
/ \ / \
h01 h23 h01 h23
/ \ / \ / \ / \
[10] [20] [30] [40] [10] [99] [30] [40]
β β
index=1 index=1
old_value=20 new_value=99
What the Prover knows What the Verifier sees
ββββββββββββββββββββ ββββββββββββββββββββββ
γ»index = 1 γ»old_root
γ»old_value = 20 γ»new_root
γ»new_value = 99 γ»proof (cryptographic)
γ»Merkle path (sibling nodes)
γ»old_root, new_root
Code
pub struct StateTransitionCircuit {
pub data: CircuitData<F, C, D>,
old_leaf: Target,
new_leaf: Target,
index: Target,
merkle_proof: MerkleProofTarget<Target>,
old_root: PoseidonHashOutTarget,
new_root: PoseidonHashOutTarget,
}
impl StateTransitionCircuit {
pub fn new(height: usize) -> Self {
let config = CircuitConfig::standard_recursion_config();
let mut builder = CircuitBuilder::<F, D>::new(config);
let old_leaf = builder.add_virtual_target();
let new_leaf = builder.add_virtual_target();
let index = builder.add_virtual_target();
let merkle_proof = MerkleProofTarget::<Target>::new(&mut builder, height);
let old_root = PoseidonHashOutTarget::new(&mut builder);
let new_root = PoseidonHashOutTarget::new(&mut builder);
// Constraint 1: old_leaf at this index produces old_root
merkle_proof.verify::<F, C, D>(&mut builder, &old_leaf, index, old_root);
// Constraint 2: compute root from new_leaf with same index and siblings β computed_new_root
let computed_new_root =
merkle_proof.get_root::<F, C, D>(&mut builder, &new_leaf, index);
// Constraint 3: computed root must match the provided new_root
computed_new_root.connect(&mut builder, new_root);
// Public inputs: only the two roots
builder.register_public_inputs(&old_root.to_vec());
builder.register_public_inputs(&new_root.to_vec());
let data = builder.build::<C>();
Self { data, old_leaf, new_leaf, index, merkle_proof, old_root, new_root }
}
}
The prover sets the witness (secret inputs):
fn prove(
&self,
tree: &mut MerkleTree<u32>,
leaf_index: u64,
old_value: u32,
new_value: u32,
) -> ProofWithPublicInputs<F, C, D> {
// Get old state
let proof = tree.prove(leaf_index);
let old_root = tree.get_root();
// Update the tree
tree.update_leaf(leaf_index, new_value.hash());
let new_root = tree.get_root();
// Set all secret witnesses
let mut pw = PartialWitness::new();
pw.set_target(self.old_leaf, F::from_canonical_u32(old_value));
pw.set_target(self.new_leaf, F::from_canonical_u32(new_value));
pw.set_target(self.index, F::from_canonical_u64(leaf_index));
self.merkle_proof.set_witness(&mut pw, &proof);
self.old_root.set_witness(&mut pw, old_root);
self.new_root.set_witness(&mut pw, new_root);
self.data.prove(pw).unwrap()
}
Usage:
// Create a Merkle tree with 4 leaves
let mut tree = MerkleTree::<u32>::new(2); // height=2 β 4 leaves
tree.update_leaf(0, 10u32.hash());
tree.update_leaf(1, 20u32.hash());
tree.update_leaf(2, 30u32.hash());
tree.update_leaf(3, 40u32.hash());
let circuit = StateTransitionCircuit::new(2);
// Prove: "I changed leaf 1 from 20 to 99"
let proof = circuit.prove(&mut tree, 1, 20, 99);
// Verify: the root transition is correct
circuit.data.verify(proof).expect("should verify");
π€₯ Can you deceive?
When a prover tries to cheat β e.g. claiming the old value was 999 when it was actually 20 β the Merkle path won't match the old_root, and proof generation itself fails.
It's impossible to generate a valid proof for a false statement. π‘οΈ
// Panics β you can't prove a lie
let result = std::panic::catch_unwind(|| {
circuit.prove(&mut tree, 1, 999, 50); // 999 β actual value
});
assert!(result.is_err());
π§± Overall Picture: How To Combine the Patterns
The three patterns above are useful on their own, but they become truly powerful when combined.
Combining the Patterns
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β Prover (the party who holds the data) β
β ββββββββββββββββββββββββββββββββββββ β
β β
β Step 1: Prove individual operations β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β β
β β ββββββββββββββββββ ββββββββββββββββββ β β
β β β Operation A β β Operation B β ... β β
β β β β β β β β
β β β State β β State β β β
β β β Transition β β Transition β β β
β β β (Merkle tree) β β (Merkle tree) β β β
β β βββββββββ¬βββββββββ βββββββββ¬βββββββββ β β
β β β proof_a β proof_b β β
β ββββββββββββΌββββββββββββββββββββΌββββββββββββββββββββββββ β
β β β β
β βΌ βΌ β
β Step 2: Chain the proofs together (hash chain) β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β β
β β hash(hash(hash(0, proof_a), proof_b), ...) β β
β β = final_hash β β
β β β β
β βββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ β
β β β
β Final proof β
β β
βββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β
β Send proof
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β Verifier (auditor / smart contract / server) β
β ββββββββββββββββββββββββββββββββββββββββββ β
β β
β verify(proof) β Ok β β
β β
β Visible: old_root, new_root β
β Hidden: individual operations, data values, account details β
β β
β * Verifying just one proof cryptographically guarantees β
β the correctness of ALL operations β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Role of Each Pattern
| Pattern | What it proves | Example use cases |
|---|---|---|
| Hash Chain | A sequence of data was processed in the correct order | Audit logs, batch processing, data pipelines |
| State Transition | A Merkle tree update was performed correctly | Database updates, balance changes, config changes |
By combining them:
- Prove each individual update with a state transition proof
- Chain those proofs together with a hash chain
- The verifier checks just one final proof to confirm the correctness of everything
π οΈ Practical Tips
1. ποΈ Always Build in Release Mode
cargo test --release test_name
Debug mode is orders of magnitude slower. A proof that takes 2 seconds in release can take minutes in debug. π’βπ
2. π The Circuit Pattern Is Always the Same
struct MyCircuit {
data: CircuitData<F, C, D>, // Compiled circuit
// ... targets (wire slots)
}
impl MyCircuit {
fn new() -> Self { // Build phase: define constraints
let mut builder = CircuitBuilder::<F, D>::new(config);
// ... add constraints ...
let data = builder.build::<C>();
}
fn prove(&self, ...) { // Prove phase: set witness
let mut pw = PartialWitness::new();
// ... set values on targets ...
self.data.prove(pw)
}
}
3. ππ Public vs. Secret Is Decided at Build Time
// This value stays secret (only the prover knows it)
let secret = builder.add_virtual_target();
// This value becomes public (verifier can see it)
builder.register_public_input(secret); // Now it's public
4. β οΈ Constraints Are Assertions, Not Computations
// This does NOT compute a + b at build time
// It creates a gate with the constraint: sum = a + b
let sum = builder.add(a, b);
// At prove time, if the witness values don't satisfy
// the constraint, proof generation fails
π¬ Wrapping Up
Zero-knowledge proofs aren't magic. They're constraint systems wrapped in clever cryptography. The circuit defines the rules, the prover fills in the secret inputs, and the verifier checks the proof without ever seeing those inputs.
With Plonky2:
- π Hash Chain β prove that data was processed in the correct order
- π³ State Transition β prove a database update without revealing what changed
The code patterns are consistent and composable. Once you understand CircuitBuilder β PartialWitness β prove β verify, you can stack simple circuits to build arbitrarily complex proof systems. π§±β¨
The code in this article uses the Plonky2 framework β a fast recursive SNARK system built on Poseidon hashing over the Goldilocks field.
Top comments (0)