Rand Protocol
Documentation
Programmes d'exemple

Un registre hors chaîne

Un programme confidentiel qui conserve un état là où les programmes n'en ont pas : un arbre de Merkle de 256 comptes qui vit hors chaîne, chaque lot de transferts étant prouvé comme une transition d'une racine à la suivante et chaîné par des reçus. L'interface, le guest Rust, l'outil hôte, la manière dont le nombre de cycles a été divisé par deux, et le programme en production sur la chaîne 18.

Un programme sur Rand n’a pas de stockage : un appel lit ses entrées, publie huit mots et laisse un reçu (Programmes). Cette page présente la méthode standard pour conserver malgré tout un état. L’état vit hors chaîne sous la forme d’un arbre de Merkle, le programme prouve chaque modification comme un pas d’une racine à la suivante, et les reçus enchaînent les pas.

L’exemple est un registre de 256 comptes. Un appel applique jusqu’à quatre transferts et prouve que chaque émetteur possédait le compte, disposait des fonds, et que rien n’a été créé ni perdu. La chaîne voit un statut, un compteur et un hachage.

Programmes d’exemple présente la mise en place commune aux quatre pages d’exemple.

1. Ce qui est prouvé

Le registre est un arbre de Merkle de profondeur 8 sur Poseidon2, le hachage que la machine prouve au moindre coût. Une feuille est un compte :

owner = first 4 words of Poseidon2("OWNR", secret)
leaf  = Poseidon2("LEAF", owner, balance low, balance high, nonce)
node  = Poseidon2(left, right, "NODE")

Le secret est composé de quatre mots que le titulaire du compte garde par-devers lui.

Un appel prend en entrée privée la racine courante et un lot de transferts. Pour chaque transfert, il prouve que :

  1. la feuille de l’émetteur appartient à l’arbre, grâce à son chemin de Merkle ;
  2. l’appelant connaît le secret derrière l’identifiant de propriétaire de l’émetteur ;
  3. le montant est au plus égal au solde de l’émetteur ;
  4. l’arbre obtenu après le débit contient la feuille du destinataire ;
  5. le crédit ne provoque pas de dépassement de capacité.

Il publie ensuite un lien : Poseidon2(old root, new root, count, "LINK"), tronqué à 160 bits.

Pourquoi un lien et non les deux racines. Une racine fait huit mots et il y a huit emplacements de sortie. Publier les deux racines imposerait de tronquer chacune à environ 96 bits, et une valeur de 96 bits peut être mise en collision avec environ 2⁴⁸ opérations, ce qui permettrait à un prouveur de présenter deux registres différents sous un même reçu. Un seul hachage de 160 bits des deux racines n’a pas ce défaut. Les racines complètes circulent hors chaîne avec les données du registre, et quiconque les détient recalcule le lien.

Suivre le registre. Un suiveur détient l’état courant. Lorsqu’un reçu du programme apparaît avec le statut 1, le suiveur obtient les nouvelles feuilles auprès de l’auteur de l’appel, reconstruit la racine et calcule le lien à partir de sa propre ancienne racine, de la nouvelle racine et du compteur du reçu. S’il est égal aux sorties du reçu, le pas est valide et le suiveur adopte le nouvel état. Un reçu qui ne se rattache pas à la racine courante du suiveur est ignoré. C’est aussi ce qui tranche une course : deux appelants qui construisent sur la même racine obtiennent chacun un reçu, et celui que la chaîne ordonne en premier est celui que tous les suiveurs acceptent.

Qui peut faire un appel. Quiconque connaît le secret d’un émetteur et l’arbre courant. La règle ne prévoit aucun opérateur : un titulaire peut prouver son propre transfert sur sa propre machine, de sorte que le secret ne la quitte jamais.

2. L’interface

Il n’y a pas de segment public. L’identité du registre est sa racine de genèse, convenue hors chaîne.

