返回 Expert 笔记
Expert Day 224

Circom 实战 1 — Merkle Proof 电路

Merkle tree 在 ZK 中的应用、IfElseSelector 模式、Poseidon-2 hash

2026-12-11
Phase 4 - ZK电路开发实战 (Day 223-243)
ZKCircomMerklePoseidoninclusion-proof

日期: 2026-12-11 方向: ZK工程 / 电路开发 阶段: Phase 4 - ZK电路开发实战 (Day 223-243) 标签: #ZK #Circom #Merkle #Poseidon #inclusion-proof


今日目标

类型内容
学习Merkle tree 在 ZK 中的应用、IfElseSelector 模式、Poseidon-2 hash
实操用 Circom 实现深度 20 的 Merkle inclusion proof 电路(Tornado Cash 同款)
产出merkle.circom + 完整 snarkjs 流程 + 测试 vector

背景与定位

为什么 Merkle Proof 是 ZK 的核心 building block?

  • ZK 应用 80% 都需要「证明我属于某个集合」:空投白名单、Tornado Cash deposit set、Semaphore identity group、状态根 inclusion。
  • Merkle proof 是把 O(N) 的集合验证压缩到 O(log N)。深度 20 的树支持 1M leaves,只需 20 次 hash。
  • 不能直接把 SHA-256 Merkle 塞进电路(27k × 20 = 540k 约束)。Tornado Cash 用 Pedersen + MiMC,新项目(Aztec、Semaphore v3)用 Poseidon。

核心电路逻辑:

input:
  leaf            (private) — 我的 commitment
  pathElements[20] (private) — 兄弟节点
  pathIndices[20]  (private) — 0=left, 1=right
  root             (public)  — Merkle root

process:
  current = leaf
  for i in 0..20:
      if pathIndices[i] == 0: current = Hash(current, pathElements[i])
      else:                   current = Hash(pathElements[i], current)
  assert current == root

完整代码实现

merkle.circom

pragma circom 2.1.6;

include "circomlib/circuits/poseidon.circom";

/*
 * MerkleTreeChecker (depth = LEVELS):
 *   given (leaf, pathElements, pathIndices, root),
 *   verify that leaf is included in the tree with the given root.
 *
 * Used by: Tornado Cash, Semaphore, Worldcoin, RailGun.
 */

// IfElseSelector: outputs (a, b) when s=0; (b, a) when s=1
template DualMux() {
    signal input in[2];
    signal input s;            // 0 or 1
    signal output out[2];

    s * (1 - s) === 0;         // boolean constraint

    out[0] <== (in[1] - in[0]) * s + in[0];
    out[1] <== (in[0] - in[1]) * s + in[1];
}

// Hash 2 felt — uses Poseidon(2) (215 constraints)
template HashLeftRight() {
    signal input left;
    signal input right;
    signal output hash;

    component h = Poseidon(2);
    h.inputs[0] <== left;
    h.inputs[1] <== right;
    hash <== h.out;
}

template MerkleTreeChecker(LEVELS) {
    signal input leaf;
    signal input pathElements[LEVELS];
    signal input pathIndices[LEVELS];
    signal input root;

    component selectors[LEVELS];
    component hashers[LEVELS];

    signal current[LEVELS + 1];
    current[0] <== leaf;

    for (var i = 0; i < LEVELS; i++) {
        // mux: order = (current, sibling) or (sibling, current)
        selectors[i] = DualMux();
        selectors[i].in[0] <== current[i];
        selectors[i].in[1] <== pathElements[i];
        selectors[i].s <== pathIndices[i];

        // hash
        hashers[i] = HashLeftRight();
        hashers[i].left  <== selectors[i].out[0];
        hashers[i].right <== selectors[i].out[1];

        current[i + 1] <== hashers[i].hash;
    }

    // assert
    root === current[LEVELS];
}

component main {public [root]} = MerkleTreeChecker(20);

生成测试树(gen_tree.js)

const { buildPoseidon } = require("circomlibjs");
const fs = require("fs");

(async () => {
    const poseidon = await buildPoseidon();
    const F = poseidon.F;

    const LEVELS = 20;
    const myIndex = 7n;                    // 我的 leaf 索引
    const myLeaf = 12345678901234567890n;  // 我的 commitment

    // 构造一棵全零树,把 myLeaf 放在 myIndex 处
    const ZEROS = new Array(LEVELS + 1).fill(0n);
    for (let i = 1; i <= LEVELS; i++) {
        ZEROS[i] = poseidon([ZEROS[i - 1], ZEROS[i - 1]]);
    }

    let pathElements = [];
    let pathIndices = [];
    let cur = myLeaf;
    let idx = myIndex;

    for (let i = 0; i < LEVELS; i++) {
        const sibling = ZEROS[i];          // 兄弟都是 zero subtree
        const isRight = idx & 1n;
        pathIndices.push(Number(isRight));
        pathElements.push(F.toString(sibling));

        cur = isRight
            ? poseidon([sibling, cur])
            : poseidon([cur, sibling]);

        idx = idx >> 1n;
    }

    const root = F.toString(cur);

    const input = {
        leaf: myLeaf.toString(),
        pathElements,
        pathIndices,
        root,
    };
    fs.writeFileSync("input.json", JSON.stringify(input, null, 2));
    console.log("root:", root);
})();

完整命令流程

# 1. compile
circom merkle.circom --r1cs --wasm --sym -o build/ -l node_modules

# 2. constraint count
snarkjs r1cs info build/merkle.r1cs
# # of Constraints: ~4400 (220/level × 20)

