Rand Protocol
文档
核心概念

票据与承诺树

每一笔转账所依据的票据模型——承诺、作废符(nullifier)、信封、深度为 32 的树、锚点,以及锚点窗口和时间窗口——以及钱包如何扫描属于自己的票据。

使用本页来构建或审计任何读取池的东西:扫描自身票据的钱包、跟踪承诺与作废符的索引器,或检查一笔交易公布了什么的工具。这条链上的价值是一组票据;链上保存的是它们的承诺,从不保存其内容。

票据

票据是 { pk, from, amount, asset, time, r }:28 个 32 位字,共 112 字节,布局为 pk(8) from(8) amount_lo amount_hi asset time r(8)。pk 是所有者的票据公开值(由查看密钥派生),from 是发送方的票据公开值,amount 是以单位计的 u64(1 RAND = 10⁹ 单位),asset 是 u32(RAND 为 0,否则为代币注册表中该 RPL 代币的紧凑索引,无论是桥接的还是原生的;参见 RPL 代币),time 是发送方指定的区块高度,r 是八个随机的盲化字。

明文从不出现在链上。出现在链上的是:

  • 它的承诺 cm = H(CM, words),即在域标签 4 下对这 28 个字做的 Poseidon2 哈希,作为叶子追加到树中;
  • 它的信封,即以所有者地址密封的明文,只有所有者,或发送方通过其出站查看密钥,才能读取。自链 18 起(含链 21),信封还携带一条加密备注。链根据公开字段计算出的少数票据完全没有信封(见下文)。

花费票据会公布它的作废符 H(NF, nk, cm),即在域标签 3 下对所有者的查看密钥与承诺做的 Poseidon2 哈希。没有 nk,就无法把作废符与其承诺关联起来。作废符集合用来防止双花。

信封

Envelope { kem_ct, to_receiver, to_sender, body }
部分字节含义
kem_ct1,088面向接收方地址的 ML-KEM-768 封装
to_receiver60在封装得到的秘密之下包裹的每笔交易密钥 TxKey
to_sender60在发送方的出站查看密钥 ovk 之下包裹的同一个 TxKey
body652在 TxKey 之下的 112 字节票据与 512 字节备注字段(链 17 及之前为 140 字节,仅含票据本身)

每个部分都是 ChaCha20-Poly1305,前置一个随机的 12 字节 nonce 并附带 16 字节标签,关联数据是一个标签(rand-envelope-receiver、rand-envelope-sender、rand-envelope-body)后接票据的承诺,因此密钥无法打开不是由它密封的信封,且被打开的票据与其所在的叶子绑定。密文共 1,860 字节;加上 bincode 的长度前缀,传输时为 1,892 字节。链 20 的创世文件设置了 envelope_bytes: 1860,其账本拒绝任何其他长度的票据信封(envelope is 1348 bytes, this chain requires exactly 1860);除此之外不检查信封的其他任何方面。链 17 及之前,信封没有备注字段,为 1,348 字节(传输时 1,380 字节),账本只限制其不超过 2,048 字节,在创世文件没有 envelope_bytes 的链上这仍然是规则。

备注字段为 len (u16 LE) ‖ UTF-8 text ‖ zero padding,共 512 字节,因此文本最多 510 字节。自链 18 起,每个票据信封都有该字段,无论是否有备注:找零输出、虚拟输出和不带备注的支付都以相同方式填充,链上没有任何东西能区分备注、其长度或其缺失。备注不在票据、承诺或任何证明之中。凡是已经能读取该票据的人都能读取它:所有者、通过 ovk 的发送方,或获得该输出 TxKey 的任何人。格式错误的备注字段(长度错误、无效的 UTF-8、非零填充)会被视为没有备注打开,绝不会让所有者失去票据。备注可以是任何人写的文本,因为任何人都可以向公开地址支付一笔粉尘票据:应将其作为不可信内容显示,并替换其中的控制字符和 Unicode 格式字符,rand 钱包即是如此。rand send --memo 会把备注密封进支付输出;rand notes --memo 和 rand history --memo 会显示它。

树、锚点与窗口

承诺被追加到一棵深度为 32 的 Poseidon2 树中,最旧的在前,且永不移除。叶子 i 是链创建的第 i 个票据;创世存入票据是最初的叶子。无论转移什么,一个捆绑都会追加四个叶子和四个作废符,虚拟项也包括在内。空叶子全为零,节点为 H(NODE, left, right)(域标签 7)。节点只保留边界,因此共识状态从不保存叶子;rand_getWitness 按需从存储的叶子重建完整的树,这就是它成为节点提供的最昂贵读取的原因。

