Rand Protocol
Documentation
Guides

Programs

The confidential program model - a RISC-V guest proved in the zkVM, deployed once and called with private inputs - with the build, deploy, call, invoke and receipt commands, RPL-2 program state since chain 20, and an honest list of what is not possible yet.

Use this page to put a program on chain and call it: what a program is, how to build one, what a call publishes and what it hides, how to read a receipt back, and the limits you will hit. Every program is public code; every call is a proof. Example programs has complete applications, and randprotocol/example-programs has the source of the programs deployed on chain 20 (chain 21, cut on 2026-10-12, started with none: programs never carry over a cut, and the example programs are not redeployed on it yet), including the RPL-2 ones (below).

What a program is

A program is RV32IM machine code for the Rand zkVM: { base_pc: u32, words: Vec<u32> }, at most the chain’s program cap (65,535 words on chain 21, as on every chain since chain 13; 4,096 on a genesis without max_program_words, such as chain 12), base_pc a multiple of 4, every word decoding as an instruction (a word that does not decode is rejected at deploy, never at run time). Chain 21, like chains 19 and 20, also refuses to deploy a program that no call could hold: more than 8,180 words with no public segment, fewer with one (below). It is content addressed and immutable:

program_id = blake3("rand-program" || base_pc || words)       // the id, with no public input
program_id = blake3("rand-program-2", base_pc || u32_le(len(words)) || words || u32_le(len(public)) || public)   // deployed with --public
code_hash  = hc = Poseidon2 digest of (base_pc, len, words)    // what calls are verified against

A call is a STARK proof that the program with digest hc ran on some private inputs, spent no more than a declared gas limit, and published eight output words. The node verifies the proof, records a receipt, and nothing else happens: outputs are data, not effects. A call cannot move RAND; the caller moves value in the bundle that pays for the call, and a program’s outputs mean whatever the program and its caller agree they mean. Since chain 20 an Invoke is the exception: its proof vouches for a declared state transition, which the ledger then applies (Program state (RPL-2)).

What the guest can do:

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; never FENCE, CSRs, EBREAK, the A or C extensions

Syscalls, by number in a7:

#syscalleffect
0HALTends the run
1WRITE_OUTPUT slot wordpins public output slot < 8; each slot at most once, unwritten slots are zero
2READ_INPUT idxreturns private input word idx, bound to the salted commitment H_IN
3POSEIDON2 ptr nhashes n ≤ 4096 words in place with the Poseidon2 sponge
4KECCAK ptrone Keccak-f[1600] permutation in place; makes the proof carry the keccak table
5SHA256 ptrone SHA-256 compression in place; makes the proof carry the sha256 table
6READ_PUBLIC idxreturns public-segment word idx; since chain 20 the public segment is fixed at deploy (rand program deploy --public); on a genesis without max_program_public_words it is empty and this reads nothing
7POSEIDON2_LEN ptr nPOSEIDON2 with the message length bound into the sponge, so [a] and [a, 0] hash differently (constraint set 7, chain 16 and later). rand-guest check at circuits aeacf31 still refuses it (next section)

Memory: 32 registers with x0 zero; RAM that starts at zero; every address below 2³⁰; a 64 KiB stack for compiled guests. A program built with rand-guest carries its read-only and initialised data in the image container, and a loader prologue that is part of the program writes that data before the program’s own code runs (The RISC-V ISA). Note commitments, nullifiers and Merkle checks are not syscalls but library routines over POSEIDON2.

Since chain 18 (constraint set 8) every run is metered in gas. An ordinary instruction and syscalls 0, 1, 2 and 6 cost 1 gas each, POSEIDON2 over n words costs 3 + 3 × ⌈n / 4⌉, one KECCAK permutation costs 192 and one SHA256 compression costs 64. Example programs has the table and what a call declares.

Building one

Three ways, all producing RV32IM words:

The built-in assembler. The wallet ships five hand-assembled guests and assembles them to a { "base_pc", "words" } JSON file:

rand program build --guest private_payment --arg 1000 --out pp.json   # prints the program id
rand program build --guest balance_check --arg 1000 --out bc.json
# guests: fib, memcpy, bubble_sort, balance_check, private_payment, public_echo, rpl2_counter (v0.6.8); --arg is repeatable

