Rand Protocol
文档
示例程序

责任证明

一个机密程序,在私有客户余额之上构建 SHA-256 默克尔求和树,并证明总额由已公布的储备数字所覆盖——包括接口、Rust guest、客户用纯 Python 完成的包含性验证、预算,以及部署在 chain 18 上的该程序。

托管方欠每位客户一笔余额。本程序证明这些余额之和不超过托管方已公布的储备金数额,而无需公布任何一笔余额或总额。每位客户只需几行 Python、无需任何特殊工具,即可核验自己的余额已被计入。

示例程序介绍了四个示例页面共用的准备工作。

1. 它证明什么

托管方构建一棵默克尔求和树(Merkle sum tree)。叶子对应一个账户:即客户 id 与余额的哈希,余额附于其旁。节点是其两个子节点及其和值的哈希,两个和值之和附于其旁。因此根节点携带其下所有余额的总和。

这棵树是在程序内部、根据私有的账户列表构建的。该调用证明:

  1. 收据中公布的根,是托管方所提供账户上的一棵求和树的根;
  2. 树中每个和值都是其下两个值的真实之和,且无溢出;
  3. 根处的总额不超过部署时确定的储备金数额。

第 2 点是未附证明而公布的求和树所无法提供的。针对裸求和树的已知攻击是:托管方把父节点的和值写得小于其子节点之和,而任何客户仅凭一条路径都无法察觉。在这里,和值由程序计算得出,而针对任何其他算术结果的证明并不存在。

该证明不涵盖完整性:哪些账户被纳入由托管方选择。这一缺口由客户来弥补。每位客户核验:已公布的根之下存在一个带有其 id 和余额的叶子(第 7 节)。被遗漏的账户会被其所有者发现。

2. 接口

公开字含义
0, 1储备金数额,以资产最小单位计的 u64,先低位字,后高位字
2快照标签;程序不读取它,但程序 id 会绑定它
私有字含义
0账户数 n,至多 32
之后,每个账户id(16 字节,按四个大端字表示)、余额低位字、余额高位字
输出槽含义
0状态:1 已覆盖,2 未覆盖,3 账户超过 32 个,4 某个和值溢出
1n
2 至 6根哈希的前 20 字节,按五个大端字表示
7未使用;Rust panic 会在此处写入 0xdeadbeef

储备金数额位于公开段而不在输出中,原因有二。其一,没有空间:状态、账户数和 160 位的根已占满各槽。其二,它在证明出现之前就陈述了断言:部署程序即公布”储备金为 R,快照为 S”,随后该调用要么证明这些余额在 R 之内,要么返回状态 2。

哈希,确切定义如下:

leaf  = SHA-256( be32(0) ‖ id (16 bytes) ‖ be64(balance) )                                  28 bytes
node  = SHA-256( be32(1) ‖ left hash ‖ be64(left sum) ‖ right hash ‖ be64(right sum) )      84 bytes
empty = leaf with id 0 and balance 0

树始终有 32 个叶子。账户从左侧依次填入,其余叶子为空叶子。开头的 0 和 1 使叶子永远不会被误读为节点。

字节序。 SHA-256 全程为大端,而 SHA256 系统调用以十六个大端字的形式接收其 64 字节的块。因此本程序将 id 读作四个大端字,并将根以大端字发布:输出 2 以十六进制打印,即为根的前八位十六进制数字。这与 Keccak 程序的约定相反,因为这两种哈希是在相反的字节序上定义的。

3. 客户端程序(guest)

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

#![no_std]
#![no_main]

use guest_sdk::{halt, read_input, read_public, sha256_compress, write_output};

/// The tree always has 32 leaves. Accounts fill it from the left; the rest are empty leaves.
const LEAVES: usize = 32;

const SOLVENT: u32 = 1;
const INSOLVENT: u32 = 2;
const TOO_MANY: u32 = 3;
const OVERFLOW: u32 = 4;

const LEAF_TAG: u32 = 0;
const NODE_TAG: u32 = 1;

