Rand Protocol
Documentación
Programas de ejemplo

Un libro mayor fuera de la cadena

Un programa confidencial que mantiene estado donde los programas no lo tienen: un árbol de Merkle de 256 cuentas que vive fuera de la cadena, en el que cada lote de transferencias se prueba como una transición de una raíz a la siguiente y se encadena mediante recibos. La interfaz, el guest en Rust, la herramienta del host, cómo se redujo a la mitad el número de ciclos y el programa en vivo en chain 18.

Un programa en Rand no tiene almacenamiento: una llamada lee sus entradas, publica ocho palabras y deja un recibo (Programas). Esta página muestra la forma estándar de conservar estado de todos modos. El estado vive fuera de la cadena como un árbol de Merkle, el programa demuestra cada cambio como un paso de una raíz a la siguiente, y los recibos encadenan los pasos entre sí.

El ejemplo es un libro mayor de 256 cuentas. Una llamada aplica hasta cuatro transferencias y demuestra que cada remitente era titular de la cuenta, tenía los fondos, y que nada se creó ni se perdió. La cadena ve un estado, un recuento y un hash.

Programas de ejemplo contiene la configuración que comparten las cuatro páginas de ejemplo.

1. Qué demuestra

El libro mayor es un árbol de Merkle de profundidad 8 sobre Poseidon2, el hash que la máquina demuestra al menor costo. Una hoja es una cuenta:

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

El secreto son cuatro palabras que conserva el titular de la cuenta.

Una llamada toma como entrada privada la raíz actual y un lote de transferencias. Para cada transferencia demuestra que:

  1. la hoja del remitente está en el árbol, mediante su camino de Merkle;
  2. quien llama conoce el secreto que corresponde al identificador de propietario del remitente;
  3. el monto es como máximo el saldo del remitente;
  4. el árbol posterior al débito contiene la hoja del receptor;
  5. el crédito no desborda.

Después publica un enlace: Poseidon2(old root, new root, count, "LINK"), truncado a 160 bits.

Por qué un enlace y no las dos raíces. Una raíz son ocho palabras y hay ocho ranuras de salida. Publicar ambas raíces obligaría a truncar cada una a unos 96 bits, y un valor de 96 bits admite una colisión con unas 2⁴⁸ operaciones, lo que permitiría a un probador mostrar dos libros mayores distintos bajo un mismo recibo. Un único hash de 160 bits de ambas raíces no tiene ese problema. Las raíces completas viajan fuera de la cadena con los datos del libro mayor, y cualquiera que las tenga recalcula el enlace.

Seguir el libro mayor. Un seguidor mantiene el estado actual. Cuando aparece un recibo del programa con estado 1, el seguidor obtiene las hojas nuevas de quien hizo la llamada, reconstruye la raíz y calcula el enlace a partir de su propia raíz anterior, la raíz nueva y el recuento del recibo. Si coincide con las salidas del recibo, el paso es válido y el seguidor adopta el nuevo estado. Un recibo que no enlaza desde la raíz actual del seguidor se ignora. Eso es también lo que resuelve una carrera: dos quienes llaman que construyen sobre la misma raíz obtienen ambos recibos, y el que la cadena ordena primero es el que todo seguidor acepta.

Quién puede hacer una llamada. Cualquiera que conozca el secreto de un remitente y el árbol actual. La regla no incluye ningún operador: un titular puede demostrar su propia transferencia en su propia máquina, de modo que el secreto nunca sale de ella.

2. La interfaz

No hay segmento público. La identidad del libro mayor es su raíz génesis, acordada fuera de la cadena.

