Rand Protocol
文档
示例程序

链下账本

一个在程序本身没有状态的地方保存状态的机密程序——一棵 256 个账户的默克尔树,存放在链下;每一批转账都被证明为从一个根到下一个根的状态转换,并通过收据串联起来。内容包括:接口、Rust guest、主机工具、周期数如何减半,以及已在 chain 18 上线的程序。

Rand 上的程序没有存储:一次调用读取其输入,发布八个字,并留下一份回执(Programs)。本页展示在此前提下保存状态的标准做法。状态以 Merkle 树的形式存放在链下,程序把每次变更证明为从一个根到下一个根的一步,回执则把这些步骤串联起来。

示例是一个包含 256 个账户的账本。一次调用最多应用四笔转账,并证明每个发送方都拥有该账户、余额充足,且没有任何资金被凭空创造或丢失。链上可见的只有一个状态、一个计数和一个哈希。

Example programs 介绍了四个示例页面共用的设置。

1. 它证明什么

该账本是一棵基于 Poseidon2 的深度为 8 的 Merkle 树,Poseidon2 是该机器证明成本最低的哈希。一个叶子对应一个账户:

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

secret 是由账户持有者保管的四个字。

一次调用以当前根和一批转账作为私有输入。对每笔转账,它证明:

  1. 发送方的叶子位于树中,依据其 Merkle 路径;
  2. 调用者知道发送方 owner id 背后的 secret;
  3. 金额不超过发送方的余额;
  4. 扣款之后的树包含接收方的叶子;
  5. 入账不会溢出。

随后它发布一个 link:Poseidon2(old root, new root, count, "LINK"),截断为 160 位。

为什么用 link 而不是两个根。 一个根是八个字,而输出槽恰好是八个。若同时发布两个根,就必须把每个根截断到约 96 位,而 96 位的值只需约 2⁴⁸ 的工作量即可找到碰撞,这将使 prover 能够在同一份回执下展示两个不同的账本。对两个根取一个 160 位的哈希则不存在这个问题。完整的根随账本数据一起在链下传递,任何持有它们的人都可以重新计算 link。

跟随账本。 跟随者持有当前状态。当该程序出现状态为 1 的回执时,跟随者从发起调用的人那里获取新的叶子,重建根,并用自己持有的旧根、新根以及回执中的 count 计算 link。若结果与回执的输出一致,则该步骤有效,跟随者采纳新状态。若回执无法从跟随者当前的根链接而来,则被忽略。竞争也由此得到解决:两个基于同一个根构建的调用者都会得到回执,而链所排序在先的那一个,就是所有跟随者接受的那一个。

谁可以发起调用。 任何知道发送方 secret 和当前树的人。规则中没有运营方:持有者可以在自己的机器上证明自己的转账,因此 secret 永远不会离开那台机器。

2. 接口

没有公开段。账本的身份就是其创世根,由各方在链下约定。

私有字含义
0转账数量,至多为 4
1 至 8旧根
之后,每笔转账 146 个字
+0, +1发送方和接收方的叶子索引,均小于 256
+2, +3金额,一个 u64,先低位字后高位字
+4 至 +7发送方的 secret
+8 至 +10发送方的余额(低位、高位)和 nonce
+11 至 +74发送方的 Merkle 路径:8 个兄弟节点,每个 8 个字,自叶子向上
+75 至 +78接收方的 owner id
+79 至 +81接收方的余额(低位、高位)和 nonce
+82 至 +145接收方的 Merkle 路径,位于扣款后留下的树中
输出槽含义
0状态:1 已应用,2 发送方的叶子不在树中(或 secret 错误),3 接收方的叶子不在树中,4 余额不足,5 入账溢出,6 发送方即接收方,7 转账超过 4 笔,8 索引越界
1已应用的转账数:成功时为 count,否则为失败转账的索引
2 至 6成功时,为 link 的前五个字
7未使用;Rust panic 会在此写入 0xdeadbeef

转账按顺序应用,每一笔都作用于前一笔留下的树。在演示批次中,账户 3 从零开始,在第一笔转账中收到 125,并在第三笔中花掉其中的 25。

3. guest

my-guests/ledger/src/main.rs,完整代码:

