跳至主要内容

使用零知識證明建立秘密狀態

server
offchain
centralized
zero-knowledge
zokrates
mud
進階
Ori Pomerantz
2025年3月15日
40 分鐘閱讀

在區塊鏈上沒有秘密。 發佈在區塊鏈上的所有內容,每個人都可以公開讀取。 這是必要的,因為區塊鏈的基礎是任何人都能夠驗證它。 然而,遊戲通常仰賴秘密狀態。 例如,如果你能直接到區塊鏈瀏覽器上查看地圖,踩地雷opens in a new tab遊戲就完全沒有意義了。

最簡單的解決方案是使用伺服器元件來保存秘密狀態。 然而,我們使用區塊鏈的原因是為了防止遊戲開發者作弊。 我們需要確保伺服器元件的誠實性。 伺服器可以提供狀態的哈希,並使用零知識證明來證明用於計算移動結果的狀態是正確的。

閱讀本文後,你將了解如何建立這類保存秘密狀態的伺服器、一個用於顯示狀態的用戶端,以及一個用於兩者之間通訊的鏈上元件。 我們將使用的主要工具有:

工具目的已在版本上驗證
Zokratesopens in a new tab零知識證明及其驗證1.1.9
Typescriptopens in a new tab伺服器和用戶端的程式設計語言5.4.2
Nodeopens in a new tab執行伺服器20.18.2
Viemopens in a new tab與區塊鏈通訊2.9.20
MUDopens in a new tab鏈上資料管理2.0.12
Reactopens in a new tab用戶端使用者介面18.2.0
Viteopens in a new tab提供用戶端程式碼4.2.1

踩地雷範例

踩地雷opens in a new tab 是一款包含秘密地雷區地圖的遊戲。 玩家選擇在特定位置挖掘。 如果該位置有地雷,遊戲就結束了。 否則,玩家會得到該位置周圍八個方格中的地雷數量。

此應用程式使用 MUDopens in a new tab 編寫,這是一個讓我們可以使用鍵值資料庫opens in a new tab將資料儲存在鏈上,並自動將該資料與鏈下元件同步的框架。 除了同步之外,MUD 還能輕鬆提供存取控制,並讓其他使用者能無需許可地擴充opens in a new tab我們的應用程式。

執行踩地雷範例

若要執行踩地雷範例:

  1. 確保您已安裝先決條件opens in a new tabNodeopens in a new tabFoundryopens in a new tabgitopens in a new tabpnpmopens in a new tabmprocsopens in a new tab

  2. 複製儲存庫。

    1git clone https://github.com/qbzzt/20240901-secret-state.git
  3. 安裝套件。

    1cd 20240901-secret-state/
    2pnpm install
    3npm install -g mprocs

    如果 Foundry 是作為 pnpm install 的一部分安裝的,您需要重新啟動命令列 shell。

  4. 編譯合約

    1cd packages/contracts
    2forge build
    3cd ../..
  5. 啟動程式(包括 anvilopens in a new tab 區塊鏈)並等待。

    1mprocs

    請注意,啟動需要很長時間。 若要查看進度,請先使用向下箭頭捲動至 contracts 索引標籤,以查看正在部署的 MUD 合約。 當您收到訊息 Waiting for file changes… 時,表示合約已部署,後續進度將在 server 索引標籤中顯示。 在那裡,您要等到收到訊息 Verifier address: 0x....

    如果此步驟成功,您會看到 mprocs 螢幕,左側是不同的程序,右側是目前所選程序的主控台輸出。

    mprocs 螢幕

    如果 mprocs 出現問題,您可以手動執行這四個程序,每個程序都在各自的命令列視窗中執行:

    • Anvil

      1cd packages/contracts
      2anvil --base-fee 0 --block-time 2
    • 合約

      1cd packages/contracts
      2pnpm mud dev-contracts --rpc http://127.0.0.1:8545
    • 伺服器

      1cd packages/server
      2pnpm start
    • 用戶端

      1cd packages/client
      2pnpm run dev
  6. 現在您可以瀏覽至用戶端opens in a new tab,點擊 New Game,然後開始遊戲。

資料表

我們需要在鏈上建立數個資料表opens in a new tab

  • Configuration:此資料表是單例,它沒有鍵且只有單一記錄。 它用於保存遊戲設定資訊:

    • height:地雷區的高度
    • width:地雷區的寬度
    • numberOfBombs:每個地雷區中的炸彈數量
  • VerifierAddress:此資料表也是單例。 它用於保存設定的一部分,即驗證者合約 (verifier) 的地址。 我們可以將此資訊放在 Configuration 資料表中,但它是由另一個元件(伺服器)設定的,因此將其放在單獨的資料表中更容易。

  • PlayerGame:鍵是玩家的地址。 資料是:

    • gameId:一個 32 位元組的值,是玩家正在遊玩的地圖的哈希(遊戲識別碼)。
    • win:一個布林值,表示玩家是否贏得了遊戲。
    • lose:一個布林值,表示玩家是否輸掉了遊戲。
    • digNumber:遊戲中成功挖掘的次數。
  • GamePlayer:此資料表保存從 gameId 到玩家地址的反向對應。

  • Map:鍵是由三個值組成的元組:

    • gameId:一個 32 位元組的值,是玩家正在遊玩的地圖的哈希(遊戲識別碼)。
    • x 座標
    • y 座標

    值是一個單一數字。 如果偵測到炸彈,則為 255。 否則,它是該位置周圍炸彈數量加一。 我們不能只使用炸彈的數量,因為在 EVM 中所有儲存空間和 MUD 中所有行值預設為零。 我們需要區分「玩家還沒有在這裡挖掘」和「玩家在這裡挖掘了,但發現周圍沒有炸彈」。

此外,用戶端和伺服器之間的通訊是透過鏈上元件進行的。 這也是使用資料表實作的。

  • PendingGame:未處理的新遊戲開始請求。
  • PendingDig:在特定遊戲的特定位置挖掘的未處理請求。 這是一個鏈下資料表opens in a new tab,表示它不會寫入 EVM 儲存空間,只能透過事件在鏈下讀取。

執行與資料流

這些流程協調用戶端、鏈上元件和伺服器之間的執行。

初始化

當您執行 mprocs 時,會發生以下步驟:

  1. mprocsopens in a new tab 會執行四個元件:

  2. contracts 套件會部署 MUD 合約,然後執行 PostDeploy.s.sol 指令碼opens in a new tab。 此指令碼會設定組態。 來自 github 的程式碼指定了一個 10x5 的地雷區,其中有八個地雷opens in a new tab

  3. 伺服器opens in a new tab 首先設定 MUDopens in a new tab。 除其他事項外,這會啟動資料同步,因此相關資料表的副本會存在於伺服器的記憶體中。

  4. 伺服器訂閱一個函式,以便Configuration 資料表變更時opens in a new tab執行。 PostDeploy.s.sol 執行並修改資料表後,會呼叫此函式opens in a new tab

  5. 當伺服器初始化函式取得設定後,它會呼叫 zkFunctionsopens in a new tab 來初始化伺服器的零知識部分。 在我們取得設定之前,這無法發生,因為零知識函式必須將地雷區的寬度和高度作為常數。

  6. 伺服器的零知識部分初始化後,下一步是將零知識驗證合約部署到區塊鏈opens in a new tab並在 MUD 中設定驗證對象地址。

  7. 最後,我們訂閱更新,這樣我們就可以看到玩家何時請求開始新遊戲opens in a new tab在現有遊戲中挖掘opens in a new tab

新遊戲

這是玩家請求新遊戲時發生的情況。

  1. 如果此玩家沒有正在進行的遊戲,或者有遊戲但 gameId 為零,用戶端會顯示新遊戲按鈕opens in a new tab。 當使用者按下此按鈕時,React 會執行 newGame 函式opens in a new tab

  2. newGameopens in a new tab 是一個 System 呼叫。 在 MUD 中,所有呼叫都透過 World 合約路由,在大多數情況下,您會呼叫 <namespace>__<function name>。 在這種情況下,呼叫的是 app__newGame,然後 MUD 會將其路由到 GameSystem 中的 newGameopens in a new tab

  3. 鏈上函式會檢查玩家是否沒有正在進行的遊戲,如果沒有,則將請求新增至 PendingGame 資料表opens in a new tab

  4. 伺服器偵測到 PendingGame 中的變更,並執行訂閱的函式opens in a new tab。 此函式會呼叫 newGameopens in a new tab,而後者又會呼叫 createGameopens in a new tab

  5. createGame 做的第一件事是建立一個具有適當數量地雷的隨機地圖opens in a new tab。 然後,它會呼叫 makeMapBordersopens in a new tab 來建立一個帶有空白邊界的地圖,這對於 Zokrates 是必要的。 最後,createGame 會呼叫 calculateMapHash 來取得地圖的哈希,該哈希用作遊戲 ID。

  6. newGame 函式將新遊戲新增至 gamesInProgress

  7. 伺服器做的最後一件事是呼叫 app__newGameResponseopens in a new tab,這是在鏈上進行的。 此函式位於另一個 System 中,即 ServerSystemopens in a new tab,以啟用存取控制。 存取控制在 MUD 設定檔opens in a new tab mud.config.tsopens in a new tab 中定義。

    存取清單只允許單一地址呼叫 System。 這將對伺服器函式的存取限制為單一地址,因此沒有人可以冒充伺服器。

  8. 鏈上元件會更新相關資料表:

    • PlayerGame 中建立遊戲。
    • GamePlayer 中設定反向對應。
    • PendingGame 中移除請求。
  9. 伺服器識別到 PendingGame 的變更,但不會執行任何操作,因為 wantsGameopens in a new tab 為 false。

  10. 在用戶端,gameRecordopens in a new tab 被設定為玩家地址的 PlayerGame 項目。 當 PlayerGame 變更時,gameRecord 也會變更。

  11. 如果 gameRecord 中有值,且遊戲尚未分出勝負,用戶端會顯示地圖opens in a new tab

