CKB上零知识证明的可行性研究

kashortgirl 发布于 2026-06-10 阅读 35

本文探讨了在Nervos CKB区块链上实现零知识证明(ZK)的可行性。

我认为可能实现的目标,以及架构为何让这一切易于处理

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

这一切的起点

我花了一些时间研究 CKB,阅读规范、使用 Molecule 构建、研究 Cell 模型。后来我开始问一个不同的问题:不仅关注 CKB 能做什么,而是思考如果在它之上叠加零知识证明,它能实现什么。

这篇笔记是我试着诚实地思考这个问题的记录。它不是教程,也不是产品公告。它是在探讨架构是否支持我设想的那些功能,以及如果支持,又可能带来哪些可能性。

CKB 的设计方式

要理解为什么 ZK 适合 CKB,你需要先理解 CKB 在基础层面的运作方式。这里三个特性最为关键。

一切都是 Cell。

CKB 没有账户,只有 Cell:简单的容器,包含容量(锁在里面的 CKB 代币)、一个锁定脚本(谁可以花费它)以及一个数据字段(你想存储的任何字节)。你的余额并不是某个地方存储的数字,而是你控制的密钥所拥有的所有存活 Cell 的容量总和。

交易消耗 Cell 并生成新的 Cell。没有“更新”操作。旧的 Cell 消亡,新的 Cell 诞生。

交易
  输入   = 被消耗的 Cell
  输出   = 被创建的新 Cell

这种显式的消耗-产生模型意味着状态始终是可见、可移植且原子性的。每个状态转换都是一笔交易,每笔交易都是可验证的。

脚本只验证,不计算。

在以太坊上,智能合约运行计算、更新状态、调用其他合约、触发事件。在 CKB 上,脚本只做一件事:验证。锁定脚本验证花费者拥有正确的密钥,类型脚本验证状态转换是否有效。脚本返回成功或失败,仅此而已。

这是一个根本性的区别。CKB 并非为链上计算而设计,而是为链上验证而设计。

CKB-VM 运行 RISC-V。

大多数区块链在具有固定指令集的自定义虚拟机上运行合约。添加新操作需要新的操作码,而这需要硬分叉。

CKB-VM 运行 RISC-V——一个真实的硬件指令集。任何能编译为 RISC-V 的代码都可以作为 CKB 脚本运行,不需要特殊的操作码,也不需要修改协议。链不关心脚本内部做什么,它只负责运行并收取 cycles。

没有硬编码的密码学操作。默认的签名验证和哈希函数以可部署脚本的形式提供,而不是协议原语。新的密码学原语与普通代码一样以 Cell 形式部署。这对抗量子性有一个有趣的启示:如果出现一种抗量子签名方案,CKB 可以通过部署新脚本来采用它,而无需硬分叉。不过那是另一篇笔记的话题了。

为什么这自然地映射到 ZK

零知识证明包含一个证明者和一个验证者。

证明者    在链下运行昂贵的计算
          生成一个简短的密码学证明

验证者    廉价地检查证明
          从不重新运行计算
          得知结果,但不知道私有输入

现在再看看 CKB 的设计:

CKB 脚本    验证状态转换
            从不自己运行计算
            返回成功或失败

ZK 验证者   验证正确计算的证明
            从不自己重新运行计算
            返回有效或无效

它们在结构上是相同的。CKB 被设计为验证层,而 ZK 证明是需要被验证的东西。这种契合感觉并非巧合。

Cell 模型进一步强化了这一点。ZK 证明通常需要提交到状态:“我在证明关于这些数据当前状态的某些东西。”在 CKB 上,状态是显式的,存在于 Cell 中。一个 ZK 状态转换看起来像这样:

输入 Cell    = 旧状态
输出 Cell    = 新状态
见证         = ZK 证明

类型脚本:
  从输入 Cell 读取旧状态
  从输出 Cell 读取新状态
  验证 ZK 证明
  如果证明有效 -> 接受状态转换
  如果证明无效 -> 拒绝交易

旧状态、新状态、证明——三样东西,全部由现有的 CKB 原语处理,协议不需要任何特殊支持。

而且因为 CKB-VM 运行 RISC-V,你可以将任何 ZK 验证器(Groth16、PLONK、STARKs 等等)实现为原生脚本。将验证器编译成 RISC-V 二进制文件,作为 Cell 部署,然后由类型脚本调用。链运行它并收取 cycles。

Cycle 成本模型意味着什么

CKB 为执行的每一条 RISC-V 指令收取 cycles。没有隐藏成本,没有 Gas 估算的意外,也没有针对特定操作的特殊定价。

这对 ZK 很重要,因为 ZK 验证器是计算密集型的。一个 Groth16 验证器涉及椭圆曲线配对,这是应用密码学中最昂贵的操作之一。在以太坊上,运行验证器的成本取决于是否存在针对你的证明系统的预编译、该预编译如何定价以及区块允许的 Gas 限制。如果不存在针对你的证明系统的预编译,你需要为每个操作支付完整的 EVM Gas。

在 CKB 上,成本就是你的验证器在 RISC-V cycles 中执行所需的代价。旧的证明系统、新的证明系统、实验性的证明系统,都使用相同的定价模型、相同的部署流程、相同的规则。

我构建了一个 Groth16/BN254 验证器 来具体测试这一点。以下数据来自生产调用路径:验证密钥从 cell_dep 解码,证明从见证中读取,完整的配对检查运行在 riscv64imac CKB-VM 上:

每次验证的 cycles        约 1.02 亿
CKB 区块 cycle 限制       35 亿
每次验证占用的区块比例    ~2.9%

每次验证仅占用区块的 2.9%——这在实践中是可行的。这意味着一个可部署、可测量的 Groth16 验证器今天就已经存在于 CKB 上,而且相同的方法可以推广到任何其他能编译为 RISC-V 的证明系统。

哪些功能成为可能

有了这个基础,一些类别的应用在 CKB 上开始显得自然。大多数在技术上是其他链也能实现的,但区别在于:在 EVM 链上,成本和易用性取决于针对你的证明系统是否存在预编译,并且状态布局必须被压缩到键值抽象中;而在 CKB 上,验证器只是像其他代码一样部署的代码,状态只是 Cell 中的字节。

私密状态转换。

类型脚本可以在不知道生成证明的私有输入的情况下验证 ZK 证明。证明放在见证中,公共承诺(例如 nullifier、新状态根或输出承诺)放在输出 Cell 的数据字段中。链看到提交了一个有效证明,并且新的承诺格式正确,但它看不到被证明的具体内容。

无需透露身份的成员资格证明。

证明你属于某个集合,而不透露具体是哪个成员。资格集合作为 Merkle 根公开提交,以字节形式存储在 Cell 的数据字段中。证明显示你知道从叶子到根的路径。Cell 上的类型脚本验证包含证明,如果有效则接受花费。需要增长的 Nullifier 集合拥有自己的 Cell,由相同的脚本更新。没有注册合约,没有预编译,只有 Cell 和一个验证器脚本。

这与治理直接相关,用例是在不透露选民身份的情况下证明投票资格。

具有私有输入的可验证计算。

在链下运行计算,生成一个证明计算正确完成的证明,然后在链上提交证明和公共输出。链验证证明,任何人都可以确认结果正确,而无需重新运行计算或看到输入。唯二的约束是计算必须可以表示为你的证明者支持的电路。在此约束内,链上的故事保持不变。

证明聚合的批量更新。

在链下聚合许多状态转换,生成一个证明所有转换都有效的单个证明。然后提交一笔交易,其中包含一个携带旧聚合状态的输入 Cell、一个携带新聚合状态的输出 Cell,以及一个携带聚合证明的见证。Cell 模型干净地处理了验证方面,因为输入和输出已经分别表示了状态之前和之后。

完整的 Rollup 还需要数据可用性和一个不依赖操作者合作的退出机制,这些是单独的问题,仅凭验证者无法解决。但每个 Rollup 式设计所依赖的证明检查层可以嵌入 CKB,无需协议提供任何自定义支持。

一个具体原语:验证槽。

在构建 Groth16 验证器时,我最终得到了一个值得命名的小型可组合原语。一个 Cell 存在于链上,其类型脚本是验证器,并通过其类型脚本参数绑定到一个特定的验证密钥。验证器允许在没有证明的情况下创建 Cell,但需要有效证明才能花费它。这个 Cell 成为一个开放的验证槽,绑定到恰好一个计算。任何持有该计算有效证明的人都可以花费它。

这种“任何人都可以通过证明 X 来满足的有状态槽”在 CKB 上自然地组合,因为 Cell 是一等对象,拥有自己的类型脚本和自己的数据。在验证是对固定合约的函数调用的链上,这更难以清晰地表达。

我的发现

