返回 Expert 笔记
Expert Day 227

Tornado Cash 重构 — 简化版隐私混币器

Tornado Cash 架构、commitment-nullifier 范式、双花防护、relayer fee 设计

2026-12-14
Phase 4 - ZK电路开发实战 (Day 223-243)
ZKTornado-Cashmixerprivacynullifier

日期: 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。

为什么有效?

  1. 不可链接性:Merkle proof 是 ZK,链上观察者只看到 deposit set 和 nullifierHash,无法关联。
  2. 双花防护nullifierHash 提交后存入 mapping,再次提交相同值会被拒绝。
  3. 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 size800 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。社区方案:把 recipientrelayerfee 全部 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)

面试题

  1. Q: Tornado Cash 如何防止双花? A: 每个 deposit 关联一个 nullifier;withdraw 时电路输出 Poseidon(nullifier) 作为 public input;合约把这个 nullifierHash 存入 mapping,再次提交相同值会被拒绝。

  2. Q: 为什么 Tornado Cash 把 recipient 也作为 public input 写到 proof 中? A: 防止 relayer 篡改 recipient(front-running 把钱转给自己)。proof 与 recipient 绑定,更换 recipient 必须重新生成 proof,而 prover 没有 secret/nullifier 无法生成。

  3. Q: 如果 trusted setup 的 toxic waste 泄露会怎样? A: 攻击者可以伪造任何 proof,提空整个池子。所以需要 MPC ceremony(多人参与,至少一人诚实即安全)。Tornado v1 ceremony 有 1100+ 参与者。

  4. 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 更友好。