Compiled programs, with rand-guest. The circuits repository’s rand-guest builds a no_std Rust crate (riscv32im-unknown-none-elf, Rust 1.98.1) or a C directory (clang 23.1.1) into an image container, checks every word against the machine’s decoder, and prints hc and the program id. The build is hermetic, so the same source gives the same hc on any machine. For chains 18 to 20 build the toolchain at circuits commit aeacf31 (branch feat/v067-circuits), the commit fullnode v0.6.7 and v0.6.8 both pin. At that commit rand-guest check lists syscalls 0 to 6 only, so a guest that calls guest_sdk::poseidon2_len (syscall 7) is rejected at build although the machine runs it (zkp-circuits issue #1). Step by step: Deploy a Rust program and Deploy a RISC-V program.

Translated programs. sbpf2rv translates a Solana sBPF ELF, and evm2rv translates EVM runtime bytecode, into programs whose outputs equal Rand’s interpreter guests word for word: The Solana transpiler and The Ethereum transpiler.

The wallet itself has no compiler. rand program deploy accepts a .json, a raw .bin of little-endian words, or the image container rand-guest writes (fullnode v0.3 and later).

Deploying

rand program deploy pp.json          # a bundle pays the floor; waits for the commit; prints the id
rand program show <id>               # id, base_pc, words_len, code_hash, deployed_at, public_words_len, public_digest

The testnet is chain 21 (live since 2026-10-12), and deploying, calling or invoking on it needs the rand wallet from fullnode v0.8.0. An older wallet cannot send there at all: chain 21 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 even decode; and, as on 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, and the example programs are not redeployed on it yet. The public endpoint https://rpc.randprotocol.org serves a wallet’s sync, deploys and calls (setup).

The wallet first prints the program id, the word count and hc, and asks the node for the fee with rand_estimateFee, which refuses a program over the chain’s cap before any proving. Then it builds a zero-value self-transfer bundle (the one 4-in-4-out hidden-asset bundle every transaction carries; the fee comes out of its RAND slots) that pays 0.001 RAND + 100,000 units × words out of its own notes (public-segment words count as code words), attaches Action::Deploy { base_pc, words, public }, proves the bundle against the transaction’s binding and submits. Since chain 17 that 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). Before chain 17 it was the bundle proof alone, about 100 s and about 1.42 MB on chain 15. The deploy fee has no gas or byte term: gas prices calls only. The node checks every word decodes, computes hc, stores the record, and starts building the verifier key in the background. The code is public; who paid for it is not — rand_getProgram has no deployer field. Deploying the same words twice is idempotent.

→ {"method":"rand_getProgram","params":["675adeea…0065"]}
← { "id": "675adeea…0065", "base_pc": 0, "words_len": 42, "code_hash": "…", "deployed_at": 10,
    "public_words_len": 0, "public_digest": null }
→ {"method":"rand_getProgramCode","params":["675adeea…0065"]}
← { "base_pc": 0, "words": [ … ] }

Calling

rand call <program id> --input 400 --input 250 --input 300 --input 75
rand receipt <tx hash>

The wallet fetches the code and the public segment from the node and checks that they hash to the id. It runs the call in the emulator and prints the gas and the fee before it proves anything:

gas: 5636 (declaring 6144, tier 12)
fee 0.006191437 RAND (incl. two price steps of headroom)

It then seals the input transcript (below), proves the call locally under the chain’s FRI profile with the inputs as the private vector, proves the bundle that pays the fee, submits, waits, and prints the receipt. Since chain 20 the call proof is bound to its own transaction (a binding that includes the genesis hash) and the bundle is two proofs, so a call is three: the call, the tier-10 spend authorisation and the tier-14 bundle. The two lines above are from the anonymous vote’s call on chain 18 on 2026-09-30 (An anonymous vote).

The fee. Since chain 20 the floor is BUNDLE_BASE + gas_price × declared gas limit + byte_price × ⌈bytes / 1024⌉, where BUNDLE_BASE is 0.001 RAND, the bytes are the call proof plus the input envelope, and the prices started at 100 and 800 units (a unit is 10⁻⁹ RAND) and move with block fullness. Chains 20 and 21 cap them at 10,000 and 80,000 units, and only the bytes that pay the byte price (calls’ proofs and envelopes) count toward its load (byte_load: "paying"); on chains 18 and 19 the prices had no ceiling and every transaction’s bytes moved the byte price. The ledger’s flat 0.001 RAND + call_fee(tier) schedule, the rule on chain 15, does not apply there. The wallet fixes the fee before the proof exists, so it prices the proof at the chain’s proof cap (4 MiB on chain 20, 6 MiB on chain 21) and adds two price steps of headroom. On chain 21 the whole floor is burned (genesis fees.burn_floor): the proposer keeps only what the sender pays above it, and rand_getSupply.base_fees_burned counts what was destroyed (Fees). Four measured calls on chain 18 cost 0.0057 to 0.0075 RAND each (the arithmetic). rand fee call <tier> --gas <limit> --bytes <B> asks the node for the floor itself. private_payment reads four private balances and, if they sum to at least its threshold, publishes the surplus: outputs [1, 0, 25, 0, 0, 0, 0, 0]. The chain sees the proof, the tier and those eight words; it never sees the balances.

