Rand Protocol
Documentation
RISC-V programs

Write a program in Rust or C

The complete guide to your own confidential program - what a program can and cannot do, how to shape its inputs and outputs, a sealed-bid auction written in both Rust and C, testing, the proving budget, deploying with a public input, calling it and checking the result.

This page covers everything you need to put your own program on Rand: what a program can be, how to design what it reads and publishes, and a complete example written twice, once in Rust and once in C. It then covers testing, the proving budget, deploying, calling, and how anyone can check the result. Every rand-guest output below comes from a real run and is pasted as printed. The runs were made on 2026-09-25 at circuits commit 7ef3220. The testnet is now chain 21 (fullnode v0.8.0, which vendors circuits d217252, constraint set 9); chain 20’s toolchain, like chains 18 and 19’s, was built at aeacf31 (branch feat/v067-circuits; fullnode v0.6.8 pinned the same commit), and the outputs have not been re-run on either. A guest image is the same words whatever the constraint set, so a program built at aeacf31 deploys on chain 21; only the proofs are set 9’s, and the v0.8.0 wallet makes them. The page says where the SDK or the chain differs.

The two short tutorials are the quick path: Deploy a Rust program and Deploy a RISC-V program. This page assumes you have done step 1 of the Rust tutorial: a randprotocol/zkp-circuits checkout at aeacf31, Rust 1.98.1 with the riscv32im-unknown-none-elf target, the rand-guest binary, and for C, clang 23.1.1 and llvm-tools. The RISC-V ISA is the reference for the machine itself. Example programs goes on from here with larger programs and what chain 18’s gas metering changed. This page covers RPL-1 programs, which a Call runs statelessly; chains 20 and 21 also have RPL-2 program state, cells and a vault changed by an Invoke (Programs), with complete examples in randprotocol/example-programs.

1. What a program can be

A program is any computation that fits this shape:

  • It reads 32-bit words as input.
  • It publishes at most eight 32-bit words as output.
  • It is deterministic: the same inputs always give the same outputs.
  • It halts within its tier’s budget (section 7).

Inside those limits you can write anything. You get loops, recursion, structs, arrays, 64-bit arithmetic, crates and a heap if you bring an allocator. The machine is bare RV32IM, though, and there is no operating system under it:

you haveyou do not have
RV32IM integer instructions, MUL/DIV/REM includedfloating-point hardware (Rust’s software floats work, slowly)
up to 1 MiB of RAM from 0x1000 in the default linker script, with a 64 KiB stacka heap, unless you supply an allocator (section 6)
Poseidon2, Keccak-f and SHA-256 as syscallsfiles, network, clocks, randomness, threads, std, libc
no_std Rust crates and self-contained Cstate that survives from one call to the next
eight public output wordsa way to move RAND or tokens from inside the program

The last row matters most when you design a program. Outputs are data, not effects. A call proves that a program with a given digest ran on some private inputs and published eight words. The chain verifies the proof and stores the eight words in a receipt. It changes no balance and triggers nothing. Whatever the words mean is agreed between the program, its caller, and whoever reads the receipt. Programs lists what the chain does not support yet.

2. Design the interface first

A program has three channels. Choose what goes on each before you write any code.

channelset whenread withwho sees itlimit on chain 21 (as on chain 20)
private inputevery callread_input(i) / rand_read_input(i)only the caller, and anyone they hand the call’s key to16,071 words (the sealed input envelope’s cap)
public segmentonce, at deployread_public(i) / rand_read_public(i)everyone; the program id binds it32,768 words
outputsby the runwrite_output(slot, w) / rand_write_outputeveryone, in the receipt8 words

Private inputs prove nothing about the world on their own

A private input is bound to the proof by the salted commitment H_IN, so it cannot change after proving. But the program has no way to know that the input is true. The caller can type any number. To make a private input mean something, tie it to something public inside the program:

  • a commitment published earlier (in the public segment, or given to the verifier out of band) that the private input must open, as in the auction below;
  • a Merkle root that a private leaf must be proved under (Poseidon2 is the syscall for this);
  • a digest the verifier already holds, which the program recomputes and publishes as an output.

This is the central design skill for confidential programs. The proof tells the verifier what was computed. The binding to public data tells it over what.

Encoding data as words

Everything travels as u32:

dataencoding
a byte stringa length word, then the bytes four per word, little-endian, the last word zero-padded (the form the wallet uses for an ELF public input)
a u64 or amounttwo words, low then high
a signed integertwo’s complement in one word
a large resultits Poseidon2 digest: 8 words, which fills all the outputs; or publish a few summary words and a shorter commitment

The command-line tools take input words in decimal: rand-guest run --input and rand call --input do not accept 0x…. A public-segment file for rand program deploy --public accepts both decimal and 0x hex.

Shape the outputs

  • Slot 0 is a status word. Give each outcome a number, including the failures: an opening that does not match, too many items, input out of range. A failed check should still halt cleanly with a status, so that the caller gets a proof that says what went wrong.
  • Each slot is written at most once. A second write to the same slot is a trap (DoubleWrite(0) in the emulator), and a run that traps cannot be proved. Slots you never write read as zero.
  • In Rust, keep slot 7 free. A panicking Rust guest writes 0xdeadbeef (3,735,928,559) to slot 7 and halts. Outputs it wrote before the panic stay in place, so a reader must check slot 7 before believing the others (section 6).
  • Reading past the inputs you were given is a trap (InputIndex(5) in the emulator). Read the count first and check it.

3. The worked example: a sealed-bid auction

