Tornado Cash 重构 — 简化版隐私混币器
Tornado Cash 架构、commitment-nullifier 范式、双花防护、relayer fee 设计
日期: 2026-12-14 方向: ZK工程 / 电路开发 阶段: Phase 4 - ZK电路开发实战 (Day 223-243) 标签: #ZK #Tornado-Cash #mixer #privacy #nullifier
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | Tornado Cash 架构、commitment-nullifier 范式、双花防护、relayer fee 设计 |
| 实操 | 写完整 mini_tornado:deposit/withdraw circom + Solidity 池子 + JS 客户端 |
| 产出 | mini_tornado/(含 contracts、circuits、scripts、tests) |
背景与定位
Tornado Cash 的设计精髓 / The genius of Tornado Cash:
- 用户
deposit时往池子打 1 ETH,同时提交一个 commitment $C = H(\text{nullifier} | \text{secret})$。 - 链上把所有 commitment 维护在一棵 Merkle tree 中。
- 用户
withdraw时,不暴露自己是哪个 deposit,而是用 ZK 证明:「我知道某个 (nullifier, secret) 满足 $H(n|s) \in \text{Tree}$」。 - 同时把
H(nullifier)作为 public input 提交到链上(防止双花)。 - 池子打钱给指定的 recipient。
为什么有效?
- 不可链接性:Merkle proof 是 ZK,链上观察者只看到 deposit set 和 nullifierHash,无法关联。
- 双花防护:
nullifierHash提交后存入 mapping,再次提交相同值会被拒绝。 - relayer 机制:用户可以付一笔 fee 让 relayer 帮自己发 tx,避免暴露 EOA gas address。
架构图 / Architecture
DEPOSIT FLOW
─────────────
Alice
│
│ secret s, nullifier n (random 31-byte each)
│ commitment c = Poseidon(n, s)
▼
┌──────────┐ call deposit(c) + 1 ETH ┌──────────────┐
│ App │ ────────────────────────▶ │ TornadoPool │
│ (browser)│ │ (Solidity) │
└──────────┘ │ tree.insert(c)│
│ root[k] = ... │
└──────────────┘
WITHDRAW FLOW
──────────────
Bob (recipient)
▲
│ 1 ETH + (relayer fee deducted)
│
┌──────────────┐ withdraw(proof, pub) ┌──────────────┐
│ TornadoPool │ ◀────────────────────── │ Relayer │
│ │ │ (any node) │
│ verifier. │ └──────────────┘
│ verifyProof()│ ▲
│ nullifiers[h]│ │
│ = used │ proof, public │
└──────────────┘ ◀──────────── │
│
┌─────────┴────────┐
│ Alice's Browser │
│ load (s, n) from│
│ local storage │
│ generate proof │
└──────────────────┘
PUBLIC INPUTS (in proof):
root — currentRoot (or any historical)
nullifierHash — Poseidon(n) ← prevents double-spend
recipient — Bob's address ← bound to proof, can't be replaced
relayer — relayer address
fee — paid to relayer
完整代码实现
circuits/withdraw.circom
pragma circom 2.1.6;
include "circomlib/circuits/poseidon.circom";
include "./merkle.circom"; // 复用 Day 224 的 MerkleTreeChecker
template CommitmentHasher() {
signal input nullifier;
signal input secret;
signal output commitment;
signal output nullifierHash;
component cHash = Poseidon(2);
cHash.inputs[0] <== nullifier;
cHash.inputs[1] <== secret;
commitment <== cHash.out;
component nHash = Poseidon(1);
nHash.inputs[0] <== nullifier;
nullifierHash <== nHash.out;
}
template Withdraw(LEVELS) {
// public
signal input root;
signal input nullifierHash;
signal input recipient; // 不参与运算,但作为 public binding
signal input relayer;
signal input fee;
signal input refund;
// private
signal input nullifier;
signal input secret;
signal input pathElements[LEVELS];
signal input pathIndices[LEVELS];
// 1. recompute commitment + nullifierHash
component hasher = CommitmentHasher();
hasher.nullifier <== nullifier;
hasher.secret <== secret;
hasher.nullifierHash === nullifierHash;
// 2. verify Merkle proof
component tree = MerkleTreeChecker(LEVELS);
tree.leaf <== hasher.commitment;
tree.root <== root;
for (var i = 0; i < LEVELS; i++) {
tree.pathElements[i] <== pathElements[i];
tree.pathIndices[i] <== pathIndices[i];
}
// 3. bind recipient/relayer/fee/refund to proof
// (无须运算,但通过 quadratic constraints 引用一次)
signal rSquare;
rSquare <== recipient * recipient;
signal relayerSquare;
relayerSquare <== relayer * relayer;
signal feeSquare;
feeSquare <== fee * fee;
signal refundSquare;
refundSquare <== refund * refund;
}
component main {public [root, nullifierHash, recipient, relayer, fee, refund]}
= Withdraw(20);
contracts/MiniTornado.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./WithdrawVerifier.sol";
interface IPoseidon {
function poseidon(uint256[2] calldata) external pure returns (uint256);
}
contract MiniTornado {
uint256 public constant DENOMINATION = 1 ether;
uint32 public constant LEVELS = 20;
Groth16Verifier public immutable verifier;
IPoseidon public immutable hasher;
// Incremental Merkle Tree state
mapping(uint256 => bytes32) public filledSubtrees;
mapping(uint256 => bytes32) public roots; // history (max 30)
uint32 public currentRootIndex;
uint32 public nextLeafIndex;
mapping(bytes32 => bool) public commitments;
mapping(bytes32 => bool) public nullifierHashes;
event Deposit(bytes32 indexed commitment, uint32 leafIndex, uint256 timestamp);
event Withdraw(address to, bytes32 nullifierHash, address indexed relayer, uint256 fee);
constructor(address _verifier, address _hasher) {
verifier = Groth16Verifier(_verifier);
hasher = IPoseidon(_hasher);
bytes32 zero = bytes32(uint256(keccak256("tornado-cash-zero")) %
21888242871839275222246405745257275088548364400416034343698204186575808495617);
for (uint32 i = 0; i < LEVELS; i++) {
filledSubtrees[i] = zero;
zero = bytes32(hasher.poseidon([uint256(zero), uint256(zero)]));
}
roots[0] = zero; // initial root = all zeros
}
// ─── DEPOSIT ────────────────────────────────────────────
function deposit(bytes32 commitment) external payable {
require(msg.value == DENOMINATION, "wrong amount");
require(!commitments[commitment], "duplicate");
uint32 idx = nextLeafIndex;
require(idx < 2**LEVELS, "tree full");
bytes32 cur = commitment;
uint32 currentIndex = idx;
for (uint32 i = 0; i < LEVELS; i++) {
bytes32 left;
bytes32 right;
if (currentIndex % 2 == 0) {
left = cur;
right = filledSubtrees[i];
} else {
left = filledSubtrees[i];
right = cur;
filledSubtrees[i] = cur;
}
cur = bytes32(hasher.poseidon([uint256(left), uint256(right)]));
currentIndex /= 2;
}
currentRootIndex = (currentRootIndex + 1) % 30;
roots[currentRootIndex] = cur;
nextLeafIndex = idx + 1;
commitments[commitment] = true;
emit Deposit(commitment, idx, block.timestamp);
}
function isKnownRoot(bytes32 root) public view returns (bool) {
if (root == bytes32(0)) return false;
for (uint32 i = 0; i < 30; i++) {
if (roots[i] == root) return true;
}
return false;
}
// ─── WITHDRAW ───────────────────────────────────────────
function withdraw(
uint[2] calldata pA,
uint[2][2] calldata pB,
uint[2] calldata pC,
bytes32 root,
bytes32 nullifierHash,
address payable recipient,
address payable relayer,
uint256 fee,
uint256 refund
) external {
require(fee <= DENOMINATION, "fee too high");
require(!nullifierHashes[nullifierHash], "double spend");
require(isKnownRoot(root), "unknown root");
uint[6] memory pub = [
uint256(root),
uint256(nullifierHash),
uint256(uint160(recipient)),
uint256(uint160(relayer)),
fee,
refund
];
require(verifier.verifyProof(pA, pB, pC, pub), "invalid proof");
nullifierHashes[nullifierHash] = true;
recipient.transfer(DENOMINATION - fee);
if (fee > 0) relayer.transfer(fee);
emit Withdraw(recipient, nullifierHash, relayer, fee);
}
}
scripts/deposit.ts
import { ethers } from "hardhat";
import { buildPoseidon } from "circomlibjs";
import { randomBytes } from "crypto";
import * as fs from "fs";
async function main() {
const poseidon = await buildPoseidon();
const F = poseidon.F;
const nullifier = "0x" + randomBytes(31).toString("hex");
const secret = "0x" + randomBytes(31).toString("hex");
const commitment = F.toString(poseidon([BigInt(nullifier), BigInt(secret)]));
fs.writeFileSync("./note.json", JSON.stringify({nullifier, secret}));
const tornado = await ethers.getContractAt("MiniTornado", process.env.TORNADO!);
const tx = await tornado.deposit(
"0x" + BigInt(commitment).toString(16).padStart(64, "0"),
{ value: ethers.parseEther("1") }
);
const r = await tx.wait();
console.log("deposited, gas =", r!.gasUsed.toString());
}
main();
scripts/withdraw.ts
import { ethers } from "hardhat";
import { buildPoseidon } from "circomlibjs";
import { groth16 } from "snarkjs";
import * as fs from "fs";
async function main() {
const note = JSON.parse(fs.readFileSync("note.json", "utf8"));
// 1. read on-chain Deposit events to reconstruct tree
const tornado = await ethers.getContractAt("MiniTornado", process.env.TORNADO!);
const events = await tornado.queryFilter(tornado.filters.Deposit());
const poseidon = await buildPoseidon();
const F = poseidon.F;
const myCommit = F.toString(poseidon([BigInt(note.nullifier), BigInt(note.secret)]));
const myIndex = events.findIndex(e => e.args.commitment === ...); // 简化
// 2. build merkle path locally
const { pathElements, pathIndices, root } = buildMerklePath(events, myIndex);
// 3. generate proof
const recipient = process.env.RECIPIENT!;
const relayer = process.env.RELAYER || ethers.ZeroAddress;
const fee = 0n;
const refund = 0n;
const input = {
root, nullifierHash: F.toString(poseidon([BigInt(note.nullifier)])),
recipient: BigInt(recipient).toString(),
relayer: BigInt(relayer).toString(),
fee: fee.toString(), refund: refund.toString(),
nullifier: BigInt(note.nullifier).toString(),
secret: BigInt(note.secret).toString(),
pathElements, pathIndices
};
const { proof, publicSignals } = await groth16.fullProve(
input, "build/withdraw_js/withdraw.wasm", "build/withdraw_final.zkey"
);
// 4. send tx
const tx = await tornado.withdraw(
[proof.pi_a[0], proof.pi_a[1]],
[[proof.pi_b[0][1], proof.pi_b[0][0]], [proof.pi_b[1][1], proof.pi_b[1][0]]],
[proof.pi_c[0], proof.pi_c[1]],
...publicSignals, recipient, relayer, fee, refund
);
console.log("withdrew, gas =", (await tx.wait()).gasUsed.toString());
}
main();
真实 Tornado Cash 数据 / Real Tornado Cash Data
| 指标 | 数值 |
|---|---|
| Mainnet pool 1 ETH 合约 | 0x12D66f87A04A9E220743712cE6d9bB1B5616B8Fc |
| TVL(高峰 2022-08,制裁前) | ~$200M |
| 累计 deposit 次数 | ~200,000 |
| Withdraw gas | ~360,000 |
| Deposit gas | ~1,100,000(含 20 次 Poseidon hash) |
| Proof generation | ~10 sec(浏览器 snarkjs) |
| Proof size | 800 bytes |
| 约束数 | ~25,000 |
安全教训 / Security Lessons
教训 1:trusted setup ceremony 透明度
Tornado Cash v1 的 trusted setup 由 ~1100 人参与的 MPC ceremony 完成,参数公开可审计。没有 trusted setup 的项目是危险的——如果 toxic waste 被保留,任何人可以伪造 proof 提空池子。
教训 2:制裁与协议中立
2022 年 8 月 OFAC 制裁 Tornado Cash 智能合约地址,这是历史上首次链上代码被制裁。开发者 Alexey Pertsev 被捕。教训:ZK ≠ 法律豁免,前端服务不可避免要做合规处理。
教训 3:Nova-style 漏洞
2023 年 Nova 协议(Tornado fork)出现 nullifier 复用漏洞:约束 nullifierHash === Poseidon(nullifier) 写漏,prover 可以提交 nullifierHash = 0 并多次 withdraw。lesson:每个 public input 都要在电路中验证。
教训 4:relayer 信任
Relayer 收到 proof 后可能延迟广播以 front-run。社区方案:把 recipient、relayer、fee 全部 bind 到 proof(如我们电路里所做),这样 relayer 不能换 recipient。
性能与生产经验
- Deposit gas 高的原因:Solidity 内调用 Poseidon(~20k gas/hash × 20 levels = 400k)。Tornado v2 改用 MiMC(更便宜但 ZK 内更贵)。
- 浏览器 prove 慢:10 秒在用户体验差。Tornado UI 用 web worker + WASM 优化。新项目(Aztec)用 Web2-style 后端 prove。
- 历史 root 30 个:约 1 小时窗口(每 ~120 sec 出块)。如果用户离线超 1 小时,需要等下一个 root 包含或重新生成 path。
关键速查
commitment = Poseidon(nullifier, secret)
nullifierHash = Poseidon(nullifier)
[storage layer]
commitments[c] = true (deposit)
nullifierHashes[nh] = true (withdraw)
roots[i] = bytes32 (history)
面试题
-
Q: Tornado Cash 如何防止双花? A: 每个 deposit 关联一个 nullifier;withdraw 时电路输出
Poseidon(nullifier)作为 public input;合约把这个 nullifierHash 存入 mapping,再次提交相同值会被拒绝。 -
Q: 为什么 Tornado Cash 把 recipient 也作为 public input 写到 proof 中? A: 防止 relayer 篡改 recipient(front-running 把钱转给自己)。proof 与 recipient 绑定,更换 recipient 必须重新生成 proof,而 prover 没有 secret/nullifier 无法生成。
-
Q: 如果 trusted setup 的 toxic waste 泄露会怎样? A: 攻击者可以伪造任何 proof,提空整个池子。所以需要 MPC ceremony(多人参与,至少一人诚实即安全)。Tornado v1 ceremony 有 1100+ 参与者。
-
Q: 如何把 Tornado Cash 改造成可监管/合规版本? A: 思路:(a) 加白名单 commitment(KYC 后才能 deposit);(b) 加可撤销 view key(监管者持有);(c) 用 zkKYC(Polygon ID/Sismo)。但每加一项都减弱隐私。
明日预告
Day 228 — Noir 基础。Aztec 的 Rust-like ZK DSL,相比 Circom 类型系统更强、debug 更友好。