#![no_std]
#![no_main]

use guest_sdk::{halt, poseidon2, read_input, write_output};

/// The ledger is a Merkle tree of 2^8 = 256 accounts.
const DEPTH: usize = 8;
const MAX_TRANSFERS: u32 = 4;
/// Private words per transfer: from, to, amount (2), secret (4), sender balance (2) and nonce,
/// sender path (64), receiver owner (4), balance (2) and nonce, receiver path (64).
const TRANSFER_WORDS: u32 = 146;

const OK: u32 = 1;
const BAD_SENDER: u32 = 2;
const BAD_RECEIVER: u32 = 3;
const INSUFFICIENT: u32 = 4;
const OVERFLOW: u32 = 5;
const SELF_TRANSFER: u32 = 6;
const TOO_MANY: u32 = 7;
const BAD_INDEX: u32 = 8;

const OWNER_TAG: u32 = 0x4f57_4e52; // "OWNR"
const LEAF_TAG: u32 = 0x4c45_4146; // "LEAF"
const NODE_TAG: u32 = 0x4e4f_4445; // "NODE"
const LINK_TAG: u32 = 0x4c49_4e4b; // "LINK"

type Digest = [u32; 8];

/// Poseidon2 over `buf[..n]`. The sponge does not pad, so every message this program hashes
/// has a fixed length and carries its own tag word.
fn hash(buf: &mut [u32], n: usize) -> Digest {
    assert!(n <= buf.len() && buf.len() >= 8);
    // SAFETY: the syscall reads `buf[..n]` and overwrites `buf[..8]`; the assert keeps both
    // inside `buf`, which `&mut` makes aligned, valid and unaliased for the call.
    unsafe { poseidon2(buf.as_mut_ptr(), n) };
    let mut d = [0u32; 8];
    d.copy_from_slice(&buf[..8]);
    d
}

/// An account's owner id: the first four words of Poseidon2("OWNR", secret).
fn owner_of(secret: &[u32; 4]) -> [u32; 4] {
    let mut buf = [OWNER_TAG, secret[0], secret[1], secret[2], secret[3], 0, 0, 0];
    let d = hash(&mut buf, 5);
    [d[0], d[1], d[2], d[3]]
}

fn leaf(owner: &[u32; 4], balance: u64, nonce: u32) -> Digest {
    let mut buf = [
        LEAF_TAG, owner[0], owner[1], owner[2], owner[3], balance as u32, (balance >> 32) as u32, nonce,
    ];
    hash(&mut buf, 8)
}

/// One level of a walk. `buf` holds a node's message, `left ‖ right ‖ "NODE"`, and arrives with
/// the running hash in words 0..8, where the last hash left its digest. `side` is 0 when the
/// running hash is the left child and 8 when it is the right one: it is moved to its half and
/// the sibling written to the other. The halves are picked by an offset, not a branch, so both
/// cases execute the same instructions (for a left child the move is a copy onto itself).
fn level(buf: &mut [u32; 17], sib: &Digest, side: usize) {
    let mut cur = [0u32; 8];
    cur.copy_from_slice(&buf[..8]);
    buf[side..side + 8].copy_from_slice(&cur);
    buf[8 - side..16 - side].copy_from_slice(sib);
    // SAFETY: the syscall reads the 17 words of `buf` and overwrites its first 8.
    unsafe { poseidon2(buf.as_mut_ptr(), 17) };
}

/// Walks two leaves up the same path at once and returns the root each one reaches. The path
/// is the eight siblings whose words start at private input `at`; bit `d` of `index` says
/// whether the running hash is the right child at level `d`. An update needs both walks (the
/// leaf before and the leaf after), and doing them together reads each sibling once.
fn roots_from(before: Digest, after: Digest, index: u32, at: u32) -> (Digest, Digest) {
    let (mut a, mut b) = ([0u32; 17], [0u32; 17]);
    a[..8].copy_from_slice(&before);
    b[..8].copy_from_slice(&after);
    a[16] = NODE_TAG;
    b[16] = NODE_TAG;
    for d in 0..DEPTH {
        let side = 8 * ((index >> d) & 1) as usize;
        let sib: Digest = core::array::from_fn(|k| read_input(at + (8 * d + k) as u32));
        level(&mut a, &sib, side);
        level(&mut b, &sib, side);
    }
    let (mut ra, mut rb) = ([0u32; 8], [0u32; 8]);
    ra.copy_from_slice(&a[..8]);
    rb.copy_from_slice(&b[..8]);
    (ra, rb)
}