/// SHA-256's initial state (FIPS 180-4 §5.3.3).
const IV: [u32; 8] = [
    0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
];

#[derive(Clone, Copy)]
struct Node {
    hash: [u32; 8],
    sum: u64,
}

/// SHA-256 of the 28 bytes `be32(0) ‖ id (16 bytes) ‖ be64(balance)`. The message, its `0x80`
/// terminator and its bit length fit one 64-byte block, so a leaf is one compression.
fn leaf(id: &[u32; 4], balance: u64) -> Node {
    let mut buf = [0u32; 24];
    buf[0] = LEAF_TAG;
    buf[1..5].copy_from_slice(id);
    buf[5] = (balance >> 32) as u32;
    buf[6] = balance as u32;
    buf[7] = 0x8000_0000;
    buf[15] = 28 * 8;
    buf[16..].copy_from_slice(&IV);
    sha256_compress(&mut buf);
    let mut hash = [0u32; 8];
    hash.copy_from_slice(&buf[16..]);
    Node { hash, sum: balance }
}

/// SHA-256 of the 84 bytes `be32(1) ‖ left hash ‖ be64(left sum) ‖ right hash ‖ be64(right sum)`:
/// two compressions. `None` when the two sums do not fit a u64.
fn parent(l: &Node, r: &Node) -> Option<Node> {
    let sum = l.sum.checked_add(r.sum)?;
    let mut buf = [0u32; 24];
    buf[16..].copy_from_slice(&IV);
    // Block 1: words 0..16 of the message.
    buf[0] = NODE_TAG;
    buf[1..9].copy_from_slice(&l.hash);
    buf[9] = (l.sum >> 32) as u32;
    buf[10] = l.sum as u32;
    buf[11..16].copy_from_slice(&r.hash[..5]);
    sha256_compress(&mut buf);
    // Block 2: the last five words, the terminator, zeros, the bit length.
    buf[..3].copy_from_slice(&r.hash[5..]);
    buf[3] = (r.sum >> 32) as u32;
    buf[4] = r.sum as u32;
    buf[5] = 0x8000_0000;
    buf[6..15].fill(0);
    buf[15] = 84 * 8;
    sha256_compress(&mut buf);
    let mut hash = [0u32; 8];
    hash.copy_from_slice(&buf[16..]);
    Some(Node { hash, sum })
}

/// Public segment, fixed at deploy: [reserves low word, reserves high word, snapshot label].
/// Private input: [n, then n accounts of (id: 4 words, balance low, balance high)].
/// Outputs: [status, n, root (first 5 words), 0].
#[no_mangle]
pub extern "C" fn main() -> ! {
    let reserves = read_public(0) as u64 | (read_public(1) as u64) << 32;

    let n = read_input(0) as usize;
    if n > LEAVES {
        write_output(0, TOO_MANY);
        halt();
    }

    let empty = leaf(&[0; 4], 0);
    let mut row = [empty; LEAVES];
    for (i, slot) in row.iter_mut().enumerate().take(n) {
        let at = 1 + 6 * i as u32;
        let id = [read_input(at), read_input(at + 1), read_input(at + 2), read_input(at + 3)];
        let balance = read_input(at + 4) as u64 | (read_input(at + 5) as u64) << 32;
        *slot = leaf(&id, balance);
    }

    // Fold the row in place, a level at a time, until one node is left.
    let mut width = LEAVES;
    while width > 1 {
        width /= 2;
        for i in 0..width {
            match parent(&row[2 * i], &row[2 * i + 1]) {
                Some(p) => row[i] = p,
                None => {
                    write_output(0, OVERFLOW);
                    halt();
                }
            }
        }
    }
    let root = row[0];

    write_output(0, if root.sum <= reserves { SOLVENT } else { INSOLVENT });
    write_output(1, n as u32);
    for k in 0..5 {
        write_output(2 + k as u32, root.hash[k]);
    }
    halt();
}