A seller wants to run a second-price auction without publishing the losing bids:

  1. Each bidder picks a bid and a random 128-bit salt, and gives the seller a commitment: Poseidon2 over ("BID1", bidder index, bid, salt).
  2. The seller deploys the auction program with the reserve price and every commitment as its public segment. The program id now names this auction and nothing else.
  3. Each bidder sends the seller their bid and salt privately.
  4. The seller calls the program with every bid and salt as private input. The program checks each opening against its commitment, picks the winner, and publishes [status, winner, price, bidder count].

The chain learns who won and at what price. It never sees the losing bids. A seller who changes any bid gets BAD_OPENING instead of a result, because the commitments are fixed in the program id.

Everything lives inside the circuits checkout, because rand-guest build finds guest-sdk by looking upward from the guest’s directory:

circuits/
  guest-sdk/
  rand-guest/
  my-guests/
    commit/          a Rust guest that computes one commitment
    auction-core/    the auction rule as a plain no_std library, tested on the host
    auction/         the Rust guest
    auction-c/       the same guest in C
    auction-c-test/  a host test for the C rule, outside the guest's directory

The emulator as a hash calculator

Bidders need Poseidon2 over the Goldilocks field, exactly as the machine computes it. The simplest correct tool is a tiny guest that computes it, run on the emulator. A digest is eight words, which is exactly the number of output slots.

my-guests/commit/Cargo.toml:

[package]
name = "commit"
version = "0.1.0"
edition = "2021"

[dependencies]
guest-sdk = { path = "../../guest-sdk" }

[profile.release]
opt-level = 3
lto = true
panic = "abort"
codegen-units = 1

[workspace]

my-guests/commit/src/main.rs:

#![no_std]
#![no_main]

use guest_sdk::{halt, poseidon2, read_input, write_output};

/// Inputs: a bidder's index, bid and four salt words.
/// Outputs: the 8-word commitment Poseidon2("BID1", index, bid, salt[0..4]).
#[no_mangle]
pub extern "C" fn main() -> ! {
    let mut buf = [0u32; 8];
    buf[0] = 0x4249_4431; // "BID1"
    for i in 0..6 {
        buf[1 + i] = read_input(i as u32);
    }
    unsafe { poseidon2(buf.as_mut_ptr(), 7) };
    for (slot, word) in buf.iter().enumerate() {
        write_output(slot as u32, *word);
    }
    halt();
}

poseidon2(ptr, n) hashes n words in place and writes the 8-word digest over ptr..ptr+8, so the buffer must hold at least eight words even though only seven are hashed. The "BID1" tag keeps these digests apart from any other Poseidon2 use.