挖掘

  1. 玩家點擊地圖儲存格的按鈕opens in a new tab,這會呼叫 dig 函式opens in a new tab。 此函式會在鏈上呼叫 digopens in a new tab

  2. 鏈上元件會執行一些健全性檢查opens in a new tab,如果成功,則將挖掘請求新增至 PendingDigopens in a new tab

  3. 伺服器偵測到 PendingDig 的變更opens in a new tab如果有效opens in a new tab,它會呼叫零知識程式碼opens in a new tab(如下所述)以產生結果和其有效的證明。

  4. 伺服器opens in a new tab 在鏈上呼叫 digResponseopens in a new tab

  5. digResponse 做兩件事。 首先,它會檢查零知識證明opens in a new tab。 然後,如果證明通過檢查,它會呼叫 processDigResultopens in a new tab 來實際處理結果。

  6. processDigResult 檢查遊戲是否已失敗opens in a new tab獲勝opens in a new tab,並更新鏈上地圖 Mapopens in a new tab

  7. 用戶端會自動擷取更新並更新顯示給玩家的地圖opens in a new tab,並在適用的情況下告知玩家是獲勝還是失敗。

使用 Zokrates

在上述流程中,我們跳過了零知識部分,將其視為一個黑盒子。 現在讓我們打開它,看看程式碼是如何編寫的。

對地圖進行哈希

我們可以使用此 JavaScript 程式碼opens in a new tab來實作 Poseidonopens in a new tab,這是我們使用的 Zokrates 哈希函式。 然而,雖然這樣做會更快,但它也比僅使用 Zokrates 哈希函式來做更複雜。 這是一個教學課程,因此程式碼是為簡單性而非效能而優化的。 因此,我們需要兩個不同的 Zokrates 程式,一個只計算地圖的哈希(hash),另一個則實際建立在地圖上某個位置挖掘結果的零知識證明(dig)。

哈希函式

這是計算地圖哈希的函式。 我們將逐行檢視此程式碼。

1import "hashes/poseidon/poseidon.zok" as poseidon;
2import "utils/pack/bool/pack128.zok" as pack128;

這兩行從 Zokrates 標準程式庫opens in a new tab匯入兩個函式。 第一個函式opens in a new tabPoseidon 哈希opens in a new tab. 它接受一個 field 元素opens in a new tab的陣列,並傳回一個 field

Zokrates 中的欄位元素通常長度小於 256 位元,但不會少太多。 為了簡化程式碼,我們將地圖限制為最多 512 位元,並對四個欄位的陣列進行哈希處理,每個欄位只使用 128 位元。 為此,pack128 函式opens in a new tab 會將 128 位元的陣列轉換成 field

1 def hashMap(bool[${width+2}][${height+2}] map) -> field {

此行開始函式定義。 hashMap 取得一個名為 map 的單一參數,它是一個二維 bool(ean) 陣列。 地圖的大小是 width+2 乘以 height+2,原因如下所述

我們可以使用 ${width+2}${height+2},因為 Zokrates 程式在此應用程式中以範本字串opens in a new tab的形式儲存。 ${} 之間的程式碼由 JavaScript 評估,這樣程式就可以用於不同大小的地圖。 地圖參數周圍有一個寬度為一個位置的邊界,其中沒有任何炸彈,這就是我們需要將寬度和高度加二的原因。

傳回值是包含哈希的 field

1 bool[512] mut map1d = [false; 512];

地圖是二維的。 然而,pack128 函式不適用於二維陣列。 所以我們先使用 map1d 將地圖扁平化為一個 512 位元組的陣列。 預設情況下,Zokrates 變數是常數,但我們需要在迴圈中為此陣列賦值,因此我們將其定義為 mutopens in a new tab

我們需要初始化陣列,因為 Zokrates 沒有 undefined[false; 512] 運算式表示一個包含 512 個 false 值的陣列opens in a new tab

1 u32 mut counter = 0;

我們還需要一個計數器來區分我們已在 map1d 中填入的位元和尚未填入的位元。

1 for u32 x in 0..${width+2} {

這是在 Zokrates 中宣告 for 迴圈opens in a new tab的方式。 Zokrates for 迴圈必須有固定的邊界,因為雖然它看起來像一個迴圈,但編譯器實際上會「展開」它。 運算式 ${width+2} 是一個編譯時常數,因為 width 是由 TypeScript 程式碼在呼叫編譯器之前設定的。

1 for u32 y in 0..${height+2} {
2 map1d[counter] = map[x][y];
3 counter = counter+1;
4 }
5 }

對於地圖中的每個位置,將該值放入 map1d 陣列並遞增計數器。

1 field[4] hashMe = [
2 pack128(map1d[0..128]),
3 pack128(map1d[128..256]),
4 pack128(map1d[256..384]),
5 pack128(map1d[384..512])
6 ];

pack128map1d 建立一個包含四個 field 值的陣列。 在 Zokrates 中,array[a..b] 表示從 a 開始到 b-1 結束的陣列切片。

1 return poseidon(hashMe);
2}

使用 poseidon 將此陣列轉換為哈希。

哈希程式

伺服器需要直接呼叫 hashMap 來建立遊戲識別碼。 然而,Zokrates 只能呼叫程式上的 main 函式來啟動,所以我們建立一個帶有呼叫哈希函式的 main 函式的程式。

1${hashFragment}
2
3def main(bool[${width+2}][${height+2}] map) -> field {
4 return hashMap(map);
5}

挖掘程式

這是應用程式零知識部分的核心,我們在這裡產生用於驗證挖掘結果的證明。

1${hashFragment}
2
3// The number of mines in location (x,y)
4def map2mineCount(bool[${width+2}][${height+2}] map, u32 x, u32 y) -> u8 {
5 return if map[x+1][y+1] { 1 } else { 0 };
6}

為什麼需要地圖邊界

零知識證明使用算術電路opens in a new tab,它沒有與 if 陳述式等效的簡單方法。 取而代之的是,他們使用等效於條件運算子opens in a new tab的方法。 如果 a 可以是零或一,您可以將 if a { b } else { c } 計算為 ab+(1-a)c

因此,Zokrates if 陳述式總是會評估兩個分支。 例如,如果您有這段程式碼:

1bool[5] arr = [false; 5];
2u32 index=10;
3return if index>4 { 0 } else { arr[index] }

它會出錯,因為它需要計算 arr[10],即使該值稍後會乘以零。

這就是我們需要在地圖周圍留一個位置寬邊界的原因。 我們需要計算一個位置周圍的地雷總數,這表示我們需要查看我們挖掘位置的上一行和下一行、左側和右側的位置。 這意味著這些位置必須存在於提供給 Zokrates 的地圖陣列中。

1def main(private bool[${width+2}][${height+2}] map, u32 x, u32 y) -> (field, u8) {

預設情況下,Zokrates 證明包含其輸入。 除非您實際知道是哪個點,否則知道某個點周圍有五個地雷是沒有用的(而且您不能只將它與您的請求匹配,因為那樣證明者就可以使用不同的值而不告訴您)。 然而,我們需要將地圖保密,同時將其提供給 Zokrates。 解決方案是使用一個 private 參數,一個_不_會被證明揭示的參數。

這開啟了另一個濫用的途徑。 證明者可以使用正確的座標,但建立一個在地點周圍有任意數量地雷的地圖,甚至可能在地點本身就有地雷。 為防止這種濫用,我們讓零知識證明包含地圖的哈希,也就是遊戲識別碼。

1 return (hashMap(map),

此處的傳回值是一個元組,其中包含地圖哈希陣列以及挖掘結果。

1 if map2mineCount(map, x, y) > 0 { 0xFF } else {

如果位置本身有炸彈,我們使用 255 作為特殊值。

1 map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
2 map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
3 map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
4 }
5 );
6}

