CKB零知识投票:用锁脚本哈希替代nullifier

kashortgirl 发布于 2026-06-20 阅读 69

本文是CKB零知识证明投票系列的第二篇笔记,探讨了去重原语的选择。

无 nullifier 的去重:这个设计从 CKB 借用了什么

注意:这是我个人的研究笔记,记录我在探索 CKB 上的零知识证明时学到的和发现的内容。欢迎讨论和反馈。

上一篇:研究笔记:ZK on CKB:笔记 01

我在笔记 01 结尾留下了一个开放问题。这个投票设计的可靠性论证成立:投票不会被遗漏,存款不会被重复计算。隐私是另一个独立的问题,答案也不同。一个公开的观察者观察 DAO 存款和投票 cell 在投票窗口期间的活动,能够将两者关联起来,并读出谁投了什么。我在这里停了下来,因为将匿名性叠加到一个可行的设计上,与最初的问题不同。

这篇笔记并不是一个隐私提议。在问“我们如何添加隐私?”之前,我想先了解这个设计使用了什么去重原语来代替 nullifier,以及为什么它不需要制造一个匿名的去重原语。结果发现,这本身就是一堂有用的设计课。隐私问题本身,即如何将匿名性叠加到可行的设计上,留待后续笔记。

Nullifier:它们是什么,做什么

Nullifier 是一个一次性标签,表明“这个秘密已经被使用过”。它是 Semaphore 和 MACI 等匿名防重复投票系统背后的核心原语,借鉴自 Zcash 防止双花的方式。

(关于 nullifier 推导和验证的可运行代码示例,我在这里构建了一个。本文的其余部分假定你了解基本机制。)

通常的配方是:

nullifier = hash(secret_key, context)

其中 context 界定了动作的范围:一个项目 ID、一个周期,或者任何标识“你正在投票的对象”的东西。从这个结构中,你可以获得四个属性:

  • 确定性:相同的秘密 + 相同的上下文产生相同的 nullifier
  • 唯一性:不同的秘密产生不同的 nullifier
  • 不可链接性:nullifier 不泄露关于秘密或投票者的任何信息
  • 不可伪造性:只有秘密持有者才能产生正确的 nullifier

在投票流程中,投票者的秘密承诺存储在一个合格投票者的 Merkle 树中。当他们投票时,他们发布 nullifier 以及一个 zk 证明,证明两件事:

  • 成员身份:他们知道一个秘密,其承诺在树中
  • 诚实推导:nullifier 是使用上述配方从该秘密计算出来的,而不是凭空捏造的

验证者维护一个已见集合;如果 nullifier 已经存在,则拒绝第二次投票。

这个原语在 zk 投票中无处不在,因为它能制造出唯一但匿名的身份。链可以知道“无论这个人是谁,他们已经投过票了”,而无需知道他们是谁。

但这个设计跳过了它们

所以我开始在这个设计中寻找 nullifier 方案。具体来说:一个投票者承诺的 Merkle 树,每个投票 cell 上的一个 nullifier 字段,证明公开值中的一个 seen_tags 集合,一个从投票者秘密推导一次性标签的电路。我指的是那些常规组件!

但一个都没有。

提案 cell 的 args 中包含一个 Type ID 和一个 SP1 验证密钥哈希。投票 cell 的数据是 { vote, amount, dao_index }。证明的 PublicValues 是 { proposal, start_block_hash, end_block_hash, proposal_script, passed, yes_vote, no_vote }。SP1 guest 中没有任何 Poseidon 哈希、Merkle 成员资格证明、秘密集合的承诺。

guest 确实构建了 Merkle 树,但用途不同:验证每个区块的 transactions_root 字段与该区块中的实际交易是否匹配。相同的数据结构,不同的目的。与投票者资格无关,与隐藏身份无关。

然而它确实能够防重复投票。笔记 01 验证了这一点。

那么,如果没有 nullifier 来做去重,那到底是什么呢?这就是这篇笔记剩余部分要讲的内容。

CKB 的做法

去重机制是 SP1 guest 中的三行 Rust 代码。

// 投票者锁定哈希 -> (方向: 0=否 / 1=是, 金额以 shannon 为单位)
let mut vote_map: BTreeMap<[u8; 32], (u8, u64)> = BTreeMap::new();

一个 BTreeMap。没有 nullifier 集合、没有 Merkle 树、没有 Poseidon。键是一个 32 字节的投票者身份。值是他们投票的内容以及他们持有的 stake 数量。

当 guest 在区块遍历过程中找到一个投票 cell 时,投票者身份计算如下:

let voter_lock_hash = blake2b_256(output.lock().as_slice());

cell 锁定脚本的 blake2b 哈希。锁定脚本是“谁可以花费这个 cell”的公开表达,通常是一个绑定到特定公钥的签名方案。两个具有相同锁定脚本的 cell 由同一个私钥持有者拥有。

然后去重本身:

vote_map.insert(voter_lock_hash, (direction, amount));

标准的 BTreeMap::insert。如果一个键已经存在,新值会覆盖旧值。这一个语义就是整个去重原语:来自相同 voter_lock_hash 的第二次投票会静默覆盖第一次。规范中的“投票撤回”功能实际上是这个行为的附带效果,而不是一个单独的机制。

那么,是什么阻止了投票者伪造这个身份呢?链上的投票类型脚本会验证投票 cell 的锁定是否与 DAO 存款的锁定一致。当 guest 在区块中看到投票 cell 时,CKB 共识已经证明了投票者控制该锁定对应的密钥。guest 永远不需要验证身份,链已经替它做过了。

为什么有效:UTXO 作为去重原语

Nullifier 制造了唯一但匿名的身份。链得知有人投了票,但不知道是谁。

CKB 的 UTXO 模型已经提供了唯一但公开的身份。链得知这个特定的锁定脚本投了票,并且知道谁的密钥控制着该锁定脚本。

这种不对称性就是问题的全部原因。如果你的去重原语需要匿名性,你就必须在电路内部制造身份,因为链无法在不暴露身份的情况下告诉你谁是谁。Nullifier 正是做这件事的。如果你的去重原语不需要匿名性,你可以完全跳过制造步骤,直接从底层读取身份。

CKB 免费为你提供了这一点:一个锁定脚本受签名保护,只有持有相应密钥的人才能授权创建带有该锁定脚本的 cell 的交易。每次区块被接受时,共识都会强制验证这一点。两个具有相同锁定脚本的 cell 可证明来自同一个密钥持有者。这与 nullifier 提供的唯一性保证相同,只不过它来自共识而非密码学,并且表现为一个 32 字节的哈希,你可以直接用作 BTreeMap 的键。

你不需要添加一个原语。你借用已经存在的东西。

权衡

我逐渐认识到,两种原语之间的选择归结为一个问题:匿名性是否在威胁模型之内?如果是,设计就必须在零知识中制造身份(nullifier);如果不是,设计就可以直接从链上读取身份(即本文中的锁定脚本哈希)。

属性 Nullifier 制造 UTXO 已提供
投票者唯一性
匿名性
不可伪造性 通过秘密知识 通过签名门控
电路成本
适用链 任何链 UTXO 风格链

当“观察者知道谁投了票”本身就是一种失败场景时,nullifier 才是正确的原语。我遇到过的典型例子包括:举报者投票、反共谋 DAO 治理(这是 MACI 构建的用例)、公司董事会投票(其中报复是真实存在的),以及必须可证明成员身份而无需透露具体成员的身份系统。代价是实实在在的:额外的原语、更大的电路、需要维护的 Merkle 树,以及随之增长的审计范围。

在公开归属可以接受或者本身就是设计意图的情况下,所有这些机制都不需要。对于基于 stake 的公开治理,投票 cell 必须发布 dao_index(指向投票者的 DAO 存款)和 amount(stake 权重),以便 guest 验证计票。这两个字段都标识了投票者。dao_index 指向一个公开拥有的 cell。amount 与可公开索引的 stake 余额相关。在投票 cell 上添加 nullifier 并不能隐藏协议要求该 cell 必须发布的内容。这就是这个设计所做的权衡。

额外的防御:每个 DAO 存款的去重

锁定脚本去重能捕获身份重用,但它本身无法捕获“取出存款,在新地址重新存入,然后再次投票”。不同的锁定脚本产生不同的 voter_lock_hash,从而在 vote_map 中留下两个条目。同样的 1,000 CKB stake 变成双重权重。笔记 01 在问题 2 中讨论了这种攻击。以下是击败它的代码级机制。

Guest 维护了第二个映射,与 vote_map 并列:

// DAO 存款 outpoint (36 字节) -> 投票者锁定哈希
let mut dao_outpoint_to_voter: BTreeMap<[u8; 36], [u8; 32]> = BTreeMap::new();

当记录一个投票 cell 时,它引用的每个 DAO 存款 outpoint 也会被记录,并映射到该投票者的锁定哈希。然后,投票窗口内的每笔交易都会被扫描:

for input in raw.inputs().iter() {
    let op_bytes: [u8; 36] = input.previous_output().as_slice().try_into().expect(...);
    if let Some(voter_lock_hash) = dao_outpoint_to_voter.remove(&op_bytes) {
        vote_map.remove(&voter_lock_hash);
    }
}

如果某个输入消耗了支持投票的 DAO outpoint,那么相应的投票就会从 vote_map 中驱逐。取出并重新投票的攻击在取出的那一刻就被终止了。当 Alice 的第一笔存款作为交易输入出现时,她的第一票便立即消失。到她的第二票从新地址注册时,她是一个拥有 1,000 CKB stake 的新投票者,而不是 2,000 CKB。

这是链上投票类型脚本在结构上无法进行的检查。类型脚本在投票创建时运行,它只看到一笔交易。它可以验证投票 cell 的锁定是否匹配它所引用的 DAO 存款,但它无法看到八个区块后该存款发生了什么。空间完整性由类型脚本负责,而时间完整性(投票窗口内发生的变化)则由 guest 负责。

我的收获

设计文档列出了九项功能。匿名性不在其中,但也没有被明确排除。这篇笔记试图指出这种省略背后隐含的权衡:该设计使用 CKB 的 UTXO 原生身份作为其去重原语,在没有为电路制造新的密码学原语的情况下获得了基于 stake 的唯一性,并接受公开归属作为代价。

让我印象深刻的并不是“跳过 nullifier”,而是:在寻求新原语之前,要先了解你的底层已经提供了什么。CKB 已经通过锁定脚本身份免费提供了“每投票者一票”的保证。在此基础上再添加 nullifier,对唯一性来说是多余的,对于 stake 机制无论如何都会泄露的隐私来说也是无效的。

笔记 01 结尾留下的那个更难的问题,即如何在不对已有功能造成破坏的前提下将隐私层叠加到这个设计上,仍然悬而未决。那将是下一篇笔记的内容,而不是这篇。

参考文献:

  • 原文链接: x.com/kashortgirl/status...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论