mot privésignification
0le nombre de transferts, au plus 4
1 à 8l’ancienne racine
puis, par transfert, 146 mots
+0, +1l’indice de la feuille de l’émetteur et celui du destinataire, chacun inférieur à 256
+2, +3le montant, un u64, mot de poids faible puis mot de poids fort
+4 à +7le secret de l’émetteur
+8 à +10le solde de l’émetteur (faible, fort) et son nonce
+11 à +74le chemin de Merkle de l’émetteur : 8 nœuds frères de 8 mots, de la feuille vers le haut
+75 à +78l’identifiant de propriétaire du destinataire
+79 à +81le solde du destinataire (faible, fort) et son nonce
+82 à +145le chemin de Merkle du destinataire, dans l’arbre que laisse le débit
emplacement de sortiesignification
0statut : 1 appliqué, 2 feuille de l’émetteur absente de l’arbre (ou secret erroné), 3 feuille du destinataire absente de l’arbre, 4 solde insuffisant, 5 le crédit provoque un dépassement, 6 l’émetteur est le destinataire, 7 plus de 4 transferts, 8 indice hors limites
1transferts appliqués : le compteur en cas de succès, sinon l’indice du transfert en échec
2 à 6en cas de succès, les cinq premiers mots du lien
7inutilisé ; une panique Rust y écrit 0xdeadbeef

Les transferts s’appliquent dans l’ordre, chacun sur l’arbre que le précédent a laissé. Dans le lot de démonstration, le compte 3 part de zéro, reçoit 125 lors du premier transfert et en dépense 25 lors du troisième.

3. Le programme invité

my-guests/ledger/src/main.rs, en entier :

#![no_std]
#![no_main]

use guest_sdk::{halt, poseidon2, read_input, write_output};

/// The ledger is a Merkle tree of 2^8 = 256 accounts.
const DEPTH: usize = 8;
const MAX_TRANSFERS: u32 = 4;
/// Private words per transfer: from, to, amount (2), secret (4), sender balance (2) and nonce,
/// sender path (64), receiver owner (4), balance (2) and nonce, receiver path (64).
const TRANSFER_WORDS: u32 = 146;

const OK: u32 = 1;
const BAD_SENDER: u32 = 2;
const BAD_RECEIVER: u32 = 3;
const INSUFFICIENT: u32 = 4;
const OVERFLOW: u32 = 5;
const SELF_TRANSFER: u32 = 6;
const TOO_MANY: u32 = 7;
const BAD_INDEX: u32 = 8;

const OWNER_TAG: u32 = 0x4f57_4e52; // "OWNR"
const LEAF_TAG: u32 = 0x4c45_4146; // "LEAF"
const NODE_TAG: u32 = 0x4e4f_4445; // "NODE"
const LINK_TAG: u32 = 0x4c49_4e4b; // "LINK"

type Digest = [u32; 8];

/// Poseidon2 over `buf[..n]`. The sponge does not pad, so every message this program hashes
/// has a fixed length and carries its own tag word.
fn hash(buf: &mut [u32], n: usize) -> Digest {
    assert!(n <= buf.len() && buf.len() >= 8);
    // SAFETY: the syscall reads `buf[..n]` and overwrites `buf[..8]`; the assert keeps both
    // inside `buf`, which `&mut` makes aligned, valid and unaliased for the call.
    unsafe { poseidon2(buf.as_mut_ptr(), n) };
    let mut d = [0u32; 8];
    d.copy_from_slice(&buf[..8]);
    d
}

/// An account's owner id: the first four words of Poseidon2("OWNR", secret).
fn owner_of(secret: &[u32; 4]) -> [u32; 4] {
    let mut buf = [OWNER_TAG, secret[0], secret[1], secret[2], secret[3], 0, 0, 0];
    let d = hash(&mut buf, 5);
    [d[0], d[1], d[2], d[3]]
}

fn leaf(owner: &[u32; 4], balance: u64, nonce: u32) -> Digest {
    let mut buf = [
        LEAF_TAG, owner[0], owner[1], owner[2], owner[3], balance as u32, (balance >> 32) as u32, nonce,
    ];
    hash(&mut buf, 8)
}

