メインコンテンツへスキップ

シークレット状態にゼロ知識を使用する

サーバー
オフチェーン
中央集権型
ゼロ知識
zokrates
mud
プライバシー
上級
オリ・ポメランツ
2025年3月15日
49 分で読めます

ブロックチェーン上に秘密はありません。ブロックチェーンに投稿されたすべての情報は、誰でも読むことができます。ブロックチェーンは誰でも検証できることに基づいているため、これは不可欠です。しかし、ゲームはしばしば秘密の状態(シークレット状態)に依存します。たとえば、マインスイーパー (新しいタブで開く)のゲームは、ブロック・エクスプローラーにアクセスしてマップを見るだけであれば、まったく意味を成しません。

最も簡単な解決策は、秘密の状態を保持するためにサーバーコンポーネントを使用することです。しかし、私たちがブロックチェーンを使用する理由は、ゲーム開発者による不正を防ぐためです。サーバーコンポーネントが誠実であることを保証する必要があります。サーバーは状態のハッシュを提供し、ゼロ知識証明を使用して、手の結果を計算するために使用された状態が正しいことを証明できます。

この記事を読むと、この種の秘密の状態を保持するサーバー、状態を表示するためのクライアント、およびその2つ間の通信のためのオンチェーンコンポーネントを作成する方法がわかります。主に使用するツールは以下の通りです:

ツール目的検証済みバージョン
Zokrates (新しいタブで開く)ゼロ知識証明とその検証1.1.9
TypeScript (新しいタブで開く)サーバーとクライアント両方のプログラミング言語5.4.2
Node (新しいタブで開く)サーバーの実行20.18.2
Viem (新しいタブで開く)ブロックチェーンとの通信2.9.20
MUD (新しいタブで開く)オンチェーンデータ管理2.0.12
React (新しいタブで開く)クライアントのユーザーインターフェース18.2.0
Vite (新しいタブで開く)クライアントコードの提供4.2.1

マインスイーパーの例

マインスイーパー (新しいタブで開く)は、地雷原のある秘密のマップを含むゲームです。プレイヤーは特定の場所を掘ることを選択します。その場所に地雷があればゲームオーバーです。そうでない場合、プレイヤーはその場所の周囲8マスにある地雷の数を知ることができます。

このアプリケーションは、キーバリュー型データベース (新しいタブで開く)を使用してデータをオンチェーンに保存し、そのデータをオフチェーンのコンポーネントと自動的に同期できるフレームワークであるMUD (新しいタブで開く)を使用して書かれています。同期に加えて、MUDを使用すると、アクセス制御を提供したり、他のユーザーがパーミッションレスでアプリケーションを拡張 (新しいタブで開く)したりすることが容易になります。

マインスイーパーの例を実行する

マインスイーパーの例を実行するには:

  1. 前提条件がインストールされている (新しいタブで開く)ことを確認します:Node (新しいタブで開く)Foundry (新しいタブで開く)git (新しいタブで開く)pnpm (新しいタブで開く)、およびmprocs (新しいタブで開く)

  2. リポジトリをクローンします。

    git clone https://github.com/qbzzt/20240901-secret-state.git
    
  3. パッケージをインストールします。

    cd 20240901-secret-state/
    pnpm install
    npm install -g mprocs
    

    Foundryがpnpm installの一部としてインストールされた場合は、コマンドラインシェルを再起動する必要があります。

  4. コントラクトをコンパイルします。

    cd packages/contracts
    forge build
    cd ../..
    
  5. プログラム(anvil (新しいタブで開く)ブロックチェーンを含む)を起動して待機します。

    mprocs
    

    起動には時間がかかることに注意してください。進行状況を確認するには、まず下矢印を使用して_contracts_タブにスクロールし、MUDコントラクトがデプロイされていることを確認します。_Waiting for file changes…_というメッセージが表示されたら、コントラクトのデプロイは完了しており、その後の進行は_server_タブで行われます。そこで、_Verifier address: 0x...._というメッセージが表示されるまで待機します。

    このステップが成功すると、mprocs画面が表示され、左側にさまざまなプロセス、右側に現在選択されているプロセスのコンソール出力が表示されます。

    The mprocs screen

    mprocsに問題がある場合は、4つのプロセスをそれぞれ独自のコマンドラインウィンドウで手動で実行できます。

    • Anvil

      cd packages/contracts
      anvil --base-fee 0 --block-time 2
      
    • コントラクト

      cd packages/contracts
      pnpm mud dev-contracts --rpc http://127.0.0.1:8545
      
    • サーバー

      cd packages/server
      pnpm start
      
    • クライアント

      cd packages/client
      pnpm run dev
      
  6. これで、クライアント (新しいタブで開く)にアクセスし、New Gameをクリックしてプレイを開始できます。

