Esta página reúne todo lo necesario para poner un programa propio en Rand: qué puede ser un programa, cómo diseñar lo que lee y lo que publica, y un ejemplo completo escrito dos veces, una en Rust y otra en C. Después trata las pruebas, el presupuesto de prueba, el despliegue, la llamada y cómo cualquiera puede comprobar el resultado. Cada salida de rand-guest que aparece más abajo proviene de una ejecución real y se pega tal como se imprimió. Las ejecuciones se hicieron el 2026-09-25 en el commit 7ef3220 de circuits. El testnet es ahora la chain 20, cuya toolchain, como la de las chains 18 y 19, se compila en aeacf31 (rama feat/v067-circuits; el fullnode v0.6.8 fija el mismo commit), y las salidas no se han vuelto a ejecutar allí. La página indica dónde difieren el SDK o la chain.
Los dos tutoriales breves son el camino rápido: Deploy a Rust program y Deploy a RISC-V program. Esta página da por hecho que ya has completado el paso 1 del tutorial de Rust: un checkout de randprotocol/zkp-circuits en aeacf31, Rust 1.98.1 con el target riscv32im-unknown-none-elf, el binario rand-guest y, para C, clang 23.1.1 y llvm-tools. The RISC-V ISA es la referencia de la propia máquina. Example programs continúa desde aquí con programas más grandes y con lo que cambió la medición de gas de la chain 18. Esta página trata los programas RPL-1, que un Call ejecuta sin estado; la chain 20 también tiene estado de programa RPL-2, celdas y una bóveda que modifica un Invoke (Programs), con ejemplos completos en randprotocol/example-programs.
1. Qué puede ser un programa
Un programa es cualquier cómputo que encaje en esta forma:
- Lee palabras de 32 bits como entrada.
- Publica como máximo ocho palabras de 32 bits como salida.
- Es determinista: las mismas entradas siempre dan las mismas salidas.
- Termina dentro del presupuesto de su nivel (sección 7).
Dentro de esos límites puedes escribir cualquier cosa. Dispones de bucles, recursión, structs, arrays, aritmética de 64 bits, crates y un heap si aportas un asignador. Aun así, la máquina es RV32IM pura y no hay ningún sistema operativo debajo:
| dispones de | no dispones de |
|---|---|
instrucciones enteras RV32IM, incluidas MUL/DIV/REM | hardware de coma flotante (los flotantes por software de Rust funcionan, pero lentamente) |
hasta 1 MiB de RAM desde 0x1000 en el linker script predeterminado, con una pila de 64 KiB | un heap, salvo que aportes un asignador (sección 6) |
| Poseidon2, Keccak-f y SHA-256 como syscalls | archivos, red, relojes, aleatoriedad, hilos, std, libc |
crates de Rust no_std y C autocontenido | estado que persista de una llamada a la siguiente |
| ocho palabras de salida públicas | una forma de mover RAND o tokens desde dentro del programa |
La última fila es la más importante a la hora de diseñar un programa. Las salidas son datos, no efectos. Una llamada prueba que un programa con un digest determinado se ejecutó sobre ciertas entradas privadas y publicó ocho palabras. La chain verifica la prueba y almacena las ocho palabras en un recibo. No modifica ningún saldo ni desencadena nada. Lo que signifiquen las palabras lo acuerdan el programa, quien lo llama y quien lea el recibo. Programs enumera lo que la chain aún no admite.
2. Diseña primero la interfaz
Un programa tiene tres canales. Decide qué va en cada uno antes de escribir código.
| canal | se fija | se lee con | quién lo ve | límite en la chain 20 |
|---|---|---|---|---|
| entrada privada | en cada llamada | read_input(i) / rand_read_input(i) | solo quien llama, y quien reciba de esa persona la clave de la llamada | 16,071 palabras (el tope del sobre de entrada sellado) |
| segmento público | una vez, al desplegar | read_public(i) / rand_read_public(i) | todos; el id del programa lo vincula | 32,768 palabras |
| salidas | durante la ejecución | write_output(slot, w) / rand_write_output | todos, en el recibo | 8 palabras |
Las entradas privadas, por sí solas, no prueban nada sobre el mundo
Una entrada privada queda vinculada a la prueba mediante el compromiso con sal H_IN, de modo que no puede cambiar después de generar la prueba. Pero el programa no tiene forma de saber que la entrada es verdadera. Quien llama puede escribir cualquier número. Para que una entrada privada signifique algo, hay que atarla a algo público dentro del programa:
- un compromiso publicado antes (en el segmento público, o entregado al verificador por otra vía) que la entrada privada debe abrir, como en la subasta de abajo;
- una raíz de Merkle bajo la cual debe probarse una hoja privada (Poseidon2 es la syscall para esto);
- un digest que el verificador ya posee, que el programa recalcula y publica como salida.
Esta es la habilidad central de diseño para programas confidenciales. La prueba le dice al verificador qué se calculó. El vínculo con datos públicos le dice sobre qué.
Codificar datos como palabras
Todo viaja como u32:
| dato | codificación |
|---|---|
| una cadena de bytes | una palabra de longitud y luego los bytes, cuatro por palabra, little-endian, con la última palabra rellenada con ceros (la forma que usa la wallet para una entrada pública ELF) |
un u64 o un importe | dos palabras, primero la baja y luego la alta |
| un entero con signo | complemento a dos en una palabra |
| un resultado grande | su digest de Poseidon2: 8 palabras, que ocupa todas las salidas; o bien publicar unas pocas palabras de resumen y un compromiso más corto |
Las herramientas de línea de comandos toman las palabras de entrada en decimal: rand-guest run --input y rand call --input no aceptan 0x…. Un archivo de segmento público para rand program deploy --public acepta tanto decimal como hexadecimal 0x.
Da forma a las salidas
- La ranura 0 es una palabra de estado. Asigna un número a cada resultado, incluidos los fallos: una apertura que no coincide, demasiados elementos, una entrada fuera de rango. Un control fallido debe terminar igualmente de forma limpia con un estado, de modo que quien llama obtenga una prueba que diga qué salió mal.
- Cada ranura se escribe como máximo una vez. Una segunda escritura en la misma ranura es un trap (
DoubleWrite(0)en el emulador), y una ejecución que cae en un trap no puede probarse. Las ranuras que nunca escribes se leen como cero. - En Rust, deja libre la ranura 7. Un guest de Rust que entra en pánico escribe
0xdeadbeef(3,735,928,559) en la ranura 7 y termina. Las salidas que escribió antes del pánico se mantienen, así que un lector debe comprobar la ranura 7 antes de fiarse de las demás (sección 6). - Leer más allá de las entradas recibidas es un trap (
InputIndex(5)en el emulador). Lee primero el recuento y compruébalo.
3. El ejemplo completo: una subasta con pujas selladas
Un vendedor quiere realizar una subasta de segundo precio sin publicar las pujas perdedoras:
- Cada postor elige una puja y una sal aleatoria de 128 bits, y entrega al vendedor un compromiso: Poseidon2 sobre
("BID1", índice del postor, puja, sal). - El vendedor despliega el programa de la subasta con el precio de reserva y todos los compromisos como su segmento público. El id del programa nombra ahora esta subasta y nada más.
- Cada postor envía al vendedor su puja y su sal de forma privada.
- El vendedor llama al programa con todas las pujas y sales como entrada privada. El programa comprueba cada apertura contra su compromiso, elige al ganador y publica
[estado, ganador, precio, número de postores].
La chain sabe quién ganó y a qué precio. Nunca ve las pujas perdedoras. Un vendedor que modifique cualquier puja obtiene BAD_OPENING en lugar de un resultado, porque los compromisos están fijados en el id del programa.
Todo vive dentro del checkout de circuits, porque rand-guest build encuentra guest-sdk buscando hacia arriba desde el directorio del guest:
circuits/
guest-sdk/
rand-guest/
my-guests/
commit/ a Rust guest that computes one commitment
auction-core/ the auction rule as a plain no_std library, tested on the host
auction/ the Rust guest
auction-c/ the same guest in C
auction-c-test/ a host test for the C rule, outside the guest's directory
El emulador como calculadora de hashes
Los postores necesitan Poseidon2 sobre el campo Goldilocks, exactamente como lo calcula la máquina. La herramienta correcta más sencilla es un guest diminuto que lo calcule, ejecutado en el emulador. Un digest son ocho palabras, que es justo el número de ranuras de salida.
my-guests/commit/Cargo.toml:
[package]
name = "commit"
version = "0.1.0"
edition = "2021"
[dependencies]
guest-sdk = { path = "../../guest-sdk" }
[profile.release]
opt-level = 3
lto = true
panic = "abort"
codegen-units = 1
[workspace]
my-guests/commit/src/main.rs:
#![no_std]
#![no_main]
use guest_sdk::{halt, poseidon2, read_input, write_output};
/// Inputs: a bidder's index, bid and four salt words.
/// Outputs: the 8-word commitment Poseidon2("BID1", index, bid, salt[0..4]).
#[no_mangle]
pub extern "C" fn main() -> ! {
let mut buf = [0u32; 8];
buf[0] = 0x4249_4431; // "BID1"
for i in 0..6 {
buf[1 + i] = read_input(i as u32);
}
unsafe { poseidon2(buf.as_mut_ptr(), 7) };
for (slot, word) in buf.iter().enumerate() {
write_output(slot as u32, *word);
}
halt();
}
poseidon2(ptr, n) calcula el hash de n palabras in situ y escribe el digest de 8 palabras sobre ptr..ptr+8, de modo que el búfer debe tener al menos ocho palabras aunque solo se procesen siete. La etiqueta "BID1" mantiene estos digests separados de cualquier otro uso de Poseidon2.
En aeacf31, poseidon2 es una unsafe fn, porque recibe un puntero crudo, así que la llamada va dentro de un bloque unsafe. En 7ef3220, donde se produjeron las salidas de abajo, era una función segura y la misma línea se escribía sin el bloque. La esponja no aplica relleno: [a] y [a, 0] producen el mismo hash. Aquí es inofensivo, porque todos los mensajes tienen siete palabras y empiezan por una etiqueta. Para un mensaje cuya longitud pueda variar, el SDK en aeacf31 tiene poseidon2_len(buf, n), que vincula la longitud (syscall 7), pero rand-guest check en ese commit rechaza la syscall 7, de modo que un guest así todavía no compila (zkp-circuits issue #1). Incluye la longitud en el mensaje.
$ rand-guest build my-guests/commit --out commit.bin
76 words against a cap of 4096 (fits); 0 ecall(s) with a non-static a7
OK
wrote commit.bin and its .sha256 (76 words, hc 41007891b5c393296fdd76539c93d0b06678bc8a0bd52cfc3b6886dcf0381343, program id 11dabdf740989d43599b296c43342bf3ce890e980f5cc558d15ba8ca9e8e9438)
$ rand-guest run commit.bin --input 0 --input 400 --input 2654435769 --input 2135587861 --input 4087136352 --input 1559071514
out[0] = 3502385358
out[1] = 1187922325
out[2] = 2500412136
out[3] = 2452821097
out[4] = 2045657999
out[5] = 1866863173
out[6] = 678782893
out[7] = 3524569252
cycles 77
tier 10
Las líneas de cargo se omiten aquí y en las salidas de compilación siguientes. La advertencia unaligned-scalar-mem que imprime cargo es esperada (Deploy a Rust program).
Cuatro postores, con estas pujas y sales:
| ofertante | oferta | salt (cuatro palabras) |
|---|---|---|
| 0 | 400 | 2654435769 2135587861 4087136352 1559071514 |
| 1 | 250 | 461845907 3432918353 3864292196 2246822507 |
| 2 | 900 | 3266489909 668265263 374761393 3550635116 |
| 3 | 300 | 4252018373 3042594569 1759714724 625341585 |
Al ejecutar commit.bin una vez por ofertante y escribir la reserva (100), el recuento (4) y los cuatro resúmenes en orden, se obtiene el segmento público, auction-public.txt, de 34 palabras:
100 4 3502385358 1187922325 2500412136 2452821097 2045657999 1866863173 678782893 3524569252 3413403069 1515911705 3633767467 1218237979 474331206 896634170 2889783686 541804293 3111308722 99534202 1917186958 1017842926 3082732585 4215265588 467906960 2929273893 588931287 1295911076 647129560 1384102451 625140897 4060943258 2970944086 698558155
En una subasta real, cada ofertante ejecuta commit.bin en su propia máquina, y el salt proviene de una fuente de aleatoriedad real.
4. El programa en Rust
La regla, como una biblioteca que se puede probar
Mantenga la lógica que no necesita syscalls en su propio crate no_std. guest-sdk se niega a compilar para cualquier destino que no sea riscv32, por lo que un crate guest no puede ejecutar cargo test en su portátil. Un crate de biblioteca sin dependencia de guest-sdk sí puede.
my-guests/auction-core/Cargo.toml:
[package]
name = "auction-core"
version = "0.1.0"
edition = "2021"
[workspace]
my-guests/auction-core/src/lib.rs:
//! The auction's rule, with no syscalls, so it builds and tests on the host.
#![no_std]
#[derive(Debug, PartialEq, Eq)]
pub enum Outcome {
/// The highest bidder wins and pays the second-highest bid, or the reserve if higher.
Sold { winner: u32, price: u32 },
/// No bid reached the reserve.
NoSale,
}
/// A second-price (Vickrey) auction. Ties go to the lower index.
pub fn settle(reserve: u32, bids: &[u32]) -> Outcome {
let mut best: Option<(u32, u32)> = None; // (index, bid)
let mut second = 0u32;
for (i, &bid) in bids.iter().enumerate() {
match best {
Some((_, top)) if bid <= top => second = second.max(bid),
Some((_, top)) => {
second = top;
best = Some((i as u32, bid));
}
None => best = Some((i as u32, bid)),
}
}
match best {
Some((winner, top)) if top >= reserve => Outcome::Sold { winner, price: second.max(reserve) },
_ => Outcome::NoSale,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn highest_bid_pays_the_second() {
assert_eq!(settle(100, &[400, 250, 900, 300]), Outcome::Sold { winner: 2, price: 400 });
}
#[test]
fn the_reserve_sets_a_floor() {
assert_eq!(settle(500, &[400, 250, 900, 300]), Outcome::Sold { winner: 2, price: 500 });
assert_eq!(settle(1000, &[400, 250, 900, 300]), Outcome::NoSale);
}
#[test]
fn a_tie_goes_to_the_lower_index() {
assert_eq!(settle(0, &[700, 700]), Outcome::Sold { winner: 0, price: 700 });
}
#[test]
fn no_bids_is_no_sale() {
assert_eq!(settle(0, &[]), Outcome::NoSale);
}
}
$ cd my-guests/auction-core && cargo +1.98.1 test
running 4 tests
test tests::a_tie_goes_to_the_lower_index ... ok
test tests::highest_bid_pays_the_second ... ok
test tests::no_bids_is_no_sale ... ok
test tests::the_reserve_sets_a_floor ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
El guest
my-guests/auction/Cargo.toml depende de ambos crates por ruta:
[package]
name = "auction"
version = "0.1.0"
edition = "2021"
[dependencies]
guest-sdk = { path = "../../guest-sdk" }
auction-core = { path = "../auction-core" }
[profile.release]
opt-level = 3
lto = true
panic = "abort"
codegen-units = 1
[workspace]
my-guests/auction/src/main.rs:
#![no_std]
#![no_main]
use auction_core::{settle, Outcome};
use guest_sdk::{halt, poseidon2, read_input, read_public, write_output};
const MAX_BIDDERS: usize = 64;
const TAG: u32 = 0x4249_4431; // "BID1", the same tag the commit guest uses
// Output slot 0: what happened.
const SOLD: u32 = 1;
const NO_SALE: u32 = 2;
const BAD_OPENING: u32 = 3;
const TOO_MANY: u32 = 4;
/// Public (fixed at deploy): reserve, n, then n 8-word commitments.
/// Private (per call): for each bidder, the bid and four salt words.
/// Outputs: [status, winner, price, n, 0, 0, 0, 0]; a panic sets slot 7 to 0xdeadbeef.
#[no_mangle]
pub extern "C" fn main() -> ! {
let reserve = read_public(0);
let n = read_public(1) as usize;
if n > MAX_BIDDERS {
finish(TOO_MANY, 0, 0, n as u32);
}
let mut bids = [0u32; MAX_BIDDERS];
for i in 0..n {
let base = (5 * i) as u32;
let bid = read_input(base);
let mut buf = [TAG, i as u32, bid, read_input(base + 1), read_input(base + 2),
read_input(base + 3), read_input(base + 4), 0];
unsafe { poseidon2(buf.as_mut_ptr(), 7) };
let committed = 2 + 8 * i as u32;
for w in 0..8 {
if buf[w] != read_public(committed + w as u32) {
finish(BAD_OPENING, i as u32, 0, n as u32);
}
}
bids[i] = bid;
}
match settle(reserve, &bids[..n]) {
Outcome::Sold { winner, price } => finish(SOLD, winner, price, n as u32),
Outcome::NoSale => finish(NO_SALE, 0, 0, n as u32),
}
}
fn finish(status: u32, winner: u32, price: u32, n: u32) -> ! {
write_output(0, status);
write_output(1, winner);
write_output(2, price);
write_output(3, n);
halt();
}
Todas las rutas de salida pasan por finish, que escribe cada slot una vez y detiene la ejecución. El límite del bucle proviene del segmento público, que queda fijado en el despliegue. Por eso un llamador no puede hacer que el programa lea menos ofertas de las que se comprometieron.
Compilar y ejecutar
$ rand-guest build my-guests/auction --out auction.bin
258 words against a cap of 4096 (fits); 0 ecall(s) with a non-static a7
OK
wrote auction.bin and its .sha256 (258 words, hc aa04e4c451e986f086e9fcff53b213eb52e593317c32a979d5e24991164f050d, program id 148ab0c4b9ad5115caab4a2ab433e3e13b5e8fbecbd9d8a9d6ba20c64f942c73)
rand-guest run toma el segmento público como indicadores --public repetidos. En una shell:
PUB=$(for w in $(cat auction-public.txt); do printf -- "--public %s " "$w"; done)
BIDS="--input 400 --input 2654435769 --input 2135587861 --input 4087136352 --input 1559071514
--input 250 --input 461845907 --input 3432918353 --input 3864292196 --input 2246822507
--input 900 --input 3266489909 --input 668265263 --input 374761393 --input 3550635116
--input 300 --input 4252018373 --input 3042594569 --input 1759714724 --input 625341585"
rand-guest run auction.bin $PUB $BIDS --tier 10
out[0] = 1
out[1] = 2
out[2] = 400
out[3] = 4
out[4] = 0
out[5] = 0
out[6] = 0
out[7] = 0
cycles 641
tier 10
tier 10: fits (cycles 721 of 1023, Poseidon2 permutations 88 of 128)
Vendido (1) al ofertante 2 por 400, la segunda oferta más alta, con cuatro ofertantes contabilizados. Dos ejecuciones que deberían fallar:
| cambio | salidas 0–3 | significado |
|---|---|---|
| la oferta 250 del ofertante 1 sustituida por 260 | 3 1 0 0 | BAD_OPENING en el ofertante 1; no se publica nada más |
| reserva de 1,000 en lugar de 100 (un segmento público distinto, y por tanto un id de programa distinto) | 2 0 0 4 | NO_SALE: ninguna oferta alcanzó la reserva |
5. El mismo programa en C
Un directorio guest en C contiene archivos .c y sus propios encabezados. rand-guest build --lang c aporta guest.h, _start y un pequeño runtime (Deploy a RISC-V program). Compila todos los archivos .c del directorio, así que los archivos de prueba del host deben ir en otro lugar.
my-guests/auction-c/auction.h:
#ifndef AUCTION_H
#define AUCTION_H
#include <stdint.h>
/* Outcome of a second-price auction. sold is 0 when no bid reached the reserve. */
struct outcome {
uint32_t sold;
uint32_t winner;
uint32_t price;
};
struct outcome settle(uint32_t reserve, const uint32_t *bids, uint32_t n);
#endif
my-guests/auction-c/settle.c:
#include "auction.h"
/* A second-price (Vickrey) auction. Ties go to the lower index. */
struct outcome settle(uint32_t reserve, const uint32_t *bids, uint32_t n) {
struct outcome o = {0, 0, 0};
if (n == 0) {
return o;
}
uint32_t winner = 0, top = bids[0], second = 0;
for (uint32_t i = 1; i < n; i++) {
if (bids[i] > top) {
second = top;
top = bids[i];
winner = i;
} else if (bids[i] > second) {
second = bids[i];
}
}
if (top >= reserve) {
o.sold = 1;
o.winner = winner;
o.price = second > reserve ? second : reserve;
}
return o;
}
my-guests/auction-c/main.c:
#include "guest.h"
#include "auction.h"
#define MAX_BIDDERS 64
#define TAG 0x42494431u /* "BID1", the same tag the commit guest uses */
enum { SOLD = 1, NO_SALE = 2, BAD_OPENING = 3, TOO_MANY = 4 };
static uint32_t bids[MAX_BIDDERS];
static void finish(uint32_t status, uint32_t winner, uint32_t price, uint32_t n) {
rand_write_output(0, status);
rand_write_output(1, winner);
rand_write_output(2, price);
rand_write_output(3, n);
rand_halt();
}
/* Public (fixed at deploy): reserve, n, then n 8-word commitments.
* Private (per call): for each bidder, the bid and four salt words.
* Outputs: [status, winner, price, n, 0, 0, 0, 0]. */
void main(void) {
uint32_t reserve = rand_read_public(0);
uint32_t n = rand_read_public(1);
if (n > MAX_BIDDERS) {
finish(TOO_MANY, 0, 0, n);
}
for (uint32_t i = 0; i < n; i++) {
uint32_t base = 5 * i;
uint32_t buf[8] = {TAG, i, rand_read_input(base), rand_read_input(base + 1),
rand_read_input(base + 2), rand_read_input(base + 3),
rand_read_input(base + 4), 0};
bids[i] = buf[2];
rand_poseidon2(buf, 7);
for (uint32_t w = 0; w < 8; w++) {
if (buf[w] != rand_read_public(2 + 8 * i + w)) {
finish(BAD_OPENING, i, 0, n);
}
}
}
struct outcome o = settle(reserve, bids, n);
if (o.sold) {
finish(SOLD, o.winner, o.price, n);
}
finish(NO_SALE, 0, 0, n);
}
bids es un static inicializado a cero, por lo que va en .bss. Eso no cuesta palabras de imagen, porque la RAM ya se lee como cero.
Probar la regla en el host
settle.c no usa ninguna syscall, de modo que su compilador habitual puede compilarlo. my-guests/auction-c-test/test_settle.c:
#include <assert.h>
#include <stdio.h>
#include "../auction-c/auction.h"
int main(void) {
uint32_t bids[] = {400, 250, 900, 300};
struct outcome o = settle(100, bids, 4);
assert(o.sold && o.winner == 2 && o.price == 400);
o = settle(500, bids, 4);
assert(o.sold && o.price == 500);
o = settle(1000, bids, 4);
assert(!o.sold);
uint32_t tie[] = {700, 700};
o = settle(0, tie, 2);
assert(o.winner == 0 && o.price == 700);
puts("settle: 4 checks passed");
return 0;
}
$ cc -Wall -o test_settle my-guests/auction-c-test/test_settle.c my-guests/auction-c/settle.c && ./test_settle
settle: 4 checks passed
Compilar y ejecutar
$ rand-guest build --lang c my-guests/auction-c --out auction-c.bin
200 words against a cap of 4096 (fits); 0 ecall(s) with a non-static a7
OK
wrote auction-c.bin and its .sha256 (200 words, hc 68605aa8d28a863a4a52a25de07ba159327a0fd0145a9dcd5e5aa6e79ab6bea0, program id 5667c343b8848805a908c9009d426164f39d2f927b2c780529a054f0d66aafc1)
$ rand-guest run auction-c.bin $PUB $BIDS --tier 10
out[0] = 1
out[1] = 2
out[2] = 400
out[3] = 4
out[4] = 0
out[5] = 0
out[6] = 0
out[7] = 0
cycles 576
tier 10
tier 10: fits (cycles 641 of 1023, Poseidon2 permutations 73 of 128)
Las salidas coinciden con las del guest en Rust. Con cuatro ofertantes, la imagen en C es 58 palabras más pequeña y 65 ciclos más rápida; la sección 7 muestra que el equilibrio se invierte con 64 ofertantes. Son programas distintos, con hc e ids de programa diferentes. Despliegue el que publique con su código fuente.
6. Notas por lenguaje
Rust
El crate. #![no_std], #![no_main] y un #[no_mangle] pub extern "C" fn main() -> ! que termina en halt(). El perfil release establece panic = "abort". Una tabla [workspace] vacía mantiene el crate fuera de cualquier workspace superior. Omita .cargo/config.toml y sus propios indicadores del compilador: rand-guest build rechaza el primero y establece todos los segundos (Deploy a Rust program).
Panics. El manejador de panics de guest-sdk escribe 0xdeadbeef en el slot 7 y detiene la ejecución. La ejecución sigue siendo válida y demostrable. Este programa lee el índice 999 de un Vec de seis elementos después de escribir los slots 0 y 1:
$ rand-guest run heap.bin --input 6 --input 1 --input 2 --input 3 --input 4 --input 5 --input 6
out[0] = 4
out[1] = 1080033280
out[2] = 0
out[3] = 0
out[4] = 0
out[5] = 0
out[6] = 0
out[7] = 3735928559
cycles 1326
tier 12
Los slots 0 y 1 contienen valores reales, y el slot 7 indica que la ejecución entró en pánico. Un llamador que envíe una ejecución así obtiene un recibo en la cadena. Quien lea los recibos debe rechazar el slot 7 = 3,735,928,559, o bien el programa nunca debe entrar en pánico. Prefiera get(), la aritmética comprobada y códigos de estado explícitos en cada ruta.
Un heap. No hay un asignador por defecto. alloc funciona en cuanto se proporciona uno. Un asignador bump sobre una arena fija es adecuado para un programa que se ejecuta una vez y termina:
extern crate alloc;
use alloc::vec::Vec;
use core::alloc::{GlobalAlloc, Layout};
use core::cell::UnsafeCell;
/// A bump allocator over a fixed arena: allocation moves a pointer forward, free does nothing.
/// One run is one short-lived process, so nothing needs to be reclaimed.
struct Bump {
arena: UnsafeCell<[u8; ARENA]>,
next: UnsafeCell<usize>,
}
const ARENA: usize = 256 * 1024;
unsafe impl Sync for Bump {}
unsafe impl GlobalAlloc for Bump {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let next = &mut *self.next.get();
let start = (*next + layout.align() - 1) & !(layout.align() - 1);
let end = start + layout.size();
if end > ARENA {
return core::ptr::null_mut();
}
*next = end;
(self.arena.get() as *mut u8).add(start)
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {}
}
#[global_allocator]
static HEAP: Bump = Bump { arena: UnsafeCell::new([0; ARENA]), next: UnsafeCell::new(0) };
La arena está puesta a cero, por lo que reside en .bss y no añade palabras a la imagen. El linker script por defecto ofrece 1 MiB de RAM, y la arena, sus statics y la pila de 64 KiB deben caber en ella. Con este asignador, Vec, collect() e iter().sum() compilan y pasan check. Sin embargo, alloc y sus dependencias añaden código: este programa de prueba tiene 1,341 palabras, frente a 258 de la subasta. La sección 7 explica por qué el tamaño del programa cuesta presupuesto de prueba.
Coma flotante. El destino no tiene instrucciones de coma flotante, y Rust reduce f32/f64 a rutinas de software. Funcionan: la media de 9, 2, 7, 4 y 5 calculada en f64 devolvió los bits de 5.4f32. Cuestan muchos ciclos cada una. Use enteros o coma fija cuando pueda.
Crates. Los crates no_std de crates.io se compilan como cualquier dependencia. El CRC-32 de hello, rand con el crate crc, ejecutado en el emulador, imprimió 2124296908, el mismo valor que da zlib.crc32 de Python. Los crates que necesitan std no se compilan para este destino. rand-guest build no pasa --locked, así que haga commit de su Cargo.lock: sin él, una recompilación meses después puede resolver un crate más reciente y producir un hc distinto.
La trampa unimp: un hallazgo Csr procedente de código de biblioteca. Algunos fragmentos de core se compilan a una palabra unimp (0xc0001073, csrrw x0, cycle, x0) en una ruta que LLVM considera inalcanzable. La máquina no tiene CSR, así que check rechaza la imagen:
0x00002250 0xc0001073 Csr: CSR instruction: the machine has no CSRs
2613 words against a cap of 4096 (fits); 0 ecall(s) with a non-static a7
REJECTED
wrote heap.bin (the loader refuses it: no hc)
El culpable aquí fue slice::sort_unstable sobre un Vec cuya longitud solo se conoce en tiempo de ejecución. sort() estable hizo lo mismo, mientras que sort_unstable sobre un array fijo de 16 elementos pasó. Para encontrar el origen de un hallazgo así, desensamble el ELF del guest y examine la función alrededor de la dirección que imprimió check:
llvm-objdump -d -C my-guests/heap/target/riscv32im-unknown-none-elf/release/heap | grep -B12 unimp
Después sustituya esa llamada por código propio (en este programa, una breve ordenación por inserción). El propio halt() de guest-sdk termina en loop {} en lugar de una promesa noreturn por el mismo motivo.
C
- El punto de entrada es
void main(void), y termina enrand_halt(). El_startincluido establece la pila y lo invoca. - No hay libc. No hay
printf,mallocni<string.h>. El runtime proporcionamemcpy,memmove,memsetymemcmp(clang emite llamadas a ellas para copias de structs e inicializadores grandes) y las cuatro rutinas auxiliares de división de 64 bits. Todas son bucles por bytes, así que copie y divida por palabras en el código crítico. Para un heap, escriba un asignador bump sobre un arraystatic, como en Rust. - El comportamiento indefinido no se detecta. No hay manejador de panics. Una escritura fuera de límites corrompe la memoria en silencio, y una carga o almacenamiento desalineado provoca una trampa, lo que significa que la ejecución no se puede demostrar. Compruebe los índices contra sus propios límites, como
main.ccompruebanfrente aMAX_BIDDERS. - Reglas del directorio. No añada su propio
guest.hnistart.S;build --lang crechaza ambos. Mantenga los archivos de prueba fuera del directorio guest, ya que todos los archivos.cde este se compilan en la imagen. - clang fijado.
rand-guestrechaza cualquier clang distinto de 23.1.1.RAND_GUEST_CLANG_UNPINNED=1lo anula, con una advertencia de que suhcno coincidirá con la compilación de nadie más.
7. ¿Se podrá demostrar? El presupuesto
El prover rellena cada ejecución hasta un nivel t. Un nivel tiene dos presupuestos independientes, y rand-guest run --tier t informa de ambos:
- filas: como máximo
2ᵗ − 1, contando los ciclos ejecutados más las filas de resumen; - permutaciones de Poseidon2: como máximo
2ᵗ⁻³.
| nivel | filas | permutaciones de Poseidon2 | techo de gas sin tabla de hash | una llamada en la chain 20 |
|---|---|---|---|---|
| 10 | 1,023 | 128 | 1,279 | aceptada |
| 12 | 4,095 | 512 | 5,119 | aceptada |
| 14 | 16,383 | 2,048 | 20,479 | aceptada |
| 16 | 65,535 | 8,192 | 81,919 | rechazada desde v0.5.6 |
| 18, 20 | 2¹⁸ − 1, 2²⁰ − 1 | 2¹⁵, 2¹⁷ | 327,679, 1,310,719 | rechazada desde v0.5.6 |
La comisión depende del gas y de los bytes, no del nivel. En la chain 15, una llamada pagaba una tarifa fija de 0.001 + call_fee(tier): 0.002 RAND en el nivel 10, 0.0021 en el nivel 12 y 0.0022 en el nivel 14. Desde la chain 18, cada ejecución se mide. Una fila ordinaria es 1 de gas, una fila de absorción de Poseidon2 son 3, una permutación de Keccak son 192 y una compresión de SHA-256 son 64. La prueba publica un límite de gas declarado, el circuito exige que el gas de la ejecución sea igual o inferior a él, y el gas exacto permanece privado. El techo que un nivel puede declarar sin tabla de hash es (2ᵗ − 1) + 2ᵗ⁻², la columna anterior. El mínimo de la comisión es
floor = BUNDLE_BASE + gas_price × declared gas limit + byte_price × ⌈bytes / 1024⌉
con BUNDLE_BASE de 0.001 RAND, siendo los bytes la prueba de la llamada más el sobre de entrada, y con precios que partieron de 100 unidades por gas y 800 unidades por KiB (una unidad es 10⁻⁹ RAND) y se mueven un poco en cada bloque. En la chain 20 no pueden superar 10,000 ni 80,000 unidades, y solo los bytes de prueba y de sobre de las llamadas mueven el precio por byte; en las chains 18 y 19 no había techo. La sección 9 muestra lo que paga la wallet por defecto, y Example programs recoge cuatro llamadas medidas en la chain 18, de 0.0057 a 0.0075 RAND cada una.
El nivel 14 es el más alto que puede usar una llamada. Desde fullnode v0.5.6 (2026-09-25), un nodo rechaza con CallTierTooHigh cualquier prueba de llamada por encima del nivel 14, antes de construir una clave del verificador. La misma regla invalida un bloque que incluya una llamada así. El límite protege a los validadores con 2–4 GB de memoria: construir la clave del verificador de un nivel 20 toma minutos y gigabytes. rand call no rechaza una llamada así antes de generar la prueba, aunque en la chain 20 imprime primero el nivel. Ejecute rand-guest run … --tier 14 antes de llamar, y si la ejecución no cabe, reestructure el programa. Antes de un despliegue, rand program deploy --input <word> … (o --check-call para una llamada sin entradas) ejecuta en el emulador la llamada que usted tiene prevista y se niega a desplegar a menos que quede en el nivel 14 o por debajo.
Qué consume el presupuesto
Las filas de digest y las permutaciones son la sorpresa. Antes de que se ejecute su código, la máquina hashea el programa, las entradas privadas y el segmento público en hc, H_IN y H_PUB. Cada uno cuesta aproximadamente una permutación y una fila por cada cuatro palabras. Además, cada llamada a POSEIDON2 que usted haga cuesta aproximadamente una permutación por cada cuatro palabras hasheadas. Para la subasta en el nivel 10:
| elemento | palabras | permutaciones |
|---|---|---|
el programa (hc) | 258 | unas 65 |
las entradas privadas (H_IN) | 20 | unas 5 |
el segmento público (H_PUB) | 34 | unas 9 |
| cuatro verificaciones de compromiso, 7 palabras cada una | 4 × 7 | 8 |
total, según lo informa run | 88 de 128 |
Filas: 641 ciclos más las filas de digest sumaron 721 de 1.023.
En la chain 20, igual que en la chain 19, el recuento de la propia wallet es ligeramente superior al de rand-guest run. Una prueba de llamada allí está vinculada a su transacción (en la chain 20 el vínculo incluye además el hash del génesis, con las mismas ocho palabras), y el vínculo viaja como ocho palabras más de segmento público: dos filas de digest y dos permutaciones más para un programa con segmento público, y una más de cada una para un programa sin él.
Esto hace que el tamaño del programa forme parte del presupuesto. El programa de prueba del heap ejecutó solo 1.183 ciclos, pero sus 1.341 palabras requirieron 340 permutaciones y lo llevaron al nivel 12. Con las 2.048 permutaciones del nivel 14, un programa de bastante más de unas 8.000 palabras no puede llamarse en absoluto, haga lo que haga. En la chain 20 el límite exacto es de 8.180 palabras para un programa sin segmento público y una llamada sin entradas, y menos con cualquiera de los dos. El tope de programa en la chain 20 es de 65.535 palabras, igual que en la chain 15, pero las chains 19 y 20 se niegan a desplegar un programa que supere ese límite (ProgramUncallable), mientras que la chain 15 cobraba la comisión de despliegue por un programa que nadie podía llamar. Por eso el ERC-20 traducido de la chain 13 (11.686 palabras, llamado en el nivel 16) no puede desplegarse ni llamarse en la chain 20. Mantenga los programas pequeños: opt-level = "s" en Rust, -Os (ya configurado) en C, pocas dependencias, y no use alloc salvo que lo necesite.
La subasta crece de forma lineal: cada postor adicional añade 5 palabras de entrada, 8 palabras públicas, 2 permutaciones para la verificación y unos cien ciclos. Con 64 postores (ofertas aleatorias, 320 palabras de entrada, 514 palabras públicas), ambas compilaciones eligen al mismo ganador y precio, y ambas necesitan el nivel 14:
auction.bin tier 14: fits (cycles 6557 of 16383, Poseidon2 permutations 403 of 2048)
auction-c.bin tier 14: fits (cycles 8571 of 16383, Poseidon2 permutations 388 of 2048)
Las permutaciones cabrían en las 512 del nivel 12, pero las filas no caben en sus 4.095. Aquí son los ciclos, y no el hashing, los que fijan el nivel. Con 64 postores la compilación en Rust es la más rápida. Su opt-level = 3 supera al -Os fijo de la compilación en C una vez que el bucle domina.
Keccak y SHA-256
keccak y sha256_compress cuestan cada una una fila, pero una prueba que use cualquiera de las dos lleva una tabla adicional. En aeacf31 ambas reciben una referencia a un arreglo, keccak(&mut state) sobre un [u32; 50] y sha256_compress(&mut buf) sobre un [u32; 24], mientras que 7ef3220 recibía un puntero sin procesar. Desde v0.5.6 una llamada puede usar como máximo 128 permutaciones Keccak-f y como máximo 128 compresiones SHA-256. En la chain 20 una permutación Keccak cuesta además 192 de gas y una compresión SHA-256 64.
La tabla hace más grande la prueba, y la chain 20 limita una prueba de llamada a 4.194.304 bytes (4 MiB, como en las chains 17 a 19; el límite era de 8 MiB en las chains 13 a 16). En la chain 18, una llamada de nivel 12 con la tabla Keccak midió 3,35 MB, la mayor parte del límite, y una llamada de nivel 14 con la tabla SHA-256 midió 1,91 MB, frente a 1,50 MB con ninguna de las dos (Example programs). Cada KiB se cobra al precio por byte. En la chain 15 solo se cobraban los bytes que superaban unos 2 MiB + 18.432 gratuitos, a 1.000 unidades por KiB (Fees). Use Poseidon2 salvo que deba coincidir con un hash externo.
Memoria y tiempo de prueba
El tiempo y la memoria de prueba crecen de forma pronunciada con el nivel. Una llamada de nivel 10 se prueba en segundos en un portátil. En la chain 20, cada despliegue y cada llamada lleva además dos pruebas del lado del bundle: una autorización de gasto de nivel 10 (segundos) y el bundle de nivel 14 que paga la comisión (unos dos minutos en un portátil). Las cuatro llamadas medidas en la chain 18 tardaron entre tres y cinco minutos y medio cada una y alcanzaron picos de 6,1 a 6,6 GB (Example programs). En la chain 15 el bundle era una única prueba de nivel 14 de unos 100 s y unos 5,7 GB. Validator and aggregator hardware recoge las cifras medidas.
8. Deploy
La testnet es la chain 20. Para desplegar se necesita una wallet rand de fullnode v0.6.8 o posterior, una clave y algo de RAND (Introduction; el faucet de la chain 20, igual que el de la chain 19, solo acuña a las 16 wallets que su génesis incluye en la lista de permitidos). Una wallet v0.6.7 no puede enviar en la chain 20: el génesis de la chain 20 fija binding_domain: 1, de modo que cada vínculo de prueba y cada mensaje firmado incluye el hash del génesis, que una wallet v0.6.7 no incorpora. El endpoint público https://rpc.randprotocol.org basta para sincronizar la wallet, desplegar y llamar (setup). La subasta se despliega con su segmento público:
rand fee deploy 258 --public-words 34 # 0.001 + 0.0001 × (258 + 34) = 0.0302 RAND
rand program deploy auction.bin --public auction-public.txt
program deploy imprime el id del programa, hc, el número de palabras, y el número de palabras y el digest de la entrada pública. Comprueba el programa frente al tope de la chain y la entrada pública frente a max_program_public_words (32.768 en la chain 20) antes de generar ninguna prueba. Después prueba un bundle de autotransferencia de valor cero que paga la comisión, lo envía y espera la confirmación. En la chain 20, como desde la chain 17, son dos pruebas, cada una con su propia línea de salida: una autorización de gasto de nivel 10 (segundos, unos 1,37 MB) y el bundle de nivel 14 (unos dos minutos, unos 1,5 MB). En la chain 15 era una sola prueba de nivel 14 de unos 100 s y unos 1,42 MB. La comisión de despliegue no tiene término de gas. La subasta no se ha desplegado en las chains 18 a 20; An anonymous vote muestra un despliegue con segmento público en la chain 18 tal como lo imprimió la wallet.
El id del programa cambia con --public. rand-guest imprimió 148ab0c4…2c73 solo para el código. Un despliegue con segmento público se almacena bajo blake3("rand-program-2", base_pc ‖ len ‖ words ‖ len ‖ public), que vincula también la reserva y los cuatro compromisos. Use el id que imprime program deploy. Una segunda subasta con el mismo código y compromisos distintos es un id de programa distinto.
rand program show <auction id> # base_pc, words_len, code_hash, deployed_at, public_words_len, public_digest
code_hash es el hc que imprimió rand-guest, escrito de otra forma. rand-guest imprime cada una de las ocho palabras del digest en big-endian, y la wallet, rand program show y rand_getProgram imprimen los bytes de cada palabra en little-endian, de modo que cada grupo de ocho dígitos hexadecimales tiene los bytes invertidos. Para las compilaciones anteriores, aa04e4c4…050d (Rust) aparece como c4e404aa…4f16, y 68605aa8…bea0 (C) como a85a6068…b69a.
9. Call
El vendedor llama con las 20 palabras privadas:
rand call <auction id> --expect-public auction-public.txt \
--input 400 --input 2654435769 --input 2135587861 --input 4087136352 --input 1559071514 \
--input 250 --input 461845907 --input 3432918353 --input 3864292196 --input 2246822507 \
--input 900 --input 3266489909 --input 668265263 --input 374761393 --input 3550635116 \
--input 300 --input 4252018373 --input 3042594569 --input 1759714724 --input 625341585
La wallet obtiene el código y el segmento público del programa y comprueba que su hash coincida con el id. --expect-public también se niega, antes de generar la prueba, si el segmento público no son las palabras de ese archivo. A continuación la wallet ejecuta la llamada en el emulador e imprime el gas exacto, el límite que declarará, el nivel (el más pequeño en el que cabe la ejecución, aquí el nivel 10) y la comisión:
gas: <exact> (declaring <limit>, tier <t>)
fee <amount> RAND (incl. two price steps of headroom)
Sella las 20 palabras de entrada en un sobre que solo el llamante puede abrir, prueba la ejecución localmente, paga la comisión mediante un bundle, lo envía, espera e imprime el recibo. En la chain 20 son tres pruebas: la llamada, la autorización de gasto de nivel 10 y el bundle de nivel 14.
Lo que cuesta la llamada. En la chain 15 esta llamada pagó 0.001 + call_fee(10) = 0,002 RAND. En la chain 20 la wallet paga el mínimo de la sección 7, calculado antes de que exista la prueba, con tres valores por defecto:
- El límite declarado es el gas exacto redondeado hacia arriba a un múltiplo de
2^(tier − 2): 256 en el tier 10, 1,024 en el tier 12, 4,096 en el tier 14.--gas-limit maxdeclara el techo del tier y--gas-limit Nuna cifra exacta; - la prueba se tarifa al tope de la cadena de 4,194,304 bytes, no a su tamaño real, porque una prueba de llamada en la chain 20 queda vinculada a su transacción y la comisión debe fijarse primero;
- dos escalones de precio de margen, un factor de 1.125², porque la llamada se incluye dos o tres bloques después de que se fija su precio.
--fee reemplaza el valor por defecto, y rand fee call <tier> --gas <limit> --bytes <B> consulta al nodo por el mínimo. La llamada de la subasta no se ha realizado en las chains 18 a 20, de modo que su línea de gas y su comisión no se muestran aquí. Una votación anónima tiene una llamada completa en la chain 18 tal como la imprimió la wallet, con 0.0062 RAND.
El recibo incluye el tier, H_IN, H_PUB y las salidas [1, 2, 400, 4, 0, 0, 0, 0]: las mismas palabras que imprimió el emulador, porque la prueba corresponde a esa misma ejecución. Las cuatro ofertas y sales no están en la cadena. --auditor <rand1…> también sella las entradas para un auditor designado, y --no-envelope no publica ninguna transcripción, lo cual no se puede deshacer (Programs).
10. Comprobar el resultado
Un postor, o cualquier otra persona, puede comprobar la subasta sin confiar en el vendedor:
- El código. Recompile el código fuente publicado con
rand-guest builden el mismo commit de circuits, con Rust 1.98.1 (o clang 23.1.1). Comparehccon elcode_hashderand program showorand_getProgram, teniendo en cuenta la grafía con los bytes invertidos (sección 8). La compilación es reproducible, de modo que ambos coinciden en cualquier máquina. - El segmento público. Obténgalo con
rand_getProgramPublic, o ejecuterand call --expect-public. Compruebe que su propio compromiso está en su índice. - El recibo.
rand receipt <tx hash>orand_getReceipt: estado1, el ganador y el precio, la ranura 7 distinta de0xdeadbeef, yprogramigual al id de la subasta. - Las entradas, si tiene derecho a ellas. El
rand open-call <tx hash>del vendedor (o el--as-auditorde un auditor) abre las entradas selladas, las compara conH_IN, vuelve a ejecutar el programa y termina con un código distinto de cero ante cualquier discrepancia. Use una wallet v0.6.8: desde la v0.6.8 la reejecución se hace sobre el segmento público del programa y el vínculo de la llamada, por lo que funciona para la subasta (issue #116 de fullnode). En la v0.6.7,open-callse detenía conre-running the program on these inputs failed: PublicIndex(0)para cualquier programa desplegado con un segmento público.
La prueba garantiza que la regla se aplicó a aperturas de las ofertas comprometidas. No mueve dinero. El ganador paga al vendedor con un rand send ordinario, y el recibo es a lo que ambas partes remiten.
Solución de problemas
| síntoma | causa y solución |
|---|---|
la compilación falla en una llamada a poseidon2(…), keccak(…) o sha256_compress(…) escrita para un commit anterior | en aeacf31 poseidon2 es una unsafe fn y las otras dos reciben &mut [u32; 50] y &mut [u32; 24] (secciones 3 y 7) |
check informa Syscall con a7 = 7 | el guest llama a poseidon2_len; rand-guest check en aeacf31 no incluye la syscall 7 en su lista (issue #1 de zkp-circuits) |
check informa Csr en 0xc0001073 | el código de biblioteca se compiló a unimp; encuentre la función con llvm-objdump y reemplace la llamada (sección 6) |
run indica que la ejecución no cabe en ningún tier, o que necesita más del tier 14 | demasiadas filas o permutaciones de Poseidon2; reduzca el programa, haga menos hashing o divida el trabajo en varias llamadas, pasando un digest entre ellas |
| ranura 7 = 3735928559 | el guest de Rust entró en pánico; las salidas anteriores no son fiables |
trap: DoubleWrite(n) | la ranura de salida n se escribió dos veces; haga que toda salida pase por una sola función |
trap: InputIndex(i) | el programa leyó una entrada privada que el llamante no proporcionó; lea y compruebe primero un contador |
trap: … en una carga o un almacenamiento | una dirección desalineada o fuera de rango; en C, un índice de arreglo más allá de su límite |
invalid value '0x…' for '--input' | las palabras de entrada son decimales en la línea de comandos |
una llamada rechazada con CallTierTooHigh | la prueba está por encima del tier 14; compruebe con rand-guest run --tier 14 antes de llamar |
el hc recompilado difiere del desplegado | commit de circuits, toolchain o versiones de dependencias distintos; fije los tres y haga commit de Cargo.lock. Si solo difiere la grafía, cada grupo de ocho dígitos hexadecimales está con los bytes invertidos (sección 8) |
rand open-call se detiene con PublicIndex(0) | el programa tiene un segmento público y la wallet es v0.6.7, cuya reejecución no pasa ninguno (issue #116 de fullnode); use v0.6.8 |
… is not inside a circuits checkout | mueva el directorio del guest dentro del checkout (Deploy a Rust program) |