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

秘密のステートにゼロ知識を使用する

サーバー
オフチェーン
中央集権型
ゼロ知識
zokrates
mud
上級
Ori Pomerantz
2025年3月15日
48 分の読書

ブロックチェーンには秘密はありません。 ブロックチェーンに投稿されたものはすべて、誰でも読むことができます。 ブロックチェーンは誰でも検証できることに基づいているため、これが必要です。 しかし、ゲームはしばしば秘密のステートに依存します。 例えば、マインスイーパ (opens in a new tab)というゲームは、ブロックチェーンエクスプローラーでマップを見ることができれば、まったく意味がありません。

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

この記事を読んだ後、あなたはこの種の秘密のステートを保持するサーバー、ステートを表示するためのクライアント、そして両者間の通信のためのオンチェーンコンポーネントを作成する方法を知るでしょう。 使用する主なツールは次のとおりです。

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

マインスイーパの例

マインスイーパ (opens in a new tab)は、地雷原のある秘密のマップを含むゲームです。 プレイヤーは特定の場所を掘ることを選択します。 その場所に地雷があれば、ゲームオーバーです。 そうでなければ、プレイヤーはその場所を囲む8つのマスにある地雷の数を取得します。

このアプリケーションは、key-valueデータベース (opens in a new tab)を使用してデータをオンチェーンに保存し、そのデータをオフチェーンコンポーネントと自動的に同期させるフレームワークであるMUD (opens in a new tab)を使用して書かれています。 同期に加えて、MUDはアクセス制御を容易にし、他のユーザーが私たちのアプリケーションをパーミッションレスで拡張 (opens in a new tab)できるようにします。

マインスイーパの例の実行

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

  1. 前提条件がインストールされている (opens in a new tab)ことを確認してください:Node (opens in a new tab)Foundry (opens in a new tab)git (opens in a new tab)pnpm (opens in a new tab)、そしてmprocs (opens 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

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

  4. コントラクトをコンパイルする

    1cd packages/contracts
    2forge build
    3cd ../..
  5. プログラム(anvil (opens in a new tab)ブロックチェーンを含む)を起動し、待ちます。

    1mprocs

    起動には時間がかかることに注意してください。 進捗を確認するには、まず下矢印を使用して contracts タブまでスクロールし、MUDコントラクトがデプロイされていることを確認します。 「Waiting for file changes…」というメッセージが表示されたら、コントラクトはデプロイされ、さらなる進捗は server タブで行われます。 そこで、「Verifier address: 0x....」というメッセージが表示されるまで待ちます。

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

    mprocs画面

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

    • 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: キーは3つの値のタプルです:

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

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

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

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

実行とデータフロー

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

初期化

mprocsを実行すると、次のステップが実行されます:

  1. mprocs (opens in a new tab)は4つのコンポーネントを実行します:

  2. contractsパッケージはMUDコントラクトをデプロイし、その後 PostDeploy.s.sol スクリプト (opens in a new tab)を実行します。 このスクリプトは構成を設定します。 githubのコードは10x5の地雷原に8つの地雷があること (opens in a new tab)を指定しています。

  3. サーバー (opens in a new tab)MUDの設定 (opens in a new tab)から始まります。 とりわけ、これによりデータ同期が有効になり、関連するテーブルのコピーがサーバーのメモリに存在することになります。

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

  5. サーバーの初期化関数が構成を取得すると、サーバーのゼロ知識部分を初期化するためにzkFunctions (opens in a new tab)を呼び出します。 ゼロ知識関数は地雷原の幅と高さを定数として持つ必要があるため、構成を取得するまでこれは実行できません。

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

  7. 最後に、プレイヤーが新しいゲームの開始 (opens in a new tab)または既存のゲームでの採掘 (opens in a new tab)をリクエストしたときに表示されるように、アップデートをサブスクライブします。

新しいゲーム

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

  1. このプレイヤーに進行中のゲームがない場合、またはゲームIDがゼロのゲームがある場合、クライアントは新しいゲームボタン (opens in a new tab)を表示します。 ユーザーがこのボタンを押すと、ReactはnewGame関数を実行します (opens in a new tab)

  2. newGame (opens in a new tab)Systemコールです。 MUDでは、すべての呼び出しはWorldコントラクトを経由し、ほとんどの場合、<namespace>__<function name>を呼び出します。 この場合、呼び出しはapp__newGameであり、MUDはそれをGameSystemnewGame (opens in a new tab)にルーティングします。

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

  4. サーバーはPendingGameの変更を検出し、サブスクライブされた関数を実行します (opens in a new tab)。 この関数はnewGame (opens in a new tab)を呼び出し、それがさらにcreateGame (opens in a new tab)を呼び出します。

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

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

  7. サーバーが最後に行うことは、オンチェーンにあるapp__newGameResponse (opens in a new tab)を呼び出すことです。 この関数は、アクセス制御を有効にするために、別のSystemServerSystem (opens in a new tab)にあります。 アクセス制御は、MUD構成ファイル (opens in a new tab)mud.config.ts (opens in a new tab)で定義されています。

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

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

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

  10. クライアントでは、gameRecord (opens 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)を呼び出します。 この関数はオンチェーンで dig を呼び出します (opens in a new tab)

  2. オンチェーンコンポーネントはいくつかのサニティチェックを実行し (opens in a new tab)、成功した場合、採掘リクエストをPendingDig (opens 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)はオンチェーンでdigResponse (opens in a new tab)を呼び出します。

  5. digResponseは2つのことを行います。 まず、ゼロ知識証明をチェックします (opens in a new tab)。 次に、証明がチェックアウトされた場合、実際に結果を処理するためにprocessDigResult (opens in a new tab)を呼び出します。

  6. processDigResultは、ゲームが負けた (opens in a new tab)勝った (opens in a new tab)かを確認し、オンチェーンマップであるMapを更新します (opens in a new tab)

  7. クライアントはアップデートを自動的に取得し、プレイヤーに表示されるマップを更新し (opens in a new tab)、該当する場合は勝ちか負けかをプレイヤーに伝えます。

Zokratesの使用

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

マップのハッシュ化

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

ハッシュ関数

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

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

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

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

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

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

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

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

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

マップは2次元です。 しかし、pack128関数は2次元配列では機能しません。 そこで、まずmap1dを使用してマップを512バイトの配列にフラット化します。 デフォルトではZokratesの変数は定数ですが、ループ内でこの配列に値を代入する必要があるため、mut (opens 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はTypeScriptコードがコンパイラを呼び出す前に設定されるため、式${width+2}はコンパイル時定数です。

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から4つの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}

digプログラム

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

1${hashFragment}
2
3// (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}

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

ゼロ知識証明は、if文に簡単な同等のものがない算術回路 (opens in a new tab)を使用します。 代わりに、条件演算子 (opens in a new tab)の同等のものを使用します。 aがゼロか1のいずれかである場合、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]を計算する必要があるため、エラーが発生します。

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

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

