Rand Protocol
Documentation
RISC-V programs

The RISC-V ISA

What RV32IM means on Rand - the instructions the zkVM proves, what the checker refuses, the eight syscalls, memory, the image container a program ships in, and the program cap.

Read this page before you write a program for Rand. It says which machine your code runs on, what the toolchain refuses, how a program talks to the outside, and what file you deploy. The next pages use it: Deploy a Rust program, Deploy a RISC-V program, and the two transpilers.

The machine

Rand programs are RV32IM: the 32-bit RISC-V base integer set plus the multiply and divide extension. They run inside the Rand zkVM, which proves each run with a Plonky3 batch STARK. The verifier never sees an instruction. It checks the proof against hc, the in-circuit Poseidon2 digest of the program.

groupinstructions
upper / jumpsLUI AUIPC JAL JALR
branchesBEQ BNE BLT BGE BLTU BGEU
loads / storesLW SW LB LH LBU LHU SB SH
ALUADDI SLTI SLTIU XORI ORI ANDI SLLI SRLI SRAI ADD SUB SLL SLT SLTU XOR SRL SRA OR AND
RV32MMUL MULH MULHU MULHSU DIV DIVU REM REMU
systemECALL only

JALR clears bit 0 of its target, as RV32I says, and its encoding must have funct3 = 0. Both rules came with constraint set 7 (chain 16); before it an odd target could not be fetched and the run could not be proved. A target that is even but not a multiple of four still cannot be fetched.

There is no floating point, no atomics (the A extension), no compressed instructions (the C extension) and no 64-bit registers. A 64-bit division in C becomes a call to a software helper; see the C path.

What is refused

rand-guest check runs every text word through the machine’s own decoder. A program that passes cannot fail in the circuit for an encoding, syscall-number or layout reason. The checker refuses:

findingwhat it is
Compresseda 16-bit RVC encoding; the machine decodes only 32-bit words
Undecodablean opcode, funct or shift amount the machine does not implement
FenceFENCE or FENCE.I; the machine has no memory ordering
Csrany CSR instruction; the machine has no CSRs
EbreakEBREAK; traps are not modelled
Syscallan ecall whose a7 is set by a li just before it and names no syscall
Capmore words than --max-words, counted as the loader counts them
Layouta text base that is not word-aligned

A four-word program with a fence, an ebreak and ecall number 9, as rand-guest check reports it (exit code 1):

$ rand-guest check bad.elf
note: the loader refuses this image (Decode { index: 0, word: 267386895, err: Opcode(15) }), so its data prologue could not be counted; the word count below is the text alone
0x00001000  0x0ff0000f  Fence: FENCE/FENCE.I: the machine has no memory ordering instructions
0x00001004  0x00100073  Ebreak: EBREAK: traps are not modelled
0x0000100c  0x00000073  Syscall: ecall with a7 = 9, which is no syscall the machine implements
4 words against a cap of 4096 (fits); 0 ecall(s) with a non-static a7
REJECTED

An ecall whose a7 the checker cannot see (set by anything but a li, or after a branch) is counted as unresolved_ecalls, not refused. The machine still traps on a bad number at run time.

The checker does not check pointer values, lengths or input indices, the 64 KiB stack against RAM, or the alignment of addresses computed at run time. A misaligned access is a constraint violation, so the toolchain compiles with -unaligned-scalar-mem. rand-guest run reports any run-time trap.

Syscalls

ECALL reads the syscall number from a7, the first argument from a0 and the second from a1. A value comes back in a0.

#syscalleffectgasRust (guest-sdk)C (guest.h)
0HALTends the run1halt()rand_halt()
1WRITE_OUTPUT slot wordsets public output slot < 8, at most once per slot; unwritten slots are zero1write_output(slot, word)rand_write_output
2READ_INPUT idxreturns private input word idx, bound to the salted commitment H_IN1read_input(idx)rand_read_input
3POSEIDON2 ptr nhashes n ≤ 4096 words at word address ptr and writes the 8-word digest over ptr..ptr+8; no padding3 + 3·⌈n/4⌉unsafe poseidon2(ptr, n)rand_poseidon2
4KECCAK ptrone Keccak-f[1600] permutation in place on 50 words192keccak(state), keccak256(msg)rand_keccak
5SHA256 ptrone SHA-256 compression in place on 24 words64sha256_compress(buf), sha256(msg)rand_sha256_compress
6READ_PUBLIC idxreturns public-segment word idx, bound to the unsalted H_PUB1read_public(idx)rand_read_public
7POSEIDON2_LEN ptr nPOSEIDON2 with the length n bound into the sponge, at least one permutation (constraint set 7)3 + 3·max(⌈n/4⌉, 1)poseidon2_len(buf, n)none

Every other instruction costs 1 gas: gas is one per cpu row, and only the hash syscalls weigh more (constraint set 8, since chain 18). A Poseidon2 call is its ecall row, two write-back rows and ⌈n/4⌉ absorb rows at 3 gas each.

