Skip to main content

Choosing a proof format

This guide covers the three proof types ZisK supports: a STARK as the base type, a Minimal STARK that wraps it into a smaller proof, and a PLONK that makes it verifiable on-chain. The right choice depends on where the proof will be consumed.

Understanding proof types

STARK is always the starting point. Minimal STARK and PLONK are independent post-processing steps that wrap it, each trading extra proving time for a different output property: a smaller proof for off-chain use, or a constant-size proof verifiable on-chain. The two paths are mutually exclusive; you cannot convert a Minimal STARK into a PLONK.

STARK proof

Generates a single aggregated STARK proof by splitting the execution trace across specialized state machines, each proven independently and then recursively combined. Use this when proof size does not matter and you want the fastest turnaround.

Minimal STARK proof

Generates a recursive proof that verifies the STARK final proof, producing a smaller output. Use this when you need a more compact proof for off-chain distribution but do not require on-chain verification.

PLONK proof

Works like compressed, but instead of wrapping the STARK in another STARK it wraps it in a SNARK verifier. The result is a constant-size proof that can be verified on-chain. It is the only proof type the Solidity verifier accepts.


Working on a real example

To try each format against a real proof, set up the gcd example: a guest that reads two u64 values, computes their greatest common divisor, and commits the result.

Already have the gcd project?

If you followed Choosing a prover client, you already built this exact gcd example, there is nothing to set up again. Skip straight to Selecting a proof type.

Set up the project

You have two ways to get a working ZisK project for this guide. Pick whichever fits your situation; the rest of the guide is identical either way.

Clone the examples repository

If you want the finished version of the program, clone the companion examples repo and move into the gcd directory:

bash
git clone https://github.com/0xPolygonHermez/zisk.git
cd zisk/examples/gcd/guest

Scaffold a new project

To start from an empty project and write the program yourself, use the cargo-zisk CLI to scaffold a new workspace:

bash
cargo-zisk new gcd
cd gcd/guest

Download the sample input this guide uses into a samples/ folder inside the guest/ crate, where the later commands expect it:

bash
mkdir samples
BASE=https://raw.githubusercontent.com/0xPolygonHermez/zisk/refs/heads/main/examples/gcd
curl -L -o samples/example-input.bin $BASE/guest/samples/example-input.bin

Either path lands you in a project with a guest/ crate where the ZisK program lives and a common/ crate with the shared logic.

Define the shared logic

Define the gcd helper in the common crate. It is pure logic with no extra dependencies:

common/src/lib.rs
/// Returns the greatest common divisor of `a` and `b` using the Euclidean algorithm.
pub fn gcd(mut a: u64, mut b: u64) -> u64 {
while b != 0 {
let r = a % b;
a = b;
b = r;
}
a
}

Write the guest program

Now open guest/src/main.rs. It reads two u64 values, computes their GCD, and commits the result as a public output:

guest/src/main.rs
#![no_main]
ziskos::entrypoint!(main);

use gcd_common::gcd;

fn main() {
// Read the two inputs from the guest's standard input stream.
let a = ziskos::io::read::<u64>();
let b = ziskos::io::read::<u64>();

// Compute the value we want to prove.
let result = gcd(a, b);

// Commit the result as a public output so a verifier can inspect it
// without re-executing the program.
ziskos::io::commit(&result);

println!("gcd({a}, {b}) => {result}");
}

Build the guest once before generating proofs in the next section:

bash
cargo-zisk build --release

Selecting a proof type

STARK is the default in both the CLI and the SDK. Use the sections below to switch to a different type depending on where you are working from. No other changes to your code are required.

Using cargo-zisk

Pass a flag to cargo-zisk prove to select a different proof type. No flag means STARK:

bash
# STARK (default)
cargo-zisk prove --release -i samples/example-input.bin

# Minimal STARK
cargo-zisk prove --release -i samples/example-input.bin --minimal

# PLONK
cargo-zisk prove --release -i samples/example-input.bin --plonk

Using zisk-sdk

From the SDK the proof type is selected with .wrap(ProofKind::...) on the prove builder before .run(); omitting the call leaves the proof at the default STARK output. We'll build one host binary per format under host/src/. The first time, build the STARK host step by step; the other two are near-identical copies of it, shown in full for clarity.

Generate a STARK proof

First, declare the stark binary in host/Cargo.toml:

host/Cargo.toml
[[bin]]
name = "stark-host"
path = "src/stark.rs"

Then create host/src/stark.rs, import the types, and load the compiled guest ELF:

host/src/stark.rs
use zisk_sdk::{load_program, GuestProgram, ProverClient, ZiskStdin};

/// Guest ELF binary, embedded into the host at build time.
static PROGRAM: GuestProgram = load_program!("gcd-guest");

Inside main, build the embedded prover, write the two u64 operands the guest reads, and run the one-time setup. None of this depends on the proof format:

host/src/stark.rs
let client = ProverClient::embedded().build()?;

let stdin = ZiskStdin::new();
stdin.write::<u64>(&12);
stdin.write::<u64>(&18);

// One-time setup for this guest binary.
client.setup(&PROGRAM).run_sync()?;

Now comes the step that selects the proof format. For the STARK baseline, prove without a wrap, then verify, verification is identical for every format:

host/src/stark.rs
// STARK (default): no wrap.
let proof = client.prove(&PROGRAM, stdin).run_sync()?;

if proof.with_program_vk(&PROGRAM.vk()?).verify().is_ok() {
println!("Proof was verified successfully.");
}

Putting every step together, the complete host/src/stark.rs is:

host/src/stark.rs
use zisk_sdk::{load_program, GuestProgram, ProverClient, ZiskStdin};

/// Guest ELF binary, embedded into the host at build time.
static PROGRAM: GuestProgram = load_program!("gcd-guest");

fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ProverClient::embedded().build()?;

// Write the two GCD operands the guest reads.
let stdin = ZiskStdin::new();
stdin.write::<u64>(&12);
stdin.write::<u64>(&18);

// One-time setup for this guest binary.
client.setup(&PROGRAM).run_sync()?;

// STARK (default): no wrap.
let proof = client.prove(&PROGRAM, stdin).run_sync()?;

if proof.with_program_vk(&PROGRAM.vk()?).verify().is_ok() {
println!("Proof was verified successfully.");
}

Ok(())
}

Build and run the stark binary:

bash
cd ../host
cargo run --release --bin stark-host
Passing arguments to the cloned example

If you cloned the example from the repository, the host accepts trailing arguments after -- to change the input and select the execution backend:

bash
cargo run --release --bin stark-host -- <a> <b> --gpu --asm

<a> and <b> set the GCD input, --gpu runs proving on a CUDA GPU, and --asm uses the native Assembly executor (Linux x86_64). All three are optional and can be combined.

Generate a minimal STARK proof

The Minimal STARK host is the STARK host with a single extra call on the prove builder. .wrap(ProofKind::VadcopFinalMinimal) runs an additional recursive proving pass that verifies the STARK final proof and emits a smaller one in its place. You trade a little extra proving time for a more compact output that is cheaper to store and transmit off-chain. The client, input, and setup stay exactly as they were, only the prove call changes.

Register a second binary:

host/Cargo.toml
[[bin]]
name = "minimal-host"
path = "src/minimal.rs"

Then create host/src/minimal.rs: the STARK host with ProofKind added to the imports and the .wrap(...) appended to the prove call:

host/src/minimal.rs
use zisk_sdk::{load_program, GuestProgram, ProofKind, ProverClient, ZiskStdin};

/// Guest ELF binary, embedded into the host at build time.
static PROGRAM: GuestProgram = load_program!("gcd-guest");

fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ProverClient::embedded().build()?;

let stdin = ZiskStdin::new();
stdin.write::<u64>(&12);
stdin.write::<u64>(&18);

client.setup(&PROGRAM).run_sync()?;

// Minimal STARK: wrap the STARK into a smaller recursive proof.
let proof = client
.prove(&PROGRAM, stdin)
.wrap(ProofKind::VadcopFinalMinimal)
.run_sync()?;

if proof.with_program_vk(&PROGRAM.vk()?).verify().is_ok() {
println!("Proof was verified successfully.");
}

Ok(())
}

Run the minimal binary:

bash
cargo run --release --bin minimal-host
Passing arguments to the cloned example

If you cloned the example from the repository, the host accepts trailing arguments after -- to change the input and select the execution backend:

bash
cargo run --release --bin minimal-host -- <a> <b> --gpu --asm

<a> and <b> set the GCD input, --gpu runs proving on a CUDA GPU, and --asm uses the native Assembly executor (Linux x86_64). All three are optional and can be combined.

Generate a PLONK proof

The PLONK host wraps the STARK in a SNARK verifier, producing the constant-size proof that the on-chain Solidity verifier accepts. It is the most expensive of the three to generate. Unlike Minimal STARK, it takes two changes from the STARK host:

  • The client is built with .plonk(), which preloads the PLONK (SNARK) proving key. That key is large and not loaded by default, so without this call the prover has nothing to build the PLONK wrapper with and proving fails.
  • The prove builder wraps with .wrap(ProofKind::Plonk) instead of leaving the STARK output unwrapped.

Register a third binary:

host/Cargo.toml
[[bin]]
name = "plonk-host"
path = "src/plonk.rs"

Then create host/src/plonk.rs with both changes in place:

host/src/plonk.rs
use zisk_sdk::{load_program, GuestProgram, ProofKind, ProverClient, ZiskStdin};

/// Guest ELF binary, embedded into the host at build time.
static PROGRAM: GuestProgram = load_program!("gcd-guest");

fn main() -> Result<(), Box<dyn std::error::Error>> {
// `.plonk()` preloads the PLONK proving key.
let client = ProverClient::embedded().plonk().build()?;

let stdin = ZiskStdin::new();
stdin.write::<u64>(&12);
stdin.write::<u64>(&18);

client.setup(&PROGRAM).run_sync()?;

// PLONK: wrap the STARK into a constant-size, on-chain-verifiable proof.
let proof = client
.prove(&PROGRAM, stdin)
.wrap(ProofKind::Plonk)
.run_sync()?;

if proof.with_program_vk(&PROGRAM.vk()?).verify().is_ok() {
println!("Proof was verified successfully.");
}

Ok(())
}

Run the plonk binary:

bash
cargo run --release --bin plonk-host
Passing arguments to the cloned example

If you cloned the example from the repository, the host accepts trailing arguments after -- to change the input and select the execution backend:

bash
cargo run --release --bin plonk-host -- <a> <b> --gpu --asm

<a> and <b> set the GCD input, --gpu runs proving on a CUDA GPU, and --asm uses the native Assembly executor (Linux x86_64). All three are optional and can be combined.


Summary

You now know the three output formats ZisK can produce and exactly when each one makes sense. The default gets you a proof faster so, just reach for Minimal STARK or PLONK only when your target requires it.


Next steps

With proof format covered, the remaining pieces of the pipeline are how inputs flow in and what to do with the result once it is out: