プライバシーを保護するアプリ固有のPlasmaを作成する
はじめに
ロールアップとは対照的に、Plasmaは完全性のためにイーサリアムメインネットを使用しますが、可用性のためには使用しません。 この記事では、Plasmaのように動作するアプリケーションを作成します。イーサリアムは完全性(不正な変更がないこと)を保証しますが、可用性(中央集権的なコンポーネントがダウンしてシステム全体が無効になる可能性があること)は保証しません。
ここで作成するアプリケーションは、プライバシーを保護する銀行です。 異なるアドレスは残高を持つアカウントを所有し、他のアカウントにお金(ETH)を送ることができます。 銀行は状態(アカウントとその残高)とトランザクションのハッシュをポストしますが、実際の残高はプライベートに保つことができるオフチェーンに保持します。
設計
これは本番環境対応のシステムではなく、教育用ツールです。 そのため、いくつかの単純化された仮定のもとに書かれています。
-
固定アカウントプール。 特定のアカウント数があり、各アカウントはあらかじめ決められたアドレスに属します。 ゼロ知識証明では可変サイズのデータ構造を扱うのが難しいため、これによりシステムが大幅に簡素化されます。 本番環境対応のシステムでは、状態ハッシュとしてMerkleルートを使用し、必要な残高のためにMerkle証明を提供できます。
-
メモリストレージ。 本番システムでは、再起動に備えてすべてのアカウント残高をディスクに書き込む必要があります。 ここでは、情報が単に失われても問題ありません。
-
送金のみ。 本番システムでは、資産を銀行に預け入れたり引き出したりする方法が必要になります。 しかし、ここでの目的は概念を説明することだけなので、この銀行は送金に限定されています。
ゼロ知識証明
根本的なレベルでは、ゼロ知識証明は、証明者が何らかの公開データ_Datapublic_と_Dataprivate_の間に_Relationship_という関係が存在するような、何らかのデータ_Dataprivate_を知っていることを示します。 検証者は、_Relationship_と_Datapublic_を知っています。
プライバシーを保護するためには、状態とトランザクションがプライベートである必要があります。 しかし、完全性を確保するためには、状態の暗号論的ハッシュ (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)を防止するために使用します。
コンポーネント
このシステムには2つのコンポーネントが必要です。
- トランザクションを受信し、処理し、ゼロ知識証明とともにハッシュをチェーンにポストする_サーバー_。
- ハッシュを保存し、ゼロ知識証明を検証して状態遷移が正当であることを保証する_スマートコントラクト_。
データと制御フロー
これらは、さまざまなコンポーネントが通信して、あるアカウントから別のアカウントに送金する方法です。
-
ウェブブラウザは、署名者のアカウントから別のアカウントへの送金を要求する署名済みトランザクションを送信します。
-
サーバーはトランザクションが有効であることを検証します。
- 署名者は銀行に十分な残高のあるアカウントを持っている。
- 受信者は銀行にアカウントを持っている。
-
サーバーは、送金された金額を署名者の残高から差し引き、受信者の残高に加算することで、新しい状態を計算します。
-
サーバーは、状態の変更が有効であることを示すゼロ知識証明を計算します。
-
サーバーは、以下を含むトランザクションをイーサリアムに送信します。
- 新しい状態ハッシュ
- トランザクションハッシュ (トランザクションの送信者が処理されたことを知るため)
- 新しい状態への移行が有効であることを証明するゼロ知識証明
-
スマートコントラクトはゼロ知識証明を検証します。
-
ゼロ知識証明が確認された場合、スマートコントラクトは次のアクションを実行します。
- 現在の状態ハッシュを新しい状態ハッシュに更新する
- 新しい状態ハッシュとトランザクションハッシュを含むログエントリを出力する
ツール
クライアント側のコードには、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使用するブロックチェーンは、Foundry (opens in a new tab)の一部であるローカルテスト用ブロックチェーンのanvilです。
実装
これは複雑なシステムなので、段階的に実装していきます。
ステージ1 - 手動のゼロ知識
最初のステージでは、ブラウザでトランザクションに署名し、その情報を手動でゼロ知識証明に提供します。 ゼロ知識コードは、その情報をserver/noir/Prover.tomlで受け取ることを想定しています(こちら (opens in a new tab)にドキュメントがあります)。
動作を確認するには:
-
Node (opens in a new tab)とNoir (opens in a new tab)がインストールされていることを確認してください。 できれば、macOS、Linux、WSL (opens in a new tab)などのUNIXシステムにインストールしてください。
-
ステージ1のコードをダウンロードし、Webサーバーを起動してクライアントコードを提供します。
1git clone https://github.com/qbzzt/250911-zk-bank.git -b 01-manual-zk2cd 250911-zk-bank3cd client4npm install5npm run devここでWebサーバーが必要な理由は、特定の種類の不正行為を防ぐため、多くのウォレット(MetaMaskなど)がディスクから直接提供されるファイルを受け入れないためです。
-
ウォレットでブラウザを開きます。
-
ウォレットで新しいパスフレーズを入力します。 これにより既存のパスフレーズが削除されるため、必ずバックアップを取っておいてください。
パスフレーズは
test test test test test test test test test test test junkで、これはanvilのデフォルトのテスト用パスフレーズです。 -
クライアント側のコード (opens in a new tab)にアクセスします。
-
ウォレットに接続し、宛先アカウントと金額を選択します。
-
「Sign (署名)」 をクリックし、トランザクションに署名します。
-
Prover.tomlの見出しの下にテキストがあります。
server/noir/Prover.tomlをそのテキストに置き換えます。 -
ゼロ知識証明を実行します。
1cd ../server/noir2nargo execute出力は以下のようになります
1ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute23[zkBank] Circuit witness successfully solved4[zkBank] Witness saved to target/zkBank.gz5[zkBank] Circuit output: (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85) -
最後の2つの値をウェブブラウザに表示されるハッシュと比較して、メッセージが正しくハッシュ化されているかを確認します。
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"]これら3つのパラメータは固定サイズのバイト配列です。
1[[accounts]]2address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"3balance=100_0004nonce=056[[accounts]]7address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"8balance=100_0009nonce=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フック (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 signature4 })公開鍵を取得 (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}"23pubKeyX=${hexToArray(pubKey.slice(4,4+2*32))}4pubKeyY=${hexToArray(pubKey.slice(4+2*32))}Viemは公開鍵を65バイトの16進数文字列として提供します。 最初のバイトはバージョンマーカーである0x04です。 これに続いて、公開鍵のxに32バイト、公開鍵のyに32バイトが続きます。
しかし、Noirはこの情報をxとyの2つのバイト配列として受け取ることを想定しています。 ゼロ知識証明の一部としてではなく、ここでクライアント上で解析する方が簡単です。
これは一般的にゼロ知識の良い実践であることに注意してください。 ゼロ知識証明内のコードは高価であるため、ゼロ知識証明の外で実行できる処理は、ゼロ知識証明の外で_実行されるべき_です。
1signature=${hexToArray(signature.slice(2,-2))}署名も65バイトの16進数文字列として提供されます。 しかし、最後のバイトは公開鍵を復元するためにのみ必要です。 公開鍵はすでにNoirコードに提供されるため、署名を検証するために必要ではなく、Noirコードもそれを要求しません。
1${accounts.map(accountInProverToml).reduce((a,b) => a+b, "")}2`アカウントを提供します。
1 setProverToml(proverToml)2 }34 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;これら2つの関数は外部ライブラリで、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>という名前のデータ型は、そのビット数の符号なし整数です。 サポートされている型はu8、u16、u32、u64、u128のみです。
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: u326}送金トランザクションで保存する情報です。
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 ];配列の最初の値はアカウントアドレスです。 2番目の値には、残高とノンス (nonce)の両方が含まれています。 .into()の呼び出しは、数値を必要なデータ型に変更します。 account.nonceはu32値ですが、u128値であるaccount.balance « 32に加算するには、u128である必要があります。 それが最初の.into()です。 2番目のものは、u128の結果をFieldに変換して、配列に収まるようにします。
1 flat2}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 }67 flat8}910fn 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;34 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");23 account4}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);34 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} does not have {txnAmount} finney");34 assert (accounts[from].nonce == txn.nonce,5 f"Transaction has nonce {txnNonce}, but the account is expected to use {accountNonce}");これらは、トランザクションを無効にする可能性のある2つの条件です。
1 let mut newAccounts = accounts;23 newAccounts[from].balance -= txn.amount;4 newAccounts[from].nonce += 1;5 newAccounts[to].balance += txn.amount;67 newAccounts8}新しいアカウント配列を作成して返します。
1fn readAddress(messageBytes: [u8; MESSAGE_LENGTH]) -> Fieldこの関数は、メッセージからアドレスを読み取ります。
1{2 let mut result : Field = 0;34 for i in 7..47 {アドレスは常に20バイト(別名 40桁の16進数)で、7文字目から始まります。
1 result *= 0x10;2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-93 result += (messageBytes[i]-48).into();4 }5 if messageBytes[i] >= 65 & messageBytes[i] <= 70 { // A-F6 result += (messageBytes[i]-65+10).into()7 }8 if messageBytes[i] >= 97 & messageBytes[i] <= 102 { // a-f9 result += (messageBytes[i]-97+10).into()10 } 11 } 1213 result14}1516fn 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の1000分の1)の量です。 2番目の数字はノンス (nonce)です。 それらの間のテキストは無視されます。
1 for i in 48..MESSAGE_LENGTH {2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-93 let digit = (messageBytes[i]-48);45 if stillReadingAmount {6 amount = amount*10 + digit.into();7 }89 if lookingForNonce { // We just found it10 stillReadingNonce = true;11 lookingForNonce = false;12 }1314 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 }2728 (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();56 txn.to = readAddress(messageBytes);7 let (amount, nonce) = readAmountAndNonce(messageBytes);8 txn.amount = amount;9 txn.nonce = nonce;1011 txn12}すべて表示この関数はメッセージをバイトに変換し、次に金額をTransferTxnに変換します。
1// ViemのhashMessageと同等2// https://viem.sh/docs/utilities/hashMessage#hashmessage3fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {アカウントはゼロ知識証明の内部でのみハッシュ化されるため、Pedersenハッシュを使用することができました。 しかし、このコードでは、ブラウザによって生成されたメッセージの署名をチェックする必要があります。 そのためには、EIP 191 (opens in a new tab)のイーサリアム署名形式に従う必要があります。 これは、標準のプレフィックス、ASCIIでのメッセージ長、およびメッセージ自体を含む結合されたバッファを作成し、それをハッシュ化するためにイーサリアム標準のkeccak256を使用する必要があることを意味します。
1 // ASCII prefix2 let prefix_bytes = [3 0x19, // \x194 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 }56 let messageBytes : [u8; MESSAGE_LENGTH] = message.as_bytes();78 if MESSAGE_LENGTH <= 9 {9 for i in 0..1 {10 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];11 }1213 for i in 0..MESSAGE_LENGTH {14 buffer[i+26+1] = messageBytes[i];15 }16 }1718 if MESSAGE_LENGTH >= 10 & MESSAGE_LENGTH <= 99 {19 for i in 0..2 {20 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];21 }2223 for i in 0..MESSAGE_LENGTH {24 buffer[i+26+2] = messageBytes[i];25 }26 }2728 if MESSAGE_LENGTH >= 100 {29 for i in 0..3 {30 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];31 }3233 for i in 0..MESSAGE_LENGTH {34 buffer[i+26+3] = messageBytes[i];35 }36 }3738 assert(MESSAGE_LENGTH < 1000, "Messages whose length is over three digits are not supported");すべて表示メッセージ長が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{この関数は署名を検証し、それにはメッセージハッシュが必要です。 そして、署名したアドレスとメッセージハッシュを提供してくれます。 メッセージハッシュは2つのField値で提供されます。これは、バイト配列よりもプログラムの残りの部分で使いやすいためです。
フィールドの計算は大きな数を法 (opens in a new tab)として行われますが、その数は通常256ビット未満であるため(そうでなければEVMでこれらの計算を実行するのが難しくなるため)、2つのField値を使用する必要があります。
1 let hash = hashMessage(message);23 let mut (hash1, hash2) = (0,0);45 for i in 0..16 {6 hash1 = hash1*256 + hash[31-i].into();7 hash2 = hash2*256 + hash[15-i].into();8 }hash1とhash2を可変変数として指定し、ハッシュをバイトごとに書き込みます。
1 (2 ecrecover::ecrecover(pubKeyX, pubKeyY, signature, hash), これはSolidityのecrecover (opens in a new tab)に似ていますが、2つの重要な違いがあります。
- 署名が有効でない場合、呼び出しは
assertに失敗し、プログラムは中止されます。 - 公開鍵は署名とハッシュから復元できますが、これは外部で実行できる処理であり、したがってゼロ知識証明の内部で行う価値はありません。 ここで誰かが私たちをだまそうとすると、署名の検証は失敗します。
1 hash1,2 hash23 )4}56fn 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 array14 Field, // Hash of new accounts array15 Field, // First 16 bytes of message hash16 Field, // Last 16 bytes of message hash17 )すべて表示最後に、main関数に到達します。 アカウントのハッシュが古い値から新しい値に正当に変更されるトランザクションがあることを証明する必要があります。 また、特定のトランザクションハッシュを持っていることを証明する必要もあります。そうすることで、送信した人が自分のトランザクションが処理されたことを知ることができます。
1{2 let mut txn = readTransferTxn(message);txnは可変である必要があります。なぜなら、fromアドレスはメッセージからではなく、署名から読み取るからです。
1 let (fromAddress, txnHash1, txnHash2) = signatureToAddressAndHash(2 message,3 pubKeyX,4 pubKeyY,5 signature);67 txn.from = fromAddress;89 let newAccounts = apply_transfer_txn(accounts, txn);1011 (12 hash_accounts(accounts),13 hash_accounts(newAccounts),14 txnHash1,15 txnHash216 )17}すべて表示ステージ2 - サーバーの追加
第2ステージでは、ブラウザからの送金トランザクションを受信して実装するサーバーを追加します。
動作を確認するには:
-
Viteが実行中の場合は停止します。
-
サーバーを含むブランチをダウンロードし、必要なモジュールがすべて揃っていることを確認します。
1git checkout 02-add-server2cd client3npm install4cd ../server5npm installNoirコードをコンパイルする必要はありません。ステージ1で使用したコードと同じです。
-
サーバーを起動します。
1npm run start -
別のコマンドラインウィンドウでViteを実行して、ブラウザコードを提供します。
1cd client2npm run dev -
クライアントコード(http://localhost:5173 (opens in a new tab))にアクセスします
-
トランザクションを発行する前に、ノンス (nonce)と送信できる金額を知る必要があります。 この情報を取得するには、**「Update account data (アカウントデータを更新)」**をクリックしてメッセージに署名します。
ここでジレンマがあります。 一方で、再利用可能なメッセージ(リプレイ攻撃 (opens in a new tab))に署名したくないため、そもそもノンス (nonce)が必要です。 しかし、まだノンス (nonce)がありません。 解決策は、一度しか使用できず、両側で既に持っているノンス (nonce)、例えば現在時刻などを選択することです。
この解決策の問題は、時刻が完全に同期していない可能性があることです。 そこで代わりに、毎分変わる値に署名します。 これは、リプレイ攻撃に対する脆弱性のウィンドウが最大1分であることを意味します。 本番環境では署名されたリクエストがTLSで保護されること、またトンネルの反対側であるサーバーは既に残高とノンス (nonce)を開示できる(動作するためにそれらを知る必要がある)ことを考えると、これは許容できるリスクです。
-
ブラウザが残高とノンス (nonce)を取得すると、送金フォームが表示されます。 宛先アドレスと金額を選択し、**「Transfer (送金)」**をクリックします。 このリクエストに署名します。
-
送金を確認するには、**「Update account data (アカウントデータを更新)」**するか、サーバーを実行しているウィンドウを確認します。 サーバーは状態が変更されるたびにログを記録します。
1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start23> server@1.0.0 start4> node --experimental-json-modules index.mjs56Listening on port 30007Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 processed8New 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 processed15New 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 processed22New 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 request2const accountInformation = async signature => {3 const fromAddress = await recoverAddress({4 hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),5 signature6 })アカウント情報を提供するには、署名のみが必要です。 その理由は、メッセージがどうなるかを既に知っており、したがってメッセージハッシュも知っているからです。
1const processMessage = async (message, signature) => {メッセージを処理し、エンコードされたトランザクションを実行します。
1 // Get the public key2 const pubKey = await recoverPublicKey({3 hash,4 signature5 })サーバーでJavaScriptを実行するようになったので、クライアントではなくサーバーで公開鍵を取得できます。
1 let noirResult2 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: Accounts9 })すべて表示noir.executeはNoirプログラムを実行します。 パラメータは、Prover.toml (opens in a new tab)で提供されるものと同等です。 Viemが行うように、長い値は単一の16進数値(0x60A7)ではなく、16進文字列の配列(["0x60", "0xA7"])として提供されることに注意してください。
1 } catch (err) {2 console.log(`Noir error: ${err}`)3 throw Error("Invalid transaction, not processed")4 }エラーが発生した場合は、それをキャッチし、簡略化されたバージョンをクライアントにリレーします。
1 Accounts[fromAccountNumber].nonce++2 Accounts[fromAccountNumber].balance -= amount3 Accounts[toAccountNumber].balance += amountトランザクションを適用します。 Noirコードでは既に実行しましたが、そこから結果を抽出するよりもここで再度実行する方が簡単です。
1let Accounts = [2 {3 address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",4 balance: 5000,5 nonce: 0,6 },初期のAccounts構造体。
ステージ3 - イーサリアムスマートコントラクト
-
サーバーとクライアントプロセスを停止します。
-
スマートコントラクトを含むブランチをダウンロードし、必要なモジュールがすべて揃っていることを確認します。
1git checkout 03-smart-contracts2cd client3npm install4cd ../server5npm install -
別のコマンドラインウィンドウで
anvilを実行します。 -
検証キーとSolidityベリファイアを生成し、ベリファイアコードをSolidityプロジェクトにコピーします。
1cd noir2bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak3bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol4cp target/Verifier.sol ../../smart-contracts/src -
スマートコントラクトに移動し、
anvilブロックチェーンを使用するように環境変数を設定します。1cd ../../smart-contracts2export ETH_RPC_URL=http://localhost:85453ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -
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 -
ZkBankコントラクトをデプロイします。1ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'`2echo $ZKBANK_ADDRESS0x199..67bの値は、Accountsの初期状態のPedersonハッシュです。server/index.mjsでこの初期状態を変更した場合、トランザクションを実行してゼロ知識証明によって報告される初期ハッシュを確認できます。 -
サーバーを実行します。
1cd ../server2npm run start -
別のコマンドラインウィンドウでクライアントを実行します。
1cd client2npm run dev -
いくつかのトランザクションを実行します。
-
状態がオンチェーンで変更されたことを確認するには、サーバープロセスを再起動します。 トランザクションの元のハッシュ値がオンチェーンに保存されているハッシュ値と異なるため、
ZkBankがトランザクションを受け入れなくなったことを確認してください。これは予想されるエラーの種類です。
1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start23> server@1.0.0 start4> node --experimental-json-modules index.mjs56Listening on port 30007Verification error: ContractFunctionExecutionError: The contract function "processTransaction" reverted with the following reason:8Wrong old state hash910Contract Call:11 address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F051212 function: processTransaction(bytes _proof, bytes32[] _publicInputs)13 args: (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000)すべて表示
server/index.mjs
このファイルの変更点は、主に実際の証明を作成し、オンチェーンで送信することに関連しています。
1import { exec } from 'child_process'2import util from 'util'34const 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のデフォルトの事前資金提供アカウントの1つです。
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, "")証明はField値のJSON配列であり、それぞれが16進数値として表されます。 ただし、トランザクションでは単一のbytes値として送信する必要があり、Viemはこれを大きな16進数文字列で表します。 ここでは、すべての値を連結し、すべての0xを削除し、最後に1つ追加することで形式を変更します。
1 await execPromise(`rm -r ${fname} ${fileID}`)23 return proof4}クリーンアップして証明を返します。
1const processMessage = async (message, signature) => {2 .3 .4 .56 const publicFields = noirResult.returnValue.map(x=>'0x' + x.slice(2).padStart(64, "0"))公開フィールドは32バイト値の配列である必要があります。 ただし、トランザクションハッシュを2つのField値に分割する必要があったため、16バイト値として表示されます。 ここでは、Viemが実際には32バイトであることを理解できるようにゼロを追加します。
1 const proof = await generateProof(noirResult.witness, `${fromAddress}-${nonce}`)各アドレスは各ノンス (nonce)を一度しか使用しないため、fromAddressとnonceの組み合わせをウィットネスファイルと出力ディレクトリの一意の識別子として使用できます。
1 try {2 await zkBank.write.processTransaction([3 proof, publicFields])4 } catch (err) {5 console.log(`Verification error: ${err}`)6 throw Error("Can't verify the transaction onchain")7 }8 .9 .10 .11}すべて表示トランザクションをチェーンに送信します。
smart-contracts/src/ZkBank.sol
これは、トランザクションを受け取るオンチェーンコードです。
1// SPDX-License-Identifier: MIT23pragma solidity >=0.8.21;45import {HonkVerifier} from "./Verifier.sol";67contract ZkBank {8 HonkVerifier immutable myVerifier;9 bytes32 currentStateHash;1011 constructor(address _verifierAddress, bytes32 _initialStateHash) {12 currentStateHash = _initialStateHash;13 myVerifier = HonkVerifier(_verifierAddress);14 }すべて表示オンチェーンコードは、2つの変数を追跡する必要があります。ベリファイア(nargoによって作成される別のコントラクト)と現在の状態ハッシュです。
1 event TransactionProcessed(2 bytes32 indexed transactionHash,3 bytes32 oldStateHash,4 bytes32 newStateHash5 );状態が変更されるたびに、TransactionProcessedイベントを発行します。
1 function processTransaction(2 bytes calldata _proof,3 bytes32[] calldata _publicFields4 ) public {この関数はトランザクションを処理します。 証明(bytesとして)と公開入力(bytes32配列として)を、ベリファイアが必要とする形式で取得します(オンチェーン処理を最小限に抑え、したがってガスコストを削減するため)。
1 require(_publicInputs[0] == currentStateHash,2 "Wrong old state hash");ゼロ知識証明は、トランザクションが現在のハッシュから新しいハッシュに変更されることである必要があります。
1 myVerifier.verify(_proof, _publicFields);ベリファイアコントラクトを呼び出して、ゼロ知識証明を検証します。 このステップは、ゼロ知識証明が間違っている場合にトランザクションをリバートします。
1 currentStateHash = _publicFields[1];23 emit TransactionProcessed(4 _publicFields[2]<<128 | _publicFields[3],5 _publicFields[0],6 _publicFields[1]7 );8 }9}すべて表示すべてが問題なければ、状態ハッシュを新しい値に更新し、TransactionProcessedイベントを発行します。
中央集権型コンポーネントによる悪用
情報セキュリティは、3つの属性で構成されます。
- 機密性、ユーザーは読む権限のない情報を読むことができません。
- 完全性、情報は、許可されたユーザーによって、許可された方法でのみ変更できます。
- 可用性、承認されたユーザーはシステムを使用できます。
このシステムでは、ゼロ知識証明を通じて完全性が提供されます。 可用性の保証ははるかに困難であり、銀行は各アカウントの残高とすべてのトランザクションを知る必要があるため、機密性は不可能です。 情報を持っているエンティティがその情報を共有するのを防ぐ方法はありません。
ステルスアドレス (opens in a new tab)を使用して真に機密性の高い銀行を作成することは可能かもしれませんが、それはこの記事の範囲を超えています。
偽情報
サーバーが完全性を侵害する方法の1つは、データが要求された (opens in a new tab)ときに偽の情報を提供することです。
これを解決するために、アカウントをプライベート入力として受け取り、情報が要求されたアドレスをパブリック入力として受け取る2番目のNoirプログラムを作成できます。 出力は、そのアドレスの残高とノンス (nonce)、およびアカウントのハッシュです。
もちろん、ノンス (nonce)と残高をオンチェーンにポストしたくないため、この証明はオンチェーンで検証できません。 しかし、ブラウザで実行されているクライアントコードで検証することはできます。
強制トランザクション
L2での可用性を確保し、検閲を防ぐための通常のメカニズムは、強制トランザクション (opens in a new tab)です。 しかし、強制トランザクションはゼロ知識証明と組み合わせられません。 サーバーは、トランザクションを検証できる唯一のエンティティです。
smart-contracts/src/ZkBank.solを変更して、強制トランザクションを受け入れ、処理されるまでサーバーが状態を変更するのを防ぐことができます。 しかし、これにより、単純なサービス拒否攻撃にさらされることになります。 強制トランザクションが無効で処理できない場合はどうなるでしょうか?
解決策は、強制トランザクションが無効であることを示すゼロ知識証明を持つことです。 これにより、サーバーには3つのオプションが与えられます。
- 強制トランザクションを処理し、処理されたことと新しい状態ハッシュを示すゼロ知識証明を提供する。
- 強制トランザクションを拒否し、トランザクションが無効であること(不明なアドレス、不正なノンス (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日