/// One level of a walk. `buf` holds a node's message, `left ‖ right ‖ "NODE"`, and arrives with
/// the running hash in words 0..8, where the last hash left its digest. `side` is 0 when the
/// running hash is the left child and 8 when it is the right one: it is moved to its half and
/// the sibling written to the other. The halves are picked by an offset, not a branch, so both
/// cases execute the same instructions (for a left child the move is a copy onto itself).
fn level(buf: &mut [u32; 17], sib: &Digest, side: usize) {
    let mut cur = [0u32; 8];
    cur.copy_from_slice(&buf[..8]);
    buf[side..side + 8].copy_from_slice(&cur);
    buf[8 - side..16 - side].copy_from_slice(sib);
    // SAFETY: the syscall reads the 17 words of `buf` and overwrites its first 8.
    unsafe { poseidon2(buf.as_mut_ptr(), 17) };
}

/// Walks two leaves up the same path at once and returns the root each one reaches. The path
/// is the eight siblings whose words start at private input `at`; bit `d` of `index` says
/// whether the running hash is the right child at level `d`. An update needs both walks (the
/// leaf before and the leaf after), and doing them together reads each sibling once.
fn roots_from(before: Digest, after: Digest, index: u32, at: u32) -> (Digest, Digest) {
    let (mut a, mut b) = ([0u32; 17], [0u32; 17]);
    a[..8].copy_from_slice(&before);
    b[..8].copy_from_slice(&after);
    a[16] = NODE_TAG;
    b[16] = NODE_TAG;
    for d in 0..DEPTH {
        let side = 8 * ((index >> d) & 1) as usize;
        let sib: Digest = core::array::from_fn(|k| read_input(at + (8 * d + k) as u32));
        level(&mut a, &sib, side);
        level(&mut b, &sib, side);
    }
    let (mut ra, mut rb) = ([0u32; 8], [0u32; 8]);
    ra.copy_from_slice(&a[..8]);
    rb.copy_from_slice(&b[..8]);
    (ra, rb)
}

fn read_u64(at: u32) -> u64 {
    read_input(at) as u64 | (read_input(at + 1) as u64) << 32
}

/// Applies the transfer whose words start at `at` to the tree with root `root`.
fn apply(root: Digest, at: u32) -> Result<Digest, u32> {
    let from = read_input(at);
    let to = read_input(at + 1);
    let amount = read_u64(at + 2);
    let secret = [read_input(at + 4), read_input(at + 5), read_input(at + 6), read_input(at + 7)];
    let sender_balance = read_u64(at + 8);
    let sender_nonce = read_input(at + 10);
    let receiver = [read_input(at + 75), read_input(at + 76), read_input(at + 77), read_input(at + 78)];
    let receiver_balance = read_u64(at + 79);
    let receiver_nonce = read_input(at + 81);

    if from >= 1 << DEPTH || to >= 1 << DEPTH {
        return Err(BAD_INDEX);
    }
    if from == to {
        return Err(SELF_TRANSFER);
    }

    // The sender's leaf is in the tree, and the caller knows the secret behind its owner id.
    // The second walk carries the debited leaf up the same path: the root after the debit.
    let sender = owner_of(&secret);
    let (seen, debited) = roots_from(
        leaf(&sender, sender_balance, sender_nonce),
        leaf(&sender, sender_balance.wrapping_sub(amount), sender_nonce.wrapping_add(1)),
        from,
        at + 11,
    );
    if seen != root {
        return Err(BAD_SENDER);
    }
    if amount > sender_balance {
        return Err(INSUFFICIENT);
    }

    // The receiver's leaf is in the tree the debit left behind; the second walk credits it.
    let (seen, credited) = roots_from(
        leaf(&receiver, receiver_balance, receiver_nonce),
        leaf(&receiver, receiver_balance.wrapping_add(amount), receiver_nonce),
        to,
        at + 82,
    );
    if seen != debited {
        return Err(BAD_RECEIVER);
    }
    if receiver_balance.checked_add(amount).is_none() {
        return Err(OVERFLOW);
    }
    Ok(credited)
}

