Rand Protocol
Documentation
Concepts fondamentaux

Notes et arbre d'engagements

Le modèle de notes sur lequel repose chaque transfert - engagements, nullifiers, enveloppes, l'arbre de profondeur 32, ancres et fenêtres d'ancre et de temps - et la façon dont un portefeuille analyse la chaîne pour retrouver ses propres notes.

Utilisez cette page pour construire ou auditer tout ce qui lit le pool : un wallet qui analyse la chaîne à la recherche de ses notes, un indexeur qui suit les engagements et les nullifiers, ou un outil qui vérifie ce qu’une transaction publie. La valeur sur cette chaîne est un ensemble de notes ; la chaîne en détient les engagements, jamais le contenu.

Une note

Une note est { pk, from, amount, asset, time, r } : 28 mots de 32 bits, soit 112 octets, disposés comme pk(8) from(8) amount_lo amount_hi asset time r(8). pk est la valeur publique de note du propriétaire (dérivée de la clé de visualisation), from celle de l’expéditeur, amount un u64 en unités (1 RAND = 10⁹ unités), asset un u32 (0 pour RAND, sinon l’indice dense du registre de jetons pour un jeton RPL, ponté ou natif ; voir jetons RPL), time une hauteur de bloc visée par l’expéditeur, et r huit mots aléatoires d’aveuglement.

Le texte en clair n’apparaît jamais sur la chaîne. Ce qui apparaît, c’est :

  • son engagement cm = H(CM, words), un hachage Poseidon2 des 28 mots sous l’étiquette de domaine 4, ajouté comme feuille de l’arbre ;
  • son enveloppe, le texte en clair scellé à l’adresse du propriétaire, de sorte que seul le propriétaire, ou l’expéditeur au moyen de sa clé de visualisation sortante, puisse le lire. Depuis la chaîne 18 (chaîne 20 incluse), l’enveloppe porte aussi un mémo chiffré. Quelques notes que la chaîne calcule à partir de champs publics ne portent aucune enveloppe (ci-dessous).

Dépenser la note publie son nullifier H(NF, nk, cm), un hachage Poseidon2 sous l’étiquette de domaine 3 de la clé de visualisation du propriétaire et de l’engagement. Sans nk, un nullifier ne peut pas être relié à son engagement. L’ensemble des nullifiers est ce qui empêche la double dépense.

L’enveloppe

Envelope { kem_ct, to_receiver, to_sender, body }
partieoctetsce que c’est
kem_ct1,088une encapsulation ML-KEM-768 vers l’adresse du destinataire
to_receiver60la clé par transaction TxKey enveloppée sous le secret encapsulé
to_sender60la même TxKey enveloppée sous la clé de visualisation sortante de l’expéditeur ovk
body652la note de 112 octets et un champ de mémo de 512 octets sous TxKey (140 octets, la note seule, jusqu’à la chaîne 17)

Chaque partie est chiffrée en ChaCha20-Poly1305 avec un nonce aléatoire de 12 octets en préfixe et une balise de 16 octets, et les données associées sont une étiquette (rand-envelope-receiver, rand-envelope-sender, rand-envelope-body) suivie de l’engagement de la note, de sorte qu’une clé ne peut pas ouvrir une enveloppe qu’elle n’a pas scellée et qu’une note ouverte est liée à la feuille sous laquelle elle se trouve. Cela représente 1,860 octets de texte chiffré ; les préfixes de longueur de bincode les portent à 1,892 sur le réseau. Le genesis de la chaîne 20 fixe envelope_bytes: 1860, et son registre refuse toute enveloppe de note d’une autre longueur (envelope is 1348 bytes, this chain requires exactly 1860) ; il ne vérifie rien d’autre à son sujet. Jusqu’à la chaîne 17, l’enveloppe n’avait pas de champ de mémo et faisait 1,348 octets (1,380 sur le réseau), et le registre se contentait de la plafonner à 2,048 octets, ce qui reste la règle sur une chaîne dont le genesis n’a pas envelope_bytes.

Le champ de mémo est len (u16 LE) ‖ UTF-8 text ‖ zero padding, soit 512 octets, donc au plus 510 octets de texte. Chaque enveloppe de note sur la chaîne 20 le comporte, qu’il y ait ou non un mémo : une sortie de monnaie rendue, une sortie factice et un paiement sans mémo sont remplis de la même façon, et rien sur la chaîne ne distingue un mémo, sa longueur ou son absence. Le mémo ne fait partie ni de la note, ni de l’engagement, ni d’aucune preuve. Il est lisible par exactement ceux qui peuvent déjà lire la note : le propriétaire, l’expéditeur au moyen de ovk, ou quiconque a reçu la TxKey de cette sortie. Un champ de mémo malformé (longueur invalide, UTF-8 invalide, remplissage non nul) s’ouvre comme une absence de mémo et ne fait jamais perdre la note à son propriétaire. Un mémo est le texte de n’importe qui, puisque n’importe qui peut envoyer une note de poussière à une adresse publique : affichez-le comme non fiable, en remplaçant les caractères de contrôle et de format Unicode, comme le fait le wallet rand. rand send --memo en scelle un dans la sortie de paiement ; rand notes --memo et rand history --memo l’affichent.

L’arbre, les ancres et les fenêtres

Les engagements sont ajoutés à un unique arbre Poseidon2 de profondeur 32, du plus ancien au plus récent, et ne sont jamais retirés. La feuille i est la i-ième note que la chaîne a jamais créée ; les notes de dépôt du genesis sont les premières feuilles. Un bundle ajoute quatre feuilles et quatre nullifiers quoi qu’il déplace, factices comprises. La feuille vide est entièrement nulle et un nœud est H(NODE, left, right) (étiquette de domaine 7). Un nœud ne conserve que la frontière, de sorte que l’état de consensus ne contient jamais les feuilles ; rand_getWitness reconstruit l’arbre complet à partir des feuilles stockées à la demande, ce qui en fait la lecture la plus coûteuse qu’un nœud serve.

Un bundle prouve l’appartenance de ses entrées à un ancrage, une racine de l’arbre. Deux règles garantissent l’honnêteté d’un ancrage :

constantevaleurrègle
ANCHOR_WINDOW256 blocs ; 1,024 sur la chaîne 20l’ancrage doit être l’une des window dernières racines de fin de bloc ; une racine que l’arbre traverse en milieu de bloc n’est jamais un ancrage
TIME_WINDOW256 blocs ; 1,024 sur la chaîne 20le time du bundle doit être dans [height − window, height]

Les deux fenêtres font 256 blocs, sauf si le genesis définit proof_window_blocks (v0.6.8, de 256 à 4,096), qui remplace les deux ensemble. La chaîne 20 fixe 1,024 ; les chaînes 14 à 19 n’avaient pas ce champ.

Les fenêtres existent parce que la preuve prend un temps réel : une preuve de bundle de palier 14 dure environ 100 secondes sur un ordinateur portable (et, depuis la chaîne 17, le wallet produit une seconde preuve d’authentification de palier 10, environ 7 secondes). 256 blocs représentaient environ cinq minutes avec les blocs de 1,17 s de la chaîne 18, et une preuve déléguée sur un prover lent a été refusée à 294 s ; les 1,024 blocs de la chaîne 20 représentent environ 23 minutes aux 1,34 s relevées par l’explorateur. Un wallet qui perd la course reçoit anchor is not one of the last 1024 roots et prouve à nouveau. rand_getAnchor sans paramètre renvoie la racine de fin de bloc de la tête de chaîne, le seul ancrage sur lequel un prover doit construire.

Le bundle

Toute transaction qui déplace de la valeur porte exactement un bundle à actifs cachés fixe 4 entrées, 4 sorties (depuis la chaîne 14 ; il était de 2 entrées, 2 sorties jusqu’à la chaîne 13), prouvé par le guest zkVM à actifs cachés dont l’empreinte hc_bundle est fixée dans le fichier genesis (60af094acfe65d85fdb18fb3d06cf9085dcf28c96e59e87f1ee527226e6e3fce, guest de bundle v3, sur les chaînes 17 à 20 ; c’était 83d3a3704a1fcdb9bae7136c0a947ffa34bd53f055388395ed705fe8cacd0ef8, guest v1, sur les chaînes 14 et 15) :

Bundle { anchor, nullifiers[4], commitments[4], fee, burn_a, burn_r, burn_asset, time, envelopes[4], proof,
         auth_commit, auth_proof }     // the last two since chain 17

Les emplacements 0 à 1, en entrée et en sortie, portent un actif privé A ; les emplacements 2 à 3 portent toujours du RAND. Dans un paiement en RAND seul, les emplacements 0 à 1 sont factices, et la valeur comme les frais transitent par les emplacements 2 à 3. Dans un transfert de jeton, le jeton transite par les emplacements 0 à 1 et les frais en RAND sont payés depuis les emplacements 2 à 3 de la même preuve. A est un mot témoin de la preuve et ne figure dans aucun champ public, de sorte qu’un transfert de n’importe quel jeton RPL est Action::None avec burn_a = burn_r = burn_asset = 0, champ pour champ de même forme qu’un paiement en RAND.

La preuve atteste que chaque entrée réelle est une feuille sous anchor et porte l’actif de son emplacement, que les quatre nullifiers sont ceux qui sont publiés, que chaque groupe est équilibré (in0 + in1 = out0 + out1 + burn_a dans l’actif privé, in2 + in3 = out2 + out3 + fee + burn_r en RAND), et que le dépensier détient les clés, sans révéler quelles feuilles, quels montants, quel actif, ni qui. Les trois champs de destruction sont la seule déclaration publique du bundle sur la valeur qui quitte le pool : burn_a et burn_asset ne sont non nuls que sur un TokenBurn ou un BridgeBurn, où ils égalent le montant et l’actif de l’action ; burn_r est le RAND détruit, non nul seulement sur un Bond (la mise) et un RegisterAggregator (la caution). Les frais sont toujours en RAND.

Depuis la chaîne 17, l’autorité de dépenser est répartie sur deux preuves (autorisation scindée). Le guest de bundle v3 prend la clé de visualisation nk et un sel là où les guests précédents prenaient la clé de dépense : il dérive pk_self = H(PK, nk), exige que chaque entrée lui appartienne, et intègre c = H(AUTH, nk ‖ salt) dans l’empreinte qu’il publie. Un second guest, le guest d’authentification (genesis hc_auth, 1e4e347f44cf86750b30a9a4bdf9ec9256efe353d4ff8017451eca7d195639c1), prend la clé de dépense sk et le même sel, dérive nk = H(NK, sk) et publie le même c. Le bundle porte c sous le nom auth_commit et la preuve de palier 10 du guest d’authentification sous le nom auth_proof, et le registre exige que les deux preuves se vérifient contre la même liaison de transaction et que les deux valeurs de c concordent. Un prover qui détient nk et le sel peut donc produire la preuve du bundle mais pas la preuve d’authentification, ce sur quoi repose la preuve déléguée. Le sel est composé de 256 bits aléatoires neufs par bundle, de sorte que auth_commit est public et ne relie rien. Un wallet qui prouve pour lui-même produit lui aussi les deux preuves, si bien qu’une transaction auto-prouvée et une transaction déléguée se ressemblent sur la chaîne.

Les emplacements ont une forme fixe, ils sont donc souvent factices : une entrée factice est une note de valeur nulle appartenant à l’émetteur de la dépense (son nullifier est tout de même publié), une sortie factice est une note de montant nul, et la sortie de monnaie rendue est présente même lorsque la monnaie rendue est nulle. Une transaction d’aspect différent en l’absence de monnaie rendue révélerait qu’il n’y en avait pas. Conséquence pour les portefeuilles : chacun des deux groupes d’un bundle dépense au plus deux notes. Un paiement en RAND, une obligation, un déploiement ou un appel dépense au plus deux notes RAND ; un transfert ou une destruction de jeton dépense au plus deux notes du jeton et au plus deux notes RAND pour les frais. Une valeur répartie sur trois notes ou plus dans un même groupe doit d’abord être consolidée, par un envoi à sa propre adresse.

Depuis la chaîne 14, la preuve est aussi liée à sa transaction : elle est établie sur un hachage de la transaction entière dont les preuves ont été mises à blanc, et vérifiée par rapport à lui ; elle ne peut donc pas être copiée sur une autre action, d’autres enveloppes ou une autre chaîne ; sur la chaîne 20, le hachage couvre aussi le hachage de genèse, de sorte qu’elle ne peut pas non plus l’être sur une chaîne qui partage le même identifiant de chaîne (Transactions).

Ce qui est public et ce qui est caché

public sur la chaînecaché
un transfert, de RAND ou de n’importe quel jeton RPLanchor, les quatre nullifiers, les quatre engagements, les frais, burn_a = burn_r = burn_asset = 0, time, quatre chiffrés d’enveloppe, la preuve, auth_commit et la preuve d’autorisationqui l’a envoyé, qui est payé, le montant, le mémo, quel actif a circulé, la monnaie rendue, quelles feuilles ont été dépensées, quels emplacements étaient factices
une destruction de jeton ou une destruction de ponttout ce qu’un transfert publie, avec burn_asset et burn_a égaux à l’actif et au montant de l’action (et, pour une destruction de pont, les frais du relayeur, le jeton de contrepartie et la destination)quelles notes ont été détruites, et par qui
un mint du faucetl’engagement, l’enveloppe, le montant, le validateur qui a émis, et depuis la chaîne 14 l’ouverture de la note (pk, time, r), à partir de laquelle l’admission recalcule l’engagementchaque dépense ultérieure de la note
un dépôt de genèse{ cm, envelope, amount, opening: { pk, time, r } } dans le fichier de genèse, avec asset dans l’ouverture pour une note d’un jeton listé (la genèse de la chaîne 20 comporte deux notes zUSD) : le pk du propriétaire, le montant et l’actif sont publics par conceptionquand et en quoi elle est dépensée par la suite
un retrait, un dépôt de pont, un mint de jeton ou le mint initial d’un enregistrementle montant, le destinataire, le time de la note et son blinding r (la chaîne calcule elle-même l’engagement)chaque dépense ultérieure de la note
le versement d’un invoke ou le mint d’un programme (chaîne 20)le montant, l’actif, le destinataire et r ; le time de la note est celui du bundlechaque dépense ultérieure de la note
une note de frais de pont (chaîne 20)le montant, l’actif et time, un blinding dérivé, la trésorerie de genèse comme propriétaire, et aucune enveloppechaque dépense ultérieure de la note

La valeur entre dans le pool avec un montant public — un mint du faucet, un dépôt de genèse, le retrait d’un validateur, une attestation de pont et sa note de frais, un mint de jeton, le versement d’un coffre de programme — et en sort sous forme de frais ou de destruction publics. Tout ce qui se passe entre les deux est une note.

Ce tableau décrit ce que les preuves de la chaîne 20 cachent, et cela n’a pas toujours été vrai. Sur les chaînes 14 et 15, la preuve du bundle elle-même en divulguait davantage (la revue zkVM du 2026-09-27, INT-2 / GV-1) : le total courant LogUp de chaque table était publié sans blinding, et le guest v1 sautait les vérifications de Merkle et d’actif pour une entrée factice et se ramifiait sur chaque bit du chemin de Merkle ; il suffisait donc de lire une preuve de bundle pour savoir lesquels des quatre emplacements d’entrée étaient réels, et donc si elle déplaçait un jeton ou seulement du RAND, ainsi que le nombre de bits à 1 dans l’indice de chaque feuille dépensée. Deux changements ont comblé cette fuite. La chaîne 16 est passée à l’ensemble de contraintes 7, qui masque chaque terminal LogUp et impose à chaque table déclarée un plancher de 2^7 lignes, et au guest de bundle v2 sans branchement, dans lequel chaque emplacement, factice ou réel, exécute la vérification complète d’appartenance et d’actif par les mêmes instructions. Le guest v3, qu’exécutent les chaînes 17 à 20, reprend la relation de v2 avec nk et le sel à la place de la clé de dépense, et est lui aussi sans branchement. Les bundles validés sur les chaînes 14 et 15 conservent ce qu’ils ont divulgué. Ce qu’une preuve montre encore sur la chaîne 20, c’est son en-tête : pour un bundle, une forme unique et fixe (palier 14, une limite de gaz déclarée de 20 479 sur chaque bundle), et pour un Call ou un Invoke, le palier, la limite de gaz déclarée et le fait que l’appel ait utilisé ou non le précompilé keccak ou sha256, ainsi que sa fréquence approximative (Preuves).

Notes sans enveloppe

La plupart des notes se retrouvent en ouvrant leur enveloppe. Certaines sont calculées par la chaîne à partir de champs publics, et pour celles-là l’enveloppe est soit absente, soit à ne pas croire.

  • Notes de frais de pont (chaîne 20). Sous bridge.fees, la chaîne ajoute une note zUSD à la trésorerie de genèse à chaque dépôt et à chaque destruction (jetons RPL). Elle n’a pas d’enveloppe : le stockage l’indexe avec une enveloppe vide, scellée pour personne, et rand_getCompactBlocks et rand_getCommitments la présentent ainsi, celle d’un dépôt après la note de dépôt et celle d’une destruction après les quatre notes du bundle. Chaque mot de la note est public : le montant, issu de la transaction et du taux de genèse, l’actif et time, issus de la transaction, from = 0, le blinding blake3("rand-bridge-fee-r-1" ‖ mu) pour un dépôt ou blake3("rand-bridge-burn-fee-r-1" ‖ txid) pour une destruction, et pour propriétaire le recipient de la genèse.
  • Notes dont l’enveloppe ne lie rien. Un dépôt de pont, un mint de jeton, le mint initial d’un enregistrement et, sur la chaîne 20, les versements d’un invoke et les mints de programme portent une enveloppe scellée par celui qui a soumis la transaction, mais la chaîne calcule la note à partir des champs publics de l’action et ne vérifie jamais l’enveloppe par rapport à elle. Un soumissionnaire négligent ou hostile peut sceller du contenu absurde ; un destinataire ne doit donc pas s’y fier.

Le portefeuille rand reconstruit donc lui-même ces notes (wallet::rebuilt_notes_with), dans une passe publique parallèle au déchiffrement d’essai : pour chaque transaction de ce type qui désigne son propre pk, et pour les notes de frais lorsque son adresse est celle de la trésorerie, il calcule la note et l’enregistre à la feuille dont elle produit l’engagement par hachage. Une note reconstruite n’est qu’une candidate : un nœud qui ment sur une transaction, sur le taux de frais ou sur un hachage fait qu’une note reste introuvable, jamais qu’une note soit créditée. Depuis la v0.6.8, les en-têtes que renvoie rand_getBlocks portent dans public_notes les transactions de dépôt, de mint et d’enregistrement, et sur une chaîne à frais de pont les destructions aussi, preuves retirées, sous leur véritable identifiant ; la passe ne lit donc que des pages d’en-têtes. Les invokes ne figurent pas dans public_notes en v0.6.8 : face à un nœud v0.6.8, le portefeuille ne trouve donc le versement d’un invoke que par son enveloppe, que l’invocateur scelle et que rien ne vérifie. Le main du fullnode y porte aussi les invokes, preuves retirées, depuis d0bb73fb ; c’est un changement limité au nœud, pas encore déployé sur la flotte de la chaîne 20, et le portefeuille n’a besoin d’aucune modification pour en profiter.

Balayage : comment un portefeuille retrouve ses notes

Un nœud répond « voici l’arbre entier », jamais « voici votre solde ». Un portefeuille :

  1. parcourt les feuilles par pages avec rand_getCommitments(from_index, limit) (au plus 1 000 par appel) ou le flux par bloc rand_getCompactBlocks(from_height, to_height) (au plus 128 blocs par appel) ; chaque transaction porteuse d’un bundle y liste quatre notes et quatre nullifiers, et aucun lecteur ne peut distinguer un emplacement factice d’un emplacement réel ;
  2. tente de déchiffrer chaque enveloppe : en tant que destinataire par sa clé de décapsulation ML-KEM, en tant qu’émetteur par ovk ; conserve les notes dont le pk est le sien ; et reconstruit les notes à montant public à partir de leurs transactions (ci-dessus) ;
  3. calcule le nullifier de chaque note conservée et marque celles qui figurent dans rand_getNullifiers(from_height, limit) ;
  4. additionne le reste.

rand_getTreeInfo indique l’étendue à balayer : { "next_index", "root", "nullifiers" }. Pour une dépense, le portefeuille a besoin du chemin de Merkle à 32 niveaux de chaque entrée réelle. Le portefeuille rand garde sa propre copie de l’arbre des engagements, construite pendant le balayage à partir des mêmes pages rand_getCommitments, et calcule lui-même chaque chemin ; la seule question sur l’arbre qu’un envoi pose au nœud est donc rand_getAnchor, qui ne désigne aucune feuille. rand_getWitness(index) reste sur le nœud pour les autres clients. Elle indique au nœud quelle feuille l’appelant s’apprête à dépenser, ce qui était la plus grande fuite du pool avant l’arbre local : un client contraint de l’utiliser devrait exécuter son propre nœud, ou demander des témoins dont il n’a pas besoin en plus de ceux dont il a besoin.

rand sync        # scanned 41 leaves and 37 blocks; 1 notes, 1 unspent
rand notes       # index, asset, amount, height, spent, pending, memo
rand history     # notes this wallet created for others, opened through ovk

Une note est pending entre rand send --no-wait et le prochain sync qui voit soit son nullifier, soit des blocs au-delà de time + window (1 024 sur la chain 20, lu depuis rand_getLimits.proof_window_blocks ; 256 sans cette valeur). Passé ce seuil, le bundle ne peut plus jamais être admis et la note redevient dépensable.

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