托管方欠每位客户一笔余额。本程序证明这些余额之和不超过托管方已公布的储备金数额,而无需公布任何一笔余额或总额。每位客户只需几行 Python、无需任何特殊工具,即可核验自己的余额已被计入。
示例程序介绍了四个示例页面共用的准备工作。
1. 它证明什么
托管方构建一棵默克尔求和树(Merkle sum tree)。叶子对应一个账户:即客户 id 与余额的哈希,余额附于其旁。节点是其两个子节点及其和值的哈希,两个和值之和附于其旁。因此根节点携带其下所有余额的总和。
这棵树是在程序内部、根据私有的账户列表构建的。该调用证明:
- 收据中公布的根,是托管方所提供账户上的一棵求和树的根;
- 树中每个和值都是其下两个值的真实之和,且无溢出;
- 根处的总额不超过部署时确定的储备金数额。
第 2 点是未附证明而公布的求和树所无法提供的。针对裸求和树的已知攻击是:托管方把父节点的和值写得小于其子节点之和,而任何客户仅凭一条路径都无法察觉。在这里,和值由程序计算得出,而针对任何其他算术结果的证明并不存在。
该证明不涵盖完整性:哪些账户被纳入由托管方选择。这一缺口由客户来弥补。每位客户核验:已公布的根之下存在一个带有其 id 和余额的叶子(第 7 节)。被遗漏的账户会被其所有者发现。
2. 接口
| 公开字 | 含义 |
|---|---|
| 0, 1 | 储备金数额,以资产最小单位计的 u64,先低位字,后高位字 |
| 2 | 快照标签;程序不读取它,但程序 id 会绑定它 |
| 私有字 | 含义 |
|---|---|
| 0 | 账户数 n,至多 32 |
| 之后,每个账户 | id(16 字节,按四个大端字表示)、余额低位字、余额高位字 |
| 输出槽 | 含义 |
|---|---|
| 0 | 状态:1 已覆盖,2 未覆盖,3 账户超过 32 个,4 某个和值溢出 |
| 1 | n |
| 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 个账户 | |
|---|---|---|
| 私有字 | 73 | 193 |
| 周期 | 7,492 | 10,681 |
| SHA-256 压缩次数 | 75 | 95 |
| 层级 | 14 | 14 |
压缩次数为: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. 自行核验
- 声明。 对程序 id 调用
rand_getProgramPublic,会返回储备金额和标签。托管方是否真的持有该储备,不在本证明的范围之内:可由链上余额、审计师函件来证明;对于桥接资产,则可由该链自身的rand_getTokenSupply来证明。 - 代码。 在同一个 circuits 提交上重新构建
my-guests/solvency,并将hc与程序的code_hash比较。 - 回执。 状态为
1,槽位 7 不是3735928559,且program等于该 id。输出 2 至 6 是每位客户据以核验的根。 - 你自己的叶子,见第 7 节。
任何人都可以用演示账户重复这次调用,也可以修改某个余额,观察根如何变化。
本证明不能给你什么
- 储备本身。 本证明针对的是负债相对于某个数字的证明。这个数字需要它自己的证据。
- 没有客户参与就无法保证完整性。 托管方遗漏的账户,只有该账户所属的客户才能发现。核验的客户越多,可供遗漏的空间就越小。
- 与储备相吻合的时间点。 托管方可以在快照的那一小时内借入资金。在不公布的时间频繁进行快照,可以缩小这一空间。
- 隐藏的相邻节点。 客户的路径会显示兄弟节点的总和(见第 7 节)。
- 单次调用中超过 32 个账户。 参见预算。