Circom 实战 1 — Merkle Proof 电路
Merkle tree 在 ZK 中的应用、IfElseSelector 模式、Poseidon-2 hash
日期: 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× DualMux | 3 |
| 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];
}
面试题
-
Q: 为什么不用 SHA-256 做 ZK Merkle? A: SHA-256 在 R1CS 中需要 ~27,000 约束/次,深度 20 树就是 540k 约束,prove 要 10× 更慢。Poseidon 是 ZK 友好的 algebraic hash,仅需 215 约束/次。
-
Q: Incremental Merkle Tree 怎么节省链上存储? A: 不存所有节点,只存「right edge」上的 log(N) 个节点。新插入时从右边重 hash 到 root。Tornado Cash 的 MerkleTreeWithHistory.sol 用 32 槽位存 depth=20 的 right edge。
-
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 替代品、年龄证明、转账金额范围证明的核心。