填充是手工写出的。 guest_sdk::sha256 接受任意字节串,并逐字节复制。这里的每条消息都是长度已知的整数个字,因此程序直接填充 16 字的块:先是消息字,然后是 0x80000000,再是零,最后一个字为比特长度。叶子是一次压缩。节点的 84 字节会溢出到第二个块,因此节点是两次压缩。

余额为 u64,求和带检查。 余额不可能为负,因为该类型没有负值,这就排除了另一种缩小总额的经典手法。checked_add 把溢出转化为状态 4,而不是回绕后的和。

资不抵债的结果仍然是一个结果。 状态 2 是一次正常停机,并且根已公布。托管方无法为超出储备金数额的余额获得状态 1 的收据,而状态 2 的收据恰恰证明了这一点。

4. 主机端

solvency.py 仅使用 Python 标准库:

solvency.py setup   <accounts.csv> <reserves> <snapshot label>   writes public.txt, prints the root
solvency.py witness <accounts.csv>                 prints the private words
solvency.py path    <accounts.csv> <row>           prints that account's inclusion proof (JSON)
solvency.py verify  <proof.json> <root prefix hex> checks a proof against a receipt's root

其中的两个哈希就是客户所需的全部规范:

def leaf(ident: bytes, balance: int):
    return hashlib.sha256(struct.pack(">I", 0) + ident + struct.pack(">Q", balance)).digest(), balance

def parent(l, r):
    h = hashlib.sha256(struct.pack(">I", 1) + l[0] + struct.pack(">Q", l[1]) + r[0] + struct.pack(">Q", r[1]))
    return h.digest(), l[1] + r[1]

演示在 demo-accounts.csv 中有十二个账户,每行一个 id,balance,余额以 10⁻⁸ 为单位(zUSD 等桥接代币所用的精度)。它们合计 100,215.185678。托管方声称储备金为 101,000:

$ python3 solvency.py setup demo-accounts.csv 10100000000000 20260930
root 46b78564d60f75bdc9552d8ad1c4aadf411354054be3ffb80337b8549471a82c
root prefix 46b78564d60f75bdc9552d8ad1c4aadf41135405
root words 1186432356 3591337405 3377802634 3519326943 1091785733
liabilities 10021518567800
wrote public.txt
$ cat public.txt
2531887104 2351 20260930

2351 × 2³² + 2531887104 为 10,100,000,000,000。liabilities 一行是托管方自己的视角,从不公布。

id 必须唯一且不可猜测。 如果两位余额相同的客户被分配了相同的叶子,托管方就可以只为两人计一笔负债。只有其客户本人才能认出是自己的 id,可防止这种情况;而不可猜测的 id,则可阻止客户在看到相邻节点的哈希后,去试探邻居的余额。演示使用某个哈希的前 16 字节。真实部署会根据客户账户和每次快照的随机数(nonce)派生 id,并展示给客户。

5. 构建与运行

$ rand-guest build my-guests/solvency --max-words 65535
994 words against a cap of 65535 (fits); 0 ecall(s) with a non-static a7
OK
wrote my-guests/solvency/image.bin and its .sha256 (994 words, hc 5559791a05bd9269bf7fc4e893c05e8b21defe5ef966d831b49ea463fb4552d3, program id 697412b5db65647164c21b27e4b4feb0b5f0b9fce2381157cd0786bfecf1769f)
$ cd my-guests/solvency
$ python3 solvency.py witness demo-accounts.csv > witness.txt
$ rand-guest run image.bin --public $(cat public.txt) --input $(cat witness.txt) --tier 14
out[0] = 1
out[1] = 12
out[2] = 1186432356
out[3] = 3591337405
out[4] = 3377802634
out[5] = 3519326943
out[6] = 1091785733
out[7] = 0
cycles 7492
tier 14
tier 14: fits (cycles 7762 of 16383, Poseidon2 permutations 270 of 2048)

状态 1,十二个账户,以及脚本打印的五个根字。见证(witness)为 73 个字:账户数,加上每个账户六个字。

