零知识证明的工程落地,最终都要落到「电路」这一层。电路是计算过程的可验证表示:开发者把业务逻辑翻译成一组多项式约束,证明系统据此生成简洁证明,链上验证器只需常数级别的配对运算即可判定证明是否成立。理解约束系统与电路语言,是判断一个 ZK 应用是否安全、是否可用的前提。
本文以 Circom 2.1 与 SnarkJS 为主线,覆盖从约束建模、编译产物、证明生成到 Solidity 验证器部署的完整链路,并重点剖析欠约束漏洞这一类电路层面的系统性风险。读者应具备有限域算术与椭圆曲线配对的基本认知。
前置:/blockchain-zkp-privacy/(zk-SNARK 与证明系统的数学直觉)、/ethereum-evm-solidity/(EVM 与预编译合约)。
目录
- 1. ZK 电路的抽象:约束系统
- 2. Circom 语言与编译流程
- 3. 常用模板与信号语义
- 4. R1CS 与证明系统选择
- 5. Groth16 证明生成与 SnarkJS 工作流
- 6. 与 Solidity 验证器对接
- 7. 电路安全:欠约束与常见漏洞
- 8. 性能优化:约束数量与证明时间
- 9. 实战:Merkle 证明与隐私转账
- 10. 工具链与工程化实践
- 延伸阅读
1. ZK 电路的抽象:约束系统
1.1 从计算到约束
算术电路是一张由加法门与乘法门构成的有向无环图,所有运算都在有限域 F_p 上进行。电路不「执行」计算,而是把「输入与输出满足某种关系」这件事翻译成一组方程。证明者要证明的不是「我跑了某段程序」,而是「我掌握一组赋值,使得这组方程全部成立」。
以 out = a * b + c 为例,电路会引入中间信号 t,并生成两条约束:t = a * b 与 out = t + c。乘法门是昂贵的,加法门在 R1CS 中几乎免费,因此电路设计的核心目标是「用最少的乘法约束表达逻辑」。
1.2 R1CS 的数学形式
R1CS(Rank-1 Constraint System)把每条约束统一写成 (A · s) * (B · s) = (C · s),其中 s 是所有信号的赋值向量,A、B、C 是稀疏系数矩阵。一条约束对应一行。若电路有 n 个信号、m 条约束,则 A、B、C 各为 m×n 矩阵,s 为 n 维向量。
这种形式的精妙之处在于:任意多项式等式都能拆成若干个「秩 1」的乘积等式,而二次约束恰好是 Groth16 等配对型证明系统能够高效处理的上界。约束数量(constraints)而非代码行数,才是衡量电路复杂度的真实指标。下面这张表概括了常见操作的约束规模量级。
| 操作 | 约束数量量级 | 说明 |
|---|---|---|
| 乘法 | 1 | 单条 R1CS |
| Poseidon 哈希 | 约 240 | 面向电路设计 |
| Keccak256 | 约 150000 | 位运算代价高 |
| SHA256 | 约 27000 | 需要大量布尔约束 |
2. Circom 语言与编译流程
2.1 第一个电路
Circom 是一门面向约束系统的领域语言,语法接近 JavaScript,但语义围绕信号与约束展开。下面是一个乘法电路:
pragma circom 2.1.6;
template Multiplier2() {
signal input a;
signal input b;
signal output c;
c <== a * b;
}
component main = Multiplier2();
signal input 声明私有输入,signal output 声明输出。<== 同时完成赋值与约束添加:它既把 a * b 的值写入 c,又生成约束 c === a * b。component main 指定电路入口,方括号内 {public [a, b]} 可把部分输入声明为公开信号。
2.2 编译产物解析
编译命令会产出三份关键文件:
circom multiplier2.circom --r1cs --wasm --sym
snarkjs r1cs info multiplier2.r1cs
snarkjs r1cs print multiplier2.r1cs multiplier2.sym
.r1cs 是约束系统的二进制描述,.wasm 是见证生成器(witness generator),.sym 是信号与变量的符号映射。r1cs info 会打印约束数、私有输入数、公开输入数与输出数,是评估电路规模的第一手数据。r1cs print 则把约束还原成可读的多项式,是调试欠约束问题的主要手段。
3. 常用模板与信号语义
3.1 信号与变量
Circom 中必须区分两类量:signal 是不可变的、参与约束的电路连线;var 是编译期的中间变量,不进入 R1CS。赋值运算符同样分两类:<== 与 <--。
<-- 只赋值、不加约束,是电路漏洞的头号来源。典型错误写法是 out <-- in / 2,它告诉见证生成器如何算出 out,却没有约束 out 与 in 的关系,证明者可以任意伪造 out。正确做法是补一条 <== 或 ===:out * 2 === in。规则很简单:只要用了 <--,就必须有对应的 === 兜底。
3.2 标准库模板
circomlib 提供了经过审计的基础组件:Num2Bits 把域元素拆成二进制位,IsZero 判断是否为零,Poseidon 是面向电路优化的哈希,MerkleTreeInclusionProof 验证 Merkle 路径。下面展示 Num2Bits 的核心思想——用位分解把「比较」这种非线性操作转成线性约束:
template Num2Bits(n) {
signal input in;
signal output out[n];
var lc = 0;
for (var i = 0; i < n; i++) {
out[i] <-- (in >> i) & 1;
out[i] * (out[i] - 1) === 0;
lc += out[i] * (2 ** i);
}
lc === in;
}
注意 out[i] <-- ... 用的是单侧赋值,但紧跟的 out[i] * (out[i] - 1) === 0 强制每一位是 0 或 1,最后的 lc === in 保证分解之和等于原值。少了任何一条,电路就是欠约束的。
4. R1CS 与证明系统选择
4.1 约束系统到证明系统
R1CS 只是中间表示,真正的证明由后端系统生成。不同后端对可信设置、证明大小、验证开销的取舍差异巨大。Groth16 把 R1CS 编译为 QAP,证明只有 3 个群元素,链上验证约 25 万 gas;PLONK 使用通用可信设置,电路变更无需重新做仪式;Halo2 基于内积论证,完全不需要可信设置;STARK 是透明且后量子安全的,但证明体积在百 KB 级别。
4.2 选型对比
| 系统 | 可信设置 | 证明大小 | 链上验证 Gas | 适用场景 |
|---|---|---|---|---|
| Groth16 | 每电路一次 | 约 200 字节 | 约 250000 | 链上验证、固定电路 |
| PLONK | 通用一次 | 约 800 字节 | 约 300000 | 电路频繁迭代 |
| Halo2 | 无需 | 数 KB | 高 | 无仪式、zkEVM |
| STARK | 无需 | 数十 KB | 极高 | 后量子、L2 聚合 |
选型的第一原则是:如果电路固定且验证在链上,选 Groth16;如果电路会迭代或不愿承担仪式成本,选 PLONK 系;如果追求透明性与抗量子,选 STARK 系。
5. Groth16 证明生成与 SnarkJS 工作流
5.1 可信设置与 Powers of Tau
Groth16 需要一个与电路绑定的结构化参考串(CRS),它由「Powers of Tau」仪式产生。仪式的本质是多方依次对同一组随机数做贡献,只要有一方诚实地销毁了中间随机数,最终参数就是安全的。流程如下:
snarkjs powersoftau new bn128 12 pot12_0000.ptau -v
snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau --name=first
snarkjs powersoftau prepare phase2 pot12_final.ptau pot12_final.ptau
bn128 指 BN254 曲线,12 表示支持最多 2^12 条约束。仪式完成后得到最终的 pot12_final.ptau,它是公开可复用的。
5.2 生成证明
用电路专属的 zkey 生成证明,命令链条非常固定:
snarkjs groth16 setup multiplier2.r1cs pot12_final.ptau multiplier2_0000.zkey
snarkjs zkey contribute multiplier2_0000.zkey multiplier2_final.zkey --name=dev
snarkjs zkey export verificationkey multiplier2_final.zkey verification_key.json
snarkjs groth16 fullprove input.json multiplier2.wasm multiplier2_final.zkey proof.json public.json
snarkjs groth16 verify verification_key.json public.json proof.json
fullprove 一步完成见证生成与证明生成,适合开发调试;生产环境通常拆成 witness calculate 与 groth16 prove,以便见证复用。verify 返回 OK 表示证明有效。
6. 与 Solidity 验证器对接
6.1 导出验证器
SnarkJS 可以直接导出 Solidity 验证器:
snarkjs zkey export solidityverifier multiplier2_final.zkey Groth16Verifier.sol
导出的合约包含一个 verifyProof 函数,签名固定为四个参数:
function verifyProof(
uint[2] memory a,
uint[2][2] memory b,
uint[2][3] memory c,
uint[1] memory input
) public view returns (bool)
a 是 G1 上的两个坐标,b 是 G2 上的四个坐标(按 [2][2] 排布),c 是 G1 上的两个坐标,input 是公开输入数组,其长度由电路公开信号数量决定。G1 与 G2 的坐标数不同,是配对函数 e(a, b) = e(c, delta) 的数学结构决定的。
6.2 链上调用与 Gas
验证器内部调用地址 0x08 的椭圆曲线配对预编译,一次 verifyProof 大约执行 3 次配对。业务合约的典型接法是把证明参数透传:
contract Mixer {
Groth16Verifier verifier;
function withdraw(
uint[2] calldata a,
uint[2][2] calldata b,
uint[2][3] calldata c,
uint[2] calldata input
) external {
require(verifier.verifyProof(a, b, c, input), "invalid proof");
}
}
Groth16 验证的 gas 与公开输入数量线性相关,每多一个公开输入约增加数千 gas,因此应尽量把公开输入压缩成哈希后传入。
7. 电路安全:欠约束与常见漏洞
7.1 欠约束信号
欠约束(under-constrained)是 ZK 电路独有的、也是最危险的漏洞类别。它指的是:电路允许某组信号取多个不同的值,而验证仍然通过。由于证明者可以选择任意满足约束的赋值,欠约束直接等价于「证明可以伪造」。
Tornado Cash 这类隐私转账电路,其安全完全依赖「承诺(commitment)与作废符(nullifier)都被完整约束」这一前提。历史上多起 ZK 项目事故都源于电路漏掉了某条约束:例如位分解模板忘记约束每一位为布尔值,或 selector 数组未约束其取值只能是 0/1,攻击者便能构造出金额不一致的假证明。Tornado Cash 的核心电路在设计上也特别强调对 Merkle 路径与作废符哈希的双重约束,任何一环缺失都会导致资金被无限提取。
7.2 审计与形式化工具
识别欠约束不能只靠人眼。常用手段有三类:静态分析工具 circomspect 会标记可疑的 <-- 与未约束信号;形式化验证工具 Ecne、CIVER 通过符号执行证明「每个信号都被唯一确定」;而最朴素的验证是模糊测试——对同一个电路构造多组见证,若存在两组不同的赋值都能通过约束检查,电路就是欠约束的。工程上建议把这三类工具纳入 CI。
8. 性能优化:约束数量与证明时间
8.1 约束数量优化
约束数量直接决定证明时间、内存与可信设置规模。优化手段包括:用 Poseidon 替代 Keccak256,把哈希的约束量从十几万降到几百;避免对大整数做 Num2Bits,改用范围检查与查表;把多个小约束合并成一条二次约束,减少 R1CS 行数。下面是一个范围检查的常见写法:
template RangeCheck(n) {
signal input in;
component bits = Num2Bits(n + 1);
bits.in <== in;
}
它用 n+1 位分解隐含地证明了 in < 2^n,比逐个比较更省约束。值得注意的是,约束数量与证明时间并非严格线性:Groth16 的证明时间大致随约束数线性增长,但见证生成与 MSM 运算在大电路上会显著变慢。
8.2 证明时间与内存
一条经验数据是:十万约束级别的 Groth16 电路,证明生成在普通笔记本上约需数秒到十几秒,内存占用数百 MB;百万约束级别则需要数十 GB 内存。因此大电路通常拆分成多个子电路,或用递归证明把验证本身也做成电路。Halo2 与 STARK 的证明时间更长,但不需要每电路仪式,适合证明生成与验证分离的场景。
9. 实战:Merkle 证明与隐私转账
9.1 电路设计
隐私转账的核心是:证明者知道某个承诺的预像,该承诺位于一棵 Merkle 树中,且该承诺尚未被花费。电路需要三个公开输入——Merkle 根、作废符哈希、接收地址——以及两个私有输入——秘密值与前缀。
9.2 完整电路
下面是一个精简版的提款电路:
pragma circom 2.1.6;
include "circomlib/circuits/poseidon.circom";
include "circomlib/circuits/merkleTree.circom";
template Withdraw(levels) {
signal input root;
signal input nullifierHash;
signal input secret;
signal input nullifier;
signal input pathElements[levels];
signal input pathIndices[levels];
component commitment = Poseidon(2);
commitment.inputs[0] <== secret;
commitment.inputs[1] <== nullifier;
component tree = MerkleTreeInclusionProof(levels);
tree.leaf <== commitment.out;
tree.root <== root;
for (var i = 0; i < levels; i++) {
tree.pathElements[i] <== pathElements[i];
tree.pathIndices[i] <== pathIndices[i];
}
component nullHash = Poseidon(1);
nullHash.inputs[0] <== nullifier;
nullHash.out === nullifierHash;
}
component main {public [root, nullifierHash]} = Withdraw(20);
关键点在于 nullHashHash === nullifierHash:它把私有输入 nullifier 与公开的作废符哈希绑定,合约据此记录已花费状态,防止重复提款。component main {public [root, nullifierHash]} 声明了两个公开输入,其余全部私有。这样验证者知道「有人证明了某笔存款的存在」,却不知道是谁。
10. 工具链与工程化实践
10.1 工具链
主流工具链包括:circom 编译器(Rust 实现,2.0 起大幅提速)、snarkjs 证明与仪式工具、circomlib 标准库、circomkit 测试框架。构建集成方面,hardhat-circom 把编译与证明生成接入 Hardhat 任务流,foundry 生态则通过脚本调用 circom 二进制。前端侧常用 snarkjs 的 WASM 版本在浏览器内生成证明,避免私密输入离开用户设备。
10.2 工程化
生产级 ZK 项目的工程实践包括:把 .r1cs、.zkey、verification_key.json 作为构建产物纳入版本管理或内容寻址存储;在 CI 中对每条电路跑约束数回归测试,约束数意外下降往往意味着漏约束;为可信设置仪式保留完整的贡献记录与熵证明;把验证器合约与业务合约分离部署,便于独立审计与升级。最后,任何上链前都应经过至少一次独立审计,重点检查 <-- 的每一处使用。
10.3 速查表与一句话记忆
| 环节 | 命令或要点 | 关键产物 |
|---|---|---|
| 编译电路 | circom c.circom –r1cs –wasm –sym | .r1cs / .wasm / .sym |
| 查看约束 | snarkjs r1cs info c.r1cs | 约束数量 |
| 打印约束 | snarkjs r1cs print c.r1cs c.sym | 可读多项式 |
| 可信设置 | snarkjs powersoftau new bn128 12 | .ptau |
| 电路 setup | snarkjs groth16 setup c.r1cs pot.zkey | _0000.zkey |
| 生成证明 | snarkjs groth16 fullprove input.json | proof.json |
| 本地验证 | snarkjs groth16 verify vk.json public.json | OK |
| 导出验证器 | snarkjs zkey export solidityverifier | Groth16Verifier.sol |
一句话记忆:<-- 只赋值不约束,用了它就必须补 ===;约束数量是电路的唯一硬通货,而欠约束等于证明可伪造。
延伸阅读
- /blockchain-zkp-privacy/ — 零知识证明与隐私计算:zk-SNARK/zk-STARK 的数学核心与 Tornado Cash 原理
- /blockchain-rollup-architecture/ — zkRollup 如何把电路证明用于 L2 扩容与状态验证
- /blockchain-security/ — 智能合约安全审计方法论,含验证器合约的常见风险
- /ethereum-evm-solidity/ — EVM 预编译合约与配对运算的底层机制
- /blockchain-account-abstraction-userop/ — 账户抽象与 ZK 在钱包隐私中的结合点
- Web3 区块链专题 — 区块链 Web3 专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。