Rand Protocol
Documentazione
Programmi di esempio

Un ledger off-chain

Un programma confidenziale che mantiene lo stato dove i programmi non ne hanno: un albero di Merkle da 256 account che vive off-chain, in cui ogni batch di trasferimenti è dimostrato come transizione da una radice alla successiva e collegato tramite ricevute. L'interfaccia, il guest in Rust, lo strumento host, come è stato dimezzato il numero di cicli e il programma attivo sulla chain 18.

Un programma su Rand non ha storage: una chiamata legge i propri input, pubblica otto parole e lascia una ricevuta (Programmi). Questa pagina mostra il modo standard per mantenere comunque uno stato. Lo stato vive fuori dalla chain come albero di Merkle, il programma dimostra ogni modifica come un passaggio da una radice alla successiva e le ricevute concatenano i passaggi.

L’esempio è un ledger di 256 account. Una chiamata applica fino a quattro trasferimenti e dimostra che ogni mittente possedeva l’account, aveva i fondi e che nulla è stato creato o perso. La chain vede uno stato, un conteggio e un hash.

Programmi di esempio contiene la configurazione condivisa da tutte e quattro le pagine di esempio.

1. Cosa dimostra

Il ledger è un albero di Merkle di profondità 8 su Poseidon2, l’hash che la macchina dimostra al costo più basso. Una foglia è un account:

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

Il secret è composto da quattro parole che il titolare dell’account custodisce.

Una chiamata riceve come input privato la radice corrente e un lotto di trasferimenti. Per ogni trasferimento dimostra che:

  1. la foglia del mittente è nell’albero, tramite il suo percorso di Merkle;
  2. chi chiama conosce il secret che sta dietro all’owner id del mittente;
  3. l’importo è al massimo pari al saldo del mittente;
  4. l’albero dopo l’addebito contiene la foglia del destinatario;
  5. l’accredito non va in overflow.

Pubblica poi un link: Poseidon2(old root, new root, count, "LINK"), troncato a 160 bit.

Perché un link e non le due radici. Una radice è di otto parole e gli slot di output sono otto. Pubblicare entrambe le radici significherebbe troncare ciascuna a circa 96 bit, e un valore a 96 bit può essere portato a collisione con circa 2⁴⁸ di lavoro, il che permetterebbe a un prover di mostrare due ledger diversi sotto un’unica ricevuta. Un unico hash a 160 bit di entrambe le radici non ha questo problema. Le radici complete viaggiano fuori dalla chain insieme ai dati del ledger e chiunque le possieda ricalcola il link.

Seguire il ledger. Un osservatore detiene lo stato corrente. Quando compare una ricevuta del programma con stato 1, l’osservatore ottiene le nuove foglie da chi ha effettuato la chiamata, ricostruisce la radice e calcola il link a partire dalla propria vecchia radice, dalla nuova radice e dal conteggio della ricevuta. Se coincide con gli output della ricevuta, il passaggio è valido e l’osservatore adotta il nuovo stato. Una ricevuta che non si collega alla radice corrente dell’osservatore viene ignorata. È anche ciò che risolve una corsa: due chiamanti che costruiscono sulla stessa radice ottengono entrambi una ricevuta, e quella che la chain ordina per prima è quella che ogni osservatore accetta.

Chi può effettuare una chiamata. Chiunque conosca il secret di un mittente e l’albero corrente. Nella regola non c’è alcun operatore: un titolare può dimostrare il proprio trasferimento sulla propria macchina, così il secret non la lascia mai.

2. L’interfaccia

Non c’è alcun segmento pubblico. L’identità del ledger è la sua radice di genesi, concordata fuori dalla chain.

