ZK 电路开发实战:Circom 约束系统、Groth16 证明与链上验证器

从约束系统出发系统讲解 Circom 电路开发:R1CS 建模、模板与信号语义、Groth16 与 PLONK 选型、SnarkJS 工作流、Solidity 验证器对接,以及欠约束漏洞与约束数量优化。

零知识证明的工程落地,最终都要落到「电路」这一层。电路是计算过程的可验证表示:开发者把业务逻辑翻译成一组多项式约束,证明系统据此生成简洁证明,链上验证器只需常数级别的配对运算即可判定证明是否成立。理解约束系统与电路语言,是判断一个 ZK 应用是否安全、是否可用的前提。

本文以 Circom 2.1 与 SnarkJS 为主线,覆盖从约束建模、编译产物、证明生成到 Solidity 验证器部署的完整链路,并重点剖析欠约束漏洞这一类电路层面的系统性风险。读者应具备有限域算术与椭圆曲线配对的基本认知。

前置:zk-SNARK 与证明系统的数学直觉、EVM 与预编译合约。


目录


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
电路 setupsnarkjs groth16 setup c.r1cs pot.zkey_0000.zkey
生成证明snarkjs groth16 fullprove input.jsonproof.json
本地验证snarkjs groth16 verify vk.json public.jsonOK
导出验证器snarkjs zkey export solidityverifierGroth16Verifier.sol

一句话记忆:<-- 只赋值不约束,用了它就必须补 ===;约束数量是电路的唯一硬通货,而欠约束等于证明可伪造。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「区块链 Web3」更多文章

  1. DeFi 风险管理与清算:抵押率、清算机制与坏账处置
  2. MPC 钱包与密钥管理:门限签名、2-of-3 架构与攻击面分析
  3. 形式化验证实战:Certora、Halmos 与 Echidna 的规约、边界与 CI 集成