Privacy Pool 隐私池的设计

文章深入分析了隐私池(Privacy Pool)的设计,包括其与龙卷风现金(Tornado Cash)的对比、核心功能(如怒退、部分提款)、代码实现细节(包括commitment.circom、withdraw.circom等回路的分析)以及合约层面的设计(存款、提款、中继提款、怒退、关联集)。文章还提到链下隐私的问题和可能的改进方向。

隐私池的设计

隐私池的网站介面,走一個简洁乾淨的美術風格

感謝 TEM 審稿者 Kevin, Kimi, Nic 的細心審閱。

最近在參加铁人賽,写了有关龙卷风现金(Tornado Cash),以及其继任者隐私池(Privacy Pool)的

铁人賽的文章比較摘要式的談,但這篇想看一些源代码的细节。

讀者若有具备龙卷风现金的一些基礎知识,会比較能欣賞隐私池的细节。但如果沒有的話也沒关係,可以看一下简介。

简介

龙卷风现金是一种打斷币流的混币器,目的是讓使用者得到「链上」的隐私。合約只有「存款」和「提款」兩個函数。使用者把一筆金額,例如一顆以太币,存入龙卷风现金合約。過了一段時間之后,也許幾個月,再從合約提款。人們只能知道提款者是眾多存款者之一,但除非有其他已知暴露的信息,沒办法知道提款者具体是哪個存款者。

提款的交易中,合約会检查提款者送出的零知识证明。通過這個检查,代表提款者曾經存款,且不曾提款過。

背后用到的密码學就是Hash函数和零知识证明。

Hash函数可以用一些合理的方式層層包裝,例如 f(x) = keccak(keccak(x))g(x) = keccak(a, b, c, keccak(x)) (其中 a, b, c 是常数),這些函数 f, g 都会針對 x 產生出不会撞號的Hash值。雖然 f 和 g 都用了 keccak ,但 f 和 g 可以「視為」兩种不同的Hash函数。

在龙卷风现金的應用中,想像我們有兩個包裝過的Hash函数:hash1 和 hash2 。使用者有個秘密的隨机值 random。這個隨机值必須從夠大的空間抽樣(例如: 128 位元),讓攻击者無法用電腦暴力搜索,從Hash值反推隨机值的数字。

  • 存款:留下 hash1(random)
  • 提款:留下 hash2(random)

外人無法從Hash值 hash1(random)hash2(random) 得知兩者的关联。

因此使用者在提款時:

  1. 合約先检查 hash2(random) 不曾出现過,確認該使用者尚未提款。
  2. 电路以私密参数接收 hash1(random),以公開参数接收 hash2(random)
  3. 电路验证 hash1(random)hash2(random) 背后的隨机值是相同的。這樣验证存提款是同一個人,卻又不揭露存款者的信息。
  4. 最后合約把 hash2(random) 記录在链上。這樣重複提款時,在步驟一就会失敗。

hash2(random) 像是在存款名單上,把提款過的人的名字劃掉,但劃掉了誰只有提款者知曉。

龙卷风现金的問題

龙卷风现金的设计是任何人都可以使用,這造成了一些問題。駭客攻击完合約或交易所后,盜取的币会往龙卷风现金跑,造成日后追緝不易。

既然壞人可能從龙卷风现金出來,交易所就不收從龙卷风现金出來的币。

因應這种现況,龙卷风现金開發團隊提供一個所謂「 合規工具」,讓使用者可以對交易所提出零知识证明,去证明自己從龙卷风现金出來的币,來源是哪個存款地址。讀者可以看出來,使用「合規工具」,就相當於失去使用龙卷风现金的意義。

Vitalik 和 Ameen Soleimani 等人,在 2023 年發表一篇 論文,提出名為 隐私池(Privacy Pool) 的设计。其实也就是改良龙卷风现金,並在背后加上一個人工維護的名單,稱作 关联集(Association Set)。這個名單的设计可以是允許名單或拒絕名單。提款時得先证明自己在允許名單中,或不在拒絕名單中。人們可以去设计自己的名單,看想要美國合規、歐盟合規、或中國合規都可以。

隐私池

2025 三月 0xbow 團隊部署了正式版的隐私池 https://privacypools.com/

其中关联集的運作方式是允許名單,在存款打入隐私池合約之后,需要等一陣子。 关联集提供者(Association Set Provider, ASP) 会把存款的標籤加到允許名單中。名單在密码學中,是以Hash树表達,在电路中用Hash树根與成員证明即可。