parola privatasignificato
0il numero di trasferimenti, al massimo 4
da 1 a 8la vecchia radice
poi, per ogni trasferimento, 146 parole
+0, +1l’indice della foglia del mittente e quello del destinatario, ciascuno inferiore a 256
+2, +3l’importo, un u64, prima la parola bassa poi quella alta
da +4 a +7il secret del mittente
da +8 a +10il saldo del mittente (basso, alto) e il nonce
da +11 a +74il percorso di Merkle del mittente: 8 nodi fratelli da 8 parole, dalla foglia verso l’alto
da +75 a +78l’owner id del destinatario
da +79 a +81il saldo del destinatario (basso, alto) e il nonce
da +82 a +145il percorso di Merkle del destinatario, nell’albero che l’addebito lascia dietro di sé
slot di outputsignificato
0stato: 1 applicato, 2 foglia del mittente non presente nell’albero (o secret errato), 3 foglia del destinatario non presente nell’albero, 4 saldo insufficiente, 5 l’accredito va in overflow, 6 il mittente coincide con il destinatario, 7 più di 4 trasferimenti, 8 indice fuori intervallo
1trasferimenti applicati: il conteggio in caso di successo, altrimenti l’indice del trasferimento fallito
da 2 a 6in caso di successo, le prime cinque parole del link
7inutilizzato; un panic di Rust vi scrive 0xdeadbeef

I trasferimenti vengono applicati in ordine, ciascuno all’albero lasciato dal precedente. Nel lotto dimostrativo, l’account 3 parte da zero, riceve 125 nel primo trasferimento e ne spende 25 nel terzo.

3. Il guest

my-guests/ledger/src/main.rs, completo:

#![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();
}

La vecchia radice è un input privato, e ciò è corretto. Nulla obbliga chi chiama a partire dalla radice reale. Una chiamata che parte da una radice inventata dimostra un passaggio corretto a partire da un ledger che nessun altro detiene, e il suo link non corrisponde allo stato di alcun osservatore. Il vincolo è il link: è un impegno pubblico su entrambe le radici, e gli osservatori lo confrontano con il proprio.

La conservazione è per costruzione. L’importo addebitato e quello accreditato sono la stessa variabile. Non c’è alcuna somma separata da verificare.

L’addebito e l’accredito usano di proposito l’aritmetica wrapping_. Il programma costruisce la foglia “dopo” prima di sapere che la foglia “prima” è autentica, perché entrambe percorrono il cammino insieme. Se l’importo supera il saldo, la foglia con wrapping viene calcolata e poi scartata quando il controllo due righe più avanti restituisce lo stato 4.

Il nonce aumenta a ogni addebito, quindi la foglia di un mittente cambia anche quando un saldo torna a un valore precedente. Gli osservatori possono vedere quante volte un account ha speso.

4. Due fatti sulla toolchain in cui questo guest si è imbattuto

rand-guest check rifiuta il Poseidon2 legato alla lunghezza. L’SDK ha due chiamate Poseidon2. poseidon2 (syscall 3) non applica padding: un messaggio fino a quattro parole produce lo stesso hash dello stesso messaggio con parole nulle aggiunte in coda. poseidon2_len (syscall 7) inserisce la lunghezza nella sponge ed è il valore predefinito più sicuro per dati di lunghezza variabile. La macchina implementa entrambe, ma nei circuits aeacf31 l’elenco delle syscall del checker si ferma a 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)

Perciò questo guest usa poseidon2 e segue la regola che rende sicura la sponge senza padding: ogni messaggio ha una sola lunghezza fissa e ogni tipo di messaggio porta la propria parola di tag.

Azzerare un buffer in un ciclo è la riga più costosa che non noterete. La prima versione della visita di Merkle era quella ovvia: una funzione che costruiva un nuovo [0u32; 17] per ogni nodo, vi copiava i due figli, calcolava l’hash e restituiva il digest. Quattro trasferimenti richiedevano 26.124 cicli e non stavano nel tier 14, il più alto che una chiamata può usare. Tre modifiche, nessuna delle quali tocca ciò che viene dimostrato:

modificacicli per quattro trasferimenti
un nuovo buffer azzerato e un array di percorso per ogni nodo26.124
un buffer per visita, azzerato una sola volta; nessun array di percorso riempito di zeri15.897
le visite prima e dopo eseguite insieme, ogni nodo fratello letto una sola volta15.445
l’hash in corso mantenuto nel buffer dell’hash, dove la syscall lo lascia12.393