Flags: --tier T forces a tier; --gas-limit <N|max> declares an exact gas limit or the tier’s ceiling in place of the default, the exact gas rounded up to a multiple of 2^(tier − 2); --fee <RAND> sets the fee; --expect-public <FILE> refuses before proving unless the program’s public segment is that file’s words; --auditor <rand1…> seals the transcript for a named auditor too; --print-call-key prints the per-call key; --no-envelope publishes no transcript; --cuda selects the GPU backend (untested on hardware, and incompatible with sealing an envelope). Chain 20 refuses --cuda on a call outright: a call proof bound to its transaction is made on the CPU backend.

Receipts

A receipt is stored per committed call and recomputed when a node syncs or verifies its chain:

→ {"method":"rand_getReceipt","params":["b082…09b0"]}
← { "tx": "b082…09b0", "program": "675adeea…0065", "tier": 14,
    "outputs": [1, 0, 25, 0, 0, 0, 0, 0], "height": 105, "index": 0, "h_in": "9c0e…7f", "h_pub": null }

h_in is the proof’s salted commitment to the private inputs; on its own it discloses nothing. h_pub is the digest of the program’s deploy-time public segment, the program’s public_digest, or null for a program deployed without one. There is no effect field, and the declared gas limit is not in the receipt. The explorer shows the same object as receipt on GET /transactions/:hash for a call, lists a program’s calls on GET /programs/:id, and filters GET /transactions?kind=call&program=<id>.

Input envelopes: disclosing a call later

By default rand call publishes the call’s (salt, inputs) transcript sealed in a call-input envelope, so that the inputs can be shown later without a second proof:

partbyteswhat it is
body12 + 16 + 4·n + 16salt ‖ inputs under a fresh per-call key K_call
to_sender60K_call wrapped to the caller’s outgoing viewing key
kem_ct1,088 or 0an ML-KEM-768 encapsulation to the auditor, empty with no auditor
to_auditor60 or 0K_call wrapped under that encapsulation’s secret

The body’s associated data is H_IN, so an envelope lifted onto another call opens for nobody. The chain checks only the size (at most max_call_envelope_bytes: 65,536 bytes on chains 20 and 21, 18,432 on a genesis without the field) and serves it verbatim with rand_getCallEnvelope(tx). Three keys open it and no more: the caller’s viewing key (every call that wallet made), the per-call key (that one call), the auditor’s viewing key (that one call).

rand open-call <txhash>                    # as the caller
rand open-call <txhash> --as-auditor       # as the auditor
rand open-call <txhash> --call-key <hex>   # with the per-call key alone

open-call opens the transcript, checks input_digest(salt, inputs) == H_IN, re-runs the program on the recovered inputs through the emulator, compares the outputs with the receipt’s — and exits non-zero if either check fails, so a script cannot mistake a warning for a yes. --no-envelope is irreversible: once the salt is gone nobody, the caller included, can open that call.

