Rand Protocol
Documentation
RISC-V programs

The Solana transpiler

sbpf2rv translates a Solana sBPF program into Rand's RISC-V ISA - what it is, the trust model and its ELF guard, what traps and what is refused, the measured numbers, and a full walkthrough that translates the real SPL Token program and matches the sBPF interpreter word for word.

sbpf2rv takes a compiled Solana program (an sBPF ELF, the .so that cargo build-sbf produces) and turns it into a Rand program. It writes C, and rand-guest build compiles that C into an RV32IM image. The image publishes the same eight output words as Rand’s sBPF interpreter guest, sbpf.bin, for the same ELF and instruction. This page shows the whole path on the real SPL Token program.

What it is

inputan sBPF v1 ELF
outputa C shim crate; rand-guest build makes it an image container
where it runsoff chain, on the developer’s machine; the chain only sees the image
what it must matchsbpf.bin’s eight output words, on the same two input tapes
repositorysbpf2rv/ in the circuits repository
pinned toolchainRust 1.98.1, clang 23.1.1, the cc crate 1.4.6

The translated program keeps the interpreter’s ABI. It reads the same tapes: the ELF on the public tape and the serialized instruction (accounts, then instruction data) on the private tape. It runs the same sbpf-core harness around the program: it decodes the input, loads the ELF, checks the account region and hashes the input and output. Only the sBPF execution itself is replaced by native code. That is why its outputs equal the interpreter’s word for word.

Trust model

Three things bind a proof of a translated program.

  1. hc binds the translated code. The C is compiled into the image, so the image’s hc covers it.
  2. The ELF guard binds hc to one program. The shim loads the program from the ELF on the tape, so without a check one image could run a different, caller-chosen ELF under the same hc. The generated program.c therefore ends with a digest of the loaded program it was translated from: text, read-only data, their addresses and the entry point. Before running anything, the shim hashes the tape’s loaded program the same way (Poseidon2) and compares. Any other program halts with BadElf, status 2, over the pre-state. So a proof that verifies against this hc is a run of that program.
  3. H_PUB binds the ELF’s bytes. The ELF travels on the public tape, and the proof’s public-input digest covers it word for word.

Whether hc is the faithful translation of a given ELF is checked by rebuilding. Run the pinned sbpf2rv on the ELF, build with the pinned toolchain, and compare hc. The build is reproducible for this reason.

The chain does not record the source ELF or its hash. It stores the image and its program id. A deployer who wants the ELF known publishes it beside the program.

The guard binds the loaded program, not the file. Two ELFs that differ only where the loader does not look (header padding, symbol tables) load to the same program and run identically on both sides. An ELF with one changed .rodata byte, even one the transfer never reads, is refused by the translation and run by the interpreter.

What traps and what is refused

Nothing is refused at translation. A malformed ELF is refused with the loader’s own message, but no instruction in a well-formed ELF is. The scanner reports what a program could hit as warnings, and the translated program traps at run time exactly where the interpreter would:

warningwhat it isat run time
UnknownSyscalla syscall hash the runtime does not implementtrap UnknownSyscall
Cpia cross-program invocation (sol_invoke_signed*)the same UnknownSyscall trap; CPI is not implemented
RegisterOutOfRangea register r11..r15, or a callx register above r10trap BadInsn
UnknownOpcodean opcode with no v1 class, including the v2-only sdiv/srem family and hor64trap BadInsn
JumpOutOfTexta jump, call or fall-through outside the function’s texttrap BadJump
BadCallImmSrca call imm with src in 2..=10 (only in hand-built ELFs)trap BadInsn

A translator that refused unreached code would refuse programs the interpreter runs today. SPL Token is such a program: it calls sol_set_return_data and sol_get_sysvar, which the Transfer path never reaches.

Three divergences are accepted. Each one only makes the translation halt where the interpreter might not; the translation never completes a run the interpreter halts.

  1. A callx outside the scanned set traps BadJump, where the interpreter may run real code at that address.
  2. Budget checks may be deferred one basic block. To fit SPL Token under the 65,535-word cap, 1,505 of its 3,546 blocks skip their own instruction-limit check. The halt kind can differ inside the one block that crosses the limit. The status and the eight words are the same.
  3. Only the source program runs. Another ELF is refused (the guard, above).

Ed25519 and secp256k1. Software implementations of sol_ed25519_verify and sol_secp256k1_recover exist in sbpf-rt/ but are not linked. The interpreter traps on both, so for parity the translation traps too. Each call costs about 14.3M and 15.7M cycles respectively, far over the largest tier (2²⁰). They need coprocessor tables.

