Working with inputs
This guide walks through how a guest program receives inputs during execution. It covers how the ZisK input buffer works, the read primitives available, and how to choose between them depending on your guest program's needs.
Reading inputs in guest programs
Guest programs have no direct access to the outside world during execution. All information they need must be provided through the input buffer, which is the channel through which a guest program receives input data.
Understanding ZisK inputs
The input is a sequential byte buffer. There is no random access,
each read advances a cursor forward. Each io::read / io::read_slice call
consumes a u64 length header (8 bytes) encoded in little-endian order,
followed by the value bytes zero-padded to the next 8-byte boundary.
Values must be written in the same order the guest reads them;
diverging order causes an error.
Consuming inputs
ziskos::io exposes two primitives for consuming the input buffer.
read deserializes the next bytes directly into a typed value.
read_slice gives you the raw bytes and leaves interpretation
entirely up to you, which is useful for custom formats, zero-copy
access, or structures only known at runtime:
| Function | Use when | Description |
|---|---|---|
read::<T>() -> T | You want automatic deserialization into a typed value | Reads the next item and deserializes it into T using bincode. T must implement serde::Deserialize. |
read_slice() -> &[u8] | You need raw byte access or a custom format | Returns the next chunk of the input buffer as a raw byte slice with no deserialization. Interpretation is entirely up to you. |
Both primitives can be mixed freely within the same program, each handling the part of the input it is best suited for.
Working on a real example
In this section you will build a guest program that sums only the
prime numbers from a list of u64 values, using an is_prime
helper shared through the common crate. You will write it three
ways, each time changing how the guest reads the input while keeping
the same logic. By the end you will have a concrete feel for
each primitive and know when to reach for one over the other.
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 primes
directory:
git clone https://github.com/0xPolygonHermez/zisk.git
cd zisk/examples/primes/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 primes
cd primes/guest
Download the sample inputs this guide uses into a samples/ folder
inside the guest/ crate, where the later commands expect them:
mkdir samples
BASE=https://raw.githubusercontent.com/0xPolygonHermez/zisk/refs/heads/main/examples/primes
curl -L -o samples/example-input-struct.bin $BASE/example-input-struct.bin
curl -L -o samples/example-input-multiple.bin $BASE/example-input-multiple.bin
curl -L -o samples/example-input-raw.bin $BASE/example-input-slice.bin
Either path lands you in a project with a guest/ crate where the
ZisK program lives and a common/ crate with the shared input
types.
Reading a typed struct
Start with the simplest pattern: define a struct that mirrors the input layout and read the entire input in a single call. Every field is deserialized and available before any logic runs.
First, add serde to the common crate dependencies:
[package]
name = "primes-common"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = "1.0.228"
Then open common/src/lib.rs and define the shared input type
together with the is_prime helper the guest will use:
// Used by the host and guest for serialization and deserialization of input data.
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct InputDTO {
pub values: Vec<u64>,
}
/// Returns `true` if `n` is a prime number, `false` otherwise.
pub fn is_prime(n: &u64) -> bool {
if *n < 2 {
return false;
}
if *n == 2 || *n == 3 {
return true;
}
if *n % 2 == 0 || *n % 3 == 0 {
return false;
}
let mut i = 5;
loop {
if i * i > *n {
return true;
} else if *n % i == 0 || *n % (i + 2) == 0 {
return false;
} else {
i += 6;
}
}
}
Each approach in this guide will have its own binary so they can all live in
the project at once. Rename guest/src/main.rs to guest/src/struct.rs and write the
program. A single io::read::<InputDTO>() call reads the entire input
at once, bincode deserializes the full values vector before any logic
runs:
#![no_main]
ziskos::entrypoint!(main);
use primes_common::{is_prime, InputDTO};
fn main() {
// Read the input from the guest's standard input.
let input = ziskos::io::read::<InputDTO>();
// Compute the value we want to prove.
let mut result = 0u64;
for value in &input.values {
if is_prime(value) {
result += value;
}
}
// Commit the result as a public output so a verifier can inspect it
// without re-executing the program.
ziskos::io::commit(&result);
println!("sum-primes({:?}) => {result}", input.values);
}
Before building, register the file as a binary target in
guest/Cargo.toml as each example in this guide gets its
own [[bin]] entry so they can all be built and run side by side:
[[bin]]
name = "struct-guest"
path = "src/struct.rs"
Build and run the struct binary, passing the sample input:
cargo-zisk build --release --bin struct-guest
cargo-zisk run --release --bin struct-guest -i samples/example-input-struct.bin
sum-primes([5, 11, 18, 23, 45]) => 39
The loop walks input.values and accumulates the primes into result.
One read, one struct, clean and type-safe, but bincode copies every byte
into owned Rust values, the full input is in memory before the loop starts.
If you need to action each value before reading the next, the following
approach gives you more control.
Reading multiple times
This is the second example in the guide, so rather than touching the
struct binary you'll add a new one alongside it. The logic stays the
same, only the way the input is consumed changes.
Drop the struct entirely. Instead of reading the whole input in one
call, read the length header first, then pull each value out one at a
time, checking and summing primes as they arrive, so the guest never
holds the full vector at once. Create a second program at
guest/src/multiple.rs:
#![no_main]
ziskos::entrypoint!(main);
use primes_common::is_prime;
fn main() {
// Read the length from the guest's standard input.
let len = ziskos::io::read::<u64>();
print!("sum-primes([");
// Reading each value from the guest standard inputs and adding it if its prime
let mut result = 0;
for i in 0..len {
let value = ziskos::io::read::<u64>();
if i == 0 {
print!("{value}");
} else {
print!(",{value}");
}
if is_prime(&value) {
result += value;
}
}
// Commit the result as a public output so a verifier can inspect it
// without re-executing the program.
ziskos::io::commit(&result);
println!("]) => {result}");
}
Add a second [[bin]] entry to guest/Cargo.toml for this file:
[[bin]]
name = "multiple-guest"
path = "src/multiple.rs"
Build and run the multiple binary:
cargo-zisk build --release --bin multiple-guest
cargo-zisk run --release --bin multiple-guest -i samples/example-input-multiple.bin
sum-primes([5, 11, 18, 23, 45]) => 39
Same result. Each value is checked the moment it arrives, so the guest never has to hold the full vector in memory before it can start work.
Reading raw bytes
In this last example we'll use read_slice together with rkyv
for zero-copy access to a typed struct. rkyv serializes the data in a
layout that can be accessed directly from the byte slice, no
deserialization step, no allocations, no manual offset arithmetic.
First, add rkyv to the common crate alongside the existing serde
dependency:
[dependencies]
serde = "1.0.228"
rkyv = { version = "0.8.16" , features = ["std", "alloc"]}
Then add a parallel InputZeroCopyDTO next to the serde-based one,
deriving rkyv's Archive, Serialize, and Deserialize:
// Re-exported so the guest and host can use the same `rkyv` version as the derive macros below.
pub use rkyv;
// Used by the guest for zero-copy deserialization, avoiding heap allocation overhead in the guest.
#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub struct InputZeroCopyDTO {
pub values: Vec<u64>,
}
Now create guest/src/slice.rs. The key difference from the other
approaches is that from_bytes gives a typed view of the raw bytes,
input.values is accessible directly, with no copy:
#![no_main]
ziskos::entrypoint!(main);
use primes_common::{is_prime, rkyv, InputZeroCopyDTO};
fn main() {
// Read the input from the guest's standard input.
let raw_input = ziskos::io::read_slice();
// Zero-copy view: no deserialization, no allocation
let input =
rkyv::api::high::from_bytes::<InputZeroCopyDTO, rkyv::rancor::Error>(raw_input.as_ref())
.unwrap();
// Compute the value we want to prove.
let mut result = 0;
for value in &input.values {
if is_prime(value) {
result += value;
}
}
// Commit the result as a public output so a verifier can inspect it
// without re-executing the program.
ziskos::io::commit(&result);
println!("sum-primes({:?}) => {result}", input.values);
}
Add a third [[bin]] entry to guest/Cargo.toml for this file:
[[bin]]
name = "slice-guest"
path = "src/slice.rs"
Build and run the raw binary, passing the rkyv-encoded input as a
binary file:
cargo-zisk build --release --bin slice-guest
cargo-zisk run --release --bin slice-guest -i samples/example-input-slice.bin
sum-primes([5, 11, 18, 23, 45]) => 39
Same result as the previous two approaches. The advantage over manual byte parsing is that rkyv handles field layout and alignment, you get the typed struct interface without the bincode copy cost.
Comparing the three approaches
There is no single best choice. Use read::<T>() when the schema is
fixed and having all fields ready at once is more convenient, bincode
handles framing but copies every byte into owned values. Use individual
reads when you want to process each value as it arrives without
allocating the full collection up front. Use read_slice() when you want the raw bytes
and intend to interpret them yourself, you control the wire format and
decode straight from the slice, so nothing is allocated beyond what you
choose.
Summary
You now understand how data flows into a guest program. The input buffer is the only channel from the outside world, everything the guest knows is consumed from there. The choice of primitive determines how much control you have over the read sequence, how much memory is allocated, and how clearly the intent reads in code.
The key invariant to keep in mind: the way the guest reads determines the layout of the input. Change the read pattern, switching from a struct to individual fields, or from typed reads to a raw slice, and the input passed on must change to match.
Next steps
With inputs covered, the natural next step is the other side of the I/O model — how your guest exposes results to the verifier:
- Committing outputs: learn how outputs are committed to the proof and made available to the verifier as public values.
- Profiling your program: measure execution cost to find bottlenecks before they become expensive to prove.