fn read_u64(at: u32) -> u64 {
    read_input(at) as u64 | (read_input(at + 1) as u64) << 32
}

/// Applies the transfer whose words start at `at` to the tree with root `root`.
fn apply(root: Digest, at: u32) -> Result<Digest, u32> {
    let from = read_input(at);
    let to = read_input(at + 1);
    let amount = read_u64(at + 2);
    let secret = [read_input(at + 4), read_input(at + 5), read_input(at + 6), read_input(at + 7)];
    let sender_balance = read_u64(at + 8);
    let sender_nonce = read_input(at + 10);
    let receiver = [read_input(at + 75), read_input(at + 76), read_input(at + 77), read_input(at + 78)];
    let receiver_balance = read_u64(at + 79);
    let receiver_nonce = read_input(at + 81);

    if from >= 1 << DEPTH || to >= 1 << DEPTH {
        return Err(BAD_INDEX);
    }
    if from == to {
        return Err(SELF_TRANSFER);
    }

    // The sender's leaf is in the tree, and the caller knows the secret behind its owner id.
    // The second walk carries the debited leaf up the same path: the root after the debit.
    let sender = owner_of(&secret);
    let (seen, debited) = roots_from(
        leaf(&sender, sender_balance, sender_nonce),
        leaf(&sender, sender_balance.wrapping_sub(amount), sender_nonce.wrapping_add(1)),
        from,
        at + 11,
    );
    if seen != root {
        return Err(BAD_SENDER);
    }
    if amount > sender_balance {
        return Err(INSUFFICIENT);
    }

    // The receiver's leaf is in the tree the debit left behind; the second walk credits it.
    let (seen, credited) = roots_from(
        leaf(&receiver, receiver_balance, receiver_nonce),
        leaf(&receiver, receiver_balance.wrapping_add(amount), receiver_nonce),
        to,
        at + 82,
    );
    if seen != debited {
        return Err(BAD_RECEIVER);
    }
    if receiver_balance.checked_add(amount).is_none() {
        return Err(OVERFLOW);
    }
    Ok(credited)
}

/// Private input: [count, old root (8 words), then `count` transfers of 146 words each].
/// Outputs: [status, transfers applied, link (first 5 words), 0], where
/// link = Poseidon2(old root, new root, count, "LINK").
#[no_mangle]
pub extern "C" fn main() -> ! {
    let count = read_input(0);
    if count > MAX_TRANSFERS {
        write_output(0, TOO_MANY);
        halt();
    }
    let mut old = [0u32; 8];
    for (k, w) in old.iter_mut().enumerate() {
        *w = read_input(1 + k as u32);
    }

    let mut root = old;
    let mut done = 0;
    while done < count {
        match apply(root, 9 + TRANSFER_WORDS * done) {
            Ok(next) => root = next,
            Err(status) => {
                write_output(0, status);
                write_output(1, done);
                halt();
            }
        }
        done += 1;
    }

    let mut buf = [0u32; 18];
    buf[..8].copy_from_slice(&old);
    buf[8..16].copy_from_slice(&root);
    buf[16] = count;
    buf[17] = LINK_TAG;
    let link = hash(&mut buf, 18);

    write_output(0, OK);
    write_output(1, count);
    for k in 0..5 {
        write_output(2 + k as u32, link[k]);
    }
    halt();
}

旧根是私有输入,这是安全的。 没有任何机制强制调用者从真实的根出发。从一个捏造的根出发的调用,证明的是从一个无人持有的账本出发的正确一步,其 link 与任何跟随者的状态都不匹配。绑定依靠的是 link:它是对两个根的公开承诺,跟随者会将其与自己的状态比较。

守恒是由构造保证的。 扣除的金额与记入的金额是同一个变量,无需另外核对总和。