At aeacf31 poseidon2 is an unsafe fn, because it takes a raw pointer, so the call sits in an unsafe block. At 7ef3220, where the outputs below were produced, it was a safe function and the same line was written without the block. The sponge does not pad: [a] and [a, 0] hash alike. That is harmless here, where every message is seven words and starts with a tag. For a message whose length can vary, the SDK at aeacf31 has poseidon2_len(buf, n), which binds the length (syscall 7), but rand-guest check at that commit refuses syscall 7, so such a guest does not build yet (zkp-circuits issue #1). Put the length in the message instead.

$ rand-guest build my-guests/commit --out commit.bin
76 words against a cap of 4096 (fits); 0 ecall(s) with a non-static a7
OK
wrote commit.bin and its .sha256 (76 words, hc 41007891b5c393296fdd76539c93d0b06678bc8a0bd52cfc3b6886dcf0381343, program id 11dabdf740989d43599b296c43342bf3ce890e980f5cc558d15ba8ca9e8e9438)

$ rand-guest run commit.bin --input 0 --input 400 --input 2654435769 --input 2135587861 --input 4087136352 --input 1559071514
out[0] = 3502385358
out[1] = 1187922325
out[2] = 2500412136
out[3] = 2452821097
out[4] = 2045657999
out[5] = 1866863173
out[6] = 678782893
out[7] = 3524569252
cycles 77
tier 10

The cargo lines are left out of the build outputs here and below. The unaligned-scalar-mem warning cargo prints is expected (Deploy a Rust program).

Four bidders, with these bids and salts:

bidderbidsalt (four words)
04002654435769 2135587861 4087136352 1559071514
1250461845907 3432918353 3864292196 2246822507
29003266489909 668265263 374761393 3550635116
33004252018373 3042594569 1759714724 625341585

Running commit.bin once per bidder and writing the reserve (100), the count (4) and the four digests in order gives the public segment, auction-public.txt, with 34 words:

100 4 3502385358 1187922325 2500412136 2452821097 2045657999 1866863173 678782893 3524569252 3413403069 1515911705 3633767467 1218237979 474331206 896634170 2889783686 541804293 3111308722 99534202 1917186958 1017842926 3082732585 4215265588 467906960 2929273893 588931287 1295911076 647129560 1384102451 625140897 4060943258 2970944086 698558155

In a real auction each bidder runs commit.bin on their own machine, and the salt comes from a real random source.

4. The program in Rust

The rule, as a library you can test

Keep the logic that does not need syscalls in its own no_std crate. guest-sdk refuses to build for any target but riscv32, so a guest crate cannot run cargo test on your laptop. A library crate with no guest-sdk dependency can.

my-guests/auction-core/Cargo.toml:

[package]
name = "auction-core"
version = "0.1.0"
edition = "2021"

[workspace]

my-guests/auction-core/src/lib.rs:

//! The auction's rule, with no syscalls, so it builds and tests on the host.
#![no_std]

#[derive(Debug, PartialEq, Eq)]
pub enum Outcome {
    /// The highest bidder wins and pays the second-highest bid, or the reserve if higher.
    Sold { winner: u32, price: u32 },
    /// No bid reached the reserve.
    NoSale,
}

/// A second-price (Vickrey) auction. Ties go to the lower index.
pub fn settle(reserve: u32, bids: &[u32]) -> Outcome {
    let mut best: Option<(u32, u32)> = None; // (index, bid)
    let mut second = 0u32;
    for (i, &bid) in bids.iter().enumerate() {
        match best {
            Some((_, top)) if bid <= top => second = second.max(bid),
            Some((_, top)) => {
                second = top;
                best = Some((i as u32, bid));
            }
            None => best = Some((i as u32, bid)),
        }
    }
    match best {
        Some((winner, top)) if top >= reserve => Outcome::Sold { winner, price: second.max(reserve) },
        _ => Outcome::NoSale,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn highest_bid_pays_the_second() {
        assert_eq!(settle(100, &[400, 250, 900, 300]), Outcome::Sold { winner: 2, price: 400 });
    }

    #[test]
    fn the_reserve_sets_a_floor() {
        assert_eq!(settle(500, &[400, 250, 900, 300]), Outcome::Sold { winner: 2, price: 500 });
        assert_eq!(settle(1000, &[400, 250, 900, 300]), Outcome::NoSale);
    }

    #[test]
    fn a_tie_goes_to_the_lower_index() {
        assert_eq!(settle(0, &[700, 700]), Outcome::Sold { winner: 0, price: 700 });
    }

    #[test]
    fn no_bids_is_no_sale() {
        assert_eq!(settle(0, &[]), Outcome::NoSale);
    }
}
$ cd my-guests/auction-core && cargo +1.98.1 test
running 4 tests
test tests::a_tie_goes_to_the_lower_index ... ok
test tests::highest_bid_pays_the_second ... ok
test tests::no_bids_is_no_sale ... ok
test tests::the_reserve_sets_a_floor ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

The guest

my-guests/auction/Cargo.toml depends on both crates by path:

[package]
name = "auction"
version = "0.1.0"
edition = "2021"

[dependencies]
guest-sdk = { path = "../../guest-sdk" }
auction-core = { path = "../auction-core" }

[profile.release]
opt-level = 3
lto = true
panic = "abort"
codegen-units = 1

[workspace]

my-guests/auction/src/main.rs:

#![no_std]
#![no_main]

use auction_core::{settle, Outcome};
use guest_sdk::{halt, poseidon2, read_input, read_public, write_output};

const MAX_BIDDERS: usize = 64;
const TAG: u32 = 0x4249_4431; // "BID1", the same tag the commit guest uses

// Output slot 0: what happened.
const SOLD: u32 = 1;
const NO_SALE: u32 = 2;
const BAD_OPENING: u32 = 3;
const TOO_MANY: u32 = 4;

/// Public (fixed at deploy): reserve, n, then n 8-word commitments.
/// Private (per call): for each bidder, the bid and four salt words.
/// Outputs: [status, winner, price, n, 0, 0, 0, 0]; a panic sets slot 7 to 0xdeadbeef.
#[no_mangle]
pub extern "C" fn main() -> ! {
    let reserve = read_public(0);
    let n = read_public(1) as usize;
    if n > MAX_BIDDERS {
        finish(TOO_MANY, 0, 0, n as u32);
    }

    let mut bids = [0u32; MAX_BIDDERS];
    for i in 0..n {
        let base = (5 * i) as u32;
        let bid = read_input(base);
        let mut buf = [TAG, i as u32, bid, read_input(base + 1), read_input(base + 2),
                       read_input(base + 3), read_input(base + 4), 0];
        unsafe { poseidon2(buf.as_mut_ptr(), 7) };
        let committed = 2 + 8 * i as u32;
        for w in 0..8 {
            if buf[w] != read_public(committed + w as u32) {
                finish(BAD_OPENING, i as u32, 0, n as u32);
            }
        }
        bids[i] = bid;
    }

    match settle(reserve, &bids[..n]) {
        Outcome::Sold { winner, price } => finish(SOLD, winner, price, n as u32),
        Outcome::NoSale => finish(NO_SALE, 0, 0, n as u32),
    }
}

fn finish(status: u32, winner: u32, price: u32, n: u32) -> ! {
    write_output(0, status);
    write_output(1, winner);
    write_output(2, price);
    write_output(3, n);
    halt();
}

Every exit path goes through finish, which writes each slot once and halts. The loop bound comes from the public segment, which is fixed at deploy. So a caller cannot make the program read fewer bids than were committed.

Build and run

$ rand-guest build my-guests/auction --out auction.bin
258 words against a cap of 4096 (fits); 0 ecall(s) with a non-static a7
OK
wrote auction.bin and its .sha256 (258 words, hc aa04e4c451e986f086e9fcff53b213eb52e593317c32a979d5e24991164f050d, program id 148ab0c4b9ad5115caab4a2ab433e3e13b5e8fbecbd9d8a9d6ba20c64f942c73)

rand-guest run takes the public segment as repeated --public flags. In a shell:

PUB=$(for w in $(cat auction-public.txt); do printf -- "--public %s " "$w"; done)
BIDS="--input 400 --input 2654435769 --input 2135587861 --input 4087136352 --input 1559071514
      --input 250 --input 461845907 --input 3432918353 --input 3864292196 --input 2246822507
      --input 900 --input 3266489909 --input 668265263 --input 374761393 --input 3550635116
      --input 300 --input 4252018373 --input 3042594569 --input 1759714724 --input 625341585"
rand-guest run auction.bin $PUB $BIDS --tier 10
out[0] = 1
out[1] = 2
out[2] = 400
out[3] = 4
out[4] = 0
out[5] = 0
out[6] = 0
out[7] = 0
cycles 641
tier 10
tier 10: fits (cycles 721 of 1023, Poseidon2 permutations 88 of 128)

Sold (1) to bidder 2 at 400, the second-highest bid, with four bidders counted. Two runs that should fail:

changeoutputs 0–3meaning
bidder 1’s bid 250 replaced by 2603 1 0 0BAD_OPENING at bidder 1; nothing else is published
reserve 1,000 instead of 100 (a different public segment, so a different program id)2 0 0 4NO_SALE: no bid reached the reserve

5. The same program in C

A C guest directory holds .c files and your own headers. rand-guest build --lang c supplies guest.h, _start and a small runtime (Deploy a RISC-V program). It compiles every .c file in the directory, so host test files belong somewhere else.

my-guests/auction-c/auction.h:

#ifndef AUCTION_H
#define AUCTION_H
#include <stdint.h>

/* Outcome of a second-price auction. sold is 0 when no bid reached the reserve. */
struct outcome {
    uint32_t sold;
    uint32_t winner;
    uint32_t price;
};

struct outcome settle(uint32_t reserve, const uint32_t *bids, uint32_t n);
#endif

my-guests/auction-c/settle.c:

#include "auction.h"

/* A second-price (Vickrey) auction. Ties go to the lower index. */
struct outcome settle(uint32_t reserve, const uint32_t *bids, uint32_t n) {
    struct outcome o = {0, 0, 0};
    if (n == 0) {
        return o;
    }
    uint32_t winner = 0, top = bids[0], second = 0;
    for (uint32_t i = 1; i < n; i++) {
        if (bids[i] > top) {
            second = top;
            top = bids[i];
            winner = i;
        } else if (bids[i] > second) {
            second = bids[i];
        }
    }
    if (top >= reserve) {
        o.sold = 1;
        o.winner = winner;
        o.price = second > reserve ? second : reserve;
    }
    return o;
}

my-guests/auction-c/main.c:

#include "guest.h"
#include "auction.h"

#define MAX_BIDDERS 64
#define TAG 0x42494431u /* "BID1", the same tag the commit guest uses */

enum { SOLD = 1, NO_SALE = 2, BAD_OPENING = 3, TOO_MANY = 4 };

static uint32_t bids[MAX_BIDDERS];

static void finish(uint32_t status, uint32_t winner, uint32_t price, uint32_t n) {
    rand_write_output(0, status);
    rand_write_output(1, winner);
    rand_write_output(2, price);
    rand_write_output(3, n);
    rand_halt();
}

/* Public (fixed at deploy): reserve, n, then n 8-word commitments.
 * Private (per call): for each bidder, the bid and four salt words.
 * Outputs: [status, winner, price, n, 0, 0, 0, 0]. */
void main(void) {
    uint32_t reserve = rand_read_public(0);
    uint32_t n = rand_read_public(1);
    if (n > MAX_BIDDERS) {
        finish(TOO_MANY, 0, 0, n);
    }
    for (uint32_t i = 0; i < n; i++) {
        uint32_t base = 5 * i;
        uint32_t buf[8] = {TAG, i, rand_read_input(base), rand_read_input(base + 1),
                           rand_read_input(base + 2), rand_read_input(base + 3),
                           rand_read_input(base + 4), 0};
        bids[i] = buf[2];
        rand_poseidon2(buf, 7);
        for (uint32_t w = 0; w < 8; w++) {
            if (buf[w] != rand_read_public(2 + 8 * i + w)) {
                finish(BAD_OPENING, i, 0, n);
            }
        }
    }
    struct outcome o = settle(reserve, bids, n);
    if (o.sold) {
        finish(SOLD, o.winner, o.price, n);
    }
    finish(NO_SALE, 0, 0, n);
}

bids is a zero-initialised static, so it goes in .bss. That costs no image words, because RAM already reads zero.

Test the rule on the host

settle.c uses no syscall, so your normal compiler can build it. my-guests/auction-c-test/test_settle.c:

#include <assert.h>
#include <stdio.h>
#include "../auction-c/auction.h"

int main(void) {
    uint32_t bids[] = {400, 250, 900, 300};
    struct outcome o = settle(100, bids, 4);
    assert(o.sold && o.winner == 2 && o.price == 400);
    o = settle(500, bids, 4);
    assert(o.sold && o.price == 500);
    o = settle(1000, bids, 4);
    assert(!o.sold);
    uint32_t tie[] = {700, 700};
    o = settle(0, tie, 2);
    assert(o.winner == 0 && o.price == 700);
    puts("settle: 4 checks passed");
    return 0;
}
$ cc -Wall -o test_settle my-guests/auction-c-test/test_settle.c my-guests/auction-c/settle.c && ./test_settle
settle: 4 checks passed

Build and run

$ rand-guest build --lang c my-guests/auction-c --out auction-c.bin
200 words against a cap of 4096 (fits); 0 ecall(s) with a non-static a7
OK
wrote auction-c.bin and its .sha256 (200 words, hc 68605aa8d28a863a4a52a25de07ba159327a0fd0145a9dcd5e5aa6e79ab6bea0, program id 5667c343b8848805a908c9009d426164f39d2f927b2c780529a054f0d66aafc1)

$ rand-guest run auction-c.bin $PUB $BIDS --tier 10
out[0] = 1
out[1] = 2
out[2] = 400
out[3] = 4
out[4] = 0
out[5] = 0
out[6] = 0
out[7] = 0
cycles 576
tier 10
tier 10: fits (cycles 641 of 1023, Poseidon2 permutations 73 of 128)

The outputs match the Rust guest’s. With four bidders, the C image is 58 words smaller and 65 cycles faster; section 7 shows the balance tipping at 64 bidders. The two are different programs with different hcs and program ids. Deploy whichever one you publish the source of.

6. Language notes

Rust

The crate. #![no_std], #![no_main], and a #[no_mangle] pub extern "C" fn main() -> ! that ends in halt(). The release profile sets panic = "abort". An empty [workspace] table keeps the crate out of any parent workspace. Leave out .cargo/config.toml and your own compiler flags: rand-guest build refuses the first and sets all of the second (Deploy a Rust program).

Panics. guest-sdk’s panic handler writes 0xdeadbeef to slot 7 and halts. The run is still a valid, provable run. This program reads index 999 of a six-element Vec after writing slots 0 and 1:

$ rand-guest run heap.bin --input 6 --input 1 --input 2 --input 3 --input 4 --input 5 --input 6
out[0] = 4
out[1] = 1080033280
out[2] = 0
out[3] = 0
out[4] = 0
out[5] = 0
out[6] = 0
out[7] = 3735928559
cycles 1326
tier 12

Slots 0 and 1 hold real values, and slot 7 says the run panicked. A caller who submits such a run gets a receipt on chain. Whoever reads receipts must reject slot 7 = 3,735,928,559, or the program must never panic. Prefer get(), checked arithmetic and explicit status codes on every path.

A heap. There is no allocator by default. alloc works once you provide one. A bump allocator over a fixed arena suits a program that runs once and exits:

extern crate alloc;

use alloc::vec::Vec;
use core::alloc::{GlobalAlloc, Layout};
use core::cell::UnsafeCell;

/// A bump allocator over a fixed arena: allocation moves a pointer forward, free does nothing.
/// One run is one short-lived process, so nothing needs to be reclaimed.
struct Bump {
    arena: UnsafeCell<[u8; ARENA]>,
    next: UnsafeCell<usize>,
}
const ARENA: usize = 256 * 1024;
unsafe impl Sync for Bump {}

unsafe impl GlobalAlloc for Bump {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let next = &mut *self.next.get();
        let start = (*next + layout.align() - 1) & !(layout.align() - 1);
        let end = start + layout.size();
        if end > ARENA {
            return core::ptr::null_mut();
        }
        *next = end;
        (self.arena.get() as *mut u8).add(start)
    }
    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {}
}

#[global_allocator]
static HEAP: Bump = Bump { arena: UnsafeCell::new([0; ARENA]), next: UnsafeCell::new(0) };

The arena is zeroed, so it sits in .bss and adds no image words. The default linker script gives 1 MiB of RAM, and the arena, your statics and the 64 KiB stack must fit inside it. With this allocator, Vec, collect() and iter().sum() all build and pass check. alloc and its dependencies add code, though: this test program is 1,341 words, against 258 for the auction. Section 7 explains why program size costs proving budget.

Floating point. The target has no float instructions, and Rust lowers f32/f64 to software routines. They work: the mean of 9, 2, 7, 4 and 5 computed in f64 came back as the bits of 5.4f32. They cost many cycles each. Use integers or fixed point where you can.

Crates. no_std crates from crates.io build like any dependency. The crc crate’s CRC-32 of hello, rand, run on the emulator, printed 2124296908, the same value Python’s zlib.crc32 gives. Crates that need std do not build for this target. rand-guest build does not pass --locked, so commit your Cargo.lock: without it, a rebuild months later may resolve a newer crate and produce a different hc.

The unimp trap: a Csr finding from library code. Some core code compiles to an unimp word (0xc0001073, csrrw x0, cycle, x0) on a path LLVM considers unreachable. The machine has no CSRs, so check refuses the image:

0x00002250  0xc0001073  Csr: CSR instruction: the machine has no CSRs
2613 words against a cap of 4096 (fits); 0 ecall(s) with a non-static a7
REJECTED
wrote heap.bin (the loader refuses it: no hc)

The offender here was slice::sort_unstable on a Vec whose length is known only at run time. Stable sort() did the same, while sort_unstable on a fixed 16-element array passed. To find the source of such a finding, disassemble the guest’s ELF and look at the function around the address check printed:

llvm-objdump -d -C my-guests/heap/target/riscv32im-unknown-none-elf/release/heap | grep -B12 unimp

Then replace that call with code of your own (for this program, a short insertion sort). guest-sdk’s own halt() ends in loop {} rather than a noreturn promise for the same reason.

C

  • The entry point is void main(void), and it ends in rand_halt(). The bundled _start sets the stack and calls it.
  • There is no libc. No printf, malloc or <string.h>. The runtime provides memcpy, memmove, memset and memcmp (clang emits calls to them for struct copies and large initialisers) and the four 64-bit division helpers. All of them are byte loops, so copy and divide in words in hot code. For a heap, write a bump allocator over a static array, as in Rust.
  • Undefined behaviour is not caught. There is no panic handler. An out-of-bounds write corrupts memory silently, and a misaligned load or store traps, which means the run cannot be proved. Check indices against your own limits, as main.c checks n against MAX_BIDDERS.
  • Directory rules. Do not add your own guest.h or start.S; build --lang c refuses both. Keep test files outside the guest directory, since every .c file in it is compiled into the image.
  • Pinned clang. rand-guest refuses any clang other than 23.1.1. RAND_GUEST_CLANG_UNPINNED=1 overrides that, with a warning that your hc will not match anyone else’s build.

7. Will it prove? The budget

The prover pads each run to a tier t. A tier has two independent budgets, and rand-guest run --tier t reports both:

  • rows: at most 2ᵗ − 1, counting the cycles executed plus the digest rows;
  • Poseidon2 permutations: at most 2ᵗ⁻³.
tierrowsPoseidon2 permutationsgas ceiling with no hash tablea call on chain 21
101,0231281,279accepted
124,0955125,119accepted
1416,3832,04820,479accepted
1665,5358,19281,919refused since v0.5.6
18, 202¹⁸ − 1, 2²⁰ − 12¹⁵, 2¹⁷327,679, 1,310,719refused since v0.5.6

The fee follows gas and bytes, not the tier. On chain 15 a call paid a flat 0.001 + call_fee(tier): 0.002 RAND at tier 10, 0.0021 at tier 12 and 0.0022 at tier 14. Since chain 18 every run is metered. An ordinary row is 1 gas, a Poseidon2 absorb row 3, a Keccak permutation 192 and a SHA-256 compression 64. The proof publishes a declared gas limit, the circuit enforces that the run’s gas is at or under it, and the exact gas stays private. The ceiling a tier can declare with no hash table is (2ᵗ − 1) + 2ᵗ⁻², the column above. The fee floor is

floor = BUNDLE_BASE + gas_price × declared gas limit + byte_price × ⌈bytes / 1024⌉

with BUNDLE_BASE 0.001 RAND, the bytes being the call proof plus the input envelope, and prices that started at 100 units per gas and 800 units per KiB (a unit is 10⁻⁹ RAND) and move a little each block. Since chain 20 they cannot rise past 10,000 and 80,000 units, and only calls’ proof and envelope bytes move the byte price; on chains 18 and 19 there was no ceiling. On chain 21 the whole floor is burned (genesis fees.burn_floor) and the proposer keeps only what is paid above it; the floor itself is unchanged. Section 9 shows what the wallet pays by default, and Example programs has four measured calls on chain 18, at 0.0057 to 0.0075 RAND each.

Tier 14 is the highest tier a call can use. Since fullnode v0.5.6 (2026-09-25), a node refuses any call proof above tier 14 with CallTierTooHigh, before it builds a verifier key. The same rule makes a block that carries such a call invalid. The limit protects validators with 2–4 GB of memory: a tier-20 verifier key takes minutes and gigabytes to build. rand call does not refuse such a call before it proves, though since chain 20 it prints the tier first. Run rand-guest run … --tier 14 before you call, and if the run does not fit, restructure the program. Before a deploy, rand program deploy --input <word> … (or --check-call for a call with no inputs) runs the call you have in mind through the emulator and refuses to deploy unless it lands at or under tier 14.

What spends the budget

The digest rows and permutations are the surprise. Before your code runs, the machine hashes the program, the private inputs and the public segment into hc, H_IN and H_PUB. Each costs about one permutation and one row per four words. On top of that, every POSEIDON2 call you make costs about one permutation per four words hashed. For the auction at tier 10:

itemwordspermutations
the program (hc)258about 65
the private inputs (H_IN)20about 5
the public segment (H_PUB)34about 9
four commitment checks, 7 words each4 × 78
total, as run reports it88 of 128

Rows: 641 cycles plus the digest rows came to 721 of 1,023.

On chain 21, as on chains 19 and 20, the wallet’s own count is slightly higher than rand-guest run’s. A call proof there is bound to its transaction (since chain 20 the binding also includes the genesis hash, at the same eight words), and the binding travels as eight more words of public segment: two more digest rows and two more permutations for a program with a public segment, one more of each for a program without one.

This makes program size part of the budget. The heap test program ran only 1,183 cycles, yet its 1,341 words took 340 permutations and pushed it to tier 12. At tier 14’s 2,048 permutations, a program much above about 8,000 words cannot be called at all, whatever it does. On chain 21 the exact bound is 8,180 words for a program with no public segment and a call with no inputs, and less with either. The program cap on chain 21 is 65,535 words, as it was on chain 15, but every chain since 19 refuses to deploy a program over that bound (ProgramUncallable), where chain 15 took the deploy fee for a program nobody could call. That is why chain 13’s translated ERC-20 (11,686 words, called at tier 16) can be neither deployed nor called on chain 21. Keep programs small: opt-level = "s" in Rust, -Os (already set) in C, few dependencies, no alloc unless you need it.

The auction grows linearly: each extra bidder adds 5 input words, 8 public words, 2 permutations for the check, and about a hundred cycles. With 64 bidders (random bids, 320 input words, 514 public words), both builds pick the same winner and price, and both need tier 14:

auction.bin    tier 14: fits (cycles 6557 of 16383, Poseidon2 permutations 403 of 2048)
auction-c.bin  tier 14: fits (cycles 8571 of 16383, Poseidon2 permutations 388 of 2048)

The permutations would fit tier 12’s 512, but the rows do not fit its 4,095. The cycles, not the hashing, set the tier here. At 64 bidders the Rust build is the faster one. Its opt-level = 3 beats the C build’s fixed -Os once the loop dominates.

Keccak and SHA-256

keccak and sha256_compress each cost one row, but a proof that uses either one carries an extra table. At aeacf31 both take an array reference, keccak(&mut state) over a [u32; 50] and sha256_compress(&mut buf) over a [u32; 24], where 7ef3220 took a raw pointer. Since v0.5.6 a call may use at most 128 Keccak-f permutations and at most 128 SHA-256 compressions. Since chain 18 a Keccak permutation also costs 192 gas and a SHA-256 compression 64.

The table makes the proof larger, and chain 21 caps a call proof at 6,291,456 bytes (6 MiB, raised at its cut for the private-multisig authorisation proof; 4 MiB on chains 17 to 20, 8 MiB on chains 13 to 16). On chain 18 a tier-12 call with the Keccak table measured 3.35 MB, most of the cap, and a tier-14 call with the SHA-256 table 1.91 MB, against 1.50 MB with neither (Example programs). Every KiB is charged at the byte price. On chain 15 only the bytes past a free 2 MiB + 18,432 were charged, at 1,000 units per KiB (Fees). Use Poseidon2 unless you must match an external hash.

Memory and time to prove

Proving time and memory grow steeply with the tier. A tier-10 call proves in seconds on a laptop. Since chain 20 every deploy and every call also carries two bundle-side proofs: a tier-10 spend authorisation (seconds) and the tier-14 bundle that pays the fee (about two minutes on a laptop). The four calls measured on chain 18 took between three and five and a half minutes each and peaked at 6.1 to 6.6 GB (Example programs). On chain 15 the bundle was a single tier-14 proof of about 100 s and about 5.7 GB. Validator and aggregator hardware has the measured figures.

8. Deploy

The testnet is chain 21 (live since 2026-10-12). Deploying needs the rand wallet from fullnode v0.8.0, a key and some RAND (Introduction; chain 21’s faucet, like chain 20’s, mints only to the 16 wallets its genesis allowlists). An older wallet cannot send on chain 21: it runs constraint set 9, which changed every verifier key and the proof encoding, so a v0.6.8 or v0.7.x wallet’s proofs do not decode there; and, as since chain 20, the genesis sets binding_domain: 1, so every proof binding and signed message includes the genesis hash. Chain 21 started with no programs (nothing carries over a cut), so the auction below would be a first deploy there. The public endpoint https://rpc.randprotocol.org is enough to sync the wallet, deploy and call (setup). The auction deploys with its public segment:

rand fee deploy 258 --public-words 34                 # 0.001 + 0.0001 × (258 + 34) = 0.0302 RAND
rand program deploy auction.bin --public auction-public.txt

program deploy prints the program id, hc, the word count, and the public input’s word count and digest. It checks the program against the chain’s cap and the public input against max_program_public_words (32,768 on chains 20 and 21) before it proves anything. It then proves a zero-value self-transfer bundle that pays the fee, submits it, and waits for the commit. On chain 21, as since chain 17, that is two proofs, each with its own line of output: a tier-10 spend authorisation (seconds, about 1.37 MB) and the tier-14 bundle (about two minutes, about 1.5 MB). On chain 15 it was one tier-14 proof of about 100 s and about 1.42 MB. The deploy fee has no gas term. The auction has not been deployed on chains 18 to 20; An anonymous vote shows a deploy with a public segment on chain 18 as the wallet printed it.

The program id changes with --public. rand-guest printed 148ab0c4…2c73 for the code alone. A deploy with a public segment is stored under blake3("rand-program-2", base_pc ‖ len ‖ words ‖ len ‖ public), which binds the reserve and the four commitments too. Use the id program deploy prints. A second auction with the same code and different commitments is a different program id.

rand program show <auction id>        # base_pc, words_len, code_hash, deployed_at, public_words_len, public_digest

code_hash is the hc that rand-guest printed, spelled differently. rand-guest prints each of the digest’s eight words big-endian, and the wallet, rand program show and rand_getProgram print each word’s bytes little-endian, so every group of eight hex digits is byte-reversed. For the builds above, aa04e4c4…050d (Rust) appears as c4e404aa…4f16, and 68605aa8…bea0 (C) as a85a6068…b69a.

9. Call

The seller calls with all 20 private words:

rand call <auction id> --expect-public auction-public.txt \
  --input 400 --input 2654435769 --input 2135587861 --input 4087136352 --input 1559071514 \
  --input 250 --input 461845907  --input 3432918353 --input 3864292196 --input 2246822507 \
  --input 900 --input 3266489909 --input 668265263  --input 374761393  --input 3550635116 \
  --input 300 --input 4252018373 --input 3042594569 --input 1759714724 --input 625341585

The wallet fetches the program’s code and public segment and checks that they hash to the id. --expect-public also refuses, before proving, if the public segment is not that file’s words. The wallet then runs the call in the emulator and prints the exact gas, the limit it will declare, the tier (the smallest the run fits, tier 10 here), and the fee:

gas: <exact> (declaring <limit>, tier <t>)
fee <amount> RAND (incl. two price steps of headroom)

It seals the 20 input words in an envelope only the caller can open, proves the run locally, pays the fee through a bundle, submits, waits and prints the receipt. Since chain 20 that is three proofs: the call, the tier-10 spend authorisation and the tier-14 bundle.

What the call costs. On chain 15 this call paid 0.001 + call_fee(10) = 0.002 RAND. Since chain 20 the wallet pays the floor from section 7, worked out before the proof exists, with three defaults:

  • the declared limit is the exact gas rounded up to a multiple of 2^(tier − 2): 256 at tier 10, 1,024 at tier 12, 4,096 at tier 14. --gas-limit max declares the tier’s ceiling and --gas-limit N an exact figure;
  • the proof is priced at the chain’s cap, 6,291,456 bytes on chain 21 (4,194,304 on chain 20), not at its real size, because a call proof since chain 20 is bound to its transaction and the fee must be fixed first;
  • two price steps of headroom, a factor of 1.125², because the call lands two or three blocks after it is priced.

--fee overrides the default, and rand fee call <tier> --gas <limit> --bytes <B> asks the node for the floor itself. The auction’s call has not been made on chains 18 to 20, so its gas line and fee are not shown here. An anonymous vote has a complete call on chain 18 as the wallet printed it, at 0.0062 RAND.

The receipt carries the tier, H_IN, H_PUB and the outputs [1, 2, 400, 4, 0, 0, 0, 0]: the same words the emulator printed, because the proof is of that same execution. The four bids and salts are not on chain. --auditor <rand1…> also seals the inputs to a named auditor, and --no-envelope publishes no transcript at all, which cannot be undone (Programs).

10. Check the result

A bidder, or anyone else, can check the auction without trusting the seller:

  1. The code. Rebuild the published source with rand-guest build at the same circuits commit, Rust 1.98.1 (or clang 23.1.1). Compare hc with the code_hash from rand program show or rand_getProgram, allowing for the byte-reversed spelling (section 8). The build is reproducible, so the two match on any machine.
  2. The public segment. Fetch it with rand_getProgramPublic, or run rand call --expect-public. Check that your own commitment is at your index.
  3. The receipt. rand receipt <tx hash> or rand_getReceipt: status 1, the winner and the price, slot 7 not 0xdeadbeef, and program equal to the auction’s id.
  4. The inputs, if you are entitled to them. The seller’s rand open-call <tx hash> (or an auditor’s --as-auditor) opens the sealed inputs, checks them against H_IN, re-runs the program, and exits non-zero on any mismatch. Use a v0.6.8 or later wallet (v0.8.0 on chain 21): since v0.6.8 the re-run is made over the program’s public segment and the call binding, so it works for the auction (fullnode issue #116). In v0.6.7 open-call stopped with re-running the program on these inputs failed: PublicIndex(0) for any program deployed with a public segment.

The proof guarantees the rule was applied to openings of the committed bids. It does not move money. The winner pays the seller with an ordinary rand send, and the receipt is what both sides point to.

Troubleshooting

symptomcause and fix
the build fails at a poseidon2(…), keccak(…) or sha256_compress(…) call written for an older commitat aeacf31 poseidon2 is an unsafe fn and the other two take &mut [u32; 50] and &mut [u32; 24] (sections 3 and 7)
check reports Syscall with a7 = 7the guest calls poseidon2_len; rand-guest check at aeacf31 does not list syscall 7 (zkp-circuits issue #1)
check reports Csr at 0xc0001073library code compiled to unimp; find the function with llvm-objdump and replace the call (section 6)
run says the run fits no tier, or needs more than tier 14too many rows or Poseidon2 permutations; shrink the program, hash less, or split the work across several calls, carrying a digest between them
slot 7 = 3735928559the Rust guest panicked; outputs before it are unreliable
trap: DoubleWrite(n)output slot n written twice; route every exit through one function
trap: InputIndex(i)the program read a private input the caller did not supply; read and check a count first
trap: … on a load or storea misaligned or out-of-range address; in C, an array index past its bound
invalid value '0x…' for '--input'input words are decimal on the command line
a call refused with CallTierTooHighthe proof is above tier 14; check with rand-guest run --tier 14 before calling
the rebuilt hc differs from the deployed onedifferent circuits commit, toolchain, or dependency versions; pin all three and commit Cargo.lock. If only the spelling differs, each group of eight hex digits is byte-reversed (section 8)
rand open-call stops with PublicIndex(0)the program has a public segment and the wallet is v0.6.7, whose re-run passes none (fullnode issue #116); use v0.6.8 or later
… is not inside a circuits checkoutmove the guest directory inside the checkout (Deploy a Rust program)

Last verified against fullnode commit c0878c3f (chain 21) on 2026-10-12, and circuits `7ef3220` (`guest-sdk/src/lib.rs`, `rand-guest/guest.h`, `rand-guest/rt.c`, `rand-guest/README.md`, `research/docs/01-isa.md`, `research/src/machine.rs`); every `rand-guest` and `cargo test` output on this page was produced on 2026-09-25, fullnode `origin/main` `089bdd6` (`docs/cli.md`; `docs/confidential.md` and `executor.rs` for v0.5.6's call-proof caps, commit `1588be9`), circuits `aeacf31` (`guest-sdk/src/lib.rs`, `rand-guest/src/check.rs`, `rand-guest/guest.h`; `git diff 7ef3220 aeacf31 -- guest-sdk rand-guest`), read on 2026-09-30; no output on this page was re-run at that commit, fullnode `90e7716` (`docs/cli.md`, `docs/fees.md` §1.1, `docs/confidential.md`, `docs/guests.md` §7.1, `deploy/genesis-chain18.json`) and the wallet at tag v0.6.7 `86941a1` (`crates/randprotocol-client/src/main.rs`, `wallet.rs`; `gas.rs`, `ledger/mod.rs`, `executor.rs`), read on 2026-09-30; the auction was not deployed or called on chain 18, fullnode tag v0.6.8 `c9c9bd3c` (`docs/program-state.md`, `crates/randprotocol-core/src/ledger/program_state.rs`, the client's `open-call` (#116)) and `rand_getLimits` on chain 20, read on 2026-10-01; the auction was not deployed or called on chain 20, fullnode tag v0.8.0 `c0878c3f` (`CHANGELOG.md`, `docs/fees.md` §1.3) and `rand_getLimits` on chain 21, read on 2026-10-12; the limits this page quotes are chain 21's where they differ from chain 20's; nothing on this page was deployed or called on chain 21. Where the code and this page disagree, the code is right; tell us.