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 的合约基本上会有两个函式:
Deposit 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 的例子中,我们用秘密来产生证明,完成的链下计算包括:
将秘密hash 成commitment 算出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 是否正确,以及必须事先确认:
public 变数root 有在合约的roots 里面。 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 大放异彩的应用。
声明:本站所提供的资讯信息不代表任何投资暗示, 本站所发布文章仅代表个人观点,仅供参考。