[0u32; 17] dentro un ciclo si compila in una chiamata a memset per iterazione. La syscall scrive il proprio digest sulle prime otto parole del suo buffer, quindi disporre il messaggio come left ‖ right ‖ tag significa che il digest si trova già dove va il figlio sinistro del livello successivo. llvm-objdump -d sull’ELF in target/riscv32im-unknown-none-elf/release/ mostra dove si trovano i cicli.

5. Il lato host

Costruire i percorsi richiede Poseidon2 fuori dalla macchina. Il checkout dei circuits lo include: rand_zkvm::hash::sponge_hash è la funzione lato host per la sponge che la syscall POSEIDON2 calcola. ledger-host è un piccolo binario Rust che dipende da essa tramite percorso:

[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 file di stato è composto da 256 righe owner balance nonce. Il ledger dimostrativo ha sei account finanziati. I loro secret sono in demo-accounts.txt e, come in ogni demo di queste pagine, sono pubblici di proposito. I saldi sono in unità di 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 file di trasferimenti ha un from to amount secret per riga:

$ 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

I sei saldi sommano ancora a 1.737,5. Il witness è di 593 parole: il conteggio, la vecchia radice e 146 per ogni trasferimento.

6. Compilare ed eseguire

$ 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)

Le cinque parole del link coincidono con quelle dello strumento host. I rifiuti, ciascuno eseguito nell’emulatore con un lotto di un trasferimento rispetto a state-0.txt:

lottoout[0], out[1]
l’account 2 invia più di quanto possiede4, 0
il trasferimento dell’account 2 con il secret dell’account 52, 0
un trasferimento valido, poi l’account 5 che paga se stesso6, 1
un trasferimento valido costruito su state-1.txt con una parola della vecchia radice modificata2, 0
un trasferimento valido con il saldo del destinatario dichiarato in modo errato3, 0
un conteggio pari a 57, 0

7. Il budget

un trasferimentoquattro trasferimenti
parole private155593
cicli3,22112,394
permutazioni Poseidon2, digest inclusi1,030 di 2,048
tier1214

Un trasferimento equivale a 37 hash: 32 nodi (due percorsi di otto, ciascuno percorso per la foglia prima e dopo), quattro foglie e un id del proprietario. Quattro trasferimenti lasciano inutilizzati circa 3,600 cicli del budget del tier 14. Una build con il limite portato a 5 ha eseguito cinque trasferimenti in 15,832 dei 16,383 cicli: ci sta con un margine minimo, quindi il programma distribuito si ferma a 4.

Qui Poseidon2 è l’hash economico. Ogni ciclo ordinario costa 1 gas e ogni riga di absorb di Poseidon2 ne costa 3, contro i 64 di una compressione SHA-256 e i 192 di una permutazione Keccak. I 149 hash Poseidon2 di questa chiamata aggiungono in tutto circa 2,500 gas, più o meno il costo di 13 permutazioni Keccak. Il voto anonimo ne fa 12:

gas: 14110 (declaring 16384, tier 14)

12,740 righe a 1 gas ciascuna, più altre 2 per ognuna delle 685 righe di absorb, fanno 14,110.

8. Distribuzione

Il programma non ha un segmento pubblico, quindi il suo id è quello stampato da 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 solo programma distribuito serve un numero qualsiasi di ledger. Due ledger si distinguono per la radice di genesi, e le loro ricevute non sono mai collegate tra loro.

9. Chiamata

