sbpf2rv prende un programma Solana compilato (un ELF sBPF, il .so prodotto da cargo build-sbf) e lo trasforma in un programma Rand. Scrive codice C, e rand-guest build compila quel C in un’immagine RV32IM. L’immagine pubblica le stesse otto parole di output del guest interprete sBPF di Rand, sbpf.bin, per lo stesso ELF e la stessa istruzione. Questa pagina mostra l’intero percorso sul vero programma SPL Token.
Che cos’è
| input | un ELF sBPF v1 |
| output | un crate shim in C; rand-guest build ne ricava un contenitore di immagine |
| dove viene eseguito | off chain, sulla macchina dello sviluppatore; la chain vede solo l’immagine |
| a cosa deve corrispondere | alle otto parole di output di sbpf.bin, sugli stessi due nastri di input |
| repository | sbpf2rv/ nel repository dei circuiti |
| toolchain fissata | Rust 1.98.1, clang 23.1.1, il crate cc 1.4.6 |
Il programma tradotto mantiene l’ABI dell’interprete. Legge gli stessi nastri: l’ELF sul nastro pubblico e l’istruzione serializzata (prima gli account, poi i dati dell’istruzione) sul nastro privato. Esegue lo stesso harness sbpf-core attorno al programma: decodifica l’input, carica l’ELF, controlla la regione degli account e calcola l’hash di input e output. Solo l’esecuzione sBPF vera e propria è sostituita da codice nativo. Per questo i suoi output coincidono con quelli dell’interprete parola per parola.
Modello di fiducia
Tre elementi vincolano la prova di un programma tradotto.
hcvincola il codice tradotto. Il C è compilato nell’immagine, quindi l’hcdell’immagine lo copre.- La guardia sull’ELF vincola
hca un solo programma. Lo shim carica il programma dall’ELF presente sul nastro; senza un controllo, un’unica immagine potrebbe eseguire sotto lo stessohcun ELF diverso, scelto dal chiamante. Per questo ilprogram.cgenerato termina con un digest del programma caricato da cui è stato tradotto: testo, dati di sola lettura, i loro indirizzi e il punto di ingresso. Prima di eseguire qualsiasi cosa, lo shim calcola allo stesso modo (Poseidon2) l’hash del programma caricato dal nastro e lo confronta. Qualsiasi altro programma si arresta conBadElf, stato 2, sullo stato precedente. Quindi una prova che si verifica rispetto a questohcè l’esecuzione di quel programma. H_PUBvincola i byte dell’ELF. L’ELF viaggia sul nastro pubblico, e il digest degli input pubblici della prova lo copre parola per parola.
Se hc sia la traduzione fedele di un dato ELF si verifica ricostruendo. Si esegue sbpf2rv alla versione fissata sull’ELF, si compila con la toolchain fissata e si confronta hc. La build è riproducibile proprio per questo motivo.
La chain non registra l’ELF sorgente né il suo hash. Memorizza l’immagine e il suo program id. Chi effettua il deploy e vuole che l’ELF sia noto lo pubblica accanto al programma.
La guardia vincola il programma caricato, non il file. Due ELF che differiscono solo dove il loader non guarda (padding dell’header, tabelle dei simboli) si caricano nello stesso programma e vengono eseguiti in modo identico da entrambe le parti. Un ELF con un solo byte di .rodata modificato, anche uno che il trasferimento non legge mai, viene rifiutato dalla traduzione ed eseguito dall’interprete.
Che cosa genera una trap e che cosa viene rifiutato
Nulla viene rifiutato in fase di traduzione. Un ELF malformato viene rifiutato con il messaggio del loader stesso, ma nessuna istruzione di un ELF ben formato lo è. Lo scanner segnala come avvisi ciò che un programma potrebbe incontrare, e il programma tradotto genera una trap a run time esattamente dove lo farebbe l’interprete:
| avviso | che cos’è | a run time |
|---|---|---|
UnknownSyscall | un hash di syscall che il runtime non implementa | trap UnknownSyscall |
Cpi | una cross-program invocation (sol_invoke_signed*) | la stessa trap UnknownSyscall; la CPI non è implementata |
RegisterOutOfRange | un registro r11..r15, oppure un registro di callx superiore a r10 | trap BadInsn |
UnknownOpcode | un opcode senza classe v1, inclusa la famiglia sdiv/srem solo v2 e hor64 | trap BadInsn |
JumpOutOfText | un salto, una chiamata o un fall-through fuori dal testo della funzione | trap BadJump |
BadCallImmSrc | una call imm con src in 2..=10 (solo negli ELF costruiti a mano) | trap BadInsn |
Un traduttore che rifiutasse il codice non raggiunto rifiuterebbe programmi che l’interprete esegue oggi. SPL Token è uno di questi programmi: chiama sol_set_return_data e sol_get_sysvar, che il percorso Transfer non raggiunge mai.
Sono accettate tre divergenze. Ciascuna fa solo sì che la traduzione si arresti dove l’interprete potrebbe non farlo; la traduzione non porta mai a termine un’esecuzione che l’interprete interrompe.
- Una
callxfuori dall’insieme scansionato genera la trapBadJump, mentre l’interprete potrebbe eseguire codice reale a quell’indirizzo. - I controlli del budget possono essere rinviati di un basic block. Per far stare SPL Token sotto il limite di 65.535 parole, 1.505 dei suoi 3.546 blocchi omettono il proprio controllo del limite di istruzioni. Il tipo di arresto può differire all’interno dell’unico blocco che supera il limite. Lo stato e le otto parole sono gli stessi.
- Viene eseguito solo il programma sorgente. Un altro ELF è rifiutato (la guardia, sopra).
Ed25519 e secp256k1. Implementazioni software di sol_ed25519_verify e sol_secp256k1_recover esistono in sbpf-rt/ ma non sono collegate. L’interprete genera una trap su entrambe, quindi per parità la traduzione fa lo stesso. Ogni chiamata costa rispettivamente circa 14,3M e 15,7M cicli, ben oltre il livello più grande (2²⁰). Servono tabelle del coprocessore.
I numeri
Misurati su SPL Token (guests-compiled/sbpf/programs/spl_token.so, la fixture di test dell’interprete stesso), con la guardia sull’ELF attiva.
| immagine | parole di programma | limite | hc |
|---|---|---|---|
| SPL Token, tradotto | 65.096 | 65.535 (439 di margine) | 8ca905ae…758b |
sbpf.bin, l’interprete | 8.317 | 65.535 | 059a7086…f476 |
| vettore | stato | istruzioni sBPF | cicli tradotto | cicli sbpf.bin | livello (entrambi) |
|---|---|---|---|---|---|
Transfer 250 | 1 | 143 | 765.851 | 694.498 | 20 |
Transfer oltre il saldo | 0 | 133 | 748.836 | 679.814 | 20 |
MintTo 250 | 1 | 120 | 708.648 | 634.423 | 20 |
MintTo da parte dell’autorità sbagliata | 0 | 134 | 696.694 | 627.241 | 20 |
Burn 250 | 1 | 131 | 710.245 | 637.038 | 20 |
Burn oltre il saldo | 0 | 121 | 695.434 | 624.781 | 20 |
Transfer con due account | 0 | 69 | 645.015 | 569.656 | 20 |
| troppi account (l’harness rifiuta) | 2 | 0 | 303.933 | 304.616 | 20 |
Dove vanno i cicli nel Transfer:
| fase | tradotto | sbpf.bin |
|---|---|---|
| lettura di entrambi i nastri | 302.881 | 302.880 |
| caricamento dell’ELF | 177.012 | 173.859 |
| controllo della regione degli account | 103.829 | 103.824 |
| azzeramento di stack e heap sBPF | 49.181 | 49.181 |
| hash di output e input, parole pubbliche, glue | 51.091 | 42.723 |
| la guardia sull’ELF | 72.949 | — |
| il programma sBPF stesso | 8.908 | 22.031 |
| totale | 765.851 | 694.498 |
L’esecuzione del programma tradotto è circa 2,4–2,5 volte più economica per istruzione sBPF (circa 62 cicli contro 154). Ma circa il 98% di ogni esecuzione è costituito dall’harness fisso, e la guardia aggiunge 73k cicli. Quindi, per SPL Token, l’immagine tradotta costa da 69k a 75k cicli in più, ed entrambe restano al livello 20.
La traduzione conviene solo per i programmi ad alta intensità di calcolo. Con circa 90 cicli risparmiati per istruzione, una chiamata che esegue 10.000 istruzioni sBPF risparmia circa 0,9M cicli. SPL Token ne esegue da 120 a 143.
Procedura guidata: SPL Token, tradotto e verificato
Ogni comando viene eseguito dalla radice di un checkout dei circuiti a 7ef3220, con la toolchain di Deploy a Rust program e clang 23.1.1 da Deploy a RISC-V program. Gli output lunghi sono tagliati dove indicato con ….
0. Compilare gli strumenti
(cd sbpf2rv && cargo +1.98.1 build --release)
(cd rand-guest && cargo +1.98.1 build --release)
1. Tradurre
--out deve essere una directory all’interno del checkout dei circuiti.
$ 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). I quattro avvisi sono syscall reali che Transfer non raggiunge mai: l’hash 2720453611 è sol_set_return_data (tre punti di chiamata) e 331461893 è sol_get_sysvar.
2. Compilare l’immagine
L’immagine supera il limite predefinito di 4.096 parole, quindi si passa --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
Chiunque esegua questi due passaggi con la stessa toolchain ottiene questo hc.
3. Eseguire entrambi e confrontare
Il Transfer di 250 usa due liste di parole, costruite da tests/parity.rs a partire dalla codifica di chiamata dell’interprete stesso: 27.151 parole pubbliche (l’ELF, codificato in parole) e 10.458 parole private (gli account, poi il discriminante e l’importo del Transfer). rand-guest run le riceve come --public e --input. Abbreviate alle prime parole di ciascuna lista:
$ 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
Le stesse due liste sul guest interprete:
$ 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
Tutte e otto le parole sono identiche. out[0] = 1 è lo stato (successo). out[1..8] sono il digest che l’ABI dell’interprete definisce su input e output.
4. Verificare tutti gli otto vettori con un solo comando
Il test di parità compila l’immagine tradotta, esegue ogni vettore con rand-guest run su entrambe le immagini e fallisce a qualsiasi differenza nelle otto parole:
$ 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
L’immagine legge l’ELF dal nastro pubblico, quindi l’ELF è l’input pubblico del programma, fissato al momento del deploy. --public accetta il file .so stesso; il wallet lo codifica in parole negli stessi 27,151 word del passo 4.
Serve una chain il cui genesis imposti max_program_words ad almeno 65,096 e max_program_public_words ad almeno 27,151. La chain 12 limitava un deploy a 4,096 parole e non prevedeva alcun input pubblico, quindi il wallet rifiutava questa immagine prima ancora di generare una prova. La chain 13, la chain della v0.4, impostava 65,535 e 32,768, e ogni chain successiva mantiene entrambi i valori. La chain 20, quella attiva (genesis 6210cf071a390d7ac61d8cbea5dd9d139d1a493a862b54a45a498f36af2d5135), mantiene inoltre i limiti di blocco e di call-envelope della chain 13 e dimezza il tetto della prova a 4 MiB (4,194,304 byte, dalla chain 17; le chain da 13 a 16 impostavano 8 MiB). I tetti dimensionali non sono più l’unico controllo. Il genesis della chain 20 imposta hardening_v6, e con esso un deploy di più parole di programma di quante ne possa contenere una chiamata al tetto di chiamata del tier 14 non è valido (ProgramUncallable): al massimo 8,180 parole senza input pubblico, meno se ce n’è uno. Questa immagine conta 65,096 parole accanto a un input pubblico di 27,151 parole, quindi la chain 20 rifiuta il deploy, e rifiuterebbe anche una chiamata: ogni vettore SPL viene eseguito al tier 20, sopra il tetto di chiamata del tier 14 (Limiti). L’aggregazione dei blocchi è disattivata sulla chain 20 (il suo genesis non ha una sezione aggregation); i suoi bootstrap node sono elencati in Running a validator. La commissione di deploy conta le parole pubbliche insieme alle parole di codice: 0.001 + 0.0001 × (65,096 + 27,151) = 9.2257 RAND, la commissione pagata dal deploy sulla chain 13 qui sotto.
Questo deploy è stato eseguito sulla chain 13 il 2026-09-19, quando la chain 13 era la chain attiva (profilo FRI di produzione, genesis 8123ccac1883a45750e4df6964fb7cd3f0b321798cde4c0ef406a0293939ece3), dal wallet genesis shielded-1. Ogni chain successiva (dalla 14 alla 19) è stata creata come chain nuova, senza i programmi della chain 13, e l’immagine non è stata rideployata su nessuna di esse, quindi la transazione e l’altezza qui sotto appartengono alla chain 13 e non si risolvono più sull’explorer. L’id del programma è un hash di base_pc, delle parole e delle sole parole pubbliche, quindi la stessa immagine e lo stesso ELF ottengono lo stesso id su qualsiasi chain che ammetta il deploy. L’output del wallet, con le righe di avanzamento omesse (…):
$ 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
…
| id del programma | 740236918310f8e52bb0c1ef49b2b0e0c018762e289666660685b0694c8dd00a |
| immagine | 65,096 parole; hc 8ca905ae… nella grafia di rand-guest, ae05a98c… in quella del wallet |
| input pubblico | 27,151 parole (l’ELF), digest ec57b10ed6f3a3d5fe87ec072f691322ad747377ba2f0a1788bcfe3e7334d67e |
| bundle delle commissioni (bundle 2-in-2-out della chain 13) | tier 14, 1,418,406 byte, provato in 94.3 s |
| tx di deploy (chain 13) | 626fc9387ecc1f6b39f44184807303ac2843029d8fb8e28338bf186445927530 |
| ancorata all’altezza (chain 13) | 400 |
| commissione | 9.2257 RAND |
Dalla chain 14 il bundle di pagamento è l’unico bundle hidden-asset 4-in-4-out che ogni transazione porta con sé, provato rispetto al binding della transazione. È ancora al tier 14: circa 100 s e circa 1.42 MB al profilo di produzione sulle chain 14 e 15. Dalla chain 17 un bundle porta anche una prova di autenticazione del tier 10 (autorizzazione divisa), quindi sulle chain 18 e 19 il bundle delle commissioni di una transazione è di circa 1.49 MB di prova del bundle e circa 1.37 MB di prova di autenticazione.
L’id del programma vincola l’ELF, quindi differisce dall’id che rand-guest info stampa per l’immagine nuda. Chiamate l’id stampato da deploy.
Limiti
- Nessuna chiamata al programma è stata provata, il programma non è deployato sulla chain 20, e la chain 20 rifiuta sia il deploy sia la chiamata. Il deploy qui sopra è avvenuto sulla chain 13. Con
hardening_v6della chain 20, un deploy di più parole di quante ne possa contenere una chiamata del tier 14 non è valido (ProgramUncallable; al massimo 8,180 parole), e l’immagine ne conta 65,096. Il lato input rientrerebbe nei limiti: l’ELF è l’input pubblico del deploy, cherand callrecupera da sé, e le 10,458 parole private rientrano nel tetto di input di 16,071, che la chain 20 mantiene. Ma ogni vettore SPL è al tier 20, e dalla fullnode v0.5.6 (2026-09-25) un nodo rifiuta qualsiasi prova di chiamata sopra il tier 14 (CallTierTooHigh), prima di costruire una verifier key. Lo stesso controllo limita le tabelle hash che una chiamata può dichiarare: 2¹³ righe SHA-256 (128 compressioni) e 2¹² righe Keccak (128 permutazioni). Una chiamata del tier 20 può essere provata solo off chain, e non esiste ancora alcuna prova del tier 20 (punto successivo). - Nessuna prova è stata ancora prodotta al tier 20. Una prova del tier 20 è stata terminata su un laptop da 48 GB a circa 31 GB, ed è stata terminata per OOM su un droplet da 64 GB a 65.1 GB dopo 10 m 41 s. Estrapolando dalla scala misurata tra i tier 16 e 18 (memoria circa ×3.9, tempo circa ×4.1 ogni +2 tier), il tier 20 richiede circa 330 GB e circa 3.6 h, più del droplet con più memoria di DigitalOcean (m-32vcpu-256gb, 256 GB). Il prover è stato eseguito a thread singolo: una macchina da 16 vCPU è rimasta al 99% di un solo core. Vedi memoria di proving.
- Nessun CPI. Le invocazioni tra programmi vanno in trap.
- Solo sBPF v1. Gli opcode solo v2 vanno in trap.
- Le syscall Ed25519 e secp256k1 vanno in trap finché non avranno tabelle di coprocessore.
- La chain registra l’ELF solo come input pubblico. Un deploy con
--publicmemorizza le parole dell’ELF e il loro digest, erand_getProgramPublicle serve. La chain non registra come l’immagine sia stata costruita a partire da esso: pubblicate gli input di build in modo che altri possano ricostruirehc.