Rand Protocol
Documentation
Programmes RISC-V

Le transpileur Solana

sbpf2rv traduit un programme Solana sBPF vers le RISC-V ISA de Rand : ce qu'il est, le modèle de confiance et sa garde ELF, ce qui déclenche un trap et ce qui est refusé, les chiffres mesurés, et un pas-à-pas complet qui traduit le véritable programme SPL Token et concorde mot pour mot avec l'interpréteur sBPF.

sbpf2rv prend un programme Solana compilé (un ELF sBPF, le .so que produit cargo build-sbf) et le transforme en programme Rand. Il écrit du C, et rand-guest build compile ce C en une image RV32IM. L’image publie les mêmes huit mots de sortie que l’invité interpréteur sBPF de Rand, sbpf.bin, pour le même ELF et la même instruction. Cette page montre le parcours complet sur le vrai programme SPL Token.

Ce que c’est

entréeun ELF sBPF v1
sortieun crate shim en C ; rand-guest build en fait un conteneur d’image
où il s’exécutehors chaîne, sur la machine du développeur ; la chaîne ne voit que l’image
ce qu’il doit reproduireles huit mots de sortie de sbpf.bin, sur les deux mêmes bandes d’entrée
dépôtsbpf2rv/ dans le dépôt circuits
chaîne d’outils épingléeRust 1.98.1, clang 23.1.1, le crate cc 1.4.6

Le programme traduit conserve l’ABI de l’interpréteur. Il lit les mêmes bandes : l’ELF sur la bande publique et l’instruction sérialisée (les comptes, puis les données d’instruction) sur la bande privée. Il exécute autour du programme le même harnais sbpf-core : il décode l’entrée, charge l’ELF, vérifie la région des comptes et hache l’entrée et la sortie. Seule l’exécution sBPF proprement dite est remplacée par du code natif. C’est pourquoi ses sorties sont égales à celles de l’interpréteur, mot pour mot.

Modèle de confiance

Trois éléments lient une preuve d’un programme traduit.

  1. hc lie le code traduit. Le C est compilé dans l’image, donc le hc de l’image le couvre.
  2. La garde ELF lie hc à un seul programme. Le shim charge le programme depuis l’ELF présent sur la bande ; sans vérification, une même image pourrait donc exécuter un autre ELF, choisi par l’appelant, sous le même hc. Le program.c généré se termine donc par une empreinte du programme chargé dont il est issu : texte, données en lecture seule, leurs adresses et le point d’entrée. Avant d’exécuter quoi que ce soit, le shim hache de la même manière (Poseidon2) le programme chargé depuis la bande et compare. Tout autre programme s’arrête avec BadElf, statut 2, sur l’état préalable. Une preuve qui se vérifie contre ce hc est donc l’exécution de ce programme.
  3. H_PUB lie les octets de l’ELF. L’ELF circule sur la bande publique, et l’empreinte des entrées publiques de la preuve le couvre mot pour mot.

Que hc soit la traduction fidèle d’un ELF donné se vérifie en reconstruisant. Exécutez le sbpf2rv épinglé sur l’ELF, compilez avec la chaîne d’outils épinglée, puis comparez hc. La compilation est reproductible pour cette raison.

La chaîne n’enregistre ni l’ELF source ni son empreinte. Elle stocke l’image et son identifiant de programme. Un déployeur qui veut que l’ELF soit connu le publie à côté du programme.

La garde lie le programme chargé, pas le fichier. Deux ELF qui ne diffèrent que là où le chargeur ne regarde pas (remplissage d’en-tête, tables de symboles) se chargent en un même programme et s’exécutent de façon identique des deux côtés. Un ELF dont un seul octet de .rodata a changé, même un octet que le transfert ne lit jamais, est refusé par la traduction et exécuté par l’interpréteur.

Ce qui s’interrompt et ce qui est refusé

Rien n’est refusé à la traduction. Un ELF malformé est refusé avec le message propre du chargeur, mais aucune instruction d’un ELF bien formé ne l’est. Le scanner signale sous forme d’avertissements ce qu’un programme pourrait rencontrer, et le programme traduit s’interrompt à l’exécution exactement là où l’interpréteur le ferait :