怒退

隐私池因為增加了关联集,讓提款不再是無條件能發生。這有一個新問題:那萬一使用者存款了,卻被標記無法提款,錢卡在合約裡怎麼办?

這有兩种情況:一种是使用者存款后,被標記為不合規了,不給放到允許清單裡。另一种可能是,使用者已經存款,並提款数次了,但突然被從允許清單移除。

因應這些情況,隐私池也设计了 怒退(Ragequit) 功能。在被清單排除后,使用者可以怒退存款,放弃清理蹤跡並取回自己的余額。但使用者必須記得第一次存款時是用哪個帳號存的,資產只能回去原來那個地址。否則壞人可以利用怒退來变相清理金流足跡。

支援不同存提款金額

龙卷风现金同一個币种,会分三個固定額度的池子,例如 1 ETH, 10 ETH, 100 ETH,並規定存提款就是該固定的額度。目的是為了避免金額差異暴露存提款者的身份。

在固定額度之下, 假設在 1 ETH 的池子內有 300 人存款,匿名集的大小就是 300 人。 300 人的意思是任何一個最新的提款者,他可能是這 300 人的其中一人,匿名集越大就越難猜中提款者是誰。要挑惕一點的話可以把匿名集扣掉已知曝光的存款者與提款者身份連結。

隐私池解放了額度限制,设计了 部分提款功能 ,這讓存款和提款都不再是固定的額度。使用者可以存一大筆金額到合約裡,並每次少量取出。這樣每次取出的部分,可以有各自獨立的足跡。以往一筆大金額就只能有一筆足跡,想要有獨立足跡,必須切碎再重新過合約。

缺點也很明顯:假設池裡共五個人,前面四個都存 1 ETH ,就小明存了 100 ETH 。如果小明使出部分提款 2 ETH ,那就從這五個裡面曝光了。

曝光小明並不只是害到他本人。原本其他人享有 5 人的匿名集,现在因為小明的行為,匿名集变成 4 人了。

因此,在一個千余人存款的池子裡,有各种不同存提款的金額之下,要計算匿名集的大小並不如固定額度的龙卷风现金單純容易。

不同提款金額帶來了一些方便,卻也讓釐清匿名集有多大這件事变困難了。

程式码实现

這邊的分析会以 v1.1.1 版本為主。

專案的零知识证明, 仍用 Circom 語言,配 Groth16 架構,與適用以太坊預編譯能验证的 bn128 曲線验证。

Hash函数

主要的Hash函数用电路友善的 Poseidon Hash函数。最近發现 TACEO 有一個 推文 講怎麼在五花八門的Hash函数中選擇想要的。

因為 Groth16 使用的是算数电路,開發者只能使用整数加法和乘法去表達程式的邏輯。 Poseidon 是專門為算数电路设计的Hash函数,因為只有用加法與乘法構成,所以在电路中使用的成本很低。但有些小個性:他只能吃固定数量的参数,所以会看到有 Poseidon(2) 或 Poseidon(3) 之類的區分,前者只能吃兩個参数,后者三個。

电路或合約中也会看到 keccak Hash函数,目前看起來這是拿來得到標籤用的。keccak 是在 EVM 環境中非常便宜。

從一些前人的 評測 來看

在 EVM 中,也就是合約中,成本以燃氣 gas 計算

  • keccak 起步價 30 gas ,之后每吸收 32 位元組輸入 消耗 6 gas
  • poseidon 大概一個参数 10,000 gas (circomlibjs 实作 (以 JavaScript 撰写 EVM 操作码))

在电路中,成本是消耗多少條限制式

  • keccak 151,357 條
  • poseidon 240 條

整体而言,keccak 在合約超便宜,电路中爆貴。 poseidon 則反過來。

Hash树

Hash树是用 PSE 團隊開發出來的 LeanIMT ,主要是可以在链上增長树,並且节省燃氣。

电路

电路有三個檔案,我們会略過 merkleTree.circom

commmitment.circom

這段就是計算 存款與提款分別留下來的Hash值。 註銷符(nullifier) 是一個抽樣空間夠大的隨机数,連結了這兩個Hash值。

