跳至主要內容

撰寫一個保護隱私的特定應用程式電漿

零知識
伺服器
鏈下
隱私
進階
奧里·波梅蘭茨
2025年10月15日
46 分鐘閱讀

簡介

匯總相反,電漿使用以太坊主網來確保完整性,但不保證可用性。在本文中,我們將撰寫一個行為類似於電漿的應用程式,由以太坊保證完整性(沒有未經授權的更改),但不保證可用性(中心化元件可能會停機並使整個系統癱瘓)。

我們在此撰寫的應用程式是一個保護隱私的銀行。不同的地址擁有帶有餘額的帳戶,並且它們可以將資金(ETH)發送給其他帳戶。銀行會發布狀態(帳戶及其餘額)和交易的雜湊值,但將實際餘額保留在鏈下,以保持其隱私。

設計

這不是一個可用於生產環境的系統,而是一個教學工具。因此,在編寫時採用了幾個簡化的假設。

  • 固定的帳戶池。帳戶數量是特定的,且每個帳戶屬於一個預先決定的地址。這使得系統簡單得多,因為在零知識證明中很難處理可變大小的資料結構。對於可用於生產環境的系統,我們可以使用 默克爾根 作為狀態雜湊,並為所需的餘額提供默克爾證明。

  • 記憶體儲存。在生產系統上,我們需要將所有帳戶餘額寫入磁碟,以便在重新啟動時保留它們。在這裡,如果資訊直接遺失也沒關係。

  • 僅限轉帳。生產系統需要一種將資產存入銀行並提取的方法。但這裡的目的只是為了說明概念,因此這家銀行僅限於轉帳。

零知識證明

從根本上來說,零知識證明表明證明者知道某些資料 Dataprivate,使得某些公開資料 DatapublicDataprivate 之間存在某種關係 Relationship。驗證者知道 RelationshipDatapublic

為了保護隱私,我們需要狀態和交易保持私密。但為了確保完整性,我們需要狀態的 密碼學雜湊 (opens in a new tab) 是公開的。為了向提交交易的人證明這些交易確實發生了,我們還需要發布交易雜湊值。

在大多數情況下,Dataprivate 是零知識證明程式的輸入,而 Datapublic 是輸出。

Dataprivate 中的這些欄位:

  • Staten,舊狀態
  • Staten+1,新狀態
  • Transaction,將舊狀態更改為新狀態的交易。此交易需要包含以下欄位:
    • 接收轉帳的 目標地址
    • 正在轉帳的 金額
    • 隨機數,以確保每筆交易只能被處理一次。 來源地址不需要包含在交易中,因為它可以從簽章中還原。
  • Signature,授權執行交易的簽章。在我們的案例中,唯一被授權執行交易的地址是來源地址。由於我們的零知識系統的運作方式,除了以太坊簽章之外,我們還需要帳戶的公鑰。

這些是 Datapublic 中的欄位:

  • Hash(Staten) 舊狀態的雜湊
  • Hash(Staten+1) 新狀態的雜湊
  • Hash(Transaction) 將狀態從 Staten 更改為 Staten+1 的交易雜湊值。

該關係會檢查幾個條件:

  • 公開雜湊確實是私密欄位的正確雜湊。
  • 將交易應用於舊狀態時,會產生新狀態。
  • 簽章來自交易的來源地址。

由於密碼學雜湊函數的特性,證明這些條件就足以確保完整性。

資料結構

主要的資料結構是伺服器所持有的狀態。對於每個帳戶,伺服器會追蹤帳戶餘額和一個 隨機數 (opens in a new tab),用於防止 重放攻擊 (opens in a new tab)

元件

此系統需要兩個元件:

  • 伺服器,負責接收交易、處理交易,並將雜湊連同零知識證明發布到鏈上。
  • 智能合約,負責儲存雜湊並驗證零知識證明,以確保狀態轉換是合法的。

資料與控制流程

這些是各個元件之間進行通訊以從一個帳戶轉帳到另一個帳戶的方式。

  1. 網頁瀏覽器提交一筆已簽署的交易,要求從簽署者的帳戶轉帳到另一個不同的帳戶。

  2. 伺服器驗證該交易是否有效:

    • 簽署者在銀行中有一個餘額充足的帳戶。
    • 接收者在銀行中有一個帳戶。
  3. 伺服器透過從簽署者的餘額中減去轉帳金額,並將其加到接收者的餘額中,來計算新狀態。

  4. 伺服器計算一個零知識證明,證明該狀態變更是有效的。

  5. 伺服器向以太坊提交一筆交易,其中包含:

    • 新狀態雜湊
    • 交易雜湊值(以便交易發送者可以知道它已被處理)
    • 證明轉換到新狀態是有效的零知識證明
  6. 智能合約驗證零知識證明。

  7. 如果零知識證明驗證通過,智能合約會執行以下操作:

    • 將當前狀態雜湊更新為新狀態雜湊
    • 發出一個包含新狀態雜湊和交易雜湊值的日誌條目

