Managing i/o
This guide covers the two I/O channels a host program controls: writing the input the guest will read during execution, and reading back the public values the guest committed once the proof is verified.
Writing to the guest program
Before the prover runs, the host prepares a Stdin and fills it
with the data the guest program expects. Whatever the host writes is
exactly what the guest reads on the other side. The
serialization format and order must match exactly.
Write primitives
Stdin exposes two primitives for appending to the input:
| Method | Use when | Description |
|---|---|---|
write(&T) | You have a typed Rust value | Serializes value with bincode and appends it. T must implement serde::Serialize. |
write_slice(&[u8]) | Data is already in byte form | Appends raw bytes directly without any serialization. |
Both primitives can be mixed freely within the same Stdin. Use
stdin.write when the guest reads a typed value with
io::read::<T>(). Use stdin.write_slice when the guest reads raw
bytes with io::read_slice() or when the data is already serialized
externally.
Each write call writes two sections to the buffer: a u64
length header (8 bytes) that stores the byte size of the value, followed
by the value bytes zero-padded to the next 8-byte boundary as follows:
Working on a real example
In this section you will run the same primes program
covered in Working with inputs. The
guest reads a list of u64 values, filters the primes via the
shared is_prime helper, and commits their sum as a single u64
public output. You will assemble its input three different ways,
each matching a corresponding guest variant from that guide. The
two sides must always agree on the byte layout; that is the key
invariant to keep in mind as you work through the approaches.
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 drives,
clone the companion examples repo and move into the primes
directory:
git clone https://github.com/0xPolygonHermez/zisk.git
cd zisk/examples/primes
Scaffold a new project
To start from an empty project and build it yourself, scaffold a
new workspace with cargo-zisk:
cargo-zisk new primes
cd primes
This guide drives the host side of a guest that already exists. If
you are scaffolding from scratch, first complete
Working with inputs, which writes the guest
and the common crate this guide reuses, then come back and continue
here.
Either path lands you in a project with a guest/ crate, a
common/ crate, and a host/ crate. The guest and common crates,
including the InputDTO type and the is_prime helper, are the ones
built in Working with inputs; this guide
reuses them as-is and only adds the host side.
Writing a typed struct
This is the first host-side implementation, so we'll build it step by
step; the later variants are copies that change only how the input is
assembled. Because a single io::read::<InputDTO>() on the guest reads
the whole input and handles the framing automatically, the host only has
to assemble an InputDTO and serialize it in one stdin.write.
InputDTO is defined in the common crate and shared by both sides; the
matching guest is the Struct variant from
Working with inputs.
Each variant in this guide is its own binary. Rename host/src/main.rs
to host/src/struct.rs and register it in host/Cargo.toml, wiring the
host dependencies at the same time:
[dependencies]
tokio = { version = "1.52.1", features = ["rt", "rt-multi-thread", "macros"] }
zisk-sdk = { workspace = true }
primes-common = { path = "../common" }
[[bin]]
name = "struct-host"
path = "src/struct.rs"
Open host/src/struct.rs, import the SDK types and the shared InputDTO
and is_prime, and load the matching guest ELF:
use primes_common::{is_prime, InputDTO};
use zisk_sdk::{load_program, GuestProgram, ProverClient, ZiskStdin};
/// Guest ELF binary, embedded into the host at build time.
static PROGRAM: GuestProgram = load_program!("struct-guest");
In main, build the embedded prover, assemble the InputDTO, and write
it in a single call:
let client = ProverClient::embedded().build()?;
// Assemble the typed input and serialize it in a single write.
let input = InputDTO { values: vec![5, 11, 18, 23, 45] };
let stdin = ZiskStdin::new();
stdin.write(&input);
Then set up the program, prove, verify, and check the committed sum against the value computed locally:
client.setup(&PROGRAM).run_sync()?;
let proof = client.prove(&PROGRAM, stdin).run_sync()?;
if proof.with_program_vk(&PROGRAM.vk()?).verify().is_ok() {
println!("Proof was verified successfully.");
}
let expected = input.values.iter().filter(|n| is_prime(n)).sum::<u64>();
assert_eq!(proof.get_publics().read::<u64>()?, expected);
println!("sum-primes({:?}) => {expected}", input.values);
Putting it together, the complete host/src/struct.rs:
use primes_common::{is_prime, InputDTO};
use zisk_sdk::{load_program, GuestProgram, ProverClient, ZiskStdin};
/// Guest ELF binary, embedded into the host at build time.
static PROGRAM: GuestProgram = load_program!("struct-guest");
fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ProverClient::embedded().build()?;
// Assemble the typed input and serialize it in a single write.
let input = InputDTO { values: vec![5, 11, 18, 23, 45] };
let stdin = ZiskStdin::new();
stdin.write(&input);
// One-time setup, then prove.
client.setup(&PROGRAM).run_sync()?;
let proof = client.prove(&PROGRAM, stdin).run_sync()?;
// Verify against the guest's verification key.
if proof.with_program_vk(&PROGRAM.vk()?).verify().is_ok() {
println!("Proof was verified successfully.");
}
// The committed sum must match the value computed locally.
let expected = input.values.iter().filter(|n| is_prime(n)).sum::<u64>();
assert_eq!(proof.get_publics().read::<u64>()?, expected);
println!("sum-primes({:?}) => {expected}", input.values);
Ok(())
}
This host writes a framed InputDTO in a single call. The guest must
consume it the same way, with a single io::read::<InputDTO>() (the
Struct variant from Working with inputs).
If the guest is wired to read individual u64s or a raw byte slice
instead, the layouts will not match and the run will fail.
Run the host:
cargo run --release --bin struct-host
sum-primes => 39
If you cloned the example from the repository, the host accepts the input
values as trailing arguments after --:
cargo run --release --bin struct-host -- 5 11 18 23 45
The trailing values become the input list, and --gpu / --asm
additionally select a CUDA GPU or the native Assembly executor.
Writing multiple typed values
Now write a u64 length followed by each u64 value individually,
instead of one struct. Each stdin.write produces its own length
header, which is what lets the guest consume the values one at a time;
the matching guest is the Multiple writes variant from
Working with inputs. Copy host/src/struct.rs
to host/src/multiple.rs and register it as a second binary:
[[bin]]
name = "multiple-host"
path = "src/multiple.rs"
Only two things change from the struct host: point PROGRAM at the
matching guest.
static PROGRAM: GuestProgram = load_program!("multiple-guest");
This host writes a u64 length followed by each value as a separate
stdin.write call. The guest must consume it the same way, first an
io::read::<u64>() for the length, then one io::read::<u64>() per
value (the Multiple writes variant from
Working with inputs). If the guest is wired
to read a single InputDTO or a raw byte slice instead, the layouts will
not match and the run will fail.
Then replace the input assembly so the length and each value are written
separately. InputDTO is no longer needed, so the import becomes
use primes_common::is_prime; over a plain Vec<u64>:
// Write the length, then each value as its own framed write.
let values: Vec<u64> = vec![5, 11, 18, 23, 45];
let stdin = ZiskStdin::new();
stdin.write(&(values.len() as u64));
for value in &values {
stdin.write(value);
}
The setup, prove, verify, and the expected check stay the same, except
they read from values instead of input.values:
let expected = values.iter().filter(|n| is_prime(n)).sum::<u64>();
assert_eq!(proof.get_publics().read::<u64>()?, expected);
println!("sum-primes({:?}) => {expected}", values);
Run:
cargo run --release --bin multiple-host
sum-primes => 39
If you cloned the example from the repository, the host accepts the input
values as trailing arguments after --:
cargo run --release --bin multiple-host -- 5 11 18 23 45
The trailing values become the input list, and --gpu / --asm
additionally select a CUDA GPU or the native Assembly executor.
Same result. Writing primitives individually lets you source the length and the values from different places, validate or transform each before appending it, or build the input incrementally as data becomes available.
Writing bytes
Now let's drop the per-value bincode framing entirely. The host
serializes the whole payload once with rkyv
and pushes the resulting buffer in a single stdin.write_slice
call. This variant uses rkyv, which the primes-common crate already
re-exports, so no extra host dependency is needed, just register the
third binary:
[[bin]]
name = "slice-host"
path = "src/slice.rs"
Copy host/src/struct.rs to host/src/slice.rs. First swap the import
for the zero-copy type and the rkyv re-export, and point PROGRAM at the
matching guest:
use primes_common::{is_prime, rkyv, InputZeroCopyDTO};
static PROGRAM: GuestProgram = load_program!("slice-guest");
This host pushes a single rkyv-encoded buffer with stdin.write_slice,
so PROGRAM must point at the matching guest binary above. That guest
must consume the input the same way, with a single
io::read_input_slice() and an rkyv view onto InputZeroCopyDTO (the
Raw variant from Working with inputs). If
it is wired to read a bincode-framed InputDTO or individual u64s
instead, the layouts will not match and the run will fail.
Then replace the input assembly: serialize the payload once with rkyv and
push the bytes in a single stdin.write_slice:
// Serialize the whole payload once and push it as raw bytes.
let input = InputZeroCopyDTO { values: vec![5, 11, 18, 23, 45] };
let raw_input = rkyv::to_bytes::<rkyv::rancor::Error>(&input).unwrap();
let stdin = ZiskStdin::new();
stdin.write_slice(&raw_input);
The setup, prove, verify, and output check are the same as the struct
host. Putting it together, the complete host/src/slice.rs:
use primes_common::{is_prime, rkyv, InputZeroCopyDTO};
use zisk_sdk::{load_program, GuestProgram, ProverClient, ZiskStdin};
/// Guest ELF binary, embedded into the host at build time.
static PROGRAM: GuestProgram = load_program!("slice-guest");
fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ProverClient::embedded().build()?;
// Serialize the whole payload once and push it as raw bytes.
let input = InputZeroCopyDTO { values: vec![5, 11, 18, 23, 45] };
let raw_input = rkyv::to_bytes::<rkyv::rancor::Error>(&input).unwrap();
let stdin = ZiskStdin::new();
stdin.write_slice(&raw_input);
// One-time setup, then prove.
client.setup(&PROGRAM).run_sync()?;
let proof = client.prove(&PROGRAM, stdin).run_sync()?;
// Verify against the guest's verification key.
if proof.with_program_vk(&PROGRAM.vk()?).verify().is_ok() {
println!("Proof was verified successfully.");
}
// The committed sum must match the value computed locally.
let expected = input.values.iter().filter(|n| is_prime(n)).sum::<u64>();
assert_eq!(proof.get_publics().read::<u64>()?, expected);
println!("sum-primes({:?}) => {expected}", input.values);
Ok(())
}
Run:
cargo run --release --bin slice-host
sum-primes => 39
If you cloned the example from the repository, the host accepts the input
values as trailing arguments after --:
cargo run --release --bin slice-host -- 5 11 18 23 45
The trailing values become the input list, and --gpu / --asm
additionally select a CUDA GPU or the native Assembly executor.
Same result. Writing the payload as rkyv-encoded bytes skips the per-value bincode framing entirely and lets the guest map the struct onto the raw bytes with no allocation. All three approaches produce the same result because host and guest agree on the input layout in each case. Change one side without updating the other and the guest will read garbage.
Writing inputs as a stream
So far every variant has built the full input upfront and handed
directly to the client. For workloads where the
input is too large to fit in memory, or is produced incrementally
at runtime, the SDK exposes ZiskStream, a drop-in replacement
backed by a live transport (Unix socket, QUIC, or gRPC). The byte contract with
the guest is identical; only when the bytes arrive changes.
ZiskStream is only supported by the Assembly executor.
let client = ProverClient::embedded().assembly().build()?;
Choosing a transport
ZiskStream is created through one of four transport
constructors, each suited to a different deployment:
| Constructor | Transport | Use when |
|---|---|---|
ZiskStream::unix() | Unix domain socket at an auto-assigned /tmp/ path | Host and prover run on the same machine and you don't care about the socket path. |
ZiskStream::unix_at(path) | Unix domain socket at an explicit path | Same as above, but you need to control the path (sandboxes, multi-tenant hosts). |
ZiskStream::quic("quic://host:port") | QUIC over UDP | Host and prover live on different machines. |
ZiskStream::grpc() | gRPC push (frames sent via PushJobInput) | Cluster and remote-prover deployments where a coordinator drives the connection. |
Writing and flushing
The write surface mirrors ZiskStdin, plus a flush/reset pair
that controls the live transport:
| Method | Description |
|---|---|
write(&T) | Bincode-serializes a typed value and buffers the resulting frame. |
write_slice(&[u8]) | Buffers raw bytes with the standard length-header framing. |
write_bytes(&[u8]) | Buffers raw bytes with no framing — pair with a read_slice of a known size on the guest. |
flush() | Sends every buffered frame over the transport. Blocks until the prover is connected. |
reset() | Discards buffered frames that have not been sent yet. Already-flushed frames cannot be taken back. |
Writes accumulate in a local buffer; nothing leaves the host
until flush() runs. The first flush() also opens the transport
and waits for the prover to connect, so subsequent flushes only
pay the send cost.
Lifecycle
Each prove job moves a stream through three phases:
| Phase | What happens |
|---|---|
| Buffering | write / write_slice / write_bytes calls accumulate frames locally. Nothing crosses the transport yet. |
| Live | flush() opens the transport (if it isn't already open), waits for the prover to connect, and sends every buffered frame in order. Further writes are sent on the next flush(). |
| Closed | finish() tears the transport down. The SDK calls this automatically when the JobHandle resolves, so most callers never invoke it directly. |
Crucially, the prover blocks on every io::read* call inside the
guest until the matching frame lands. The host can take seconds
between flushes without the prover timing out.
The flip side is just as useful: the guest can consume each
frame the moment it arrives, instead of waiting for the entire
input to be buffered. A guest that calls io::read* in a loop
will keep working through values as fast as the host can push
them. Host production and guest consumption proceed pace-for-pace
across the transport, which is what lets streaming actually
overlap the two sides.
Streaming partial inputs
Streaming becomes useful when the input is produced incrementally.
Instead of buffering the whole input and proving in one call, the host
opens a live stream, launches the proof as a background job, and pushes
each frame with its own flush(). The guest's next io::read* unblocks
the moment that frame lands, so the producer and the prover advance in
lockstep instead of one waiting for the other.
We'll build it by adapting the multiple host. Copy host/src/multiple.rs
to host/src/stream.rs and register it:
[[bin]]
name = "stream-host"
path = "src/stream.rs"
Three things change from the multiple host.
First, swap ZiskStdin for ZiskStream in the import, and make main
async, the host now awaits a proving job while it keeps writing:
use zisk_sdk::{load_program, GuestProgram, ProverClient, ZiskStream};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
Second, streaming requires the Assembly prover, so enable it on the builder:
let client = ProverClient::embedded().assembly().build()?;
Third, replace the buffered writes and the run_sync proof with a live
stream: open a ZiskStream::unix(), write the length up front, launch
the proof as a job, then write and flush each value, and finally await
the job:
// Open a live stream and write the length up front.
let input: Vec<u64> = vec![5, 11, 18, 23, 45];
let stream = ZiskStream::unix();
stream.write(&(input.len() as u64));
client.setup(&PROGRAM).run_sync()?;
// Launch the guest; it parks on each io::read until the next frame arrives.
let job = client.prove(&PROGRAM, stream.clone()).run()?;
// Push each value as its own flushed frame.
for value in &input {
stream.write::<u64>(value);
stream.flush()?;
}
// Await completion and obtain the proof.
let proof = job.await?;
The verify and output check stay the same. Putting it together, the
complete host/src/stream.rs:
use primes_common::is_prime;
use zisk_sdk::{load_program, GuestProgram, ProverClient, ZiskStream};
static PROGRAM: GuestProgram = load_program!("multiple-guest");
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Streaming requires the Assembly prover, so enable it on the builder.
let client = ProverClient::embedded().assembly().build()?;
// Open a live stream and write the length up front.
let input: Vec<u64> = vec![5, 11, 18, 23, 45];
let stream = ZiskStream::unix();
stream.write(&(input.len() as u64));
// One-time setup.
client.setup(&PROGRAM).run_sync()?;
// Launch the guest; it parks on each io::read until the next frame arrives.
let job = client.prove(&PROGRAM, stream.clone()).run()?;
// Push each value as its own flushed frame.
for value in &input {
stream.write::<u64>(value);
stream.flush()?;
}
// Await completion and obtain the proof.
let proof = job.await?;
if proof.verify().is_ok() {
println!("Proof was verified successfully.");
}
let expected = input.iter().filter(|n| is_prime(n)).sum::<u64>();
assert_eq!(proof.get_publics().read::<u64>()?, expected);
println!("sum-primes({:?}) => {expected}", input);
Ok(())
}
The matching guest is the unmodified Multiple variant from
Working with inputs; it does not know it is
being streamed. Each io::read::<u64>() blocks until the host flushes
the next value, at which point the guest processes it immediately and
loops back for the next one.
Run:
cargo run --release --bin stream-host
sum-primes => 39
The two sides advance in lockstep across the transport: the host flushes
a value, the guest's next io::read::<u64>() returns, the loop body
runs, and the guest parks on the following read until the host produces
the next value. End-to-end latency tracks whichever is slower, the
producer or the prover, instead of the sum of both.
The guest does not know whether its input came from a buffered
ZiskStdin or a live ZiskStream. Both arrive on the same io::read*
calls, so any of the guest variants from
Working with inputs can be driven from a
stream as long as the host writes frames in the matching order.
Reading guest commited outputs
Once a proof is available, the host can read back whatever the guest committed. Public values are the only channel through which the guest exposes results that are cryptographically bound to the proof and cannot be forged. Whatever the guest committed is exactly what the host reads on the other side; the type and order must match exactly.
Read primitives
proof.get_publics() returns a &PublicValues that acts as a
cursor over the bytes the guest committed. It exposes three
primitives for consuming that cursor:
| Method | Use when | Description |
|---|---|---|
read::<T>() | The guest committed a typed Rust value | Reads the next bytes and deserializes them into T using bincode. Advances the cursor. T must implement serde::Deserialize. |
read_slice(&mut buf) | The guest committed raw bytes whose size you know | Fills buf from the cursor without deserialization. Advances the cursor by buf.len() bytes. |
Both primitives share a cursor, so order matters: each call
consumes from where the previous one left off. The third is
non-destructive and can be called any time, before or after the
others. The sum-primes guest commits a single u64 of value 39, which
bincode packs little-endian across two slots:
Reading typed outputs
The sum-primes guest commits a single u64, so one
read::<u64>() call drains the cursor and gives you the result
directly:
let proof = client.prove(&PROGRAM, stdin).run()?.await?;
let result: u64 = proof.get_publics().read::<u64>()?;
println!("sum-primes => {result}");
sum-primes => 39
Reading slice
To consume the same payload as raw bytes instead of letting
bincode reconstruct the typed value, pass a buffer of the right
size to read_slice. The sum-primes u64 fills eight bytes,
which you can then assemble back into a number with
u64::from_le_bytes:
let proof = client.prove(&PROGRAM, stdin).run()?.await?;
let mut buf = [0u8; 8];
proof.get_publics().read_slice(&mut buf)?;
let result = u64::from_le_bytes(buf);
println!("sum-primes => {result} (bytes: {:02x?})", buf);
sum-primes => 39 (bytes: [27, 00, 00, 00, 00, 00, 00, 00])
The bytes follow the same packed 32-bit slot layout the guest
produced with io::commit and io::commit_slice.
Summary
You now know how inputs flow into the guest and how public values flow back out. The host controls the producer side of the input and is able to read back what the guest commits as public values. As we've seen it is utterly important to keep both sides in sync.
Next steps
With I/O covered end to end, the natural next step is what to do with the proof itself:
- Verifying a proof: load a proof from disk and verify it programmatically.