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 have | you do not have |
|---|---|
RV32IM integer instructions, MUL/DIV/REM included | floating-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 stack | a heap, unless you supply an allocator (section 6) |
| Poseidon2, Keccak-f and SHA-256 as syscalls | files, network, clocks, randomness, threads, std, libc |
no_std Rust crates and self-contained C | state that survives from one call to the next |
| eight public output words | a 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.
| channel | set when | read with | who sees it | limit on chain 21 (as on chain 20) |
|---|---|---|---|---|
| private input | every call | read_input(i) / rand_read_input(i) | only the caller, and anyone they hand the call’s key to | 16,071 words (the sealed input envelope’s cap) |
| public segment | once, at deploy | read_public(i) / rand_read_public(i) | everyone; the program id binds it | 32,768 words |
| outputs | by the run | write_output(slot, w) / rand_write_output | everyone, in the receipt | 8 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:
| data | encoding |
|---|---|
| a byte string | a 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 amount | two words, low then high |
| a signed integer | two’s complement in one word |
| a large result | its 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:
- Each bidder picks a bid and a random 128-bit salt, and gives the seller a commitment: Poseidon2 over
("BID1", bidder index, bid, salt). - 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.
- Each bidder sends the seller their bid and salt privately.
- 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:
| bidder | bid | salt (four words) |
|---|---|---|
| 0 | 400 | 2654435769 2135587861 4087136352 1559071514 |
| 1 | 250 | 461845907 3432918353 3864292196 2246822507 |
| 2 | 900 | 3266489909 668265263 374761393 3550635116 |
| 3 | 300 | 4252018373 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:
| change | outputs 0–3 | meaning |
|---|---|---|
| bidder 1’s bid 250 replaced by 260 | 3 1 0 0 | BAD_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 4 | NO_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 inrand_halt(). The bundled_startsets the stack and calls it. - There is no libc. No
printf,mallocor<string.h>. The runtime providesmemcpy,memmove,memsetandmemcmp(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 astaticarray, 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.cchecksnagainstMAX_BIDDERS. - Directory rules. Do not add your own
guest.horstart.S;build --lang crefuses both. Keep test files outside the guest directory, since every.cfile in it is compiled into the image. - Pinned clang.
rand-guestrefuses any clang other than 23.1.1.RAND_GUEST_CLANG_UNPINNED=1overrides that, with a warning that yourhcwill 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ᵗ⁻³.
| tier | rows | Poseidon2 permutations | gas ceiling with no hash table | a call on chain 21 |
|---|---|---|---|---|
| 10 | 1,023 | 128 | 1,279 | accepted |
| 12 | 4,095 | 512 | 5,119 | accepted |
| 14 | 16,383 | 2,048 | 20,479 | accepted |
| 16 | 65,535 | 8,192 | 81,919 | refused since v0.5.6 |
| 18, 20 | 2¹⁸ − 1, 2²⁰ − 1 | 2¹⁵, 2¹⁷ | 327,679, 1,310,719 | refused 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:
| item | words | permutations |
|---|---|---|
the program (hc) | 258 | about 65 |
the private inputs (H_IN) | 20 | about 5 |
the public segment (H_PUB) | 34 | about 9 |
| four commitment checks, 7 words each | 4 × 7 | 8 |
total, as run reports it | 88 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 maxdeclares the tier’s ceiling and--gas-limit Nan 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:
- The code. Rebuild the published source with
rand-guest buildat the same circuits commit, Rust 1.98.1 (or clang 23.1.1). Comparehcwith thecode_hashfromrand program showorrand_getProgram, allowing for the byte-reversed spelling (section 8). The build is reproducible, so the two match on any machine. - The public segment. Fetch it with
rand_getProgramPublic, or runrand call --expect-public. Check that your own commitment is at your index. - The receipt.
rand receipt <tx hash>orrand_getReceipt: status1, the winner and the price, slot 7 not0xdeadbeef, andprogramequal to the auction’s id. - 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 againstH_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.7open-callstopped withre-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
| symptom | cause and fix |
|---|---|
the build fails at a poseidon2(…), keccak(…) or sha256_compress(…) call written for an older commit | at 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 = 7 | the guest calls poseidon2_len; rand-guest check at aeacf31 does not list syscall 7 (zkp-circuits issue #1) |
check reports Csr at 0xc0001073 | library 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 14 | too many rows or Poseidon2 permutations; shrink the program, hash less, or split the work across several calls, carrying a digest between them |
| slot 7 = 3735928559 | the 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 store | a 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 CallTierTooHigh | the proof is above tier 14; check with rand-guest run --tier 14 before calling |
the rebuilt hc differs from the deployed one | different 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 checkout | move the guest directory inside the checkout (Deploy a Rust program) |