如果玩家沒有踩到地雷,則將該位置周圍區域的地雷數量相加並傳回。

從 TypeScript 使用 Zokrates

Zokrates 有一個命令列介面,但在這個程式中,我們在 TypeScript 程式碼opens in a new tab中使用它。

包含 Zokrates 定義的程式庫稱為 zero-knowledge.tsopens in a new tab

1import { initialize as zokratesInitialize } from "zokrates-js"

匯入 Zokrates JavaScript 繫結opens in a new tab。 我們只需要 initializeopens in a new tab 函式,因為它會傳回一個解析為所有 Zokrates 定義的 promise。

1export const zkFunctions = async (width: number, height: number) : Promise<any> => {

與 Zokrates 本身相似,我們也只匯出一個函式,這個函式也是非同步的opens in a new tab。 當它最終傳回時,它會提供幾個函式,如下所示。

1const zokrates = await zokratesInitialize()

初始化 Zokrates,從程式庫中取得我們需要的一切。

1const hashFragment = `
2 import "utils/pack/bool/pack128.zok" as pack128;
3 import "hashes/poseidon/poseidon.zok" as poseidon;
4 .
5 .
6 .
7 }
8 `
9
10const hashProgram = `
11 ${hashFragment}
12 .
13 .
14 .
15 `
16
17const digProgram = `
18 ${hashFragment}
19 .
20 .
21 .
22 `
顯示全部

接下來是我們上面看到的哈希函式和兩個 Zokrates 程式。

1const digCompiled = zokrates.compile(digProgram)
2const hashCompiled = zokrates.compile(hashProgram)

我們在這裡編譯這些程式。

1// Create the keys for zero knowledge verification.
2// On a production system you'd want to use a setup ceremony.
3// (https://zokrates.github.io/toolbox/trusted_setup.html#initializing-a-phase-2-ceremony).
4const keySetupResults = zokrates.setup(digCompiled.program, "")
5const verifierKey = keySetupResults.vk
6const proverKey = keySetupResults.pk

在生產系統上,我們可能會使用更複雜的設定儀式opens in a new tab,但對於示範來說,這已經足夠了。 使用者知道證明者金鑰不是問題——他們仍然無法用它來證明事情,除非事情是真的。 因為我們指定了熵(第二個參數 ""),所以結果總會是相同的。

注意: Zokrates 程式的編譯和金鑰的建立是緩慢的過程。 不需要每次都重複它們,只有當地圖大小改變時才需要。 在生產系統上,您只會做一次,然後儲存輸出。 我不在這裡這樣做的唯一原因是為了簡單起見。

calculateMapHash

1const calculateMapHash = function (hashMe: boolean[][]): string {
2 return (
3 "0x" +
4 BigInt(zokrates.computeWitness(hashCompiled, [hashMe]).output.slice(1, -1))
5 .toString(16)
6 .padStart(64, "0")
7 )
8}

computeWitnessopens in a new tab 函式實際上會執行 Zokrates 程式。 它會傳回一個包含兩個欄位的結構:output,即程式的輸出,為 JSON 字串;以及 witness,即建立結果的零知識證明所需的資訊。 這裡我們只需要輸出。

輸出是一個形式為 "31337" 的字串,一個用引號括起來的十進位數字。 但我們需要 viem 的輸出是一個形式為 0x60A7 的十六進位數字。 所以我們使用 .slice(1,-1) 來移除引號,然後使用 BigInt 將剩餘的字串(一個十進位數字)轉換為 BigIntopens in a new tab.toString(16) 將此 BigInt 轉換為十六進位字串,而 "0x"+ 則添加了十六進位數字的標記。

1// Dig and return a zero knowledge proof of the result
2// (server-side code)

零知識證明包括公共輸入(xy)和結果(地圖的哈希和炸彈的數量)。

1 const zkDig = function(map: boolean[][], x: number, y: number) : any {
2 if (x<0 || x>=width || y<0 || y>=height)
3 throw new Error("Trying to dig outside the map")

在 Zokrates 中檢查索引是否越界是一個問題,所以我們在這裡進行檢查。

1const runResults = zokrates.computeWitness(digCompiled, [map, `${x}`, `${y}`])

執行挖掘程式。

1 const proof = zokrates.generateProof(
2 digCompiled.program,
3 runResults.witness,
4 proverKey)
5
6 return proof
7 }

使用 generateProofopens in a new tab 並傳回證明。

1const solidityVerifier = `
2 // Map size: ${width} x ${height}
3 \n${zokrates.exportSolidityVerifier(verifierKey)}
4 `

一個 Solidity 驗證器,這是一個我們可以部署到區塊鏈並用來驗證由 digCompiled.program 產生的證明的智能合約。

1 return {
2 zkDig,
3 calculateMapHash,
4 solidityVerifier,
5 }
6}

