跳至主要內容

撰寫一個保護隱私的應用程式專屬電漿 (plasma)

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

簡介

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

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

設計

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

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

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

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

零知識證明

在基本層面上,零知識證明顯示證明者知道某些資料 Dataprivate,使得某些公開資料 Datapublic 和 Dataprivate 之間存在關係 Relationship。驗證者知道 Relationship 和 Datapublic。

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

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

Dataprivate 中的這些欄位:

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

這些是 Datapublic 中的欄位:

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

該關係檢查幾個條件:

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

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

資料結構

主要的資料結構是伺服器持有的狀態。對於每個帳戶,伺服器會追蹤帳戶餘額和一個隨機數 (在新分頁開啟),用於防止重放攻擊 (在新分頁開啟)。

元件

該系統需要兩個元件:

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

資料與控制流程

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

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

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

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

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

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

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

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

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

工具

對於客戶端程式碼,我們將使用 Vite (在新分頁開啟)、React (在新分頁開啟)、Viem (在新分頁開啟) 和 Wagmi (在新分頁開啟)。這些是業界標準工具;如果您不熟悉它們,可以使用本教學。

伺服器的大部分內容是使用 Node (在新分頁開啟) 以 JavaScript 撰寫的。零知識部分是用 Noir (在新分頁開啟) 撰寫的。我們需要 1.0.0-beta.10 版本,因此在您按照指示安裝 Noir (在新分頁開啟) 後,請執行:

noirup -v 1.0.0-beta.10

我們使用的區塊鏈是 anvil,這是一個本地測試區塊鏈,屬於 Foundry (在新分頁開啟) 的一部分。

實作

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

第 1 階段 - 手動零知識

在第一階段,我們將在瀏覽器中簽署一筆交易,然後手動將資訊提供給零知識證明。零知識程式碼預期在 server/noir/Prover.toml 中獲取該資訊(文件在這裡 (在新分頁開啟))。

要查看實際運作情況:

  1. 確保您已安裝 Node (在新分頁開啟) 和 Noir (在新分頁開啟)。最好將它們安裝在 UNIX 系統上,例如 macOS、Linux 或 WSL (在新分頁開啟)。

  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
    

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

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

  4. 在錢包中,輸入新的助記詞。請注意,這將刪除您現有的助記詞,因此_請確保您有備份_。

    助記詞是 test test test test test test test test test test test junk,這是 anvil 的預設測試助記詞。

  5. 瀏覽至客戶端程式碼 (在新分頁開啟)。

  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)
    
  10. 將最後兩個值與您在網頁瀏覽器上看到的雜湊進行比較,以查看訊息是否被正確雜湊。

server/noir/Prover.toml

此檔案 (在新分頁開啟)顯示了 Noir 預期的資訊格式。

message="send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 500 finney (milliEth) 0                             "

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

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

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

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

這是指定結構陣列的方法。對於每個條目,我們指定地址、餘額(以 milliETH,即芬尼 (在新分頁開啟)為單位)以及下一個隨機數值。

client/src/Transfer.tsx

此檔案 (在新分頁開啟)實作了客戶端處理並產生 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 (在新分頁開啟) 讓我們可以存取 viem (在新分頁開啟) 函式庫和錢包。

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

這是用空格填充的訊息。每次其中一個 useState (在新分頁開啟) 變數發生變化時,元件都會重新繪製並更新 message。

  const sign = async () => {

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

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

要求錢包簽署訊息 (在新分頁開啟)。

    const hash = hashMessage(message)

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

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

取得公鑰 (在新分頁開啟)。這是 Noir ecrecover (在新分頁開啟) 函數所必需的。

    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,一個版本標記。接下來是公鑰 x 的 32 個位元組,然後是公鑰 y 的 32 個位元組。

然而,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 (在新分頁開啟))格式。

server/noir/src/main.nr

此檔案 (在新分頁開啟)是實際的零知識程式碼。

use std::hash::pedersen_hash;