palabra privadasignificado
0el número de transferencias, como máximo 4
1 a 8la raíz anterior
luego, por transferencia, 146 palabras
+0, +1el índice de hoja del remitente y del receptor, cada uno menor que 256
+2, +3el monto, un u64, primero la palabra baja y luego la alta
+4 a +7el secreto del remitente
+8 a +10el saldo del remitente (baja, alta) y el nonce
+11 a +74el camino de Merkle del remitente: 8 hermanos de 8 palabras, desde la hoja hacia arriba
+75 a +78el identificador de propietario del receptor
+79 a +81el saldo del receptor (baja, alta) y el nonce
+82 a +145el camino de Merkle del receptor, en el árbol que deja el débito
ranura de salidasignificado
0estado: 1 aplicada, 2 la hoja del remitente no está en el árbol (o el secreto es incorrecto), 3 la hoja del receptor no está en el árbol, 4 saldo insuficiente, 5 el crédito desborda, 6 el remitente es el receptor, 7 más de 4 transferencias, 8 índice fuera de rango
1transferencias aplicadas: el recuento si hay éxito, de lo contrario el índice de la transferencia que falla
2 a 6si hay éxito, las cinco primeras palabras del enlace
7sin uso; un pánico de Rust escribe aquí 0xdeadbeef

Las transferencias se aplican en orden, cada una sobre el árbol que dejó la anterior. En el lote de demostración, la cuenta 3 empieza en cero, recibe 125 en la primera transferencia y gasta 25 de ellos en la tercera.

3. El 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 raíz anterior es una entrada privada, y eso es sólido. Nada obliga a quien llama a partir de la raíz real. Una llamada que parte de una raíz inventada demuestra un paso correcto desde un libro mayor que nadie más tiene, y su enlace no coincide con el estado de ningún seguidor. El enlace es lo que vincula: es un compromiso público con ambas raíces, y los seguidores lo comparan con el suyo.

La conservación es por construcción. El monto debitado y el monto acreditado son la misma variable. No hay una suma aparte que comprobar.

El débito y el crédito usan aritmética wrapping_ a propósito. El programa construye la hoja «posterior» antes de saber que la hoja «anterior» es genuina, porque ambas recorren el camino a la vez. Si el monto excede el saldo, se calcula la hoja con desbordamiento y luego se descarta cuando la comprobación dos líneas más abajo devuelve el estado 4.

El nonce se incrementa en cada débito, de modo que la hoja de un remitente cambia incluso cuando un saldo vuelve a un valor anterior. Los seguidores pueden ver cuántas veces ha gastado una cuenta.

4. Dos datos de la cadena de herramientas con los que tropezó este guest

rand-guest check rechaza el Poseidon2 ligado a la longitud. El SDK tiene dos llamadas a Poseidon2. poseidon2 (syscall 3) no rellena: un mensaje de hasta cuatro palabras produce el mismo hash que ese mensaje con palabras cero añadidas. poseidon2_len (syscall 7) siembra la longitud en la esponja y es la opción más segura por defecto para datos de longitud variable. La máquina implementa ambas, pero en los circuitos aeacf31 la lista de syscalls del verificador de comprobación se detiene en 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)

Por eso este guest usa poseidon2 y sigue la regla que hace segura la esponja sin relleno: todo mensaje tiene una única longitud fija, y cada tipo de mensaje lleva su propia palabra de etiqueta.

Poner a cero un búfer en un bucle es la línea más cara en la que no repararás. La primera versión del recorrido de Merkle era la obvia: una función que construía un [0u32; 17] nuevo para cada nodo, copiaba los dos hijos, calculaba el hash y devolvía el resumen. Cuatro transferencias tomaron 26 124 ciclos y no cabían en el tier 14, el tier más alto que puede usar una llamada. Tres cambios, ninguno de los cuales afecta a lo que se demuestra:

cambiociclos para cuatro transferencias
un búfer nuevo puesto a cero y un arreglo de camino por nodo26 124
un búfer por recorrido, puesto a cero una vez; sin arreglo de camino relleno de ceros15 897
los recorridos anterior y posterior hechos juntos, cada hermano leído una vez15 445
el hash en curso conservado en el búfer de hash, donde lo deja la syscall12 393