Since v0.6.8 the re-run is made over the public segment the proof was made over: the program’s deploy-time public words and, under hardening_v6, the eight-word call binding after them (fullnode issue #116). In v0.6.7 open-call worked only for a program deployed without a public segment; for a program with one it printed the inputs and then stopped with re-running the program on these inputs failed: PublicIndex(0), exiting non-zero without running either check.

Program state (RPL-2)

Chain 20 was the first chain with RPL-2, switched on by its genesis program_state section, and chain 21 carries the section with the same values. A program gets three things it did not have (fullnode docs/program-state.md, crates/randprotocol-core/src/ledger/program_state.rs at v0.6.8):

cellsa map from an 8-word key to an 8-word value, per program. An absent cell reads as eight zeros; writing eight zeros removes it. Committed in the state root
a vaulta public balance per asset (0 is RAND). Value enters through an invoke’s bundle and leaves as notes the chain computes
a token of its owna token registered with rand token create --program <id> (mint authority Program(id), no initial supply) is minted and burned only by that program’s invokes

Cells and vault balances are public, like a token’s supply. Nothing in them is a note.

Invoke (Action 33) is a call that also declares a transition, and its call proof shows the program accepts exactly that transition:

Invoke     { program, proof, input_envelope, transition }
Transition { reads:  [{ key, value }]     // cells read, with the values read; keys strictly ascending
             writes: [{ key, value }]     // cells written; keys strictly ascending
             inflow: none | deposit | burn
             pays:   [{ asset, amount, recipient, r, envelope }]   // notes out of the vault
             mints:  [{ asset, amount, recipient, r, envelope }] } // new units of the program's own token

What comes in is what the bundle burns: burn_r is RAND deposited into the vault; burn_a of burn_asset is a token deposited (inflow deposit) or destroyed (inflow burn, only the program’s own token). What goes out is one chain-computed note per payout, pays then mints, with the amount and the recipient’s pk public, as a mint’s are. The ledger applies the transition only if the program exists, the shape is legal, every cell in reads still holds the declared value, the vault (after this transaction’s own deposit) covers every pay, every mint is of the program’s own token, and the proofs verify. A read that no longer matches is refused as StaleRead; the wallet re-reads and re-proves.

How an invoke differs from a call.

Call (RPL-1)Invoke (RPL-2)
public segment the proof is made overpublic ‖ call_bindingpublic ‖ call_binding ‖ context, the transition as words
what the chain does on successstores a receipt; outputs are datastores the receipt and applies the transition: writes cells, moves the vault, appends payout notes
valueonly the bundle’s own transferdeposits into the vault through the bundle’s burns; payouts and mints out of it
depends on statenothe proof does not (the segment is built from the transaction), but the read check does, at admission and in the pool
feethe call floorthe call floor plus cell_fee per cell created

A program reads context word i with READ_PUBLIC(public_len + 8 + i). Word 0 is the version (1), words 1 to 4 the counts of reads, writes, pays and mints, then burn_r, the inflow kind, burn_asset and burn_a, then each read and write as key and value (8 words each), then each payout as asset and amount. Recipients are not in the context: the program decides amounts, the binding fixes who is paid. A program must constrain every context word it is shown, and must have no panicking path, because guest-sdk’s panic handler halts and a halted run is a provable run.

Chain 21’s limits (rand_getLimits.program_state, unchanged from chain 20):

limitchain 21
cell_fee10,000,000 units, 0.01 RAND, per cell created (a cell written non-zero where none was stored); rewriting or deleting a cell is free
max_reads8 cells
max_writes8 cells
max_payouts4 notes, pays and mints together

The segment rule is usually tighter: the whole segment must fit the public table a call to the same program is proved over, 127 words for a program with no public input. That leaves 119 context words, for example three reads, three writes and four payouts.

Commands and RPC (wallet v0.6.8 and later):

rand program build --guest rpl2_counter --out counter.json   # one cell, incremented by one per invoke
rand program invoke <id> --transition step.json [--input N | --inputs-file F.json]
rand program state <id> [--cell <64 hex>]                   # rand_getProgramCell / rand_getProgramCells
rand program vault <id>                                      # rand_getProgramVault

program invoke checks every reads entry against the live cell before it proves, and exits 3 on a stale read, before proving or at inclusion, so a script re-reads and retries. rand_estimateFee with "kind": "invoke" quotes the fee. The transition file’s format is in the fullnode’s docs/cli.md. Complete RPL-2 programs deployed on chain 20 (amm, stableswap, orderbook, stablecoin, lending, perp, crowdfund, stoploss), and RPL-1 ones (sealed-auction, ballot, eligibility, private-join, credit-score), are in randprotocol/example-programs.

Chain 21 also carries RPL-3, perpetual futures, as a genesis perps section rather than a deployed program: one market, BTC-PERP settled in zUSD, whose matching engine is a pinned guest (engine_hc f286a611…1573, proved at tier 18 by the venue’s own prover, off chain) and whose state has its own genesis root. It has six actions of its own (tags 34 to 39) and four reads, rand_getPerps, rand_getPerpAccount, rand_getPerpAccounts and rand_getPerpInputs (Transactions, JSON-RPC). A program you deploy cannot call it.

What is public and what is hidden

publichidden
the program id and its code; the tier; the declared gas limit; the eight outputs; H_IN; the bundle’s fee and public fields; the envelope’s size; whether the call used Keccak or SHA-256, and roughly how often (the proof header declares each hash table’s height)the private inputs; registers, memory, branches; the real cycle count and the exact gas; the H_IN salt; who called it and what they paid with

Two proofs of the same run are different bytes. That was not enough on chain 15 and earlier: a call with 31 or fewer private words proved a table too small to hide, and its inputs could be recovered from the proof. The prover has floored those tables at 128 rows since v0.6, and constraint set 7 (chain 16 and later) blinds the per-table totals that still leaked. hc is binding but not hiding: anyone who can enumerate candidate programs can confirm which one was deployed.

What is not possible yet

Stated as the fullnode states it:

  • No cross-program calls, and no state outside RPL-2. A Call is one proof over one run; nothing carries from call to call except what the caller feeds back in. Since chain 20 an Invoke reads and writes the program’s own cells and vault (above); no chain before 20 has program state, and an invoke cannot touch another program’s cells.
  • No program-driven transfers outside a vault. Effect kind 1, the program-paid transfer to an account, was deleted with the accounts, and a Call’s outputs are statements. Since chain 20 an Invoke pays out of the program’s own vault, at most four notes, with amounts and recipients public.
  • No compiler in the wallet. Programs are the built-in assembled guests, or images built with rand-guest in the circuits repository.
  • The public input is fixed at deploy, not per call. Constraint set 6 added READ_PUBLIC and H_PUB. Since chain 20 a deploy may carry up to 32,768 public words, the program id binds them, and every call’s proof is checked against their digest. There are no per-call public words.
  • Hash syscalls make large proofs. A keccak-carrying proof is about 3.2 MB, over the default 2 MiB proof cap outright; a sha256-carrying proof adds about 400 KB. Chain 21’s cap is 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), which admits both: on chain 18 a tier-12 call with the Keccak table measured 3.35 MB and a tier-14 call with the SHA-256 table 1.91 MB (Example programs). A call is also limited to 128 Keccak-f permutations and 128 SHA-256 compressions (below), and every KiB of proof is charged at the byte price.
  • EVM and Solana programs do not deploy on chain 21, and their calls are over the call-tier cap. The interpreter guests (evm.bin, 18,009 words; sbpf.bin, 8,317) and the translated images (the ERC-20, 11,686 words; SPL Token, 65,096) are over the default 4,096-word cap and under the 65,535 that chain 13 set and every chain since has kept. Both translated images were deployed on chain 13 on 2026-09-19, SPL Token with its ELF as the public input. Chains 14 and 15 were cut fresh, and neither image was redeployed on either. Chain 21, like chains 19 and 20, refuses to deploy any of the four: under its hardening_v6 rule a program that no tier-14 call could hold, more than 8,180 words with no public segment and fewer with one, is refused at deploy (ProgramUncallable), so that a deploy fee is not paid for a program nobody can call. An ERC-20 call proves at tier 16 or 18: the tier-16 approve was called on chain 13 on 2026-09-19 with a 3,412,405-byte production proof (Ethereum transpiler), and chain 21 refuses any call above tier 14 (next item), so no ERC-20 call is admissible there today. SPL Token calls are tier 20, which needs about 330 GB; no SPL call proof exists (proving memory). EVM block-context opcodes trap, and there is no on-chain EVM state.
  • A call proves at tier 14 or below. Since fullnode v0.5.6 (2026-09-25) a call proof above tier 14 is refused (CallTierTooHigh), and so is one with more than 128 Keccak-f permutations or 128 SHA-256 compressions. Chain 13’s tier-16 ERC-20 approve would be refused on chain 21, as it would have been on chains 15 and 18 to 20, and so would every tier-18 ERC-20 or tier-20 SPL Token call; those still prove off chain (the budget).
  • GPU proving has never run on hardware. --cuda needs a --features cuda build and committed PTX; neither existed on the fleet at chain 20’s cut (2026-10-01). On the fleet a call proof is made on the CPU backend; since v0.7.1 a --features cuda build picks the GPU by default when it sees one, and --cpu forces the CPU.
  • hc is not hiding, as above, and a witness request still tells the node which leaf a wallet spends.

Last verified against fullnode commit c0878c3f (chain 21) on 2026-10-12. Where the code and this page disagree, the code is right; tell us.