本頁涵蓋將你自己的程式部署到 Rand 所需的一切:程式可以是甚麼、如何設計它讀取與發布的內容,以及一個以 Rust 和 C 各寫一次的完整範例。之後再說明測試、證明預算、部署、呼叫,以及任何人如何核實結果。下列每一項 rand-guest 輸出均來自實際運行,並按原樣貼出。這些運行於 2026-09-25 在 circuits commit 7ef3220 上進行。testnet 現為 chain 20,其工具鏈與 chain 18 和 19 一樣,建置於 aeacf31(分支 feat/v067-circuits;fullnode v0.6.8 固定使用同一 commit),而輸出並未在該處重新運行。凡 SDK 或鏈有所不同之處,本頁均會註明。
兩篇簡短教學是最快的途徑:Deploy a Rust program 與 Deploy a RISC-V program。本頁假設你已完成 Rust 教學的第 1 步:一個位於 aeacf31 的 randprotocol/zkp-circuits checkout、已安裝 riscv32im-unknown-none-elf target 的 Rust 1.98.1、rand-guest 二進位檔,而使用 C 時,另需 clang 23.1.1 與 llvm-tools。The RISC-V ISA 是機器本身的參考文件。Example programs 由此延伸,介紹更大型的程式,以及 chain 18 的 gas 計量帶來的改變。本頁涵蓋 RPL-1 程式,即由 Call 以無狀態方式運行的程式;chain 20 亦設有 RPL-2 程式狀態、cell 與 vault,由 Invoke 更改(Programs),完整範例見 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 或代幣的途徑 |
設計程式時,最後一行最為重要。**輸出是資料,不是效果。**一次呼叫證明的是:具有某個摘要的程式,在某些私有輸入上運行,並發布了八個字。鏈會驗證證明,並把這八個字存入收據。它不會更改任何餘額,也不會觸發任何事情。這些字的含義,由程式、其呼叫者以及閱讀收據的人共同約定。Programs 列出了鏈目前尚未支援的功能。
2. 先設計介面
程式有三個通道。在編寫任何程式碼之前,先決定每個通道放置甚麼。
| 通道 | 設定時機 | 讀取方式 | 誰可看見 | chain 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 checkout 之內,因為 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),但該 commit 的 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
此處及下文的建置輸出均略去 cargo 的各行。cargo 印出的 unaligned-scalar-mem 警告屬預期之內(Deploy a Rust program)。
四位競投者,其出價與鹽如下:
| 投標者 | 出價 | salt(四個字) |
|---|---|---|
| 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,而 salt 來自真正的隨機源。
4. 以 Rust 撰寫程式
規則:可供測試的函式庫
把不需要 syscall 的邏輯放進獨立的 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 和一個小型執行時(Deploy a RISC-V program)。它會編譯目錄中的每一個 .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 不使用 syscall,所以你一般的編譯器即可建置。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 profile 設定 panic = "abort"。空的 [workspace] 表使該 crate 不屬於任何上層 workspace。不要加入 .cargo/config.toml 和你自己的編譯器旗標:rand-guest build 會拒絕前者,並自行設定後者的全部內容(Deploy a Rust program)。
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 即可使用。對於只執行一次便退出的程式,建立在固定記憶體區上的 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) };
該記憶體區已清零,因此位於 .bss,不增加映像字。預設連結器腳本提供 1 MiB 的 RAM,記憶體區、你的 static 變數和 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. 能否證明?預算
prover 會把每次執行填充至某個層級 t。每個層級有兩項獨立的預算,而 rand-guest run --tier t 會同時報告兩者:
- 列:至多
2ᵗ − 1,計算已執行的週期加上摘要列; - Poseidon2 置換:至多
2ᵗ⁻³。
| 層級 | 列 | Poseidon2 置換 | 無雜湊表時的 gas 上限 | 在 chain 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 起拒絕 |
費用取決於 gas 和位元組,而非層級。 在 chain 15 上,一次呼叫支付固定的 0.001 + call_fee(tier):層級 10 為 0.002 RAND,層級 12 為 0.0021,層級 14 為 0.0022。自 chain 18 起,每次執行都會計量。一般的列為 1 gas,Poseidon2 absorb 列為 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 和 19 上則沒有上限。第 9 節顯示錢包預設支付的金額,而 Example programs 載有在 chain 18 上四次實測的呼叫,每次費用為 0.0057 至 0.0075 RAND。
第 14 級是呼叫可使用的最高級別。 自 fullnode v0.5.6(2026-09-25)起,節點會以 CallTierTooHigh 拒絕任何高於第 14 級的呼叫證明,且在建立驗證者金鑰(verifier key)之前即拒絕。同一規則亦令載有此類呼叫的區塊無效。此限制旨在保護只有 2–4 GB 記憶體的驗證者:第 20 級的驗證者金鑰需時數分鐘、佔用數 GB 才能建立。rand call 不會在生成證明前拒絕此類呼叫,不過在 chain 20 上它會先印出級別。呼叫之前,請先執行 rand-guest run … --tier 14,若該次運行未能容納於此級別,便須重構程式。部署之前,rand program deploy --input <word> …(若呼叫不帶輸入,則用 --check-call)會以模擬器運行你所設想的呼叫,除非其結果落在第 14 級或以下,否則拒絕部署。
預算花在哪裏
摘要列(digest row)與置換(permutation)的數量往往出人意表。在你的程式碼運行之前,機器會將程式、私有輸入與公開段(public segment)分別雜湊為 hc、H_IN 與 H_PUB。每一項約佔 每四個字(word)一次置換、一列。除此之外,你每次呼叫 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 20 上,一如 chain 19,錢包自行計算的數字略高於 rand-guest run 所報告者。該處的呼叫證明與其交易綁定(在 chain 20 上,綁定亦包含創世雜湊,同樣是八個字),而此綁定以另外八個字的公開段形式附帶:對有公開段的程式而言,多出兩個摘要列與兩次置換;對沒有公開段的程式,則各多一個。
這意味着程式的大小也是預算的一部分。堆(heap)測試程式只運行了 1,183 個週期,但其 1,341 個字佔用 340 次置換,令它升至第 12 級。在第 14 級的 2,048 次置換下,字數遠超約 8,000 的程式,無論其執行甚麼,都根本無法呼叫。在 chain 20 上,確切的上限是:無公開段且呼叫不帶輸入的程式為 8,180 個字,有任何一項則更少。chain 20 的程式上限為 65,535 個字,與 chain 15 相同,但 chain 19 與 chain 20 會拒絕部署超出此界限的程式(ProgramUncallable),而 chain 15 則會收取部署費用,卻讓程式無人能夠呼叫。這就是為何 chain 13 上經轉譯的 ERC-20(11,686 個字,以第 16 級呼叫)在 chain 20 上既無法部署,也無法呼叫。程式應保持精簡: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 中,兩者均接受陣列引用: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 gas,每次 SHA-256 壓縮另收 64 gas。
這張表令證明變大,而 chain 20 將呼叫證明上限定為 4,194,304 位元組(4 MiB,與 chain 17 至 19 相同;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 上,每次部署與每次呼叫還會附帶兩個捆綁包(bundle)端的證明:一個第 10 級的花費授權(spend authorisation)證明(數秒),以及支付費用的第 14 級捆綁包證明(在手提電腦上約兩分鐘)。在 chain 18 上量度的四次呼叫,每次耗時三至五分半鐘,記憶體峰值為 6.1 至 6.6 GB(Example programs)。在 chain 15 上,捆綁包是單一的第 14 級證明,約 100 秒、約 5.7 GB。Validator and aggregator hardware 載有實測數據。
8. 部署
testnet 為 chain 20。部署需要 fullnode v0.6.8 或更新版本的 rand 錢包、一條金鑰及若干 RAND(Introduction;chain 20 的水龍頭與 chain 19 相同,只向其創世區塊列入允許名單的 16 個錢包發放)。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 會印出程式 id、hc、字數,以及公開輸入的字數與摘要。它在生成任何證明之前,會先對照鏈的上限檢查程式,並對照 max_program_public_words(chain 20 上為 32,768)檢查公開輸入。接着它會證明一個支付費用的零值自我轉帳捆綁包,提交並等待確認上鏈。在 chain 20 上,一如自 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 以大端序(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
錢包會取得程式的程式碼與公開段,並檢查兩者的雜湊是否與 id 相符。若公開段並非該檔案中的字,--expect-public 亦會在生成證明前拒絕。接着錢包在模擬器中運行該呼叫,並印出確切的 gas、將會聲明的上限、級別(運行所能容納的最小級別,此處為第 10 級)及費用:
gas: <exact> (declaring <limit>, tier <t>)
fee <amount> RAND (incl. two price steps of headroom)
它將這 20 個輸入字密封於只有呼叫者才能開啟的信封(envelope)內,在本機生成該次運行的證明,經由捆綁包支付費用,提交、等待並印出收據。在 chain 20 上,這共有三個證明:呼叫、第 10 級的花費授權,以及第 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聲明該 tier 的上限,--gas-limit N則聲明確切數值; - 證明按鏈上限 4,194,304 bytes 計價,而非按其實際大小計價,因為 chain 20 上的調用證明與其交易綁定,費用必須先行確定;
- 預留兩個價格級距的空間,即 1.125² 倍,因為調用會在定價後兩至三個區塊才落地。
--fee 可覆蓋預設值,而 rand fee call <tier> --gas <limit> --bytes <B> 會向節點查詢費用下限。拍賣的調用尚未在 chain 18 至 20 上進行,故此處不列出其 gas 行及費用。匿名投票在 chain 18 上有一次完整的調用,內容與錢包所顯示的相同,費用為 0.0062 RAND。
收據載有 tier、H_IN、H_PUB 及輸出 [1, 2, 400, 4, 0, 0, 0, 0]:與模擬器所打印的字詞相同,因為證明所針對的正是同一次執行。四個出價及其 salt 並不在鏈上。--auditor <rand1…> 亦會將輸入密封予指定的審計方,而 --no-envelope 則完全不發布任何記錄,此舉無法撤銷(Programs)。
10. 核對結果
出價人或任何其他人,都可以在無須信任賣方的情況下核對這場拍賣:
- 代碼。 在同一個 circuits commit 上,以 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,顯示勝出者及價格,slot 7 不是0xdeadbeef,且program等於該拍賣的 id。 - 輸入(如你有權查閱)。 賣方的
rand open-call <tx hash>(或審計方的--as-auditor)會開啟已密封的輸入,對照H_IN檢查,重新運行程式,若有任何不符即以非零狀態退出。請使用 v0.6.8 錢包:自 v0.6.8 起,重新運行是基於程式的公開段及調用綁定進行,因此適用於這場拍賣(fullnode issue #116)。在 v0.6.7 中,對於任何附帶公開段而部署的程式,open-call都會以re-running the program on these inputs failed: PublicIndex(0)中止。
證明保證了規則是套用於已承諾出價的開啟值之上,但它並不轉移資金。勝出者以一般的 rand send 向賣方付款,而收據則是雙方共同依據的憑證。
Troubleshooting
| 症狀 | 原因及解決方法 |
|---|---|
構建在針對舊 commit 編寫的 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 在 0xc0001073 報告 Csr | 庫代碼被編譯為 unimp;以 llvm-objdump 找出該函數,並替換該調用(第 6 節) |
run 顯示該次運行不適用於任何 tier,或需要超過 tier 14 | 行數或 Poseidon2 置換過多;縮減程式、減少哈希,或將工作拆分為多次調用,並在其間傳遞摘要 |
| slot 7 = 3735928559 | Rust guest 已 panic;其之前的輸出並不可靠 |
trap: DoubleWrite(n) | 輸出 slot n 被寫入兩次;讓所有出口都經由同一個函數 |
trap: InputIndex(i) | 程式讀取了調用方未提供的私有輸入;先讀取並檢查數量 |
載入或儲存時出現 trap: … | 地址未對齊或超出範圍;在 C 中,即陣列索引超出其邊界 |
invalid value '0x…' for '--input' | 在命令行中,輸入字詞須為十進位 |
調用被 CallTierTooHigh 拒絕 | 證明高於 tier 14;調用前先以 rand-guest run --tier 14 檢查 |
重新構建的 hc 與已部署的不同 | circuits commit、工具鏈或依賴版本不同;三者都要固定,並提交 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) |