Circom + snarkjs 端到端流程
完整 ZK 工作流:edit → compile → setup → prove → verify (off-chain & on-chain)
日期: 2026-12-13 方向: ZK工程 / 电路开发 阶段: Phase 4 - ZK电路开发实战 (Day 223-243) 标签: #ZK #Circom #snarkjs #solidity-verifier #hardhat
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | 完整 ZK 工作流:edit → compile → setup → prove → verify (off-chain & on-chain) |
| 实操 | 把 Day 224 Merkle 电路完整串起来,部署 Verifier.sol 到本地 hardhat |
| 产出 | flow.md(自动化脚本)+ MerkleVerifier.sol + 调用脚本 + 测试 |
背景与定位
ZK 工程化的难点不在于写电路,而在于把整套链路跑通。一个完整的 ZK 应用需要:
[circom DSL] → [compile] → [r1cs/wasm] → [trusted setup]
↓
[input.json] → [witness.wtns] → [groth16 prove] → [proof.json + public.json]
↓
[verify off-chain] OR
[Solidity verifier on-chain]
每一步都有出错可能:版本不匹配、ptau 不够大、Solidity 调用 ABI 错、bytes32 vs uint256 转换、bn128 endianness。
完整自动化脚本 / Full Automation Script
scripts/build.sh
#!/usr/bin/env bash
set -euo pipefail
CIRCUIT="merkle"
LEVELS=20
PTAU=14 # 2^14 = 16384 ≥ 4400 constraints
mkdir -p build contracts
echo "=== 1. Compile $CIRCUIT.circom ==="
circom $CIRCUIT.circom \
--r1cs --wasm --sym --c \
-o build/ \
-l node_modules
echo "=== 2. R1CS info ==="
snarkjs r1cs info build/$CIRCUIT.r1cs
echo "=== 3. Generate input.json ==="
node scripts/gen_tree.js > build/input.json
echo "=== 4. Compute witness ==="
node build/${CIRCUIT}_js/generate_witness.js \
build/${CIRCUIT}_js/$CIRCUIT.wasm \
build/input.json \
build/witness.wtns
echo "=== 5. Powers of Tau (Phase 1) ==="
if [ ! -f build/pot${PTAU}_final.ptau ]; then
snarkjs powersoftau new bn128 $PTAU build/pot_0.ptau -v
snarkjs powersoftau contribute build/pot_0.ptau build/pot_1.ptau \
--name="dev" -v -e="$(date +%s)"
snarkjs powersoftau prepare phase2 build/pot_1.ptau \
build/pot${PTAU}_final.ptau -v
fi
echo "=== 6. Groth16 setup (Phase 2) ==="
snarkjs groth16 setup \
build/$CIRCUIT.r1cs \
build/pot${PTAU}_final.ptau \
build/${CIRCUIT}_0.zkey
snarkjs zkey contribute \
build/${CIRCUIT}_0.zkey \
build/${CIRCUIT}_final.zkey \
--name="dev" -v -e="$(date +%s)"
snarkjs zkey export verificationkey \
build/${CIRCUIT}_final.zkey \
build/vkey.json
echo "=== 7. Generate proof ==="
snarkjs groth16 prove \
build/${CIRCUIT}_final.zkey \
build/witness.wtns \
build/proof.json \
build/public.json
echo "=== 8. Verify off-chain ==="
snarkjs groth16 verify \
build/vkey.json \
build/public.json \
build/proof.json
echo "=== 9. Export Solidity verifier ==="
snarkjs zkey export solidityverifier \
build/${CIRCUIT}_final.zkey \
contracts/MerkleVerifier.sol
echo "=== 10. Export call data ==="
snarkjs zkey export soliditycalldata \
build/public.json \
build/proof.json \
> build/calldata.txt
echo "DONE. proof: $(wc -c < build/proof.json) bytes"
Makefile(包装版)
.PHONY: all clean prove verify deploy test
CIRCUIT ?= merkle
all: build prove verify
build:
bash scripts/build.sh
prove:
snarkjs groth16 prove \
build/$(CIRCUIT)_final.zkey build/witness.wtns \
build/proof.json build/public.json
verify:
snarkjs groth16 verify build/vkey.json build/public.json build/proof.json
deploy:
npx hardhat run scripts/deploy.ts --network localhost
test:
npx hardhat test
clean:
rm -rf build/ contracts/MerkleVerifier.sol
Solidity Verifier 集成
contracts/MerkleVerifier.sol(snarkjs 输出,节选)
snarkjs 输出标准 Groth16 verifier,关键接口:
// 自动生成
contract Groth16Verifier {
function verifyProof(
uint[2] calldata _pA,
uint[2][2] calldata _pB,
uint[2] calldata _pC,
uint[1] calldata _pubSignals // [root]
) public view returns (bool);
}
contracts/MerkleApp.sol(业务包装)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./MerkleVerifier.sol";
/**
* Application contract: tracks committed merkle root and verifies inclusion proofs.
*/
contract MerkleApp {
Groth16Verifier public immutable verifier;
bytes32 public latestRoot;
event ProofVerified(address indexed prover, uint256 root);
constructor(address _verifier) {
verifier = Groth16Verifier(_verifier);
}
function setRoot(bytes32 root) external {
// in real app, only authorized
latestRoot = root;
}
function proveInclusion(
uint[2] calldata pA,
uint[2][2] calldata pB,
uint[2] calldata pC,
uint[1] calldata pubSignals
) external returns (bool) {
require(uint256(latestRoot) == pubSignals[0], "root mismatch");
require(verifier.verifyProof(pA, pB, pC, pubSignals), "invalid proof");
emit ProofVerified(msg.sender, pubSignals[0]);
return true;
}
}
scripts/deploy.ts
import { ethers } from "hardhat";
async function main() {
const Verifier = await ethers.getContractFactory("Groth16Verifier");
const verifier = await Verifier.deploy();
await verifier.waitForDeployment();
console.log("Verifier:", await verifier.getAddress());
const App = await ethers.getContractFactory("MerkleApp");
const app = await App.deploy(await verifier.getAddress());
await app.waitForDeployment();
console.log("App :", await app.getAddress());
}
main().catch((e) => { console.error(e); process.exit(1); });
scripts/call_verify.ts
import { ethers } from "hardhat";
import * as fs from "fs";
async function main() {
const proof = JSON.parse(fs.readFileSync("build/proof.json", "utf8"));
const pub = JSON.parse(fs.readFileSync("build/public.json", "utf8"));
const app = await ethers.getContractAt("MerkleApp", process.env.APP!);
await app.setRoot("0x" + BigInt(pub[0]).toString(16).padStart(64, "0"));
// call calldata
const tx = await app.proveInclusion(
[proof.pi_a[0], proof.pi_a[1]],
[[proof.pi_b[0][1], proof.pi_b[0][0]], // ⚠️ swapped order!
[proof.pi_b[1][1], proof.pi_b[1][0]]],
[proof.pi_c[0], proof.pi_c[1]],
[pub[0]]
);
const r = await tx.wait();
console.log("verified, gas =", r!.gasUsed.toString());
}
main();
关键陷阱:proof.pi_b 元素顺序
snarkjs 输出 pi_b 是 [[x_c1, x_c0], [y_c1, y_c0]](数学上的 G2 元素),但 Solidity 的 bn128_pairing 预编译要求 [x_c0, x_c1] 顺序。
正确写法:
[[proof.pi_b[0][1], proof.pi_b[0][0]],
[proof.pi_b[1][1], proof.pi_b[1][0]]]
或直接用:
snarkjs zkey export soliditycalldata public.json proof.json
它会输出已经处理好的 calldata,可以直接拼到 tx data。
真实 gas 数据 / Real Gas Cost
$ npx hardhat test
MerkleApp
✓ should accept valid proof (gas: 287,453)
✓ should reject wrong root (gas: 234,012)
✓ should reject invalid proof (gas: 281,234)
gas breakdown:
- pairing 预编译 (
bn256Pairing): 113,000 gas (固定) - ECMUL × 1 (
bn256ScalarMul): 6,000 gas × n_pub - ECADD × n_pub: 150 gas × n_pub
- storage R/W: ~25,000
- Total: ~230k - 290k
常见陷阱总结
| 陷阱 | 表现 | 修复 |
|---|---|---|
| ptau 太小 | Error: Powers of Tau too small | 用更大的 PTAU 值 |
| pi_b 顺序错 | verify 始终返回 false | 用 soliditycalldata 命令 |
| public 数量不一致 | revert "Invalid proof" | 检查 main 的 public list |
| Poseidon JS / Sol 不一致 | root mismatch | 用同一 hash impl |
| witness 浮点 | RangeError | input.json 用 string 大数 |
| circom 版本 | unknown pragma | 升级 circom ≥ 2.1 |
生产工程化 / Production Engineering
CI/CD 集成(GitHub Actions)
- name: Setup ZK toolchain
run: |
npm install -g snarkjs@latest
cargo install --git https://github.com/iden3/circom
- name: Build circuit
run: make all
- name: Verify proof
run: make verify
- name: Hardhat tests
run: npx hardhat test
性能优化建议
- 复用 ptau:把 Phase 1 的
pot{N}_final.ptau上传到对象存储(IPFS / S3),CI 直接下载。 - Rapidsnark:snarkjs 是纯 JS 慢,生产用 rapidsnark(C++ + asm),prove 速度快 10×。
- Witness 计算 native:
circom --c生成 C++ witness 计算器,比 wasm 快 3×。
关键速查
# 一行 build & verify
circom c.circom --r1cs --wasm --sym -o build && \
node build/c_js/generate_witness.js build/c_js/c.wasm input.json build/w.wtns && \
snarkjs groth16 setup build/c.r1cs ptau.ptau zkey && \
snarkjs zkey contribute zkey final.zkey -e="x" && \
snarkjs groth16 prove final.zkey build/w.wtns proof.json public.json && \
snarkjs groth16 verify <(snarkjs zkey export verificationkey final.zkey) public.json proof.json
面试题
-
Q: 完整描述 Circom + snarkjs 的工作流,从 .circom 文件到链上验证。 A: (1) circom 编译产生 r1cs + wasm;(2) Powers of Tau Phase 1 通用 setup;(3) Groth16 Phase 2 circuit-specific setup 产生 zkey;(4) input.json + wasm 生成 witness;(5)
groth16 prove用 zkey + witness 产生 proof;(6) snarkjs 导出 Verifier.sol;(7) Solidity 调用 verifyProof,由 bn256_pairing 预编译完成。 -
Q: snarkjs 输出的
pi_b在调用 Solidity 时为什么要交换 c0/c1 顺序? A: snarkjs 用数学惯例(高阶系数在前),Solidity 预编译bn256Pairing要求 (c0, c1) 顺序。最佳做法:用soliditycalldata命令直接输出可用 calldata。 -
Q: 为什么 Groth16 verifier gas 几乎与电路大小无关? A: Groth16 proof 是 3 个 G1/G2 元素,verify 是固定的 1 次 pairing + n_public_input 次 ECMUL。无论电路 1k 还是 100M 约束,proof size 和 verify gas 都是常数。
明日预告
Day 227 — Tornado Cash 重构:简化版隐私混币器。把 Day 224 Merkle + Day 226 Verifier 串成一个完整的 deposit/withdraw 隐私池。