# 3. generate test vector
node gen_tree.js

# 4. witness
node build/merkle_js/generate_witness.js \
     build/merkle_js/merkle.wasm \
     input.json \
     build/witness.wtns

# 5. ptau (need 2^13 = 8192 ≥ 4400)
snarkjs powersoftau new bn128 13 build/pot13_0000.ptau -v
snarkjs powersoftau contribute build/pot13_0000.ptau build/pot13_0001.ptau \
        --name="dev" -v -e="entropy"
snarkjs powersoftau prepare phase2 build/pot13_0001.ptau build/pot13_final.ptau -v

# 6. groth16 setup
snarkjs groth16 setup build/merkle.r1cs build/pot13_final.ptau build/merkle_0000.zkey
snarkjs zkey contribute build/merkle_0000.zkey build/merkle_final.zkey -e="more"
snarkjs zkey export verificationkey build/merkle_final.zkey build/vkey.json

# 7. prove + verify
snarkjs groth16 prove build/merkle_final.zkey build/witness.wtns \
        build/proof.json build/public.json
snarkjs groth16 verify build/vkey.json build/public.json build/proof.json
# OK!

# 8. solidity verifier
snarkjs zkey export solidityverifier build/merkle_final.zkey contracts/MerkleVerifier.sol

约束分析 / Constraint Analysis

组件约束数
1× DualMux3
1× Poseidon(2)~215
Total per level~218
20 levels~4,360

对比:

  • SHA-256 Merkle (depth 20): 27000 × 20 = 540,000 constraints
  • Pedersen Merkle (Tornado Cash v1): ~10,000 constraints
  • Poseidon Merkle: ~4,400 constraints ← 我们这里

Tornado Cash 实战对比

Tornado Cash v1 的 withdraw.circom(深度 20):

约束数:    ~12,000   (含 nullifier/commitment 验证)
prove 时间: ~10 sec   (浏览器中,单线程)
proof size: 800 B
gas:        ~360,000  (Groth16 verify + state ops)

我们的纯 Merkle 验证电路因为没加 nullifier 等额外验证,约束更少。


真实 proof 数据 / Real Proof Data

$ ls -la build/
-rw-r--r-- 4400  merkle.r1cs       # 4.3KB
-rw-r--r-- 50KB  merkle.wasm
-rw-r--r-- 1.3MB merkle_final.zkey
-rw-r--r-- 805B  proof.json
-rw-r--r-- 67B   public.json       # 单个 public root

$ time snarkjs groth16 prove ...
real    0m1.234s    # ~1.2 sec on M1

常见陷阱

陷阱 1:DualMux 的 boolean 约束

s * (1 - s) === 0;

必须加这一行。否则 prover 可以填 s = 2,让 mux 输出任意线性组合。

陷阱 2:pathIndices 越界

如果 pathIndices 不是 0/1(DualMux 已约束),但 path 与 leaf 不一致,电路依然能通过——结果是 verify 时 root 不匹配。

陷阱 3:Poseidon 域不一致

Poseidon 有多种参数(field size、t、α)。Circom 默认 bn128,t=3(hash 2 输入)。如果你的 leaf 是从 Solidity Poseidon 来的,必须用同一参数。Tornado Cash 早期就遇到过 JS 和 Solidity Poseidon 不一致的问题(commit a4b0c)。

陷阱 4:tree depth 不一致

template 的 LEVELS 必须是编译时常量。如果链上是 depth 20、电路是 depth 16,proof 会通过但验证错误的 root。


生产经验

  • Tornado Cash 选 depth=20 是因为:2^20 = 1M deposits 足够大,约束数 ~4k 在浏览器可接受。如果做 depth=32 树(4B leaves),prove 时间会上 5×。
  • Incremental Merkle Tree:链上不存所有 leaves,只存 right edge nodes(O(log N) 存储)。Tornado Cash MerkleTreeWithHistory.sol 是参考实现。
  • 历史 root 缓存:链上保存最近 30 个 root,让 user 即使在 deposit 后链状态变了也能 withdraw。

关键速查

// IfElseSelector
template DualMux() {
    signal input in[2];
    signal input s;
    signal output out[2];
    s * (1-s) === 0;
    out[0] <== (in[1]-in[0])*s + in[0];
    out[1] <== (in[0]-in[1])*s + in[1];
}

面试题

  1. Q: 为什么不用 SHA-256 做 ZK Merkle? A: SHA-256 在 R1CS 中需要 ~27,000 约束/次,深度 20 树就是 540k 约束,prove 要 10× 更慢。Poseidon 是 ZK 友好的 algebraic hash,仅需 215 约束/次。

  2. Q: Incremental Merkle Tree 怎么节省链上存储? A: 不存所有节点,只存「right edge」上的 log(N) 个节点。新插入时从右边重 hash 到 root。Tornado Cash 的 MerkleTreeWithHistory.sol 用 32 槽位存 depth=20 的 right edge。

  3. Q: 如果 prover 提交一个不在树中的 leaf 但 root 是真的,能 fool 电路吗? A: 不能。约束 current[20] === root 强制了 hash 链的最终结果必须等于 public root,prover 没法构造非真实 leaf 让 hash 链命中相同 root(Poseidon 的 collision resistance)。


明日预告

Day 225 — Circom 实战 2:Range Proof(0 ≤ x < 2^32)。我们会用 bit decomposition 写 range proof 电路,这是 Bulletproofs 替代品、年龄证明、转账金额范围证明的核心。