$ 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`

Stato 1, quattro trasferimenti e il collegamento bcf87def 900bb297 8b6c24bf aaf6c9d3 57d58ba9 in esadecimale. h_pub è null perché il programma non ha un segmento pubblico. La chain non contiene alcun conto, alcun importo e alcuna radice.

La prova pesa 1,498,589 byte, la dimensione di una prova di chiamata senza tabella di hash: Poseidon2 è già presente in ogni prova, mentre Keccak e SHA-256 portano ciascuno una tabella propria. La chiamata ha richiesto poco più di cinque minuti su un laptop Apple Silicon (115 s per la prova della chiamata, 20 s per l’autorizzazione di spesa, 134 s per il bundle) e ha raggiunto un picco di 6.1 GB.

10. Seguirlo in prima persona

Un follower che possiede state-0.txt e riceve state-1.txt verifica la ricevuta con un solo comando:

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

Quelle cinque parole sono gli output da 2 a 6 della ricevuta, e il 4 è il suo output 1. Un conteggio diverso, o uno dei due stati modificato di una sola unità, dà un collegamento diverso.

Un secondo passo. Per questa pagina il ledger è avanzato ancora una volta. batch-2.txt contiene due trasferimenti rispetto a 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

La sua vecchia radice è la nuova radice del primo passo, c021ecd0…7e91. Questa è la catena di custodia: sulla chain 18, la ricevuta 0ec66eed…6a99 collegava lo stato di genesi a state-1 e la ricevuta 590f9814…8a99 collegava state-1 a state-2. Sulla chain 20 gli stessi due passi, con le stesse radici, sono 9c9998bb…66eb e dd756a89…ffc2. Lo stato attuale del ledger dimostrativo è quello che questi due batch producono a partire da demo-accounts.txt, quindi chiunque può ricostruirlo ed eseguire il terzo passo.

Aprire una chiamata. Il wallet ha sigillato gli input privati di ogni chiamata in una busta che solo la viewing key del chiamante apre. Il chiamante può aprirla in seguito, e il wallet riesegue il programma su ciò che trova:

$ 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

Quegli input contengono la vecchia radice e ogni foglia modificata, quindi una busta sigillata per un auditor (rand call --auditor <rand1…>) è un modo per mettere i dati di un passo dove una parte indicata possa sempre ottenerli. Con v0.6.7, open-call funziona per questo programma e fallisce sugli altri tre: per un programma con un segmento pubblico apre gli input e poi si ferma con re-running the program on these inputs failed: PublicIndex(0), perché la riesecuzione avviene senza l’input pubblico del programma.

Che cosa la prova non garantisce

  • Disponibilità. La chain dimostra che ogni passo è valido. Non conserva il ledger. Chi esegue un passo deve pubblicare le nuove foglie, altrimenti i follower non possono eseguire il successivo. Un chiamante che dimostra un passo e trattiene i dati ha congelato il ledger a quel punto per tutti coloro che accettano la ricevuta, quindi un’implementazione reale richiede una regola per questo caso: un comitato che firma per i dati, oppure la pubblicazione delle foglie modificate in un luogo pubblico.
  • Privacy tra i partecipanti. La chain non vede altro che un conteggio e un hash. Ma chi paga deve fornire la foglia del destinatario, quindi il pagatore conosce il saldo del destinatario, e chiunque possieda il file di stato possiede ogni saldo. Il shielded pool di Rand evita questo problema usando note anziché conti (Note e albero degli impegni). Questo esempio mantiene i conti per restare piccolo.
  • Ordinamento. Due passi validi a partire dalla stessa radice sono in conflitto, e conta solo il primo nell’ordine della chain. Il secondo chiamante ha pagato una ricevuta che nessuno accetta e deve ricostruire sulla nuova radice.
  • Un collegamento a RAND o a un token. I saldi sono numeri in un albero. Nulla li sposta dentro o fuori dal shielded pool: gli output di un programma sono dati, e la chain non agisce su nessuno di essi.
  • Trasferimenti firmati dal titolare e dimostrati da un altro. Possedere un conto significa conoscere la preimmagine di un hash, e la preimmagine è un input privato, quindi chi produce la prova deve essere il titolare. Delegare richiederebbe una verifica di firma all’interno del programma. Una verifica Dilithium o ECDSA non entra in una chiamata del tier 14.

Ultima verifica rispetto al commit 48dc04b del fullnode (chain 18) il 2026-09-30 e 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. Dove il codice e questa pagina non concordano, ha ragione il codice; segnalacelo.