最後,傳回其他程式碼可能需要的一切。

安全性測試

安全性測試很重要,因為功能錯誤最終會顯現出來。 但如果應用程式不安全,這很可能會隱藏很長一段時間,直到有人作弊並竊取屬於他人的資源時才會被揭露。

權限

在這個遊戲中,有一個特權實體,那就是伺服器。 它是唯一允許呼叫 ServerSystemopens in a new tab 中函式的使用者。 我們可以使用 castopens in a new tab 來驗證對權限函式的呼叫僅能以伺服器帳戶進行。

伺服器的私密金鑰在 setupNetwork.tsopens in a new tab

  1. 在執行 anvil(區塊鏈)的電腦上,設定這些環境變數。

    1WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b
    2UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
    3AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
  2. 使用 cast 嘗試將驗證器地址設定為未經授權的地址。

    1cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $UNAUTHORIZED_KEY

    cast 不僅會報告失敗,您還可以在瀏覽器中的遊戲中開啟 MUD 開發工具,點擊資料表,然後選擇 app__VerifierAddress。 查看地址是否不為零。

  3. 將驗證器地址設定為伺服器的地址。

    1cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $AUTHORIZED_KEY

    app__VerifiedAddress 中的地址現在應為零。

所有在同一個 System 中的 MUD 函式都經過相同的存取控制,所以我認為這個測試是足夠的。 如果您不這麼認為,您可以檢查 ServerSystemopens in a new tab 中的其他函式。

零知識濫用

驗證 Zokrates 的數學超出了本教學課程的範圍(以及我的能力)。 然而,我們可以對零知識程式碼執行各種檢查,以驗證如果它沒有正確完成就會失敗。 所有這些測試都要求我們更改 zero-knowledge.tsopens in a new tab 並重新啟動整個應用程式。 僅重新啟動伺服器程序是不夠的,因為它會使應用程式處於不可能的狀態(玩家正在進行遊戲,但遊戲不再對伺服器可用)。

錯誤答案

最簡單的可能性是在零知識證明中提供錯誤的答案。 為此,我們進入 zkDig修改第 91 行opens in a new tab

1proof.inputs[3] = "0x" + "1".padStart(64, "0")

這表示無論正確答案為何,我們都將始終聲稱有一個炸彈。 嘗試用這個版本玩遊戲,您會在 pnpm dev 畫面的 server 標籤中看到此錯誤:

1 cause: {
2 code: 3,
3 message: 'execution reverted: revert: Zero knowledge verification fail',
4 data: '0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000
5000000000000000000000000000000000000000000000000205a65726f206b6e6f776c6564676520766572696669636174696f6
6e206661696c'
7 },

所以這種作弊方式會失敗。

錯誤的證明

如果我們提供正確的資訊,但證明資料錯誤會發生什麼? 現在,將第 91 行替換為:

1proof.proof = {
2 a: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
3 b: [
4 ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
5 ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
6 ],
7 c: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
8}

它仍然失敗,但現在它在沒有原因的情況下失敗,因為它發生在驗證器呼叫期間。

使用者如何驗證零信任程式碼?

智能合約相對容易驗證。 通常,開發者會將原始碼發佈到區塊瀏覽器,而區塊瀏覽器會驗證原始碼是否確實編譯為合約部署交易中的程式碼。 在 MUD Systems 的情況下,這稍微複雜一些opens in a new tab,但不會複雜太多。

這對零知識來說更難。 驗證器包含一些常數,並對它們執行一些計算。 這不會告訴您正在證明什麼。

