This page takes a small Rust program from source to a deployed program on Rand. Every rand-guest command below was run, and its output is pasted, not summarised. The runs were made on 2026-09-19 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 (fullnode v0.6.8 pinned the same commit as v0.6.7), 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 RISC-V ISA explains the machine, the syscalls and the file format.
1. Install the toolchain
hc depends on the exact compiler, so the toolchain is pinned.
| tool | version | why |
|---|---|---|
| Rust | 1.98.1 | compiles the guest and rand-guest itself |
the riscv32im-unknown-none-elf target | for 1.98.1 | the guest’s target |
| a circuits checkout | randprotocol/zkp-circuits at aeacf31 for chains 18 to 20 | holds rand-guest, guest-sdk and the linker script |
git clone https://github.com/randprotocol/zkp-circuits circuits
cd circuits
git checkout aeacf31 # branch feat/v067-circuits, what fullnode v0.6.7 and v0.6.8 pin
rustup toolchain install 1.98.1
rustup target add riscv32im-unknown-none-elf --toolchain 1.98.1
(cd rand-guest && cargo +1.98.1 build --release)
alias rand-guest="$PWD/rand-guest/target/release/rand-guest"
rand-guest has five subcommands: build, check, pack, run and info.
The commit matters because hc is a function of the source and the toolchain: a guest rebuilt against another commit of guest-sdk can get a different hc. Between 7ef3220 and aeacf31 the rand-guest directory did not change, and guest-sdk/src/lib.rs did (step 2).
2. Write the guest
A guest is a no_std, no_main binary crate that depends on guest-sdk. It must sit inside the circuits checkout: rand-guest build finds the checkout by looking for guest-sdk/ above the guest’s directory, and refuses a guest outside one.
This guest reads a threshold, a count and that many private balances. It publishes 1 and the surplus if the balances cover the threshold. It never publishes the balances.
circuits/
guest-sdk/
rand-guest/
my-guests/
threshold/
Cargo.toml
src/main.rs
my-guests/threshold/Cargo.toml:
[package]
name = "threshold"
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]
The empty [workspace] keeps the crate out of any parent workspace, as the committed fib guest does.
my-guests/threshold/src/main.rs:
#![no_std]
#![no_main]
use guest_sdk::{halt, read_input, write_output};
/// Input 0 is a threshold, input 1 a count n, inputs 2..2+n are private balances.
/// Output 0 is 1 if the balances sum to at least the threshold, else 0.
/// Output 1 is the surplus when they do. The balances themselves are never published.
#[no_mangle]
pub extern "C" fn main() -> ! {
let threshold = read_input(0);
let n = read_input(1);
let mut sum: u32 = 0;
for i in 0..n {
sum = sum.wrapping_add(read_input(2 + i));
}
if sum >= threshold {
write_output(0, 1);
write_output(1, sum - threshold);
}
halt();
}
guest-sdk exports read_input, read_public, write_output, poseidon2, keccak, keccak256, sha256_compress, sha256 and halt, and supplies _start, which sets the stack pointer and calls main. There is no allocator and no floating point.
At aeacf31 three of those signatures differ from 7ef3220, and there is one new export:
| function | at 7ef3220 | at aeacf31 |
|---|---|---|
keccak | fn keccak(ptr: *mut u32) | fn keccak(state: &mut [u32; 50]) |
sha256_compress | fn sha256_compress(ptr: *mut u32) | fn sha256_compress(buf: &mut [u32; 24]) |
poseidon2 | fn poseidon2(ptr: *mut u32, n: usize) | unsafe fn poseidon2(ptr: *mut u32, n: usize) |
poseidon2_len | not present | fn poseidon2_len(buf: &mut [u32], n: usize) -> [u32; 8], syscall 7 |
poseidon2_len binds the message length into the sponge and is the safe call. rand-guest check at aeacf31 still lists syscalls 0 to 6 only, so a guest that calls it builds to an image that build rejects with ecall with a7 = 7, which is no syscall the machine implements (zkp-circuits issue #1). Until that is fixed, a guest hashes with unsafe { poseidon2(ptr, n) } over fixed-length, tagged messages. The guest on this page uses none of the four.
Rules for the guest directory:
- No
.cargo/configor.cargo/config.tomlin it, in any parent, or in$CARGO_HOME.buildrefuses and names each file, because cargo would merge it into the build. - No compiler flags of your own.
buildsets them all: the linker script,-unaligned-scalar-mem, and a path remap that makes the image independent of where the checkout lives. - One
.ldfile at most.builduses the one.ldin the guest’s directory, orguest-sdk/guest.ldif there is none. With more than one, name it with--ld.
3. Build and check
$ rand-guest build my-guests/threshold --out threshold.bin
Compiling guest-sdk v0.1.0 (…/circuits/guest-sdk)
warning: unstable feature specified for `-Ctarget-feature`: `unaligned-scalar-mem`
|
= note: this feature is not stably supported; its behavior can change in the future
warning: `guest-sdk` (lib) generated 1 warning
Compiling threshold v0.1.0 (…/circuits/my-guests/threshold)
warning: `threshold` (bin "threshold") generated 1 warning (1 duplicate)
Finished `release` profile [optimized] target(s) in 0.15s
40 words against a cap of 4096 (fits); 0 ecall(s) with a non-static a7
OK
wrote threshold.bin and its .sha256 (40 words, hc a491df99f289d81e2b6e09849a850b14e36e2bd6edc5f5a6de46c3d67c703665, program id 1b129cbce89df252150ab3d387a927beb1650a40e6cb0b04e86cdc203ca1b853)
The unaligned-scalar-mem warning is expected: the toolchain turns unaligned access off on purpose.
build compiles with cargo +1.98.1 build --release --target riscv32im-unknown-none-elf, runs check on the result, and writes the image container and a .sha256 pin. If check rejects the program, build still writes the image and exits non-zero.
| flag | default | meaning |
|---|---|---|
--out <path> | <dir>/image.bin | where the image goes |
--max-words <n> | 4096 | the cap check measures against; pass the target chain’s cap (65,535 on chain 21) |
--ld <path> | the one .ld in the directory, else guest-sdk/guest.ld | the linker script |
--lang rust|c | rust | the compiler |
4. Run it on the emulator
Pass private inputs with --input, one word each. --tier t also reports whether the run fits tier t.
$ rand-guest run threshold.bin --input 1000 --input 4 --input 400 --input 250 --input 300 --input 75 --tier 10
out[0] = 1
out[1] = 25
out[2] = 0
out[3] = 0
out[4] = 0
out[5] = 0
out[6] = 0
out[7] = 0
cycles 52
tier 10
tier 10: fits (cycles 66 of 1023, Poseidon2 permutations 14 of 128)
The balances sum to 1,025, so the program publishes 1 and a surplus of 25. With balances 400 and 250 the sum is short, and every output is zero. A trap prints trap: <error> and exits 2.
5. Inspect the image
$ rand-guest info threshold.bin
form: image container
text 40 words at 0x1000; data 0 words (0 non-zero) at 0x0; prologue 0 words; program 40 words from base_pc 0x1000
hc a491df99f289d81e2b6e09849a850b14e36e2bd6edc5f5a6de46c3d67c703665
program id 1b129cbce89df252150ab3d387a927beb1650a40e6cb0b04e86cdc203ca1b853
40 words against a cap of 4096: fits
hc is what a call’s proof is verified against. The program id is what you deploy and call under.
6. Deploy
Use the rand wallet from fullnode v0.3 or later; older wallets do not read the image container. Since chain 14 every bundle proof is verified against its transaction’s binding, so a wallet older than the chain-14 build (b3c594c) proves a bundle the chain refuses. On chain 21, the live chain since 2026-10-12, use the rand from fullnode v0.8.0 (the fleet runs v0.8.0, and a rand built on Linux is attached to its signed GitHub release). 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 every signed message includes the genesis hash. Chain 21 started with no programs: nothing deployed on chain 20 carries over a cut. The public endpoint https://rpc.randprotocol.org is enough to sync the wallet, deploy and call (setup).
rand fee deploy 40 # the floor for a 40-word program
rand program deploy threshold.bin
program deploy prints the program id, the word count and hc. It then asks the node for the deploy fee with rand_estimateFee, which refuses a program over the chain’s cap before any proving. Then it proves a zero-value self-transfer bundle that pays the fee, submits it, and waits for the commit. The bundle is the one 4-in-4-out hidden-asset bundle every transaction carries. Since chain 17 it is two proofs, and the wallet prints a line for each: a tier-10 spend authorisation (seconds, about 1.37 MB) and the tier-14 bundle (about two minutes on a laptop, about 1.5 MB). On chain 15 it was one tier-14 proof of about 100 s and about 1.42 MB. An anonymous vote shows a deploy on chain 18 as the wallet printed it.
The deploy fee is 0.001 RAND + 0.0001 RAND × words, on chain 21 as before; on chain 21 it is burned rather than paid to the proposer (genesis fees.burn_floor). Gas prices calls, not deploys:
| program | words | deploy fee |
|---|---|---|
threshold | 40 | 0.005 RAND |
| a program at the default cap (chain 12) | 4,096 | 0.4106 RAND |
| the largest program chain 21 deploys with no public segment | 8,180 | 0.819 RAND |
| a program at the 65,535-word cap (chains 13 to 15) | 65,535 | 6.5545 RAND |
threshold is under 4,096 words, so it fits the default cap and chain 21’s 65,535. A program over 4,096 words needs a chain whose genesis sets max_program_words at least that high, such as chain 21 (The RISC-V ISA). Chain 21, like chains 19 and 20, has a second, lower bound: it refuses to deploy a program that no tier-14 call could hold (ProgramUncallable), which is more than 8,180 words with no public segment and fewer with one. rand program deploy --input <word> … (or --check-call for a call with no inputs) runs the call you have in mind through the emulator first and refuses to deploy unless it lands at or under tier 14.
Check the record:
rand program show 1b129cbce89df252150ab3d387a927beb1650a40e6cb0b04e86cdc203ca1b853
Its 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 a491df99…3665 appears as 99df91a4…707c: every group of eight hex digits is byte-reversed. The program id is spelled the same by both.
7. Call it
rand call 1b129cbce89df252150ab3d387a927beb1650a40e6cb0b04e86cdc203ca1b853 \
--input 1000 --input 4 --input 400 --input 250 --input 300 --input 75
The wallet fetches the code, runs the call in the emulator, and prints gas: <exact> (declaring <limit>, tier <t>) and the fee before it proves anything. It then seals the input transcript, proves the run locally, pays the fee through a bundle, and prints the receipt. Since chain 20 that is three proofs: the call, the tier-10 spend authorisation and the tier-14 bundle.
The fee since chain 20 is no longer 0.001 + call_fee(tier), which made this call 0.002 RAND on chain 15. The floor is BUNDLE_BASE + gas_price × declared gas limit + byte_price × ⌈bytes / 1024⌉: 0.001 RAND, plus the declared limit at the gas price (100 units at genesis, a unit being 10⁻⁹ RAND), plus the proof and envelope bytes at the byte price (800 units per KiB at genesis). Since chain 20 the two prices cannot rise past 10,000 and 80,000 units, and on chain 21 the floor is burned rather than paid to the proposer. The exact gas stays private. By default the wallet declares it rounded up to a multiple of 2^(tier − 2), prices the proof at the chain’s cap (6,291,456 bytes on chain 21; 4,194,304 on chain 20) because the fee is fixed before the proof exists, and adds two price steps of headroom. Four measured calls on chain 18 cost 0.0057 to 0.0075 RAND each (Example programs). This call has not been made on chains 18 to 21, so no figure is given for it.
The chain sees the tier, the declared gas limit, H_IN and the eight output words [1, 25, 0, 0, 0, 0, 0, 0]. It never sees the balances.
Programs covers receipts, input envelopes and rand open-call. A Call like this one is stateless; since chain 20 a program can also keep cells and a vault through an Invoke (Program state (RPL-2)).
Let others check your hc
The build is reproducible. build scrubs the compiler’s environment (RUSTFLAGS, CARGO_* overrides, RUSTC wrappers and more), refuses cargo config files, and remaps the checkout’s path to /rand-circuits. The same source with Rust 1.98.1 gives the same hc on any machine. To let others confirm what you deployed, publish the guest’s source and your circuits commit (aeacf31 for a guest built for chains 18 to 20). Anyone can then rebuild and compare hc with rand_getProgram’s code_hash. The chain stores only the words, not the source.
Troubleshooting
| message | cause |
|---|---|
… is not inside a circuits checkout (no guest-sdk/ above it) | move the guest directory inside the checkout |
build names a .cargo/config.toml | delete or move it; it cannot be scrubbed |
REJECTED with Cap | the program is over --max-words; raise it to the target chain’s cap, or shrink the program (opt-level = "s") |
REJECTED with Syscall and a7 = 7 | the guest calls poseidon2_len; rand-guest check at aeacf31 does not know syscall 7 yet (zkp-circuits issue #1) |
REJECTED with Fence, Csr or Ebreak | the build emitted an instruction the machine does not have; see what is refused |
trap: … from run | a run-time fault, such as a misaligned address or an unimplemented syscall number |
run says the run fits no tier | the run is over 2²⁰ rows or its Poseidon2 budget; it cannot be proved |