[0u32; 17] dentro de un bucle se compila en una llamada a memset por iteración. La syscall escribe su resumen sobre las primeras ocho palabras de su búfer, así que colocar el mensaje como left ‖ right ‖ tag significa que el resumen ya está donde va el hijo izquierdo del siguiente nivel. llvm-objdump -d sobre el ELF en target/riscv32im-unknown-none-elf/release/ muestra dónde están los ciclos.

5. El lado del host

Construir caminos requiere Poseidon2 fuera de la máquina. La copia de trabajo de los circuitos lo incluye: rand_zkvm::hash::sponge_hash es la función del lado del host para la esponja que calcula la syscall POSEIDON2. ledger-host es un pequeño binario de Rust que depende de ella por ruta:

[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 archivo de estado son 256 líneas de owner balance nonce. El libro mayor de demostración tiene seis cuentas con fondos. Sus secretos están en demo-accounts.txt y, como en toda demostración de estas páginas, son públicos a propósito. Los saldos están en unidades 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 archivo de transferencias tiene un from to amount secret por línea:

$ 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

Los seis saldos siguen sumando 1737,5. El testigo son 593 palabras: el recuento, la raíz anterior y 146 por transferencia.

6. Compilar y ejecutar

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

Las cinco palabras del enlace coinciden con las de la herramienta del host. Los rechazos, cada uno ejecutado en el emulador con un lote de una transferencia contra state-0.txt:

loteout[0], out[1]
la cuenta 2 envía más de lo que tiene4, 0
la transferencia de la cuenta 2 con el secreto de la cuenta 52, 0
una transferencia válida y luego la cuenta 5 pagándose a sí misma6, 1
una transferencia válida construida sobre state-1.txt con una palabra de la raíz anterior cambiada2, 0
una transferencia válida con el saldo del receptor declarado de forma incorrecta3, 0
un recuento de 57, 0

7. El presupuesto

una transferenciacuatro transferencias
palabras privadas155593
ciclos3,22112,394
permutaciones de Poseidon2, incluidos los digests1,030 de 2,048
tier1214

Una transferencia son 37 hashes: 32 nodos (dos rutas de ocho, cada una recorrida para la hoja anterior y la posterior), cuatro hojas y un identificador de propietario. Cuatro transferencias dejan sin usar unos 3,600 ciclos del presupuesto del tier 14. Una compilación con el límite elevado a 5 ejecutó cinco transferencias en 15,832 de los 16,383 ciclos, lo que cabe con poco margen, así que el programa desplegado se detiene en 4.

Poseidon2 es aquí el hash barato. Cada ciclo ordinario cuesta 1 de gas y cada fila de absorción de Poseidon2 cuesta 3, frente a 64 por una compresión de SHA-256 y 192 por una permutación de Keccak. Los 149 hashes de Poseidon2 de esta llamada suman entre todos unos 2,500 de gas, aproximadamente lo que cuestan 13 permutaciones de Keccak. El voto anónimo hace 12:

gas: 14110 (declaring 16384, tier 14)

12,740 filas a 1 de gas cada una, más 2 adicionales por cada una de las 685 filas de absorción, son 14,110.

8. Desplegar

El programa no tiene segmento público, así que su id es el que imprimió 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 programa desplegado sirve a cualquier número de libros contables. Dos libros contables se distinguen por su raíz de génesis, y sus recibos nunca se enlazan entre sí.

9. Llamar

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

Estado 1, cuatro transferencias y el enlace bcf87def 900bb297 8b6c24bf aaf6c9d3 57d58ba9 en hexadecimal. h_pub es null porque el programa no tiene segmento público. La cadena no guarda ninguna cuenta, ningún importe ni ninguna raíz.

La prueba pesa 1,498,589 bytes, el tamaño de una prueba de llamada sin tabla de hashes: Poseidon2 ya está en todas las pruebas, mientras que Keccak y SHA-256 aportan cada uno una tabla propia. La llamada tardó poco más de cinco minutos en un portátil Apple Silicon (115 s para la prueba de la llamada, 20 s para la autorización de gasto, 134 s para el paquete) y alcanzó un pico de 6.1 GB.

10. Sígalo usted mismo

Un seguidor que tiene state-0.txt y recibe state-1.txt comprueba el recibo con un solo comando:

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

Esas cinco palabras son las salidas 2 a 6 del recibo, y el 4 es su salida 1. Un recuento distinto, o cualquiera de los dos estados modificado en una unidad, da un enlace distinto.

Un segundo paso. El libro contable avanzó una vez más para esta página. batch-2.txt son dos transferencias contra 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

Su raíz anterior es la nueva raíz del primer paso, c021ecd0…7e91. Esa es la cadena de custodia: en la chain 18, el recibo 0ec66eed…6a99 enlazó el estado de génesis con state-1 y el recibo 590f9814…8a99 enlazó state-1 con state-2. En la chain 20, los mismos dos pasos, con las mismas raíces, son 9c9998bb…66eb y dd756a89…ffc2. El estado actual del libro contable de demostración es el que esos dos lotes producen a partir de demo-accounts.txt, de modo que cualquiera puede reconstruirlo y dar el tercer paso.

Abrir una llamada. La billetera selló las entradas privadas de cada llamada en un sobre que solo abre la clave de visualización de quien llama. Quien llama puede abrirlo más tarde, y la billetera vuelve a ejecutar el programa con lo que encuentra:

$ 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

Esas entradas contienen la raíz anterior y cada hoja modificada, de modo que un sobre sellado para un auditor (rand call --auditor <rand1…>) es una forma de poner los datos de un paso donde una parte designada siempre pueda obtenerlos. Con v0.6.7, open-call funciona para este programa y falla en los otros tres: en un programa con segmento público abre las entradas y luego se detiene con re-running the program on these inputs failed: PublicIndex(0), porque la nueva ejecución se hace sin la entrada pública del programa.

Lo que la prueba no le da

  • Disponibilidad. La cadena demuestra que cada paso es válido. No guarda el libro contable. Quien realiza un paso tiene que publicar las nuevas hojas, o los seguidores no podrán dar el siguiente. Quien llama y demuestra un paso pero retiene los datos ha congelado el libro contable en ese punto para todos los que aceptan el recibo, por lo que un despliegue real necesita una regla para esto: un comité que firme por los datos, o publicar las hojas modificadas en algún lugar público.
  • Privacidad entre participantes. La cadena no ve más que un recuento y un hash. Pero quien paga tiene que aportar la hoja de quien recibe, así que quien paga conoce el saldo de quien recibe, y cualquiera que tenga el archivo de estado tiene todos los saldos. El propio shielded pool de Rand evita esto con notes en lugar de cuentas (Notes y el árbol de compromisos). Este ejemplo mantiene las cuentas para seguir siendo pequeño.
  • Orden. Dos pasos válidos desde la misma raíz entran en conflicto, y solo cuenta el primero en el orden de la cadena. Quien llama en segundo lugar ha pagado por un recibo que nadie acepta y tiene que reconstruir sobre la nueva raíz.
  • Un enlace con RAND o con un token. Los saldos son números en un árbol. Nada los mueve hacia dentro o fuera del shielded pool: las salidas de un programa son datos, y la cadena no actúa sobre ninguna de ellas.
  • Transferencias firmadas por el titular y demostradas por otra persona. Ser propietario de una cuenta significa conocer una preimagen de hash, y la preimagen es una entrada privada, así que quien genera la prueba tiene que ser el titular. Delegar exigiría una verificación de firma dentro del programa. Una verificación de Dilithium o de ECDSA no cabe en una llamada de tier 14.

Verificado por última vez con el commit 48dc04b del fullnode (cadena 18) el 2026-09-30, y 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. Cuando el código y esta página no coinciden, el código tiene razón; avísenos.