Skip to main content

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.

Guest Program
① commit::<u8>(83)
② commit::<u16>(66u16)
③ commit::<u16>(93u8)
Output Buffer
slot[0]
53000000
slot[1]
42000000
slot[2]
5D000000

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:

FunctionUse whenDescription
commit::<T>(&value)You want automatic serialization of a typed valueSerializes 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 formPacks 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.

Output size matters

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:

bash
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:

bash
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:

bash
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:

common/src/lib.rs
/// 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:

guest/src/sequential.rs
#![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:

guest/Cargo.toml
[[bin]]
name = "sequential-guest"
path = "src/sequential.rs"

Build and run the sequential binary:

bash
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:

common/Cargo.toml
[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:

common/src/lib.rs
/// 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:

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:

guest/Cargo.toml
[[bin]]
name = "single-guest"
path = "src/single.rs"

Build and run the single binary:

bash
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:

common/Cargo.toml
[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:

common/src/lib.rs
/// 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:

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:

guest/Cargo.toml
[[bin]]
name = "compressed-guest"
path = "src/compressed.rs"

Build and run the compressed binary:

bash
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 valuecommit structCompressed
When to useA few independent values, no shared output typeVerifier deserializes a typed valueOutput size must be fixed
PaddingUp to one pad per commit call (each call's tail slot)One pad at most (single commit)None — 32 bytes fill 8 slots exactly
SchemaImplicit — commit order mattersExplicit — shared type definitionImplicit — verifier rehashes to verify
Output sizeGrows with dataSame bytes as per-value, but one commitFixed (e.g. 32 bytes for SHA-256)
Verifier recoversYesYesNo — 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.