シークレット状態にゼロ知識を使用する
ブロックチェーン上に秘密はありません。ブロックチェーンに投稿されたすべての情報は、誰でも読むことができます。ブロックチェーンは誰でも検証できることに基づいているため、これは不可欠です。しかし、ゲームはしばしば秘密の状態(シークレット状態)に依存します。たとえば、マインスイーパー (opens in a new tab)のゲームは、ブロック・エクスプローラーにアクセスしてマップを見るだけであれば、まったく意味を成しません。
最も簡単な解決策は、秘密の状態を保持するためにサーバーコンポーネントを使用することです。しかし、私たちがブロックチェーンを使用する理由は、ゲーム開発者による不正を防ぐためです。サーバーコンポーネントが誠実であることを保証する必要があります。サーバーは状態のハッシュを提供し、ゼロ知識証明を使用して、手の結果を計算するために使用された状態が正しいことを証明できます。
この記事を読むと、この種の秘密の状態を保持するサーバー、状態を表示するためのクライアント、およびその2つ間の通信のためのオンチェーンコンポーネントを作成する方法がわかります。主に使用するツールは以下の通りです:
| ツール | 目的 | 検証済みバージョン |
|---|---|---|
| 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マスにある地雷の数を知ることができます。
このアプリケーションは、キーバリュー型データベース (opens in a new tab)を使用してデータをオンチェーンに保存し、そのデータをオフチェーンのコンポーネントと自動的に同期できるフレームワークであるMUD (opens in a new tab)を使用して書かれています。同期に加えて、MUDを使用すると、アクセス制御を提供したり、他のユーザーがパーミッションレスでアプリケーションを拡張 (opens in a new tab)したりすることが容易になります。
マインスイーパーの例を実行する
マインスイーパーの例を実行するには:
-
前提条件がインストールされている (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)。 -
リポジトリをクローンします。
git clone https://github.com/qbzzt/20240901-secret-state.git -
パッケージをインストールします。
cd 20240901-secret-state/ pnpm install npm install -g mprocsFoundryが
pnpm installの一部としてインストールされた場合は、コマンドラインシェルを再起動する必要があります。 -
コントラクトをコンパイルします。
cd packages/contracts forge build cd ../.. -
プログラム(anvil (opens in a new tab)ブロックチェーンを含む)を起動して待機します。
mprocs起動には時間がかかることに注意してください。進行状況を確認するには、まず下矢印を使用して_contracts_タブにスクロールし、MUDコントラクトがデプロイされていることを確認します。_Waiting for file changes…_というメッセージが表示されたら、コントラクトのデプロイは完了しており、その後の進行は_server_タブで行われます。そこで、_Verifier address: 0x...._というメッセージが表示されるまで待機します。
このステップが成功すると、
mprocs画面が表示され、左側にさまざまなプロセス、右側に現在選択されているプロセスのコンソール出力が表示されます。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
-
-
これで、クライアント (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を実行すると、以下のステップが発生します。
-
mprocs(opens in a new tab)は4つのコンポーネントを実行します。- ローカルブロックチェーンを実行するAnvil (opens in a new tab)
- MUDのコントラクトをコンパイル(必要に応じて)およびデプロイするコントラクト (opens in a new tab)
- Vite (opens in a new tab)を実行してUIとクライアントコードをWebブラウザに提供するクライアント (opens in a new tab)
- サーバーアクションを実行するサーバー (opens in a new tab)
-
contractsパッケージはMUDコントラクトをデプロイし、その後PostDeploy.s.solスクリプト (opens in a new tab)を実行します。このスクリプトは設定を行います。GitHubのコードは、8つの地雷が含まれる10x5の地雷原 (opens in a new tab)を指定しています。 -
サーバー (opens in a new tab)はMUDのセットアップ (opens in a new tab)から開始します。とりわけ、これによりデータ同期がアクティブになり、関連するテーブルのコピーがサーバーのメモリ内に存在することになります。
-
サーバーは、
Configurationテーブルが変更されたとき (opens in a new tab)に実行される関数をサブスクライブします。この関数 (opens in a new tab)は、PostDeploy.s.solが実行されてテーブルが変更された後に呼び出されます。 -
サーバーの初期化関数が設定を取得すると、
zkFunctionsを呼び出して (opens in a new tab)、サーバーのゼロ知識部分を初期化します。ゼロ知識関数は地雷原の幅と高さを定数として持つ必要があるため、設定を取得するまでこれは実行できません。 -
サーバーのゼロ知識部分が初期化された後、次のステップはゼロ知識検証コントラクトをブロックチェーンにデプロイ (opens in a new tab)し、MUDで検証者のアドレスを設定することです。
-
最後に、プレイヤーが新しいゲームの開始 (opens in a new tab)または既存のゲームでの掘削 (opens in a new tab)をリクエストしたときにわかるように、更新をサブスクライブします。
新しいゲーム
プレイヤーが新しいゲームをリクエストしたときに起こることは以下の通りです。
-
このプレイヤーの進行中のゲームがない場合、または進行中のゲームがあってもgameIdがゼロの場合、クライアントは新しいゲームボタン (opens in a new tab)を表示します。ユーザーがこのボタンを押すと、Reactは
newGame関数を実行します (opens in a new tab)。 -
newGame(opens in a new tab)はSystem呼び出しです。MUDでは、すべての呼び出しはWorldコントラクトを経由してルーティングされ、ほとんどの場合<namespace>__<function name>を呼び出します。この場合、呼び出しはapp__newGameに対して行われ、MUDはそれをGameSystem内のnewGame(opens in a new tab)にルーティングします。 -
オンチェーン関数は、プレイヤーが進行中のゲームを持っていないことを確認し、ない場合はリクエストを
PendingGameテーブルに追加します (opens in a new tab)。 -
サーバーは
PendingGameの変更を検出し、サブスクライブされた関数を実行します (opens in a new tab)。この関数はnewGame(opens in a new tab)を呼び出し、それがさらにcreateGame(opens in a new tab)を呼び出します。 -
createGameが最初に行うことは、適切な数の地雷を持つランダムなマップを作成する (opens in a new tab)ことです。次に、makeMapBorders(opens in a new tab)を呼び出して、Zokratesに必要な空白の境界線を持つマップを作成します。最後に、createGameはcalculateMapHashを呼び出してマップのハッシュを取得し、これをゲームIDとして使用します。 -
newGame関数は、新しいゲームをgamesInProgressに追加します。 -
サーバーが最後に行うことは、オンチェーンの
app__newGameResponse(opens in a new tab)を呼び出すことです。この関数は、アクセス制御を有効にするために、別のSystemであるServerSystem(opens in a new tab)にあります。アクセス制御はMUD設定ファイル (opens in a new tab)のmud.config.ts(opens in a new tab)で定義されています。アクセスリストは、単一のアドレスのみが
Systemを呼び出すことを許可します。これにより、サーバー関数へのアクセスが単一のアドレスに制限されるため、誰もサーバーになりすますことはできません。 -
オンチェーンコンポーネントは関連するテーブルを更新します。
PlayerGameにゲームを作成します。GamePlayerに逆マッピングを設定します。PendingGameからリクエストを削除します。
-
サーバーは
PendingGameの変更を識別しますが、wantsGame(opens in a new tab)がfalseであるため、何も行いません。 -
クライアントでは、
gameRecord(opens in a new tab)がプレイヤーのアドレスのPlayerGameエントリに設定されます。PlayerGameが変更されると、gameRecordも変更されます。 -
gameRecordに値があり、ゲームの勝敗がまだ決まっていない場合、クライアントはマップを表示します (opens in a new tab)。
掘る
-
プレイヤーがマップセルのボタンをクリックする (opens in a new tab)と、
dig関数 (opens in a new tab)が呼び出されます。この関数はオンチェーンのdig(opens in a new tab)を呼び出します。 -
オンチェーンコンポーネントはいくつかの健全性チェックを実行し (opens in a new tab)、成功した場合は掘削リクエストを
PendingDig(opens in a new tab)に追加します。 -
サーバーは
PendingDigの変更を検出します (opens in a new tab)。それが有効な場合 (opens in a new tab)、ゼロ知識コードを呼び出して (opens in a new tab)(後述)、結果とそれが有効であることの証明の両方を生成します。 -
サーバー (opens in a new tab)はオンチェーンの
digResponse(opens in a new tab)を呼び出します。 -
digResponseは2つのことを行います。まず、ゼロ知識証明 (opens in a new tab)をチェックします。次に、証明が確認された場合、processDigResult(opens in a new tab)を呼び出して実際に結果を処理します。 -
processDigResultはゲームに負けた (opens in a new tab)か勝った (opens in a new tab)かを確認し、オンチェーンマップであるMapを更新します (opens in a new tab)。 -
クライアントは自動的に更新を取得し、プレイヤーに表示されるマップを更新し (opens in a new tab)、該当する場合はプレイヤーに勝敗を伝えます。
Zokratesの使用
上で説明したフローでは、ゼロ知識の部分をブラックボックスとして扱い、スキップしました。ここでは、それを開いて、そのコードがどのように書かれているかを見てみましょう。
マップのハッシュ化
このJavaScriptコード (opens in a new tab)を使用して、私たちが使用するZokratesのハッシュ関数であるPoseidon (opens in a new tab)を実装することができます。しかし、これは高速ですが、単にZokratesのハッシュ関数を使用するよりも複雑になります。これはチュートリアルであるため、コードはパフォーマンスではなくシンプルさを重視して最適化されています。したがって、マップのハッシュを計算するだけのプログラム(hash)と、マップ上の場所を掘った結果のゼロ知識証明を実際に作成するプログラム(dig)の2つの異なるZokratesプログラムが必要です。
ハッシュ関数
これはマップのハッシュを計算する関数です。このコードを1行ずつ見ていきましょう。
import "hashes/poseidon/poseidon.zok" as poseidon;
import "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に変換します。
def hashMap(bool[${width+2}][${height+2}] map) -> field {
この行は関数定義の開始です。hashMapは、mapという単一のパラメータ(2次元のbool(ean)配列)を受け取ります。マップのサイズがwidth+2×height+2である理由は、後述します。
Zokratesプログラムはこのアプリケーション内でテンプレート文字列 (opens in a new tab)として保存されているため、${width+2}と${height+2}を使用できます。${と}の間のコードはJavaScriptによって評価され、この方法でプログラムを異なるマップサイズに使用できます。マップパラメータの周囲には爆弾のない1マス分の境界線があるため、幅と高さに2を足す必要があります。
戻り値は、ハッシュを含むfieldです。
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)を意味します。
u32 mut counter = 0;
また、map1dにすでに入力したビットとまだ入力していないビットを区別するためのカウンターも必要です。
for u32 x in 0..${width+2} {
これがZokratesでforループ (opens in a new tab)を宣言する方法です。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 };
}
なぜマップの境界線が必要なのか
ゼロ知識証明は算術回路 (opens in a new tab)を使用しますが、これにはif文に相当する簡単なものがありません。代わりに、条件演算子 (opens in a new tab)に相当するものを使用します。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コード (opens in a new tab)内で使用します。
Zokratesの定義を含むライブラリはzero-knowledge.ts (opens in a new tab)と呼ばれます。
import { initialize as zokratesInitialize } from "zokrates-js"
ZokratesのJavaScriptバインディング (opens in a new tab)をインポートします。すべてのZokrates定義に解決されるプロミスを返すため、initialize (opens in a new tab)関数のみが必要です。
export const zkFunctions = async (width: number, height: number) : Promise<any> => {
Zokrates自体と同様に、ここでも1つの関数のみをエクスポートし、それも非同期 (opens in a new tab)です。最終的に戻るときに、後述するいくつかの関数を提供します。
const zokrates = await zokratesInitialize()
Zokratesを初期化し、ライブラリから必要なものをすべて取得します。
const hashFragment = `
import "utils/pack/bool/pack128.zok" as pack128;
import "hashes/poseidon/poseidon.zok" as poseidon;
.
.
.
}
`
const hashProgram = `
${hashFragment}
.
.
.
`
const digProgram = `
${hashFragment}
.
.
.
`
次に、上で見たハッシュ関数と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
本番システムでは、より複雑なセットアップ・セレモニー (opens in a new tab)を使用するかもしれませんが、デモンストレーションとしてはこれで十分です。ユーザーがプルーバーの鍵を知ることができるのは問題ではありません。真実でない限り、それを使用して証明することはできないからです。エントロピー(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 (opens in a new tab)関数は実際にZokratesプログラムを実行します。これは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進数のマーカーを追加します。
// 採掘し、結果のゼロ知識証明を返します。
// (サーバーサイドコード)
ゼロ知識証明には、公開入力(xとy)と結果(マップのハッシュと爆弾の数)が含まれます。
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 (opens in a new tab)を使用して証明を返します。
const solidityVerifier = `
// Map size: ${width} x ${height}
\n${zokrates.exportSolidityVerifier(verifierKey)}
`
Solidityの検証者。ブロックチェーンにデプロイし、digCompiled.programによって生成された証明を検証するために使用できるスマート・コントラクトです。
return {
zkDig,
calculateMapHash,
solidityVerifier,
}
}
最後に、他のコードが必要とする可能性のあるすべてのものを返します。
## セキュリティ・テスト
機能のバグはいずれ明らかになるため、セキュリティ・テストは重要です。しかし、アプリケーションが安全でない場合、誰かが不正行為を行い、他人のリソースを奪って逃げることで発覚するまで、長期間隠れたままになる可能性が高いです。
### パーミッション
このゲームには、サーバーという1つの特権エンティティが存在します。これは、[`ServerSystem`](https://github.com/qbzzt/20240901-secret-state/blob/main/packages/contracts/src/systems/ServerSystem.sol)内の関数を呼び出すことが許可されている唯一のユーザーです。[`cast`](https://book.getfoundry.sh/cast/)を使用して、パーミッションドな関数への呼び出しがサーバーのアカウントとしてのみ許可されていることを検証できます。
[サーバーの秘密鍵は`setupNetwork.ts`にあります](https://github.com/qbzzt/20240901-secret-state/blob/main/packages/server/src/mud/setupNetwork.ts#L52)。
1. `anvil`(ブロックチェーン)を実行しているコンピュータで、これらの環境変数を設定します。
```sh copy
WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b
UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
-
castを使用して、検証者のアドレスを未承認のアドレスとして設定しようと試みます。cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $UNAUTHORIZED_KEYcastが失敗を報告するだけでなく、ブラウザ上のゲームでMUD Dev Toolsを開き、Tablesをクリックしてapp__VerifierAddressを選択することもできます。アドレスがゼロではないことを確認してください。 -
検証者のアドレスをサーバーのアドレスとして設定します。
cast 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)。
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の場合、これは少し複雑になります (opens in a new tab)が、それほどではありません。
ゼロ知識の場合、これはより困難になります。検証者にはいくつかの定数が含まれており、それらに対して計算を実行します。これでは、何が証明されているのかわかりません。
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プログラムを利用可能にし、少なくとも一部のユーザーが適切な検証鍵を使用して自分でコンパイルすることです。
これを行うには:
-
Zokratesプログラムを含むファイル
dig.zokを作成します。以下のコードは、元のマップサイズである10x5を維持していることを前提としています。import "utils/pack/bool/pack128.zok" as pack128; import "hashes/poseidon/poseidon.zok" as poseidon; def hashMap(bool[12][7] map) -> field { bool[512] mut map1d = [false; 512]; u32 mut counter = 0; for u32 x in 0..12 { for u32 y in 0..7 { map1d[counter] = map[x][y]; counter = counter+1; } } field[4] hashMe = [ pack128(map1d[0..128]), pack128(map1d[128..256]), pack128(map1d[256..384]), pack128(map1d[384..512]) ]; return poseidon(hashMe); } // 位置(x,y)にある地雷の数 def map2mineCount(bool[12][7] map, u32 x, u32 y) -> u8 { return if map[x+1][y+1] { 1 } else { 0 }; } def main(private bool[12][7] map, u32 x, u32 y) -> (field, u8) { return (hashMap(map) , if map2mineCount(map, x, y) > 0 { 0xFF } else { 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) } ); } -
Zokratesコードをコンパイルし、検証鍵を作成します。検証鍵は、元のサーバーで使用されたのと同じエントロピー(この場合は空の文字列 (opens in a new tab))を使用して作成する必要があります。
zokrates compile --input dig.zok zokrates setup -e "" -
自分でSolidityの検証者を作成し、ブロックチェーン上のものと機能的に同一であることを検証します(サーバーはコメントを追加しますが、それは重要ではありません)。
zokrates export-verifier diff verifier.sol ~/20240901-secret-state/packages/contracts/src/verifier.sol
設計上の決定事項
十分に複雑なアプリケーションでは、トレードオフを必要とする競合する設計目標が存在します。いくつかのトレードオフと、現在のソリューションが他の選択肢よりも好ましい理由を見てみましょう。
なぜゼロ知識なのか
マインスイーパーの場合、実際にはゼロ知識は必要ありません。サーバーは常にマップを保持し、ゲームが終了したときにすべてを公開するだけで済みます。そして、ゲームの終了時に、スマート・コントラクトがマップのハッシュを計算し、それが一致するかどうかを検証し、一致しない場合はサーバーにペナルティを科すか、ゲームを完全に無効にすることができます。
このよりシンプルなソリューションを使用しなかった理由は、明確に定義された終了状態を持つ短いゲームでしか機能しないためです。ゲームが無限に続く可能性がある場合(自律型世界(autonomous worlds) (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)も純粋な計算であり、特定の地雷原のサイズに対して複数回行う必要はありません。ここでも、シンプルにするために1回だけ行われます。
さらに、セットアップセレモニー (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)がやや複雑になるため、チュートリアルとしてはパフォーマンスの向上に見合わないと判断しました。 -
1次元配列と2次元配列の両方をZokratesに送信する。しかし、このソリューションでは何も得られません。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に関する私の誤解のいくつかを解いてくれました。
残っている誤りはすべて私の責任です。
