Skip to main content

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:

MethodUse whenDescription
write(&T)You have a typed Rust valueSerializes value with bincode and appends it. T must implement serde::Serialize.
write_slice(&[u8])Data is already in byte formAppends 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:

Host Program
① stdin.write:::<u8>(&83)
② stdin.write::<u16>(&66)
③ stdin.write::<u8>(&93)
Input Buffer
len (u64)
0100000000000000
value + padding
5300000000000000
len (u64)
0200000000000000
value + padding
4200000000000000
len (u64)
0100000000000000
value + padding
5D00000000000000

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:

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

bash
cargo-zisk new primes
cd primes
note

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:

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

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");

In main, build the embedded prover, assemble the InputDTO, and write it in a single call:

host/src/struct.rs
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:

host/src/struct.rs
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:

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(())
}
WARNING

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:

bash
cargo run --release --bin struct-host
sum-primes => 39
Passing arguments to the cloned example

If you cloned the example from the repository, the host accepts the input values as trailing arguments after --:

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

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

Only two things change from the struct host: point PROGRAM at the matching guest.

host/src/multiple.rs
static PROGRAM: GuestProgram = load_program!("multiple-guest");
WARNING

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

host/src/multiple.rs
// 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:

host/src/multiple.rs
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:

bash
cargo run --release --bin multiple-host
sum-primes => 39
Passing arguments to the cloned example

If you cloned the example from the repository, the host accepts the input values as trailing arguments after --:

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

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

host/src/slice.rs
use primes_common::{is_prime, rkyv, InputZeroCopyDTO};

static PROGRAM: GuestProgram = load_program!("slice-guest");
WARNING

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:

host/src/slice.rs
// 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:

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:

bash
cargo run --release --bin slice-host
sum-primes => 39
Passing arguments to the cloned example

If you cloned the example from the repository, the host accepts the input values as trailing arguments after --:

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

Assembly executor required

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:

ConstructorTransportUse when
ZiskStream::unix()Unix domain socket at an auto-assigned /tmp/ pathHost 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 pathSame as above, but you need to control the path (sandboxes, multi-tenant hosts).
ZiskStream::quic("quic://host:port")QUIC over UDPHost 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:

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

PhaseWhat happens
Bufferingwrite / write_slice / write_bytes calls accumulate frames locally. Nothing crosses the transport yet.
Liveflush() 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().
Closedfinish() 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:

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

host/src/stream.rs
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:

host/src/stream.rs
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:

host/src/stream.rs
// 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:

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:

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

Same guest, different driver

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:

MethodUse whenDescription
read::<T>()The guest committed a typed Rust valueReads 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 knowFills 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:

Output Buffer
slot[0]
27000000
slot[1]
00000000
Host Program
① publics.read::<u64>() → 39

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:

host/src/main.rs
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:

host/src/main.rs
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: