Эта страница охватывает всё, что нужно, чтобы запустить собственную программу в Rand: чем может быть программа, как спроектировать то, что она читает и публикует, а также полный пример, написанный дважды: на Rust и на C. Затем рассматриваются тестирование, бюджет доказательства, развёртывание, вызов и то, как любой может проверить результат. Каждый вывод rand-guest ниже получен в реальном запуске и вставлен в том виде, в каком был напечатан. Запуски выполнялись 2026-09-25 на коммите circuits 7ef3220. Сейчас testnet работает на chain 20; её инструментарий, как и у chain 18 и 19, собран на aeacf31 (ветка feat/v067-circuits; fullnode v0.6.8 закрепляет тот же коммит), и выводы там заново не запускались. Страница указывает, где SDK или цепочка отличаются.
Два коротких руководства — быстрый путь: Deploy a Rust program и Deploy a RISC-V program. Эта страница предполагает, что вы выполнили шаг 1 руководства по Rust: есть checkout randprotocol/zkp-circuits на aeacf31, Rust 1.98.1 с целью riscv32im-unknown-none-elf, бинарный файл rand-guest, а для C — clang 23.1.1 и llvm-tools. The RISC-V ISA — справочник по самой машине. Example programs продолжает с более крупными программами и тем, что изменил учёт газа в chain 18. Эта страница посвящена программам RPL-1, которые Call выполняет без состояния; в chain 20 есть также состояние программ RPL-2, ячейки и хранилище, изменяемые через Invoke (Programs), с полными примерами в randprotocol/example-programs.
1. Чем может быть программа
Программа — это любое вычисление, которое укладывается в такую форму:
- Оно читает на вход 32-битные слова.
- Оно публикует на выход не более восьми 32-битных слов.
- Оно детерминировано: одни и те же входные данные всегда дают одни и те же выходные.
- Оно завершается в пределах бюджета своего уровня (раздел 7).
В этих границах можно написать что угодно. Доступны циклы, рекурсия, структуры, массивы, 64-битная арифметика, крейты и куча, если подключить аллокатор. Но машина — это голая RV32IM, и под ней нет операционной системы:
| есть | нет |
|---|---|
целочисленные инструкции RV32IM, включая MUL/DIV/REM | аппаратной поддержки чисел с плавающей запятой (программные числа Rust работают, но медленно) |
до 1 MiB ОЗУ начиная с 0x1000 в скрипте компоновщика по умолчанию, со стеком 64 KiB | кучи, если не предоставить аллокатор (раздел 6) |
| Poseidon2, Keccak-f и SHA-256 как системные вызовы | файлов, сети, часов, случайности, потоков, std, libc |
крейты Rust с no_std и самодостаточный C | состояния, сохраняющегося от одного вызова к другому |
| восемь публичных выходных слов | способа переместить RAND или токены изнутри программы |
Последняя строка важнее всего при проектировании программы. Выходные данные — это данные, а не эффекты. Вызов доказывает, что программа с заданным дайджестом выполнилась на некоторых приватных входных данных и опубликовала восемь слов. Цепочка проверяет доказательство и сохраняет эти восемь слов в квитанции. Она не меняет ни одного баланса и ничего не запускает. Что означают эти слова, договариваются между собой программа, её вызывающий и тот, кто читает квитанцию. Programs перечисляет то, чего цепочка пока не поддерживает.
2. Сначала спроектируйте интерфейс
У программы три канала. Выберите, что пойдёт по каждому, прежде чем писать код.
| канал | когда задаётся | чем читается | кто видит | лимит в chain 20 |
|---|---|---|---|---|
| приватный вход | при каждом вызове | read_input(i) / rand_read_input(i) | только вызывающий и те, кому он передаст ключ вызова | 16,071 слов (предел запечатанного конверта входных данных) |
| публичный сегмент | один раз, при развёртывании | read_public(i) / rand_read_public(i) | все; идентификатор программы связывает его | 32,768 слов |
| выходные данные | по ходу выполнения | write_output(slot, w) / rand_write_output | все, в квитанции | 8 слов |
Приватные входные данные сами по себе ничего не доказывают о мире
Приватный вход привязан к доказательству солёным обязательством H_IN, поэтому после доказательства он измениться не может. Но у программы нет способа узнать, что вход истинен. Вызывающий может ввести любое число. Чтобы приватный вход что-то значил, свяжите его внутри программы с чем-то публичным:
- с обязательством, опубликованным ранее (в публичном сегменте или переданным верификатору вне канала), которое приватный вход должен раскрыть, как в аукционе ниже;
- с корнем Меркла, под которым должен быть доказан приватный лист (для этого служит системный вызов Poseidon2);
- с дайджестом, который верификатор уже имеет, а программа пересчитывает его и публикует как выходное значение.
Это центральный навык проектирования конфиденциальных программ. Доказательство сообщает верификатору, что было вычислено. Привязка к публичным данным сообщает, над чем.
Кодирование данных словами
Всё передаётся как u32:
| данные | кодирование |
|---|---|
| байтовая строка | слово длины, затем байты по четыре в слове, little-endian, последнее слово дополнено нулями (форма, которую кошелёк использует для публичного входа ELF) |
u64 или сумма | два слова, сначала младшее, затем старшее |
| знаковое целое | дополнительный код в одном слове |
| крупный результат | его дайджест Poseidon2: 8 слов, что занимает все выходные слоты; либо опубликуйте несколько итоговых слов и более короткое обязательство |
Инструменты командной строки принимают входные слова в десятичном виде: rand-guest run --input и rand call --input не принимают 0x…. Файл публичного сегмента для rand program deploy --public принимает и десятичную запись, и шестнадцатеричную с 0x.
Формирование выходных данных
- Слот 0 — слово статуса. Дайте номер каждому исходу, включая сбои: раскрытие не совпадает, слишком много элементов, вход вне диапазона. Неудавшаяся проверка всё равно должна чисто завершаться со статусом, чтобы вызывающий получил доказательство, говорящее, что пошло не так.
- Каждый слот записывается не более одного раза. Вторая запись в тот же слот — это ловушка (
DoubleWrite(0)в эмуляторе), а выполнение, попавшее в ловушку, доказать нельзя. Слоты, которые вы не записывали, читаются как ноль. - В Rust оставьте слот 7 свободным. Паникующий гость на Rust записывает
0xdeadbeef(3,735,928,559) в слот 7 и завершается. Выходные данные, записанные до паники, остаются на месте, поэтому читатель должен проверить слот 7, прежде чем доверять остальным (раздел 6). - Чтение за пределами выданных вам входных данных — ловушка (
InputIndex(5)в эмуляторе). Сначала прочитайте счётчик и проверьте его.
3. Разобранный пример: аукцион с запечатанными ставками
Продавец хочет провести аукцион второй цены, не публикуя проигравшие ставки:
- Каждый участник выбирает ставку и случайную 128-битную соль и передаёт продавцу обязательство: Poseidon2 от
("BID1", индекс участника, ставка, соль). - Продавец развёртывает программу аукциона, передав резервную цену и все обязательства как её публичный сегмент. Теперь идентификатор программы называет именно этот аукцион и никакой другой.
- Каждый участник приватно отправляет продавцу свою ставку и соль.
- Продавец вызывает программу, передавая все ставки и соли как приватный вход. Программа сверяет каждое раскрытие с его обязательством, выбирает победителя и публикует
[status, winner, price, bidder count].
Цепочка узнаёт, кто победил и по какой цене. Проигравших ставок она не видит никогда. Продавец, изменивший любую ставку, получает BAD_OPENING вместо результата, потому что обязательства зафиксированы в идентификаторе программы.
Всё находится внутри checkout circuits, потому что rand-guest build находит guest-sdk, поднимаясь вверх от каталога гостя:
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
Эмулятор как калькулятор хэшей
Участникам нужен Poseidon2 над полем Goldilocks в точности так, как его вычисляет машина. Самый простой верный инструмент — крошечный гость, который его вычисляет, запущенный на эмуляторе. Дайджест — это восемь слов, то есть ровно столько, сколько выходных слотов.
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) хэширует n слов на месте и записывает 8-словный дайджест поверх ptr..ptr+8, поэтому буфер должен вмещать не менее восьми слов, хотя хэшируются только семь. Тег "BID1" отделяет эти дайджесты от любого другого использования Poseidon2.
В aeacf31 poseidon2 — это unsafe fn, потому что принимает сырой указатель, поэтому вызов находится в блоке unsafe. В 7ef3220, где получены приведённые ниже выводы, это была безопасная функция, и та же строка записывалась без блока. Губка не выполняет дополнение: [a] и [a, 0] хэшируются одинаково. Здесь это безвредно, поскольку каждое сообщение состоит из семи слов и начинается с тега. Для сообщения переменной длины в SDK на aeacf31 есть poseidon2_len(buf, n), который связывает длину (системный вызов 7), но rand-guest check на этом коммите отвергает системный вызов 7, поэтому такой гость пока не собирается (zkp-circuits issue #1). Поместите длину в само сообщение.
$ 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
Строки cargo опущены в выводах сборки здесь и ниже. Предупреждение unaligned-scalar-mem, которое печатает cargo, ожидаемо (Deploy a Rust program).
Четыре участника, со следующими ставками и солями:
| участник | ставка | соль (четыре слова) |
|---|---|---|
| 0 | 400 | 2654435769 2135587861 4087136352 1559071514 |
| 1 | 250 | 461845907 3432918353 3864292196 2246822507 |
| 2 | 900 | 3266489909 668265263 374761393 3550635116 |
| 3 | 300 | 4252018373 3042594569 1759714724 625341585 |
Если запустить commit.bin по одному разу для каждого участника и записать резерв (100), количество (4) и четыре дайджеста по порядку, получится публичный сегмент auction-public.txt из 34 слов:
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
В настоящем аукционе каждый участник запускает commit.bin на своей машине, а соль берётся из настоящего источника случайности.
4. Программа на Rust
Правило в виде библиотеки, которую можно протестировать
Логику, не требующую системных вызовов, держите в отдельном крейте no_std. guest-sdk отказывается собираться под любую цель, кроме riscv32, поэтому гостевой крейт нельзя запустить через cargo test на ноутбуке. Библиотечный крейт без зависимости от guest-sdk можно.
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
Гостевая программа
my-guests/auction/Cargo.toml подключает оба крейта по путям:
[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();
}
Каждый путь выхода проходит через finish, которая записывает каждый слот один раз и останавливает выполнение. Граница цикла берётся из публичного сегмента, который фиксируется при развёртывании. Поэтому вызывающая сторона не может заставить программу прочитать меньше ставок, чем было зафиксировано.
Сборка и запуск
$ 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 принимает публичный сегмент в виде повторяющихся флагов --public. В оболочке:
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)
Продано (1) участнику 2 по цене 400, то есть по второй по величине ставке; подсчитано четыре участника. Два запуска, которые должны завершиться ошибкой:
| изменение | выходы 0–3 | значение |
|---|---|---|
| ставка 250 участника 1 заменена на 260 | 3 1 0 0 | BAD_OPENING у участника 1; больше ничего не публикуется |
| резерв 1 000 вместо 100 (другой публичный сегмент, а значит, другой идентификатор программы) | 2 0 0 4 | NO_SALE: ни одна ставка не достигла резерва |
5. Та же программа на C
Каталог гостевой программы на C содержит файлы .c и ваши собственные заголовки. rand-guest build --lang c добавляет guest.h, _start и небольшую среду выполнения (Развёртывание программы RISC-V). Он компилирует каждый файл .c в каталоге, поэтому тестовые файлы для хоста должны лежать в другом месте.
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 — это обнулённая static-переменная, поэтому она попадает в .bss. Это не добавляет слов в образ, так как ОЗУ и так читается как нули.
Тест правила на хосте
settle.c не использует системных вызовов, поэтому его соберёт обычный компилятор. 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
Сборка и запуск
$ 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)
Выходы совпадают с выходами гостевой программы на Rust. При четырёх участниках образ на C меньше на 58 слов и быстрее на 65 циклов; раздел 7 показывает, что баланс смещается при 64 участниках. Это две разные программы с разными hc и разными идентификаторами программы. Развёртывайте ту, исходный код которой вы публикуете.
6. Заметки о языках
Rust
Крейт. #![no_std], #![no_main] и функция #[no_mangle] pub extern "C" fn main() -> !, которая заканчивается вызовом halt(). В профиле release задано panic = "abort". Пустая таблица [workspace] не даёт крейту попасть в рабочее пространство родительского каталога. Не добавляйте .cargo/config.toml и собственные флаги компилятора: rand-guest build отвергает первый и сам задаёт все вторые (Развёртывание программы на Rust).
Паники. Обработчик паники в guest-sdk записывает 0xdeadbeef в слот 7 и останавливает выполнение. Такой запуск остаётся корректным и доказуемым. Эта программа читает индекс 999 шестиэлементного Vec после записи слотов 0 и 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
Слоты 0 и 1 содержат настоящие значения, а слот 7 сообщает, что запуск завершился паникой. Вызывающая сторона, которая отправит такой запуск, получит квитанцию в сети. Тот, кто читает квитанции, должен отвергать слот 7 = 3 735 928 559, либо программа не должна паниковать вообще. Используйте на каждом пути get(), проверяемую арифметику и явные коды статуса.
Куча. По умолчанию аллокатора нет. alloc работает, как только вы его предоставите. Линейный аллокатор над арендой фиксированного размера подходит программе, которая запускается один раз и завершается:
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) };
Арена обнулена, поэтому она находится в .bss и не добавляет слов в образ. Скрипт компоновщика по умолчанию выделяет 1 МиБ ОЗУ, и арена, ваши статические данные и стек в 64 КиБ должны уместиться в этом объёме. С этим аллокатором Vec, collect() и iter().sum() собираются и проходят check. Но alloc и его зависимости добавляют код: эта тестовая программа занимает 1 341 слово против 258 у аукциона. Раздел 7 объясняет, почему размер программы расходует бюджет доказательства.
Числа с плавающей точкой. В целевой архитектуре нет инструкций для чисел с плавающей точкой, и Rust преобразует f32/f64 в программные подпрограммы. Они работают: среднее чисел 9, 2, 7, 4 и 5, вычисленное в f64, вернулось в виде битов 5.4f32. Но каждая операция занимает много циклов. Где можно, используйте целые числа или числа с фиксированной точкой.
Крейты. Крейты no_std с crates.io собираются как любая зависимость. CRC-32 строки hello, rand из крейта crc, запущенный на эмуляторе, вывел 2124296908 — то же значение, что даёт zlib.crc32 в Python. Крейты, которым нужен std, для этой цели не собираются. rand-guest build не передаёт --locked, поэтому фиксируйте в репозитории свой Cargo.lock: без него пересборка через несколько месяцев может подтянуть более новый крейт и дать другой hc.
Ловушка unimp: находка Csr из библиотечного кода. Часть кода core компилируется в слово unimp (0xc0001073, csrrw x0, cycle, x0) на пути, который LLVM считает недостижимым. В машине нет CSR, поэтому check отвергает образ:
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)
Виновником здесь оказалась slice::sort_unstable для Vec, длина которого известна только во время выполнения. Стабильная sort() вела себя так же, тогда как sort_unstable для массива из 16 элементов фиксированной длины проходила. Чтобы найти источник такой находки, дизассемблируйте ELF гостевой программы и посмотрите на функцию вокруг адреса, который напечатал check:
llvm-objdump -d -C my-guests/heap/target/riscv32im-unknown-none-elf/release/heap | grep -B12 unimp
Затем замените этот вызов собственным кодом (для этой программы — короткой сортировкой вставками). По той же причине собственная halt() в guest-sdk заканчивается loop {}, а не обещанием noreturn.
C
- Точка входа —
void main(void), и она заканчивается вызовомrand_halt(). Входящий в поставку_startзадаёт стек и вызывает её. - Libc нет. Нет
printf,mallocи<string.h>. Среда выполнения предоставляетmemcpy,memmove,memsetиmemcmp(clang генерирует их вызовы для копирования структур и больших инициализаторов), а также четыре вспомогательные функции 64-битного деления. Все они реализованы побайтовыми циклами, поэтому в горячем коде копируйте и делите словами. Для кучи напишите линейный аллокатор надstatic-массивом, как в Rust. - Неопределённое поведение не перехватывается. Обработчика паники нет. Запись за границами массива молча портит память, а невыровненное чтение или запись вызывает ловушку, из-за чего запуск нельзя доказать. Проверяйте индексы по собственным пределам, как
main.cпроверяетnотносительноMAX_BIDDERS. - Правила для каталога. Не добавляйте собственные
guest.hиstart.S;build --lang cотвергает оба. Храните тестовые файлы вне каталога гостевой программы, так как каждый файл.cв нём компилируется в образ. - Закреплённая версия clang.
rand-guestотвергает любой clang, кроме 23.1.1. ПеременнаяRAND_GUEST_CLANG_UNPINNED=1отменяет это ограничение и выводит предупреждение, что вашhcне совпадёт со сборкой любого другого человека.
7. Докажется ли? Бюджет
Прувер дополняет каждый запуск до уровня t. У уровня два независимых бюджета, и rand-guest run --tier t сообщает оба:
- строки: не более
2ᵗ − 1, считаются выполненные циклы плюс строки дайджеста; - перестановки Poseidon2: не более
2ᵗ⁻³.
| уровень | строки | перестановки Poseidon2 | потолок газа без хеш-таблицы | вызов в цепочке 20 |
|---|---|---|---|---|
| 10 | 1 023 | 128 | 1 279 | принимается |
| 12 | 4 095 | 512 | 5 119 | принимается |
| 14 | 16 383 | 2 048 | 20 479 | принимается |
| 16 | 65 535 | 8 192 | 81 919 | отвергается начиная с v0.5.6 |
| 18, 20 | 2¹⁸ − 1, 2²⁰ − 1 | 2¹⁵, 2¹⁷ | 327 679, 1 310 719 | отвергается начиная с v0.5.6 |
Комиссия зависит от газа и байтов, а не от уровня. В цепочке 15 вызов стоил фиксированную сумму 0.001 + call_fee(tier): 0,002 RAND на уровне 10, 0,0021 на уровне 12 и 0,0022 на уровне 14. Начиная с цепочки 18 каждый запуск измеряется. Обычная строка стоит 1 газ, строка поглощения Poseidon2 — 3, перестановка Keccak — 192, сжатие SHA-256 — 64. Доказательство публикует объявленный лимит газа, схема проверяет, что газ запуска не превышает его, а точное значение газа остаётся приватным. Потолок, который уровень может объявить без хеш-таблицы, равен (2ᵗ − 1) + 2ᵗ⁻²; это столбец выше. Нижняя граница комиссии равна
floor = BUNDLE_BASE + gas_price × declared gas limit + byte_price × ⌈bytes / 1024⌉
где BUNDLE_BASE равен 0,001 RAND, байты — это доказательство вызова плюс конверт входных данных, а цены начинались со 100 единиц за газ и 800 единиц за КиБ (единица — это 10⁻⁹ RAND) и немного меняются в каждом блоке. В цепочке 20 они не могут вырасти выше 10 000 и 80 000 единиц, и цену за байты двигают только байты доказательств и конвертов вызовов; в цепочках 18 и 19 потолка не было. Раздел 9 показывает, сколько кошелёк платит по умолчанию, а в Примерах программ приведены четыре измеренных вызова в цепочке 18 — по 0,0057–0,0075 RAND каждый.
Tier 14 — самый высокий уровень, доступный для вызова. Начиная с fullnode v0.5.6 (2026-09-25), узел отклоняет любое доказательство вызова выше tier 14 с ошибкой CallTierTooHigh, ещё до построения ключа верификатора. По тому же правилу недействителен блок, содержащий такой вызов. Лимит защищает валидаторов с 2–4 ГБ памяти: на построение ключа верификатора tier 20 уходят минуты и гигабайты. rand call не отказывается от такого вызова до генерации доказательства, хотя в chain 20 сначала выводит tier. Запускайте rand-guest run … --tier 14 перед вызовом, и если выполнение не укладывается, перестройте программу. Перед развёртыванием rand program deploy --input <word> … (или --check-call для вызова без входных данных) прогоняет предполагаемый вызов через эмулятор и отказывается развёртывать программу, если она не укладывается в tier 14 или ниже.
На что уходит бюджет
Неожиданность — строки дайджеста и перестановки. Прежде чем выполнится ваш код, машина хеширует программу, приватные входные данные и публичный сегмент в hc, H_IN и H_PUB. Каждый из них стоит примерно одну перестановку и одну строку на четыре слова. Сверх того каждый вызов POSEIDON2 стоит примерно одну перестановку на четыре хешируемых слова. Для аукциона на tier 10:
| элемент | слов | перестановок |
|---|---|---|
программа (hc) | 258 | около 65 |
приватные входные данные (H_IN) | 20 | около 5 |
публичный сегмент (H_PUB) | 34 | около 9 |
| четыре проверки обязательств по 7 слов | 4 × 7 | 8 |
итого, как сообщает run | 88 из 128 |
Строки: 641 цикл плюс строки дайджеста составили 721 из 1 023.
В chain 20, как и в chain 19, собственный подсчёт кошелька немного выше, чем у rand-guest run. Доказательство вызова там привязано к своей транзакции (в chain 20 привязка включает и хеш генезиса, те же восемь слов), и привязка передаётся как ещё восемь слов публичного сегмента: ещё две строки дайджеста и ещё две перестановки для программы с публичным сегментом, по одной для программы без него.
Поэтому размер программы входит в бюджет. Тестовая программа с кучей выполнила всего 1 183 цикла, но её 1 341 слово потребовало 340 перестановок и подняло её до tier 12. При 2 048 перестановках tier 14 программу заметно больше примерно 8 000 слов нельзя вызвать вовсе, что бы она ни делала. В chain 20 точная граница — 8 180 слов для программы без публичного сегмента и вызова без входных данных, а при наличии того или другого — меньше. Предел размера программы в chain 20 — 65 535 слов, как и в chain 15, но chain 19 и chain 20 отказываются развёртывать программу сверх этой границы (ProgramUncallable), тогда как chain 15 брала плату за развёртывание программы, которую никто не мог вызвать. Поэтому переведённый ERC-20 из chain 13 (11 686 слов, вызывался на tier 16) нельзя ни развернуть, ни вызвать в chain 20. Делайте программы небольшими: opt-level = "s" в Rust, -Os (уже задан) в C, мало зависимостей, без alloc, если он не нужен.
Аукцион растёт линейно: каждый дополнительный участник добавляет 5 входных слов, 8 публичных слов, 2 перестановки на проверку и около сотни циклов. При 64 участниках (случайные ставки, 320 входных слов, 514 публичных слов) обе сборки выбирают одного победителя и одну цену, и обеим нужен tier 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)
Перестановки уместились бы в 512 перестановок tier 12, но строки не умещаются в его 4 095. Tier здесь определяют циклы, а не хеширование. При 64 участниках сборка на Rust быстрее: её opt-level = 3 выигрывает у фиксированного -Os сборки на C, когда доминирует цикл.
Keccak и SHA-256
keccak и sha256_compress стоят по одной строке, но доказательство, использующее любую из них, несёт дополнительную таблицу. В aeacf31 обе принимают ссылку на массив: keccak(&mut state) над [u32; 50] и sha256_compress(&mut buf) над [u32; 24], тогда как в 7ef3220 принимался сырой указатель. Начиная с v0.5.6 вызов может использовать не более 128 перестановок Keccak-f и не более 128 сжатий SHA-256. В chain 20 перестановка Keccak стоит ещё 192 газа, а сжатие SHA-256 — 64.
Таблица увеличивает доказательство, а chain 20 ограничивает доказательство вызова 4 194 304 байтами (4 МиБ, как в chain 17–19; в chain 13–16 предел составлял 8 МиБ). В chain 18 вызов tier 12 с таблицей Keccak занял 3,35 МБ, то есть большую часть предела, а вызов tier 14 с таблицей SHA-256 — 1,91 МБ против 1,50 МБ без обеих таблиц (Example programs). Каждый КиБ тарифицируется по цене за байт. В chain 15 тарифицировались только байты сверх бесплатных 2 МиБ + 18 432, по 1 000 единиц за КиБ (Fees). Используйте Poseidon2, если вам не нужно совпадение с внешним хешем.
Память и время доказательства
Время и память доказательства резко растут с ростом tier. Вызов tier 10 доказывается на ноутбуке за секунды. В chain 20 каждое развёртывание и каждый вызов несут ещё два доказательства на стороне пакета: авторизацию траты tier 10 (секунды) и пакет tier 14, оплачивающий комиссию (около двух минут на ноутбуке). Четыре вызова, измеренные в chain 18, заняли от трёх до пяти с половиной минут каждый, а пик памяти составил от 6,1 до 6,6 ГБ (Example programs). В chain 15 пакет был одним доказательством tier 14 примерно за 100 с и около 5,7 ГБ. Измеренные значения приведены в разделе Validator and aggregator hardware.
8. Развёртывание
Testnet — это chain 20. Для развёртывания нужен кошелёк rand версии fullnode v0.6.8 или новее, ключ и немного RAND (Introduction; faucet chain 20, как и chain 19, выдаёт средства только 16 кошелькам из allowlist его генезиса). Кошелёк v0.6.7 не может отправлять транзакции в chain 20: генезис chain 20 задаёт binding_domain: 1, поэтому каждая привязка доказательства и каждое подписанное сообщение включает хеш генезиса, которого кошелёк v0.6.7 не добавляет. Публичной конечной точки https://rpc.randprotocol.org достаточно, чтобы синхронизировать кошелёк, развернуть программу и вызвать её (setup). Аукцион развёртывается вместе с публичным сегментом:
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 выводит идентификатор программы, hc, число слов, а также число слов и дайджест публичных входных данных. Прежде чем что-либо доказывать, он проверяет программу на соответствие пределу цепочки, а публичные входные данные — на соответствие max_program_public_words (32 768 в chain 20). Затем он доказывает пакет с самоперечислением нулевой стоимости, оплачивающий комиссию, отправляет его и ждёт фиксации. В chain 20, как и начиная с chain 17, это два доказательства, каждое со своей строкой вывода: авторизация траты tier 10 (секунды, около 1,37 МБ) и пакет tier 14 (около двух минут, около 1,5 МБ). В chain 15 это было одно доказательство tier 14 примерно за 100 с и около 1,42 МБ. В комиссии за развёртывание нет газовой составляющей. Аукцион не развёртывался в chain 18–20; An anonymous vote показывает развёртывание с публичным сегментом в chain 18 в том виде, в каком его вывел кошелёк.
Идентификатор программы меняется с --public. rand-guest вывел 148ab0c4…2c73 для одного кода. Развёртывание с публичным сегментом хранится под blake3("rand-program-2", base_pc ‖ len ‖ words ‖ len ‖ public), что привязывает также резерв и четыре обязательства. Используйте идентификатор, который выводит program deploy. Второй аукцион с тем же кодом и другими обязательствами — это другой идентификатор программы.
rand program show <auction id> # base_pc, words_len, code_hash, deployed_at, public_words_len, public_digest
code_hash — это тот же hc, который вывел rand-guest, только записанный иначе. rand-guest выводит каждое из восьми слов дайджеста в порядке big-endian, а кошелёк, rand program show и rand_getProgram выводят байты каждого слова в порядке little-endian, поэтому каждая группа из восьми шестнадцатеричных цифр записана с обратным порядком байтов. Для описанных выше сборок aa04e4c4…050d (Rust) выглядит как c4e404aa…4f16, а 68605aa8…bea0 (C) — как a85a6068…b69a.
9. Вызов
Продавец вызывает программу со всеми 20 приватными словами:
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
Кошелёк получает код и публичный сегмент программы и проверяет, что их хеш совпадает с идентификатором. --expect-public также отказывает ещё до доказательства, если публичный сегмент не совпадает со словами этого файла. Затем кошелёк прогоняет вызов в эмуляторе и выводит точный расход газа, лимит, который он объявит, tier (наименьший, в который укладывается выполнение, здесь tier 10) и комиссию:
gas: <exact> (declaring <limit>, tier <t>)
fee <amount> RAND (incl. two price steps of headroom)
Он запечатывает 20 входных слов в конверт, который может открыть только вызывающий, локально доказывает выполнение, оплачивает комиссию через пакет, отправляет его, ждёт и выводит квитанцию. В chain 20 это три доказательства: вызов, авторизация траты tier 10 и пакет tier 14.
Во что обходится вызов. В chain 15 этот вызов стоил 0.001 + call_fee(10) = 0,002 RAND. В chain 20 кошелёк платит минимальную сумму из раздела 7, рассчитанную до появления доказательства, с тремя значениями по умолчанию:
- объявленный лимит — это точный расход газа, округлённый вверх до кратного
2^(tier − 2): 256 на tier 10, 1 024 на tier 12, 4 096 на tier 14.--gas-limit maxобъявляет потолок tier, а--gas-limit N— точное значение; - доказательство оценивается по предельному размеру в цепочке, 4 194 304 байта, а не по его реальному размеру, потому что доказательство вызова в chain 20 привязано к своей транзакции, а комиссию нужно зафиксировать заранее;
- запас в два шага цены, коэффициент 1,125², потому что вызов попадает в блок через два-три блока после того, как его цена определена.
--fee переопределяет значение по умолчанию, а rand fee call <tier> --gas <limit> --bytes <B> запрашивает у узла саму минимальную комиссию. Вызов аукциона не выполнялся в chain 18–20, поэтому строка с газом и комиссия для него здесь не приводятся. У анонимного голосования есть полный вызов в chain 18 в том виде, как его вывел кошелёк, с комиссией 0,0062 RAND.
Квитанция содержит tier, H_IN, H_PUB и выходы [1, 2, 400, 4, 0, 0, 0, 0]: те же слова, что вывел эмулятор, потому что доказательство относится к тому же самому исполнению. Четыре ставки и соли в цепочке не хранятся. --auditor <rand1…> дополнительно запечатывает входные данные для указанного аудитора, а --no-envelope вообще не публикует транскрипт, и это нельзя отменить (Programs).
10. Проверка результата
Участник торгов или любой другой желающий может проверить аукцион, не доверяя продавцу:
- Код. Пересоберите опубликованный исходный код командой
rand-guest buildна том же коммите circuits, с Rust 1.98.1 (или clang 23.1.1). Сравнитеhcсcode_hashизrand program showилиrand_getProgram, учитывая запись с обратным порядком байтов (раздел 8). Сборка воспроизводима, поэтому результаты совпадут на любой машине. - Публичный сегмент. Получите его через
rand_getProgramPublicили выполнитеrand call --expect-public. Убедитесь, что ваше обязательство находится под вашим индексом. - Квитанция.
rand receipt <tx hash>илиrand_getReceipt: статус1, победитель и цена, слот 7 не равен0xdeadbeef, аprogramсовпадает с идентификатором аукциона. - Входные данные, если у вас есть на них право. Команда продавца
rand open-call <tx hash>(или--as-auditorу аудитора) вскрывает запечатанные входные данные, сверяет их сH_IN, заново выполняет программу и завершается с ненулевым кодом при любом расхождении. Используйте кошелёк v0.6.8: начиная с v0.6.8 повторное выполнение производится над публичным сегментом программы и привязкой вызова, поэтому для аукциона оно работает (fullnode issue #116). В v0.6.7open-callостанавливался с ошибкойre-running the program on these inputs failed: PublicIndex(0)для любой программы, развёрнутой с публичным сегментом.
Доказательство гарантирует, что правило было применено к раскрытиям зафиксированных ставок. Оно не перемещает деньги. Победитель платит продавцу обычной командой rand send, и именно на квитанцию ссылаются обе стороны.
Troubleshooting
| симптом | причина и решение |
|---|---|
сборка падает на вызове poseidon2(…), keccak(…) или sha256_compress(…), написанном для более старого коммита | на aeacf31 poseidon2 — это unsafe fn, а две другие принимают &mut [u32; 50] и &mut [u32; 24] (разделы 3 и 7) |
check сообщает Syscall с a7 = 7 | гостевая программа вызывает poseidon2_len; rand-guest check на aeacf31 не перечисляет системный вызов 7 (zkp-circuits issue #1) |
check сообщает Csr по адресу 0xc0001073 | библиотечный код скомпилирован в unimp; найдите функцию с помощью llvm-objdump и замените вызов (раздел 6) |
run сообщает, что запуск не помещается ни в один tier или требует больше tier 14 | слишком много строк или перестановок Poseidon2; уменьшите программу, хэшируйте меньше или разделите работу на несколько вызовов, передавая между ними дайджест |
| slot 7 = 3735928559 | гостевая программа на Rust завершилась паникой; выходы до неё ненадёжны |
trap: DoubleWrite(n) | выходной слот n записан дважды; направьте все выходы через одну функцию |
trap: InputIndex(i) | программа прочитала приватный вход, который вызывающая сторона не передала; сначала прочитайте и проверьте количество |
trap: … при загрузке или сохранении | выровненный неверно или выходящий за границы адрес; в C — индекс массива за его границей |
invalid value '0x…' for '--input' | входные слова в командной строке задаются в десятичном виде |
вызов отклонён с CallTierTooHigh | доказательство выше tier 14; проверьте с помощью rand-guest run --tier 14 перед вызовом |
пересобранный hc отличается от развёрнутого | другой коммит circuits, набор инструментов или версии зависимостей; зафиксируйте все три и закоммитьте Cargo.lock. Если отличается только запись, каждая группа из восьми шестнадцатеричных цифр записана с обратным порядком байтов (раздел 8) |
rand open-call останавливается с PublicIndex(0) | у программы есть публичный сегмент, а кошелёк версии v0.6.7, повторное выполнение которого ничего не передаёт (fullnode issue #116); используйте v0.6.8 |
… is not inside a circuits checkout | переместите каталог гостевой программы внутрь checkout (Deploy a Rust program) |