工具

對於客戶端程式碼,我們將使用 Vite (opens in a new tab)React (opens in a new tab)Viem (opens in a new tab)Wagmi (opens in a new tab)。這些是業界標準的工具;如果您對它們不熟悉,可以使用 本教學

伺服器的大部分程式碼是使用 Node (opens in a new tab) 以 JavaScript 編寫的。零知識部分是以 Noir (opens in a new tab) 編寫的。我們需要 1.0.0-beta.10 版本,因此在您 按照指示安裝 Noir (opens in a new tab) 之後,請執行:

noirup -v 1.0.0-beta.10

我們使用的區塊鏈是 anvil,這是一個本地測試區塊鏈,也是 Foundry (opens in a new tab) 的一部分。

實作

因為這是一個複雜的系統,我們將分階段實作。

第 1 階段 - 手動零知識

在第一階段,我們將在瀏覽器中簽署一筆交易,然後手動將資訊提供給零知識證明。零知識程式碼預期在 server/noir/Prover.toml 中取得該資訊(文件請見此處 (opens in a new tab))。

要查看實際運作情況:

  1. 確保你已安裝 Node (opens in a new tab)Noir (opens in a new tab)。最好將它們安裝在 UNIX 系統上,例如 macOS、Linux 或 WSL (opens in a new tab)

  2. 下載第 1 階段的程式碼並啟動網頁伺服器以提供客戶端程式碼。

    git clone https://github.com/qbzzt/250911-zk-bank.git -b 01-manual-zk
    cd 250911-zk-bank
    cd client
    npm install
    npm run dev
    

    這裡需要網頁伺服器的原因是,為了防止某些類型的詐欺,許多錢包(例如梅塔馬斯克)不接受直接從磁碟提供的檔案

  3. 開啟帶有錢包的瀏覽器。

  4. 在錢包中,輸入新的密碼短語。請注意,這將刪除你現有的密碼短語,因此_請確保你已備份_。

    密碼短語為 test test test test test test test test test test test junk,這是 anvil 的預設測試密碼短語。

  5. 瀏覽至客戶端程式碼 (opens in a new tab)

  6. 連接至錢包並選擇你的目標帳戶與金額。

  7. 點擊 Sign 並簽署交易。

  8. Prover.toml 標題下,你會找到文字。將 server/noir/Prover.toml 替換為該文字。

  9. 執行零知識證明。

    cd ../server/noir
    nargo execute
    

    輸出應類似於

ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute

[zkBank] Circuit witness successfully solved [zkBank] Witness saved to target/zkBank.gz [zkBank] Circuit output: (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85)

訊息採用純文字格式,這使得使用者易於理解(在簽署時這是必要的),也便於 Noir 程式碼進行解析。金額以芬尼為單位報價,一方面可以實現小數轉帳,另一方面也易於閱讀。最後一個數字是隨機數 (opens in a new tab)

字串長度為 100 個字元。零知識證明無法很好地處理可變大小的資料,因此通常需要對資料進行填充。

pubKeyX=["0x83",...,"0x75"]
pubKeyY=["0x35",...,"0xa5"]
signature=["0xb1",...,"0x0d"]

這三個參數是固定大小的位元組陣列。

這是指定結構陣列的方法。對於每個條目,我們指定地址、餘額(以 milliETH 即芬尼 (opens in a new tab)為單位)以及下一個隨機數值。

client/src/Transfer.tsx

此檔案 (opens in a new tab)實作了客戶端處理並產生 server/noir/Prover.toml 檔案(包含零知識參數的檔案)。

以下是較有趣部分的說明。