POSEIDON2 does not pad. The sponge overwrites its first lanes with each four-word chunk, so [a], [a, 0], [a, 0, 0] and [a, 0, 0, 0] hash to one digest. A program that hashes data whose length can vary must put the length in the message, hash a fixed-length encoding, or call POSEIDON2_LEN, which seeds n into the sponge’s capacity and gives a different digest of the same words. In the Rust SDK poseidon2 is an unsafe fn over a raw pointer and poseidon2_len is the safe call over a slice; keccak and sha256_compress take &mut [u32; 50] and &mut [u32; 24].

One toolchain gap. At circuits aeacf31 the machine, the emulator and the Rust SDK implement syscall 7, but rand-guest check does not: its syscall list stops at 6, so it reports an ecall with a7 = 7 as a Syscall finding and rand-guest build exits non-zero on a guest that calls poseidon2_len (circuits issue #1). guest.h has no wrapper for it either. Until that is fixed, a guest built with the stock tool uses POSEIDON2 on fixed-length messages.

A proof publishes exactly this: hc, H_IN, H_PUB, the tier, the declared gas limit, and the eight output words. Two limits apply on chain today:

  • The public segment is fixed at deploy. Since chain 20, rand program deploy --public sets a program’s public words (at most max_program_public_words, 32,768), and every call is checked against them. There are no per-call public words of the program’s own. Every chain since 19 appends eight words to every call’s segment after them, the call binding that ties the proof to its transaction (since chain 20 it also includes the genesis hash); the guest reads its own words at the same indices as before. An RPL-2 Invoke appends the declared transition after the binding, which the program reads from public_len + 8 on (Programs). On a chain without the limit fields, such as chain 12, the public segment is empty and READ_PUBLIC has nothing to read.
  • A call’s private input words are bounded by the sealed envelope. Chain 21’s max_call_envelope_bytes of 65,536 (chain 20’s too) allows 16,071 words; a chain without the field allows 4,295.

A KECCAK or SHA256 call adds a table to the proof, and the proof’s header then shows that the call hashed. A keccak-carrying proof is about 3.3 MB: over the default 2 MiB proof cap, under chain 21’s 6 MiB (chains 17 to 20 set 4 MiB), and a call may use at most 128 Keccak-f permutations and 128 SHA-256 compressions (Programs).

Memory

  • 32 registers; x0 is zero.
  • RAM starts at zero. Every address is below 2³⁰.
  • The default linker script, guest-sdk/guest.ld, puts the program at 0x1000, then .rodata, .data and .bss, then a 64 KiB stack at __stack_top.
  • A program with a data segment needs room below its text for the loader’s prologue (next section). Its own .ld raises ORIGIN to 0x10000, as evm.ld and sbpf.ld do.

The image container

rand-guest build and rand-guest pack write one file format, the image container. All numbers are little-endian 32-bit words:

[0x444e4152 ("RAND"), 1, text_base, n_text, data_base, n_data] text… data…

The loader turns the container into a program { base_pc, words }:

  1. It writes an li/sw pair for every non-zero data word. Zero words are skipped, because RAM already reads zero.
  2. It places that prologue directly below text_base, so base_pc = text_base − 4 × prologue words. The prologue falls through into the program’s own _start.
  3. hc and the program id are computed over the whole words, prologue included. So hc binds the data as well as the code.

A container with no data gives no prologue, and the same program and hc as a headerless flat binary at the same base.

rand program deploy in fullnode v0.3 and later recognises the container by its first word. 0x444e4152 never decodes as an instruction, so a raw .bin cannot be mistaken for a container. A file that starts with the magic but is malformed is an error, not a fallback.

Two identities

namewhat it iswho uses it
hcthe circuit’s Poseidon2 digest of (base_pc, len, words)every proof of a call is verified against it; rand_getProgram shows it as code_hash
program idblake3("rand-program" ‖ base_pc ‖ words); a deploy with --public binds the public words too, under the tag rand-program-2the key a deployed program is stored and called under

rand-guest build and rand-guest info print both. hc is binding but not hiding: anyone with a candidate program can confirm it.

The program cap

A deploy may carry at most the chain’s program cap, in words.

chaincap
a genesis without max_program_words (chain 12)4,096
a genesis with max_program_wordsthat value, 1..=65535
the zkVM’s hard limit, any chain65,535

The cap is part of the genesis hash, so it cannot change on a running chain. rand-guest checks against --max-words (default 4,096). rand program deploy asks the node with rand_estimateFee and refuses an over-cap program before it proves anything.

Chain 21, the live chain, raises the cap. Its genesis hash is ca76c2766ed6090ac6ba5dad86b724dd5d2b80c0edfaed287cd8b129e663ca9a and its max_program_words is 65,535. It keeps the limits chain 13 (the v0.4 chain) introduced, except the proof cap, which chains 17 to 20 halved and chain 21 sets at 6 MiB, and the block cap, a mebibyte under chain 13’s. Its other limits:

limitchain 21
max_proof_bytes6,291,456 (6 MiB); 4 MiB on chains 17 to 20, 8 MiB on chains 13 to 16
max_block_bytes19,922,944 (19 MiB); 20 MiB on chains 13 to 20
max_call_envelope_bytes65,536
max_program_public_words32,768

On chain 21, as on chains 19 and 20, the word cap is not the binding limit. Its genesis sets hardening_v6, under which a deploy is refused (ProgramUncallable) when no call could ever prove the program. A call is capped at tier 14, whose Poseidon2 table holds 2,048 permutations. The program digest takes one per four words, the input’s salt row one and the eight call-binding words two, which leaves 2,045: 8,180 words for a program without a public input, and fewer beside one (8,168 with nine public words). Every node’s pool refuses such a deploy on any chain.

Block aggregation is off on chain 21: its genesis has no aggregation section. Chain 21 has been live since 2026-10-12 10:31 UTC on fullnode v0.8.0 (constraint set 9), a hard fork cut from chain 20’s state (the bridge at height 814,702, the operator’s balances from a scan at heights 822,056 to 822,229); chain 20 (live 2026-10-01 06:17 UTC on v0.6.8, the first chain with RPL-2 program state, Programs) was itself cut from chain 19 at height 7,258. Programs do not carry over a cut, so no program deployed on an earlier chain exists on chain 21, and the example programs deployed on chain 20 are not on it until they are redeployed. It runs on the network through these bootstrap nodes:

/ip4/164.90.239.200/tcp/30303/p2p/12D3KooWEQEbUwZRgqhDPXhW7vNBmcRcnhUDGUADe81VFdv3ALYe
/ip4/165.245.173.74/tcp/30303/p2p/12D3KooWJyc5oDHggAr89e9QrRFBy9Tg8TxXh18yzrb1SM6KKLYw
/ip4/188.166.235.187/tcp/30303/p2p/12D3KooWSyX84P1DAbWByLHpRBMLRWD53r3Jj4aqVt6tHoF8B2ZQ
imagewordsfits 4,096fits 65,535deploys on chain 21 (8,180 callable)
fib (Rust)26yesyesyes
c-fib (C)25yesyesyes
ERC-20, translated (stage two)11,686noyesno
evm.bin, the EVM interpreter18,009noyesno
sbpf.bin, the sBPF interpreter8,317noyesno
SPL Token, translated65,096noyes, with 439 to spareno

Tiers

A proof’s tier t bounds the run at 2ᵗ − 1 rows: the cycles plus the digest rows (one per four program words, one per four input words, and the public-input digest). The tier also sets a separate Poseidon2 budget. rand-guest run --tier t reports both:

tier 10: fits (cycles 146 of 1023, Poseidon2 permutations 10 of 128)

The tier is public, and so is the gas limit the proof declares. Since chain 20 the call fee follows that limit and the proof’s size, not the tier: at least 0.001 RAND + gas_price × gas_limit + byte_price × ⌈bytes / 1024⌉, at the chain’s current prices, which start at 100 and 800 units of 10⁻⁹ RAND, move with block fullness and since chain 20 stop at 10,000 and 80,000 units; on chain 21 the floor is burned rather than paid to the proposer (Fees). A limit may not exceed the header’s ceiling, (2ᵗ − 1) + 2ᵗ⁻² plus 191 per Keccak block and 63 per SHA-256 block the proof declares: 1,279 at tier 10 and 20,479 at tier 14 with no hash table. rand-guest run reports cycles and permutations, not gas. rand call runs the program first and prints the gas it spends and the limit it declares, by default the exact gas rounded up to the next multiple of 2ᵗ⁻², before it proves:

gas: 5636 (declaring 6144, tier 12)

That line is from a call on chain 18 on 2026-09-30 (An anonymous vote). On a chain without a genesis gas section, chain 15 and earlier, the fee was a tier schedule: 0.001 + (0.001 + 0.0001 × (t − 10) / 2) RAND. The memory a proof needs rises steeply with the tier; see Validator and aggregator hardware.

Since fullnode v0.5.6 (2026-09-25) a call may declare at most tier 14: a node refuses a higher-tier call proof with CallTierTooHigh before it builds a verifier key, and a block carrying one is invalid. The tier-14 budget is 16,383 rows and 2,048 Poseidon2 permutations, and the program’s own digest spends about one permutation per four words, so a program over 8,180 words cannot be called, and chain 21, like chains 19 and 20, refuses its deploy. Write a program in Rust or C works through the budget.

Last verified against fullnode commit c0878c3f (chain 21) on 2026-10-12, and circuits `aeacf31` (`rand-guest/README.md`, `rand-guest/src/check.rs`, `research/docs/01-isa.md`, `guest-sdk/`), fullnode tag v0.6.8 `c9c9bd3c` (`docs/program-state.md`, `ledger/program_state.rs`, `deploy/cut-chain20-genesis.sh`), chain 20's genesis and `rand_getLimits`, read on 2026-10-01, fullnode tag v0.8.0 `c0878c3f` (`docs/zkvm.md` §8a, constraint set 9), `deploy/genesis-chain21.json` and `rand_getLimits` on chain 21, read on 2026-10-12. Where the code and this page disagree, the code is right; tell us.