- 存款留下 Poseidon(value, label, Poseidon(nullifier, secret))(可以看成 hash1(nullifier)

- 提款留下 Poesidon(nullifier)(可以看成 hash2(nullifier)

其中 label 是拿來辨识這筆存款用的標籤。会有池子的代號 pool_scope 與 第幾個存款 nonce 。label 后面註解為 keccak256(pool_scope, nonce) % SNARK_SCALAR_FIELD。注意 EVM 裡面是 32 位元組為一字,而电路裡面加減乘除的結果得對一個 254 位元的巨大整数 SNARK_SCALAR_FIELD 取余数。如果一個数字同時要拿來餵合約和电路,要記得取余数,不然合約裡面可以有多個数字對應到一樣的电路数字,有攻击空間。

// commitment.circom
template CommitmentHasher() {

  //////////////////////// SIGNALS ////////////////////////

  signal input value;              // Value of commitment
  signal input label;              // keccak256(pool_scope, nonce) % SNARK_SCALAR_FIELD
  signal input nullifier;          // Nullifier of commitment
  signal input secret;             // Secret of commitment

  signal output commitment;        // Commitment hash
  signal output nullifierHash;     // Nullifier hash

  ///////////////////// END OF SIGNALS /////////////////////

  // 1. Compute nullifier hash
  component nullifierHasher = Poseidon(1);
  nullifierHasher.inputs[0] <== nullifier;

  // 2. Compute precommitment
  component precommitmentHasher = Poseidon(2);
  precommitmentHasher.inputs[0] <== nullifier;
  precommitmentHasher.inputs[1] <== secret;

  // 3. Compute commitment hash
  component commitmentHasher = Poseidon(3);
  commitmentHasher.inputs[0] <== value;
  commitmentHasher.inputs[1] <== label;
  commitmentHasher.inputs[2] <== precommitmentHasher.out;

  // 4. Populate output signals
  commitment <== commitmentHasher.out;
  nullifierHash <== nullifierHasher.out;
}

withdraw.circom

提款电路算是电路中的核心。

部分提款功能的实作,只是單純在存款束縛(Commitment)中,記載存入的金額。提款時做以下事情:

  • 检查提款金額必須小於存款余額。
  • 剩余的存款,製造一個新的存款束縛,放回存款树。(后面合約处理)
  • 用註銷符Hash值註銷旧的存款束縛。(合約記得註銷符Hash值)
  • 對於关联集,也只是在电路中多放一顆树,检查存款標籤的成員证明。

有個有趣的點是提款者的信息。以往龙卷风现金,有 四個相关变数:提款金額的收受地址 _recipient、中继人 _relayer、小费金額 _fee 、還有一個和 ERC20 有关的 _refund 。在龙卷风现金的提款电路結尾,對四個变数做了沒有意義的平方乘法,意图是用合約綁定四個变数,好像签名的效果。

龙卷风现金,預設提款者是倚賴第三方中继人。這是因為和合約互动要繳手續费,因此提款帳戶需要有以太币。若想要提款到完全空白的新地址,可以請中继人幫忙。提款者在零知识证明中指定中继人的地址與要給他們的小费,這樣可以避免中继人的小费被中途打劫。

隐私池捨弃龙卷风现金那种把收受地址、中继人、小费金額等欄位信息写死在电路裡的做法。那些信息会在合約被检查,但在电路內不会用到。隐私池把提款需要的信息抽象成下列 提款結構struct Withdrawal)。在隐私池的提款电路內,需要「签名」的信息变成提款結構的 keccak Hash值,賦值給 context 变数。

struct Withdrawal {
  address processooor;
  bytes data;
}

提款結構中,諧謔稱為 处理者(processooor)的地址,可以是实際的提款者或某個合約。提款結構有個資料欄位,可以打包任意信息。隐私池可以自行提款,也能委託中继人幫忙。我們后面合約可以看到怎麼做。

這樣做的好处有兩种:第一是中继業務所需要的信息抽象掉了,不用写死在电路裡面。合約裡可以做很靈活的设计。

第二是在合約中,检查零知识证明所需的公開輸入(Public Input)变少了。在验证 Groth16 证明的合約中,每多一個公開輸入的参数,会需要多做一個橢圓曲線的常数乘法和加法。(大概消耗 数千 gas

// withdraw.circom
template Withdraw(maxTreeDepth) {

  //////////////////////// PUBLIC SIGNALS ////////////////////////

  // Signals to compute commitments
  signal input withdrawnValue;                   // Value being withdrawn

  // Signals for merkle tree inclusion proofs
  signal input stateRoot;                        // A known state root
  signal input stateTreeDepth;                   // Current state tree depth
  signal input ASPRoot;                          // Latest ASP root
  signal input ASPTreeDepth;                     // Current ASP tree depth
  signal input context;                          // keccak256(IPrivacyPool.Withdrawal, scope) % SNARK_SCALAR_FIELD

  //////////////////// END OF PUBLIC SIGNALS ////////////////////

  /////////////////////// PRIVATE SIGNALS ///////////////////////

  // Signals to compute commitments
  signal input label;                            // keccak256(scope, nonce) % SNARK_SCALAR_FIELD
  signal input existingValue;                    // Value of the existing commitment
  signal input existingNullifier;                // Nullifier of the existing commitment
  signal input existingSecret;                   // Secret of the existing commitment
  signal input newNullifier;                     // Nullifier for the new commitment
  signal input newSecret;                        // Secret for the new commitment

  // Signals for merkle tree inclusion proofs
  signal input stateSiblings[maxTreeDepth];      // Siblings of the state tree
  signal input stateIndex;                       // Indices for the state tree
  signal input ASPSiblings[maxTreeDepth];        // Siblings of the ASP tree
  signal input ASPIndex;                         // Indices for the ASP tree

  /////////////////// END OF PRIVATE SIGNALS ///////////////////

  /////////////////////// OUTPUT SIGNALS ///////////////////////

  signal output newCommitmentHash;               // Hash of new commitment
  signal output existingNullifierHash;           // Hash of the existing commitment nullifier

  /////////////////// END OF OUTPUT SIGNALS ///////////////////

  // 1. Compute existing commitment
  component existingCommitmentHasher = CommitmentHasher();
  existingCommitmentHasher.value <== existingValue;
  existingCommitmentHasher.label <== label;
  existingCommitmentHasher.nullifier <== existingNullifier;
  existingCommitmentHasher.secret <== existingSecret;
  signal existingCommitment <== existingCommitmentHasher.commitment;

  // 2. Output existing nullifier hash
  existingNullifierHash <== existingCommitmentHasher.nullifierHash;

  // 3. Verify existing commitment is in state tree
  component stateRootChecker = LeanIMTInclusionProof(maxTreeDepth);
  stateRootChecker.leaf <== existingCommitment;
  stateRootChecker.leafIndex <== stateIndex;
  stateRootChecker.siblings <== stateSiblings;
  stateRootChecker.actualDepth <== stateTreeDepth;

  stateRoot === stateRootChecker.out;

  // 4. Verify label is in ASP tree
  component ASPRootChecker = LeanIMTInclusionProof(maxTreeDepth);
  ASPRootChecker.leaf <== label;
  ASPRootChecker.leafIndex <== ASPIndex;
  ASPRootChecker.siblings <== ASPSiblings;
  ASPRootChecker.actualDepth <== ASPTreeDepth;

  ASPRoot === ASPRootChecker.out;

  // 5. Check the withdrawn amount is valid
  signal remainingValue <== existingValue - withdrawnValue;
  component remainingValueRangeCheck = Num2Bits(128);
  remainingValueRangeCheck.in <== remainingValue;
  _ <== remainingValueRangeCheck.out;
  component withdrawnValueRangeCheck = Num2Bits(128);
  withdrawnValueRangeCheck.in <== withdrawnValue;
  _ <== withdrawnValueRangeCheck.out;

  // 6. Check existing and new nullifier don't match
  component nullifierEqualityCheck = IsEqual();
  nullifierEqualityCheck.in[0] <== existingNullifier;
  nullifierEqualityCheck.in[1] <== newNullifier;
  nullifierEqualityCheck.out === 0;

  // 7. Compute new commitment
  component newCommitmentHasher = CommitmentHasher();
  newCommitmentHasher.value <== remainingValue;
  newCommitmentHasher.label <== label;
  newCommitmentHasher.nullifier <== newNullifier;
  newCommitmentHasher.secret <== newSecret;

  // 8. Output new commitment hash
  newCommitmentHash <== newCommitmentHasher.commitment;
  _ <== newCommitmentHasher.nullifierHash;

  // 9. Square context for integrity
  signal contextSquared <== context * context;
}

合約

可以直接看到 PrivacyPool.sol

存款

存款的部分,使用者把私下算好的Hash值 _precommitmentHash = Poseidon(nullifier, secret) 傳給合約,並繳納存款。

合約会自动幫使用者產生新的標籤。合約会需要把標籤記下來(放 depositors 变数),日后怒退会用到。

其他功能和旧的龙卷风现金差不多,包含把存款束縛放到合約維護的Hash树中,以及從使用者身上取得存款。

這邊比較混淆的可能是 PoseidonT4 這個写法,雖然名稱裡有 4 ,但实際上只吃三個参数。 PoseidonTX 只能收納 X-1 個参数,這是論文留下來的奇怪慣例。

  /// PrivacyPool.sol
  function deposit(
    address _depositor,
    uint256 _value,
    uint256 _precommitmentHash
  ) external payable onlyEntrypoint returns (uint256 _commitment) {
    // Check deposits are enabled
    if (dead) revert PoolIsDead();

    if (_value >= type(uint128).max) revert InvalidDepositValue();

    // Compute label
    uint256 _label = uint256(keccak256(abi.encodePacked(SCOPE, ++nonce))) % Constants.SNARK_SCALAR_FIELD;
    // Store depositor
    depositors[_label] = _depositor;

    // Compute commitment hash
    _commitment = PoseidonT4.hash([_value, _label, _precommitmentHash]);

    // Insert commitment in state (revert if already present)
    _insert(_commitment);

    // Pull funds from caller
    _pull(msg.sender, _value);

    emit Deposited(_depositor, _commitment, _label, _value, _precommitmentHash);
  }

自行提款

這邊的提款函数是給不委託中继人的提款者使用的。提款人要自己付燃氣手續费。

验過证明之后,合約記得註銷符Hash值,以及把剩余的余額做一筆新存款。

  /// PrivacyPool.sol
  function withdraw(
    Withdrawal memory _withdrawal,
    ProofLib.WithdrawProof memory _proof
  ) external validWithdrawal(_withdrawal, _proof) {
    // Verify proof with Groth16 verifier
    if (!WITHDRAWAL_VERIFIER.verifyProof(_proof.pA, _proof.pB, _proof.pC, _proof.pubSignals)) revert InvalidProof();

    // Mark existing commitment nullifier as spent
    _spend(_proof.existingNullifierHash());

    // Insert new commitment in state
    _insert(_proof.newCommitmentHash());

    // Transfer out funds to procesooor
    _push(_withdrawal.processooor, _proof.withdrawnValue());

    emit Withdrawn(
      _withdrawal.processooor, _proof.withdrawnValue(), _proof.existingNullifierHash(), _proof.newCommitmentHash()
    );
  }

验证的部分,分為电路的验证與合約的验证。前者以另外部署的 WITHDRAWAL_VERIFIER 合約,做验证 groth16 证明必要的橢圓曲線運算。后者以 validWithdrawal 修飾子去做。

裡面得检查:

  • 合約交易的發送方,確实是零知识证明中 context 变数所綁定的处理人 processooor 。如果沒检查這件事,其他人可以複製证明內容,並劫持提款到其他地址。
  • 检查零知识证明中所綁定的存款树(他們稱狀態树 state tree)的树根,有在合約紀录之中
  • 检查零知识证明中所綁定的关联集树根,也有在合約紀录之中

合約会滾动記录Hash树的树根。因為提款者在產生提款证明時,可能其他存款者也在存款。因此合約不能只記得一個树根,而是要記得一段時間的树根,才不会提款交易發出的時候,树根都過期了。

  /// PrivacyPool.sol
  modifier validWithdrawal(Withdrawal memory _withdrawal, ProofLib.WithdrawProof memory _proof) {
    // Check caller is the allowed processooor
    if (msg.sender != _withdrawal.processooor) revert InvalidProcessooor();

    // Check the context matches to ensure its integrity
    if (_proof.context() != uint256(keccak256(abi.encode(_withdrawal, SCOPE))) % Constants.SNARK_SCALAR_FIELD) {
      revert ContextMismatch();
    }

    // Check the tree depth signals are less than the max tree depth
    if (_proof.stateTreeDepth() > MAX_TREE_DEPTH || _proof.ASPTreeDepth() > MAX_TREE_DEPTH) revert InvalidTreeDepth();

    // Check the state root is known
    if (!_isKnownRoot(_proof.stateRoot())) revert UnknownStateRoot();

    // Check the ASP root is the latest
    if (_proof.ASPRoot() != ENTRYPOINT.latestRoot()) revert IncorrectASPRoot();
    _;
  }

委託提款

在委託提款的情境中,提款者会指定中继人,並約定好手續费。產好证明后,由中继人對合約發起交易。

前述 Withdrawal 提款結構中的 processooor 值,会是 Entrypoint.sol 的地址,原因我們后面解釋。而 data 欄位值,則是下列中继資料的序列化。

  struct RelayData {
    address recipient; /// The recipient of the funds withdrawn from the pool
    address feeRecipient; /// The recipient of the fee
    uint256 relayFeeBPS; /// The relay fee in basis points
  }

recipient 会是提款人的地址。 feeRecipient 則是中继者。最后费用則是以基點(basis points, bp) 計算。 100 bp 是收提款金額 1% 的意思, 50 bp 是 0.5% 。

中继提款的函数長在 Entrypoint.sol 合約。這個合約管理眾多不同資產的池子。

注意合約開頭有個 if (_withdrawal.processooor != address(this)) revert InvalidProcessooor(); ,检查处理者必須是這個合約的地址。

接著 relay 函数会先以合約的身份呼叫池子的提款函数 _pool.withdraw(_withdrawal, _proof); 。這一步会先把錢提到合約這邊,然后再切分成小费與剩余提款額度,分別給予中继人與提款者。

(那步驟我第一次看時一度卡住,覺得怎麼錢被提了兩次。 withdraw 函数裡 _push 了一次,relay 函数裡又 _transfer 一次)

可以觀察 _data 結構是怎麼從提款結構的資料欄位解構出來的。

  /// Entrypoint.sol
  function relay(
    IPrivacyPool.Withdrawal calldata _withdrawal,
    ProofLib.WithdrawProof calldata _proof,
    uint256 _scope
  ) external nonReentrant {
    // Check withdrawn amount is non-zero
    if (_proof.withdrawnValue() == 0) revert InvalidWithdrawalAmount();
    // Check allowed processooor is this Entrypoint
    if (_withdrawal.processooor != address(this)) revert InvalidProcessooor();

    // Fetch pool by scope
    IPrivacyPool _pool = scopeToPool[_scope];
    if (address(_pool) == address(0)) revert PoolNotFound();

    // Store pool asset
    IERC20 _asset = IERC20(_pool.ASSET());
    uint256 _balanceBefore = _assetBalance(_asset);

    // Process withdrawal
    _pool.withdraw(_withdrawal, _proof);

    // Decode relay data
    RelayData memory _data = abi.decode(_withdrawal.data, (RelayData));

    if (_data.relayFeeBPS > assetConfig[_asset].maxRelayFeeBPS) revert RelayFeeGreaterThanMax();

    uint256 _withdrawnAmount = _proof.withdrawnValue();

    // Deduct fees
    uint256 _amountAfterFees = _deductFee(_withdrawnAmount, _data.relayFeeBPS);

    uint256 _feeAmount = _withdrawnAmount - _amountAfterFees;

    // Transfer withdrawn funds to recipient
    _transfer(_asset, _data.recipient, _amountAfterFees);
    // Transfer fees to fee recipient
    _transfer(_asset, _data.feeRecipient, _feeAmount);

    // Check pool balance has not been reduced
    uint256 _balanceAfter = _assetBalance(_asset);
    if (_balanceBefore > _balanceAfter) revert InvalidPoolState();

    emit WithdrawalRelayed(msg.sender, _data.recipient, _asset, _withdrawnAmount, _feeAmount);
  }

怒退

使用者在遲遲沒办法得到关联集提供者的許可時,可以怒退款。做法是產生零知识证明,並呼叫 ragequit 函数。

怒退使用了 commitment.circom 這個电路,並把 valuelabel 這兩個輸入值 設為公開。輸出值 commitmentHashnullifierHash 也是公開的。

合約中的 ProofLib.RagequitProof 這個参数結構(欄位沒列在本文),裡面检查 commitmentHash nullifierHash value label 四個公開参数。

合約中,要检查怒退者地址確实是當時的存款地址,用 depositors[_label] 這個变数。

注意提款电路中,新與旧的存款束縛皆会分享同一個標籤。因此即使提款多次,合約中留下來最新的存款束縛,其 label 会是一樣的。因此怒退時,可以用帶有最新余額的存款束縛,與 depositors[_label] 作比較。

如果沒有要求原始存款地址這個條件的話,会發生什麼事呢?可以知道的是,怒退者可以使用其他地址進行怒退。但怒退者有沒有办法变相借怒退,達成清理蹤跡的提款效果?理論上應該不可行,因為有效的零知识证明会咬著 label,暴露當時的存款束縛,也就把當時存款地址的关係綁在一起。

推測要求原始存款地址,可能只是实務上想避免無謂的分析成本。

  /// PrivacyPool.sol
  function ragequit(ProofLib.RagequitProof memory _proof) external {
    // Check if caller is original depositor
    uint256 _label = _proof.label();
    if (depositors[_label] != msg.sender) revert OnlyOriginalDepositor();

    // Verify proof with Groth16 verifier
    if (!RAGEQUIT_VERIFIER.verifyProof(_proof.pA, _proof.pB, _proof.pC, _proof.pubSignals)) revert InvalidProof();

    // Check commitment exists in state
    if (!_isInState(_proof.commitmentHash())) revert InvalidCommitment();

    // Mark existing commitment nullifier as spent
    _spend(_proof.nullifierHash());

    // Transfer out funds to ragequitter
    _push(msg.sender, _proof.value());

    emit Ragequit(msg.sender, _proof.commitmentHash(), _proof.label(), _proof.value());
  }

合約裡面,有趣的小细节大概是因為從存款树裡找 commitmentHash 在怒退情境中沒有隐私需求,所以可以直接在 EVM 裡用 mapping 找。

关联集

一直想了解隐私池怎麼处理关联集和存款树。

要產出提款电路的证明,必須做出关联集和存款树的Hash树成員证明。电路裡的变数要有树根、自己的树葉、以及必要的中間节點。

一般而言,必須先取得所有的葉子,再從所有的葉子算出需要的東西。

存款树的話,因為其建構都在合約中進行,插入新葉子時,也会釋出事件(Events)。跑节點的人可以監聽事件去取得所有的葉子。沒跑节點的人,印象以往龙卷风现金是跟 web3 提供者(provider)拉事件回來。

关联集的話,合約中僅有授權人士可以更新树根,並会附上 IPFS CID。這些都沒什麼嚴格的检查,全憑良心。

  /// Entrypoint.sol
  function updateRoot(uint256 _root, string memory _ipfsCID) external onlyRole(_ASP_POSTMAN) returns (uint256 _index) {
    // Check provided values are non-zero
    if (_root == 0) revert EmptyRoot();
    uint256 _cidLength = bytes(_ipfsCID).length;
    if (_cidLength < 32 || _cidLength > 64) revert InvalidIPFSCIDLength();

    // Push new association set and update index
    associationSets.push(AssociationSetData(_root, _ipfsCID, block.timestamp));
    _index = associationSets.length - 1;

    emit RootUpdated(_root, _ipfsCID, block.timestamp);
  }

目前 网站程式码 顯示,提款或怒退的時候,会去打一個 API ,取得所有系統现有的存款葉子和关联集葉子。

实際用网站操作,並觀察開發者工具的网路分頁(Network tab)。結果發现是打 https://api.0xbow.io/1/public/mt-leaves 這個 API ,拉回一個 JSON 物件,分別有关联集葉子近兩千筆,及存款树葉子近四千筆。一個葉子 32 位元組,数千筆應該就幾 KB 大小,以前端而言應該也不罪過?

這裡最壞的情況是 API 壞掉了,無法透過网頁下載关联集和存款树。人們可以自行跑节點取得存款树葉子,然后怒退。

链下隐私

隐私池這類工具目的在於清理链上蹤跡。但沒管到链下的隐私。因此透過网頁傳送提款需求,似乎還是会透漏 IP 信息。要與其他工具搭配著用。

因為关联集有存在 IPFS 上,理想上可能可以找到一些比較隐私的做法,從 IPFS 取得关联集。

目前除了网頁之外,似乎也還沒看到可以自己架設的版本。如果有人有知道或是蓋出來可以分享一下。

結語

龙卷风现金與隐私池是我很喜歡的專案。他們电路和合約夠小,讀起來不会太累,能夠思考各种细节。同時又是個已經正在運作的專案,並產生各种社会衝击。每次重看都会看到一些之前沒看到的東西。

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
EthTaipei
EthTaipei
Taipei Ethereum Meetup