返回 Expert 笔记
Expert Day 226

Circom + snarkjs 端到端流程

完整 ZK 工作流:edit → compile → setup → prove → verify (off-chain & on-chain)

2026-12-13
Phase 4 - ZK电路开发实战 (Day 223-243)
ZKCircomsnarkjssolidity-verifierhardhat

日期: 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 始终返回 falsesoliditycalldata 命令
public 数量不一致revert "Invalid proof"检查 main 的 public list
Poseidon JS / Sol 不一致root mismatch用同一 hash impl
witness 浮点RangeErrorinput.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

性能优化建议

  1. 复用 ptau:把 Phase 1 的 pot{N}_final.ptau 上传到对象存储(IPFS / S3),CI 直接下载。
  2. Rapidsnark:snarkjs 是纯 JS 慢,生产用 rapidsnark(C++ + asm),prove 速度快 10×。
  3. Witness 计算 nativecircom --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

面试题

  1. 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 预编译完成。

  2. Q: snarkjs 输出的 pi_b 在调用 Solidity 时为什么要交换 c0/c1 顺序? A: snarkjs 用数学惯例(高阶系数在前),Solidity 预编译 bn256Pairing 要求 (c0, c1) 顺序。最佳做法:用 soliditycalldata 命令直接输出可用 calldata。

  3. 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 隐私池。