Pedersen 雜湊 (在新分頁開啟)隨 Noir 標準函式庫 (在新分頁開啟)提供。零知識證明通常使用此雜湊函數。與標準雜湊函數相比,它在算術電路 (在新分頁開啟)內部計算要容易得多。

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

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

global ACCOUNT_NUMBER : u32 = 5;

Noir 的靈感來自 Rust (在新分頁開啟)。變數預設為常數。這就是我們定義全域配置常數的方式。具體來說,ACCOUNT_NUMBER 是我們儲存的帳戶數量。

名為 u<number> 的資料類型是該位元數的無號整數。唯一支援的類型是 u8、u16、u32、u64 和 u128。

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 簽章 (在新分頁開啟)需要一個帶有 26 位元組前綴的緩衝區,後跟 ASCII 格式的訊息長度,最後是訊息本身。

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

我們儲存的關於帳戶的資訊。Field (在新分頁開啟) 是一個數字,通常最多 253 位元,可以直接在實作零知識證明的算術電路 (在新分頁開啟)中使用。在這裡,我們使用 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 (在新分頁開啟) 函數會導致零知識證明崩潰。在這種情況下,如果我們找不到具有相關地址的帳戶。為了報告地址,我們使用格式字串 (在新分頁開啟)。

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)金額。第二個數字是隨機數。它們之間的任何文字都會被忽略。

回傳元組 (tuple) (在新分頁開啟) 是 Noir 從函數回傳多個值的方式。

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

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

我們能夠對帳戶使用 Pedersen 雜湊,因為它們僅在零知識證明內部進行雜湊。然而,在此程式碼中,我們需要檢查由瀏覽器產生的訊息簽章。為此,我們需要遵循 EIP-191 (在新分頁開啟) 中的以太坊簽署格式。這意味著我們需要建立一個組合緩衝區,其中包含標準前綴、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 值,因為欄位計算是對一個大數字進行模運算 (modulo) (在新分頁開啟),但該數字通常小於 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();
    }

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

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

這類似於 Solidity 的 ecrecover (在新分頁開啟),有兩個重要的區別:

  • 如果簽章無效,呼叫將在 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 (在新分頁開啟)

  6. 在發出交易之前,您需要知道隨機數以及您可以發送的金額。要獲取此資訊,請點擊 Update account data(更新帳戶資料)並簽署訊息。

    我們在這裡遇到了一個兩難。一方面,我們不想簽署可以重複使用的訊息(重放攻擊 (在新分頁開啟)),這就是為什麼我們首先需要一個隨機數。然而,我們還沒有隨機數。解決方案是選擇一個只能使用一次且雙方都已經擁有的隨機數,例如當前時間。

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

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

  8. 要查看轉帳,請更新帳戶資料或查看執行伺服器的視窗。伺服器會在每次狀態變更時記錄狀態。

server/index.mjs

此檔案 (在新分頁開啟)包含伺服器程序,並與 main.nr (在新分頁開啟) 處的 Noir 程式碼互動。以下是較有趣部分的解釋。

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

noir.js (在新分頁開啟) 函式庫是 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 (在新分頁開啟) 中提供的參數。請注意,長值是作為十六進位字串陣列 (["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 不再接受交易,因為交易中的原始雜湊值與儲存在鏈上的雜湊值不同。

    這是預期的錯誤類型。

server/index.mjs

此檔案中的變更主要與建立實際證明並將其提交到鏈上相關。

import { exec } from 'child_process'
import util from 'util'

const execPromise = util.promisify(exec)

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

請注意,如果您決定使用 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}`)

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

將交易發送到鏈上。

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 事件。

中心化元件的濫用

資訊安全包含三個屬性:

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

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

也許可以使用隱形地址 (在新分頁開啟)建立一個真正機密的銀行,但這超出了本文的範圍。

虛假資訊

伺服器違反完整性的一種方式是在請求資料 (在新分頁開啟)時提供虛假資訊。

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

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

強制交易

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

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

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

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

可用性保證金

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

糟糕的 Noir 程式碼

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

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

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

結論

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

點擊這裡查看我的更多作品 (在新分頁開啟)。

致謝

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

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