扣款和入账有意使用 wrapping_ 算术。 程序在还不知道”之前”的叶子是否真实时,就已构造出”之后”的叶子,因为两者沿路径同时计算。如果金额超过余额,则会计算出回绕后的叶子,随后在两行之后的检查返回状态 4 时将其丢弃。

nonce 在每次扣款时递增,因此即使余额回到先前的值,发送方的叶子也会改变。跟随者可以看出一个账户花费了多少次。

4. 这个 guest 遇到的两个工具链问题

rand-guest check 拒绝带长度绑定的 Poseidon2。 SDK 有两个 Poseidon2 调用。poseidon2(syscall 3)不做填充:至多四个字的消息,与在其后补上零字的同一消息,哈希结果相同。poseidon2_len(syscall 7)把长度写入海绵的初始状态,对于变长数据是更安全的默认选择。机器两者都实现了,但在 circuits aeacf31 版本中,检查器的 syscall 列表止于 6:

$ rand-guest build my-guests/ledger --max-words 65535
…
0x00001114  0x00000073  Syscall: ecall with a7 = 7, which is no syscall the machine implements
…
Error: the image is rejected by the checker (it was still written)

因此这个 guest 使用 poseidon2,并遵循使未填充海绵安全的规则:每种消息都有固定的长度,且每种消息带有自己的标签字。

在循环中清零缓冲区,是你不会注意到的最昂贵的一行。 Merkle 遍历的第一个版本是最直观的写法:一个函数为每个节点新建一个 [0u32; 17],把两个子节点复制进去,哈希,然后返回摘要。四笔转账用了 26,124 个周期,放不进 tier 14,即一次调用可使用的最高层级。三处修改,都不改变所证明的内容:

修改四笔转账的周期数
每个节点一个新清零的缓冲区和一个路径数组26,124
每次遍历一个缓冲区,仅清零一次;不使用以零填充的路径数组15,897
之前和之后的遍历一并完成,每个兄弟节点只读一次15,445
运行中的哈希保留在哈希缓冲区中,即 syscall 留下它的位置12,393

循环内的 [0u32; 17] 会编译为每次迭代一次 memset 调用。syscall 把摘要写在其缓冲区的前八个字之上,因此把消息排布为 left ‖ right ‖ tag,意味着摘要恰好位于下一层左子节点所在的位置。对 target/riscv32im-unknown-none-elf/release/ 下的 ELF 运行 llvm-objdump -d,可以看到周期消耗在哪里。

5. 主机端

构建路径需要在机器之外使用 Poseidon2。circuits 检出中已包含它:rand_zkvm::hash::sponge_hash 是 POSEIDON2 syscall 所计算的海绵的主机端函数。ledger-host 是一个小型 Rust 二进制程序,通过路径依赖它:

[dependencies]
rand_zkvm = { path = "../../research" }
ledger-host owner   <secret: 32 hex>                   the owner id of a secret
ledger-host genesis <accounts file> <state file>       build a tree, write its state
ledger-host root    <state file>                       the state's root
ledger-host step    <state file> <transfers file> <new state file>
                                                       stdout: the private words
                                                       stderr: old root, new root, link
ledger-host link    <old state file> <new state file> <count>
                                                       the link a receipt must show

状态文件有 256 行,每行为 owner balance nonce。演示账本有六个已注资的账户。它们的 secret 在 demo-accounts.txt 中,并且与这些页面上的每个演示一样,是有意公开的。余额的单位为 10⁻⁸。

$ cd my-guests/ledger-host && cargo +1.98.1 build --release && cd ../ledger
$ alias ledger-host=../ledger-host/target/release/ledger-host
$ cat demo-accounts.txt
de0f9e0173fe8358df1de251d81b4bec 50000000000
350826bc703c63ee3497f907373397f0 12000000000
8d5684dd764886843e4660d2c1ef12cc 7550000000
89442144b6673a3f93a5aa2359ddc6cf 0
99e9f82eede0b8b9feca124c0e1808be 100000000000
f29afdfbf0b821aa6c52f21cedc4c14a 4200000000
$ ledger-host genesis demo-accounts.txt state-0.txt
root bd1e27e17c0a0fe6e102f4c2197ed8721f22fccc99657860f035d1f5a4d1a568

转账文件每行为一条 from to amount secret:

