零知識證明: Tornado Cash 專案學習

阿菜ACai發表於2024-05-09

前言

最近在瞭解零知識證明方面的內容,這方面的內容確實不好入門也不好掌握,在瞭解了一些基礎的概念以後,決定選擇一個應用了零知識證明的專案來進行進一步的學習。最終選擇了 Tornado Cash 這個專案,因為它著名且精緻,適合入門的同學進行學習。

學習 Tornado Cash 專案,涉及以下方面:

  1. 瞭解專案功能與特性
  2. 瞭解專案智慧合約實現
  3. 瞭解專案零知識證明部分實現

合約地址:https://etherscan.io/address/0x910cbd523d972eb0a6f4cae4618ad62622b39dbf#code

專案概覽

Tornado Cash 專案的主要功能是代幣混淆。由於區塊鏈上所有的交易都是公開的,所有人都可以透過分析交易的內容來得知在這筆交易中,代幣從哪些地址流向了哪些地址。而當你希望把你的代幣從賬戶 A 轉移到賬戶 B ,但是又不想被人分析出這兩個賬戶之間存在著轉賬關係,這個時候你就需要用到 Tornado Cash (下稱 tornado)的代幣混淆功能。

Tornado的主要業務流程:

使用者根據存款金額選擇對應的匿名池,並將資金髮送到智慧合約。合約會在不暴露取款憑證的前提下記錄這筆存款操作。然後存款資金會在匿名池中與其他來源的資金混合。最後,使用者提供取款憑證,透過零知識證明的校驗,匿名池會將代幣傳送給指定的地址。這樣就完成了一次代幣混淆了。

存款頁面

image

取款憑證

image

tornado-eth-0.1-1-0xa6096ebb820ba1023314df16bd79f5c739187108fac1c9be3f7d1537c596890e2ecf4cedba0cd59b5a53414ce48e26d540664ee66d2dc015d03333e118b6

提款頁面

image

取款憑證所包含的資訊

Tornado UI 程式碼中可以得知,當使用者點選提款按鈕時,將呼叫 onWithdraw 方法,該方法處理提款憑證的提交。

根據下面的程式碼流程,可以追溯到 note 的解析方法

components/withdraw/Withdraw.vue -> store/application.js -> utils/crypto.js

根據下面的程式碼,可以得知取款憑證被解析成以下內容

  1. tornado(_) - 標識這是一個 Tornado Cash 的提款憑證。
  2. eth(currency) - 加密貨幣的型別,這裡是以太坊(ETH)。
  3. 0.1(amount) - 存款或提款的金額,這裡是 0.1 ETH。
  4. 1(netId) - 網路ID,指示該憑證適用於哪個網路,如以太坊主網、Ropsten測試網等。
  5. 0xa609...e118b6(hexNote) - 加密的十六進位制字串,長度為 62 位元組,是 nullifiersecret 的串聯。
const CUT_LENGTH = 31

export function parseNote(note) {
  const [, currency, amount, netId, hexNote] = note.split('-')

  return {
    ...parseHexNote(hexNote),
    netId,
    amount,
    currency
  }
}

export function parseHexNote(hexNote) {
  const buffNote = Buffer.from(hexNote.slice(2), 'hex')

  const commitment = buffPedersenHash(buffNote)

  const nullifierBuff = buffNote.slice(0, CUT_LENGTH)
  const nullifierHash = BigInt(buffPedersenHash(nullifierBuff))
  const nullifier = BigInt(leInt2Buff(buffNote.slice(0, CUT_LENGTH)))

  const secret = BigInt(leInt2Buff(buffNote.slice(CUT_LENGTH, CUT_LENGTH * 2)))

  return {
    secret,
    nullifier,
    commitment,
    nullifierBuff,
    nullifierHash,
    commitmentHex: toFixedHex(commitment),
    nullifierHex: toFixedHex(nullifierHash)
  }
}

取款憑證的原像是兩個值 ( nullifier + secret ) 的串聯,產生長度為 62 位元組的訊息,前 31 位元組為 Nullifier,後 31 位元組為 Secret。

為什麼是 31 個位元組而不是 32 個位元組?

文件得知,採用的是 Baby Jubjub 橢圓曲線,該曲線在有限域 r = 21888242871839275222246405745257275088548364400416034343698204186575808495617 上。採用 31 位元組的數字是為了能夠使得所選擇的 Nullifier 和 Secret 值都在有限域 r 內。

>>> 2 ** 248 < 21888242871839275222246405745257275088548364400416034343698204186575808495617
True
>>> 2 ** 256 < 21888242871839275222246405745257275088548364400416034343698204186575808495617
False

程式碼總覽

整個 Tornado Cash 專案的核心程式碼主要分為智慧合約約束電路兩部分。主要的業務邏輯包括存款取款兩個部分。

  1. 智慧合約:https://github.com/tornadocash/tornado-core/tree/master/contracts
  2. 約束電路:https://github.com/tornadocash/tornado-core/tree/master/circuits

其中,存款部分與智慧合約部分的業務邏輯相關,而取款部分與智慧合約和約束電路兩部分相關。

存款業務程式碼分析

Tornado 採用了一個高度 32 的默克爾樹(Merkle tree)的葉子結點來儲存存款資訊,儲存的最大資料量為 2 ** 32

呼叫 Tornado.deposit 函式進行存款,其中 _commitment 引數為兩個秘密值拼接以後的雜湊(_commitment = hash(nullifier | secret) )。

  1. 檢查 _commitment 是否已經被使用了;
  2. 呼叫 _insert 函式把 _commitment 插入到樹的葉子節點;
  3. 標記 _commitment 已經被使用了;
  4. 檢查轉入金額是否滿足。
function deposit(bytes32 _commitment) external payable nonReentrant {
  require(!commitments[_commitment], "The commitment has been submitted");

  uint32 insertedIndex = _insert(_commitment);
  commitments[_commitment] = true;
  _processDeposit();

  emit Deposit(_commitment, insertedIndex, block.timestamp);
}

兩種路徑進行存款:

  1. 透過 Dap:引數由 Dapp 給你生成
  2. 直接呼叫智慧合約:引數自己準備

這部分的程式碼邏輯比較簡單,整個 Tornado 合約精妙的內容是在默克爾樹的處理上,接下來我們進入到 MerkleTreeWithHistory 合約來進一步瞭解。

全域性變數

在瞭解 MerkleTreeWithHistory 合約的業務邏輯之前,先來看一下它定義的全域性變數。為了節省計算與儲存成本,Tornado 採用了許多最佳化策略。下面的全域性變數會在各個策略中使用,每個變數的含義如下:

// Baby Jubjub 橢圓曲線的有限域
// 同時也被應用在 MiMC 演算法中,令所有輸入輸出值都需要進行 mod FIELD_SIZE 處理以確保落在有限域內。
uint256 public constant FIELD_SIZE = 21888242871839275222246405745257275088548364400416034343698204186575808495617;

// keccak256("tornado") % FIELD_SIZE 的值,用作填充未被使用的葉子節點。
uint256 public constant ZERO_VALUE = 21663839004416932945382355908790599225266501822907911457504978515578255421292;

// MIMC 雜湊的實現合約,用位元組碼編寫而成,沒有 solidity 程式碼
IHasher public immutable hasher;

// 默克爾樹樹的高度,取值範圍 (0, 32)
uint32 public levels;

// Merkle 樹每個層級中最新更新的全使用的子樹根節點,(層級 => 雜湊值)。
mapping(uint256 => bytes32) public filledSubtrees;

// 每次更新後 Merkle 樹根的值
mapping(uint256 => bytes32) public roots;

// 可以儲存的最大樹根數量,避免儲存過多的歷史資訊
uint32 public constant ROOT_HISTORY_SIZE = 30;

// 當前 Merkle 樹根的值,代表處於 (currentRootIndex / ROOT_HISTORY_SIZE) 的位置。
uint32 public currentRootIndex = 0;

// 下一個更新的葉子節點索引
uint32 public nextIndex = 0;

zeros() 預先計算空子樹根節點的值

zeros 函式中,已經提前計算好了高度為 i 的每個空子樹的根節點值。因為空葉子節點的值是固定的,所以可以提前計算出每個高度中,所有葉子節點都為空節點的子樹的根。這樣做可以節省大量的計算過程。

/// @dev provides Zero (Empty) elements for a MiMC MerkleTree. Up to 32 levels
function zeros(uint256 i) public pure returns (bytes32) {
  if (i == 0) return bytes32(0x2fe54c60d3acabf3343a35b6eba15db4821b340f76e741e2249685ed4899af6c);
  else if (i == 1) return bytes32(0x256a6135777eee2fd26f54b8b7037a25439d5235caee224154186d2b8a52e31d);
  else if (i == 2) return bytes32(0x1151949895e82ab19924de92c40a3d6f7bcb60d92b00504b8199613683f0c200);
  else if (i == 3) return bytes32(0x20121ee811489ff8d61f09fb89e313f14959a0f28bb428a20dba6b0b068b3bdb);
  else if (i == 4) return bytes32(0x0a89ca6ffa14cc462cfedb842c30ed221a50a3d6bf022a6a57dc82ab24c157c9);
  else if (i == 5) return bytes32(0x24ca05c2b5cd42e890d6be94c68d0689f4f21c9cec9c0f13fe41d566dfb54959);
  else if (i == 6) return bytes32(0x1ccb97c932565a92c60156bdba2d08f3bf1377464e025cee765679e604a7315c);
  else if (i == 7) return bytes32(0x19156fbd7d1a8bf5cba8909367de1b624534ebab4f0f79e003bccdd1b182bdb4);
  else if (i == 8) return bytes32(0x261af8c1f0912e465744641409f622d466c3920ac6e5ff37e36604cb11dfff80);
  else if (i == 9) return bytes32(0x0058459724ff6ca5a1652fcbc3e82b93895cf08e975b19beab3f54c217d1c007);
  else if (i == 10) return bytes32(0x1f04ef20dee48d39984d8eabe768a70eafa6310ad20849d4573c3c40c2ad1e30);
  else if (i == 11) return bytes32(0x1bea3dec5dab51567ce7e200a30f7ba6d4276aeaa53e2686f962a46c66d511e5);
  else if (i == 12) return bytes32(0x0ee0f941e2da4b9e31c3ca97a40d8fa9ce68d97c084177071b3cb46cd3372f0f);
  else if (i == 13) return bytes32(0x1ca9503e8935884501bbaf20be14eb4c46b89772c97b96e3b2ebf3a36a948bbd);
  else if (i == 14) return bytes32(0x133a80e30697cd55d8f7d4b0965b7be24057ba5dc3da898ee2187232446cb108);
  else if (i == 15) return bytes32(0x13e6d8fc88839ed76e182c2a779af5b2c0da9dd18c90427a644f7e148a6253b6);
  else if (i == 16) return bytes32(0x1eb16b057a477f4bc8f572ea6bee39561098f78f15bfb3699dcbb7bd8db61854);
  else if (i == 17) return bytes32(0x0da2cb16a1ceaabf1c16b838f7a9e3f2a3a3088d9e0a6debaa748114620696ea);
  else if (i == 18) return bytes32(0x24a3b3d822420b14b5d8cb6c28a574f01e98ea9e940551d2ebd75cee12649f9d);
  else if (i == 19) return bytes32(0x198622acbd783d1b0d9064105b1fc8e4d8889de95c4c519b3f635809fe6afc05);
  else if (i == 20) return bytes32(0x29d7ed391256ccc3ea596c86e933b89ff339d25ea8ddced975ae2fe30b5296d4);
  else if (i == 21) return bytes32(0x19be59f2f0413ce78c0c3703a3a5451b1d7f39629fa33abd11548a76065b2967);
  else if (i == 22) return bytes32(0x1ff3f61797e538b70e619310d33f2a063e7eb59104e112e95738da1254dc3453);
  else if (i == 23) return bytes32(0x10c16ae9959cf8358980d9dd9616e48228737310a10e2b6b731c1a548f036c48);
  else if (i == 24) return bytes32(0x0ba433a63174a90ac20992e75e3095496812b652685b5e1a2eae0b1bf4e8fcd1);
  else if (i == 25) return bytes32(0x019ddb9df2bc98d987d0dfeca9d2b643deafab8f7036562e627c3667266a044c);
  else if (i == 26) return bytes32(0x2d3c88b23175c5a5565db928414c66d1912b11acf974b2e644caaac04739ce99);
  else if (i == 27) return bytes32(0x2eab55f6ae4e66e32c5189eed5c470840863445760f5ed7e7b69b2a62600f354);
  else if (i == 28) return bytes32(0x002df37a2642621802383cf952bf4dd1f32e05433beeb1fd41031fb7eace979d);
  else if (i == 29) return bytes32(0x104aeb41435db66c3e62feccc1d6f5d98d0a0ed75d1374db457cf462e3a1f427);
  else if (i == 30) return bytes32(0x1f3c6fd858e9a7d4b0d1f38e256a09d81d5a5e3c963987e2d4b814cfab7c6ebb);
  else if (i == 31) return bytes32(0x2c7a07d20dff79d01fecedc1134284a8d08436606c93693b67e333f671bf69cc);
  else revert("Index out of bounds");
}

比如,要插入 N00 節點時,需要更新默克爾樹的根節點值,只需要在計算 N11 節點時獲取 zeros(0) 的值來進行構建,然後在計算 root 的值時獲取 zeros(1) 的值即可完成根節點的更新。

image

filledSubtrees 不是存放全被使用的子樹根節點嗎?

filledSubtrees[i] 被記錄的時候是作為每個層級中最新更新的子樹根節點,進行記錄。被記錄到 filledSubtrees 對映的時候該子樹未必所有葉子節點都被使用了。

而只有當 filledSubtrees[i] 成為了當前層級所有葉子節點都被使用了的子樹所對應的子樹根時,它才會被使用。

請看案例:

當插入 N00 節點時,filledSubtrees[1] 的值將會更新,但此時它對應的子樹 N11 還不是一個全部填充的子樹。

image

而當插入了 N01 時,filledSubtrees[1] 的值會被更新為 N11 的值。

image

然後插入 N02filledSubtrees[1] 的值將會被使用,此時它對應的子樹已經是一個完全填充的子樹了。

image

MerkleTreeWithHistory._insert() 函式插入葉子節點

MerkleTreeWithHistory._insert 函式

  1. 首先獲取下一個插入的葉子結點索引,確保 Merkle 樹未滿,可以執行插入操作。
  2. 然後遍歷更新 Merkle 樹每一層相關的節點
    1. 如果 currentIndex 是偶數,表示當前更新的節點為左節點,將當前雜湊值存為左子節點,並將對應層級的預設右子節點(zero value)取出。(由於葉子節點插入是從左到右,所以當更新的節點為左節點時,右節點為空子樹)。將第 i 層的 filledSubtrees 記錄為當前雜湊值。
    2. 如果 currentIndex 是奇數,表示當前更新的節點為右節點,將左子節點(之前儲存在 filledSubtrees 中的)和當前雜湊值組合。(更新的節點為右節點,意味著對應的左節點已經是完全使用的狀態)
    3. 使用 hashLeftRight 函式計算當前層的新雜湊值。
    4. currentIndex 透過除以 2 得到父節點屬於左子節點還是右子節點。
  3. 計算新的 root 索引,使用迴圈陣列的方式避免超出 ROOT_HISTORY_SIZE
  4. 更新 currentRootIndex
  5. 把新的跟雜湊存放在 roots[newRootIndex] 中。
  6. 更新 nextIndex
  7. 返回當前的葉子節點索引。
function _insert(bytes32 _leaf) internal returns (uint32 index) {
  uint32 _nextIndex = nextIndex;
  require(_nextIndex != uint32(2)**levels, "Merkle tree is full. No more leaves can be added");
  uint32 currentIndex = _nextIndex;
  bytes32 currentLevelHash = _leaf;
  bytes32 left;
  bytes32 right;

  for (uint32 i = 0; i < levels; i++) {
    if (currentIndex % 2 == 0) {
      left = currentLevelHash;
      right = zeros(i);
      filledSubtrees[i] = currentLevelHash;
    } else {
      left = filledSubtrees[i];
      right = currentLevelHash;
    }
    currentLevelHash = hashLeftRight(hasher, left, right);
    currentIndex /= 2;
  }

  uint32 newRootIndex = (currentRootIndex + 1) % ROOT_HISTORY_SIZE;
  currentRootIndex = newRootIndex;
  roots[newRootIndex] = currentLevelHash;
  nextIndex = _nextIndex + 1;
  return _nextIndex;
}

MiMC 雜湊函式

hashLeftRight 函式使用 MiMC 雜湊演算法對兩個樹葉節點進行雜湊。其中 _hasher 合約對應的是 Hasher.sol 。由於 MiMC 演算法是由位元組碼編寫的(甚至都不是用內聯彙編寫的),所以在 etherscan 上的是沒有驗證的狀態。為什麼不用內聯彙編實現,開發者給的原因是:內聯彙編不允許使用指令對堆疊進行操作

MiMC 雜湊演算法:https://byt3bit.github.io/primesym/mimc/

MiMCSponge(R, C) 函式中

  1. 輸入: R 是被雜湊的資訊,C 是輪常數。
  2. 輸出: R 是雜湊後的資訊,C 是更新後的輪常數。

在對左葉子節點進行了第一次雜湊操作後,需要對返回值進行相加取模操作 R = addmod(R, uint256(_right), FIELD_SIZE); ,以確保結果落在 FIELD_SIZE 範圍內,以便將其作為輸入再次進行雜湊操作。

function hashLeftRight(
  IHasher _hasher,
  bytes32 _left,
  bytes32 _right
) public pure returns (bytes32) {
  require(uint256(_left) < FIELD_SIZE, "_left should be inside the field");
  require(uint256(_right) < FIELD_SIZE, "_right should be inside the field");
  uint256 R = uint256(_left);
  uint256 C = 0;
  (R, C) = _hasher.MiMCSponge(R, C);
  R = addmod(R, uint256(_right), FIELD_SIZE);
  (R, C) = _hasher.MiMCSponge(R, C);
  return bytes32(R);
}

在查詢資料的過程中,發現了一個用 solidity 實現的 MiMC 演算法,不知道是否和 Tornado 用位元組碼編寫的演算法版本細節一致,但是出於學習的目的可以對 solidity 版本的演算法進行分享。

MiMC Solidity:https://gist.github.com/poma/5adb51d49057d0a0edad2cbd12945ac4#file-mimc-sol

整個演算法主要由 220 輪的運算組成,每輪運算細節如下:

  1. t 等於 xL 加上一個常數(注意這個常數每輪都不一樣)取模
  2. xR 等於上一輪的 xL
  3. xL 等於上一輪的 xR 加上 t 五次方取模
pragma solidity ^0.5.8;

contract MiMC {
  uint constant FIELD_SIZE = 21888242871839275222246405745257275088548364400416034343698204186575808495617;

  function MiMCSponge(uint256 xL, uint256 xR) public pure returns (uint256, uint256) {
    uint exp;
    uint t;
    uint xR_tmp;
    t = xL;
    exp = mulmod(t, t, FIELD_SIZE);
    exp = mulmod(exp, exp, FIELD_SIZE);
    exp = mulmod(exp, t, FIELD_SIZE);
    xR_tmp = xR;
    xR = xL;
    xL = addmod(xR_tmp, exp, FIELD_SIZE);

    t = addmod(xL, 7120861356467848435263064379192047478074060781135320967663101236819528304084, FIELD_SIZE);
    exp = mulmod(t, t, FIELD_SIZE);
    exp = mulmod(exp, exp, FIELD_SIZE);
    exp = mulmod(exp, t, FIELD_SIZE);
    xR_tmp = xR;
    xR = xL;
    xL = addmod(xR_tmp, exp, FIELD_SIZE);

    ...
    // totally 220 rounds
    ...

    t = xL;
    exp = mulmod(t, t, FIELD_SIZE);
    exp = mulmod(exp, exp, FIELD_SIZE);
    exp = mulmod(exp, t, FIELD_SIZE);
    xR = addmod(xR, exp, FIELD_SIZE);

    return (xL, xR);
  }

  function hashLeftRight(uint256 _left, uint256 _right) public pure returns (uint256) {
    uint256 R = _left;
    uint256 C = 0;
    (R, C) = MiMCSponge(R, C);
    R = addmod(R, uint256(_right), FIELD_SIZE);
    (R, C) = MiMCSponge(R, C);
    return R;
  }
}

取款業務程式碼分析

當使用者需要進行取款操作時,先向 Dapp 提供存款時獲得的 note,再由 Dapp 補充一些相關的公共資料作為輸入引數,然後就可以呼叫智慧合約進行取款操作了。

整個 withdraw 函式接收了一系列的引數,對引數進行了檢查後,呼叫 verifier.verifyProof 函式檢驗零知識證明的有效性(重點)。隨後記錄這筆取款,向收款地址傳送取款金額。

為了文章的連貫性,引數檢查部分將會在後面展開介紹

  function withdraw(
    bytes calldata _proof,
    bytes32 _root,
    bytes32 _nullifierHash,
    address payable _recipient,
    address payable _relayer,
    uint256 _fee,
    uint256 _refund
  ) external payable nonReentrant {
    require(_fee <= denomination, "Fee exceeds transfer value");
    require(!nullifierHashes[_nullifierHash], "The note has been already spent");
    require(isKnownRoot(_root), "Cannot find your merkle root"); // Make sure to use a recent one
    require(
      verifier.verifyProof(
        _proof,
        [uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund]
      ),
      "Invalid withdraw proof"
    );

    nullifierHashes[_nullifierHash] = true;
    _processWithdraw(_recipient, _relayer, _fee, _refund);
    emit Withdrawal(_recipient, _nullifierHash, _relayer, _fee);
  }

首先來看一下輸入引數:

  1. _proof:一個 zk-SNARKs 證明,用於驗證與提款請求相關的所有條件和資料都是有效的
  2. _root:Merkle 樹的根雜湊值
  3. _nullifierHash:防止雙重支付的識別符號,當存款被提取時對應的 nullifier 將被標記為已使用
  4. _recipient:接收資金的地址
  5. _relayer:中繼者地址,代替使用者進行合約互動
  6. _fee:給中繼者的費用
  7. _refund:交易的實際成本低於預支付的金額時,將退還給 _recipient 的金額

可以看到這些輸入引數都在 verifier.verifyProof 函式被使用到,verifier 合約是根據約束電路生成的,用來驗證零知識證明有效性的合約。由於他是根據約束電路生成的,所以其合約的具體實現我們不必深究,我們將重點放在約束電路的實現上。

透過閱讀 withdraw.circom 的程式碼可以得知,其中 _proof 引數作為約束證明,而剩餘的 6 個引數作為公共輸入參與驗證。

剩下的四個隱私輸入,都可以透過 note 解析出來:

  1. nullifier:note 的前 31 個位元組
  2. secret:note 的後 31 個位元組
  3. pathElements[levels]:驗證(nullifier + secret)為 Mercle 樹中的子節點所對應的 proof path
  4. pathIndices[levels]pathElements[levels] 中每個節點作為左子節點還是右子節點的標誌位
template Withdraw(levels) {
    signal input root;
    signal input nullifierHash;
    signal input recipient; // not taking part in any computations
    signal input relayer;  // not taking part in any computations
    signal input fee;      // not taking part in any computations
    signal input refund;   // not taking part in any computations
    signal private input nullifier;
    signal private input secret;
    signal private input pathElements[levels];
    signal private input pathIndices[levels];

    ...

}

首先定義的了一個 CommitmentHasher() 元件 hasher,其主要的功能就是雜湊運算,根據輸入的 nullifiersecret,計算並輸入 nullifierHashcommitment 。約束計算得到的 hasher.nullifierHash 和使用者輸入的 nullifierHash 需要是相等的。

    component hasher = CommitmentHasher();
    hasher.nullifier <== nullifier;
    hasher.secret <== secret;
    hasher.nullifierHash === nullifierHash;

然後定義的了一個 MerkleTreeChecker() 元件 tree,用作驗證 hasher.commitment 是否為以 root 為根的 Merkle 樹中的一個葉子節點。

    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];
    }

最後這部分程式碼,透過計算 recipient, relayer, fee, refund 的平方,間接地將這些值納入到電路的約束中。這樣做的目的,是為了將 Tornado.withdraw 函式中的輸入 proof 和剩餘的輸入對應起來,避免了交易資訊廣播後,攻擊者獲取了 proof 以後將 recipient 等引數替換成自己的地址進行搶跑操作。將下列引數寫進電路約束中後,一但 recipient, relayer, fee, refund 的值發生了改變,那麼對應的 proof 也會改變,從而避免了在合約呼叫層面篡改引數的問題。

    signal recipientSquare;
    signal feeSquare;
    signal relayerSquare;
    signal refundSquare;
    recipientSquare <== recipient * recipient;
    feeSquare <== fee * fee;
    relayerSquare <== relayer * relayer;
    refundSquare <== refund * refund;

MerkleTreeChecker 函式檢查

進入到 merkleTree.MerkleTreeChecker 函式,這個函式的功能就是約束 leaf 作為 root 的一個葉子結點。其中 DualMux() 函式的作用就是根據標誌位 s 來決定 in[0]in[1] 誰作為左子樹誰作為右子樹。

在決定了左右子樹以後,將它們傳入到 HashLeftRight() 函式中進行雜湊操作,輸出 hash。不斷地進行迴圈計算,並將最終結果和 root 引數進行約束 root === hashers[levels - 1].hash; ,確保兩個值相等。

template MerkleTreeChecker(levels) {
    signal input leaf;
    signal input root;
    signal input pathElements[levels];
    signal input pathIndices[levels];

    component selectors[levels];
    component hashers[levels];

    for (var i = 0; i < levels; i++) {
        selectors[i] = DualMux();
        selectors[i].in[0] <== i == 0 ? leaf : hashers[i - 1].hash;
        selectors[i].in[1] <== pathElements[i];
        selectors[i].s <== pathIndices[i];

        hashers[i] = HashLeftRight();
        hashers[i].left <== selectors[i].out[0];
        hashers[i].right <== selectors[i].out[1];
    }

    root === hashers[levels - 1].hash;
}

DualMux() 函式的巧思

DualMux 函式是一個功能簡單,但是有點巧思的函式,可以和大家分享一下。

首先是 s * (1 - s) === 0 約束,它限制了 s 的值只能是 0 或 1。

其次是輸出的表達形式 out[0] <== (in[1] - in[0])*s + in[0];,這個寫法不需要使用條件分支(if … else …),透過使用代數表示式來實現這種動態選擇。

  • s = 0 時,(in[1] - in[0]) * 0 + in[0] = 0 + in[0] = in[0],因此 out[0] = in[0]
  • s = 1 時,(in[1] - in[0]) * 1 + in[0] = (in[1] - in[0]) + in[0] = in[1],因此 out[0] = in[1]
// if s == 0 returns [in[0], in[1]]
// if s == 1 returns [in[1], in[0]]
template DualMux() {
    signal input in[2];
    signal input s;
    signal output out[2];

    s * (1 - s) === 0
    out[0] <== (in[1] - in[0])*s + in[0];
    out[1] <== (in[0] - in[1])*s + in[1];
}

為什麼 note 要採用 nullifier + secret 的形式

Tornado Cash 作為一個混幣器,其功能是隱藏存款者與取款者之間的聯絡。採用 nullifier + secret 的形式來構成取款憑證 note 是為了防止多次重複取款的同時保護取款者身份不被洩露。