テーブル

オンチェーンにはいくつかのテーブル (新しいタブで開く)が必要です。

  • Configuration: このテーブルはシングルトンであり、キーを持たず、単一のレコードを持ちます。ゲームの設定情報を保持するために使用されます。

    • height: 地雷原の高さ
    • width: 地雷原の幅
    • numberOfBombs: 各地雷原の爆弾の数
  • VerifierAddress: このテーブルもシングルトンです。設定の一部である、検証者コントラクト(verifier)のアドレスを保持するために使用されます。この情報をConfigurationテーブルに入れることもできましたが、サーバーという別のコンポーネントによって設定されるため、別のテーブルに入れる方が簡単です。

  • PlayerGame: キーはプレイヤーのアドレスです。データは以下の通りです。

    • gameId: プレイヤーがプレイしているマップのハッシュ(ゲーム識別子)である32バイトの値。
    • win: プレイヤーがゲームに勝ったかどうかを示すブール値。
    • lose: プレイヤーがゲームに負けたかどうかを示すブール値。
    • digNumber: ゲーム内で成功した掘削の数。
  • GamePlayer: このテーブルは、gameIdからプレイヤーのアドレスへの逆マッピングを保持します。

  • Map: キーは3つの値のタプルです。

    • gameId: プレイヤーがプレイしているマップのハッシュ(ゲーム識別子)である32バイトの値。
    • x 座標
    • y 座標

    値は単一の数値です。爆弾が検出された場合は255になります。そうでない場合は、その場所の周囲にある爆弾の数に1を足した値になります。EVMのすべてのストレージとMUDのすべての行の値はデフォルトでゼロであるため、単に爆弾の数を使用することはできません。「プレイヤーはまだここを掘っていない」と「プレイヤーはここを掘り、周囲に爆弾がゼロであることを発見した」を区別する必要があります。

さらに、クライアントとサーバー間の通信はオンチェーンコンポーネントを通じて行われます。これもテーブルを使用して実装されています。

  • PendingGame: 新しいゲームを開始するための未処理のリクエスト。
  • PendingDig: 特定のゲームの特定の場所を掘るための未処理のリクエスト。これはオフチェーンテーブル (新しいタブで開く)であり、EVMストレージには書き込まれず、イベントを使用してオフチェーンでのみ読み取り可能であることを意味します。

実行とデータフロー

これらのフローは、クライアント、オンチェーンコンポーネント、およびサーバー間の実行を調整します。

初期化

mprocsを実行すると、以下のステップが発生します。

  1. mprocs (新しいタブで開く)は4つのコンポーネントを実行します。

  2. contractsパッケージはMUDコントラクトをデプロイし、その後PostDeploy.s.solスクリプト (新しいタブで開く)を実行します。このスクリプトは設定を行います。GitHubのコードは、8つの地雷が含まれる10x5の地雷原 (新しいタブで開く)を指定しています。

  3. サーバー (新しいタブで開く)MUDのセットアップ (新しいタブで開く)から開始します。とりわけ、これによりデータ同期がアクティブになり、関連するテーブルのコピーがサーバーのメモリ内に存在することになります。

  4. サーバーは、Configurationテーブルが変更されたとき (新しいタブで開く)に実行される関数をサブスクライブします。この関数 (新しいタブで開く)は、PostDeploy.s.solが実行されてテーブルが変更された後に呼び出されます。

  5. サーバーの初期化関数が設定を取得すると、zkFunctionsを呼び出して (新しいタブで開く)サーバーのゼロ知識部分を初期化します。ゼロ知識関数は地雷原の幅と高さを定数として持つ必要があるため、設定を取得するまでこれは実行できません。

  6. サーバーのゼロ知識部分が初期化された後、次のステップはゼロ知識検証コントラクトをブロックチェーンにデプロイ (新しいタブで開く)し、MUDで検証者のアドレスを設定することです。

  7. 最後に、プレイヤーが新しいゲームの開始 (新しいタブで開く)または既存のゲームでの掘削 (新しいタブで開く)をリクエストしたときにわかるように、更新をサブスクライブします。