目前 CKB 上最具体的 ZK 应用是 Nervos DAO 财库的投票概念验证,它使用了 SP1 zkVM。在阅读相关内容时,有两个问题引起了我的注意。我将每个问题视为攻击场景,并追踪了访客程序以查看攻击在哪里失效。

问题 1. 证明者能否选择性地省略不利的投票?

设置:证明者希望将 NO 投票从计票中排除,以便本应失败的提案通过。有四个明显的途径。

  • 篡改区块体以删除一笔投票交易。区块头提交了 transactions_root。从修改后的区块体重新计算的 Merkle 根不再与区块头匹配。
  • 跳过整个区块以省略一系列投票。每个区块的 parent_hash 会与前一个区块的哈希值进行检查,间隙会破坏链。
  • 篡改过滤器,使诚实的 NO 投票看起来无效。过滤器是 Cell 自身的 code_hash、hash_type 和 args 的纯函数。一个真实的投票 Cell 具有它应有的值。
  • 对投票持续时间撒谎以提供更少的区块。持续时间从提案 Cell 自身的数据中读取,并通过起始区块哈希锚定到真实链。访客程序要求恰好 duration + 1 个区块。

执行大部分检查工作的逻辑位于 verify_block_integrity 中:

let prev_hash = header_hash(prev_block.header());
let parent_hash = byte32_to_arr(current_block.header().raw().parent_hash());
if prev_hash != parent_hash {
    return Err(Error::ParentHashMismatch { block_index: i });
}

这些不是临时的修补,而是源于一个特性:区块头哈希提交了其区块体及其父区块。破坏区块体,根就不匹配;破坏链,父哈希就不匹配。证明者没有任何灵活性。

问题 2. 投票者能否在多次提款中对同一笔 DAO 存款重复投票?

设置:Alice 存入 1000 CKB 到 address₁,投 YES,提款,再存入 1000 CKB 到 address₂,再次投 YES。目标是:用 1000 CKB 的质押获得 2000 CKB 的权重。

访客程序跟踪两个映射:dao_outpoint_to_voter 记录哪笔存款属于哪个投票者,vote_map 记录哪个投票者选择了什么。当 Alice 提取她的第一笔存款时,这笔存款作为交易输入出现。访客程序将每个输入视为潜在的消费事件:

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);
    }
}

在 Alice 的旧存款作为输入出现的瞬间,她的第一次投票被从计票中移除。当她的第二次投票从地址 2 注册时,她是一个拥有 1000 CKB 质押的新投票者。最终计数是一个 YES 投票,而不是两个。

一些相关的变体以及它们的结果:

  • 在投票窗口关闭后提款:投票保持有效,窗口已经结束。
  • 用别人的存款投票:链上投票类型脚本会拒绝(锁定必须与 DAO 存款所有者匹配)。
  • 两个投票者以某种方式共享一笔存款:同样的链上检查会拒绝它,访客程序还会在 outpoint 级别进行去重。

这些答案共有的模式

两次攻击失败的原因相同。设计迫使证明者通过密码学检查点,这些检查点的值无法被篡改,并且每个检查点都用于强制执行一个不变性。对于问题 1,每个区块必须哈希到一个值,该值链接到其邻居并通过 Merkle 根链接到其区块头;证明者无法选择区块中包含什么。对于问题 2,范围内的每笔消费都被处理并与活跃投票进行交叉引用;证明者无法悄悄忘记一次提款。

将它们联系在一起的是历史区块数据的不可变性。证明者不能总结、编辑或省略,他们被迫诚实地重放链,因为每一步都有一个密码学锚点,由验证者独立检查。

我从中获得的认识

我之前提到的架构契合正是使这种设计成为可能的首要原因。CKB-VM 运行 RISC-V 意味着可以在不更改协议的情况下部署一个真正的 SP1 验证器;Cell 模型意味着提案 Cell、投票 Cell 和证明检查可以在没有注册合约的情况下组合。结果是一个经得起推敲的可靠性故事。

在这个设计空间中,真正保持开放的是隐私,而不是可靠性。证明的中间状态将投票者身份与投票选择联系起来,而一个公开的观察者在投票窗口内观察 DAO 存款和投票 Cell 可以将两者关联起来。ZK 能否在不破坏现有功能的基础上,在此设计之上添加匿名层,这是一个与我最初提出的问题不同的问题——在我对此做出任何断言之前,值得进一步思考。

参考资料

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

相关文章

0 条评论