跳至主要内容

編寫一個保護隱私的特定應用程式 plasma

零知識
伺服器
鏈下
隱私
進階
Ori Pomerantz
2025年10月15日
45 分鐘閱讀

介紹

rollups 相反,plasma 使用以太坊主網來確保完整性,而非可用性。 在本文中,我們將編寫一個行為類似 plasma 的應用程式,由以太坊保證完整性 (無未經授權的變更),但不保證可用性 (中心化元件可能故障並停用整個系統)。

我們在此處編寫的應用程式是保留隱私權的銀行。 不同的地址擁有含餘額的帳戶,且可將資金 (ETH) 傳送至其他帳戶。 銀行會張貼狀態 (帳戶及其餘額) 和交易的雜湊值,但會將實際餘額保留在鏈下,以維持其隱私。

設計

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

  • 固定的帳戶池。 帳戶有特定數量,且每個帳戶都屬於預先決定的地址。 這使得系統更加簡單,因為在零知識證明中處理可變大小的資料結構很困難。 對於可供生產的系統,我們可以使用 Merkle 根 作為狀態雜湊值,並為必要的餘額提供 Merkle 證明。

  • 記憶體儲存。 在生產系統上,我們需要將所有帳戶餘額寫入磁碟,以在重新啟動時保留它們。 在此,即使資訊遺失也無妨。

  • 僅限傳送。 生產系統需要一種將資產存入銀行並提款的方式。 但這裡的目的只是為了說明概念,所以此銀行僅限於傳送。

零知識證明

在基礎層面上,零知識證明顯示證明者知道一些資料 Dataprivate,使得一些公開資料 DatapublicDataprivate 之間存在關係 Relationship。 驗證者知道 RelationshipDatapublic

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

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

Dataprivate 中的這些欄位:

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

Datapublic 中的這些欄位:

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

此關係會檢查幾個條件:

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

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

數據結構

主要資料結構是伺服器持有的狀態。 對於每個帳戶,伺服器都會追蹤帳戶餘額和一個 nonce (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) 後,請執行:

1noirup -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 階段的程式碼並啟動網頁伺服器以提供用戶端程式碼。

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

    您需要網頁伺服器的原因是,為了防止某些類型的詐騙,許多錢包 (例如 MetaMask) 不接受直接從磁碟提供的檔案。

  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. 執行零知識證明。

    1cd ../server/noir
    2nargo execute

    輸出應類似於

    1ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute
    2
    3[zkBank] Circuit witness successfully solved
    4[zkBank] Witness saved to target/zkBank.gz
    5[zkBank] Circuit output: (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85)
  10. 將最後兩個值與您在網頁瀏覽器上看到的雜湊值進行比較,以查看訊息是否已正確雜湊。

server/noir/Prover.toml

此檔案 (opens in a new tab) 顯示 Noir 預期的資訊格式。

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

此訊息採用文字格式,方便使用者理解 (這在簽署時是必要的),也方便 Noir 程式碼解析。 金額以 finney 報價,一方面可以進行部分傳送,另一方面也易於閱讀。 最後一個數字是 nonce (opens in a new tab)

該字串長度為 100 個字元。 零知識證明不善於處理可變大小的資料,因此通常需要填充資料。

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

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

1[[accounts]]
2address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
3balance=100_000
4nonce=0
5
6[[accounts]]
7address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
8balance=100_000
9nonce=0
顯示全部

這是指定結構陣列的方式。 對於每個項目,我們指定地址、餘額 (以 milliETH,又稱為 finney (opens in a new tab)),以及下一個 nonce 值。

client/src/Transfer.tsx

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

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