新しいゲーム

プレイヤーが新しいゲームをリクエストしたときに起こることは以下の通りです。

  1. このプレイヤーの進行中のゲームがない場合、または進行中のゲームがあってもgameIdがゼロの場合、クライアントは新しいゲームボタン (新しいタブで開く)を表示します。ユーザーがこのボタンを押すと、ReactはnewGame関数を実行します (新しいタブで開く)

  2. newGame (新しいタブで開く)System呼び出しです。MUDでは、すべての呼び出しはWorldコントラクトを経由してルーティングされ、ほとんどの場合<namespace>__<function name>を呼び出します。この場合、呼び出しはapp__newGameに対して行われ、MUDはそれをGameSystem内のnewGame (新しいタブで開く)にルーティングします。

  3. オンチェーン関数は、プレイヤーが進行中のゲームを持っていないことを確認し、ない場合はリクエストをPendingGameテーブルに追加します (新しいタブで開く)

  4. サーバーはPendingGameの変更を検出し、サブスクライブされた関数を実行します (新しいタブで開く)。この関数はnewGame (新しいタブで開く)を呼び出し、それがさらにcreateGame (新しいタブで開く)を呼び出します。

  5. createGameが最初に行うことは、適切な数の地雷を持つランダムなマップを作成する (新しいタブで開く)ことです。次に、makeMapBorders (新しいタブで開く)を呼び出して、Zokratesに必要な空白の境界線を持つマップを作成します。最後に、createGamecalculateMapHashを呼び出してマップのハッシュを取得し、これをゲームIDとして使用します。

  6. newGame関数は、新しいゲームをgamesInProgressに追加します。

  7. サーバーが最後に行うことは、オンチェーンのapp__newGameResponse (新しいタブで開く)を呼び出すことです。この関数は、アクセス制御を有効にするために、別のSystemであるServerSystem (新しいタブで開く)にあります。アクセス制御はMUD設定ファイル (新しいタブで開く)mud.config.ts (新しいタブで開く)で定義されています。

    アクセスリストは、単一のアドレスのみがSystemを呼び出すことを許可します。これにより、サーバー関数へのアクセスが単一のアドレスに制限されるため、誰もサーバーになりすますことはできません。

  8. オンチェーンコンポーネントは関連するテーブルを更新します。

    • PlayerGameにゲームを作成します。
    • GamePlayerに逆マッピングを設定します。
    • PendingGameからリクエストを削除します。
  9. サーバーはPendingGameの変更を識別しますが、wantsGame (新しいタブで開く)がfalseであるため、何も行いません。

  10. クライアントでは、gameRecord (新しいタブで開く)がプレイヤーのアドレスのPlayerGameエントリに設定されます。PlayerGameが変更されると、gameRecordも変更されます。

  11. gameRecordに値があり、ゲームの勝敗がまだ決まっていない場合、クライアントはマップを表示します (新しいタブで開く)

掘る

  1. プレイヤーがマップセルのボタンをクリックする (新しいタブで開く)と、dig関数 (新しいタブで開く)が呼び出されます。この関数はオンチェーンのdig (新しいタブで開く)を呼び出します。

  2. オンチェーンコンポーネントはいくつかの健全性チェックを実行し (新しいタブで開く)、成功した場合は掘削リクエストをPendingDig (新しいタブで開く)に追加します。

  3. サーバーはPendingDigの変更を検出します (新しいタブで開く)それが有効な場合 (新しいタブで開く)ゼロ知識コードを呼び出して (新しいタブで開く)(後述)、結果とそれが有効であることの証明の両方を生成します。

  4. サーバー (新しいタブで開く)はオンチェーンのdigResponse (新しいタブで開く)を呼び出します。

  5. digResponseは2つのことを行います。まず、ゼロ知識証明 (新しいタブで開く)をチェックします。次に、証明が確認された場合、processDigResult (新しいタブで開く)を呼び出して実際に結果を処理します。

  6. processDigResultはゲームに負けた (新しいタブで開く)勝った (新しいタブで開く)かを確認し、オンチェーンマップであるMapを更新します (新しいタブで開く)

  7. クライアントは自動的に更新を取得し、プレイヤーに表示されるマップを更新し (新しいタブで開く)、該当する場合はプレイヤーに勝敗を伝えます。