将储备金数额降至 100 个单位(--public 100 0 1)时,同样的账户得到 out[0] = 2。账户数为 33 时得到 out[0] = 3。两个余额之和超过 2⁶⁴ 的账户得到 out[0] = 4。

6. 预算

12 个账户32 个账户
私有字73193
周期7,49210,681
SHA-256 压缩次数7595
层级1414

压缩次数为:n 个叶子,一个空叶子(计算一次后复制),以及 31 个节点各两次。一次调用至多可使用 128 次压缩,而层级 14 是调用可使用的最高层级,因此32 个账户已接近此设计下一次调用所能证明的上限。64 个叶子的树需要 190 次压缩,无法容纳。

这是演示规模。超出该规模,意味着要把树拆分到多次调用中,并将对每棵子树的根和和值的承诺带入最终调用,这正是 Write a program 对任何超出预算的任务所描述的模式。本示例并未这样做。

自链 18 起,一次压缩的开销为 64 gas,而普通周期为 1。钱包会打印一次运行的确切 gas 及其声明的上限:

gas: 12489 (declaring 16384, tier 14)

每一行耗费 1 gas,一次压缩另加 63:7,764 行加 75 × 63 即 12,489。超过三分之一的 gas 用于哈希。(钱包比 rand-guest run 多计两行,这是链 18 为调用的公开段所增加的交易绑定所致。)

7. 客户的核验

托管方向每位客户提供其路径:五个层级上每一层的兄弟哈希和兄弟和值。