The numbers

Measured on SPL Token (guests-compiled/sbpf/programs/spl_token.so, the interpreter’s own test fixture), with the ELF guard in place.

imageprogram wordscaphc
SPL Token, translated65,09665,535 (439 spare)8ca905ae…758b
sbpf.bin, the interpreter8,31765,535059a7086…f476
vectorstatussBPF insnstranslated cyclessbpf.bin cyclestier (both)
Transfer 2501143765,851694,49820
Transfer more than the balance0133748,836679,81420
MintTo 2501120708,648634,42320
MintTo by the wrong authority0134696,694627,24120
Burn 2501131710,245637,03820
Burn more than the balance0121695,434624,78120
Transfer with two accounts069645,015569,65620
too many accounts (harness refuses)20303,933304,61620

Where the cycles go on the Transfer:

stagetranslatedsbpf.bin
read both tapes302,881302,880
load the ELF177,012173,859
check the account region103,829103,824
zero the sBPF stack and heap49,18149,181
output and input hashes, public words, glue51,09142,723
the ELF guard72,949—
the sBPF program itself8,90822,031
total765,851694,498

The translated program’s own execution is about 2.4 to 2.5 times cheaper per sBPF instruction (about 62 cycles against 154). But about 98% of every run is the fixed harness, and the guard adds 73k cycles. So for SPL Token the translated image is 69k to 75k cycles dearer, and both stay at tier 20.

Translation pays off only for compute-heavy programs. At about 90 cycles saved per instruction, a call that executes 10,000 sBPF instructions saves about 0.9M cycles. SPL Token executes 120 to 143.

Walkthrough: SPL Token, translated and matched

Every command runs from the root of a circuits checkout at 7ef3220, with the toolchain from Deploy a Rust program and clang 23.1.1 from Deploy a RISC-V program. Long output is cut where marked ….

0. Build the tools

(cd sbpf2rv && cargo +1.98.1 build --release)
(cd rand-guest && cargo +1.98.1 build --release)

1. Translate

--out must be a directory inside the circuits checkout.

$ sbpf2rv/target/release/sbpf2rv guests-compiled/sbpf/programs/spl_token.so --out sbpf2rv/target/spl-token --name spl-token
entry pc 225: 30 function(s), 3546 block(s), 12061 instruction(s), 30 callx target(s), 0 refusal(s), 4 warning(s)
  fn 225: 2065 block(s)
  …                                                (30 functions in all)
  warning: UnknownSyscall { pc: 7803, hash: 2720453611 }
  warning: UnknownSyscall { pc: 9530, hash: 2720453611 }
  warning: UnknownSyscall { pc: 8698, hash: 2720453611 }
  warning: UnknownSyscall { pc: 11547, hash: 331461893 }
wrote sbpf2rv/target/spl-token/program.c (521757 bytes of C) and the spl-token shim crate: rand-guest build sbpf2rv/target/spl-token

0 refusal(s). The four warnings are real syscalls that Transfer never reaches: hash 2720453611 is sol_set_return_data (three call sites) and 331461893 is sol_get_sysvar.

2. Build the image

The image is over the default 4,096-word cap, so pass --max-words 65535.

$ rand-guest/target/release/rand-guest build sbpf2rv/target/spl-token --max-words 65535
…
65096 words against a cap of 65535 (fits); 2 ecall(s) with a non-static a7
OK
wrote sbpf2rv/target/spl-token/image.bin and its .sha256 (65096 words, hc 8ca905ae3c62f503de7de86829040f323e098b53dba5fffe09b42f1aad16758b, program id 65d234ce9ca4d082f487c038abfa76e67f41a0d91e10b9ac388f1e1541d59184)

$ rand-guest/target/release/rand-guest info sbpf2rv/target/spl-token/image.bin
form: image container
text 64819 words at 0x10000; data 102 words (101 non-zero) at 0x4f4cc; prologue 277 words; program 65096 words from base_pc 0xfbac
hc 8ca905ae3c62f503de7de86829040f323e098b53dba5fffe09b42f1aad16758b
program id 65d234ce9ca4d082f487c038abfa76e67f41a0d91e10b9ac388f1e1541d59184
65096 words against a cap of 4096: does not fit

Anyone who runs these two steps with the same toolchain gets this hc.

3. Run both, and compare

The Transfer of 250 takes two word lists, built by tests/parity.rs from the interpreter’s own call encoding: 27,151 public words (the ELF, word-encoded) and 10,458 private words (the accounts, then the Transfer discriminant and amount). rand-guest run takes them as --public and --input. Shortened to the first words of each list:

$ rand-guest run sbpf2rv/target/spl-token/image.bin \
    --public 108600 1179403647 65794 0 0 17235971 1 2088 …   (27 151 words)
    --input  41825 4 0 65791 0 50529027 50529027 50529027 …  (10 458 words)
out[0] = 1
out[1] = 2892832079
out[2] = 376091303
out[3] = 1311040261
out[4] = 2015764099
out[5] = 3682593600
out[6] = 3311553006
out[7] = 141115266
cycles 765851
tier 20

The same two lists against the interpreter guest:

$ rand-guest run guests-compiled/bin/sbpf.bin \
    --public 108600 1179403647 65794 0 0 17235971 1 2088 …   (27 151 words)
    --input  41825 4 0 65791 0 50529027 50529027 50529027 …  (10 458 words)
out[0] = 1
out[1] = 2892832079
out[2] = 376091303
out[3] = 1311040261
out[4] = 2015764099
out[5] = 3682593600
out[6] = 3311553006
out[7] = 141115266
cycles 694498
tier 20

All eight words are identical. out[0] = 1 is the status (success). out[1..8] are the digest the interpreter’s ABI defines over the input and output.

4. Check all eight vectors in one command

The parity test builds the translated image, runs every vector through rand-guest run on both images, and fails on any difference in the eight words:

$ cd sbpf2rv && cargo +1.98.1 test --test parity the_spl_token -- --nocapture
…
translated SPL Token: 65096 program words (cap 65 535), hc 8ca905ae3c62f503de7de86829040f323e098b53dba5fffe09b42f1aad16758b
transfer 250: Ok(0), status 1; cycles translated 765851, interpreter guest 694498
transfer too much: Ok(1), status 0; cycles translated 748836, interpreter guest 679814
MintTo 250: Ok(0), status 1; cycles translated 708648, interpreter guest 634423
MintTo 250 signed by someone else: Ok(4), status 0; cycles translated 696694, interpreter guest 627241
Burn 250: Ok(0), status 1; cycles translated 710245, interpreter guest 637038
Burn 1 000 001 (balance 1 000 000): Ok(1), status 0; cycles translated 695434, interpreter guest 624781
transfer with two accounts: Ok(47244640256), status 0; cycles translated 645015, interpreter guest 569656
an account count above MAX_ACCOUNTS: Err(BadElf), status 2; cycles translated 303933, interpreter guest 304616
…
test the_spl_token_transfer_translates_and_matches_the_interpreter_word_for_word ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 8 filtered out; finished in 27.84s

5. Deploy

rand program deploy sbpf2rv/target/spl-token/image.bin --public spl_token.so

The image reads the ELF from the public tape, so the ELF is the program’s public input, fixed at deploy. --public takes the .so file itself; the wallet word-encodes it into the same 27,151 words as step 4.

This needs a chain whose genesis sets max_program_words to at least 65,096 and max_program_public_words to at least 27,151. Chain 12 capped a deploy at 4,096 words and took no public input, so the wallet refused this image there before proving anything. Chain 13, the v0.4 chain, set 65,535 and 32,768, and every chain since keeps both values. Chain 21, the live chain since 2026-10-12 (fullnode v0.8.0, genesis ca76c2766ed6090ac6ba5dad86b724dd5d2b80c0edfaed287cd8b129e663ca9a), also keeps chain 13’s call-envelope limit, sets the proof cap at 6 MiB (6,291,456 bytes; chains 17 to 20 set 4 MiB, chains 13 to 16 8 MiB) and the block cap at 19 MiB. The size caps are no longer the only gate. Chain 21’s genesis, like chain 20’s and chain 19’s, sets hardening_v6, and under it a deploy of more program words than a call can hold at the tier-14 call cap is invalid (ProgramUncallable): at most 8,180 words with no public input, fewer beside one. This image is 65,096 words beside a 27,151-word public input, so chain 21 refuses the deploy, and it would refuse a call as well: every SPL vector runs at tier 20, above the tier-14 call cap (Limits). Block aggregation is off on chain 21 (its genesis has no aggregation section); its bootstrap nodes are listed in Running a validator. The deploy fee counts the public words with the code words: 0.001 + 0.0001 × (65,096 + 27,151) = 9.2257 RAND, the fee the chain-13 deploy below paid.