avertissementce que c’està l’exécution
UnknownSyscallun hash de syscall que le runtime n’implémente pasinterruption UnknownSyscall
Cpiune invocation inter-programmes (sol_invoke_signed*)la même interruption UnknownSyscall ; CPI n’est pas implémenté
RegisterOutOfRangeun registre r11..r15, ou un registre de callx au-dessus de r10interruption BadInsn
UnknownOpcodeun opcode sans classe v1, y compris la famille sdiv/srem propre à la v2 et hor64interruption BadInsn
JumpOutOfTextun saut, un appel ou un passage en séquence hors du texte de la fonctioninterruption BadJump
BadCallImmSrcun call imm avec src dans 2..=10 (uniquement dans les ELF construits à la main)interruption BadInsn

Un traducteur qui refuserait le code non atteint refuserait des programmes que l’interpréteur exécute aujourd’hui. SPL Token en est un : il appelle sol_set_return_data et sol_get_sysvar, que le chemin Transfer n’atteint jamais.

Trois divergences sont acceptées. Chacune ne fait que provoquer un arrêt de la traduction là où l’interpréteur pourrait continuer ; la traduction n’achève jamais une exécution que l’interpréteur arrête.

  1. Un callx hors de l’ensemble scanné s’interrompt avec BadJump, alors que l’interpréteur peut exécuter du vrai code à cette adresse.
  2. Les contrôles de budget peuvent être différés d’un bloc de base. Pour faire tenir SPL Token sous le plafond de 65 535 mots, 1 505 de ses 3 546 blocs sautent leur propre contrôle de limite d’instructions. La nature de l’arrêt peut différer à l’intérieur du seul bloc qui franchit la limite. Le statut et les huit mots sont les mêmes.
  3. Seul le programme source s’exécute. Tout autre ELF est refusé (la garde, ci-dessus).

Ed25519 et secp256k1. Des implémentations logicielles de sol_ed25519_verify et sol_secp256k1_recover existent dans sbpf-rt/ mais ne sont pas liées. L’interpréteur s’interrompt sur les deux ; par parité, la traduction s’interrompt aussi. Chaque appel coûte environ 14,3 M et 15,7 M de cycles respectivement, bien au-delà du plus grand palier (2²⁰). Il leur faudrait des tables de coprocesseur.

Les chiffres

Mesurés sur SPL Token (guests-compiled/sbpf/programs/spl_token.so, la fixture de test de l’interpréteur lui-même), avec la garde ELF en place.

imagemots de programmeplafondhc
SPL Token, traduit65 09665 535 (439 de marge)8ca905ae…758b
sbpf.bin, l’interpréteur8 31765 535059a7086…f476
vecteurstatutinsns sBPFcycles traduitscycles sbpf.binpalier (les deux)
Transfer 2501143765 851694 49820
Transfer au-delà du solde0133748 836679 81420
MintTo 2501120708 648634 42320
MintTo par la mauvaise autorité0134696 694627 24120
Burn 2501131710 245637 03820
Burn au-delà du solde0121695 434624 78120
Transfer avec deux comptes069645 015569 65620
trop de comptes (le harnais refuse)20303 933304 61620

Où vont les cycles sur le Transfer :

étapetraduitsbpf.bin
lecture des deux bandes302 881302 880
chargement de l’ELF177 012173 859
vérification de la région des comptes103 829103 824
mise à zéro de la pile et du tas sBPF49 18149 181
empreintes de sortie et d’entrée, mots publics, colle51 09142 723
la garde ELF72 949—
le programme sBPF lui-même8 90822 031
total765 851694 498

L’exécution propre du programme traduit est environ 2,4 à 2,5 fois moins chère par instruction sBPF (environ 62 cycles contre 154). Mais environ 98 % de chaque exécution est le harnais fixe, et la garde ajoute 73 k cycles. Pour SPL Token, l’image traduite est donc plus chère de 69 k à 75 k cycles, et les deux restent au palier 20.

La traduction ne rapporte que pour les programmes à forte charge de calcul. À environ 90 cycles économisés par instruction, un appel qui exécute 10 000 instructions sBPF économise environ 0,9 M de cycles. SPL Token en exécute 120 à 143.

Pas à pas : SPL Token, traduit et vérifié par parité

Chaque commande s’exécute depuis la racine d’une copie de circuits à 7ef3220, avec la chaîne d’outils de Déployer un programme Rust et clang 23.1.1 de Déployer un programme RISC-V. Les sorties longues sont coupées là où figure ….

0. Construire les outils

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

1. Traduire