1export default attrs => {

此函式會建立 Transfer React 元件,其他檔案可以匯入此元件。

1 const accounts = [
2 "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
3 "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
4 "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
5 "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
6 "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
7 ]

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

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

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

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

這是以空格填補的訊息。 每當 useState (opens in a new tab) 變數變更時,元件就會重新繪製,而 message 也會更新。

1 const sign = async () => {

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

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

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

1 const hash = hashMessage(message)

取得訊息雜湊值。 提供給使用者以供偵錯 (Noir 程式碼) 會很有幫助。

1 const pubKey = await recoverPublicKey({
2 hash,
3 signature
4 })

取得公鑰 (opens in a new tab)。 這是 Noir ecrecover (opens in a new tab) 函式所需的。

1 setSignature(signature)
2 setHash(hash)
3 setPubKey(pubKey)

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

1 let proverToml = `"

Prover.toml 的文字。

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

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

然而,Noir 預期會以兩個位元組陣列的形式取得此資訊,一個用於 x,一個用於 y。 在用戶端解析比在零知識證明中解析更容易。

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

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

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

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

提供帳戶。

1 setProverToml(proverToml)
2 }
3
4 return (
5 <>
6 <h2>Transfer</h2>

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

server/noir/src/main.nr

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

1use std::hash::pedersen_hash;

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

1use keccak256::keccak256;
2use dep::ecrecover;

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

1global ACCOUNT_NUMBER : u32 = 5;

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

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

1global FLAT_ACCOUNT_FIELDS : u32 = 2;

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

1global MESSAGE_LENGTH : u32 = 100;

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

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

EIP-191 簽章 (opens in a new tab) 需要一個緩衝區,其中包含 26 位元組的前綴,後接 ASCII 格式的訊息長度,最後是訊息本身。

1struct Account {
2 balance: u128,
3 address: Field,
4 nonce: u32,
5}

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

1struct TransferTxn {
2 from: Field,
3 to: Field,
4 amount: u128,
5 nonce: u32
6}

我們儲存的傳送交易資訊。

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

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

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

陣列中的第一個值是帳戶地址。 第二個值包括餘額和 nonce。 .into() 呼叫會將數字變更為其所需的資料類型。 account.nonce 是一個 u32 值,但若要將其新增至 account.balance « 32 (一個 u128 值),它需要是 u128。 這是第一個 .into()。 第二個 .into()u128 結果轉換為 Field,使其符合陣列。

1 flat
2}

在 Noir 中,函式只能在結尾傳回一個值 (沒有提早傳回)。 若要指定傳回值,您只需在函式的右括號前評估它即可。

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

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

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

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

1 for i in 0..ACCOUNT_NUMBER {

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

1 let fields = flatten_account(accounts[i]);
2 for j in 0..FLAT_ACCOUNT_FIELDS {
3 flat[i*FLAT_ACCOUNT_FIELDS + j] = fields[j];
4 }
5 }
6
7 flat
8}
9
10fn hash_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> Field {
11 pedersen_hash(flatten_accounts(accounts))
12}
顯示全部

最後,我們來到雜湊帳戶陣列的函式。

1fn find_account(accounts: [Account; ACCOUNT_NUMBER], address: Field) -> u32 {
2 let mut account : u32 = ACCOUNT_NUMBER;
3
4 for i in 0..ACCOUNT_NUMBER {
5 if accounts[i].address == address {
6 account = i;
7 }
8 }

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

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

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

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

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

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

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

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

此函式會套用一筆傳送交易,並傳回新的帳戶陣列。

1 let from = find_account(accounts, txn.from);
2 let to = find_account(accounts, txn.to);
3
4 let (txnFrom, txnAmount, txnNonce, accountNonce) =
5 (txn.from, txn.amount, txn.nonce, accounts[from].nonce);

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

1 assert (accounts[from].balance >= txn.amount,
2 f"{txnFrom} 沒有 {txnAmount} finney");
3
4 assert (accounts[from].nonce == txn.nonce,
5 f"交易的 nonce 為 {txnNonce},但帳戶應使用 {accountNonce}");

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

1 let mut newAccounts = accounts;
2
3 newAccounts[from].balance -= txn.amount;
4 newAccounts[from].nonce += 1;
5 newAccounts[to].balance += txn.amount;
6
7 newAccounts
8}

建立新的帳戶陣列,然後傳回它。

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

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

1{
2 let mut result : Field = 0;
3
4 for i in 7..47 {

地址總是 20 位元組 (又稱為 40 個十六進位數字) 長,並從字元 #7 開始。

1 result *= 0x10;
2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-9
3 result += (messageBytes[i]-48).into();
4 }
5 if messageBytes[i] >= 65 & messageBytes[i] <= 70 { // A-F
6 result += (messageBytes[i]-65+10).into()
7 }
8 if messageBytes[i] >= 97 & messageBytes[i] <= 102 { // a-f
9 result += (messageBytes[i]-97+10).into()
10 }
11 }
12
13 result
14}
15
16fn readAmountAndNonce(messageBytes: [u8; MESSAGE_LENGTH]) -> (u128, u32)
顯示全部

從訊息中讀取金額和 nonce。

1{
2 let mut amount : u128 = 0;
3 let mut nonce: u32 = 0;
4 let mut stillReadingAmount: bool = true;
5 let mut lookingForNonce: bool = false;
6 let mut stillReadingNonce: bool = false;

在訊息中,地址後的第一個數字是要傳送的 finney (又稱為 千分之一 ETH) 金額。 第二個數字是 nonce。 兩者之間的任何文字都會被忽略。

1 for i in 48..MESSAGE_LENGTH {
2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-9
3 let digit = (messageBytes[i]-48);
4
5 if stillReadingAmount {
6 amount = amount*10 + digit.into();
7 }
8
9 if lookingForNonce { // We just found it
10 stillReadingNonce = true;
11 lookingForNonce = false;
12 }
13
14 if stillReadingNonce {
15 nonce = nonce*10 + digit.into();
16 }
17 } else {
18 if stillReadingAmount {
19 stillReadingAmount = false;
20 lookingForNonce = true;
21 }
22 if stillReadingNonce {
23 stillReadingNonce = false;
24 }
25 }
26 }
27
28 (amount, nonce)
29}
顯示全部

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

1fn readTransferTxn(message: str<MESSAGE_LENGTH>) -> TransferTxn
2{
3 let mut txn: TransferTxn = TransferTxn { from: 0, to: 0, amount:0, nonce:0 };
4 let messageBytes = message.as_bytes();
5
6 txn.to = readAddress(messageBytes);
7 let (amount, nonce) = readAmountAndNonce(messageBytes);
8 txn.amount = amount;
9 txn.nonce = nonce;
10
11 txn
12}
顯示全部

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

1// The equivalent to Viem's hashMessage
2// https://viem.sh/docs/utilities/hashMessage#hashmessage
3fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {

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

1 // ASCII prefix
2 let prefix_bytes = [
3 0x19, // \x19
4 0x45, // 'E'
5 0x74, // 't'
6 0x68, // 'h'
7 0x65, // 'e'
8 0x72, // 'r'
9 0x65, // 'e'
10 0x75, // 'u'
11 0x6D, // 'm'
12 0x20, // ' '
13 0x53, // 'S'
14 0x69, // 'i'
15 0x67, // 'g'
16 0x6E, // 'n'
17 0x65, // 'e'
18 0x64, // 'd'
19 0x20, // ' '
20 0x4D, // 'M'
21 0x65, // 'e'
22 0x73, // 's'
23 0x73, // 's'
24 0x61, // 'a'
25 0x67, // 'g'
26 0x65, // 'e'
27 0x3A, // ':'
28 0x0A // '\n'
29 ];
顯示全部

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

1 let mut buffer: [u8; HASH_BUFFER_SIZE] = [0u8; HASH_BUFFER_SIZE];
2 for i in 0..26 {
3 buffer[i] = prefix_bytes[i];
4 }
5
6 let messageBytes : [u8; MESSAGE_LENGTH] = message.as_bytes();
7
8 if MESSAGE_LENGTH <= 9 {
9 for i in 0..1 {
10 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
11 }
12
13 for i in 0..MESSAGE_LENGTH {
14 buffer[i+26+1] = messageBytes[i];
15 }
16 }
17
18 if MESSAGE_LENGTH >= 10 & MESSAGE_LENGTH <= 99 {
19 for i in 0..2 {
20 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
21 }
22
23 for i in 0..MESSAGE_LENGTH {
24 buffer[i+26+2] = messageBytes[i];
25 }
26 }
27
28 if MESSAGE_LENGTH >= 100 {
29 for i in 0..3 {
30 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
31 }
32
33 for i in 0..MESSAGE_LENGTH {
34 buffer[i+26+3] = messageBytes[i];
35 }
36 }
37
38 assert(MESSAGE_LENGTH < 1000, "不支援長度超過三位數的訊息");
顯示全部

處理長度達 999 的訊息,如果超過則失敗。 我新增了這段程式碼,即使訊息長度是常數,因為這樣更容易變更。 在生產系統上,為了更好的效能,您可能會假設 MESSAGE_LENGTH 不會變更。

1 keccak256::keccak256(buffer, HASH_BUFFER_SIZE)
2}

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

1fn signatureToAddressAndHash(
2 message: str<MESSAGE_LENGTH>,
3 pubKeyX: [u8; 32],
4 pubKeyY: [u8; 32],
5 signature: [u8; 64]
6 ) -> (Field, Field, Field) // address, first 16 bytes of hash, last 16 bytes of hash
7{

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

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

1 let hash = hashMessage(message);
2
3 let mut (hash1, hash2) = (0,0);
4
5 for i in 0..16 {
6 hash1 = hash1*256 + hash[31-i].into();
7 hash2 = hash2*256 + hash[15-i].into();
8 }

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

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

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

  • 如果簽章無效,呼叫會失敗一個 assert,程式會被中止。
  • 雖然公鑰可以從簽章和雜湊中恢復,但這項處理可以在外部完成,因此不值得在零知識證明內進行。 如果有人在此試圖欺騙我們,簽章驗證將會失敗。
1 hash1,
2 hash2
3 )
4}
5
6fn main(
7 accounts: [Account; ACCOUNT_NUMBER],
8 message: str<MESSAGE_LENGTH>,
9 pubKeyX: [u8; 32],
10 pubKeyY: [u8; 32],
11 signature: [u8; 64],
12 ) -> pub (
13 Field, // Hash of old accounts array
14 Field, // Hash of new accounts array
15 Field, // First 16 bytes of message hash
16 Field, // Last 16 bytes of message hash
17 )
顯示全部

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

1{
2 let mut txn = readTransferTxn(message);

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

1 let (fromAddress, txnHash1, txnHash2) = signatureToAddressAndHash(
2 message,
3 pubKeyX,
4 pubKeyY,
5 signature);
6
7 txn.from = fromAddress;
8
9 let newAccounts = apply_transfer_txn(accounts, txn);
10
11 (
12 hash_accounts(accounts),
13 hash_accounts(newAccounts),
14 txnHash1,
15 txnHash2
16 )
17}
顯示全部

第 2 階段 - 新增伺服器

在第二階段,我們新增一個伺服器,接收並實作來自瀏覽器的傳送交易。

實際操作如下:

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

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

    1git checkout 02-add-server
    2cd client
    3npm install
    4cd ../server
    5npm install

    無需編譯 Noir 程式碼,它與您用於第 1 階段的程式碼相同。

  3. 啟動伺服器。

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

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

  6. 在發出交易之前,您需要知道 nonce 以及可以傳送的金額。 若要取得此資訊,請按一下 Update account data 並簽署訊息。

    我們在此遇到一個兩難的局面。 一方面,我們不希望簽署可重複使用的訊息 (重放攻擊 (opens in a new tab)),這就是我們一開始需要 nonce 的原因。 然而,我們還沒有 nonce。 解決方案是選擇一個只能使用一次且雙方都已有的 nonce,例如目前時間。

    此解決方案的問題是時間可能無法完全同步。 因此,我們改為簽署一個每分鐘變更一次的值。 這表示我們遭受重放攻擊的漏洞窗口最多為一分鐘。 考量到在生產環境中,已簽署的請求將受到 TLS 保護,而且通道的另一端—伺服器—已經可以揭露餘額和 nonce (它必須知道這些才能運作),這是一個可接受的風險。

  7. 瀏覽器取回餘額和 nonce 後,會顯示傳送表單。 選擇目的地地址和金額,然後按一下 Transfer。 簽署此請求。

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

    1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start
    2
    3> server@1.0.0 start
    4> node --experimental-json-modules index.mjs
    5
    6Listening on port 3000
    7Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 processed
    8New state:
    90xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 64000 (1)
    100x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 100000 (0)
    110x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)
    120x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)
    130x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
    14Txn send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 7200 finney (milliEth) 1 processed
    15New state:
    160xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 56800 (2)
    170x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)
    180x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)
    190x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)
    200x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
    21Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 3000 finney (milliEth) 2 processed
    22New state:
    230xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 53800 (3)
    240x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)
    250x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)
    260x90F79bf6EB2c4f870365E785982E1f101E93b906 has 139000 (0)
    270x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
    顯示全部

server/index.mjs

此檔案 (opens in a new tab) 包含伺服器程序,並與 main.nr (opens in a new tab) 的 Noir 程式碼互動。 以下是有趣部分的說明。

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

noir.js (opens in a new tab) 庫在 JavaScript 程式碼和 Noir 程式碼之間提供介面。

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

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

1// We only provide account information in return to a signed request
2const accountInformation = async signature => {
3 const fromAddress = await recoverAddress({
4 hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),
5 signature
6 })

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

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

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

1 // Get the public key
2 const pubKey = await recoverPublicKey({
3 hash,
4 signature
5 })

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

1 let noirResult
2 try {
3 noirResult = await noir.execute({
4 message,
5 signature: signature.slice(2,-2).match(/.{2}/g).map(x => `0x${x}`),
6 pubKeyX,
7 pubKeyY,
8 accounts: Accounts
9 })
顯示全部

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

1 } catch (err) {
2 console.log(`Noir 錯誤:${err}`)
3 throw Error("交易無效,未處理")
4 }

如果有錯誤,請將其攔截,然後將簡化版本轉送給用戶端。

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

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

1let Accounts = [
2 {
3 address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
4 balance: 5000,
5 nonce: 0,
6 },

初始 Accounts 結構。

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

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

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

    1git checkout 03-smart-contracts
    2cd client
    3npm install
    4cd ../server
    5npm install
  3. 在個別的命令列視窗中執行 anvil

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

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

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

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

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

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

  8. 執行伺服器。

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

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

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

    這是預期的錯誤類型。

    1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start
    2
    3> server@1.0.0 start
    4> node --experimental-json-modules index.mjs
    5
    6正在接聽連接埠 3000
    7驗證錯誤:ContractFunctionExecutionError: 合約函數 "processTransaction" 因以下原因而還原:
    8舊狀態雜湊值錯誤
    9
    10合約呼叫:
    11 地址: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
    12 函數: processTransaction(bytes _proof, bytes32[] _publicInputs)
    13 參數: (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000
    顯示全部

server/index.mjs

此檔案中的變更主要與建立實際證明並在鏈上提交有關。

1import { exec } from 'child_process'
2import util from 'util'
3
4const execPromise = util.promisify(exec)

我們需要使用 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。

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

此處的地址是您從乾淨的 anvil 開始並遵循上述說明時取得的地址。

1const walletClient = createWalletClient({
2 chain: anvil,
3 transport: http(),
4 account: privateKeyToAccount("0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6")
5})

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

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

使用 bb 可執行檔產生證明。

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

將見證寫入檔案。

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

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

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

證明是一個 JSON 陣列,其中包含 Field 值,每個值都以十六進位值表示。 然而,我們需要將它作為單一的 bytes 值在交易中傳送,Viem 會以一個大的十六進位字串表示。 在此,我們透過串接所有值、移除所有 0x,然後在結尾加上一個來變更格式。

1 await execPromise(`rm -r ${fname} ${fileID}`)
2
3 return proof
4}

清理並傳回證明。

1const processMessage = async (message, signature) => {
2 .
3 .
4 .
5
6 const publicFields = noirResult.returnValue.map(x=>'0x' + x.slice(2).padStart(64, "0"))

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

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

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

1 try {
2 await zkBank.write.processTransaction([
3 proof, publicFields])
4 } catch (err) {
5 console.log(`驗證錯誤:${err}`)
6 throw Error("無法在鏈上驗證交易")
7 }
8 .
9 .
10 .
11}
顯示全部

將交易傳送到鏈上。

smart-contracts/src/ZkBank.sol

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

1// SPDX-License-Identifier: MIT
2
3pragma solidity >=0.8.21;
4
5import {HonkVerifier} from "./Verifier.sol";
6
7contract ZkBank {
8 HonkVerifier immutable myVerifier;
9 bytes32 currentStateHash;
10
11 constructor(address _verifierAddress, bytes32 _initialStateHash) {
12 currentStateHash = _initialStateHash;
13 myVerifier = HonkVerifier(_verifierAddress);
14 }
顯示全部

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

1 event TransactionProcessed(
2 bytes32 indexed transactionHash,
3 bytes32 oldStateHash,
4 bytes32 newStateHash
5 );

每當狀態變更時,我們就會發出 TransactionProcessed 事件。

1 function processTransaction(
2 bytes calldata _proof,
3 bytes32[] calldata _publicFields
4 ) public {

此函式會處理交易。 它會以驗證器所需的格式取得證明 (作為 bytes) 和公開輸入 (作為 bytes32 陣列),以最小化鏈上處理並因此降低 gas 成本。

1 require(_publicInputs[0] == currentStateHash,
2 "舊狀態雜湊值錯誤");

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

1 myVerifier.verify(_proof, _publicFields);

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

1 currentStateHash = _publicFields[1];
2
3 emit TransactionProcessed(
4 _publicFields[2]<<128 | _publicFields[3],
5 _publicFields[0],
6 _publicFields[1]
7 );
8 }
9}
顯示全部

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

中心化元件的濫用

資訊安全包含三個屬性:

  • 機密性,使用者無法讀取他們未經授權讀取的資訊。
  • 完整性,資訊不能被授權使用者以外的人以未經授權的方式變更。
  • 可用性,授權使用者可以使用系統。

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

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

不實資訊

伺服器違反完整性的一種方式是在 要求資料 (opens in a new tab) 時提供不實資訊。

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

當然,此證明無法在鏈上驗證,因為我們不想在鏈上張貼 nonce 和餘額。 然而,它可以由在瀏覽器中執行的用戶端程式碼來驗證。

強制交易

確保 L2 可用性和防止審查的通常機制是 強制交易 (opens in a new tab)。 但強制交易與零知識證明不相容。 伺服器是唯一可以驗證交易的實體。

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

解決方案是擁有一個零知識證明,證明強制交易是無效的。 這給伺服器三個選項:

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

可用性保證金

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

不良的 Noir 程式碼

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

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

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

結論

Plasma 類型的應用程式需要一個中心化元件作為資訊儲存。 這會帶來潛在的漏洞,但作為回報,它讓我們能夠以區塊鏈本身無法提供的方式保護隱私。 透過零知識證明,我們可以確保完整性,並可能使執行中心化元件的人在維持可用性方面具有經濟優勢。

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

致謝

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

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

頁面最後更新時間: 2025年10月28日

這個使用教學對你有幫助嗎?