一个捆绑针对一个锚点(即树根)证明其输入的成员资格。有两条规则保证锚点是诚实的:

常量值规则
ANCHOR_WINDOW256 个区块;链 20 和 21 上为 1,024锚点必须是最近 window 个区块末根之一;树在区块中途经过的根永远不能作为锚点
TIME_WINDOW256 个区块;链 20 和 21 上为 1,024捆绑的 time 必须在 [height − window, height] 之内

除非创世文件设置了 proof_window_blocks(v0.6.8,256 至 4,096),否则两个窗口均为 256 个区块,该字段会同时取代这两者。链 20 和 21 设为 1,024;链 14 至 19 没有该字段。

设置窗口是因为证明需要真实的时间:一个 tier-14 捆绑证明在笔记本电脑上约需 100 秒(自链 17 起,钱包还要再生成第二个 tier-10 授权证明,约 7 秒)。在链 18 的 1.17 秒区块下,256 个区块约为五分钟,而慢速证明者上的一次委托证明在 294 秒时被拒绝;链 20 的 1,024 个区块按浏览器报告的 1.34 秒计约为 23 分钟。输掉这场竞赛的钱包会收到 anchor is not one of the last 1024 roots,然后重新证明。不带参数的 rand_getAnchor 返回头部的区块末根,这是证明者唯一应当针对其构建的锚点。

捆绑

每笔转移价值的交易都恰好携带一个固定的 4 入 4 出隐藏资产捆绑(自链 14 起;链 13 及之前为 2 入 2 出),由隐藏资产 zkVM guest 证明,其摘要 hc_bundle 固定在创世文件中(链 17 至 20 上为 60af094acfe65d85fdb18fb3d06cf9085dcf28c96e59e87f1ee527226e6e3fce,即捆绑 guest v3;链 14 和 15 上为 83d3a3704a1fcdb9bae7136c0a947ffa34bd53f055388395ed705fe8cacd0ef8,即 guest v1):

Bundle { anchor, nullifiers[4], commitments[4], fee, burn_a, burn_r, burn_asset, time, envelopes[4], proof,
         auth_commit, auth_proof }     // the last two since chain 17

输入输出的槽位 0–1 携带私有资产 A;槽位 2–3 始终携带 RAND。在仅涉及 RAND 的支付中,槽位 0–1 是虚拟项,价值和费用通过槽位 2–3 转移。在代币转移中,代币通过槽位 0–1 转移,RAND 费用由同一证明的槽位 2–3 支付。A 是证明的一个见证字,不在任何公开字段中,因此任何 RPL 代币的转移都是 Action::None,且 burn_a = burn_r = burn_asset = 0,与 RAND 支付的形态逐字段一致。

该证明声明:每个真实输入都是 anchor 之下的叶子且携带其槽位的资产;四个作废符就是所公布的那些;每一组都平衡(私有资产中 in0 + in1 = out0 + out1 + burn_a,RAND 中 in2 + in3 = out2 + out3 + fee + burn_r);并且花费者持有密钥——而不泄露是哪些叶子、哪些金额、哪种资产或是谁。三个销毁字段是捆绑中关于价值离开池的唯一公开声明:burn_a 和 burn_asset 仅在 TokenBurn 或 BridgeBurn 时非零,此时它们等于该操作的金额和资产;burn_r 是被销毁的 RAND,仅在 Bond(质押)和 RegisterAggregator(保证金)时非零。费用始终是 RAND。

自链 17 起,花费授权被拆分到两个证明上(拆分授权)。捆绑 guest v3 取查看密钥 nk 和一个盐,而此前的 guest 取花费密钥:它派生 pk_self = H(PK, nk),要求每个输入都由它拥有,并把 c = H(AUTH, nk ‖ salt) 并入其发布的摘要。第二个 guest,即授权 guest(创世 hc_auth,1e4e347f44cf86750b30a9a4bdf9ec9256efe353d4ff8017451eca7d195639c1),取花费密钥 sk 和同一个盐,派生 nk = H(NK, sk) 并发布相同的 c。捆绑把 c 作为 auth_commit,把授权 guest 的 tier-10 证明作为 auth_proof,账本要求两个证明都针对同一个交易绑定验证通过,且两个 c 的值一致。因此,持有 nk 和盐的证明者可以生成捆绑证明,但无法生成授权证明,委托证明正是建立在这一点之上。盐是每个捆绑 256 位新的随机比特,因此 auth_commit 是公开的,且不关联任何东西。自行证明的钱包也会生成这两个证明,因此自行证明的交易与委托证明的交易在链上看起来相同。

固定形状意味着槽位常常是占位项:占位输入是由花费者持有的零值票据(其废止符照样公布),占位输出是零金额票据,而找零输出即使找零为零也会存在。若无找零时交易的外观有所不同,就会泄露”没有找零”这一事实。对钱包的一个后果是:一个 bundle 的两组中,每组最多花费两张票据。一次 RAND 支付、一次质押、一次部署或一次调用,最多花费两张 RAND 票据;一次代币转账或销毁,最多花费两张该代币的票据,外加最多两张用于支付手续费的 RAND 票据。分散在同一组中三张或更多票据上的价值,必须先通过向自己的地址发送一笔交易来合并。

自 chain 14 起,证明还与其交易绑定:证明是针对整笔交易(证明字段置空)的哈希生成并据此验证的,因此无法被复制到另一个动作、其他信封或另一条链上;自 chain 20 起,该哈希还涵盖创世哈希,因此也无法被复制到与之共用同一 chain id 的链上(交易)。

哪些是公开的,哪些是隐藏的

链上公开隐藏
一笔转账,无论是 RAND 还是任意 RPL 代币锚点、全部四个废止符、全部四个承诺、手续费、burn_a = burn_r = burn_asset = 0、time、四个信封密文、证明、auth_commit 与授权证明谁发送、向谁支付、金额、备注、移动的是哪种资产、找零、花费了哪些叶子、哪些槽位是占位项
一次代币销毁或桥销毁转账公布的全部内容,其中 burn_asset 与 burn_a 等于该动作的资产与金额(对于桥销毁,还包括中继费、后备代币与目的地)销毁了哪些票据,以及谁销毁了它们
一次水龙头铸造承诺、信封、金额、铸造的验证节点,以及自 chain 14 起票据的开启信息(pk、time、r),准入时据此重新计算承诺该票据之后的每一次花费
一笔创世存入创世文件中的 { cm, envelope, amount, opening: { pk, time, r } },对于所列代币的票据,开启信息中还带有 asset(chain 21 的创世包含三张 zUSD 票据,chain 20 的包含两张):所有者的 pk、金额和资产按设计是公开的它之后何时、花费到何处
一次提现、一次桥存入、一次代币铸造或一次注册的初始铸造金额、接收方、票据的 time 与盲化因子 r(链自行计算承诺)该票据之后的每一次花费
一次 invoke 的支付或程序铸造(自 chain 20 起)、一个多签账户的支付或一次永续合约的支付(chain 21)金额、资产、接收方与 r;票据的 time 取自该 bundle该票据之后的每一次花费
一张桥手续费票据(自 chain 20 起)金额、资产与 time、派生的盲化因子、作为所有者的创世金库,且没有信封该票据之后的每一次花费

价值以公开金额进入池中——水龙头铸造、创世存入、验证节点的提现、桥证明及其手续费票据、代币铸造、程序金库的支付——并以公开的手续费或销毁离开池。两者之间的一切都是票据。

该表是 chain 21 的证明所隐藏的内容,chain 20 的亦然,但情况并非一直如此。在 chain 14 和 15 上,bundle 证明本身披露了更多信息(2026-09-27 的 zkVM 审查,INT-2 / GV-1):每张表的 LogUp 累计总和未经盲化即被公布,而 v1 guest 对占位输入跳过了 Merkle 与资产检查,并对 Merkle 路径的每一位都做了分支,因此任何人都能从 bundle 证明中读出四个输入槽位中哪些是真实的,进而得知它移动的是代币还是仅 RAND,以及每个被花费叶子索引中值为 1 的位的个数。两项改动堵上了这一缺口。chain 16 改用约束集 7,它对每个 LogUp 终值进行盲化,并将每张已声明的表下限设为 2^7 行;同时改用无分支的 bundle guest v2,其中每个槽位,无论占位还是真实,都通过相同的指令执行完整的成员资格与资产检查。chain 17 至 20 运行的 guest v3 是以 nk 和盐取代花费密钥的 v2 关系,同样无分支。在 chain 14 和 15 上已提交的 bundle 仍保留其泄露的信息。在 chain 20 上,证明仍然显示的是其头部:对于 bundle,那是一个固定的形状(第 14 档,每个 bundle 声明的 gas 上限均为 20,479);对于 Call 或 Invoke,则是档位、声明的 gas 上限,以及该调用是否使用过 keccak 或 sha256 预编译、大致使用了多少次(证明)。

没有信封的票据