首先考慮只用 secret 的場景:

  1. 使用者存款,生成 secret 值,並將其雜湊值插入到 Merkle 樹中;
  2. 使用者取款,提供 secret 值對應的雜湊進行驗證;
  3. 驗證透過,取款;
  4. 將改雜湊值對應的取款狀態置位 true,防止重複取款。

經過第 4 步操作後,取款者的身份和 secret 雜湊對應上了,而 secret 雜湊又和存款者的身份是對應的。這樣就使得存款者和取款者聯絡上了,徹底暴露了資金鍊兩端的聯絡。

採用 nullifier + secret 的形式可以解決上述的問題

  1. 使用者存款,生成 nullifier + secret 值,並將其雜湊值插入到 Merkle 樹中;
  2. 使用者取款,提供 hash(nullifier) 以及包含 nullifier 和 secret 的 proof 證明;
  3. 驗證 proof
    1. 驗證 hash(nullifier) 和 proof 中的 hasher.nullifierHash 是否相等
    2. 驗證 proof 證明中的 hash(nullifier + secret) 是否為 Merkle 樹的葉子結點
  4. 將 hash(nullifier) 對應的取款狀態置位 true,防止重複取款。

透過這種形式,使用者只需要暴露 hash(nullifier) 的值,其他人無法將 hash(nullifier) 和任意葉子節點 hash(nullifier + secret) 聯

isKnownRoot() 函式檢查 root 是否還有時效性

withdraw 函式中,會透過 isKnownRoot 函式對傳入的根 _root 進行檢查

require(isKnownRoot(_root), "Cannot find your merkle root"); // Make sure to use a recent one

在這個函式中,會使用到之前提到的全域性變數

// 每次更新後 Merkle 樹根的值
mapping(uint256 => bytes32) public roots;

// 可以儲存的最大樹根數量,避免儲存過多的歷史資訊
uint32 public constant ROOT_HISTORY_SIZE = 30;

// 當前 Merkle 樹根的值,代表處於 (currentRootIndex / ROOT_HISTORY_SIZE) 的位置。
uint32 public currentRootIndex = 0;

這個函式的功能就是檢查傳入的 _root 引數是否為 roots[] 中儲存的最近的 ROOT_HISTORY_SIZE 個根。roots[]ROOT_HISTORY_SIZE 配合使用,實現了一個長度為 30 的迴圈陣列,當前根的索引值為 currentRootIndex

function isKnownRoot(bytes32 _root) public view returns (bool) {
  if (_root == 0) {
    return false;
  }
  uint32 _currentRootIndex = currentRootIndex;
  uint32 i = _currentRootIndex;
  do {
    if (_root == roots[i]) {
      return true;
    }
    if (i == 0) {
      i = ROOT_HISTORY_SIZE;
    }
    i--;
  } while (i != _currentRootIndex);
  return false;
}

採取這個方案的好處:

  1. 避免了儲存過多的歷史根值,縮小了檢索的範圍。
  2. 採用最近的 30 個根值也能夠避免當一個使用者 A 生成了 proof 到呼叫合約取款這個時間區間內,另一個使用者 B 發起了存款操作導致根值改變的情況。因為一但根值改變後,使用者 A 的 proof 將無法透過校驗。

參考文件

  1. APP:https://tornado.ws/
  2. Circuit Doc:https://docs.tornado.ws/circuits/core-deposit-circuit.html
  3. Tornado Cash工作原理(面對開發人員的逐行解析)
  4. 真正的ZK應用:回看Tornado Cash的原理與業務邏輯

後記

也好久沒有更新部落格了,這段時間裡處於一個對未來的職業發展以及技術積累比較迷茫的狀態,導致做事情有點舉棋不定,不敢做也不知道怎麼做。在這種狀態下既沒辦法靜下心來深入研究某個東西,也沒有辦法鼓起勇氣去探索新的方向,總的來說就是兩個字:內耗。
目前也沒有想到什麼好的辦法能夠走出當前這種局面,真的讓人苦惱。

相關文章