デフォルトでは、Zokratesの証明にはその入力が含まれています。 あるスポットの周りに5つの地雷があると知っていても、実際にどのスポットかがわからなければ意味がありません(また、自分のリクエストと照合するだけではダメです。なぜなら、証明者は異なる値を使用して、それについて教えない可能性があるからです)。 しかし、マップを秘密にしておく必要がありますが、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.ts (opens in a new tab)と呼ばれます。

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

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

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

Zokrates自体と同様に、1つの関数のみをエクスポートします。これも非同期 (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 `
すべて表示

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

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

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

1// ゼロ知識検証用のキーを作成します。
2// 本番システムでは、セットアップセレモニーを使用することをお勧めします。
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)を使用するかもしれませんが、デモンストレーションにはこれで十分です。 ユーザーが証明者キーを知っていても問題ありません。それが真実でない限り、それを使って物事を証明することはできません。 エントロピー(2番目のパラメータ、"")を指定しているため、結果は常に同じになります。

注: 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}

computeWitness関数(https://zokrates.github.io/toolbox/zokrates_js.html#computewitnessartifacts-args-options)は実際にZokratesプログラムを実行します。 (opens in a new tab) これは2つのフィールドを持つ構造体を返します:JSON文字列としてのプログラムの出力であるoutput、および結果のゼロ知識証明を作成するために必要な情報であるwitnessです。 ここでは出力のみが必要です。

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

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

ゼロ知識証明には、公開入力(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}`])

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

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

generateProof (opens in a new tab)を使用して証明を返し、それを返します。

1const solidityVerifier = `
2 // マップサイズ: ${width} x ${height}
3 \n${zokrates.exportSolidityVerifier(verifierKey)}
4 `

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

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

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

セキュリティテスト

機能のバグはいずれ明らかになるため、セキュリティテストは重要です。 しかし、アプリケーションが安全でない場合、誰かが不正行為をして他人のリソースを手に入れてしまうまで、それが長期間隠されたままである可能性が高いです。

