Tornado Cash 工作原理详解:承诺-作废符与零知识混币

理解承诺-作废符方案、增量默克尔树、zk-SNARK 取款证明、中继器与匿名集

9 分钟阅读
Tornado Cash 工作原理详解:承诺-作废符与零知识混币

Tornado Cash 工作原理详解:承诺-作废符与零知识混币

目录


一、核心概念

Tornado Cash 是一个加密货币混币器:用户用一个地址存入资金,用另一个地址取出,两者之间没有可追踪的链上关联。它用**零知识证明(zk-SNARK)**来证明”我知道某笔存款”,却不暴露”是哪一笔”。

二、零知识证明基础

零知识证明验证”一个计算被执行过并产生了声称的输出”,却不暴露计算的输入。

零知识证明证明的是计算的有效性,而不是某个事实的知识。

证明者必须真的执行计算来生成证明,但验证者只需检查计算确实正确发生(而非重新执行)。这让”我知道秘密”可以被验证而秘密本身不泄露。

三、混币与匿名性

协议通过混合工作:多个存款人把币发到同一个合约。取款人之后取走资金,却不暴露自己认领的是哪笔存款。

当一个地址从 Tornado Cash 取款时,人们无法判断这笔币来自哪个存款人。

注意:存款和取款交易本身在链上公开可见,隐藏的是”哪个存款 ↔ 哪个取款”的对应关系。

四、承诺-作废符方案

4.1 存款

每个存款人生成两个秘密数:

  • nullifier(作废符):用于防止双花;
  • secret(秘密):提供匿名性。

拼接后哈希成一个承诺(commitment)

commitment = hash(nullifier ‖ secret)

这个承诺被公开地加入所有存款的默克尔树。

4.2 取款

取款人必须证明:

  1. 知道承诺的原像(nullifier + secret);
  2. 该承诺存在于默克尔树中
  3. 知道 nullifier,但不暴露它

取款人只公开提交 nullifier 的哈希

nullifierHash = hash(nullifier)

零知识证明证明”我知道原始 nullifier”,却不暴露它本身。

只暴露两个组成数之一(nullifierHash),就无法确定它关联的是哪一片叶子。

这是匿名性的关键:commitment = hash(nullifier ‖ secret),但取款只暴露 hash(nullifier)。光凭 hash(nullifier) 无法反推出 commitment(缺 secret),所以无法把取款和某笔具体存款关联。

五、承诺的默克尔树

Tornado Cash 把所有承诺哈希存在一棵增量默克尔树里,以便高效证明”某承诺是成员”而无需遍历所有存款。

5.1 增量默克尔树优化

树有固定深度(32 层,支持 2³²−1 笔存款),两个效率捷径:

  • 捷径 1 — 零子树:所有只含零叶子的子树有预计算的根。协议为 32 层都预计算好,避免重复计算;
  • 捷径 2 — 缓存子树:最新叶子左侧所有已填满的子树缓存在 filledSubtrees。因为叶子不能删改,这些永不需要重算。

方向判定:哈希时往左还是往右取决于叶子索引的奇偶——偶数索引是左孩子、奇数是右孩子。这让合约只用约 32 次哈希就能更新默克尔根,而不是几百万次。

5.2 根历史

合约存储最近 30 个默克尔根。这是为了照顾”针对某个根生成了证明、但交易延迟”的取款人——期间可能有新存款改变了根。isKnownRoot() 向后遍历这 30 个根的缓冲来验证提交的根是否曾经有效。

六、电路逻辑

zk-SNARK 电路验证:

  1. 承诺哈希commitment = hash(nullifier ‖ secret) 计算正确;
  2. 作废符哈希nullifierHash = hash(nullifier) 正确导出;
  3. 默克尔证明:给定证明路径和根,承诺确实在树中;
  4. 防抢跑:接收者地址被”平方”并纳入证明计算,把证明绑定到特定接收者

电路输出根、nullifierHash、接收者地址作为公开信号,这些公开输出对提交的证明做验证。

防抢跑很关键:如果证明不绑定接收者,攻击者可以在内存池里看到你的取款证明,把接收者改成自己抢先提交。把接收者纳入证明后,篡改接收者会让证明失效。

七、防止双花

nullifierHashes mapping 存所有用过的 nullifier 哈希。取款时合约检查”该 nullifier 之前没用过”。

这既防双花又保持匿名——nullifierHash 只在取款时公开,而那时它早已淹没在成千上万笔存款之中(存款时只暴露了 commitment,没暴露 nullifier)。同一笔存款想取两次?第二次的 nullifierHash 已在 mapping 里,直接 revert。

八、中继器机制

新取款地址通常没有 Gas 付交易费(如果从自己有钱的地址转 Gas 过去,就破坏了匿名性)。**中继器(relayer)**解决:

  • 代取款人提交取款交易;
  • 从取款金额里扣一笔费用作为报酬;
  • 中继器地址也被纳入 ZK 证明(像接收者一样”平方”),防止被篡改。

这让全新地址也能取款,由中继器付 Gas。

九、MiMC 哈希函数

Tornado Cash 用 MiMC(一种 ZK 友好的哈希)而非 Keccak256,因为某些运算在零知识电路里更高效(Keccak 在电路里约束数巨大)。

以太坊没有 ZK 友好哈希的预编译。

所以 MiMC 被部署成原始字节码的独立合约,默克尔树代码通过 staticcall 调它计算哈希。

十、存取款完整流程

存款

  1. 提交者发送 commitment = hash(nullifier ‖ secret) 和固定面额;
  2. 合约校验金额、把承诺加到默克尔树的下一片叶子;
  3. 承诺永久存储。