Zokratesの使用

上で説明したフローでは、ゼロ知識の部分をブラックボックスとして扱い、スキップしました。ここでは、それを開いて、そのコードがどのように書かれているかを見てみましょう。

マップのハッシュ化

このJavaScriptコード (新しいタブで開く)を使用して、私たちが使用するZokratesのハッシュ関数であるPoseidon (新しいタブで開く)を実装することができます。しかし、これは高速ですが、単にZokratesのハッシュ関数を使用するよりも複雑になります。これはチュートリアルであるため、コードはパフォーマンスではなくシンプルさを重視して最適化されています。したがって、マップのハッシュを計算するだけのプログラム(hash)と、マップ上の場所を掘った結果のゼロ知識証明を実際に作成するプログラム(dig)の2つの異なるZokratesプログラムが必要です。

ハッシュ関数

これはマップのハッシュを計算する関数です。このコードを1行ずつ見ていきましょう。

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

これら2行は、Zokrates標準ライブラリ (新しいタブで開く)から2つの関数をインポートします。最初の関数 (新しいタブで開く)Poseidonハッシュ (新しいタブで開く)です。これはfield要素 (新しいタブで開く)の配列を受け取り、fieldを返します。

Zokratesのフィールド要素は通常256ビット未満ですが、それほど小さくはありません。コードを簡素化するために、マップを最大512ビットに制限し、4つのフィールドの配列をハッシュ化し、各フィールドでは128ビットのみを使用します。pack128関数 (新しいタブで開く)は、この目的のために128ビットの配列をfieldに変換します。

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

この行は関数定義の開始です。hashMapは、mapという単一のパラメータ(2次元のbool(ean)配列)を受け取ります。マップのサイズがwidth+2×height+2である理由は、後述します

Zokratesプログラムはこのアプリケーション内でテンプレート文字列 (新しいタブで開く)として保存されているため、${width+2}${height+2}を使用できます。${}の間のコードはJavaScriptによって評価され、この方法でプログラムを異なるマップサイズに使用できます。マップパラメータの周囲には爆弾のない1マス分の境界線があるため、幅と高さに2を足す必要があります。

戻り値は、ハッシュを含むfieldです。

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

マップは2次元です。しかし、pack128関数は2次元配列では機能しません。そのため、まずmap1dを使用してマップを512バイトの配列に平坦化します。デフォルトではZokratesの変数は定数ですが、ループ内でこの配列に値を代入する必要があるため、mut (新しいタブで開く)として定義します。

Zokratesにはundefinedがないため、配列を初期化する必要があります。[false; 512]という式は、512個のfalse値の配列 (新しいタブで開く)を意味します。

u32 mut counter = 0;

また、map1dにすでに入力したビットとまだ入力していないビットを区別するためのカウンターも必要です。

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

これがZokratesでforループ (新しいタブで開く)を宣言する方法です。Zokratesのforループは固定の境界を持つ必要があります。なぜなら、ループのように見えても、コンパイラは実際にはそれを「展開(アンロール)」するからです。widthはコンパイラを呼び出す前にTypeScriptコードによって設定されるため、${width+2}という式はコンパイル時の定数になります。

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

マップ内のすべての場所について、その値をmap1d配列に入れ、カウンターをインクリメントします。

field[4] hashMe = [
        pack128(map1d[0..128]),
        pack128(map1d[128..256]),
        pack128(map1d[256..384]),
        pack128(map1d[384..512])
    ];

map1dから4つのfield値の配列を作成するためのpack128です。Zokratesでは、array[a..b]aから始まりb-1で終わる配列のスライスを意味します。

return poseidon(hashMe);
}

poseidonを使用して、この配列をハッシュに変換します。

ハッシュプログラム

