该程序让固定名单中的成员投出一票,而不暴露自己是哪一位成员。名单是一棵由 Keccak-256 哈希构成的默克尔树,构建方式与 Ethereum 空投名单相同,因此同样的工具即可生成。该程序已部署在 chain 18 上,最后一节用你可以自行使用的演示成员来调用它。
Example programs 介绍了四个示例页面共用的准备工作:circuits 检出、钱包,以及在哪里获取源码。
1. 它证明什么
组织者持有一份成员名单。每位成员持有一个 32 字节的秘密值。组织者从不接触这些秘密值:每位成员交出一个叶子,即 keccak256(secret ‖ tag),组织者在这些叶子之上构建默克尔树。
一次投票就是一次调用。它在不泄露其他任何信息的前提下证明:
- 调用者知道某个秘密值,其叶子位于树中;
- 公布的选项属于允许的选项之一;
- 公布的作废符(nullifier)等于
keccak256(secret ‖ tag ‖ poll id)。
同一个秘密值在同一次投票中投出的每一票,作废符都相同;在其他投票中则各不相同。统计选票的人对每个作废符只计一次。作废符与叶子之间没有关联:要从作废符反推出叶子,需要知道秘密值。
**链不会拒绝第二次投票。**Rand 上的程序没有存储,因此无法记住作废符(Programs)。用同一个秘密值发起的两次调用都能通过验证,也都会留下回执。“一个作废符,一票”这条规则由计票方在回执之上执行。任何人都可以运行同样的计票,这正是计票结果可检验的原因。
2. 接口
默克尔树、投票和选项数量对每一票都相同,因此放在公开段中,在部署时固定。这样,程序 id 就只对应这一次投票,不对应其他任何投票。
| 公开字 | 含义 |
|---|---|
| 0 | 树深度 d,取值 1 到 20 |
| 1 | 投票 id |
| 2 | 选项数量;选项为 0 .. choices − 1 |
| 3 到 10 | 根,32 字节,表示为八个小端序字 |
| 私有字 | 含义 |
|---|---|
| 0 到 7 | 秘密值 |
| 8 | 成员的叶子索引 |
| 9 | 选项 |
| 10 起 | 默克尔路径:d 个兄弟节点,每个 8 个字,从叶子向上排列 |
| 输出槽 | 含义 |
|---|---|
| 0 | 状态:1 已投票,2 不是成员,3 选项超出范围,4 设置错误 |
| 1 | 选项 |
| 2 到 6 | 作废符的前 20 字节,表示为五个小端序字 |
| 7 | 未使用;Rust 的 panic 会在此处写入 0xdeadbeef |
一个 32 字节的哈希是八个字,而输出一共只有八个字。公布 160 位,就给状态和选项留出了空间。两位成员只有在发生哈希碰撞时才会得到相同的 160 位作废符,而找到这样的碰撞大约需要 2⁸⁰ 次尝试。
**字节序。**Keccak 以字节为单位运算。机器的 KECCAK 系统调用以字为单位接收状态,状态的第 i 个字节位于第 i / 4 个字的第 i mod 4 个字节。因此,每次读取四个字节并按小端序组合,32 字节就变成八个字,而公开段中的根,正是按这种方式读取的根的字节。
3. guest 程序
my-guests/allowlist/src/main.rs,完整代码:
#![no_std]
#![no_main]
use guest_sdk::{halt, keccak, read_input, read_public, write_output};
const MAX_DEPTH: u32 = 20;
const OK: u32 = 1;
const NOT_MEMBER: u32 = 2;
const BAD_CHOICE: u32 = 3;
const BAD_SETUP: u32 = 4;
/// The 32 bytes hashed after a secret to make its leaf: the ASCII tag, zero-padded.
const LEAF_TAG: [u32; 8] = tag_words(b"rand-allowlist-leaf-v1");
/// The first 28 bytes hashed after a secret to make its nullifier; the poll id is the last 4.
const NULL_TAG: [u32; 8] = tag_words(b"rand-allowlist-null-v1");
/// An ASCII tag as eight words: byte `i` of the tag is byte `i % 4` of word `i / 4`.
const fn tag_words(tag: &[u8]) -> [u32; 8] {
let mut w = [0u32; 8];
let mut i = 0;
while i < tag.len() {
w[i / 4] |= (tag[i] as u32) << (8 * (i % 4));
i += 1;
}
w
}
/// Keccak-256 of a 64-byte message, the hash Ethereum calls `keccak256(abi.encodePacked(a, b))`.
/// The message is one rate block, so this is one permutation: the two halves go into the first
/// 16 words of the state, `0x01` follows them, and `0x80` closes the 136-byte rate.
///
/// `a_second` is 0 or 1. When it is 1 the halves trade places. The place is computed as an
/// offset, not chosen by a branch, so both orders execute the same instructions.
fn hash2(a: &[u32; 8], b: &[u32; 8], a_second: u32) -> [u32; 8] {
let at = 8 * (a_second & 1) as usize;
let mut st = [0u32; 50];
st[at..at + 8].copy_from_slice(a);
st[8 - at..16 - at].copy_from_slice(b);
st[16] = 0x01;
st[33] = 0x8000_0000;
keccak(&mut st);
let mut out = [0u32; 8];
out.copy_from_slice(&st[..8]);
out
}
fn read8(at: u32) -> [u32; 8] {
let mut w = [0u32; 8];
for (i, x) in w.iter_mut().enumerate() {
*x = read_input(at + i as u32);
}
w
}
/// Public segment, fixed at deploy: [depth, poll id, number of choices, root (8 words)].
/// Private input: [secret (8 words), leaf index, choice, siblings (8 words per level)].
/// Outputs: [status, choice, nullifier (5 words), 0].
#[no_mangle]
pub extern "C" fn main() -> ! {
let depth = read_public(0);
let poll = read_public(1);
let choices = read_public(2);
if depth == 0 || depth > MAX_DEPTH {
write_output(0, BAD_SETUP);
halt();
}
let secret = read8(0);
let index = read_input(8);
let choice = read_input(9);
// Walk from the leaf to the root. Bit `level` of the index says whether the running hash
// is the right child at that level.
let mut cur = hash2(&secret, &LEAF_TAG, 0);
let mut level = 0;
while level < depth {
let sib = read8(10 + 8 * level);
cur = hash2(&cur, &sib, index >> level);
level += 1;
}
let mut diff = 0u32;
for k in 0..8 {
diff |= cur[k] ^ read_public(3 + k as u32);
}
let mut ctx = NULL_TAG;
ctx[7] = poll;
let nullifier = hash2(&secret, &ctx, 0);
if diff != 0 {
write_output(0, NOT_MEMBER);
} else if choice >= choices {
write_output(0, BAD_CHOICE);
} else {
write_output(0, OK);
write_output(1, choice);
for k in 0..5 {
write_output(2 + k as u32, nullifier[k]);
}
}
halt();
}
其中有三点值得借鉴。
每次哈希一次置换。guest_sdk::keccak256 可以处理任意字节串,代价是要做字节循环。这里的每条消息都恰好是 64 字节,小于 136 字节的速率,因此填充是两个常量字,哈希就是对一个通过字对字复制填充好的状态做一次 KECCAK 调用。以 Ethereum 的说法,hash2(a, b, 0) 就是对两个 bytes32 计算 keccak256(abi.encodePacked(a, b))。
**叶子和作废符不会与树节点混淆。**节点对两个 32 字节的子哈希求哈希。叶子对一个秘密值和一个固定的 ASCII 标签求哈希,作废符则对一个秘密值、另一个标签和投票 id 求哈希。叶子要冒充节点,它的某个子节点就必须等于该标签,而这意味着要找到一个 Keccak 原像。
**走路径时不对索引做分支。**第一个版本用掩码交换来选择两半的顺序,编译器又把掩码还原成了分支:叶子索引中每有一个 1 位,运行就多花 9 个周期。周期数本身不会公布,但调用所声明的 gas 上限是由它取整得到的(第 6 节),因此依赖于索引的运行可能泄露一点索引信息。改为把每一半写到计算出的偏移位置后问题得以解决:现在每个叶子索引的运行都是相同的 3,208 个周期。
4. 主机端
组织者和投票者需要与机器计算结果相同的 Keccak。allowlist.py 是一个无依赖的 Python 脚本,自带 Keccak-f[1600] 实现(Python 的 hashlib.sha3_256 是 SHA-3,其填充方式不同,会得到不同的摘要)。它承担两项工作:
allowlist.py setup <depth> <poll id> <choices> <secrets file> writes public.txt, prints the root
allowlist.py witness <depth> <poll id> <secrets file> <member> <choice>
prints the private words and the nullifier
整棵树,用脚本的术语表示:
LEAF_TAG = b"rand-allowlist-leaf-v1".ljust(32, b"\0")
NULL_TAG = b"rand-allowlist-null-v1".ljust(28, b"\0")
EMPTY = bytes(32) # an unused leaf: no secret hashes to it
def levels(depth, secrets):
row = [keccak256(s + LEAF_TAG) for s in secrets]
row += [EMPTY] * ((1 << depth) - len(row))
out = [row]
while len(row) > 1:
row = [keccak256(row[i] + row[i + 1]) for i in range(0, len(row), 2)]
out.append(row)
return out
nullifier = keccak256(secret + NULL_TAG + poll.to_bytes(4, "little"))
在生产环境中,组织者持有的是叶子而不是秘密值,每位投票者根据公布的叶子自行构建自己的见证。脚本之所以接受秘密值文件,是因为演示同时扮演了两种角色。
演示投票的深度为 10(1,024 个叶子),投票 id 为 1,有三个选项。其八位成员的秘密值在 demo-secrets.txt 中,每行一个 64 位十六进制秘密值,它们是有意公开的,以便任何人都可以调用该程序:
$ python3 allowlist.py setup 10 1 3 demo-secrets.txt
root ce8d6ef78a2ccaab688afa50ff969b0f89b23e7f32d8c3746848b46f773f4750
wrote public.txt
$ cat public.txt
10 1 3 4151217614 2882153610 1358596712 261854975 2134815369 1958991922 1874085992 1346846583
第一个根字 4151217614 即 0xf76e8dce:根的前四个字节 ce 8d 6e f7 按小端序读取的结果。
5. 构建与运行
$ rand-guest build my-guests/allowlist --max-words 65535
427 words against a cap of 65535 (fits); 0 ecall(s) with a non-static a7
OK
wrote my-guests/allowlist/image.bin and its .sha256 (427 words, hc 420876739f0987ae7f0fbd23a98312ed4452b7e77cf6153e424ed02aca7b5afe, program id 93c780859b60516408099dc45247a21aead612f0c65c667248253533dd932bb3)
5 号成员投给选项 2。见证有 90 个字:8 个用于秘密值,1 个用于索引,1 个用于选项,80 个用于路径。
$ cd my-guests/allowlist
$ python3 allowlist.py witness 10 1 demo-secrets.txt 5 2 > witness.txt
nullifier b5516c17d7f51034998f837217777deb8bc90886
nullifier words 392974773 873526743 1921224601 3950868247 2248722827
$ rand-guest run image.bin --public $(cat public.txt) --input $(cat witness.txt) --tier 14
out[0] = 1
out[1] = 2
out[2] = 392974773
out[3] = 873526743
out[4] = 1921224601
out[5] = 3950868247
out[6] = 2248722827
out[7] = 0
cycles 3208
tier 12
tier 14: fits (cycles 3342 of 16383, Poseidon2 permutations 134 of 2048)
这五个作废符字,与脚本在机器之外算出的结果一致。失败的情形会以状态码干净地停机,因此同样可以被证明。把秘密值的第一个字改掉而保持见证其余部分不变,得到 out[0] = 2,别无其他输出;成员在 3 个选项中请求选项 3,则得到 out[0] = 3。
6. 预算
| 程序 | 427 个字 |
| 周期 | 深度为 10 的树为 3,208;含摘要行时为 3,342 |
| Keccak 置换 | 12 次:一个叶子,十个节点,一个作废符。一次调用的上限为 128 |
| Poseidon2 置换 | 2,048 次中的 134 次,全部用于程序、输入和公开摘要 |
| 层级 | 12 |
深度每增加一层,成本是一次置换和大约 270 个周期。深度为 20 的树(一百万名成员)实测为 5,866 个周期和 22 次置换,对应层级 14,这是一次调用可使用的最高层级。
自 chain 18 起,调用需要支付 gas,而 Keccak 置换是代价高昂的指令:192 gas,普通周期则是 1。钱包会打印一次运行的精确 gas 以及为其声明的上限:
gas: 5636 (declaring 6144, tier 12)
运行的每一行花费 1 gas,每个 Keccak 行再多花 191:3,344 行加 12 × 191 等于 5,636,其中五分之二用于这十二次哈希。钱包统计的行数比 rand-guest run 多两行。在 chain 18 上,调用证明与其交易绑定,绑定信息作为八个额外的公开段字传递,这增加了两个摘要行。声明的上限 6,144,是精确数值向上取整到 1,024 的倍数。上限是公开的,精确数值则不公开。
携带 Keccak 表的证明很大。这个证明为 3,346,865 字节,而 chain 18 至 20 上的上限为 4,194,304(4 MiB)。上面深度为 20 的数字是模拟器的测量值,本页没有在该深度下生成过证明。
7. 部署
部署会携带公开段,因此费用按其 11 个字加上 427 个代码字计算:0.001 + 0.0001 × 438 = 0.0448 RAND。
$ rand fee deploy 427 --public-words 11
0.0448 RAND
$ rand program deploy image.bin --public public.txt
program id: ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970 (427 words, hc 73760842ae87099f23bd0f7fed1283a9e7b752443e15f67c2ad04e42fe5a7bca, public input 11 words, digest 2296cfca8db9d38bd0b7579056c9dea3c8d8005df7b42e2b1e954a75fed67510)
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 22.9s: tier 10, 1370827 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 190.2s: tier 14, 1497544 bytes
submitted deploy 05f8bfcbf9fccedbd53c3ba41bcc32dc8070ed3433364fc591b4f00699438de6
0 RAND out, 59.9552 RAND change, fee 0.0448 RAND, anchored at height 97632, proved in 190.2s (bundle) + 22.9s (auth)
deploy 打印出的 id ad2f719f…b970,与 rand-guest build 打印的不同。带有公开段时,id 为 blake3("rand-program-2", base_pc ‖ len ‖ words ‖ len ‖ public),因此它绑定了根、投票 id 和选项数量。对同一批成员再办一次投票,就是用不同的投票 id 把同一份代码再部署一次,得到不同的程序 id 和不同的作废符。
hc 的写法也不同:rand-guest 以大端序打印它的八个字,而钱包以小端序打印每个字的字节,所以 42087673 变成了 73760842。它们是同一个摘要。
$ rand program show ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970
{
"base_pc": 4096,
"code_hash": "73760842ae87099f23bd0f7fed1283a9e7b752443e15f67c2ad04e42fe5a7bca",
"deployed_at": 97821,
"id": "ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970",
"public_digest": "2296cfca8db9d38bd0b7579056c9dea3c8d8005df7b42e2b1e954a75fed67510",
"public_words_len": 11,
"words_len": 427
}
任何人都可以读回公开段。rand_getProgramPublic 以小端序字节返回这些字,因此根会按其打印时的样子出现:
$ curl -s https://rpc.randprotocol.org -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"rand_getProgramPublic","params":["ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970"]}'
{"id":1,"jsonrpc":"2.0","result":"0a0000000100000003000000ce8d6ef78a2ccaab688afa50ff969b0f89b23e7f32d8c3746848b46f773f4750"}
8. 投票
rand call 对每个私有字按十进制接收一个 --input。sed 把见证文件转换成这些参数:
$ rand call ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970 \
$(sed 's/[0-9][0-9]*/--input &/g' witness.txt)
proving the call locally (90 inputs stay private, over the program's 11-word public input)…
gas: 5636 (declaring 6144, tier 12)
fee 0.006191437 RAND (incl. two price steps of headroom)
proved in 29.2s: tier 12, 3346865 bytes, outputs [1, 2, 392974773, 873526743, 1921224601, 3950868247, 2248722827, 0]
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 8.2s: tier 10, 1367303 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 117.2s: tier 14, 1504260 bytes
submitted call 8aa13d1f76d28f63a46c30bb09ef44b1ba35083ca80ba7301997f98e5f783666
0 RAND out, 59.716708563 RAND change, fee 0.006191437 RAND, anchored at height 98185, proved in 117.2s (bundle) + 8.2s (auth)
{
"h_in": "ea71e7701e62d49cac37b239bef132178f481835027d62b34da45fd6cf2667c6",
"h_pub": "2296cfca8db9d38bd0b7579056c9dea3c8d8005df7b42e2b1e954a75fed67510",
"height": 98335,
"index": 0,
"outputs": [
1,
2,
392974773,
873526743,
1921224601,
3950868247,
2248722827,
0
],
"program": "ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970",
"tier": 12,
"tx": "8aa13d1f76d28f63a46c30bb09ef44b1ba35083ca80ba7301997f98e5f783666"
}
input transcript published; open it with `rand open-call 8aa13d1f76d28f63a46c30bb09ef44b1ba35083ca80ba7301997f98e5f783666`
回执是链上保留的内容:状态 1、选项 2,以及五个 nullifier 字,记录在该投票程序的 id 之下。h_pub 是投票公开段的摘要,与 program show 打印的值相同。h_in 是对 90 个私有字的加盐承诺,没有盐就无法打开任何内容。
这次调用在 Apple Silicon 笔记本上耗时三分钟,内存峰值为 6.1 GB:调用证明 29 秒,花费授权 8 秒,支付手续费的 bundle 117 秒。手续费 0.0062 RAND,主要花在字节数上。具体计算见 Four programs on chain 20。
转录记录一行。 默认情况下,钱包会把私有输入封入一个信封,只有调用者的查看密钥才能打开,并随调用一同发布。这是留给调用者的记录,而不是披露:其他任何人都无法打开。若投票者希望完全不留记录,可传入 --no-envelope。此后,包括投票者本人在内,任何人都再也无法证明这些输入是什么。
9. 统计选票
计票者只需要链本身:
- 投票。 获取程序的公开段,并对照已公布的叶子列表检查根(用
allowlist.py的levels重建这棵树)。从源码重建 guest,并将hc与程序的code_hash比较。 - 回执。 取出
program为该投票 id 且输出 0 为1的每一份回执。状态为其他值,或第 7 槽为3735928559的回执,都不是有效选票。 - 计数。 按输出 2 至 6 分组。每个 nullifier 只计一次,取其在链上顺序中的第一份回执,并把它的输出 1 加入计票结果。
为了让计票者有事可做,又发起了三次调用。成员 5 再次投票,这次投给选项 0。成员 2 投给选项 1。成员 3 投给选项 0,所用钱包只使用公共 RPC 端点。以下是成员 5 的第二次投票:
$ python3 allowlist.py witness 10 1 demo-secrets.txt 5 0 > witness.txt
nullifier b5516c17d7f51034998f837217777deb8bc90886
nullifier words 392974773 873526743 1921224601 3950868247 2248722827
$ rand call ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970 \
$(sed 's/[0-9][0-9]*/--input &/g' witness.txt)
…
proved in 31.0s: tier 12, 3345265 bytes, outputs [1, 0, 392974773, 873526743, 1921224601, 3950868247, 2248722827, 0]
…
submitted call 0f596b87f5137a165d6abc691dbe04f7533db619911d81449fea9ede602c40d0
链接受了第二次投票,这是应有之义:证明是有效的。它的回执带有与第一次相同的五个 nullifier 字。
tally.py 应用这三条规则。它从浏览器读取该程序的调用,并从公共 RPC 读取每份回执,因此既不需要钱包,也不需要节点:
$ python3 tally.py ad2f719f12d01c9f3c0962092b9898b75b0914bbb817031d51be13e54c05b970
8aa13d1f… height 98335 choice 2 nullifier b5516c17d7f51034… counted
0f596b87… height 99194 choice 0 nullifier b5516c17d7f51034… repeat, dropped
2db517df… height 99339 choice 1 nullifier 52f2455918bbc811… counted
fdbcc960… height 101833 choice 0 nullifier cd86c5454b809914… counted
tally: choice 0: 1, choice 1: 1, choice 2: 1
成员 5 的改票没有计入,而且四份回执中,除了 nullifier 本身,没有任何内容表明其中两份来自名单中的同一位成员。这场投票是开放的:你可以以八位演示成员中的任何一位身份投票,然后再次运行计票。
证明不能给你什么
- 一个秘密一票,而不是一人一票。 谁能获得叶子由组织者决定。登记了两个叶子的成员就有两票。
- 投票者的秘密只与其随机性一样可靠。 由可猜测的字符串派生的秘密,可以通过对猜测值做哈希并与已公布的叶子比较而被找出。请使用 32 个随机字节。
- 选项是公开的。 回执会显示某个 nullifier 投给了哪个选项。没有人能判断这个 nullifier 对应哪位成员,但投票人数很少且投票率公开的投票,是小样本统计,而不是密码学。
- 时间和手续费是元数据。 手续费 bundle 是屏蔽的,不指向任何人,但如果投票者为钱包充值后在清静的时段一分钟后就投票,就已经向观察者透露了一些信息。
- 计票在链下进行。 链只存储回执。统计回执并拒绝重复的 nullifier,是由读取回执的人来完成的。