大多数票据是通过打开其信封找到的。有些票据由链根据公开字段计算得出,对这些票据而言,信封要么不存在,要么不可信。

  • 桥手续费票据(自 chain 20 起)。 在 bridge.fees 之下,链在每次存入和每次销毁时向创世金库追加一张 zUSD 票据(RPL 代币)。它没有信封:存储以一个空信封为其建立索引,该信封不向任何人加密,rand_getCompactBlocks 与 rand_getCommitments 也如此显示,存入对应的手续费票据排在存入票据之后,销毁对应的排在该 bundle 的四张票据之后。该票据的每个字都是公开的:金额来自交易与创世费率,资产与 time 来自交易,from = 0,盲化因子对存入而言为 blake3("rand-bridge-fee-r-1" ‖ mu),对销毁而言为 blake3("rand-bridge-burn-fee-r-1" ‖ txid),所有者为创世的 recipient。
  • 信封不绑定任何内容的票据。 桥存入、代币铸造、注册的初始铸造,以及自 chain 20 起的 invoke 支付与程序铸造(在 chain 21 上还包括多签账户的支付与永续合约的支付),都带有由提交该交易者加密的信封,但链根据该动作的公开字段计算票据,从不对照信封进行检查。粗心或怀有恶意的提交者可以加密垃圾数据,因此接收方不得依赖它。

因此,rand 钱包自行重建这些票据(wallet::rebuilt_notes_with),在与试解密并行的公开遍历中完成:对每一笔点名其自身 pk 的此类交易,以及当其地址为金库时的手续费票据,它计算出票据,并记录在其承诺哈希与之相符的叶子处。重建出的票据只是候选:谎报交易、费率或哈希的节点只会让票据找不到,而绝不会凭空记入一张票据。自 v0.6.8 起,rand_getBlocks 返回的区块头在 public_notes 中携带存入、铸造与注册交易,在有桥手续费的链上还包括销毁,证明已剥离,使用其真实 id,因此该遍历只读取区块头页。v0.6.8 中 invoke 不在 public_notes 里,所以面对 v0.6.8 节点时,钱包只能通过信封找到 invoke 的支付,而该信封由调用者加密,无人检查。Fullnode main 自 d0bb73fb 起也在其中携带 invoke,证明已剥离;这是一项仅限节点的改动,尚未推广到 chain 20 的机群,钱包无需为此做任何改动。

扫描:钱包如何找到自己的票据

节点回答的是”这是整棵树”,而绝不是”这是你的余额”。钱包:

  1. 用 rand_getCommitments(from_index, limit) 分页读取叶子(每次调用最多 1,000 个)——或者自 fullnode v0.8.0 起,用 rand_getScanLeaves(from_index, limit) 读取它们的扫描头:同样的行,信封截取至其前 1,208 字节(kem_ct ‖ to_receiver ‖ to_sender),这就是试解密所需的全部,因此钱包只对其密钥能打开的叶子读取完整信封,在 chain 20 的叶子上每个叶子的字节数少 2.26×,并在节点以 -32601 应答时回退到完整页——或者读取按区块的流 rand_getCompactBlocks(from_height, to_height)(每次调用最多 128 个区块);每个携带 bundle 的交易在此都列出四张票据和四个废止符,任何读取者都无法区分占位槽位与真实槽位;
  2. 试解密每一个信封:作为接收方通过其 ML-KEM 解封装密钥,作为发送方通过 ovk;保留 pk 为自己的票据;并根据其交易重建带有公开金额的票据(见上);
  3. 计算每张保留票据的废止符,并标记出现在 rand_getNullifiers(from_height, limit) 中的那些;
  4. 对其余的求和。

rand_getTreeInfo 告知还需扫描多远:{ "next_index", "root", "nullifiers" }。花费时,钱包需要每个真实输入的 32 层 Merkle 路径。rand 钱包在扫描期间根据相同的 rand_getCommitments 分页自行构建承诺树的本地副本,并自行计算每一条路径,因此发送交易时向节点询问的唯一与树有关的问题是 rand_getAnchor,它不会指明任何叶子。rand_getWitness(index) 仍保留在节点上,供其他客户端使用。它会让节点知道调用方即将花费哪个叶子,而这曾是引入本地树之前资金池最大的泄露点:不得不使用它的客户端应运行自己的节点,或者在请求所需见证的同时,一并请求并不需要的见证。

rand sync        # scanned 41 leaves and 37 blocks; 1 notes, 1 unspent
rand notes       # index, asset, amount, height, spent, pending, memo
rand history     # notes this wallet created for others, opened through ovk

在 rand send --no-wait 与下一次 sync 之间,票据处于 pending 状态,直到 sync 看到其作废符,或区块高度超过 time + window(链 20 和 21 上为 1,024,读取自 rand_getLimits.proof_window_blocks;没有该字段时为 256),此后该交易包再也无法被接纳,票据重新可花费。

最近一次核对所依据的 fullnode 提交为 c0878c3f(链 21),日期为 2026-10-12。 若代码与本页不一致,以代码为准;请告知我们。