/// Private input: [count, old root (8 words), then `count` transfers of 146 words each].
/// Outputs: [status, transfers applied, link (first 5 words), 0], where
/// link = Poseidon2(old root, new root, count, "LINK").
#[no_mangle]
pub extern "C" fn main() -> ! {
    let count = read_input(0);
    if count > MAX_TRANSFERS {
        write_output(0, TOO_MANY);
        halt();
    }
    let mut old = [0u32; 8];
    for (k, w) in old.iter_mut().enumerate() {
        *w = read_input(1 + k as u32);
    }

    let mut root = old;
    let mut done = 0;
    while done < count {
        match apply(root, 9 + TRANSFER_WORDS * done) {
            Ok(next) => root = next,
            Err(status) => {
                write_output(0, status);
                write_output(1, done);
                halt();
            }
        }
        done += 1;
    }

    let mut buf = [0u32; 18];
    buf[..8].copy_from_slice(&old);
    buf[8..16].copy_from_slice(&root);
    buf[16] = count;
    buf[17] = LINK_TAG;
    let link = hash(&mut buf, 18);

    write_output(0, OK);
    write_output(1, count);
    for k in 0..5 {
        write_output(2 + k as u32, link[k]);
    }
    halt();
}

L’ancienne racine est une entrée privée, et c’est sûr. Rien n’oblige l’appelant à partir de la vraie racine. Un appel qui part d’une racine inventée prouve un pas correct à partir d’un registre que personne d’autre ne détient, et son lien ne correspond à l’état d’aucun suiveur. Le lien est la liaison : c’est un engagement public sur les deux racines, que les suiveurs comparent au leur.

La conservation est acquise par construction. Le montant débité et le montant crédité sont la même variable. Il n’y a pas de somme distincte à vérifier.

Le débit et le crédit utilisent volontairement l’arithmétique wrapping_. Le programme construit la feuille « après » avant de savoir que la feuille « avant » est authentique, car les deux parcourent le chemin ensemble. Si le montant dépasse le solde, la feuille avec dépassement est calculée puis écartée lorsque la vérification, deux lignes plus loin, renvoie le statut 4.

Le nonce est incrémenté à chaque débit, de sorte que la feuille d’un émetteur change même lorsqu’un solde revient à une valeur antérieure. Les suiveurs peuvent voir combien de fois un compte a dépensé.

4. Deux constats sur la chaîne d’outils rencontrés par ce programme invité

rand-guest check refuse le Poseidon2 lié à la longueur. Le SDK propose deux appels Poseidon2. poseidon2 (appel système 3) n’applique pas de remplissage : un message de quatre mots au plus a le même hachage que ce message complété de mots nuls. poseidon2_len (appel système 7) injecte la longueur dans l’éponge et constitue le choix par défaut le plus sûr pour des données de longueur variable. La machine implémente les deux, mais avec les circuits aeacf31, la liste des appels système du vérificateur s’arrête à 6 :

$ rand-guest build my-guests/ledger --max-words 65535
…
0x00001114  0x00000073  Syscall: ecall with a7 = 7, which is no syscall the machine implements
…
Error: the image is rejected by the checker (it was still written)

Ce programme invité utilise donc poseidon2 et suit la règle qui rend l’éponge sans remplissage sûre : chaque message a une longueur fixe unique, et chaque type de message porte son propre mot d’étiquette.

Mettre un tampon à zéro dans une boucle est la ligne la plus coûteuse que l’on ne remarque pas. La première version du parcours de Merkle était la plus évidente : une fonction qui construisait un [0u32; 17] neuf pour chaque nœud, y copiait les deux enfants, hachait et renvoyait le condensat. Quatre transferts ont coûté 26 124 cycles et ne tenaient pas dans le palier 14, le plus haut palier qu’un appel puisse utiliser. Trois changements, dont aucun ne touche à ce qui est prouvé :

changementcycles pour quatre transferts
un tampon neuf mis à zéro et un tableau de chemin par nœud26 124
un tampon par parcours, mis à zéro une seule fois ; pas de tableau de chemin rempli de zéros15 897
les parcours « avant » et « après » menés ensemble, chaque nœud frère lu une seule fois15 445
le hachage courant conservé dans le tampon de hachage, là où l’appel système le laisse12 393

