Rand Protocol
Documentation
RISC-V programs

Deploy a RISC-V program

Two paths to a deployable image without Rust - a C program built by rand-guest with the pinned clang, and a hand-written program assembled and packed, or packed into the image container byte by byte.

Any program that is RV32IM machine code can be deployed, whatever produced it. This page shows two paths: C through rand-guest build --lang c, and a program you write in assembly and pack yourself. Both end in the same image container that Deploy a Rust program produces, and both deploy with rand program deploy. Set up the circuits checkout and rand-guest first, as in step 1 of that page. For chains 18 to 20 the checkout was at commit aeacf31, which fullnode v0.6.7 and v0.6.8 both pinned; fullnode v0.8.0 (chain 21) pins d217252, constraint set 9, and an image built at either commit is the same words, so it deploys on chain 21 with the v0.8.0 wallet. The outputs below were produced on 2026-09-19 at 7ef3220 and have not been re-run. The rand-guest directory, guest.h and rt.c included, is the same at both commits.

C via clang

What you need

toolversionhow
clang23.1.1, with a RISC-V backendbrew install llvm on macOS; Apple’s system clang has no RISC-V backend. Or set $CLANG to a 23.1.1 binary
rust-lldfrom Rust 1.98.1rustup +1.98.1 component add llvm-tools

rand-guest refuses any clang whose version is not 23.1.1. RAND_GUEST_CLANG_UNPINNED=1 builds with another one, with a warning that hc will not match published images.

Write the guest

A C guest directory holds only .c files (and an optional .ld). rand-guest supplies guest.h, _start and a small runtime. Do not add your own guest.h or start.S: build --lang c refuses a directory that has either.

This is the committed guests-compiled/c-fib/fib.c:

#include "guest.h"

static uint32_t fib(uint32_t n) {
    uint32_t a = 0, b = 1;
    for (uint32_t i = 0; i < n; i++) {
        uint32_t c = a + b;
        a = b;
        b = c;
    }
    return a;
}

void main(void) {
    rand_write_output(0, fib(rand_read_input(0)));
    rand_halt();
}

