Committing outputs
This guide walks through how a guest program exposes results to the verifier by committing them to the public output. It covers how the output buffer works, the commit primitives available, and how to choose between them depending on what your program needs to expose.
Writing outputs in guest programs
After the computation finishes, the guest program has no way to return values to the outside world on its own. Committed outputs are the only mechanism: each value commited becomes part of the proof and can be read back by anyone who verifies it.
Understanding public values
Each commit call serializes a value and appends it to the public output as a sequence of 32-bit little-endian slots. The bytes are packed into groups of four, and the last group is zero-padded if the data does not align to a 4-byte boundary. The verifier reads them back in the exact same order they were committed: first committed, first read.
Appending to the public values
Now that we know what the public values are, let's look at how to write to it.
ziskos::io exposes two primitives for appending to the output buffer.
commit serializes a typed value automatically and appends it.
commit_slice appends raw bytes directly without any serialization,
which is useful when the output is already in binary form or when you
want to bypass bincode entirely:
| Function | Use when | Description |
|---|---|---|
commit::<T>(&value) | You want automatic serialization of a typed value | Serializes value with bincode, then packs the resulting bytes into 32-bit LE slots, zero-padding the last slot if needed. T must implement serde::Serialize. |
commit_slice(buf: &[u8]) | The output is already in raw byte form | Packs buf directly into 32-bit LE slots without any serialization, zero-padding the last slot if the length is not a multiple of 4. |
Which primitive should I use?
The right choice comes down to two questions: what format does the
verifier expect, and what form is the data already in at the point
of the commit. If the verifier will deserialize the output as a
typed value, use io::commit and let bincode handle the layout.
If the verifier reads raw bytes, or if the data is already a byte
array like a hash or a checksum, use io::commit_slice and skip the
serialization step entirely. In both cases the bytes land in the
same 32-bit slot format; the only difference is whether bincode
framing is included.
Every committed byte increases proof size and verification cost. Commit only what the verifier strictly needs: a digest instead of raw data, a root instead of a full list, one field instead of a whole struct. Avoid committing values the verifier can derive on its own, and prefer fixed-sized outputs.
Working on a real example
In this section you will build a guest program that reads a u64,
computes the Collatz sequence for it, and commits the result back
to the proof. You will write it three ways, each time changing
only how the output is committed while the logic and the input
stay the same.
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 this guide builds,
or just want to skim a complete project before writing your own,
clone the companion examples repo and move into the collatz
directory:
git clone https://github.com/0xPolygonHermez/zisk.git
cd zisk/examples/collatz/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. It handles
workspace setup, toolchain configuration, and dependency wiring so
you can move straight to writing logic:
cargo-zisk new collatz
cd collatz/guest
Download the sample input this guide uses into a samples/ folder
inside the guest/ crate, where the later commands expect it:
mkdir samples
BASE=https://raw.githubusercontent.com/0xPolygonHermez/zisk/refs/heads/main/examples/collatz
curl -L -o samples/example-input.bin $BASE/example-input.bin
Either path lands you in a project with a guest/ crate where the
ZisK program lives and a common/ crate for shared types and
helpers.
Define the shared logic
All three variants reuse the same collatz helper, so define it once
in the common. It is pure logic with no extra dependencies:
/// Returns the Collatz sequence starting from `n`, ending at 1.
pub fn collatz(mut n: u64) -> Vec<u64> {
let mut seq = vec![n];
while n != 1 {
n = if n % 2 == 0 { n / 2 } else { 3 * n + 1 };
seq.push(n);
}
seq
}
Each variant in this guide will add whatever extra dependencies and types it needs when it gets there.
Committing both values sequentially
The most direct approach: commit the input first, then commit the
sequence as a whole Vec<u64>. Two io::commit calls, no shared
struct. Each approach in this guide is its own binary so they can all
live in the project at once. Rename guest/src/main.rs to
guest/src/sequential.rs and write the program:
#![no_main]
ziskos::entrypoint!(main);
use collatz_common::collatz;
fn main() {
// Read the starting value from the guest's standard input stream.
let input = ziskos::io::read::<u64>();
// Compute the Collatz sequence we want to prove.
let sequence = collatz(input);
// Commit the input and the full sequence as separate public outputs so a
// verifier can inspect them without re-executing the program.
ziskos::io::commit(&input);
ziskos::io::commit(&sequence);
println!("collatz({input}) => {:?}", sequence);
}
Each io::commit of a u64 writes 8 bytes (no padding) and fills
two 32-bit slots. The Vec<u64> gets bincode framing: a u64
length prefix followed by each element.
Register this file as a binary target in guest/Cargo.toml. Each
example gets its own [[bin]] entry so they can all be built and run
side by side:
[[bin]]
name = "sequential-guest"
path = "src/sequential.rs"
Build and run the sequential binary:
cargo-zisk build --release --bin sequential-guest
cargo-zisk run --release --bin sequential-guest -i samples/example-input.bin
collatz(43) => [43, 130, 65, 196, 98, 49, 148, 74, 37, 112, 56, 28, 14, 7, 22, 11, 34, 17, 52, 26, 13, 40, 20, 10, 5, 16, 8, 4, 2, 1]
Two separate commit calls produce the same bytes a single bundled
struct would, but every io::commit pays per-call overhead inside
the prover. The next approach collapses both writes into one.
Committing a typed struct
Bundle the input and the sequence into a typed OutputDTO and commit
the whole thing in a single call. The verifier receives a fully typed
value it can deserialize immediately using the same struct definition.
This variant needs a serializable type, so add serde to the common
crate:
[package]
name = "collatz-common"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = "1.0.228"
Then define OutputDTO in common/src/lib.rs alongside collatz:
/// Output committed by the guest: the starting value and the full Collatz sequence.
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
pub struct OutputDTO {
pub n: u64,
pub sequence: Vec<u64>,
}
Create guest/src/single.rs:
#![no_main]
ziskos::entrypoint!(main);
use collatz_common::{collatz, OutputDTO};
fn main() {
// Read the starting value from the guest's standard input stream.
let input = ziskos::io::read::<u64>();
// Compute the Collatz sequence we want to prove.
let sequence = collatz(input);
// Pack the input and sequence into a single struct and commit it as a
// public output so a verifier can inspect it without re-executing the
// program.
let result = OutputDTO { n: input, sequence };
ziskos::io::commit(&result);
println!("collatz({input}) => {:?}", result.sequence);
}
Bincode lays the struct out field by field: n as a u64, then
the Vec<u64> as a u64 length prefix followed by each element.
Add a second [[bin]] entry to guest/Cargo.toml for this file:
[[bin]]
name = "single-guest"
path = "src/single.rs"
Build and run the single binary:
cargo-zisk build --release --bin single-guest
cargo-zisk run --release --bin single-guest -i samples/example-input.bin
collatz(43) => [43, 130, 65, 196, 98, 49, 148, 74, 37, 112, 56, 28, 14, 7, 22, 11, 34, 17, 52, 26, 13, 40, 20, 10, 5, 16, 8, 4, 2, 1]
One commit, one struct. The verifier deserializes the output as
an OutputDTO and has immediate access to both fields, and adding
a field later is all it takes to extend the schema. The downside
is that you pay the cost of allocating the struct before
serializing.
Compressing committed values
Stop committing the values entirely. Instead, feed the input and
every element of the sequence into a SHA-256 hasher and commit
only the digest. The output becomes a fixed 32 bytes regardless of
how long the sequence is. First, add sha2 and hex to the common
crate:
[package]
name = "collatz-common"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = "1.0.228"
sha2 = "0.11.0"
hex = "0.4.3"
Re-export them from common/src/lib.rs so the guest can use the same
sha2 and hex versions through the common crate instead of
depending on them directly:
/// Re-export the SHA-256 hasher and its `Digest` trait from the `sha2` crate.
pub use sha2::{Digest, Sha256};
/// Re-export the `hex` crate for encoding/decoding hex strings.
pub use hex;
/// A 32-byte array that holds a raw SHA-256 digest.
pub type Hash = [u8; 32];
Then create guest/src/compressed.rs:
#![no_main]
ziskos::entrypoint!(main);
use collatz_common::{collatz, hex, Digest, Hash, Sha256};
fn main() {
// Read the starting value from the guest's standard input stream.
let input = ziskos::io::read::<u64>();
// Compute the Collatz sequence we want to prove.
let sequence = collatz(input);
// Hash the input followed by every sequence element to produce a compact
// digest that represents the full computation.
let mut hasher = Sha256::new();
hasher.update(input.to_le_bytes());
sequence.iter().for_each(|value| hasher.update(value.to_le_bytes()));
let result: Hash = hasher.finalize().into();
// Commit the digest as a public output so a verifier can inspect it
// without re-executing the program.
ziskos::io::commit_slice(&result);
println!("collatz({input}) => {:?} [digest: {:?}]", sequence, hex::encode(result));
}
32 bytes divide evenly into 8 slots with no padding.
Add a third [[bin]] entry to guest/Cargo.toml for this file:
[[bin]]
name = "compressed-guest"
path = "src/compressed.rs"
Build and run the compressed binary:
cargo-zisk build --release --bin compressed-guest
cargo-zisk run --release --bin compressed-guest -i samples/example-input.bin
collatz(43) => [43, 130, 65, 196, 98, 49, 148, 74, 37, 112, 56, 28, 14, 7, 22, 11, 34, 17, 52, 26, 13, 40, 20, 10, 5, 16, 8, 4, 2, 1] [digest: "89a398940fc5790e7ff443da24fd51cc7c228b05106b3700c942519a403b0720"]
8 slots, always — whether the Collatz sequence for the input is 30 steps long or 600. The verifier cannot recover the input or the sequence from the digest alone, but if those values are shared alongside the proof the verifier can rehash them in the same order and confirm the digest matches.
Comparing the approaches
commit per value | commit struct | Compressed | |
|---|---|---|---|
| When to use | A few independent values, no shared output type | Verifier deserializes a typed value | Output size must be fixed |
| Padding | Up to one pad per commit call (each call's tail slot) | One pad at most (single commit) | None — 32 bytes fill 8 slots exactly |
| Schema | Implicit — commit order matters | Explicit — shared type definition | Implicit — verifier rehashes to verify |
| Output size | Grows with data | Same bytes as per-value, but one commit | Fixed (e.g. 32 bytes for SHA-256) |
| Verifier recovers | Yes | Yes | No — must rehash with original values |
Serialization scales with how many times you call commit: committing
each value separately runs bincode once per call, while a single struct
commit serializes once. commit_slice does not serialize at all, it
writes the raw bytes straight into the slot layout, padding only the
final slot.
Other patterns to compressing outputs
When output size is critical, more strategies exist. Merkle trees let the verifier check individual values with a short inclusion proof against a single 32-byte root, without receiving the full list. More generally, any structure that lets you replace a variable-length output with a fixed-size commitment (a root, a hash, an aggregate) keeps proof size bounded regardless of input scale.
Summary
You now understand how a guest program exposes results to the verifier. Whether you commit a typed struct, raw bytes, or a compressed digest, the choice directly affects proof size and what the verifier can recover. Commit only what the verifier strictly needs.
Next steps
With inputs and outputs covered, you have the full I/O model. The next steps focus on making your programs cheaper to prove:
-
Profiling your program: measure cycle counts per stage to find bottlenecks before they become expensive to prove.
-
Optimizing via precompiles: use ZisK-native implementations of expensive operations like SHA-256 to reduce cycle count significantly.