[0u32; 17] dans une boucle se compile en un appel à memset à chaque itération. L’appel système écrit son condensat sur les huit premiers mots de son tampon ; placer le message sous la forme left ‖ right ‖ tag fait donc que le condensat se trouve déjà là où va l’enfant gauche du niveau suivant. llvm-objdump -d sur l’ELF situé sous target/riscv32im-unknown-none-elf/release/ montre où se trouvent les cycles.

5. Le côté hôte

Construire les chemins exige Poseidon2 en dehors de la machine. La copie de travail des circuits le contient : rand_zkvm::hash::sponge_hash est la fonction côté hôte pour l’éponge que calcule l’appel système POSEIDON2. ledger-host est un petit binaire Rust qui en dépend par chemin :

[dependencies]
rand_zkvm = { path = "../../research" }
ledger-host owner   <secret: 32 hex>                   the owner id of a secret
ledger-host genesis <accounts file> <state file>       build a tree, write its state
ledger-host root    <state file>                       the state's root
ledger-host step    <state file> <transfers file> <new state file>
                                                       stdout: the private words
                                                       stderr: old root, new root, link
ledger-host link    <old state file> <new state file> <count>
                                                       the link a receipt must show

Un fichier d’état comporte 256 lignes de owner balance nonce. Le registre de démonstration compte six comptes approvisionnés. Leurs secrets figurent dans demo-accounts.txt et, comme pour toutes les démonstrations de ces pages, sont publics à dessein. Les soldes sont exprimés en unités de 10⁻⁸.

$ cd my-guests/ledger-host && cargo +1.98.1 build --release && cd ../ledger
$ alias ledger-host=../ledger-host/target/release/ledger-host
$ cat demo-accounts.txt
de0f9e0173fe8358df1de251d81b4bec 50000000000
350826bc703c63ee3497f907373397f0 12000000000
8d5684dd764886843e4660d2c1ef12cc 7550000000
89442144b6673a3f93a5aa2359ddc6cf 0
99e9f82eede0b8b9feca124c0e1808be 100000000000
f29afdfbf0b821aa6c52f21cedc4c14a 4200000000
$ ledger-host genesis demo-accounts.txt state-0.txt
root bd1e27e17c0a0fe6e102f4c2197ed8721f22fccc99657860f035d1f5a4d1a568

Un fichier de transferts comporte une ligne from to amount secret par transfert :

$ cat batch-1.txt
0 3 12500000000 de0f9e0173fe8358df1de251d81b4bec
4 1 99900000000 99e9f82eede0b8b9feca124c0e1808be
3 2 2500000000 89442144b6673a3f93a5aa2359ddc6cf
1 5 1500000000 350826bc703c63ee3497f907373397f0
$ ledger-host step state-0.txt batch-1.txt state-1.txt > witness-1.txt
old root bd1e27e17c0a0fe6e102f4c2197ed8721f22fccc99657860f035d1f5a4d1a568
new root c021ecd07d7ca8a04ee6dd7fce48052f2a88cf2b38040387066849b8e2e87e91
link bcf87def900bb2978b6c24bfaaf6c9d357d58ba9
link words 3170401775 2416685719 2339120319 2868300243 1473612713
$ head -6 state-1.txt
ff2a2fadae3eb422e7f8810b701d45c6 37500000000 1
d6f41168e6db1d63402588a7d50dda9c 110400000000 1
d240d9f11db1ed16b2902e4533c17015 10050000000 0
b357d759652ee04004d9c5c21a026d17 10000000000 1
7a179726cd16187bb760c0272c011cab 100000000 1
c393d03f75fcadfd1f1d7102040fc47b 5700000000 0

Les six soldes totalisent toujours 1 737,5. Le témoin compte 593 mots : le compteur, l’ancienne racine et 146 par transfert.

6. Compiler et exécuter