サーバーはゲーム識別子を作成するためにhashMapを直接呼び出す必要があります。しかし、Zokratesはプログラムを開始するためにmain関数しか呼び出せないため、ハッシュ関数を呼び出すmainを持つプログラムを作成します。

${hashFragment}

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

掘削プログラム

これはアプリケーションのゼロ知識部分の心臓部であり、掘削結果を検証するために使用される証明を生成する場所です。

${hashFragment}

// 位置(x,y)にある地雷の数
def map2mineCount(bool[${width+2}][${height+2}] map, u32 x, u32 y) -> u8 {
   return if map[x+1][y+1] { 1 } else { 0 };
}

なぜマップの境界線が必要なのか

ゼロ知識証明は算術回路 (新しいタブで開く)を使用しますが、これにはif文に相当する簡単なものがありません。代わりに、条件演算子 (新しいタブで開く)に相当するものを使用します。aが0または1のいずれかである場合、if a { b } else { c }ab+(1-a)cとして計算できます。

このため、Zokratesのif文は常に両方の分岐を評価します。たとえば、次のようなコードがあるとします。

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

このコードはエラーになります。なぜなら、その値が後でゼロと掛け合わされるとしても、arr[10]を計算する必要があるからです。

これが、マップの周囲に1マス分の境界線が必要な理由です。ある場所の周囲にある地雷の総数を計算する必要があります。つまり、掘っている場所の上下左右の場所を見る必要があります。これは、Zokratesに提供されるマップ配列にそれらの場所が存在していなければならないことを意味します。

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

デフォルトでは、Zokratesの証明にはその入力が含まれます。実際にどの場所であるかを知らなければ、ある場所の周囲に5つの地雷があることを知っても意味がありません(そして、プルーバーが異なる値を使用してそれを伝えない可能性があるため、単にリクエストと照合することはできません)。しかし、マップをZokratesに提供しつつ、秘密にしておく必要があります。解決策は、証明によって明らかに_されない_パラメータであるprivateパラメータを使用することです。

これは別の悪用の道を開きます。プルーバーは正しい座標を使用しつつ、その場所の周囲、あるいはその場所自体に任意の数の地雷があるマップを作成する可能性があります。この悪用を防ぐために、ゼロ知識証明にゲーム識別子であるマップのハッシュを含めるようにします。

return (hashMap(map),

ここでの戻り値は、マップのハッシュ配列と掘削結果を含むタプルです。

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

その場所自体に爆弾がある場合の特別な値として255を使用します。

map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
            map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
            map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
         }
   );
}

プレイヤーが地雷を踏んでいない場合は、その場所の周囲の地雷の数を足して返します。

TypeScriptからのZokratesの使用

Zokratesにはコマンドラインインターフェースがありますが、このプログラムではTypeScriptコード (新しいタブで開く)内で使用します。

Zokratesの定義を含むライブラリはzero-knowledge.ts (新しいタブで開く)と呼ばれます。

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

ZokratesのJavaScriptバインディング (新しいタブで開く)をインポートします。すべてのZokrates定義に解決されるプロミスを返すため、initialize (新しいタブで開く)関数のみが必要です。

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

Zokrates自体と同様に、ここでも1つの関数のみをエクスポートし、それも非同期 (新しいタブで開く)です。最終的に戻るときに、後述するいくつかの関数を提供します。

const zokrates = await zokratesInitialize()

Zokratesを初期化し、ライブラリから必要なものをすべて取得します。

次に、上で見たハッシュ関数と2つのZokratesプログラムがあります。

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

ここでそれらのプログラムをコンパイルします。

// ゼロ知識検証用の鍵を作成します。
// 本番システムでは、セットアップセレモニーを使用するとよいでしょう。
// (https://zokrates.github.io/toolbox/trusted_setup.html#initializing-a-phase-2-ceremony).
const keySetupResults = zokrates.setup(digCompiled.program, "")
const verifierKey = keySetupResults.vk
const proverKey = keySetupResults.pk

本番システムでは、より複雑なセットアップ・セレモニー (新しいタブで開く)を使用するかもしれませんが、デモンストレーションとしてはこれで十分です。ユーザーがプルーバーの鍵を知ることができるのは問題ではありません。真実でない限り、それを使用して証明することはできないからです。エントロピー(2番目のパラメータ、"")を指定するため、結果は常に同じになります。