$ python3 solvency.py path demo-accounts.csv 6 > proof6.json
$ head -12 proof6.json
{
 "id": "f8f4e4a0c855aad0d0f9ae5bab108333",
 "balance": 1820025000000,
 "path": [
  {
   "hash": "e39eaaabec5cca1fe4c76ce656692be631a39e807876f93bdd7baf8b057d6997",
   "sum": 6600000000,
   "sibling_is_left": false
  },
  {
   "hash": "4732f069886c966e6429ee9063f73429bf153eb2575cf2a28f00f27b7d0d89c6",
   "sum": 31500000000,

客户根据自己的 id 和余额重新计算根,并将其前 20 字节与收据的输出 2 至 6 比较:

$ python3 solvency.py verify proof6.json 46b78564d60f75bdc9552d8ad1c4aadf41135405
root prefix 46b78564d60f75bdc9552d8ad1c4aadf41135405 MATCH

若路径对应的余额比树中的余额高出一个单位,则无法到达根:

$ python3 solvency.py verify tampered.json 46b78564d60f75bdc9552d8ad1c4aadf41135405
root prefix e9a1b4cbe49e412214b97afe73348fa7c555fa7a DOES NOT MATCH

整个核验就是把上面的 leaf 和 parent 函数应用五次。不想运行托管方脚本的客户可以自己编写。

路径会向客户展示其兄弟节点的和值。 上述路径的第一步显示相邻叶子持有 6,600,000,000 个单位,而每上升一步都会显示一棵子树的总额。这是普通求和树所固有的。每次快照打乱叶子顺序,可限制客户随时间推移所能了解的信息;而完全隐藏和值,则需要用承诺取代明文数字,本示例并未这样做。

8. 部署

$ rand fee deploy 994 --public-words 3
0.1007 RAND
$ rand program deploy image.bin --public public.txt
program id: fce8474dfdf80b9733e04fb72d4d8d19c40ba5ab646b3d867322daf4b9b599c0 (994 words, hc 1a7959556992bd05e8c47fbf8b5ec0935efede2131d866f963a49eb4d35245fb, public input 3 words, digest 70d5ae8935d646f7a0dd426c30aaf68e9bc382534d8a6ced90e363e129403608)
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 6.8s: tier 10, 1368908 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 116.7s: tier 14, 1500710 bytes
submitted deploy 39e8f35e0bdc7bc105ce942dc492b52f7a5efbb682ab6ebecc17cb310a161ada
  0 RAND out, 59.8545 RAND change, fee 0.1007 RAND, anchored at height 97831, proved in 116.7s (bundle) + 6.8s (auth)

程序 id 将储备金数额和快照标签与代码绑定在一起。新的快照即是一次新的部署,费用约为十分之一个 RAND,其 id 就是新断言的公开声明。

9. 调用

$ rand call fce8474dfdf80b9733e04fb72d4d8d19c40ba5ab646b3d867322daf4b9b599c0 \
    $(sed 's/[0-9][0-9]*/--input &/g' witness.txt)
proving the call locally (73 inputs stay private, over the program's 3-word public input)…
gas: 12489 (declaring 16384, tier 14)
fee 0.007487437 RAND (incl. two price steps of headroom)
proved in 122.9s: tier 14, 1911152 bytes, outputs [1, 12, 1186432356, 3591337405, 3377802634, 3519326943, 1091785733, 0]
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 6.8s: tier 10, 1375371 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 111.9s: tier 14, 1500198 bytes
submitted call 5d276bf7cb65cfcd2244ffd05baceaf6cb8082aeed6129a5e87e8737b3374e17
  0 RAND out, 59.709221126 RAND change, fee 0.007487437 RAND, anchored at height 98336, proved in 111.9s (bundle) + 6.8s (auth)
{
  "h_in": "b151f38fbbefd08c9f36e65da89f016ba7fc0ccdb52bddda6544e3d86372d331",
  "h_pub": "70d5ae8935d646f7a0dd426c30aaf68e9bc382534d8a6ced90e363e129403608",
  "height": 98574,
  "index": 0,
  "outputs": [
    1,
    12,
    1186432356,
    3591337405,
    3377802634,
    3519326943,
    1091785733,
    0
  ],
  "program": "fce8474dfdf80b9733e04fb72d4d8d19c40ba5ab646b3d867322daf4b9b599c0",
  "tier": 14,
  "tx": "5d276bf7cb65cfcd2244ffd05baceaf6cb8082aeed6129a5e87e8737b3374e17"
}
input transcript published; open it with `rand open-call 5d276bf7cb65cfcd2244ffd05baceaf6cb8082aeed6129a5e87e8737b3374e17`

状态 1,十二个账户,根为十六进制的 46b78564 d60f75bd c9552d8a d1c4aadf 41135405。交易中没有任何内容陈述某一笔余额或总额。

这是一次 14 级调用,所以调用证明是最慢的一环:123 秒,之后花费授权 7 秒,捆绑包 112 秒,合计五分钟,峰值内存 6.6 GB。证明大小为 1,911,152 字节,其中 SHA-256 表约占 0.4 MB。

10. 自行核验

  1. 声明。 对程序 id 调用 rand_getProgramPublic,会返回储备金额和标签。托管方是否真的持有该储备,不在本证明的范围之内:可由链上余额、审计师函件来证明;对于桥接资产,则可由该链自身的 rand_getTokenSupply 来证明。
  2. 代码。 在同一个 circuits 提交上重新构建 my-guests/solvency,并将 hc 与程序的 code_hash 比较。
  3. 回执。 状态为 1,槽位 7 不是 3735928559,且 program 等于该 id。输出 2 至 6 是每位客户据以核验的根。
  4. 你自己的叶子,见第 7 节。

任何人都可以用演示账户重复这次调用,也可以修改某个余额,观察根如何变化。

本证明不能给你什么

  • 储备本身。 本证明针对的是负债相对于某个数字的证明。这个数字需要它自己的证据。
  • 没有客户参与就无法保证完整性。 托管方遗漏的账户,只有该账户所属的客户才能发现。核验的客户越多,可供遗漏的空间就越小。
  • 与储备相吻合的时间点。 托管方可以在快照的那一小时内借入资金。在不公布的时间频繁进行快照,可以缩小这一空间。
  • 隐藏的相邻节点。 客户的路径会显示兄弟节点的总和(见第 7 节)。
  • 单次调用中超过 32 个账户。 参见预算。

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