$ rand-guest build my-guests/ledger --max-words 65535
773 words against a cap of 65535 (fits); 0 ecall(s) with a non-static a7
OK
wrote my-guests/ledger/image.bin and its .sha256 (773 words, hc 6bb2eccbe70a562774053da4631777d84f2bc68361f30f9be5c4aa16f0153814, program id c4ddd388f2d94299610c7c3409dfdfb4dcb4ae3e0bc9ec78d0c45f51d8231cab)
$ rand-guest run my-guests/ledger/image.bin --input $(cat witness-1.txt) --tier 14
out[0] = 1
out[1] = 4
out[2] = 3170401775
out[3] = 2416685719
out[4] = 2339120319
out[5] = 2868300243
out[6] = 1473612713
out[7] = 0
cycles 12394
tier 14
tier 14: fits (cycles 12739 of 16383, Poseidon2 permutations 1030 of 2048)

Les cinq mots du lien correspondent à ceux de l’outil hôte. Les refus, chacun exécuté dans l’émulateur avec un lot d’un seul transfert sur state-0.txt :

lotout[0], out[1]
le compte 2 envoie plus qu’il ne détient4, 0
transfert du compte 2 avec le secret du compte 52, 0
un transfert valide, puis le compte 5 se payant lui-même6, 1
un transfert valide construit sur state-1.txt avec un mot de l’ancienne racine modifié2, 0
un transfert valide avec le solde du destinataire mal déclaré3, 0
un compteur de 57, 0

7. Le budget

un transfertquatre transferts
mots privés155593
cycles3 22112 394
permutations Poseidon2, digests inclus1 030 sur 2 048
palier1214

Un transfert représente 37 hachages : 32 nœuds (deux chemins de huit, chacun parcouru pour la feuille avant et après), quatre feuilles et un identifiant de propriétaire. Quatre transferts laissent environ 3 600 cycles du budget du palier 14 inutilisés. Une version avec le plafond relevé à 5 a exécuté cinq transferts en 15 832 des 16 383 cycles, ce qui tient avec très peu de marge ; le programme déployé s’arrête donc à 4.

Poseidon2 est ici la fonction de hachage la plus économique. Chaque cycle ordinaire coûte 1 gaz et chaque ligne d’absorption Poseidon2 en coûte 3, contre 64 pour une compression SHA-256 et 192 pour une permutation Keccak. Les 149 hachages Poseidon2 de cet appel ajoutent environ 2 500 gaz au total, soit à peu près le coût de 13 permutations Keccak. Le vote anonyme en effectue 12 :

gas: 14110 (declaring 16384, tier 14)

12 740 lignes à 1 gaz chacune, plus 2 de plus pour chacune des 685 lignes d’absorption, font 14 110.

8. Déploiement

Le programme n’a pas de segment public ; son identifiant est donc celui qu’a affiché rand-guest build.

$ rand fee deploy 773
0.0783 RAND
$ rand program deploy my-guests/ledger/image.bin
program id: c4ddd388f2d94299610c7c3409dfdfb4dcb4ae3e0bc9ec78d0c45f51d8231cab (773 words, hc cbecb26b27560ae7a43d0574d877176383c62b4f9b0ff36116aac4e5143815f0)
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 10.8s: tier 10, 1370188 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 113.1s: tier 14, 1494406 bytes
submitted deploy 40a8e53d7e916c90db77504cf6601c443d7da0756e04c52745f350969659588f
  0 RAND out, 59.7762 RAND change, fee 0.0783 RAND, anchored at height 97945, proved in 113.1s (bundle) + 10.8s (auth)

Un seul programme déployé sert un nombre quelconque de registres. Deux registres se distinguent par leur racine de genèse, et leurs reçus ne sont jamais liés entre eux.

9. Appel

$ rand call c4ddd388f2d94299610c7c3409dfdfb4dcb4ae3e0bc9ec78d0c45f51d8231cab \
    $(sed 's/[0-9][0-9]*/--input &/g' witness-1.txt)