注: Zokratesプログラムのコンパイルと鍵の作成は時間のかかるプロセスです。毎回繰り返す必要はなく、マップのサイズが変更されたときだけで十分です。本番システムでは、これらを1回実行し、その出力を保存します。ここでそうしていない唯一の理由は、シンプルにするためです。

calculateMapHash

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

computeWitness (新しいタブで開く)関数は実際にZokratesプログラムを実行します。これは2つのフィールドを持つ構造体を返します。JSON文字列としてのプログラムの出力であるoutputと、結果のゼロ知識証明を作成するために必要な情報であるwitnessです。ここでは出力のみが必要です。

出力は"31337"の形式の文字列、つまり引用符で囲まれた10進数です。しかし、viemに必要な出力は0x60A7の形式の16進数です。そのため、.slice(1,-1)を使用して引用符を削除し、次にBigIntを使用して残りの文字列(10進数)をBigInt (新しいタブで開く)に変換します。.toString(16)はこのBigIntを16進数の文字列に変換し、"0x"+は16進数のマーカーを追加します。

// 採掘し、結果のゼロ知識証明を返します。
// (サーバーサイドコード)

ゼロ知識証明には、公開入力(xy)と結果(マップのハッシュと爆弾の数)が含まれます。

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

Zokratesでインデックスが範囲外かどうかを確認するのは問題があるため、ここで行います。

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

掘削プログラムを実行します。

        const proof = zokrates.generateProof(
            digCompiled.program,
            runResults.witness,
            proverKey)

        return proof
    }

generateProof (新しいタブで開く)を使用して証明を返します。

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

Solidityの検証者。ブロックチェーンにデプロイし、digCompiled.programによって生成された証明を検証するために使用できるスマート・コントラクトです。

    return {
        zkDig,
        calculateMapHash,
        solidityVerifier,
    }
}

最後に、他のコードが必要とする可能性のあるすべてのものを返します。

  1. castを使用して、検証者のアドレスを未承認のアドレスとして設定しようと試みます。

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

    castが失敗を報告するだけでなく、ブラウザ上のゲームでMUD Dev Toolsを開き、Tablesをクリックしてapp__VerifierAddressを選択することもできます。アドレスがゼロではないことを確認してください。

  2. 検証者のアドレスをサーバーのアドレスとして設定します。

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

    これで、app__VerifiedAddressのアドレスはゼロになるはずです。

同じSystem内のすべてのMUD関数は同じアクセス制御を経由するため、このテストで十分だと考えられます。そう思わない場合は、ServerSystem (新しいタブで開く)内の他の関数を確認できます。

ゼロ知識の悪用

Zokratesを検証するための数学は、このチュートリアルの範囲(および私の能力)を超えています。しかし、ゼロ知識のコードに対してさまざまなチェックを実行し、正しく行われない場合に失敗することを検証できます。これらのテストはすべて、zero-knowledge.ts (新しいタブで開く)を変更し、アプリケーション全体を再起動する必要があります。サーバープロセスを再起動するだけでは不十分です。なぜなら、アプリケーションが不可能な状態(プレイヤーはゲームを進行中だが、サーバーからはそのゲームが利用できなくなる)に陥るからです。

間違った答え

最も単純な可能性は、ゼロ知識証明で間違った答えを提供することです。これを行うには、zkDigの中に入り、91行目を変更します (新しいタブで開く)

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

これは、正解に関係なく、常に爆弾が1つあると請求することを意味します。このバージョンでプレイしてみると、pnpm dev画面のserverタブに次のエラーが表示されます。

cause: {
        code: 3,
        message: 'execution reverted: revert: Zero knowledge verification fail',
        data: '0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000
000000000000000000000000000000000000000000000000205a65726f206b6e6f776c6564676520766572696669636174696f6
e206661696c'
      },

したがって、この種の不正行為は失敗します。

間違った証明

正しい情報を提供しつつ、証明データだけが間違っている場合はどうなるでしょうか?次に、91行目を次のように置き換えます。

proof.proof = {
  a: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
  b: [
    ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
    ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
  ],
  c: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
}

やはり失敗しますが、今回は検証者の呼び出し中に発生するため、理由なしで失敗します。

