ZK 密码学是一类协议,允许证明者让验证者相信某个陈述为真,而不揭示其背后的见证。本文解释了证明者-验证者的心智模型,对比了 SNARK 与 STARK 的设计选择,并提供了一个可运行的工作流来验证证明并衡量证明与验证成本。文章还涵盖了破坏实际部署的故障模式,例如验证密钥不匹配、公共输入错误以及可信设置假设。目标是提供一种可复现的方法,适用于任何 ZK 技术栈,而非特定供应商的逐步指南。
ZK 密码学保证什么、不保证什么
零知识证明(ZKP)是一种协议,证明者让验证者相信某个陈述为真,而不传递该陈述真实性之外的任何信息。在 ZK 密码学中,陈述通常表示为公共实例与私有见证之间的关系:证明者知道一个见证 w,使得 R(instance, w) 成立,而验证者只知道这样的 w 存在。一个典型例子是证明知道与公钥对应的私钥,而不泄露该私钥。
三个属性定义了安全契约。完备性意味着拥有有效见证的诚实证明者总能说服验证者。可靠性意味着不诚实的证明者无法以可忽略的概率说服验证者相信一个错误陈述。零知识性意味着验证者除了陈述的真实性外,对见证一无所知。第四个属性,简洁性,是 SNARK 和 STARK 实用的原因:证明大小和验证时间相对于计算规模保持很小。
ZK 密码学不能提供的是:它不能隐藏证明被生成这一事实,如果你的证明者环境被攻破,它不能保护见证,也不能消除将证明绑定到正确公共输入的需要。大多数生产事故不是底层数学被攻破,而是电路、验证密钥和公共输入之间的不匹配。
- 完备性:有效见证总能通过验证。
- 可靠性:错误陈述无法被证明(除非以可忽略的概率)。
- 零知识性:验证者对见证一无所知。
- 简洁性:证明大小和验证成本相对于计算是次线性的。
构建前的假设与先决条件
在编写电路或调用验证者之前,先确定以下假设,以便你的测量有意义。你需要一个已定义的陈述和见证、选定的证明系统(SNARK 或 STARK 家族)、可信设置或透明设置的决策,以及能够复现相同公共输入的验证者环境。对于链上验证,你还需要一个可信的 RPC 端点来读取验证合约和事件日志;多链 RPC 端点指南介绍了如何选择,OnFinality 的 API 服务可作为验证交易的读取路径。
工具链版本比大多数软件更重要。Circom、snarkjs、Halo2、Plonky 和 Noir 的版本会在不同版本间改变约束系统和验证密钥。固定每个依赖项,并将确切版本与你发布的任何基准测试一起记录。如果你在链上验证,还要固定链和合约地址,因为部署在一个网络上的验证者不会存在于另一个网络;在假设验证者可访问之前,请检查支持的网路。
- 书面定义陈述和见证。
- 选择证明系统和设置模型(可信 vs 透明)。
- 固定工具链版本和电路提交哈希。
- 记录验证者环境和公共输入编码。
- 为链上验证检查选择 RPC 读取路径。
证明者-验证者心智模型
将 ZK 协议视为编译器流水线。你的应用逻辑被编译成算术电路或约束系统。证明者接收见证并生成证明对象。验证者接收证明、验证密钥和公共输入,并返回接受或拒绝。验证密钥源自电路,对于具有可信设置的 SNARK,还源自仪式参数。如果这三个输入中的任何一个发生变化,即使证明本身有效,验证也会失败。
交互式证明需要证明者和验证者之间的往返消息。非交互式证明将其折叠为单条消息,通常通过 Fiat-Shamir 变换实现,该变换对转录进行哈希以派生挑战值。许多实现错误就出在这里:如果证明者和验证者对不同的转录字段进行哈希,或以不同顺序哈希,派生的挑战就会分歧,验证就会失败。调试时,始终比较双方公共输入的确切字节编码和转录域分隔符。
- 电路定义关系。
- 证明者消费见证并生成证明。
- 验证者消费证明、验证密钥和公共输入。
- Fiat-Shamir 转录必须在双方逐字节匹配。
SNARK 与 STARK:选择证明系统
SNARK(简洁非交互式知识论证)通常产生小证明和快速验证,这就是它们在链上验证中占主导地位的原因。许多 SNARK 构造依赖椭圆曲线配对和可信设置,因此仪式及其有毒废料处理成为你安全模型的一部分。STARK(可扩展透明知识论证)使用基于哈希的承诺,避免可信设置,并可扩展到非常大的计算,但证明更大,验证更重。
选择是一种权衡,而非排名。如果你的验证者在智能合约内运行且 gas 预算紧张,小 SNARK 证明通常是实用选择。如果你需要透明性和后量子倾向的假设,STARK 家族系统具有吸引力。递归证明,即一个证明验证其他证明,让你可以在批次间摊销验证成本,在 rollup 设计中很常见。无论选择哪种,都要明确记录设置假设和安全级别,因为审计人员会问。
- SNARK:小证明、快速验证,通常需要可信设置。
- STARK:透明设置、更大的证明、更重的验证。
- 递归证明:在批次间摊销验证。
- 为审计人员记录设置假设和安全级别。
使用 snarkjs 的可运行验证工作流
以下过程使用 snarkjs(一个广泛使用的 JavaScript 工具链)验证 Groth16 证明。它假设你已经从之前的证明运行中获得了 proof.json、public.json 和 verification_key.json。该命令是确定性的:成功时退出码为 0 并打印 OK!,失败时退出码非零。在 CI 中运行它,以便每次电路更改都通过验证检查。
关键细节是 public.json 必须按照电路声明它们的精确顺序包含公共输入。重新排序信号,或在一个地方将字段元素编码为十进制字符串而在另一个地方编码为十六进制字符串,是有效证明验证失败的最常见原因。
npm install -g snarkjs@latest
# Verify a Groth16 proof against the verification key and public inputs
snarkjs groth16 verify verification_key.json public.json proof.json
# Expected output on success:
# [INFO] snarkJS: OK!通过 RPC 端点进行链上证明验证
对于链上验证者,证明在交易中提交,合约的 verifyProof 函数返回布尔值。你可以通过使用相同的 calldata 调用视图函数来在链下复现验证结果。下面的脚本通过 RPC 端点读取验证者合约并打印结果。将 RPC_URL、VERIFIER_ADDRESS 和证明参数替换为你自己的值;切勿提交真实密钥。
使用专用端点进行重复验证检查,以免在运行中途被限流。OnFinality 的专用节点服务提供隔离容量,RPC 定价解释了请求量如何映射到成本。在承诺之前进行快速提供商比较时,最佳以太坊 RPC API上的 RPC Assistant 说明是一个有用的起点。
const { ethers } = require("ethers");
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const verifier = new ethers.Contract(
process.env.VERIFIER_ADDRESS,
["function verifyProof(uint256[2] a, uint256[2][2] b, uint256[2] c, uint256[] publicInputs) view returns (bool)"],
provider
);
async function main() {
const ok = await verifier.verifyProof(
["0x...", "0x..."],
[["0x...", "0x..."], ["0x...", "0x..."]],
["0x...", "0x..."],
["1", "2"]
);
console.log("verified:", ok);
}
main().catch((e) => { console.error(e); process.exit(1); });可复现地衡量证明与验证成本
ZK 性能声明只有在测量方法可复现时才有用。记录电路提交、工具链版本、硬件(CPU 型号、核心数、RAM)以及使用的确切命令。每次测量至少运行五次,并报告中位数和分布范围,而非单次运行。将证明时间与验证时间分开,因为它们的扩展方式不同:证明随电路规模增长,而验证对于简洁系统大致保持恒定。
使用下表作为你的结果模板。用你自己的运行填充它;不要从文档复制数字,因为证明时间高度依赖硬件和版本。过程是:(1) 冻结电路和见证,(2) 运行证明者五次,(3) 运行验证者五次,(4) 记录中位数和最小/最大值,(5) 在任何工具链升级后重复以检测回归。
- | 指标 | 运行 1 | 运行 2 | 运行 3 | 中位数 | 分布范围 |
- | 证明时间 (s) | | | | | |
- | 验证时间 (ms) | | | | | |
- | 证明大小 (bytes) | | | | | |
- | 峰值 RAM (MB) | | | | | |
- | 链上 gas (验证) | | | | | |
预期验证结果及其解读
成功的链下验证打印 OK! 并退出码为 0。成功的链上验证从视图调用返回 true 且不发出回滚。将任何其他结果视为失败并捕获完整错误,因为错误消息通常能识别哪个输入发生了分歧。如果验证者以 InvalidProof 回滚,则证明或公共输入有误。如果以 gas 错误回滚,则证明编码可能格式错误,或者验证者合约可能在你预期之外的网络上。
在每次验证结果旁边记录验证密钥哈希和公共输入哈希。当在暂存环境通过的证明在生产环境失败时,比较这两个哈希可以立即告诉你电路变了还是输入变了。这一单一实践解决了大多数跨环境验证失败。
- 退出码 0 和
OK!表示链下验证成功。 - 返回
true表示链上验证成功。 InvalidProof回滚指向证明或公共输入不匹配。- 每次运行记录验证密钥哈希和公共输入哈希。
常见 ZK 验证失败的故障排除
验证密钥不匹配。 证明是针对与验证者期望不同的电路版本生成的。从确切的电路提交重新生成验证密钥并重新部署验证者。公共输入顺序错误。 电路以一种顺序声明信号,而调用者以另一种顺序提供它们。将电路的公共信号列表与你传递的数组进行比较。编码不匹配。 在一个组件中编码为十进制字符串而在另一个组件中编码为十六进制的字段元素将不匹配。在边界处统一使用一种编码。
Fiat-Shamir 转录分歧。 证明者和验证者对不同的字段进行哈希或使用不同的域分隔符。比较两个实现中的转录构造。可信设置不匹配。 证明是使用与验证密钥不匹配的仪式参数生成的。使用正确的参数重新运行证明。过时的 RPC 状态。 链上验证读取的验证者合约已被升级或重新部署;在调试证明本身之前,确认地址和链 ID。
- 从确切的电路提交重新生成验证密钥。
- 将公共输入顺序与电路声明的信号匹配。
- 在每个边界处统一字段元素编码。
- 对齐 Fiat-Shamir 转录字段和域分隔符。
- 在调试数学之前确认验证者地址和链 ID。
局限性、权衡与运营风险
ZK 密码学转移信任而非消除信任。可信设置将风险集中在仪式中;被攻破的仪式可以伪造证明。透明设置消除了这种风险,但增加了证明大小和验证成本。递归证明增加了复杂性和新的故障模式。这些系统都不能保护通过证明者运行时侧信道泄露的见证,因此隔离证明环境并将见证材料视为生产秘密。
在运营上,证明是昂贵且突发的。在开发中运行舒适的证明者可能在处理大型见证时耗尽生产内存。为专用硬件做预算,监控峰值 RAM,并将证明作业排队,而不是在共享机器上并发运行。链上验证消耗的 gas 随证明编码和验证者实现而变化,因此在目标网络上测量 gas,而不是从另一条链估算。
- 可信设置集中仪式风险。
- 透明设置以证明大小换取信任消除。
- 证明者侧信道可能泄露见证。
- 证明是内存密集型的;隔离并排队作业。
- 在目标网络上测量 gas,而非另一条链。
生产 ZK 集成的后续步骤
首先正式编写陈述和见证,然后构建表达该关系的最小电路,并在添加功能之前端到端验证它。将 snarkjs 验证命令添加到 CI,以便每次电路更改都通过门控。对于链上验证者,添加针对固定合约地址和链 ID 的视图调用检查。记录测量部分的结果表,并在每次工具链升级后重新运行它。
当你准备将验证连接到实时网络时,选择符合你可靠性要求的 RPC 读取路径。查看 RPC 端点指南了解选择标准,比较 RPC 定价进行成本规划,并在验证流量需要隔离时使用专用节点。有关具体的端点和提供商建议,请咨询 RPC Assistant,而不是仅凭文档猜测。
- 在编码前形式化陈述和见证。
- 通过 CI 验证门控电路更改。
- 为链上检查固定验证者地址和链 ID。
- 每次升级后重新运行测量表。
- 选择符合可靠性需求的 RPC 读取路径。