Tornado Cash是怎么运作的?

Tornado Cash 是一个使用zk-SNARKs 建立的Dapp,它实现了匿名的代币交易,这篇文章就用一些程序代码片段,来分享它是怎么运作的。

我们知道在以太坊上的交易纪录都是公开的,你可以在etherscan 上看到某个地址的所有历史交易纪录,当然地址是合约的话也是一样。

也许创建一个新的钱包和地址就好了?假设一个情境是Alice 想要匿名传送1 ETH 给Bob,Alice 原本的钱包是A,但她不想让A 地址传给Bob 的交易纪录被看到,所以Alice 创建另一个钱包B,显然B 钱包是空的,Alice 必须把A 钱包的1 ETH 传到B 钱包,再用B 钱包的地址传给Bob。

但问题就在于,只要追踪B 钱包的地址,就能看到B 的历史交易纪录中A 钱包曾经打币给B 钱包,于是到头来交易还是被追踪到了。

Tornado Cash 的解决方案,简单来说,它是一份合约,当你要匿名传送代币时,就把一定数量的币丢进合约里(Deposit),此时你会拿到一个note,长得像这样:

tornado-eth-0.1-5-0x3863c2e16abc85d72b64d78c68fca5936db2501832e26345226efdfb2bc45804977f167d86b711bb6b4095ddaa646ec93f0a93ac4884a66c1d881f4fc985

note 就是一串字串,拥有这字串的人,就能提领(Withdraw) 刚刚传入合约的代币。握有note 就代表拥有提款的权利,所以note 一旦被别人知道,别人就可以把钱给提走。

其中,后面那段乱码,本篇文章就以「秘密」来称呼,这个秘密是由secret 与nullifier 组成,而这两个都是在链下随机产生的乱数。

因此Tornado 的合约基本上会有两个函式:

  1. Deposit
  2. Withdraw

有兴趣的人可以先到Dapp上先玩一次看看,使用Goerli测试网,这里可以领Goerli的代币:https://goerli-faucet.slock.it/

Deposit

我们就从Deposit 开始说起,简单来说, Deposit 是将资料储存到合约的Merkle Tree 上。

刚刚提到的秘密,它是在链下产生,由secret 跟nullifier 组成,合在一起之后也称作preimage,因为我们要对这个preimage 进行hash,就会成为commitment。

合约中Deposit 如下:

deposit 除了传送代币到合约之外,需填入一个参数_commitment。

我们对preimage 使用Pedersen 作为hash function 加密后产生commitment,以伪代码表示如下:

const preimage = secret + nullifier; 
const commitment = pedersenHash(preimage);

这个commitment会成为Merkle Tree的叶子,所以合约中的_insert(commitment)来自MerkleTreeWithHistory.sol的合约,将我们的资料插入Merkle Tree,然后回传一个index给你,告诉你这个commitment在Merkle Tree上的位置,最后一起发布成公开的Deposit事件。

我们知道MerkleTree 是将一大笔资料两两做杂凑后产生一个唯一值root,这个root 就是合约上所储存的历史资料。

root 的特性就是只要底下的资料一有更动,就会重新产生新的root。

所以只要一有用户deposit ,就会插入新的叶子到Merkle Tree 上,于是就会产生新的root,所以在合约中有一个阵列是用来储存所有的root 的roots:

bytes32[ROOT_HISTORY_SIZE] public roots;

roots 是用来纪录每个deposit 的历史,每一次deposit 都会创造新的root,而所有root 都会被储存进roots 里,于是当你要提领的时候,就要证明你的commitment 所算出的root 曾经出现在roots 里,代表曾经有deposit 的动作,因此才可以进行提领。

Withdraw

在Deposit 之前Tornado Cash 就会在链下产生秘密后交给使用者,拥有这个秘密的人等于拥有提款的权利。

提领的时候,秘密会在链下计算后产生proof,proof 是withdraw 需要的参数,所以只要确保这个proof 能够被验证,那么代币的接收地址(recipient) 就可以随便我们填,只要不填上当初拿来deposit 用的地址,基本上就做到匿名交易的效果了。

也就是说,产生这个proof 并提交给合约,能够证明此人知道秘密,但却不告诉合约秘密本身是什么。

function withdraw(bytes calldata _proof, bytes32 _root, bytes32 _nullifierHash, address payable _recipient, address payable _relayer, uint256 _fee, uint256 _refund) external payable nonReentrant;

我们可以清楚看到withdraw 函式里没有接收有关秘密的任何资讯作为参数,也就是秘密不会与合约有所接触,也不会暴露在etherscan 上。

回顾ZKP 所带来的效果:

  • 链下计算
  • 隐藏秘密

在Tornado Cash 的例子中,我们用秘密来产生证明,完成的链下计算包括:

  1. 将秘密hash 成commitment
  2. 算出Merkle Tree 的root。

以下是简化后的withdraw.circom:

template Withdraw(levels) { 
    signal input root; 
    signal input nullifierHash;    signal private input nullifier; 
    signal private input secret; 
    signal private input pathElements[levels]; 
    signal private input pathIndices[levels];    component hasher = CommitmentHasher(); // Pedersen 
    hasher.nullifier <== nullifier; 
    hasher.secret <== secret; 
    hasher.nullifierHash === nullifierHash;    component tree = MerkleTreeChecker(levels); // MiMC 
    tree.leaf <== hasher.commitment; 
    tree.root <== root; 
    for (var i = 0; i < levels; i++) { 
        tree.pathElements[i] < == pathElements[i]; 
        tree.pathIndices[i] <== pathIndices[i]; 
    } 
}component main = Withdraw(20);

从上述代码就可以看出这份circuit 的private 变数有:

  • secret
  • nullifier
  • pathElements
  • pathIndices

而public 变数有:

  • root
  • nullifierHash

如同我们一开始说过的,秘密就是指secret 与nullifier。这里进行的链下计算就是对secret 与nullifier 杂凑成commitment。而使用的hash function 叫做Pedersen。

在进行Merkle Tree 的计算之前,我们还检查了nullifier 杂凑后的nullifierHash 跟public 变数nullifierHash 是不是一样的。

hasher.nullifierHash === nullifierHash;

接下来,开始计算Merkle Proof,用意是确认经过杂凑后的commitment有没有出现在Merkle Tree上,所以我们的private input还有pathElements与pathIndices,让它跑一趟Merkle Proof的计算,最后就能够算出一个root,再确认计算后的root与我们的public变数root是否一样。

tree.root <== root;

于是我们就能产生一个ZKP的证明—证明private变数:secret, nullifier, pathElements, pathIndices可以计算出public变数:root与nullifierHash。

把这个证明提交给合约,合约透过Verifier 验证proof 是否正确,以及必须事先确认:

  1. public 变数root 有在合约的roots 里面。
  2. public 变数nullifierHash 在合约中是第一次出现。

必须注意ZKP 是向合约证明使用者填入的secret 和nullifier 可以计算出某个root,但无法保证这个root 曾经在合约的roots 历史上。

所以合约的withdraw 中,除了verifyProof 之外,还要事先检查ZKP 算出来的root 是不是真的在历史上发生过,所以需要isKnownRoot 的检查:

function isKnownRoot(bytes32 _root) public view returns(bool)

必须先检查isKnownRoot 后才能进行verifyProof。

经过verifyProof 验证成功后,合约就开始进行提款的动作,也就会将代币传到recipient 的地址,最后抛出Withdrawal 的事件。

nullifier 与nullifierHash

为什么我们的秘密不是只有secret 还要额外加一个nullifier?

简单来说,这是为了防止已经提领过的note 又再提领一次,也就是所谓的double spend。

require(!nullifierHashes[_nullifierHash], "The note has been already spent");

可以看到withdraw 需要填入参数nullifierHash,跟isKnownRoot 一样的状况,我们需要对电路的public 变数先经过一层检查之后,才能带入到verifyProof 里面。

nullifierHash 可以理解为这个note 的id,但它不会连结到deposit,因此可以用来纪录这个note 是否已经被提领过。

所以当verifyProof 验证成功之后,我们要纪录nullifierHash 已完成提领:

nullifierHashes[_nullifierHash] = true;

有关为什么需要事先检查public变数后,才能带入verifyProof ,可以参考ZKP与智能合约的开发入门提到的publicSignals的部分。

附上Tornado Cash 的架构图:

简化版的tornado-core

tornado-core 的程序代码很简洁漂亮,所以我模仿该专案自己实作一遍:

simple-tornado:https://github.com/chnejohnson/simple-tornado

这份专案只完成了tornado-core 的核心部分,不一样的是我的开发环境使用hardhat 与ethers 写成,而circom 与snarkjs 使用官方当前的版本,合约用0.7.0,测试使用Typescript 。

比起两年前的tornado-core ,simple-tornado 使用的技术更新,可能更适合初学者理解这份专案,但是它有bug…我在issues 的地方有纪录说明。

在开发的过程中,我的顺序是先从最小单位的MiMC hash function 开始玩,发现必须javascript 算一次hash、solidity 算一次、circom 再算一次,确保这三个语言对同一个值算出同样的hash之后,才能放心去做更复杂的Merkle Tree。

总结

我们可以看到Tornado Cash 简单的两个函式:Deposit 与Withdraw,透过将代币送入合约后再提领到另一个地址的流程,应用ZKP 达成匿名的交易。

除了断开Deposit与Withdraw的地址关联性之外,Tornado Cash还有做了一层「藏树于林」的隐私防护。

网络上很多关于ZKP 的文章或专案都是在2019 年后出产的,经过许多人对这项技术的尝试,让我们对ZKP 有了更清晰的理解,如今两年后,开发工具也变得更加成熟,期待未来在web 隐私议题上能看到更多ZKP 大放异彩的应用。

本文链接地址:https://www.wwsww.cn/jishu/8930.html
郑重声明:本文版权归原作者所有,转载文章仅为传播更多信息之目的,如作者信息标记有误,请第一时间联系我们修改或删除,多谢。