export default attrs =>  {

此函數建立了 Transfer React 元件,其他檔案可以匯入該元件。

  const accounts = [
    "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
    "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
    "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
    "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
    "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
  ]

這些是帳戶地址,由 test ... test junk 密碼短語建立的地址。如果你想使用自己的地址,只需修改此定義即可。

  const account = useAccount()
  const wallet = createWalletClient({
    transport: custom(window.ethereum!)
  })

這些 Wagmi hooks (opens in a new tab) 讓我們能夠存取 viem (opens in a new tab) 函式庫和錢包。

  const message = `send ${toAccount} ${ethAmount*1000} finney (milliEth) ${nonce}`.padEnd(100, " ")

這是訊息,用空格填充。每當 useState (opens in a new tab) 變數之一發生變化時,元件就會重新繪製並更新 message

  const sign = async () => {

當使用者點擊 Sign 按鈕時會呼叫此函數。訊息會自動更新,但簽章需要使用者在錢包中批准,除非必要,否則我們不想要求批准。

    const signature = await wallet.signMessage({
        account: fromAccount,
        message,
    })

要求錢包簽署訊息 (opens in a new tab)

    const hash = hashMessage(message)

取得訊息雜湊。將其提供給使用者以進行除錯(針對 Noir 程式碼)會很有幫助。

    const pubKey = await recoverPublicKey({
        hash,
        signature
    })

取得公鑰 (opens in a new tab)。這是 Noir ecrecover (opens in a new tab) 函數所要求的。

    setSignature(signature)
    setHash(hash)
    setPubKey(pubKey)

設定狀態變數。這樣做會重新繪製元件(在 sign 函數退出後),並向使用者顯示更新後的值。

    let proverToml = `

Prover.toml 的文字。

message="${message}"

pubKeyX=${hexToArray(pubKey.slice(4,4+2*32))}
pubKeyY=${hexToArray(pubKey.slice(4+2*32))}

Viem 以 65 位元組的十六進位字串形式向我們提供公鑰。第一個位元組是 0x04,這是一個版本標記。接下來是 32 位元組的公鑰 x,然後是 32 位元組的公鑰 y

然而,Noir 預期以兩個位元組陣列的形式取得此資訊,一個用於 x,另一個用於 y。在客戶端這裡解析它比作為零知識證明的一部分更容易。

請注意,這通常是零知識中的良好實踐。零知識證明內部的程式碼成本很高,因此任何可以在零知識證明外部完成的處理_都應該_在零知識證明外部完成。

signature=${hexToArray(signature.slice(2,-2))}

簽章也以 65 位元組的十六進位字串形式提供。然而,最後一個位元組僅用於恢復公鑰。由於公鑰已經提供給 Noir 程式碼,我們不需要它來驗證簽章,且 Noir 程式碼也不要求它。

${accounts.map(accountInProverToml).reduce((a,b) => a+b, "")}
`

提供帳戶。

    setProverToml(proverToml)
  }

  return (
    <>
        <h2>Transfer</h2>

這是元件的 HTML(更準確地說,是 JSX (opens in a new tab))格式。

server/noir/src/main.nr

此檔案 (opens in a new tab)是實際的零知識程式碼。

use std::hash::pedersen_hash;

Pedersen 雜湊 (opens in a new tab)Noir 標準函式庫 (opens in a new tab)提供。零知識證明通常使用此雜湊函數。與標準雜湊函數相比,它在算術電路 (opens in a new tab)內部計算要容易得多。

use keccak256::keccak256;
use dep::ecrecover;

這兩個函數是外部函式庫,定義在 Nargo.toml (opens in a new tab) 中。它們的功能正如其名,一個是計算 keccak256 雜湊 (opens in a new tab)的函數,另一個是驗證以太坊簽章並恢復簽署者以太坊地址的函數。

global ACCOUNT_NUMBER : u32 = 5;

Noir 的靈感來自 Rust (opens in a new tab)。變數預設為常數。這就是我們定義全域配置常數的方式。具體來說,ACCOUNT_NUMBER 是我們儲存的帳戶數量。

名為 u<number> 的資料類型是該位元數的無號整數。唯一支援的類型是 u8u16u32u64u128

global FLAT_ACCOUNT_FIELDS : u32 = 2;

此變數用於帳戶的 Pedersen 雜湊,如下所述。

global MESSAGE_LENGTH : u32 = 100;

如上所述,訊息長度是固定的。它在此處指定。

global ASCII_MESSAGE_LENGTH : [u8; 3] = [0x31, 0x30, 0x30];
global HASH_BUFFER_SIZE : u32 = 26+3+MESSAGE_LENGTH;

EIP-191 簽章 (opens in a new tab)要求緩衝區具有 26 位元組的前綴,接著是 ASCII 格式的訊息長度,最後是訊息本身。

struct Account {
    balance: u128,
    address: Field,
    nonce: u32,
}

我們儲存的關於帳戶的資訊。Field (opens in a new tab) 是一個數字,通常最多 253 位元,可以直接在實作零知識證明的算術電路 (opens in a new tab)中使用。在這裡,我們使用 Field 來儲存 160 位元的以太坊地址。

struct TransferTxn {
    from: Field,
    to: Field,
    amount: u128,
    nonce: u32
}

我們為轉帳交易儲存的資訊。

fn flatten_account(account: Account) -> [Field; FLAT_ACCOUNT_FIELDS] {

函數定義。參數是 Account 資訊。結果是一個 Field 變數陣列,其長度為 FLAT_ACCOUNT_FIELDS

let flat = [
        account.address,
        ((account.balance << 32) + account.nonce.into()).into(),
    ];

陣列中的第一個值是帳戶地址。第二個值包含餘額和隨機數。.into() 呼叫將數字變更為其所需的資料類型。account.nonce 是一個 u32 值,但要將其加到 account.balance << 32(一個 u128 值)中,它必須是一個 u128。這就是第一個 .into() 的作用。第二個呼叫將 u128 結果轉換為 Field,以便它適合放入陣列中。

flat
}

在 Noir 中,函數只能在最後回傳一個值(沒有提前回傳)。要指定回傳值,你需要在函數的右括號之前對其進行求值。

fn flatten_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] {

此函數將帳戶陣列轉換為 Field 陣列,該陣列可用作 Petersen 雜湊的輸入。

let mut flat: [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] = [0; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER];

這就是你指定可變變數(即_不是_常數)的方式。Noir 中的變數必須始終具有值,因此我們將此變數初始化為全零。

for i in 0..ACCOUNT_NUMBER {

這是一個 for 迴圈。請注意,邊界是常數。Noir 迴圈必須在編譯時知道其邊界。原因是算術電路不支援流程控制。在處理 for 迴圈時,編譯器只是將其中的程式碼放置多次,每次迭代一次。

最後,我們來到了對帳戶陣列進行雜湊的函數。

fn find_account(accounts: [Account; ACCOUNT_NUMBER], address: Field) -> u32 {
    let mut account : u32 = ACCOUNT_NUMBER;

    for i in 0..ACCOUNT_NUMBER {
        if accounts[i].address == address {
            account = i;
        }
    }

此函數尋找具有特定地址的帳戶。在標準程式碼中,此函數的效率會非常低,因為即使找到了地址,它也會迭代所有帳戶。

然而,在零知識證明中,沒有流程控制。如果我們需要檢查某個條件,我們必須每次都檢查它。

if 敘述也會發生類似的情況。上述迴圈中的 if 敘述被轉換為這些數學敘述。

conditionresult = accounts[i].address == address // 如果它們相等則為 1,否則為 0

accountnew = conditionresult*i + (1-conditionresult)*accountold

    assert (account < ACCOUNT_NUMBER, f"{address} does not have an account");

    account
}

如果斷言為假,assert (opens in a new tab) 函數會導致零知識證明崩潰。在這種情況下,如果我們找不到具有相關地址的帳戶。為了報告地址,我們使用格式化字串 (opens in a new tab)

fn apply_transfer_txn(accounts: [Account; ACCOUNT_NUMBER], txn: TransferTxn) -> [Account; ACCOUNT_NUMBER] {

此函數套用轉帳交易並回傳新的帳戶陣列。

    let from = find_account(accounts, txn.from);
    let to = find_account(accounts, txn.to);

    let (txnFrom, txnAmount, txnNonce, accountNonce) =
        (txn.from, txn.amount, txn.nonce, accounts[from].nonce);

我們無法在 Noir 的格式化字串中存取結構元素,因此我們建立一個可用的副本。

    assert (accounts[from].balance >= txn.amount,
        f"{txnFrom} does not have {txnAmount} finney");

    assert (accounts[from].nonce == txn.nonce,
        f"Transaction has nonce {txnNonce}, but the account is expected to use {accountNonce}");

這是兩個可能導致交易無效的條件。

    let mut newAccounts = accounts;

    newAccounts[from].balance -= txn.amount;
    newAccounts[from].nonce += 1;
    newAccounts[to].balance += txn.amount;

    newAccounts
}

建立新的帳戶陣列然後將其回傳。

fn readAddress(messageBytes: [u8; MESSAGE_LENGTH]) -> Field

此函數從訊息中讀取地址。

{
    let mut result : Field = 0;

    for i in 7..47 {

地址長度始終為 20 位元組(即 40 個十六進位數字),並從第 7 個字元開始。

從訊息中讀取金額和隨機數。

{
    let mut amount : u128 = 0;
    let mut nonce: u32 = 0;
    let mut stillReadingAmount: bool = true;
    let mut lookingForNonce: bool = false;
    let mut stillReadingNonce: bool = false;

在訊息中,地址後的第一個數字是要轉帳的芬尼(即千分之一 ETH)金額。第二個數字是隨機數。它們之間的任何文字都會被忽略。

回傳元組 (opens in a new tab)是 Noir 從函數回傳多個值的方式。

此函數將訊息轉換為位元組,然後將金額轉換為 TransferTxn

// 等同於 Viem 的 hashMessage
// https://viem.sh/docs/utilities/hashMessage#hashmessage
fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {

我們能夠對帳戶使用 Pedersen 雜湊,因為它們僅在零知識證明內部進行雜湊。然而,在此程式碼中,我們需要檢查由瀏覽器產生的訊息簽章。為此,我們需要遵循 EIP-191 (opens in a new tab) 中的以太坊簽署格式。這意味著我們需要建立一個組合緩衝區,其中包含標準前綴、ASCII 格式的訊息長度以及訊息本身,並使用以太坊標準的 keccak256 對其進行雜湊。

為了避免應用程式要求使用者簽署可用作交易或用於其他目的的訊息,EIP-191 指定所有已簽署的訊息都以字元 0x19(不是有效的 ASCII 字元)開頭,接著是 Ethereum Signed Message: 和換行符號。

處理長度最多為 999 的訊息,如果更大則失敗。我加入了這段程式碼,即使訊息長度是一個常數,因為這使得更改它變得更容易。在生產系統上,為了獲得更好的效能,你可能會直接假設 MESSAGE_LENGTH 不會改變。

    keccak256::keccak256(buffer, HASH_BUFFER_SIZE)
}

使用以太坊標準的 keccak256 函數。

fn signatureToAddressAndHash(
        message: str<MESSAGE_LENGTH>, 
        pubKeyX: [u8; 32],
        pubKeyY: [u8; 32],
        signature: [u8; 64]
    ) -> (Field, Field, Field)   // 地址,雜湊的前 16 個位元組,雜湊的後 16 個位元組        
{

此函數驗證簽章,這需要訊息雜湊。然後它向我們提供簽署它的地址和訊息雜湊。訊息雜湊以兩個 Field 值提供,因為在程式的其餘部分中,它們比位元組陣列更容易使用。

我們需要使用兩個 Field 值,因為欄位計算是對一個大數字進行模運算 (opens in a new tab),但該數字通常小於 256 位元(否則很難在 EVM 中執行這些計算)。

    let hash = hashMessage(message);

    let mut (hash1, hash2) = (0,0);

    for i in 0..16 {
        hash1 = hash1*256 + hash[31-i].into();
        hash2 = hash2*256 + hash[15-i].into();
    }

hash1hash2 指定為可變變數,並逐位元組將雜湊寫入其中。

    (
        ecrecover::ecrecover(pubKeyX, pubKeyY, signature, hash), 

這類似於 Solidity 的 ecrecover (opens in a new tab),但有兩個重要的區別:

  • 如果簽章無效,呼叫將在 assert 處失敗,並且程式將中止。
  • 雖然可以從簽章和雜湊中恢復公鑰,但這是可以在外部完成的處理,因此不值得在零知識證明內部進行。如果有人試圖在這裡欺騙我們,簽章驗證將會失敗。

最後,我們到達了 main 函數。我們需要證明我們有一筆交易,該交易有效地將帳戶的雜湊從舊值變更為新值。我們還需要證明它具有這個特定的交易雜湊值,以便發送它的人知道他們的交易已被處理。

{
    let mut txn = readTransferTxn(message);

我們需要 txn 是可變的,因為我們不是從訊息中讀取發送方地址,而是從簽章中讀取。

第 2 階段 - 加入伺服器

在第二階段,我們加入一個伺服器,該伺服器接收並實作來自瀏覽器的轉帳交易。

要查看實際運作情況:

  1. 如果 Vite 正在執行,請將其停止。

  2. 下載包含伺服器的分支,並確保你擁有所有必要的模組。

    git checkout 02-add-server
    cd client
    npm install
    cd ../server
    npm install
    

    不需要編譯 Noir 程式碼,它與你在第 1 階段使用的程式碼相同。

  3. 啟動伺服器。

    npm run start
    
  4. 在單獨的命令列視窗中,執行 Vite 以提供瀏覽器程式碼。

    cd client
    npm run dev
    
  5. 瀏覽至客戶端程式碼 http://localhost:5173 (opens in a new tab)

  6. 在發出交易之前,你需要知道隨機數以及你可以發送的金額。要取得此資訊,請點擊 Update account data 並簽署訊息。

    我們在這裡面臨一個兩難。一方面,我們不想簽署可以被重複使用的訊息(重放攻擊 (opens in a new tab)),這就是為什麼我們一開始就需要隨機數。然而,我們還沒有隨機數。解決方案是選擇一個只能使用一次且雙方都已經擁有的隨機數,例如當前時間。

    這個解決方案的問題在於時間可能無法完美同步。因此,我們改為簽署一個每分鐘變更一次的值。這意味著我們容易受到重放攻擊的空窗期最多為一分鐘。考慮到在生產環境中,已簽署的請求將受到 TLS 的保護,並且通道的另一端(伺服器)已經可以揭露餘額和隨機數(它必須知道它們才能運作),這是一個可接受的風險。

  7. 一旦瀏覽器取回餘額和隨機數,它就會顯示轉帳表單。選擇目標地址和金額,然後點擊 Transfer。簽署此請求。

  8. 要查看轉帳,請點擊 Update account data 或查看執行伺服器的視窗。伺服器會在每次狀態變更時記錄日誌。

ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start

server@1.0.0 start node --experimental-json-modules index.mjs

Listening on port 3000 Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 processed New state: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 64000 (1) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 100000 (0) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0) 0x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0) 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0) Txn send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 7200 finney (milliEth) 1 processed New state: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 56800 (2) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0) 0x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0) 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0) Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 3000 finney (milliEth) 2 processed New state: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 53800 (3) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0) 0x90F79bf6EB2c4f870365E785982E1f101E93b906 has 139000 (0) 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)


#### `server/index.mjs` \{#server-index-mjs-1\}

[此檔案](https://github.com/qbzzt/250911-zk-bank/blob/02-add-server/server/index.mjs)包含伺服器程序,並與 [`main.nr`](https://github.com/qbzzt/250911-zk-bank/blob/02-add-server/server/noir/src/main.nr) 處的 Noir 程式碼互動。以下是較有趣部分的說明。

```js
import { Noir } from '@noir-lang/noir_js'

noir.js (opens in a new tab) 函式庫是 JavaScript 程式碼和 Noir 程式碼之間的介面。

const circuit = JSON.parse(await fs.readFile("./noir/target/zkBank.json"))
const noir = new Noir(circuit)

載入算術電路——我們在上一階段建立的已編譯 Noir 程式——並準備執行它。

// 我們僅在回應已簽章的請求時提供帳戶資訊
const accountInformation = async signature => {
    const fromAddress = await recoverAddress({
        hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),
        signature
    })

為了提供帳戶資訊,我們只需要簽章。原因是我們已經知道訊息會是什麼,因此也知道訊息雜湊。

const processMessage = async (message, signature) => {

處理訊息並執行其編碼的交易。

    // 取得公鑰
    const pubKey = await recoverPublicKey({
        hash,
        signature
    })

現在我們在伺服器上執行 JavaScript,我們可以在那裡而不是在客戶端上擷取公鑰。

noir.execute 執行 Noir 程式。參數等同於 Prover.toml (opens in a new tab) 中提供的參數。請注意,長值以十六進位字串陣列(["0x60", "0xA7"])的形式提供,而不是像 Viem 那樣作為單一十六進位值(0x60A7)提供。

    } catch (err) {
        console.log(`Noir error: ${err}`)
        throw Error("Invalid transaction, not processed")
    }

如果發生錯誤,請捕捉它,然後將簡化版本中繼給客戶端。

    Accounts[fromAccountNumber].nonce++
    Accounts[fromAccountNumber].balance -= amount
    Accounts[toAccountNumber].balance += amount

套用交易。我們已經在 Noir 程式碼中做過了,但在這裡再做一次比從那裡提取結果更容易。

let Accounts = [
    {
        address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
        balance: 5000,
        nonce: 0,
    },

初始的 Accounts 結構。

第 3 階段 - 以太坊智能合約

  1. 停止伺服器和客戶端程序。

  2. 下載包含智能合約的分支,並確保你擁有所有必要的模組。

    git checkout 03-smart-contracts
    cd client
    npm install
    cd ../server
    npm install
    
  3. 在單獨的命令列視窗中執行 anvil

  4. 產生驗證金鑰和 Solidity 驗證者,然後將驗證者程式碼複製到 Solidity 專案中。

    cd noir
    bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak
    bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol
    cp target/Verifier.sol ../../smart-contracts/src
    
  5. 前往智能合約並設定環境變數以使用 anvil 區塊鏈。

    cd ../../smart-contracts
    export ETH_RPC_URL=http://localhost:8545
    ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
    
  6. 部署 Verifier.sol 並將地址儲存在環境變數中。

    VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'`
    echo $VERIFIER_ADDRESS
    
  7. 部署 ZkBank 合約。

    ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'`
    echo $ZKBANK_ADDRESS
    

    0x199..67b 值是 Accounts 初始狀態的 Pederson 雜湊。如果你在 server/index.mjs 中修改此初始狀態,你可以執行一筆交易來查看零知識證明報告的初始雜湊。

  8. 執行伺服器。

    cd ../server
    npm run start
    
  9. 在不同的命令列視窗中執行客戶端。

    cd client
    npm run dev
    
  10. 執行一些交易。

  11. 要驗證狀態是否在鏈上發生變更,請重新啟動伺服器程序。你會看到 ZkBank 不再接受交易,因為交易中的原始雜湊值與儲存在鏈上的雜湊值不同。

    這是預期的錯誤類型。

ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start

server@1.0.0 start node --experimental-json-modules index.mjs

Listening on port 3000 Verification error: ContractFunctionExecutionError: The contract function "processTransaction" reverted with the following reason: Wrong old state hash

Contract Call: address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 function: processTransaction(bytes _proof, bytes32[] _publicInputs) args: (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000

我們需要使用 Barretenberg 套件 (opens in a new tab)來建立要發送到鏈上的實際證明。我們可以透過執行命令列介面(bb)或使用 JavaScript 函式庫 bb.js (opens in a new tab) 來使用此套件。JavaScript 函式庫比原生執行程式碼慢得多,因此我們在這裡使用 exec (opens in a new tab) 來使用命令列。

請注意,如果你決定使用 bb.js,你需要使用與你正在使用的 Noir 版本相容的版本。在撰寫本文時,目前的 Noir 版本(1.0.0-beta.11)使用 bb.js 版本 0.87。

const zkBankAddress = process.env.ZKBANK_ADDRESS || "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"

這裡的地址是當你從乾淨的 anvil 開始並遵循上述指示時獲得的地址。

const walletClient = createWalletClient({ 
    chain: anvil, 
    transport: http(), 
    account: privateKeyToAccount("0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6")
})

此私鑰是 anvil 中預設的預先注資帳戶之一。

const generateProof = async (witness, fileID) => {

使用 bb 執行檔產生證明。

    const fname = `witness-${fileID}.gz`    
    await fs.writeFile(fname, witness)

將見證寫入檔案。

    await execPromise(`bb prove -b ./noir/target/zkBank.json -w ${fname} -o ${fileID} --oracle_hash keccak --output_format fields`)

實際建立證明。此步驟還會建立一個包含公共變數的檔案,但我們不需要它。我們已經從 noir.execute 取得了這些變數。

    const proof = "0x" + JSON.parse(await fs.readFile(`./${fileID}/proof_fields.json`)).reduce((a,b) => a+b, "").replace(/0x/g, "")

證明是一個 Field 值的 JSON 陣列,每個值都表示為十六進位值。然而,我們需要在交易中將其作為單一 bytes 值發送,Viem 將其表示為一個大型十六進位字串。在這裡,我們透過連接所有值、移除所有 0x,然後在最後加入一個來變更格式。

    await execPromise(`rm -r ${fname} ${fileID}`)

    return proof
}

清理並回傳證明。

const processMessage = async (message, signature) => {
    .
    .
    .

    const publicFields = noirResult.returnValue.map(x=>'0x' + x.slice(2).padStart(64, "0"))

公共欄位需要是 32 位元組值的陣列。然而,由於我們需要將交易雜湊值劃分在兩個 Field 值之間,因此它顯示為 16 位元組的值。在這裡我們加入零,以便 Viem 了解它實際上是 32 位元組。

    const proof = await generateProof(noirResult.witness, `${fromAddress}-${nonce}`)

每個地址只使用每個隨機數一次,因此我們可以使用 fromAddressnonce 的組合做為見證檔案和輸出目錄的唯一識別碼。

將交易發送到鏈上。

smart-contracts/src/ZkBank.sol

這是接收交易的鏈上程式碼。

鏈上程式碼需要追蹤兩個變數:驗證者(由 nargo 建立的獨立合約)和目前的狀態雜湊。

    event TransactionProcessed(
        bytes32 indexed transactionHash,
        bytes32 oldStateHash,
        bytes32 newStateHash
    );

每次狀態變更時,我們都會發出一個 TransactionProcessed 事件。

    function processTransaction(
        bytes calldata _proof,
        bytes32[] calldata _publicFields
    ) public {

此函數處理交易。它以驗證者要求的格式取得證明(作為 bytes)和公共輸入(作為 bytes32 陣列)(以最小化鏈上處理,從而降低燃料成本)。

        require(_publicInputs[0] == currentStateHash,
            "Wrong old state hash");

零知識證明需要證明交易從我們目前的雜湊變更為新的雜湊。

        myVerifier.verify(_proof, _publicFields);

呼叫驗證者合約以驗證零知識證明。如果零知識證明錯誤,此步驟將還原交易。

如果一切檢查無誤,將狀態雜湊更新為新值並發出 TransactionProcessed 事件。

中心化元件的濫用

資訊安全包含三個屬性:

  • 機密性:使用者無法讀取未經授權的資訊。
  • 完整性:除非由授權使用者以授權的方式進行,否則無法更改資訊。
  • 可用性:授權使用者可以使用該系統。

在這個系統中,完整性是透過零知識證明來提供的。可用性則難以保證,而機密性是不可能的,因為銀行必須知道每個帳戶的餘額和所有交易。我們無法阻止擁有資訊的實體分享該資訊。

或許可以使用隱形地址 (opens in a new tab)來建立一個真正機密的銀行,但這超出了本文的範圍。

虛假資訊

伺服器破壞完整性的一種方式是,在請求資料 (opens in a new tab)時提供虛假資訊。

為了解決這個問題,我們可以編寫第二個 Noir 程式,該程式接收帳戶作為私密輸入,並將請求資訊的地址作為公開輸入。輸出則是該地址的餘額與隨機數,以及帳戶的雜湊值。

當然,這個證明無法在鏈上驗證,因為我們不想將隨機數和餘額發布到鏈上。然而,它可以由在瀏覽器中執行的用戶端程式碼來驗證。

強制交易

在 L2 上確保可用性並防止審查的常見機制是強制交易 (opens in a new tab)。但是強制交易無法與零知識證明結合。伺服器是唯一可以驗證交易的實體。

我們可以修改 smart-contracts/src/ZkBank.sol 以接受強制交易,並在處理這些交易之前防止伺服器更改狀態。然而,這會讓我們面臨簡單的阻斷服務攻擊。如果強制交易無效且因此無法處理怎麼辦?

解決方案是提供一個零知識證明來證明強制交易無效。這為伺服器提供了三個選項:

  • 處理強制交易,提供已處理該交易的零知識證明以及新的狀態雜湊值。
  • 拒絕強制交易,並向合約提供該交易無效(未知地址、錯誤的隨機數或餘額不足)的零知識證明。
  • 忽略強制交易。沒有辦法強迫伺服器實際處理該交易,但這意味著整個系統將無法使用。

可用性保證金

在現實生活中的實作中,可能會有某種利潤動機來維持伺服器運作。我們可以透過讓伺服器提交可用性保證金來加強這種誘因,如果強制交易未在特定期間內處理,任何人都可以銷毀該保證金。

糟糕的 Noir 程式碼

通常,為了讓人們信任智能合約,我們會將原始碼上傳到區塊鏈瀏覽器 (opens in a new tab)。然而,在零知識證明的情況下,這是不夠的。

Verifier.sol 包含驗證金鑰,這是 Noir 程式的一個函數。然而,該金鑰並未告訴我們 Noir 程式是什麼。要真正擁有一個受信任的解決方案,你需要上傳 Noir 程式(以及建立它的版本)。否則,零知識證明可能反映的是一個不同的程式,一個帶有後門的程式。

在區塊鏈瀏覽器開始允許我們上傳並驗證 Noir 程式之前,你應該自己動手(最好上傳到 IPFS)。這樣一來,進階使用者就能夠下載原始碼,自行編譯,建立 Verifier.sol,並驗證它與鏈上的版本完全相同。

結論

電漿類型的應用程式需要一個中心化組件作為資訊儲存。這會帶來潛在的漏洞,但作為回報,它允許我們以區塊鏈本身無法提供的方式來保護隱私。透過零知識證明,我們可以確保完整性,並可能使運行該中心化組件的人在經濟上有利可圖,從而維持其可用性。

點擊此處查看我的更多作品 (opens in a new tab)

致謝

  • Josh Crites 閱讀了本文的草稿,並協助我解決了一個棘手的 Noir 問題。

任何剩餘的錯誤均由本人負責。