Rand Protocol
文档
示例程序

匿名投票

一个机密程序,在不透露具体成员身份的前提下证明其属于 Keccak Merkle 许可名单,并公布一个选择和一个阻止重复投票的 nullifier——涵盖接口、Rust guest、host 脚本、预算,以及已在 chain 18 上线的程序。

该程序让固定名单中的成员投出一票,而不暴露自己是哪一位成员。名单是一棵由 Keccak-256 哈希构成的默克尔树,构建方式与 Ethereum 空投名单相同,因此同样的工具即可生成。该程序已部署在 chain 18 上,最后一节用你可以自行使用的演示成员来调用它。

Example programs 介绍了四个示例页面共用的准备工作:circuits 检出、钱包,以及在哪里获取源码。

1. 它证明什么

组织者持有一份成员名单。每位成员持有一个 32 字节的秘密值。组织者从不接触这些秘密值:每位成员交出一个叶子,即 keccak256(secret ‖ tag),组织者在这些叶子之上构建默克尔树。

一次投票就是一次调用。它在不泄露其他任何信息的前提下证明:

  1. 调用者知道某个秘密值,其叶子位于树中;
  2. 公布的选项属于允许的选项之一;
  3. 公布的作废符(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. 统计选票

计票者只需要链本身:

  1. 投票。 获取程序的公开段,并对照已公布的叶子列表检查根(用 allowlist.py 的 levels 重建这棵树)。从源码重建 guest,并将 hc 与程序的 code_hash 比较。
  2. 回执。 取出 program 为该投票 id 且输出 0 为 1 的每一份回执。状态为其他值,或第 7 槽为 3735928559 的回执,都不是有效选票。
  3. 计数。 按输出 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,是由读取回执的人来完成的。

最近一次核对所依据的 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` and `rand` output on this page was produced on 2026-09-30, the deploy and the call on chain 18。 若代码与本页不一致,以代码为准;请告知我们。