This deploy ran on chain 13 on 2026-09-19, while chain 13 was the live chain (production FRI profile, genesis 8123ccac1883a45750e4df6964fb7cd3f0b321798cde4c0ef406a0293939ece3), from the genesis wallet shielded-1. Every chain since (14 to 19) was cut as a fresh chain without chain 13’s programs, and the image has not been redeployed on any of them, so the transaction and the height below are chain 13’s and no longer resolve on the explorer. The program id is a hash of base_pc, the words and the public words alone, so the same image and ELF get the same id on any chain that admits the deploy. The wallet’s output, with its progress lines elided (…):

$ rand program deploy <image.bin> --public spl_token.so
program id: 740236918310f8e52bb0c1ef49b2b0e0c018762e289666660685b0694c8dd00a (65096 words, hc ae05a98c03f5623c68e87dde320f0429538b093efeffa5db1a2fb4098b7516ad, public input 27151 words, digest ec57b10ed6f3a3d5fe87ec072f691322ad747377ba2f0a1788bcfe3e7334d67e)
…
proved in 94.3s: tier 14, 1418406 bytes
submitted deploy 626fc9387ecc1f6b39f44184807303ac2843029d8fb8e28338bf186445927530
…
program id740236918310f8e52bb0c1ef49b2b0e0c018762e289666660685b0694c8dd00a
image65,096 words; hc 8ca905ae… in rand-guest’s spelling, ae05a98c… in the wallet’s
public input27,151 words (the ELF), digest ec57b10ed6f3a3d5fe87ec072f691322ad747377ba2f0a1788bcfe3e7334d67e
fee bundle (chain 13’s 2-in-2-out bundle)tier 14, 1,418,406 bytes, proved in 94.3 s
deploy tx (chain 13)626fc9387ecc1f6b39f44184807303ac2843029d8fb8e28338bf186445927530
anchored at height (chain 13)400
fee9.2257 RAND

Since chain 14 the paying bundle is the one 4-in-4-out hidden-asset bundle every transaction carries, proved against the transaction’s binding. It is still tier 14: about 100 s and about 1.42 MB at the production profile on chains 14 and 15. Since chain 17 a bundle also carries a tier-10 auth proof (split authorisation), so on chains 18 and 19 a transaction’s fee bundle is about 1.49 MB of bundle proof and about 1.37 MB of auth proof.

The program id binds the ELF, so it differs from the id rand-guest info prints for the bare image. Call the id deploy printed.

Limits

  • No call to the program has been proved, it is not deployed on chain 21, and chain 21 refuses both the deploy and the call. The deploy above was on chain 13. Under chain 21’s hardening_v6 a deploy of more words than a tier-14 call can hold is invalid (ProgramUncallable; 8,180 words at most), and the image is 65,096. The input side would fit: the ELF is the deploy’s public input, which rand call fetches itself, and the 10,458 private words fit the input cap of 16,071, which chains 20 and 21 keep. But every SPL vector is tier 20, and since fullnode v0.5.6 (2026-09-25) a node refuses any call proof above tier 14 (CallTierTooHigh), before it builds a verifier key. The same check caps the hash tables a call may declare: 2¹³ SHA-256 rows (128 compressions) and 2¹² Keccak rows (128 permutations). A tier-20 call can only be proved off chain, and no tier-20 proof exists yet (next point).
  • No proof has been made at tier 20 yet. A tier-20 proof was killed on a 48 GB laptop at about 31 GB, and OOM-killed on a 64 GB droplet at 65.1 GB after 10 m 41 s. Extrapolated from the measured tier-16/18 scaling (memory about ×3.9, time about ×4.1 per +2 tiers), tier 20 needs about 330 GB and about 3.6 h — more than DigitalOcean’s largest memory droplet (m-32vcpu-256gb, 256 GB). The prover ran single-threaded: a 16-vCPU box sat at 99% of one core. See proving memory.
  • No CPI. Cross-program invocations trap.
  • sBPF v1 only. v2-only opcodes trap.
  • Ed25519 and secp256k1 syscalls trap until they have coprocessor tables.
  • The chain records the ELF only as the public input. A deploy with --public stores the ELF’s words and their digest, and rand_getProgramPublic serves them. The chain does not record how the image was built from it: publish the build inputs so others can rebuild hc.

Last verified against fullnode commit c0878c3f (chain 21) on 2026-10-12, and circuits `7ef3220` (`sbpf2rv/README.md`, `docs/superpowers/specs/2026-09-18-sbpf-to-rv32-translator-design.md`); steps 1, 2 and the parity test re-run on 2026-09-19. Where the code and this page disagree, the code is right; tell us.