$ cat batch-1.txt
0 3 12500000000 de0f9e0173fe8358df1de251d81b4bec
4 1 99900000000 99e9f82eede0b8b9feca124c0e1808be
3 2 2500000000 89442144b6673a3f93a5aa2359ddc6cf
1 5 1500000000 350826bc703c63ee3497f907373397f0
$ ledger-host step state-0.txt batch-1.txt state-1.txt > witness-1.txt
old root bd1e27e17c0a0fe6e102f4c2197ed8721f22fccc99657860f035d1f5a4d1a568
new root c021ecd07d7ca8a04ee6dd7fce48052f2a88cf2b38040387066849b8e2e87e91
link bcf87def900bb2978b6c24bfaaf6c9d357d58ba9
link words 3170401775 2416685719 2339120319 2868300243 1473612713
$ head -6 state-1.txt
ff2a2fadae3eb422e7f8810b701d45c6 37500000000 1
d6f41168e6db1d63402588a7d50dda9c 110400000000 1
d240d9f11db1ed16b2902e4533c17015 10050000000 0
b357d759652ee04004d9c5c21a026d17 10000000000 1
7a179726cd16187bb760c0272c011cab 100000000 1
c393d03f75fcadfd1f1d7102040fc47b 5700000000 0

六个余额之和仍为 1,737.5。witness 为 593 个字:count、旧根,以及每笔转账 146 个字。

6. 构建与运行

$ rand-guest build my-guests/ledger --max-words 65535
773 words against a cap of 65535 (fits); 0 ecall(s) with a non-static a7
OK
wrote my-guests/ledger/image.bin and its .sha256 (773 words, hc 6bb2eccbe70a562774053da4631777d84f2bc68361f30f9be5c4aa16f0153814, program id c4ddd388f2d94299610c7c3409dfdfb4dcb4ae3e0bc9ec78d0c45f51d8231cab)
$ rand-guest run my-guests/ledger/image.bin --input $(cat witness-1.txt) --tier 14
out[0] = 1
out[1] = 4
out[2] = 3170401775
out[3] = 2416685719
out[4] = 2339120319
out[5] = 2868300243
out[6] = 1473612713
out[7] = 0
cycles 12394
tier 14
tier 14: fits (cycles 12739 of 16383, Poseidon2 permutations 1030 of 2048)

五个 link 字与主机工具的一致。拒绝情形如下,每一种都在模拟器中以针对 state-0.txt 的单笔转账批次运行:

批次out[0], out[1]
账户 2 发送的金额超过其持有量4, 0
账户 2 的转账使用账户 5 的 secret2, 0
一笔有效转账,随后账户 5 向自己付款6, 1
一笔基于 state-1.txt 构建的有效转账,旧根中有一个字被改动2, 0
一笔有效转账,接收方的余额被错误陈述3, 0
count 为 57, 0

7. 预算

一笔转账四笔转账
私有字155593
周期3,22112,394
Poseidon2 置换(含摘要)2,048 中的 1,030
层级1214

一笔转账需要 37 次哈希:32 个节点(两条长度为八的路径,每条都要为转账前和转账后的叶子各走一遍)、四个叶子和一个所有者 id。四笔转账后,tier-14 预算中仍有约 3,600 个周期未用。将上限提高到 5 的构建在 16,383 个周期中用 15,832 个跑完了五笔转账,刚好放得下,余量很小,因此已部署的程序止步于 4。

这里 Poseidon2 是廉价的哈希。每个普通周期为 1 gas,每个 Poseidon2 absorb 行为 3 gas,而一次 SHA-256 压缩为 64,一次 Keccak 置换为 192。本次调用的 149 次 Poseidon2 哈希合计约增加 2,500 gas,大致相当于 13 次 Keccak 置换的开销。匿名投票用了 12 次:

gas: 14110 (declaring 16384, tier 14)

12,740 行每行 1 gas,再加上 685 个 absorb 行各多 2,合计 14,110。

8. 部署

该程序没有公开段,因此它的 id 就是 rand-guest build 所打印的那个。

