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
| input | an sBPF v1 ELF |
| output | a C shim crate; rand-guest build makes it an image container |
| where it runs | off chain, on the developer’s machine; the chain only sees the image |
| what it must match | sbpf.bin’s eight output words, on the same two input tapes |
| repository | sbpf2rv/ in the circuits repository |
| pinned toolchain | Rust 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.
hcbinds the translated code. The C is compiled into the image, so the image’shccovers it.- The ELF guard binds
hcto 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 samehc. The generatedprogram.ctherefore 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 withBadElf, status 2, over the pre-state. So a proof that verifies against thishcis a run of that program. H_PUBbinds 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:
| warning | what it is | at run time |
|---|---|---|
UnknownSyscall | a syscall hash the runtime does not implement | trap UnknownSyscall |
Cpi | a cross-program invocation (sol_invoke_signed*) | the same UnknownSyscall trap; CPI is not implemented |
RegisterOutOfRange | a register r11..r15, or a callx register above r10 | trap BadInsn |
UnknownOpcode | an opcode with no v1 class, including the v2-only sdiv/srem family and hor64 | trap BadInsn |
JumpOutOfText | a jump, call or fall-through outside the function’s text | trap BadJump |
BadCallImmSrc | a 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.
- A
callxoutside the scanned set trapsBadJump, where the interpreter may run real code at that address. - 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.
- 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.
| image | program words | cap | hc |
|---|---|---|---|
| SPL Token, translated | 65,096 | 65,535 (439 spare) | 8ca905ae…758b |
sbpf.bin, the interpreter | 8,317 | 65,535 | 059a7086…f476 |
| vector | status | sBPF insns | translated cycles | sbpf.bin cycles | tier (both) |
|---|---|---|---|---|---|
Transfer 250 | 1 | 143 | 765,851 | 694,498 | 20 |
Transfer more than the balance | 0 | 133 | 748,836 | 679,814 | 20 |
MintTo 250 | 1 | 120 | 708,648 | 634,423 | 20 |
MintTo by the wrong authority | 0 | 134 | 696,694 | 627,241 | 20 |
Burn 250 | 1 | 131 | 710,245 | 637,038 | 20 |
Burn more than the balance | 0 | 121 | 695,434 | 624,781 | 20 |
Transfer with two accounts | 0 | 69 | 645,015 | 569,656 | 20 |
| too many accounts (harness refuses) | 2 | 0 | 303,933 | 304,616 | 20 |
Where the cycles go on the Transfer:
| stage | translated | sbpf.bin |
|---|---|---|
| read both tapes | 302,881 | 302,880 |
| load the ELF | 177,012 | 173,859 |
| check the account region | 103,829 | 103,824 |
| zero the sBPF stack and heap | 49,181 | 49,181 |
| output and input hashes, public words, glue | 51,091 | 42,723 |
| the ELF guard | 72,949 | — |
| the sBPF program itself | 8,908 | 22,031 |
| total | 765,851 | 694,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 id | 740236918310f8e52bb0c1ef49b2b0e0c018762e289666660685b0694c8dd00a |
| image | 65,096 words; hc 8ca905ae… in rand-guest’s spelling, ae05a98c… in the wallet’s |
| public input | 27,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 |
| fee | 9.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_v6a 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, whichrand callfetches 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
--publicstores the ELF’s words and their digest, andrand_getProgramPublicserves them. The chain does not record how the image was built from it: publish the build inputs so others can rebuildhc.