DEV Community

Cover image for πŸ” Practical Guide to ZKP: Learn Real Usecase of ZKP with Plonky2
Toshiya Matsumoto
Toshiya Matsumoto

Posted on

πŸ” Practical Guide to ZKP: Learn Real Usecase of ZKP with Plonky2

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:

  1. How ZKP Circuits Work β€” Constraints, Witness, Public Inputs
  2. Hash Chain Proof β€” Proving the order of data processing
  3. 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        β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Ίβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)?)
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

πŸ” 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    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜           β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode
  1. Build Phase: builder.add(a, b) does not compute a + b. It creates a constraint that says "sum must equal a + b". The circuit is a template of rules.
  2. Prove Phase: When the prover sets a=3, b=5, Plonky2 generates a cryptographic proof that these values satisfy the constraints.
  3. Verify Phase: The verifier checks the proof. It sees sum=8 in the public inputs, but has no way of knowing a=3 or b=5.

Key point: A Target in 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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
Enter fullscreen mode Exit fullscreen mode

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 }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

πŸ€₯ 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());
Enter fullscreen mode Exit fullscreen mode

🧱 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                           β”‚
β”‚                                                                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

🎬 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)