1 function verifyingKey() pure internal returns (VerifyingKey memory vk) {
2 vk.alpha = Pairing.G1Point(uint256(0x0f43f4fe7b5c2326fed4ac6ed2f4003ab9ab4ea6f667c2bdd77afb068617ee16), uint256(0x25a77832283f9726935219b5f4678842cda465631e72dbb24708a97ba5d0ce6f));
3 vk.beta = Pairing.G2Point([uint256(0x2cebd0fbd21aca01910581537b21ae4fed46bc0e524c055059aa164ba0a6b62b), uint256(0x18fd4a7bc386cf03a95af7163d5359165acc4e7961cb46519e6d9ee4a1e2b7e9)], [uint256(0x11449dee0199ef6d8eebfe43b548e875c69e7ce37705ee9a00c81fe52f11a009), uint256(0x066d0c83b32800d3f335bb9e8ed5e2924cf00e77e6ec28178592eac9898e1a00)]);

解決方案是,至少在區塊瀏覽器將 Zokrates 驗證新增到其使用者介面之前,應用程式開發者應提供 Zokrates 程式,並讓至少一些使用者使用適當的驗證金鑰自行編譯它們。

若要如此做:

  1. 安裝 Zokratesopens in a new tab

  2. 建立一個名為 dig.zok 的檔案,其中包含 Zokrates 程式。 以下程式碼假設您保留了原始地圖大小 10x5。

    1 import "utils/pack/bool/pack128.zok" as pack128;
    2 import "hashes/poseidon/poseidon.zok" as poseidon;
    3
    4 def hashMap(bool[12][7] map) -> field {
    5 bool[512] mut map1d = [false; 512];
    6 u32 mut counter = 0;
    7
    8 for u32 x in 0..12 {
    9 for u32 y in 0..7 {
    10 map1d[counter] = map[x][y];
    11 counter = counter+1;
    12 }
    13 }
    14
    15 field[4] hashMe = [
    16 pack128(map1d[0..128]),
    17 pack128(map1d[128..256]),
    18 pack128(map1d[256..384]),
    19 pack128(map1d[384..512])
    20 ];
    21
    22 return poseidon(hashMe);
    23 }
    24
    25
    26 // The number of mines in location (x,y)
    27 def map2mineCount(bool[12][7] map, u32 x, u32 y) -> u8 {
    28 return if map[x+1][y+1] { 1 } else { 0 };
    29 }
    30
    31 def main(private bool[12][7] map, u32 x, u32 y) -> (field, u8) {
    32 return (hashMap(map) ,
    33 if map2mineCount(map, x, y) > 0 { 0xFF } else {
    34 map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
    35 map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
    36 map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
    37 }
    38 );
    39 }
    顯示全部
  3. 編譯 Zokrates 程式碼並建立驗證金鑰。 驗證金鑰必須使用原始伺服器中使用的相同熵來建立,在這種情況下是一個空字串opens in a new tab

    1zokrates compile --input dig.zok
    2zokrates setup -e ""
  4. 自行建立 Solidity 驗證器,並驗證其功能上與區塊鏈上的驗證器相同(伺服器會新增註解,但這不重要)。

    1zokrates export-verifier
    2diff verifier.sol ~/20240901-secret-state/packages/contracts/src/verifier.sol

設計決策

在任何足夠複雜的應用程式中,都有相互競爭的設計目標,需要權衡取捨。 讓我們看看一些權衡以及為什麼當前的解決方案優於其他選項。

為什麼是零知識

對於踩地雷遊戲,您並不需要真正的零知識。 伺服器可以一直持有地圖,然後在遊戲結束時揭示所有內容。 然後,在遊戲結束時,智能合約可以計算地圖哈希,驗證其是否匹配,如果不匹配則懲罰伺服器或完全忽略遊戲。

我沒有使用這個更簡單的解決方案,因為它只適用於具有明確結束狀態的短遊戲。 當遊戲可能無限進行時(例如自主世界opens in a new tab的情況),您需要一個可以在_不_揭示狀態的情況下證明狀態的解決方案。

作為教學課程,本文需要一個簡短且易於理解的遊戲,但此技術對於較長的遊戲最有用。

為什麼是 Zokrates?

Zokratesopens in a new tab 並不是唯一可用的零知識程式庫,但它與正常的、命令式opens in a new tab程式設計語言相似,並支援布林變數。

對於您的應用程式,由於有不同的需求,您可能更喜歡使用 Circumopens in a new tabCairoopens in a new tab

何時編譯 Zokrates

在這個程式中,我們在每次伺服器啟動時opens in a new tab編譯 Zokrates 程式。 這顯然是浪費資源,但這是一個教學課程,以簡單為優化目標。

如果我正在編寫一個生產級的應用程式,我會檢查我是否擁有此地雷區大小的已編譯 Zokrates 程式檔案,如果是,就使用它。 在鏈上部署驗證器合約也是如此。

建立驗證器和證明者金鑰

金鑰建立opens in a new tab是另一個純粹的計算,對於給定的地雷區大小,不需要執行多次。 同樣,為了簡單起見,它只執行一次。

此外,我們可以使用設定儀式opens in a new tab。 設定儀式的優點是,您需要每個參與者的熵或一些中間結果才能在零知識證明上作弊。 如果至少有一個儀式參與者是誠實的並刪除了該資訊,那麼零知識證明就可以免受某些攻擊。 然而,_沒有機制_可以驗證資訊是否已從所有地方刪除。 如果零知識證明至關重要,您會希望參與設定儀式。

在這裡,我們依賴 perpetual powers of tauopens in a new tab,它有數十名參與者。 這可能足夠安全,而且簡單得多。 我們在金鑰建立過程中也不添加熵,這使得使用者更容易驗證零知識設定

在哪裡驗證

我們可以在鏈上(這會消耗 gas)或在用戶端(使用 verifyopens in a new tab)驗證零知識證明。 我選擇了前者,因為這可以讓您驗證驗證器一次,然後相信只要其合約地址保持不變,它就不會改變。 如果驗證是在用戶端上完成的,那麼您每次下載用戶端時都必須驗證您收到的程式碼。

此外,雖然這個遊戲是單人遊戲,但很多區塊鏈遊戲都是多人遊戲。 鏈上驗證意味著您只需驗證零知識證明一次。 在用戶端進行驗證將需要每個用戶端獨立驗證。

在 TypeScript 或 Zokrates 中扁平化地圖?

一般來說,當處理可以在 TypeScript 或 Zokrates 中完成時,最好在 TypeScript 中進行,因為它快得多,且不需要零知識證明。 這就是為什麼,例如,我們不向 Zokrates 提供哈希並讓它驗證其是否正確的原因。 哈希必須在 Zokrates 內部完成,但傳回的哈希與鏈上的哈希之間的匹配可以在其外部進行。

然而,我們仍然在 Zokrates 中扁平化地圖opens in a new tab,而我們本可以在 TypeScript 中完成。 原因是我認為其他選項更糟。

  • 向 Zokrates 程式碼提供一個布林值的一維陣列,並使用 x*(height+2) +y 之類的運算式來取得二維地圖。 這會讓程式碼opens in a new tab變得更複雜一些,所以我決定對於教學課程來說,效能的提升不值得這樣做。

  • 向 Zokrates 傳送一維陣列和二維陣列。 然而,這個解決方案對我們沒有任何好處。 Zokrates 程式碼必須驗證它所提供的一維陣列是否真的是二維陣列的正確表示。 所以不會有任何效能提升。

  • 在 Zokrates 中扁平化二維陣列。 這是最簡單的選項,所以我選擇了它。

地圖儲存在哪裡

在這個應用程式中,gamesInProgressopens in a new tab 只是記憶體中的一個變數。 這意味著如果您的伺服器死機並需要重新啟動,它儲存的所有資訊都會遺失。 玩家不僅無法繼續他們的遊戲,他們甚至無法開始新遊戲,因為鏈上元件認為他們仍在進行遊戲。

對於生產系統來說,這顯然是不好的設計,在生產系統中,您會將此資訊儲存在資料庫中。 我之所以在這裡使用變數,唯一的原因是這是一個教學課程,簡單性是主要考量。

結論:在什麼條件下這是一種適當的技術?

所以,現在您知道如何編寫一個帶有伺服器的遊戲,該伺服器儲存不屬於鏈上的秘密狀態。 但您應該在什麼情況下這樣做呢? 有兩個主要考量。

  • 長期運行的遊戲如上所述,在一個短遊戲中,您可以在遊戲結束後發佈狀態,然後進行驗證。 但當遊戲需要很長或不確定的時間,並且狀態需要保密時,這就不是一個選項。

  • 可接受某些中心化:零知識證明可以驗證完整性,即實體沒有偽造結果。 它們無法做的是確保實體仍然可用並回答訊息。 在可用性也需要去中心化的情況下,零知識證明並不是一個足夠的解決方案,您需要多方計算opens in a new tab

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

致謝

  • Alvaro Alonso 閱讀了本文的草稿,並澄清了我對 Zokrates 的一些誤解。

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

頁面最後更新時間: 2026年2月15日

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