proving the call locally (593 inputs stay private)…
gas: 14110 (declaring 16384, tier 14)
fee 0.007489462 RAND (incl. two price steps of headroom)
proved in 114.8s: tier 14, 1498589 bytes, outputs [1, 4, 3170401775, 2416685719, 2339120319, 2868300243, 1473612713, 0]
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 19.9s: tier 10, 1374567 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 134.0s: tier 14, 1495974 bytes
submitted call 0ec66eed3bdeefc4a95741d26ad84f0a2d8198c4af3f7b1ec778e0525fa56a99
  0 RAND out, 59.701731664 RAND change, fee 0.007489462 RAND, anchored at height 98577, proved in 134.0s (bundle) + 19.9s (auth)
{
  "h_in": "68ea34ef767a30987c155a5a3cf0e69e832166b54611510ce0abc3430b8ff44c",
  "h_pub": null,
  "height": 98831,
  "index": 0,
  "outputs": [
    1,
    4,
    3170401775,
    2416685719,
    2339120319,
    2868300243,
    1473612713,
    0
  ],
  "program": "c4ddd388f2d94299610c7c3409dfdfb4dcb4ae3e0bc9ec78d0c45f51d8231cab",
  "tier": 14,
  "tx": "0ec66eed3bdeefc4a95741d26ad84f0a2d8198c4af3f7b1ec778e0525fa56a99"
}
input transcript published; open it with `rand open-call 0ec66eed3bdeefc4a95741d26ad84f0a2d8198c4af3f7b1ec778e0525fa56a99`

Statut 1, quatre transferts, et le lien bcf87def 900bb297 8b6c24bf aaf6c9d3 57d58ba9 en hexadécimal. h_pub vaut null, car le programme n’a pas de segment public. La chaîne ne contient aucun compte, aucun montant et aucune racine.

La preuve fait 1 498 589 octets, soit la taille d’une preuve d’appel sans table de hachage : Poseidon2 est déjà présent dans toutes les preuves, alors que Keccak et SHA-256 apportent chacun une table qui leur est propre. L’appel a pris un peu plus de cinq minutes sur un ordinateur portable Apple Silicon (115 s pour la preuve d’appel, 20 s pour l’autorisation de dépense, 134 s pour le lot) et a atteint un pic de 6,1 GB.

10. Le suivre soi-même

Un suiveur qui détient state-0.txt et reçoit state-1.txt vérifie le reçu en une seule commande :

$ ledger-host link state-0.txt state-1.txt 4
link bcf87def900bb2978b6c24bfaaf6c9d357d58ba9
link words 3170401775 2416685719 2339120319 2868300243 1473612713

Ces cinq mots sont les sorties 2 à 6 du reçu, et le 4 est sa sortie 1. Un nombre différent, ou l’un des deux états modifié d’une seule unité, donne un lien différent.

Une deuxième étape. Le registre a encore avancé d’un pas pour cette page. batch-2.txt contient deux transferts appliqués à state-1.txt :

$ cat batch-2.txt
2 0 1050000000 8d5684dd764886843e4660d2c1ef12cc
5 4 700000000 f29afdfbf0b821aa6c52f21cedc4c14a
$ ledger-host step state-1.txt batch-2.txt state-2.txt > witness-2.txt
old root c021ecd07d7ca8a04ee6dd7fce48052f2a88cf2b38040387066849b8e2e87e91
new root a1f79db3379bc2a06c6da4326f122bb7bec04689329b79ba76f931cbb6ef7b47
link 2e2738c49dc6edcfd1349a6932145c12b23d366b
link words 774322372 2647059919 3509885545 840195090 2990356075
$ rand call c4ddd388f2d94299610c7c3409dfdfb4dcb4ae3e0bc9ec78d0c45f51d8231cab \
    $(sed 's/[0-9][0-9]*/--input &/g' witness-2.txt)
…
gas: 7240 (declaring 8192, tier 14)
fee 0.00645165 RAND (incl. two price steps of headroom)
proved in 100.9s: tier 14, 1516155 bytes, outputs [1, 2, 774322372, 2647059919, 3509885545, 840195090, 2990356075, 0]
…
submitted call 590f9814a9de00e6d667ac17587b3ea9742b745b6b43657876c42bc095ce8a99