取款

  1. 取款人本地从合约事件重建默克尔树
  2. 生成零知识证明,证明知道 nullifier、secret 和有效的默克尔证明;
  3. 提交证明、根、nullifierHash、接收者地址、可选中继器信息;
  4. 合约验证:nullifier 未用过、根在历史中、证明有效;
  5. 资金转给接收者(扣除中继器费用)。

十一、合约架构

合约职责
Tornado.sol抽象基类(由 ETHTornado.sol 或 ERC20Tornado.sol 实现)
MerkleTreeWithHistory.sol增量树和根历史逻辑
Verifier.sol从 Circom 电路转译来的验证代码
IHasher指向 MiMC 字节码合约的接口

固定面额(如 0.1/1/10/100 ETH)很重要——如果面额任意,金额本身就成了关联存取款的线索,破坏匿名。

十二、关键安全特性

  1. 匿名集(Anonymity Set):取款人的匿名性取决于存款数量——存款很少时,分析仍可能去匿名化。匿名集越大越安全;
  2. 证明绑定:电路嵌入接收者和中继器地址,防止证明被复用/抢跑;
  3. 根陈旧检查:30 个根回看,防止快速存款使证明失效;
  4. 一次性使用:作废符方案防止重复取款。

十三、总结

  • Tornado Cash 用承诺-作废符 + 默克尔树 + zk-SNARK 实现混币;
  • 存款公开 commitment = hash(nullifier‖secret) 入树,取款只暴露 hash(nullifier) + ZK 证明”我知道树里某个承诺”;
  • 增量默克尔树(32 层、零子树预计算、缓存子树)让更新只需约 32 次哈希;
  • nullifierHash mapping 防双花,30 根历史防证明失效;
  • 电路把接收者/中继器绑进证明防抢跑;中继器代付 Gas 保护新地址匿名;
  • 用 ZK 友好的 MiMC 哈希;匿名性取决于匿名集大小。

十四、动手练习项目:迷你混币器 MiniMixer

项目目标

实现一个简化的混币器,掌握承诺-作废符方案和增量默克尔树。ZK 证明部分可分两个层次:基础版用”揭示原像”替代真 ZK 理解流程;进阶版接入真实的 Circom 电路 + Verifier。部署到 Sepolia。

合约要求

1. MerkleTreeWithHistory.sol

  • 固定深度(如 20 层,便于测试)的增量默克尔树;
  • filledSubtreeszeros(预计算的零子树根)、roots(最近 N 个根的环形缓冲);
  • insert(bytes32 leaf) returns (uint32 index):插入承诺,按奇偶判定左右孩子,约 depth 次哈希更新根,存入根历史;
  • isKnownRoot(bytes32 root) view:遍历根历史;
  • 哈希可先用 keccak256(基础版),进阶版换 MiMC/Poseidon。

2. MiniMixer.sol

  • uint256 public constant DENOMINATION = 0.1 ether;
  • deposit(bytes32 commitment) external payable:校验 msg.value == DENOMINATION、commitment 未存过,insert 进树,发 Deposit 事件(含 index 和时间,供链下重建树);
  • mapping(bytes32 => bool) public nullifierHashes;
  • 基础版 withdraw(uint256 nullifier, uint256 secret, bytes32[] calldata merkleProof, address payable recipient)
    • 计算 commitment = hash(nullifier, secret),验证 merkleProof 对某个 known root 成立;
    • 计算 nullifierHash = hash(nullifier),要求未用过,标记已用;
    • 转 DENOMINATION 给 recipient;
    • (注意:基础版揭示了 nullifier+secret,没有真匿名,仅用于理解流程!)
  • 进阶版 withdraw(bytes proof, bytes32 root, bytes32 nullifierHash, address recipient, address relayer, uint fee):调 Verifier.verifyProof 验 zk 证明,不揭示秘密。

测试要求(Foundry)

  1. test_DepositInsertsLeaf:存款后树的根更新、index 递增;
  2. test_IncrementalRootMatches:对比增量更新的根与”全量重算”的根一致;
  3. test_KnownRootHistory:多次存款后,旧根仍在 30 根历史里被 isKnownRoot 认可;
  4. test_WithdrawValidProof:用正确的 nullifier/secret/merkleProof 取款成功,recipient 收到 DENOMINATION;
  5. test_DoubleSpendBlocked:同一 nullifier 取第二次 revert;
  6. test_WrongDenominationReverts:存非固定面额 revert;
  7. test_InvalidMerkleProofReverts:伪造的 proof 取款 revert;
  8. (进阶)test_ZkProofVerification:接入 Verifier,验证真实 zk 证明,不揭示秘密。

Sepolia 部署与验证步骤

  1. 部署 MerkleTreeWithHistory + MiniMixer;
  2. 用地址 A 存 0.1 ETH(链下生成 nullifier/secret,算 commitment);
  3. 链下从 Deposit 事件重建树、生成 merkleProof;
  4. 用全新地址 B 调 withdraw 取款,确认 B 收到 0.1 ETH;
  5. 用同 nullifier 再取一次,观察 revert(防双花)。

进阶挑战(可选)

  • 把哈希换成 ZK 友好的 Poseidon,用 circom 写一个验证”承诺在树中 + 知道 nullifier”的电路,编译出 Verifier.sol 接入进阶版 withdraw,实现真正的零知识取款
  • 实现中继器:withdraw 支持 relayer + fee,把 recipient/relayer 绑进证明防抢跑;
  • 写注释回答:为什么必须固定面额?为什么只暴露 hash(nullifier) 而非 nullifier 本身就能既防双花又保匿名?匿名集大小如何影响实际隐私?

💬 评论