ユーザーはどのようにしてゼロトラストコードを検証できるか?

スマート・コントラクトの検証は比較的簡単です。通常、開発者はソースコードをブロック・エクスプローラーに公開し、ブロック・エクスプローラーはソースコードがコントラクトのデプロイのトランザクション内のコードにコンパイルされることを検証します。MUDのSystemの場合、これは少し複雑になります (新しいタブで開く)が、それほどではありません。

ゼロ知識の場合、これはより困難になります。検証者にはいくつかの定数が含まれており、それらに対して計算を実行します。これでは、何が証明されているのかわかりません。

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

解決策としては、少なくともブロック・エクスプローラーがユーザーインターフェースにZokratesの検証を追加するまでは、アプリケーション開発者がZokratesプログラムを利用可能にし、少なくとも一部のユーザーが適切な検証鍵を使用して自分でコンパイルすることです。

これを行うには:

  1. Zokratesをインストールします (新しいタブで開く)

  2. Zokratesプログラムを含むファイルdig.zokを作成します。以下のコードは、元のマップサイズである10x5を維持していることを前提としています。

  3. Zokratesコードをコンパイルし、検証鍵を作成します。検証鍵は、元のサーバーで使用されたのと同じエントロピー(この場合は空の文字列 (新しいタブで開く))を使用して作成する必要があります。

    zokrates compile --input dig.zok
    zokrates setup -e ""
    
  4. 自分でSolidityの検証者を作成し、ブロックチェーン上のものと機能的に同一であることを検証します(サーバーはコメントを追加しますが、それは重要ではありません)。

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

設計上の決定事項

十分に複雑なアプリケーションでは、トレードオフを必要とする競合する設計目標が存在します。いくつかのトレードオフと、現在のソリューションが他の選択肢よりも好ましい理由を見てみましょう。

なぜゼロ知識なのか

マインスイーパーの場合、実際にはゼロ知識は必要ありません。サーバーは常にマップを保持し、ゲームが終了したときにすべてを公開するだけで済みます。そして、ゲームの終了時に、スマート・コントラクトがマップのハッシュを計算し、それが一致するかどうかを検証し、一致しない場合はサーバーにペナルティを科すか、ゲームを完全に無効にすることができます。

このよりシンプルなソリューションを使用しなかった理由は、明確に定義された終了状態を持つ短いゲームでしか機能しないためです。ゲームが無限に続く可能性がある場合(自律型世界(autonomous worlds) (新しいタブで開く)の場合など)、状態を明らかにすること_なく_証明するソリューションが必要です。

チュートリアルとして、この記事には理解しやすい短いゲームが必要でしたが、この手法はより長いゲームで最も役立ちます。

なぜZokratesなのか?

利用可能なゼロ知識ライブラリはZokrates (新しいタブで開く)だけではありませんが、通常の命令型 (新しいタブで開く)プログラミング言語に似ており、ブール変数をサポートしています。

要件が異なるアプリケーションの場合は、Circum (新しいタブで開く)Cairo (新しいタブで開く)を使用する方が適しているかもしれません。

Zokratesをコンパイルするタイミング

このプログラムでは、サーバーが起動するたびに (新しいタブで開く)Zokratesプログラムをコンパイルします。これは明らかにリソースの無駄ですが、これはシンプルさを最適化したチュートリアルです。

本番レベルのアプリケーションを作成する場合、この地雷原のサイズでコンパイルされたZokratesプログラムのファイルがあるかどうかを確認し、あればそれを使用します。検証者コントラクトをオンチェーンにデプロイする場合も同様です。

検証者とプルーバーの鍵の作成

鍵の作成 (新しいタブで開く)も純粋な計算であり、特定の地雷原のサイズに対して複数回行う必要はありません。ここでも、シンプルにするために1回だけ行われます。

さらに、セットアップセレモニー (新しいタブで開く)を使用することもできます。セットアップセレモニーの利点は、ゼロ知識証明で不正を行うには、各参加者からのエントロピーまたは何らかの中間結果が必要になることです。少なくとも1人のセレモニー参加者が誠実であり、その情報を削除すれば、ゼロ知識証明は特定の攻撃から安全になります。ただし、情報がすべての場所から削除されたことを検証するメカニズムは_ありません_。ゼロ知識証明が極めて重要な場合は、セットアップセレモニーに参加することをお勧めします。