$ rand fee deploy 773
0.0783 RAND
$ rand program deploy my-guests/ledger/image.bin
program id: c4ddd388f2d94299610c7c3409dfdfb4dcb4ae3e0bc9ec78d0c45f51d8231cab (773 words, hc cbecb26b27560ae7a43d0574d877176383c62b4f9b0ff36116aac4e5143815f0)
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 10.8s: tier 10, 1370188 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 113.1s: tier 14, 1494406 bytes
submitted deploy 40a8e53d7e916c90db77504cf6601c443d7da0756e04c52745f350969659588f
  0 RAND out, 59.7762 RAND change, fee 0.0783 RAND, anchored at height 97945, proved in 113.1s (bundle) + 10.8s (auth)

一个已部署的程序可以服务任意数量的账本。两个账本的区别在于创世根,它们的收据彼此不会关联。

9. 调用

$ rand call c4ddd388f2d94299610c7c3409dfdfb4dcb4ae3e0bc9ec78d0c45f51d8231cab \
    $(sed 's/[0-9][0-9]*/--input &/g' witness-1.txt)
proving the call locally (593 inputs stay private)…
gas: 14110 (declaring 16384, tier 14)
fee 0.007489462 RAND (incl. two price steps of headroom)
proved in 114.8s: tier 14, 1498589 bytes, outputs [1, 4, 3170401775, 2416685719, 2339120319, 2868300243, 1473612713, 0]
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 19.9s: tier 10, 1374567 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 134.0s: tier 14, 1495974 bytes
submitted call 0ec66eed3bdeefc4a95741d26ad84f0a2d8198c4af3f7b1ec778e0525fa56a99
  0 RAND out, 59.701731664 RAND change, fee 0.007489462 RAND, anchored at height 98577, proved in 134.0s (bundle) + 19.9s (auth)
{
  "h_in": "68ea34ef767a30987c155a5a3cf0e69e832166b54611510ce0abc3430b8ff44c",
  "h_pub": null,
  "height": 98831,
  "index": 0,
  "outputs": [
    1,
    4,
    3170401775,
    2416685719,
    2339120319,
    2868300243,
    1473612713,
    0
  ],
  "program": "c4ddd388f2d94299610c7c3409dfdfb4dcb4ae3e0bc9ec78d0c45f51d8231cab",
  "tier": 14,
  "tx": "0ec66eed3bdeefc4a95741d26ad84f0a2d8198c4af3f7b1ec778e0525fa56a99"
}
input transcript published; open it with `rand open-call 0ec66eed3bdeefc4a95741d26ad84f0a2d8198c4af3f7b1ec778e0525fa56a99`

状态为 1,四笔转账,链接值为十六进制的 bcf87def 900bb297 8b6c24bf aaf6c9d3 57d58ba9。h_pub 为 null,因为该程序没有公开段。链上不记录任何账户、金额或根。

证明为 1,498,589 字节,这是不含哈希表的调用证明的大小:Poseidon2 已包含在每个证明之中,而 Keccak 和 SHA-256 各自还要带来一张独立的表。该调用在 Apple Silicon 笔记本上耗时略多于五分钟(调用证明 115 秒,花费授权 20 秒,捆绑 134 秒),峰值内存为 6.1 GB。

10. 自行跟进验证

持有 state-0.txt 并拿到 state-1.txt 的跟随者,用一条命令即可检查该收据:

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

这五个字是收据的输出 2 至 6,而 4 是它的输出 1。数量不同,或任一状态相差一个单位,得到的链接值都不同。

**第二步。**为本页,账本又前进了一步。batch-2.txt 是针对 state-1.txt 的两笔转账:

$ cat batch-2.txt
2 0 1050000000 8d5684dd764886843e4660d2c1ef12cc
5 4 700000000 f29afdfbf0b821aa6c52f21cedc4c14a
$ ledger-host step state-1.txt batch-2.txt state-2.txt > witness-2.txt
old root c021ecd07d7ca8a04ee6dd7fce48052f2a88cf2b38040387066849b8e2e87e91
new root a1f79db3379bc2a06c6da4326f122bb7bec04689329b79ba76f931cbb6ef7b47
link 2e2738c49dc6edcfd1349a6932145c12b23d366b
link words 774322372 2647059919 3509885545 840195090 2990356075
$ rand call c4ddd388f2d94299610c7c3409dfdfb4dcb4ae3e0bc9ec78d0c45f51d8231cab \
    $(sed 's/[0-9][0-9]*/--input &/g' witness-2.txt)