--out doit être un répertoire situé dans la copie de circuits.

$ 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). Les quatre avertissements sont de vrais syscalls que Transfer n’atteint jamais : le hash 2720453611 est sol_set_return_data (trois sites d’appel) et 331461893 est sol_get_sysvar.

2. Construire l’image

L’image dépasse le plafond par défaut de 4 096 mots ; passez donc --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

Quiconque exécute ces deux étapes avec la même chaîne d’outils obtient ce hc.

3. Exécuter les deux et comparer

Le Transfer de 250 prend deux listes de mots, construites par tests/parity.rs à partir de l’encodage d’appel de l’interpréteur lui-même : 27 151 mots publics (l’ELF, encodé en mots) et 10 458 mots privés (les comptes, puis le discriminant Transfer et le montant). rand-guest run les reçoit par --public et --input. Abrégées aux premiers mots de chaque liste :

$ 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

Les deux mêmes listes contre l’invité interpréteur :

$ 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

Les huit mots sont identiques. out[0] = 1 est le statut (succès). out[1..8] est l’empreinte que l’ABI de l’interpréteur définit sur l’entrée et la sortie.

4. Vérifier les huit vecteurs en une seule commande

Le test de parité construit l’image traduite, exécute chaque vecteur avec rand-guest run sur les deux images, et échoue à la moindre différence sur les huit mots :

$ 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. Déployer

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

L’image lit l’ELF depuis la bande publique ; l’ELF est donc l’entrée publique du programme, fixée au déploiement. --public prend le fichier .so lui-même ; le wallet l’encode en mots, dans les mêmes 27,151 mots qu’à l’étape 4.

Cela exige une chaîne dont le genesis fixe max_program_words à au moins 65,096 et max_program_public_words à au moins 27,151. La chaîne 12 plafonnait un déploiement à 4,096 mots et n’acceptait aucune entrée publique ; le wallet y refusait donc cette image avant toute preuve. La chaîne 13, celle de la v0.4, fixait 65,535 et 32,768, et chaque chaîne depuis conserve ces deux valeurs. La chaîne 20, la chaîne en service (genesis 6210cf071a390d7ac61d8cbea5dd9d139d1a493a862b54a45a498f36af2d5135), conserve aussi les limites de bloc et d’enveloppe d’appel de la chaîne 13, et réduit de moitié le plafond de preuve à 4 MiB (4,194,304 octets, depuis la chaîne 17 ; les chaînes 13 à 16 fixaient 8 MiB). Les plafonds de taille ne sont plus la seule barrière. Le genesis de la chaîne 20 active hardening_v6, et sous ce régime un déploiement comptant plus de mots de programme qu’un appel n’en peut contenir au plafond d’appel du palier 14 est invalide (ProgramUncallable) : au plus 8,180 mots sans entrée publique, moins avec une entrée publique. Cette image fait 65,096 mots, avec une entrée publique de 27,151 mots ; la chaîne 20 refuse donc le déploiement, et refuserait aussi un appel : chaque vecteur SPL s’exécute au palier 20, au-dessus du plafond d’appel du palier 14 (Limites). L’agrégation de blocs est désactivée sur la chaîne 20 (son genesis n’a pas de section aggregation) ; ses nœuds d’amorçage sont listés dans Exécuter un validateur. Les frais de déploiement comptent les mots publics avec les mots de code : 0.001 + 0.0001 × (65,096 + 27,151) = 9.2257 RAND, soit les frais payés par le déploiement sur la chaîne 13 ci-dessous.

Ce déploiement a eu lieu sur la chaîne 13 le 2026-09-19, lorsque la chaîne 13 était la chaîne en service (profil FRI de production, genesis 8123ccac1883a45750e4df6964fb7cd3f0b321798cde4c0ef406a0293939ece3), depuis le wallet genesis shielded-1. Chaque chaîne depuis (14 à 19) a été créée comme une chaîne neuve, sans les programmes de la chaîne 13, et l’image n’a été redéployée sur aucune d’elles ; la transaction et la hauteur ci-dessous sont donc celles de la chaîne 13 et ne se résolvent plus dans l’explorateur. L’identifiant de programme est un hachage de base_pc, des mots et des mots publics seuls ; la même image et le même ELF obtiennent donc le même identifiant sur toute chaîne qui admet le déploiement. La sortie du wallet, avec ses lignes de progression omises (…) :

$ 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
…
identifiant du programme740236918310f8e52bb0c1ef49b2b0e0c018762e289666660685b0694c8dd00a
image65,096 mots ; hc 8ca905ae… selon la notation de rand-guest, ae05a98c… selon celle du wallet
entrée publique27,151 mots (l’ELF), condensat ec57b10ed6f3a3d5fe87ec072f691322ad747377ba2f0a1788bcfe3e7334d67e
bundle de frais (bundle 2-in-2-out de la chaîne 13)palier 14, 1,418,406 octets, prouvé en 94.3 s
tx de déploiement (chaîne 13)626fc9387ecc1f6b39f44184807303ac2843029d8fb8e28338bf186445927530
ancrée à la hauteur (chaîne 13)400
frais9.2257 RAND

Depuis la chaîne 14, le bundle de paiement est l’unique bundle d’actifs masqués 4-in-4-out que porte chaque transaction, prouvé par rapport à la liaison (binding) de la transaction. Il reste au palier 14 : environ 100 s et environ 1.42 Mo au profil de production sur les chaînes 14 et 15. Depuis la chaîne 17, un bundle porte aussi une preuve d’authentification de palier 10 (autorisation scindée) ; sur les chaînes 18 et 19, le bundle de frais d’une transaction représente donc environ 1.49 Mo de preuve de bundle et environ 1.37 Mo de preuve d’authentification.

L’identifiant de programme lie l’ELF ; il diffère donc de l’identifiant qu’affiche rand-guest info pour l’image seule. Appelez l’identifiant qu’a affiché deploy.

Limites

  • Aucun appel au programme n’a été prouvé, il n’est pas déployé sur la chaîne 20, et la chaîne 20 refuse à la fois le déploiement et l’appel. Le déploiement ci-dessus a eu lieu sur la chaîne 13. Sous hardening_v6 de la chaîne 20, un déploiement comptant plus de mots qu’un appel de palier 14 n’en peut contenir est invalide (ProgramUncallable ; 8,180 mots au plus), et l’image en compte 65,096. Côté entrée, cela tiendrait : l’ELF est l’entrée publique du déploiement, que rand call récupère lui-même, et les 10,458 mots privés tiennent dans le plafond d’entrée de 16,071, que la chaîne 20 conserve. Mais chaque vecteur SPL est de palier 20, et depuis fullnode v0.5.6 (2026-09-25) un nœud refuse toute preuve d’appel au-dessus du palier 14 (CallTierTooHigh), avant de construire une clé de vérification. La même vérification plafonne les tables de hachage qu’un appel peut déclarer : 2¹³ lignes SHA-256 (128 compressions) et 2¹² lignes Keccak (128 permutations). Un appel de palier 20 ne peut être prouvé que hors chaîne, et aucune preuve de palier 20 n’existe encore (point suivant).
  • Aucune preuve n’a encore été produite au palier 20. Une preuve de palier 20 a été interrompue sur un portable de 48 Go à environ 31 Go, puis tuée par manque de mémoire (OOM) sur un droplet de 64 Go à 65.1 Go après 10 m 41 s. Extrapolé à partir de la mise à l’échelle mesurée aux paliers 16/18 (mémoire environ ×3.9, temps environ ×4.1 par tranche de +2 paliers), le palier 20 demande environ 330 Go et environ 3.6 h, soit plus que le plus gros droplet à mémoire de DigitalOcean (m-32vcpu-256gb, 256 Go). Le prover s’exécutait sur un seul thread : une machine de 16 vCPU restait à 99 % d’un cœur. Voir mémoire de preuve.
  • Pas de CPI. Les invocations inter-programmes provoquent un trap.
  • sBPF v1 uniquement. Les opcodes propres à la v2 provoquent un trap.
  • Les appels système Ed25519 et secp256k1 provoquent un trap tant qu’ils n’ont pas de tables de coprocesseur.
  • La chaîne n’enregistre l’ELF que comme entrée publique. Un déploiement avec --public stocke les mots de l’ELF et leur condensat, et rand_getProgramPublic les sert. La chaîne n’enregistre pas la manière dont l’image en a été construite : publiez les entrées de construction afin que d’autres puissent reconstruire hc.

Dernière vérification par rapport au commit c0878c3f de fullnode (chaîne 21) le 2026-10-12, et 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. Lorsque le code et cette page divergent, c'est le code qui a raison ; signalez-le-nous.