本页涵盖将你自己的程序部署到 Rand 上所需的一切:程序可以是什么、如何设计它读取和发布的内容,以及用 Rust 和 C 各写一遍的完整示例。随后介绍测试、证明预算、部署、调用,以及任何人如何核验结果。下文每一处 rand-guest 输出均来自真实运行,并按原样粘贴。这些运行于 2026-09-25 在 circuits 提交 7ef3220 上进行。testnet 现为链 21(fullnode v0.8.0,其内置 circuits d217252,约束集 9);链 20 的工具链与链 18、19 一样,构建于 aeacf31(分支 feat/v067-circuits;fullnode v0.6.8 固定在同一提交),这些输出未在任一链上重新运行。无论约束集如何,guest 镜像都由同样的字构成,因此在 aeacf31 上构建的程序可以部署到链 21;只有证明属于约束集 9,并由 v0.8.0 钱包生成。本页会指出 SDK 或链在哪些地方有所不同。
两篇简短教程是快速路径:部署 Rust 程序和部署 RISC-V 程序。本页假定你已完成 Rust 教程的第 1 步:在 aeacf31 上有一份 randprotocol/zkp-circuits 检出,安装了 Rust 1.98.1 及 riscv32im-unknown-none-elf 目标、rand-guest 二进制文件,而对于 C,还需要 clang 23.1.1 和 llvm-tools。RISC-V ISA 是机器本身的参考。示例程序从这里继续,介绍更大的程序,以及链 18 的 gas 计量带来了哪些变化。本页涵盖 RPL-1 程序,即由 Call 以无状态方式运行的程序;链 20 和链 21 还有 RPL-2 程序状态、cell 与由 Invoke 改变的 vault(程序),完整示例见 randprotocol/example-programs。
1. 程序可以是什么
程序是任何符合以下形式的计算:
- 它读取 32 位字作为输入。
- 它发布至多八个 32 位字作为输出。
- 它是确定性的:相同的输入总是给出相同的输出。
- 它在其层级的预算内停机(第 7 节)。
在这些限制之内,你可以编写任何程序。你可以使用循环、递归、结构体、数组、64 位算术、crate,以及(只要自带分配器)堆。不过这台机器是裸的 RV32IM,其下没有操作系统:
| 你拥有 | 你不具备 |
|---|---|
RV32IM 整数指令,包括 MUL/DIV/REM | 浮点硬件(Rust 的软件浮点可用,但很慢) |
默认链接脚本中从 0x1000 起至多 1 MiB 的 RAM,以及 64 KiB 的栈 | 堆,除非你自行提供分配器(第 6 节) |
| 作为系统调用的 Poseidon2、Keccak-f 和 SHA-256 | 文件、网络、时钟、随机数、线程、std、libc |
no_std Rust crate 与自包含的 C | 在两次调用之间保留的状态 |
| 八个公开输出字 | 在程序内部转移 RAND 或代币的途径 |
设计程序时,最后一行最为重要。**输出是数据,不是效果。**一次调用证明的是:具有给定摘要的程序在某些私有输入上运行,并发布了八个字。链验证该证明,并把这八个字存入收据。它不改变任何余额,也不触发任何事情。这些字意味着什么,由程序、其调用者以及读取收据的人共同约定。程序列出了链目前尚不支持的内容。
2. 先设计接口
程序有三个通道。在编写任何代码之前,先决定每个通道上放什么。
| 通道 | 设定时机 | 读取方式 | 谁能看到 | 链 21 上的限制(与链 20 相同) |
|---|---|---|---|---|
| 私有输入 | 每次调用 | read_input(i) / rand_read_input(i) | 仅调用者,以及调用者向其交出该次调用密钥的人 | 16,071 个字(密封输入信封的上限) |
| 公开段 | 部署时一次 | read_public(i) / rand_read_public(i) | 所有人;程序 id 与之绑定 | 32,768 个字 |
| 输出 | 每次运行 | write_output(slot, w) / rand_write_output | 所有人,在收据中 | 8 个字 |
私有输入本身不证明任何关于外部世界的事
私有输入通过加盐承诺 H_IN 与证明绑定,因此证明之后便无法更改。但程序无从得知该输入是否真实。调用者可以键入任何数字。要让私有输入具有意义,须在程序内部把它与某个公开的内容关联起来:
- 一个事先发布的承诺(在公开段中,或带外提供给验证者),私有输入必须能打开它,如下文的拍卖;
- 一个默克尔根,私有叶子必须在其下得到证明(Poseidon2 即为此用途的系统调用);
- 一个验证者已持有的摘要,由程序重新计算并作为输出发布。
这是机密程序的核心设计技能。证明告诉验证者计算了什么;与公开数据的绑定则告诉它是在什么之上计算的。
将数据编码为字
一切都以 u32 传递:
| 数据 | 编码 |
|---|---|
| 字节串 | 一个长度字,随后是每四个字节一个字的小端字节,最后一个字以零填充(钱包用于 ELF 公开输入的形式) |
u64 或金额 | 两个字,先低位后高位 |
| 有符号整数 | 一个字中的二进制补码 |
| 较大的结果 | 其 Poseidon2 摘要:8 个字,恰好占满所有输出;或者发布少量摘要字和一个更短的承诺 |
命令行工具以十进制接收输入字:rand-guest run --input 和 rand call --input 不接受 0x…。供 rand program deploy --public 使用的公开段文件则同时接受十进制和 0x 十六进制。
设计输出
- **槽 0 是状态字。**为每种结果编一个号,失败也包括在内:打开不匹配、条目过多、输入超出范围。失败的检查仍应以状态干净地停机,这样调用者得到的是一份说明出了什么问题的证明。
- **每个槽至多写入一次。**对同一槽的第二次写入是一个陷阱(模拟器中为
DoubleWrite(0)),发生陷阱的运行无法被证明。你从未写入的槽读出为零。 - **在 Rust 中,保持槽 7 空闲。**发生 panic 的 Rust guest 会把
0xdeadbeef(3,735,928,559)写入槽 7 并停机。它在 panic 之前写入的输出仍然保留,因此读取者在相信其他输出之前必须检查槽 7(第 6 节)。 - 读取超出所给输入的位置是一个陷阱(模拟器中为
InputIndex(5))。先读取数量并检查它。
3. 完整示例:密封投标拍卖
卖方希望进行一次第二价格拍卖,而不公开落败的出价:
- 每位投标人选定一个出价和一个随机的 128 位盐,并向卖方提交一个承诺:对
("BID1", bidder index, bid, salt)的 Poseidon2。 - 卖方部署拍卖程序,以底价和每一个承诺作为其公开段。此时程序 id 只指代这场拍卖,别无其他。
- 每位投标人私下将其出价和盐发送给卖方。
- 卖方以每个出价和盐作为私有输入来调用该程序。程序对照各自的承诺检查每个打开,选出获胜者,并发布
[status, winner, price, bidder count]。
链得知谁获胜以及成交价格,却从未看到落败的出价。若卖方更改任何出价,得到的是 BAD_OPENING 而不是结果,因为这些承诺已固定在程序 id 之中。
一切都位于 circuits 检出目录之内,因为 rand-guest build 通过从 guest 所在目录向上查找来定位 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
将模拟器用作哈希计算器
投标人需要在 Goldilocks 域上计算 Poseidon2,与机器的计算完全一致。最简单而正确的工具是一个计算它的小型 guest,在模拟器上运行。一个摘要是八个字,恰好等于输出槽的数量。
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] 的哈希相同。在这里这无害,因为每条消息都是七个字且以标签开头。对于长度可变的消息,aeacf31 的 SDK 提供了 poseidon2_len(buf, n),它绑定长度(系统调用 7),但该提交上的 rand-guest check 拒绝系统调用 7,因此这样的 guest 目前无法构建(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
从 commit.bin 的输出到最后的费用下限,译文如下,从头完整给出。
下面的构建输出省略了 cargo 行。cargo 打印的 unaligned-scalar-mem 警告属于预期现象(部署 Rust 程序)。
四位竞拍者的出价与盐值如下:
| 竞拍者 | 出价 | 盐值(四个字) |
|---|---|---|
| 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 crate。guest-sdk 拒绝为 riscv32 以外的任何目标构建,因此 guest crate 无法在你的笔记本上运行 cargo test。不依赖 guest-sdk 的库 crate 则可以。
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
Guest 程序
my-guests/auction/Cargo.toml 通过路径依赖这两个 crate:
[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 标志接收公开段。在 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)
结果为已售出(1),买家是竞拍者 2,成交价 400,即第二高出价,共统计了四位竞拍者。下面是两次应当失败的运行:
| 改动 | 输出 0–3 | 含义 |
|---|---|---|
| 将竞拍者 1 的出价 250 替换为 260 | 3 1 0 0 | 竞拍者 1 处出现 BAD_OPENING;不会发布其他任何内容 |
| 底价取 1,000 而非 100(公开段不同,因此程序 id 也不同) | 2 0 0 4 | NO_SALE:没有出价达到底价 |
5. 用 C 编写同一个程序
C guest 目录中包含 .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 中。这不占用任何镜像字,因为 RAM 本来就读为零。
在主机上测试规则
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 guest 的一致。在四位竞拍者的情况下,C 镜像少 58 个字,快 65 个周期;第 7 节显示两者的优劣在 64 位竞拍者时发生逆转。两者是不同的程序,有不同的 hc 和程序 id。请部署你公开其源码的那一个。
6. 语言说明
Rust
crate。 #![no_std]、#![no_main],以及以 halt() 结尾的 #[no_mangle] pub extern "C" fn main() -> !。release 配置设置 panic = "abort"。空的 [workspace] 表使该 crate 不属于任何上层工作区。不要使用 .cargo/config.toml 和你自己的编译器标志:rand-guest build 会拒绝前者,并自行设定后者的全部内容(部署 Rust 程序)。
panic。 guest-sdk 的 panic 处理函数把 0xdeadbeef 写入槽位 7 并停机。这次运行仍然是有效的、可证明的运行。下面的程序在写入槽位 0 和 1 之后,读取一个六元素 Vec 的索引 999:
$ 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 表明这次运行发生了 panic。提交这样一次运行的调用者会在链上得到一份回执。读取回执的一方必须拒绝槽位 7 = 3,735,928,559 的回执,否则程序就绝不能 panic。在每条路径上都应优先使用 get()、带检查的算术和显式的状态码。
堆。 默认没有分配器。提供一个之后,alloc 即可使用。对于只运行一次就退出的程序,基于固定 arena 的 bump 分配器是合适的:
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) };
arena 是零值,所以它位于 .bss,不增加镜像字。默认链接脚本提供 1 MiB 的 RAM,arena、你的静态变量和 64 KiB 的栈必须都放得进去。有了这个分配器,Vec、collect() 和 iter().sum() 都能构建并通过 check。不过 alloc 及其依赖会增加代码:这个测试程序为 1,341 个字,而拍卖程序为 258 个。第 7 节解释了为什么程序大小会消耗证明预算。
浮点数。 该目标没有浮点指令,Rust 把 f32/f64 降级为软件例程。它们可以工作:用 f64 计算 9、2、7、4 和 5 的平均值,得到的是 5.4f32 的比特。它们每次都要消耗许多周期。能用整数或定点数时就用它们。
crate 依赖。 crates.io 上的 no_std crate 与任何依赖一样可以构建。crc crate 对 hello, rand 的 CRC-32,在模拟器上运行后输出 2124296908,与 Python 的 zlib.crc32 给出的值相同。需要 std 的 crate 无法为该目标构建。rand-guest build 不传递 --locked,因此请提交你的 Cargo.lock:没有它,数月后重新构建可能解析到更新的 crate,产生不同的 hc。
unimp 陷阱:来自库代码的 Csr 发现。 一些 core 代码会在 LLVM 认为不可达的路径上编译出 unimp 字(0xc0001073,即 csrrw x0, cycle, x0)。该机器没有 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)
这里的肇事者是对长度仅在运行时才知道的 Vec 调用的 slice::sort_unstable。稳定的 sort() 也一样,而对固定的 16 元素数组调用 sort_unstable 则通过了。要找到这类发现的来源,可以反汇编 guest 的 ELF,查看 check 打印出的地址附近的函数:
llvm-objdump -d -C my-guests/heap/target/riscv32im-unknown-none-elf/release/heap | grep -B12 unimp
然后用你自己的代码替换那次调用(对这个程序而言,是一个简短的插入排序)。出于同样的原因,guest-sdk 自己的 halt() 以 loop {} 结尾,而不是作出 noreturn 的承诺。
C
- 入口点是
void main(void),并以rand_halt()结尾。随附的_start负责设置栈并调用它。 - 没有 libc。 没有
printf、malloc或<string.h>。运行时提供memcpy、memmove、memset和memcmp(clang 会为结构体拷贝和大型初始化器生成对它们的调用),以及四个 64 位除法辅助函数。它们都是逐字节循环,因此在热点代码中应按字拷贝和做除法。需要堆时,像在 Rust 中一样,在static数组上写一个 bump 分配器。 - 未定义行为不会被捕获。 没有 panic 处理函数。越界写入会悄无声息地破坏内存,而未对齐的加载或存储会触发陷阱,这意味着这次运行无法被证明。请对照你自己的上限检查索引,就像
main.c对照MAX_BIDDERS检查n那样。 - 目录规则。 不要添加你自己的
guest.h或start.S;build --lang c两者都会拒绝。把测试文件放在 guest 目录之外,因为目录中的每个.c文件都会被编译进镜像。 - 固定版本的 clang。
rand-guest拒绝除 23.1.1 以外的任何 clang。RAND_GUEST_CLANG_UNPINNED=1可以覆盖这一限制,并会给出警告:你的hc将与其他人的构建不一致。
7. 能证明吗?预算
证明者把每次运行填充到某个档位 t。一个档位有两项相互独立的预算,rand-guest run --tier t 会同时报告两者:
- 行数:至多
2ᵗ − 1,计入已执行的周期数加上摘要行; - Poseidon2 置换:至多
2ᵗ⁻³。
| 档位 | 行数 | Poseidon2 置换 | 无哈希表时的 gas 上限 | chain 21 上的调用 |
|---|---|---|---|---|
| 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 起拒绝 |
费用取决于 gas 和字节数,而不是档位。 在 chain 15 上,一次调用支付固定的 0.001 + call_fee(tier):档位 10 为 0.002 RAND,档位 12 为 0.0021,档位 14 为 0.0022。自 chain 18 起,每次运行都被计量。一个普通行为 1 gas,一个 Poseidon2 吸收行为 3,一次 Keccak 置换为 192,一次 SHA-256 压缩为 64。证明会公布一个声明的 gas 上限,电路强制要求运行的 gas 不超过该上限,而确切的 gas 保持私密。在没有哈希表的情况下,一个档位可以声明的上限是 (2ᵗ − 1) + 2ᵗ⁻²,即上表中的那一列。费用下限为
floor = BUNDLE_BASE + gas_price × declared gas limit + byte_price × ⌈bytes / 1024⌉
以 BUNDLE_BASE 0.001 RAND 计,字节数为调用证明加输入信封,价格起初为每单位 gas 100 个单位、每 KiB 800 个单位(一个单位为 10⁻⁹ RAND),此后每个区块略有浮动。自 chain 20 起,二者分别不能超过 10,000 和 80,000 个单位,且只有调用的证明与信封字节会推动字节价格;在 chain 18 和 chain 19 上则没有上限。在 chain 21 上,整个底价被销毁(创世参数 fees.burn_floor),提议者只保留高于底价的部分;底价本身不变。第 9 节展示钱包默认支付的费用,Example programs 则给出了 chain 18 上四次实测调用,每次 0.0057 至 0.0075 RAND。
第 14 层是调用可使用的最高层级。 自 fullnode v0.5.6(2026-09-25)起,节点会在构建验证密钥之前,以 CallTierTooHigh 拒绝任何高于第 14 层的调用证明。同一规则也使携带此类调用的区块无效。这一限制是为了保护内存只有 2–4 GB 的验证者:第 20 层的验证密钥需要数分钟和数 GB 内存才能构建。rand call 在生成证明之前不会拒绝此类调用,不过自 chain 20 起它会先打印层级。调用之前先运行 rand-guest run … --tier 14,如果运行结果放不进去,就重构程序。部署之前,rand program deploy --input <word> …(对于没有输入的调用则用 --check-call)会在模拟器中运行你所设想的调用,除非它落在第 14 层或以下,否则拒绝部署。
预算花在哪里
摘要行和置换才是意外之处。在你的代码运行之前,机器会把程序、私有输入和公开段哈希为 hc、H_IN 和 H_PUB。每一项大约每四个字消耗一次置换和一行。除此之外,你每调用一次 POSEIDON2,每哈希四个字大约再消耗一次置换。以第 10 层的拍卖为例:
| 项目 | 字数 | 置换次数 |
|---|---|---|
程序(hc) | 258 | 约 65 |
私有输入(H_IN) | 20 | 约 5 |
公开段(H_PUB) | 34 | 约 9 |
| 四次承诺检查,每次 7 个字 | 4 × 7 | 8 |
合计,如 run 所报告 | 88 / 128 |
行数:641 个周期加上摘要行,共 721 / 1,023。
在 chain 21 上,与 chain 19 和 chain 20 一样,钱包自己的计数比 rand-guest run 的略高。那里的调用证明与其交易绑定(自 chain 20 起,该绑定还包含创世哈希,仍为同样的八个字),而这一绑定以另外八个字的公开段随证明一同传递:对有公开段的程序,多出两个摘要行和两次置换;对没有公开段的程序,则各多出一个。
这使得程序大小成为预算的一部分。堆测试程序只运行了 1,183 个周期,但它的 1,341 个字消耗了 340 次置换,把它推到了第 12 层。在第 14 层的 2,048 次置换上限下,大小远超约 8,000 个字的程序根本无法被调用,无论它做什么。在 chain 21 上,精确上限是:无公开段且调用无输入的程序为 8,180 个字,任何一项存在则更少。chain 21 上的程序上限为 65,535 个字,与 chain 15 相同,但自 chain 19 起的每条链都会拒绝部署超过该上限的程序(ProgramUncallable),而 chain 15 则会对一个无人能调用的程序照收部署费。这就是 chain 13 上翻译得到的 ERC-20(11,686 个字,在第 16 层被调用)在 chain 21 上既不能部署也不能调用的原因。请保持程序精简:Rust 中使用 opt-level = "s",C 中使用 -Os(已设置),依赖尽量少,除非必要不使用 alloc。
拍卖程序呈线性增长:每增加一个竞拍者,就增加 5 个输入字、8 个公开字、检查所需的 2 次置换,以及约一百个周期。在 64 个竞拍者(随机出价,320 个输入字,514 个公开字)的情况下,两种构建选出相同的赢家和价格,且都需要第 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)
置换次数本可放入第 12 层的 512,但行数放不进它的 4,095。这里决定层级的是周期数,而不是哈希。在 64 个竞拍者时,Rust 构建更快。一旦循环成为主要部分,它的 opt-level = 3 就胜过 C 构建固定的 -Os。
Keccak 与 SHA-256
keccak 和 sha256_compress 各占一行,但使用其中任何一个的证明都会带上一张额外的表。在 aeacf31 中,两者都接受数组引用,即对 [u32; 50] 的 keccak(&mut state) 和对 [u32; 24] 的 sha256_compress(&mut buf),而 7ef3220 用的是原始指针。自 v0.5.6 起,一次调用至多使用 128 次 Keccak-f 置换和至多 128 次 SHA-256 压缩。自 chain 18 起,一次 Keccak 置换还消耗 192 gas,一次 SHA-256 压缩消耗 64 gas。
这张表会使证明变大,而 chain 21 把调用证明限制在 6,291,456 字节(6 MiB,在该链切换时为私有多签授权证明而提高;chain 17 至 20 为 4 MiB,chain 13 至 16 为 8 MiB)。在 chain 18 上,带 Keccak 表的第 12 层调用实测为 3.35 MB,接近上限;带 SHA-256 表的第 14 层调用为 1.91 MB,两者都不用时为 1.50 MB(Example programs)。每个 KiB 都按字节价格收费。在 chain 15 上,只有超出免费的 2 MiB + 18,432 的字节才收费,每 KiB 1,000 个单位(Fees)。除非必须匹配外部哈希,否则请使用 Poseidon2。
内存与证明时间
证明时间和内存随层级急剧增长。第 10 层的调用在笔记本电脑上数秒即可证明。自 chain 20 起,每次部署和每次调用还带有两个捆绑包一侧的证明:一个第 10 层的花费授权(数秒),以及支付费用的第 14 层捆绑包(在笔记本电脑上约两分钟)。在 chain 18 上实测的四次调用各耗时三到五分半钟,峰值内存为 6.1 至 6.6 GB(Example programs)。在 chain 15 上,捆绑包是单个第 14 层证明,约 100 秒、约 5.7 GB。Validator and aggregator hardware 给出了实测数据。
8. 部署
测试网是 chain 21(自 2026-10-12 起上线)。部署需要 fullnode v0.8.0 的 rand 钱包、一个密钥和一些 RAND(Introduction;chain 21 的水龙头与 chain 20 的一样,只向其创世文件白名单中的 16 个钱包铸币)。旧版钱包无法在 chain 21 上发送交易:它运行约束集 9,而约束集 9 改变了每一个验证密钥和证明编码,因此 v0.6.8 或 v0.7.x 钱包的证明无法在那里解码;并且,与 chain 20 起一样,创世文件设置了 binding_domain: 1,因此每个证明绑定和签名消息都包含创世哈希。chain 21 启动时没有任何程序(切换时不会有任何内容延续),所以下面的拍卖在那里将是首次部署。公共端点 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 会打印程序 id、hc、字数,以及公开输入的字数和摘要。在生成任何证明之前,它会对照链的上限检查程序,并对照 max_program_public_words(chain 20 和 21 上为 32,768)检查公开输入。然后它证明一个支付费用的零值自转账捆绑包,提交它,并等待提交完成。在 chain 21 上,与自 chain 17 起一样,这是两个证明,各有自己的输出行:一个第 10 层的花费授权(数秒,约 1.37 MB)和第 14 层的捆绑包(约两分钟,约 1.5 MB)。在 chain 15 上则是一个第 14 层证明,约 100 秒、约 1.42 MB。部署费没有 gas 项。拍卖没有部署在 chain 18 至 20 上;An anonymous vote 展示了 chain 18 上带公开段的部署,输出与钱包打印的一致。
程序 id 会随 --public 而变化。 rand-guest 为单独的代码打印的是 148ab0c4…2c73。带公开段的部署以 blake3("rand-program-2", base_pc ‖ len ‖ words ‖ len ‖ public) 存储,它同时绑定了储备和四个承诺。请使用 program deploy 打印的 id。代码相同而承诺不同的第二个拍卖,是另一个程序 id。
rand program show <auction id> # base_pc, words_len, code_hash, deployed_at, public_words_len, public_digest
code_hash 就是 rand-guest 所打印的 hc,只是写法不同。rand-guest 以大端序打印摘要的八个字中的每一个,而钱包、rand program show 和 rand_getProgram 以小端序打印每个字的字节,因此每组八个十六进制数字都是字节反转的。对于上面的构建,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
钱包会获取程序的代码和公开段,并检查它们的哈希是否等于 id。--expect-public 还会在证明之前拒绝公开段与该文件字不一致的情形。随后钱包在模拟器中运行该调用,并打印精确的 gas、将要声明的上限、层级(运行所能容纳的最小层级,此处为 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 节中的下限费用,该费用在证明生成之前就已算出,并带有三个默认值:
- 声明的上限是将精确 gas 向上取整到
2^(tier − 2)的倍数:tier 10 为 256,tier 12 为 1,024,tier 14 为 4,096。--gas-limit max声明该层级的上限,--gas-limit N则声明一个精确数值; - 证明按链的上限计价,即 chain 21 上的 6,291,456 字节(chain 20 上为 4,194,304),而不是按其实际大小,因为自 chain 20 起,调用证明与其交易绑定,费用必须先行确定;
- 预留两个价格步长的余量,即 1.125² 倍,因为调用在定价之后两到三个区块才会落块。
--fee 会覆盖默认值,rand fee call <tier> --gas <limit> --bytes <B> 则向节点询问下限费用本身。拍卖的调用尚未在 chain 18 至 20 上发起,因此此处不显示其 gas 行和费用。一次匿名投票在 chain 18 上有一次完整调用,按钱包打印的原样记录,费用为 0.0062 RAND。
回执中包含层级、H_IN、H_PUB 以及输出 [1, 2, 400, 4, 0, 0, 0, 0]:与模拟器打印的字相同,因为证明针对的正是同一次执行。四个出价及其盐值不在链上。--auditor <rand1…> 还会把输入封给指定的审计方,--no-envelope 则完全不发布任何转录,此操作无法撤销(Programs)。
10. 核验结果
投标人或任何其他人都可以在不信任卖方的情况下核验这场拍卖:
- 代码。 在相同的 circuits 提交上,使用 Rust 1.98.1(或 clang 23.1.1),以
rand-guest build重新构建已发布的源码。将hc与rand program show或rand_getProgram给出的code_hash进行比较,并考虑字节序反转的写法(第 8 节)。该构建可复现,因此两者在任何机器上都一致。 - 公开段。 用
rand_getProgramPublic获取,或运行rand call --expect-public。检查你自己的承诺位于你的索引处。 - 回执。
rand receipt <tx hash>或rand_getReceipt:状态为1,包含获胜者和价格,槽位 7 不是0xdeadbeef,且program等于该拍卖的 id。 - 输入(如果你有权查看)。 卖方的
rand open-call <tx hash>(或审计方的--as-auditor)会打开被封存的输入,将其与H_IN核对,重新运行程序,并在任何不一致时以非零状态退出。请使用 v0.6.8 或更高版本的钱包(chain 21 上为 v0.8.0):自 v0.6.8 起,重新运行是基于程序的公开段和调用绑定进行的,因此对拍卖有效(fullnode issue #116)。在 v0.6.7 中,对于任何带有公开段部署的程序,open-call都会以re-running the program on these inputs failed: PublicIndex(0)停止。
该证明保证规则被应用于已承诺出价的打开值。它并不转移资金。获胜者通过一次普通的 rand send 向卖方付款,回执是双方共同指向的凭据。
故障排除
| 症状 | 原因与修复 |
|---|---|
构建在为旧提交编写的 poseidon2(…)、keccak(…) 或 sha256_compress(…) 调用处失败 | 在 aeacf31 上,poseidon2 是 unsafe fn,另外两个分别接受 &mut [u32; 50] 和 &mut [u32; 24](第 3 和第 7 节) |
check 报告 Syscall 且 a7 = 7 | guest 调用了 poseidon2_len;aeacf31 上的 rand-guest check 未列出 syscall 7(zkp-circuits issue #1) |
check 报告 Csr,位于 0xc0001073 | 库代码被编译为 unimp;用 llvm-objdump 找出该函数并替换该调用(第 6 节) |
run 提示该运行不适合任何层级,或需要超过 tier 14 | 行数或 Poseidon2 置换过多;缩小程序、减少哈希,或将工作拆分为多次调用,并在调用之间传递摘要 |
| slot 7 = 3735928559 | Rust guest 发生了 panic;其之前的输出不可靠 |
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 | 将 guest 目录移到该 checkout 之内(Deploy a Rust program) |