guest.h declares rand_read_input, rand_read_public, rand_write_output, rand_poseidon2, rand_keccak, rand_sha256_compress and rand_halt. It has no wrapper for syscall 7, the length-bound POSEIDON2_LEN that the machine has run since constraint set 7, and rand-guest check at aeacf31 refuses an ecall with a7 = 7 (zkp-circuits issue #1). A C or assembly guest for chain 20 or 21 therefore uses syscalls 0 to 6 (syscall 8, MODQ, is constraint set 9’s and is used only by the multisig auth guest).

Build, run, inspect

$ rand-guest build --lang c guests-compiled/c-fib --out c-fib.bin
25 words against a cap of 4096 (fits); 0 ecall(s) with a non-static a7
OK
wrote c-fib.bin and its .sha256 (25 words, hc 76e723cb4c218f9a7f657f39b9f95ee2353d8fbeb601aaea394afa29928f3aee, program id c213cf5c94679cab4585ed1e81b61736a7d2fe5605dc7884e4acec4144665999)

$ rand-guest run c-fib.bin --input 20 --tier 10
out[0] = 6765
out[1] = 0
out[2] = 0
out[3] = 0
out[4] = 0
out[5] = 0
out[6] = 0
out[7] = 0
cycles 116
tier 10
tier 10: fits (cycles 126 of 1023, Poseidon2 permutations 10 of 128)

fib(20) = 6765, the same answer as the Rust fib guest.

build --lang c compiles every .c file in the directory (in sorted order), plus the bundled start.S and rt.c, with:

clang --no-default-config --target=riscv32-unknown-none-elf -march=rv32im -mabi=ilp32 -mno-relax
      -nostdlib -ffreestanding -fno-builtin -ffunction-sections -fdata-sections -Os
      -ffile-prefix-map=<checkout>=/rand-circuits -c

and links them with rust-lld -flavor gnu --gc-sections -T <ld> from the pinned Rust toolchain. --gc-sections keeps only what the guest calls.

The C runtime

-ffreestanding -fno-builtin stop clang from recognising library functions, but not from emitting calls to them. A struct copy becomes memcpy, a large zero initialiser becomes memset, and a 64-bit division by a run-time value becomes a helper call, because RV32IM divides 32-bit values only. rt.c defines these eight:

functionused for
memcpy, memmove, memset, memcmpcopies, fills, compares
__udivdi3, __umoddi3, __divdi3, __moddi364-bit / and %

They are byte loops and shift-subtract division. They are slow: in a hot loop, copy and divide in 32-bit words.

A hand-written program

You can also write the machine code yourself: assemble it, link it with the same linker script, and pack the ELF. This program adds two private inputs and publishes the sum.

add.S:

# Reads private inputs 0 and 1, writes their sum to output slot 0, halts.
    .section .text._start
    .globl _start
_start:
    li   a7, 2          # READ_INPUT
    li   a0, 0
    ecall               # a0 = input 0
    mv   t0, a0
    li   a7, 2          # READ_INPUT
    li   a0, 1
    ecall               # a0 = input 1
    add  t1, t0, a0
    li   a7, 1          # WRITE_OUTPUT slot, word
    li   a0, 0
    mv   a1, t1
    ecall
    li   a7, 0          # HALT
    ecall

Set each syscall number with a li a7, n directly before its ecall. The checker can then verify the number.

Assemble with the pinned clang, link with rust-lld and guest-sdk/guest.ld, then pack. Run from inside the circuits checkout:

CLANG=/opt/homebrew/opt/llvm/bin/clang
LLD=$(rustc +1.98.1 --print sysroot)/lib/rustlib/$(rustc +1.98.1 -vV | sed -n 's/host: //p')/bin/rust-lld
$CLANG --no-default-config --target=riscv32-unknown-none-elf -march=rv32im -mabi=ilp32 -mno-relax -c add.S -o add.o
$LLD -flavor gnu -T guest-sdk/guest.ld add.o -o add.elf
$ rand-guest pack add.elf --out add.bin
wrote add.bin (80 bytes) and add.bin.sha256

$ rand-guest check add.bin
14 words against a cap of 4096 (fits); 0 ecall(s) with a non-static a7
OK

$ rand-guest run add.bin --input 40 --input 2
out[0] = 42
out[1] = 0
out[2] = 0
out[3] = 0
out[4] = 0
out[5] = 0
out[6] = 0
out[7] = 0
cycles 14
tier 10

$ rand-guest info add.bin
form: image container
text 14 words at 0x1000; data 0 words (0 non-zero) at 0x0; prologue 0 words; program 14 words from base_pc 0x1000
hc 42986ac1f93396d7de75f6757149523e7f1e9c5999d748af9b1915ada8ee14bd
program id 3b34c9d16adbd81a4849148675a914d10e5da0bd5b6aa64967e5f8a5fc75d941
14 words against a cap of 4096: fits

pack takes the ELF’s .text as the text segment and every other allocated PROGBITS section as one data segment. .bss is left out, because RAM starts at zero.

Packing the container by hand

The image container is six header words and then the words themselves (The RISC-V ISA). You can write one without any toolchain. This Python script writes the same 80 bytes that pack wrote above:

import struct

text = [
    0x00200893,  # li   a7, 2      READ_INPUT
    0x00000513,  # li   a0, 0
    0x00000073,  # ecall           a0 = input 0
    0x00050293,  # mv   t0, a0
    0x00200893,  # li   a7, 2      READ_INPUT
    0x00100513,  # li   a0, 1
    0x00000073,  # ecall           a0 = input 1
    0x00a28333,  # add  t1, t0, a0
    0x00100893,  # li   a7, 1      WRITE_OUTPUT
    0x00000513,  # li   a0, 0      slot 0
    0x00030593,  # mv   a1, t1     word
    0x00000073,  # ecall
    0x00000893,  # li   a7, 0      HALT
    0x00000073,  # ecall
]
#          magic ("RAND")  version  text_base  n_text     data_base  n_data
header = [0x444E4152,      1,       0x1000,    len(text), 0,         0]
with open("add-hand.bin", "wb") as f:
    f.write(struct.pack(f"<{len(header) + len(text)}I", *header, *text))

rand-guest info add-hand.bin prints the same hc 42986ac1…14bd, and cmp add-hand.bin add.bin finds no difference. Always run rand-guest check on a hand-packed image: the node refuses any word that does not decode, but only at deploy time.

The wallet also deploys two older forms: a .json file { "base_pc", "words" }, and a raw .bin of little-endian words loaded at base 0. The container is the only form with a data segment.

Deploy and call

Every path above ends the same way. With a rand wallet from fullnode v0.3 or later (on chain 21, the live chain since 2026-10-12, the v0.8.0 rand: an older wallet cannot send there, because chain 21 runs constraint set 9, whose proofs an older wallet cannot make, and, like chain 20, binds every proof and signature to its genesis hash; see Deploy a Rust program):

rand program deploy add.bin
rand call <program id> --input 40 --input 2

The deploy fee is 0.001 RAND + 0.0001 RAND × words: 0.0024 RAND for add.bin’s 14 words and 0.0035 RAND for c-fib.bin’s 25. Both are under 4,096 words, so they fit any chain’s cap: the default 4,096 and chain 21’s 65,535. The call proves locally at tier 10. On chain 15 it cost 0.002 RAND. Since chain 20 the fee is no longer 0.001 + call_fee(tier): the floor is BUNDLE_BASE + gas_price × declared gas limit + byte_price × ⌈bytes / 1024⌉, rand call prints gas: <exact> (declaring <limit>, tier <t>) and the fee before it proves, and four calls measured on chain 18 cost 0.0057 to 0.0075 RAND each (Example programs). Every deploy and call since chain 20 also carries two bundle-side proofs, a tier-10 spend authorisation and the tier-14 bundle. Deploy a Rust program describes what each command does.

To let others check what you deployed, publish the source and your circuits commit (aeacf31 for chains 18 to 20). With clang 23.1.1 and Rust 1.98.1, rand-guest build gives the same hc on any machine.

Last verified against fullnode commit c0878c3f (chain 21) on 2026-10-12, and circuits `7ef3220` (`rand-guest/README.md`, `guests-compiled/c-fib`, `guest-sdk/guest.ld`); every `rand-guest` output on this page was produced on 2026-09-19, circuits `aeacf31` (`rand-guest/guest.h`, `rand-guest/src/check.rs`; `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 tag v0.6.8 `c9c9bd3c` (`docs/deploy.md` on `binding_domain`, `.github/workflows/ci.yml` circuits pin) and `rand_getLimits` on chain 20, read on 2026-10-01; nothing on this page was deployed or called on chain 20, fullnode tag v0.8.0 `c0878c3f` (`CHANGELOG.md`, `docs/zkvm.md` §8a) and `rand_getLimits` on chain 21, read on 2026-10-12; nothing on this page was deployed or called on chain 21. Where the code and this page disagree, the code is right; tell us.