パーミッション

このゲームには特権を持つエンティティが1つ、サーバーがあります。 これは、ServerSystem (opens in a new tab)の関数を呼び出すことを許可された唯一のユーザーです。 cast (opens in a new tab)を使用して、許可された関数への呼び出しがサーバーアカウントとしてのみ許可されていることを確認できます。

サーバーの秘密鍵はsetupNetwork.tsにあります (opens 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 Dev Toolsを開き、Tablesをクリックして、app__VerifierAddressを選択することもできます。 アドレスがゼロでないことを確認してください。

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

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

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

同じSystem内のすべてのMUD関数は同じアクセス制御を通過するため、このテストで十分だと考えます。 そうでない場合は、ServerSystem (opens in a new tab)の他の関数を確認できます。

ゼロ知識の悪用

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

間違った答え

最も単純な可能性は、ゼロ知識証明で間違った答えを提供することです。 そのためには、zkDigの内部に入り、91行目を変更します (opens in a new tab)

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

これは、正しい答えに関係なく、常に1つの爆弾があると主張することを意味します。 このバージョンでプレイしてみてください。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. Zokratesをインストールします (opens in a new tab)

  2. Zokratesプログラムを含むdig.zokというファイルを作成します。 以下のコードは、元のマップサイズ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 // (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なのか?

Zokrates (opens in a new tab)は利用可能な唯一のゼロ知識ライブラリではありませんが、通常の命令型 (opens in a new tab)プログラミング言語に類似しており、ブール変数をサポートしています。

あなたのアプリケーションでは、要件が異なるため、Circum (opens in a new tab)またはCairo (opens in a new tab)を使用することを好むかもしれません。

Zokratesをいつコンパイルするか

このプログラムでは、サーバーが起動するたびに (opens in a new tab)Zokratesプログラムをコンパイルします。 これは明らかにリソースの無駄ですが、これはチュートリアルであり、単純さのために最適化されています。

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

検証者キーと証明者キーの作成

キーの作成 (opens in a new tab)は、特定の地雷原サイズに対して一度以上行う必要のない純粋な計算です。 繰り返しますが、単純さのために一度だけ行われます。

さらに、セットアップセレモニー (opens in a new tab)を使用することもできます。 セットアップセレモニーの利点は、ゼロ知識証明で不正行為をするためには、各参加者からのエントロピーまたはいくつかの中間結果が必要であることです。 少なくとも1人のセレモニー参加者が正直で、その情報を削除すれば、ゼロ知識証明は特定の攻撃から安全です。 しかし、情報がどこからでも削除されたことを確認する_メカニズムはありません_。 ゼロ知識証明が非常に重要な場合は、セットアップセレモニーに参加することをお勧めします。

ここでは、数十人の参加者がいたperpetual powers of tau (opens in a new tab)に依存しています。 おそらく十分に安全で、はるかに単純です。 また、キー作成中にエントロピーを追加しないため、ユーザーがゼロ知識構成を検証しやすくなります。

どこで検証するか

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

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

マップをTypeScriptまたはZokratesでフラット化するか?

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

しかし、TypeScriptでできたにもかかわらず、私たちはまだZokratesでマップをフラット化しています (opens in a new tab)。 その理由は、私の意見では、他の選択肢がもっと悪いからです。

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

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

  • Zokratesで2次元配列をフラット化します。 これが最も簡単な選択肢なので、私はそれを選びました。

マップの保存場所

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

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

結論:どのような条件下でこれが適切なテクニックですか?

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

  • 長期間実行されるゲーム上で述べたように、短いゲームでは、ゲームが終了したらステートを公開し、すべてを検証させることができます。 しかし、ゲームが長い時間または無期限にかかり、ステートを秘密にしておく必要がある場合、それは選択肢ではありません。

  • ある程度の中央集権化が許容される:ゼロ知識証明は、エンティティが結果を偽造していないという整合性を検証できます。 彼らができないことは、エンティティがまだ利用可能であり、メッセージに応答することを保証することです。 可用性も分散化する必要がある状況では、ゼロ知識証明は十分な解決策ではなく、マルチパーティ計算 (opens in a new tab)が必要です。

私の他の作品はこちらでご覧いただけます (opens in a new tab).

謝辞

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

残りの誤りは私の責任です。

最終更新: 2026年2月25日

このチュートリアルは役に立ちましたか?