ここでは、数十人が参加したperpetual powers of tau (新しいタブで開く)に依存しています。これはおそらく十分に安全であり、はるかにシンプルです。また、鍵の作成中にエントロピーを追加しないため、ユーザーがゼロ知識の設定を検証するのが容易になります。

どこで検証するか

ゼロ知識証明は、オンチェーン(ガスがかかります)またはクライアント内(verify (新しいタブで開く)を使用)のいずれかで検証できます。私は前者を選びました。なぜなら、これにより検証者を検証することが一度で済み、その後はコントラクトのアドレスが同じである限り変更されないと信頼できるからです。クライアントで検証が行われる場合、クライアントをダウンロードするたびに受け取るコードを検証する必要があります。

また、このゲームはシングルプレイヤーですが、多くのブロックチェーンゲームはマルチプレイヤーです。オンチェーンでの検証は、ゼロ知識証明を一度だけ検証することを意味します。クライアントでこれを行うと、各クライアントが独立して検証する必要があります。

TypeScriptとZokratesのどちらでマップをフラット化するか?

一般的に、処理をTypeScriptまたはZokratesのどちらでも実行できる場合は、はるかに高速でゼロ知識証明を必要としないTypeScriptで実行する方が適しています。これが、たとえば、Zokratesにハッシュを提供してそれが正しいことを検証させない理由です。ハッシュ化はZokrates内で行う必要がありますが、返されたハッシュとオンチェーンのハッシュの一致確認は外部で行うことができます。

しかし、TypeScriptで実行できたにもかかわらず、依然としてZokratesでマップをフラット化 (新しいタブで開く)しています。その理由は、私の意見では、他の選択肢の方が悪いからです。

  • Zokratesコードにブール値の1次元配列を提供し、x*(height+2) +y のような式を使用して2次元マップを取得する。これによりコード (新しいタブで開く)がやや複雑になるため、チュートリアルとしてはパフォーマンスの向上に見合わないと判断しました。

  • 1次元配列と2次元配列の両方をZokratesに送信する。しかし、このソリューションでは何も得られません。Zokratesコードは、提供された1次元配列が本当に2次元配列の正しい表現であることを検証する必要があります。そのため、パフォーマンスの向上はありません。

  • Zokratesで2次元配列をフラット化する。これが最もシンプルな選択肢であるため、これを選びました。

マップをどこに保存するか

このアプリケーションでは、gamesInProgress (新しいタブで開く)は単なるメモリ上の変数です。これは、サーバーがダウンして再起動する必要がある場合、保存されていたすべての情報が失われることを意味します。プレイヤーはゲームを続行できないだけでなく、オンチェーンのコンポーネントがまだゲームが進行中であると認識しているため、新しいゲームを開始することすらできません。

これは、この情報をデータベースに保存する本番システムにとっては明らかに悪い設計です。ここで変数を使用した唯一の理由は、これがチュートリアルであり、シンプルさを主な考慮事項としているためです。

結論:どのような条件でこの手法が適切か?

これで、オンチェーンに置くべきではない秘密の状態を保存するサーバーを使ったゲームの書き方がわかりました。しかし、どのような場合にこれを行うべきでしょうか?主に2つの考慮事項があります。

  • 長期実行ゲーム: 前述の通り、短いゲームであれば、ゲーム終了後に状態を公開し、その時点ですべてを検証させることができます。しかし、ゲームに長い時間や無期限の時間がかかり、状態を秘密にしておく必要がある場合、それは選択肢にはなりません。

  • ある程度の中央集権化が許容される場合: ゼロ知識証明は、エンティティが結果を偽造していないという完全性を検証できます。しかし、そのエンティティが引き続き利用可能であり、メッセージに応答することを保証することはできません。可用性も分散型である必要がある状況では、ゼロ知識証明は十分な解決策ではなく、マルチパーティ計算 (新しいタブで開く)が必要になります。

私の他の作品についてはこちらをご覧ください (新しいタブで開く)

謝辞

  • Alvaro Alonso氏はこの記事の草稿を読み、Zokratesに関する私の誤解のいくつかを解いてくれました。

残っている誤りはすべて私の責任です。