Son ancienne racine est la nouvelle racine de la première étape, c021ecd0…7e91. C’est là la chaîne de traçabilité : sur la chain 18, le reçu 0ec66eed…6a99 reliait l’état de genèse à state-1, et le reçu 590f9814…8a99 reliait state-1 à state-2. Sur la chain 20, les deux mêmes étapes, avec les mêmes racines, sont 9c9998bb…66eb et dd756a89…ffc2. L’état actuel du registre de démonstration est celui que ces deux lots produisent à partir de demo-accounts.txt ; chacun peut donc le reconstruire et franchir la troisième étape.

Ouvrir un appel. Le wallet a scellé les entrées privées de chaque appel dans une enveloppe que seule la clé de visualisation de l’appelant ouvre. L’appelant peut l’ouvrir plus tard, et le wallet ré-exécute le programme sur ce qu’il y trouve :

$ rand open-call 0ec66eed3bdeefc4a95741d26ad84f0a2d8198c4af3f7b1ec778e0525fa56a99
opened with the caller's viewing key: 593 input word(s)
inputs: [4, 3172870113, 2081034214, 3775067330, 427743346, 522386636, 2573564000, 4030058997, 2765202792, 0, 3, …]
emulator outputs: [1, 4, 3170401775, 2416685719, 2339120319, 2868300243, 1473612713, 0]
receipt outputs:  [1,4,3170401775,2416685719,2339120319,2868300243,1473612713,0]
verdict: faithful — these are the words the proof was made over, and they reproduce its outputs

Ces entrées contiennent l’ancienne racine et chaque feuille modifiée ; une enveloppe scellée pour un auditeur (rand call --auditor <rand1…>) est donc un moyen de placer les données d’une étape là où une partie désignée peut toujours les obtenir. Avec la v0.6.7, open-call fonctionne pour ce programme et échoue pour les trois autres : pour un programme doté d’un segment public, il ouvre les entrées puis s’arrête sur re-running the program on these inputs failed: PublicIndex(0), car la ré-exécution est faite sans l’entrée publique du programme.

Ce que la preuve ne garantit pas

  • Disponibilité. La chaîne prouve que chaque étape est valide. Elle ne détient pas le registre. Quiconque effectue une étape doit publier les nouvelles feuilles, faute de quoi les suiveurs ne peuvent pas franchir la suivante. Un appelant qui prouve une étape et retient les données a figé le registre à ce point pour tous ceux qui acceptent le reçu ; un déploiement réel a donc besoin d’une règle à ce sujet : un comité qui signe pour les données, ou la publication des feuilles modifiées en un lieu public.
  • Confidentialité entre participants. La chaîne ne voit qu’un nombre et un hachage. Mais un payeur doit fournir la feuille du destinataire, il connaît donc le solde du destinataire, et quiconque détient le fichier d’état détient tous les soldes. Le pool shielded de Rand évite cet écueil en recourant à des notes plutôt qu’à des comptes (Notes et arbre d’engagements). Cet exemple conserve des comptes pour rester petit.
  • Ordre. Deux étapes valides issues de la même racine entrent en conflit, et seule la première dans l’ordre de la chaîne compte. Le second appelant a payé un reçu que personne n’accepte et doit reconstruire à partir de la nouvelle racine.
  • Un lien avec RAND ou avec un jeton. Les soldes sont des nombres dans un arbre. Rien ne les fait entrer dans le pool shielded ni en sortir : les sorties d’un programme sont des données, et la chaîne n’agit sur aucune d’entre elles.
  • Transferts signés par le titulaire et prouvés par un tiers. Posséder un compte revient à connaître une préimage de hachage, et cette préimage est une entrée privée ; le prover doit donc être le titulaire. Déléguer exigerait une vérification de signature dans le programme. Une vérification Dilithium ou ECDSA ne tient pas dans un appel de palier 14.

Dernière vérification par rapport au commit 48dc04b de fullnode (chaîne 18) le 2026-09-30, et circuits `aeacf31` (`guest-sdk/src/lib.rs`, `research/src/hash.rs`, `rand-guest`), the commit fullnode v0.6.7 vendors; every `rand-guest`, `ledger-host` and `rand` output on this page was produced on 2026-09-30, the deploy and the call on chain 18. Lorsque le code et cette page divergent, c'est le code qui a raison ; signalez-le-nous.