…
gas: 7240 (declaring 8192, tier 14)
fee 0.00645165 RAND (incl. two price steps of headroom)
proved in 100.9s: tier 14, 1516155 bytes, outputs [1, 2, 774322372, 2647059919, 3509885545, 840195090, 2990356075, 0]
…
submitted call 590f9814a9de00e6d667ac17587b3ea9742b745b6b43657876c42bc095ce8a99

它的旧根就是第一步的新根 c021ecd0…7e91。这就是保管链:在 chain 18 上,收据 0ec66eed…6a99 将创世状态链接到 state-1,收据 590f9814…8a99 将 state-1 链接到 state-2。在 chain 20 上,同样的两步、同样的根,对应的是 9c9998bb…66eb 和 dd756a89…ffc2。演示账本的当前状态,就是这两个批次从 demo-accounts.txt 推出的状态,因此任何人都可以重建它并执行第三步。

**打开调用。**钱包把每次调用的私有输入封存在一个信封里,只有调用者的查看密钥才能打开。调用者之后可以打开它,钱包会在找到的内容上重新运行该程序:

$ rand open-call 0ec66eed3bdeefc4a95741d26ad84f0a2d8198c4af3f7b1ec778e0525fa56a99
opened with the caller's viewing key: 593 input word(s)
inputs: [4, 3172870113, 2081034214, 3775067330, 427743346, 522386636, 2573564000, 4030058997, 2765202792, 0, 3, …]
emulator outputs: [1, 4, 3170401775, 2416685719, 2339120319, 2868300243, 1473612713, 0]
receipt outputs:  [1,4,3170401775,2416685719,2339120319,2868300243,1473612713,0]
verdict: faithful — these are the words the proof was made over, and they reproduce its outputs

这些输入包含旧根和每个发生变化的叶子,因此封存给审计方的信封(rand call --auditor <rand1…>)是把某一步的数据放到指定方始终能取得之处的一种办法。在 v0.6.7 中,open-call 对该程序可用,对其他三个程序则会失败:对于带有公开段的程序,它会先打开输入,然后以 re-running the program on these inputs failed: PublicIndex(0) 停止,因为重新运行时没有提供该程序的公开输入。

证明不能给你什么

  • **可用性。**链证明每一步都是有效的,但并不保存账本。执行某一步的人必须公布新的叶子,否则跟随者无法执行下一步。调用者若证明了一步却扣留数据,就会让所有接受该收据的人的账本停在那一点,因此真实部署需要一条对此的规则:由委员会为数据签名,或把变化的叶子发布在某个公开的地方。
  • **参与者之间的隐私。**链上除了一个计数和一个哈希之外什么也看不到。但付款方必须提供收款方的叶子,所以付款方知道收款方的余额,而任何持有状态文件的人都持有每一个余额。Rand 自己的 shielded 池用 note 而不是账户来避免这一点(Notes and the commitment tree)。本示例保留账户,是为了保持精简。
  • **排序。**从同一个根出发的两个有效步骤会冲突,只有链上顺序在前的那个算数。第二个调用者为一张无人接受的收据付了费,必须在新的根上重建。
  • **与 RAND 或代币的关联。**这些余额只是树中的数字。没有任何东西把它们转入或转出 shielded 池:程序的输出是数据,链不会据此执行任何操作。
  • **由持有者签名、由他人证明的转账。**拥有一个账户意味着知道一个哈希原像,而原像是私有输入,所以证明者必须是持有者。若要委托,程序内部需要做签名检查。Dilithium 或 ECDSA 的验证放不进一次 tier-14 调用。

最近一次核对所依据的 fullnode 提交为 48dc04b(链 18),日期为 2026-09-30,以及 circuits `aeacf31` (`guest-sdk/src/lib.rs`, `research/src/hash.rs`, `rand-guest`), the commit fullnode v0.6.7 vendors; every `rand-guest`, `ledger-host` and `rand` output on this page was produced on 2026-09-30, the deploy and the call on chain 18。 若代码与本页不一致,以代码为准;请告知我们。