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

プライバシーを保護するアプリ固有のプラズマを作成する

ゼロ知識
サーバー
オフチェーン
プライバシー
上級
オリ・ポメランツ
2025年10月15日
54 分で読めます

はじめに

ロールアップとは対照的に、プラズマは整合性のためにイーサリアム・メインネットを使用しますが、可用性のためには使用しません。この記事では、プラズマのように振る舞うアプリケーションを作成します。イーサリアムは整合性(不正な変更がないこと)を保証しますが、可用性(中央集権的なコンポーネントがダウンしてシステム全体が機能しなくなる可能性があること)は保証しません。

ここで作成するアプリケーションは、プライバシーを保護する銀行です。異なるアドレスが残高を持つアカウントを所有しており、他のアカウントに資金(ETH)を送金することができます。銀行は状態(アカウントとその残高)とトランザクションのハッシュを投稿しますが、実際の残高はプライバシーを保つことができるオフチェーンに保持します。

設計

これは本番環境に対応したシステムではなく、教育用のツールです。そのため、いくつかの単純化された前提条件に基づいて書かれています。

  • 固定のアカウントプール。特定数のアカウントが存在し、各アカウントはあらかじめ決められたアドレスに属します。ゼロ知識証明において可変サイズのデータ構造を扱うのは難しいため、これによりシステムがはるかにシンプルになります。本番環境に対応したシステムでは、状態ハッシュとしてマークル・ルートを使用し、必要な残高に対するマークル証明を提供することができます。

  • メモリストレージ。本番システムでは、再起動時に備えてすべてのアカウント残高をディスクに書き込んで保存する必要があります。ここでは、情報が単に失われても問題ありません。

  • 送金のみ。本番システムでは、銀行に資産を入金したり引き出したりする方法が必要になります。しかし、ここでの目的は概念を説明することだけなので、この銀行は送金に限定されています。

ゼロ知識証明

基本的なレベルでは、ゼロ知識証明は、公開データ DatapublicDataprivate の間に Relationship という関係が存在するような、あるデータ Dataprivate をプルーバーが知っていることを示します。検証者は RelationshipDatapublic を知っています。

プライバシーを保護するためには、状態とトランザクションを非公開にする必要があります。しかし、整合性を確保するためには、状態の暗号学的ハッシュ (opens in a new tab)を公開する必要があります。トランザクションを送信した人々に、それらのトランザクションが実際に発生したことを証明するために、トランザクション・ハッシュも公開する必要があります。

ほとんどの場合、Dataprivate はゼロ知識証明プログラムへの入力であり、Datapublic は出力です。

Dataprivate のフィールドは以下の通りです。

  • Staten、古い状態
  • Staten+1、新しい状態
  • Transaction、古い状態から新しい状態へ変更するトランザクション。このトランザクションには以下のフィールドを含める必要があります。
    • 送金を受け取る Destination address (宛先アドレス)
    • 送金される Amount (金額)
    • 各トランザクションが1回だけ処理されることを保証するための Nonce (ナンス)。 送信元アドレスは署名から復元できるため、トランザクションに含める必要はありません。
  • Signature、トランザクションの実行を許可された署名。今回のケースでは、トランザクションの実行を許可されているアドレスは送信元アドレスのみです。私たちのゼロ知識システムはそのような仕組みで動作するため、イーサリアムの署名に加えて、アカウントの公開鍵も必要になります。

Datapublic のフィールドは以下の通りです。

  • Hash(Staten)、古い状態のハッシュ
  • Hash(Staten+1)、新しい状態のハッシュ
  • Hash(Transaction)、状態を Staten から Staten+1 に変更するトランザクションのハッシュ。

この関係では、いくつかの条件をチェックします。

  • 公開ハッシュが、非公開フィールドの正しいハッシュであること。
  • トランザクションを古い状態に適用すると、新しい状態になること。
  • 署名がトランザクションの送信元アドレスからのものであること。

暗号学的ハッシュ関数の特性により、これらの条件を証明するだけで整合性を確保するのに十分です。

データ構造

主要なデータ構造は、サーバーが保持する状態です。すべてのアカウントについて、サーバーはアカウント残高と、リプレイ攻撃 (opens in a new tab)を防ぐために使用されるナンス (opens in a new tab)を追跡します。

コンポーネント

このシステムには2つのコンポーネントが必要です。

  • トランザクションを受信して処理し、ゼロ知識証明とともにハッシュをチェーンに投稿する サーバー
  • ハッシュを保存し、ゼロ知識証明を検証して状態遷移が正当であることを保証する スマート・コントラクト

データと制御のフロー

以下は、あるアカウントから別のアカウントへ送金するために、さまざまなコンポーネントが通信する方法です。

  1. Webブラウザは、署名者のアカウントから別のアカウントへの送金を要求する署名付きトランザクションを送信します。

  2. サーバーは、トランザクションが有効であることを検証します。

    • 署名者が銀行に十分な残高のあるアカウントを持っていること。
    • 受取人が銀行にアカウントを持っていること。
  3. サーバーは、署名者の残高から送金額を差し引き、受取人の残高に加算することで、新しい状態を計算します。

  4. サーバーは、状態の変更が有効であることを示すゼロ知識証明を計算します。

  5. サーバーは、以下を含むトランザクションをイーサリアムに送信します。

    • 新しい状態ハッシュ
    • トランザクション・ハッシュ (トランザクションの送信者が処理されたことを知ることができるようにするため)
    • 新しい状態への遷移が有効であることを証明するゼロ知識証明
  6. スマート・コントラクトはゼロ知識証明を検証します。

  7. ゼロ知識証明が確認された場合、スマート・コントラクトは以下のアクションを実行します。

    • 現在の状態ハッシュを新しい状態ハッシュに更新する
    • 新しい状態ハッシュとトランザクション・ハッシュを含むログエントリを発行する

ツール

クライアント側のコードには、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)した後、以下を実行してください。

noirup -v 1.0.0-beta.10

使用するブロックチェーンは anvil であり、これはFoundry (opens in a new tab)の一部であるローカルテスト用ブロックチェーンです。

実装

これは複雑なシステムであるため、段階的に実装します。

ステージ1 - 手動でのゼロ知識

最初のステージでは、ブラウザでトランザクションに署名し、その情報を手動でゼロ知識証明に提供します。ゼロ知識コードは、その情報を server/noir/Prover.toml で受け取ることを想定しています(ドキュメントはこちら (opens in a new tab))。

実際の動作を確認するには:

  1. Node (opens in a new tab)Noir (opens in a new tab) がインストールされていることを確認してください。できれば、macOS、Linux、または WSL (opens in a new tab) などのUNIXシステムにインストールしてください。

  2. ステージ1のコードをダウンロードし、クライアントコードを提供するためのウェブサーバーを起動します。

    git clone https://github.com/qbzzt/250911-zk-bank.git -b 01-manual-zk
    cd 250911-zk-bank
    cd client
    npm install
    npm run dev
    

    ここでウェブサーバーが必要な理由は、特定の種類の不正を防ぐために、多くのウォレット(メタマスクなど)がディスクから直接提供されるファイルを受け付けないためです。

  3. ウォレットがインストールされたブラウザを開きます。

  4. ウォレットで新しいパスフレーズを入力します。これにより既存のパスフレーズが削除されるため、必ずバックアップを取っておいてください

    パスフレーズは test test test test test test test test test test test junk で、これは anvil のデフォルトのテスト用パスフレーズです。

  5. クライアント側のコード (opens in a new tab)にアクセスします。

  6. ウォレットに接続し、宛先アカウントと金額を選択します。

  7. Sign をクリックし、トランザクションに署名します。

  8. Prover.toml の見出しの下にテキストが表示されます。server/noir/Prover.toml をそのテキストに置き換えます。

  9. ゼロ知識証明を実行します。

    cd ../server/noir
    nargo execute
    

    出力は以下のようになるはずです。

ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute

[zkBank] Circuit witness successfully solved [zkBank] Witness saved to target/zkBank.gz [zkBank] Circuit output: (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85)

メッセージはテキスト形式であり、ユーザーが理解しやすく(署名時に必要)、Noir コードが解析しやすくなっています。金額はフィニー単位で記載されており、これにより端数の送金が可能になる一方で、読みやすさも保たれています。最後の数字はナンス (opens in a new tab)です。

文字列の長さは100文字です。ゼロ知識証明は可変長データの処理が苦手なため、データをパディング(埋め草)することがよく必要になります。

pubKeyX=["0x83",...,"0x75"]
pubKeyY=["0x35",...,"0xa5"]
signature=["0xb1",...,"0x0d"]

これら3つのパラメータは固定長のバイト配列です。

これは構造体の配列を指定する方法です。各エントリについて、アドレス、残高(milliETH、別名フィニー (opens in a new tab))、および次のナンス値を指定します。

client/src/Transfer.tsx

このファイル (opens in a new tab)はクライアント側の処理を実装し、server/noir/Prover.toml ファイル(ゼロ知識パラメータを含むファイル)を生成します。

以下は、より興味深い部分の説明です。

export default attrs =>  {

この関数は、他のファイルがインポートできる Transfer React コンポーネントを作成します。

  const accounts = [
    "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
    "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
    "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
    "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
    "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
  ]

これらはアカウントのアドレスであり、test ... test junk パスフレーズによって作成されたアドレスです。独自のアドレスを使用したい場合は、この定義を変更するだけです。

  const account = useAccount()
  const wallet = createWalletClient({
    transport: custom(window.ethereum!)
  })

これらの Wagmi フック (opens in a new tab)を使用すると、Viem (opens in a new tab) ライブラリとウォレットにアクセスできます。

  const message = `send ${toAccount} ${ethAmount*1000} finney (milliEth) ${nonce}`.padEnd(100, " ")

これはスペースでパディングされたメッセージです。useState (opens in a new tab) 変数のいずれかが変更されるたびに、コンポーネントが再描画され、message が更新されます。

  const sign = async () => {

この関数は、ユーザーが Sign ボタンをクリックしたときに呼び出されます。メッセージは自動的に更新されますが、署名にはウォレットでのユーザーの承認が必要であり、必要な場合以外は要求したくありません。

    const signature = await wallet.signMessage({
        account: fromAccount,
        message,
    })

ウォレットにメッセージへの署名 (opens in a new tab)を要求します。

    const hash = hashMessage(message)

メッセージのハッシュを取得します。これをユーザーに提供することは、(Noir コードの)デバッグに役立ちます。

    const pubKey = await recoverPublicKey({
        hash,
        signature
    })

公開鍵を取得します (opens in a new tab)。これは Noir の ecrecover (opens in a new tab) 関数に必要です。

    setSignature(signature)
    setHash(hash)
    setPubKey(pubKey)

状態変数を設定します。これにより、(sign 関数が終了した後に)コンポーネントが再描画され、更新された値がユーザーに表示されます。

    let proverToml = `

Prover.toml 用のテキストです。

message="${message}"

pubKeyX=${hexToArray(pubKey.slice(4,4+2*32))}
pubKeyY=${hexToArray(pubKey.slice(4+2*32))}

Viem は公開鍵を65バイトの16進数文字列として提供します。最初のバイトはバージョンマーカーである 0x04 です。これに続いて、公開鍵の x 用の32バイト、そして公開鍵の y 用の32バイトが続きます。

しかし、Noir はこの情報を x 用と y 用の2つのバイト配列として受け取ることを想定しています。ゼロ知識証明の一部として解析するよりも、ここでクライアント側で解析する方が簡単です。

これはゼロ知識全般における良い実践であることに注意してください。ゼロ知識証明内のコードはコストが高いため、ゼロ知識証明の外部で実行できる処理は、ゼロ知識証明の外部で実行_すべき_です。

signature=${hexToArray(signature.slice(2,-2))}

署名も65バイトの16進数文字列として提供されます。ただし、最後のバイトは公開鍵を復元するためにのみ必要です。公開鍵はすでに Noir コードに提供されるため、署名を検証するために最後のバイトは必要なく、Noir コードもそれを要求しません。

${accounts.map(accountInProverToml).reduce((a,b) => a+b, "")}
`

アカウントを提供します。

    setProverToml(proverToml)
  }

  return (
    <>
        <h2>Transfer</h2>

これはコンポーネントの HTML(より正確には JSX (opens in a new tab))フォーマットです。

server/noir/src/main.nr

このファイル (opens in a new tab)は実際のゼロ知識コードです。

use std::hash::pedersen_hash;

ペダーセン・ハッシュ (opens in a new tab)Noir 標準ライブラリ (opens in a new tab)で提供されています。ゼロ知識証明では一般的にこのハッシュ関数が使用されます。標準的なハッシュ関数と比較して、算術回路 (opens in a new tab)内で計算するのがはるかに簡単です。

use keccak256::keccak256;
use dep::ecrecover;

これら2つの関数は外部ライブラリであり、Nargo.toml (opens in a new tab) で定義されています。これらはまさにその名前の通り、keccak256 ハッシュ (opens in a new tab)を計算する関数と、イーサリアムの署名を検証して署名者のイーサリアム・アドレスを復元する関数です。

global ACCOUNT_NUMBER : u32 = 5;

Noir は Rust (opens in a new tab) にインスパイアされています。変数はデフォルトで定数です。このようにしてグローバルな設定定数を定義します。具体的には、ACCOUNT_NUMBER は保存するアカウントの数です。

u<number> という名前のデータ型は、そのビット数の符号なし整数です。サポートされている型は u8u16u32u64、および u128 のみです。

global FLAT_ACCOUNT_FIELDS : u32 = 2;

この変数は、後述するようにアカウントのペダーセン・ハッシュに使用されます。

global MESSAGE_LENGTH : u32 = 100;

前述の通り、メッセージの長さは固定です。ここで指定されます。

global ASCII_MESSAGE_LENGTH : [u8; 3] = [0x31, 0x30, 0x30];
global HASH_BUFFER_SIZE : u32 = 26+3+MESSAGE_LENGTH;

EIP-191 署名 (opens in a new tab)では、26バイトのプレフィックス、ASCIIでのメッセージ長、そして最後にメッセージ自体を含むバッファが必要です。

struct Account {
    balance: u128,
    address: Field,
    nonce: u32,
}

アカウントについて保存する情報です。Field (opens in a new tab) は通常最大253ビットの数値であり、ゼロ知識証明を実装する算術回路 (opens in a new tab)で直接使用できます。ここでは、160ビットのイーサリアム・アドレスを保存するために Field を使用します。

struct TransferTxn {
    from: Field,
    to: Field,
    amount: u128,
    nonce: u32
}

送金トランザクションのために保存する情報です。

fn flatten_account(account: Account) -> [Field; FLAT_ACCOUNT_FIELDS] {

関数の定義です。パラメータは Account 情報です。結果は Field 変数の配列であり、その長さは FLAT_ACCOUNT_FIELDS です。

let flat = [
        account.address,
        ((account.balance << 32) + account.nonce.into()).into(),
    ];

配列の最初の値はアカウントのアドレスです。2番目の値には残高とナンスの両方が含まれます。.into() の呼び出しは、数値を必要なデータ型に変更します。account.nonceu32 の値ですが、それを u128 の値である account.balance << 32 に追加するには、u128 にする必要があります。これが最初の .into() です。2番目のものは、配列に収まるように u128 の結果を Field に変換します。

flat
}

Noir では、関数は最後にのみ値を返すことができます(早期リターンはありません)。戻り値を指定するには、関数の閉じ括弧の直前でそれを評価します。

fn flatten_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] {

この関数は、アカウントの配列を Field の配列に変換し、ペダーセン・ハッシュの入力として使用できるようにします。

let mut flat: [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] = [0; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER];

これはミュータブル(可変)な変数、つまり定数では_ない_変数を指定する方法です。Noir の変数は常に値を持つ必要があるため、この変数をすべてゼロで初期化します。

for i in 0..ACCOUNT_NUMBER {

これは for ループです。境界が定数であることに注意してください。Noir のループは、コンパイル時に境界がわかっている必要があります。その理由は、算術回路がフロー制御をサポートしていないためです。for ループを処理する際、コンパイラは単にその中のコードを各反復ごとに複数回配置します。

最後に、アカウントの配列をハッシュ化する関数に到達しました。

fn find_account(accounts: [Account; ACCOUNT_NUMBER], address: Field) -> u32 {
    let mut account : u32 = ACCOUNT_NUMBER;

    for i in 0..ACCOUNT_NUMBER {
        if accounts[i].address == address {
            account = i;
        }
    }

この関数は、特定のアドレスを持つアカウントを見つけます。この関数は、アドレスを見つけた後でもすべてのアカウントを反復処理するため、標準的なコードでは非常に非効率的です。

しかし、ゼロ知識証明にはフロー制御がありません。条件をチェックする必要がある場合は、毎回チェックする必要があります。

if 文でも同様のことが起こります。上記のループ内の if 文は、以下の数学的ステートメントに変換されます。

conditionresult = accounts[i].address == address // 等しい場合は1、それ以外は0

accountnew = conditionresult*i + (1-conditionresult)*accountold

    assert (account < ACCOUNT_NUMBER, f"{address} does not have an account");

    account
}

assert (opens in a new tab) 関数は、アサーションが偽の場合にゼロ知識証明をクラッシュさせます。この場合、該当するアドレスを持つアカウントが見つからない場合です。アドレスを報告するために、フォーマット文字列 (opens in a new tab)を使用します。

fn apply_transfer_txn(accounts: [Account; ACCOUNT_NUMBER], txn: TransferTxn) -> [Account; ACCOUNT_NUMBER] {

この関数は送金トランザクションを適用し、新しいアカウントの配列を返します。

    let from = find_account(accounts, txn.from);
    let to = find_account(accounts, txn.to);

    let (txnFrom, txnAmount, txnNonce, accountNonce) =
        (txn.from, txn.amount, txn.nonce, accounts[from].nonce);

Noir ではフォーマット文字列内の構造体要素にアクセスできないため、使用可能なコピーを作成します。

    assert (accounts[from].balance >= txn.amount,
        f"{txnFrom} does not have {txnAmount} finney");

    assert (accounts[from].nonce == txn.nonce,
        f"Transaction has nonce {txnNonce}, but the account is expected to use {accountNonce}");

これらは、トランザクションを無効にする可能性のある2つの条件です。

    let mut newAccounts = accounts;

    newAccounts[from].balance -= txn.amount;
    newAccounts[from].nonce += 1;
    newAccounts[to].balance += txn.amount;

    newAccounts
}

新しいアカウントの配列を作成し、それを返します。

fn readAddress(messageBytes: [u8; MESSAGE_LENGTH]) -> Field

この関数はメッセージからアドレスを読み取ります。

{
    let mut result : Field = 0;

    for i in 7..47 {

アドレスは常に20バイト(つまり40桁の16進数)の長さであり、7文字目から始まります。

メッセージから金額とナンスを読み取ります。

{
    let mut amount : u128 = 0;
    let mut nonce: u32 = 0;
    let mut stillReadingAmount: bool = true;
    let mut lookingForNonce: bool = false;
    let mut stillReadingNonce: bool = false;

メッセージ内で、アドレスの後の最初の数字は送金するフィニー(ETHの1000分の1)の金額です。2番目の数字はナンスです。それらの間のテキストは無視されます。

タプル (opens in a new tab)を返すことは、Noir で関数から複数の値を返す方法です。

この関数はメッセージをバイトに変換し、次に金額を TransferTxn に変換します。

// ViemのhashMessageと同等のもの
// https://viem.sh/docs/utilities/hashMessage#hashmessage
fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {

アカウントはゼロ知識証明の内部でのみハッシュ化されるため、ペダーセン・ハッシュを使用できました。しかし、このコードではブラウザによって生成されたメッセージの署名をチェックする必要があります。そのためには、EIP-191 (opens in a new tab) のイーサリアム署名フォーマットに従う必要があります。つまり、標準のプレフィックス、ASCIIでのメッセージ長、およびメッセージ自体を組み合わせたバッファを作成し、イーサリアム標準の keccak256 を使用してハッシュ化する必要があります。

アプリケーションがユーザーに、トランザクションやその他の目的に使用できるメッセージへの署名を求めるケースを避けるため、EIP-191 では、すべての署名付きメッセージが文字 0x19(有効な ASCII 文字ではない)で始まり、その後に Ethereum Signed Message: と改行が続くように指定しています。

メッセージ長を最大999まで処理し、それより大きい場合は失敗させます。メッセージ長は定数ですが、変更しやすくするためにこのコードを追加しました。本番システムでは、パフォーマンス向上のために MESSAGE_LENGTH が変更されないと単に仮定するでしょう。

    keccak256::keccak256(buffer, HASH_BUFFER_SIZE)
}

イーサリアム標準の keccak256 関数を使用します。

fn signatureToAddressAndHash(
        message: str<MESSAGE_LENGTH>, 
        pubKeyX: [u8; 32],
        pubKeyY: [u8; 32],
        signature: [u8; 64]
    ) -> (Field, Field, Field)   // アドレス、ハッシュの最初の16バイト、ハッシュの最後の16バイト        
{

この関数は署名を検証しますが、それにはメッセージのハッシュが必要です。その後、署名したアドレスとメッセージのハッシュを提供します。メッセージのハッシュは2つの Field 値で提供されます。これは、プログラムの残りの部分でバイト配列よりも使いやすいためです。

フィールドの計算は大きな数のモジュロ (opens in a new tab)(剰余)で行われますが、その数は通常256ビット未満であるため(そうでないと EVM での計算が困難になります)、2つの Field 値を使用する必要があります。

    let hash = hashMessage(message);

    let mut (hash1, hash2) = (0,0);

    for i in 0..16 {
        hash1 = hash1*256 + hash[31-i].into();
        hash2 = hash2*256 + hash[15-i].into();
    }

hash1hash2 をミュータブルな変数として指定し、ハッシュを1バイトずつ書き込みます。

    (
        ecrecover::ecrecover(pubKeyX, pubKeyY, signature, hash), 

これは Solidity の ecrecover (opens in a new tab) に似ていますが、2つの重要な違いがあります。

  • 署名が無効な場合、呼び出しは assert で失敗し、プログラムは中止されます。
  • 公開鍵は署名とハッシュから復元できますが、これは外部で実行できる処理であるため、ゼロ知識証明の内部で行う価値はありません。ここで誰かが不正を働こうとしても、署名の検証は失敗します。

最後に、main 関数に到達します。アカウントのハッシュを古い値から新しい値へ有効に変更するトランザクションがあることを証明する必要があります。また、送信者が自分のトランザクションが処理されたことを知ることができるように、それがこの特定のトランザクション・ハッシュを持っていることも証明する必要があります。

{
    let mut txn = readTransferTxn(message);

送信元アドレスはメッセージからではなく署名から読み取るため、txn をミュータブルにする必要があります。

ステージ2 - サーバーの追加

第2ステージでは、ブラウザから送金トランザクションを受信して実装するサーバーを追加します。

実際の動作を確認するには:

  1. Vite が実行中の場合は停止します。

  2. サーバーを含むブランチをダウンロードし、必要なすべてのモジュールがあることを確認します。

    git checkout 02-add-server
    cd client
    npm install
    cd ../server
    npm install
    

    Noir コードをコンパイルする必要はありません。ステージ1で使用したコードと同じです。

  3. サーバーを起動します。

    npm run start
    
  4. 別のコマンドラインウィンドウで Vite を実行し、ブラウザコードを提供します。

    cd client
    npm run dev
    
  5. http://localhost:5173 (opens in a new tab) のクライアントコードにアクセスします。

  6. トランザクションを発行する前に、ナンスと送金可能な金額を知る必要があります。この情報を取得するには、Update account data をクリックしてメッセージに署名します。

    ここでジレンマがあります。一方で、再利用可能なメッセージ(リプレイ攻撃 (opens in a new tab))に署名したくないため、そもそもナンスが必要なのです。しかし、まだナンスを持っていません。解決策は、一度しか使用できず、現在の時刻など、すでに両側で持っているナンスを選択することです。

    この解決策の問題は、時刻が完全に同期されていない可能性があることです。そのため代わりに、1分ごとに変化する値に署名します。これは、リプレイ攻撃に対する脆弱性のウィンドウが最大でも1分であることを意味します。本番環境では署名されたリクエストが TLS によって保護されること、そしてトンネルの反対側であるサーバーがすでに残高とナンスを開示できる(機能するためにはそれらを知っている必要がある)ことを考慮すると、これは許容できるリスクです。

  7. ブラウザが残高とナンスを受け取ると、送金フォームが表示されます。宛先アドレスと金額を選択し、Transfer をクリックします。このリクエストに署名します。

  8. 送金を確認するには、Update account data を実行するか、サーバーを実行しているウィンドウを確認します。サーバーは状態が変化するたびに状態をログに記録します。

ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start

server@1.0.0 start node --experimental-json-modules index.mjs

Listening on port 3000 Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 processed New state: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 64000 (1) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 100000 (0) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0) 0x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0) 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0) Txn send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 7200 finney (milliEth) 1 processed New state: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 56800 (2) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0) 0x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0) 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0) Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 3000 finney (milliEth) 2 processed New state: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 53800 (3) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0) 0x90F79bf6EB2c4f870365E785982E1f101E93b906 has 139000 (0) 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)


#### `server/index.mjs` \{#server-index-mjs-1\}

[このファイル](https://github.com/qbzzt/250911-zk-bank/blob/02-add-server/server/index.mjs)にはサーバープロセスが含まれており、[`main.nr`](https://github.com/qbzzt/250911-zk-bank/blob/02-add-server/server/noir/src/main.nr) の Noir コードと対話します。以下は興味深い部分の説明です。

```js
import { Noir } from '@noir-lang/noir_js'

noir.js (opens in a new tab) ライブラリは、JavaScript コードと Noir コードの間のインターフェースとして機能します。

const circuit = JSON.parse(await fs.readFile("./noir/target/zkBank.json"))
const noir = new Noir(circuit)

算術回路(前のステージで作成したコンパイル済みの Noir プログラム)をロードし、実行の準備をします。

// 署名されたリクエストに対する応答としてのみ、アカウント情報を提供します
const accountInformation = async signature => {
    const fromAddress = await recoverAddress({
        hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),
        signature
    })

アカウント情報を提供するには、署名のみが必要です。その理由は、メッセージが何になるか、したがってメッセージのハッシュが何になるかをすでに知っているからです。

const processMessage = async (message, signature) => {

メッセージを処理し、それがエンコードするトランザクションを実行します。

    // 公開鍵を取得する
    const pubKey = await recoverPublicKey({
        hash,
        signature
    })

サーバー上で JavaScript を実行するようになったため、クライアントではなくサーバーで公開鍵を取得できます。

noir.execute は Noir プログラムを実行します。パラメータは Prover.toml (opens in a new tab) で提供されるものと同等です。長い値は、Viem のように単一の16進数値(0x60A7)としてではなく、16進数文字列の配列(["0x60", "0xA7"])として提供されることに注意してください。

    } catch (err) {
        console.log(`Noir error: ${err}`)
        throw Error("Invalid transaction, not processed")
    }

エラーが発生した場合はそれをキャッチし、簡略化されたバージョンをクライアントに中継します。

    Accounts[fromAccountNumber].nonce++
    Accounts[fromAccountNumber].balance -= amount
    Accounts[toAccountNumber].balance += amount

トランザクションを適用します。Noir コードですでに実行しましたが、そこから結果を抽出するよりも、ここでもう一度実行する方が簡単です。

let Accounts = [
    {
        address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
        balance: 5000,
        nonce: 0,
    },

初期の Accounts 構造体です。

ステージ3 - イーサリアムのスマート・コントラクト

  1. サーバーとクライアントのプロセスを停止します。

  2. スマート・コントラクトを含むブランチをダウンロードし、必要なすべてのモジュールがあることを確認します。

    git checkout 03-smart-contracts
    cd client
    npm install
    cd ../server
    npm install
    
  3. 別のコマンドラインウィンドウで anvil を実行します。

  4. 検証キーと Solidity の検証者を生成し、検証者のコードを Solidity プロジェクトにコピーします。

    cd noir
    bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak
    bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol
    cp target/Verifier.sol ../../smart-contracts/src
    
  5. スマート・コントラクトに移動し、anvil ブロックチェーンを使用するように環境変数を設定します。

    cd ../../smart-contracts
    export ETH_RPC_URL=http://localhost:8545
    ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
    
  6. Verifier.sol をデプロイし、アドレスを環境変数に保存します。

    VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'`
    echo $VERIFIER_ADDRESS
    
  7. ZkBank コントラクトをデプロイします。

    ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'`
    echo $ZKBANK_ADDRESS
    

    0x199..67b の値は、Accounts の初期状態のペダーセン・ハッシュです。server/index.mjs でこの初期状態を変更した場合、トランザクションを実行して、ゼロ知識証明によって報告される初期ハッシュを確認できます。

  8. サーバーを実行します。

    cd ../server
    npm run start
    
  9. 別のコマンドラインウィンドウでクライアントを実行します。

    cd client
    npm run dev
    
  10. いくつかのトランザクションを実行します。

  11. 状態がオンチェーンで変更されたことを確認するには、サーバープロセスを再起動します。トランザクション内の元のハッシュ値がオンチェーンに保存されているハッシュ値と異なるため、ZkBank がトランザクションを受け付けなくなったことを確認します。

    これは想定される種類のエラーです。

ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start

server@1.0.0 start node --experimental-json-modules index.mjs

Listening on port 3000 Verification error: ContractFunctionExecutionError: The contract function "processTransaction" reverted with the following reason: Wrong old state hash

Contract Call: address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 function: processTransaction(bytes _proof, bytes32[] _publicInputs) args: (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000

オンチェーンに送信する実際の証明を作成するには、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 を使用しています。

const zkBankAddress = process.env.ZKBANK_ADDRESS || "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"

ここのアドレスは、クリーンな anvil で開始し、上記の指示に従ったときに取得できるアドレスです。

const walletClient = createWalletClient({ 
    chain: anvil, 
    transport: http(), 
    account: privateKeyToAccount("0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6")
})

この秘密鍵は、anvil のデフォルトの事前資金提供済みアカウントの1つです。

const generateProof = async (witness, fileID) => {

bb 実行可能ファイルを使用して証明を生成します。

    const fname = `witness-${fileID}.gz`    
    await fs.writeFile(fname, witness)

ウィットネスをファイルに書き込みます。

    await execPromise(`bb prove -b ./noir/target/zkBank.json -w ${fname} -o ${fileID} --oracle_hash keccak --output_format fields`)

実際に証明を作成します。このステップでは公開変数を含むファイルも作成されますが、それは必要ありません。それらの変数はすでに noir.execute から取得しています。

    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つ追加することでフォーマットを変更します。

    await execPromise(`rm -r ${fname} ${fileID}`)

    return proof
}

クリーンアップして証明を返します。

const processMessage = async (message, signature) => {
    .
    .
    .

    const publicFields = noirResult.returnValue.map(x=>'0x' + x.slice(2).padStart(64, "0"))

公開フィールドは32バイト値の配列である必要があります。しかし、トランザクション・ハッシュを2つの Field 値に分割する必要があったため、16バイト値として表示されます。ここでは、Viem が実際には32バイトであることを理解できるようにゼロを追加します。

    const proof = await generateProof(noirResult.witness, `${fromAddress}-${nonce}`)

各アドレスは各ナンスを一度しか使用しないため、fromAddressnonce の組み合わせをウィットネスファイルと出力ディレクトリの一意の識別子として使用できます。

トランザクションをチェーンに送信します。

smart-contracts/src/ZkBank.sol

これはトランザクションを受信するオンチェーンのコードです。

オンチェーンのコードは、検証者(nargo によって作成される別のコントラクト)と現在の状態ハッシュの2つの変数を追跡する必要があります。

    event TransactionProcessed(
        bytes32 indexed transactionHash,
        bytes32 oldStateHash,
        bytes32 newStateHash
    );

状態が変化するたびに、TransactionProcessed イベントを発行します。

    function processTransaction(
        bytes calldata _proof,
        bytes32[] calldata _publicFields
    ) public {

この関数はトランザクションを処理します。証明(bytes として)と公開入力(bytes32 配列として)を、検証者が必要とするフォーマットで取得します(オンチェーンの処理を最小限に抑え、したがってガス・コストを最小限に抑えるため)。

        require(_publicInputs[0] == currentStateHash,
            "Wrong old state hash");

ゼロ知識証明は、トランザクションが現在のハッシュから新しいハッシュに変更されることを証明するものである必要があります。

        myVerifier.verify(_proof, _publicFields);

検証者コントラクトを呼び出してゼロ知識証明を検証します。このステップでは、ゼロ知識証明が間違っている場合、トランザクションをリバートします。

すべてが確認されたら、状態ハッシュを新しい値に更新し、TransactionProcessed イベントを発行します。

中央集権的コンポーネントによる不正利用

情報セキュリティは3つの属性で構成されています。

  • 機密性:ユーザーは、閲覧権限のない情報を読むことができません。
  • 完全性:情報は、権限を持つユーザーによって承認された方法でしか変更できません。
  • 可用性:権限を持つユーザーはシステムを使用できます。

このシステムでは、完全性はゼロ知識証明を通じて提供されます。可用性を保証するのははるかに難しく、機密性は不可能です。なぜなら、銀行は各アカウントの残高とすべてのトランザクションを知る必要があるからです。情報を持つエンティティがその情報を共有するのを防ぐ方法はありません。

ステルス・アドレス (opens in a new tab)を使用して真に機密性の高い銀行を作成することは可能かもしれませんが、それはこの記事の範囲外です。

誤った情報

サーバーが完全性を侵害する1つの方法は、データが要求された (opens in a new tab)ときに誤った情報を提供することです。

これを解決するために、アカウントをプライベート入力として受け取り、情報が要求されたアドレスをパブリック入力として受け取る2つ目のNoirプログラムを作成できます。出力は、そのアドレスの残高とナンス、およびアカウントのハッシュです。

もちろん、ナンスや残高をオンチェーンに公開したくないため、この証明をオンチェーンで検証することはできません。ただし、ブラウザで実行されているクライアント・コードによって検証することは可能です。

強制トランザクション

L2で可用性を確保し、検閲を防ぐための通常のメカニズムは、強制トランザクション (opens in a new tab)です。しかし、強制トランザクションはゼロ知識証明と組み合わせることができません。サーバーは、トランザクションを検証できる唯一のエンティティです。

smart-contracts/src/ZkBank.sol を変更して強制トランザクションを受け入れ、それらが処理されるまでサーバーが状態を変更できないようにすることができます。しかし、これでは単純なサービス拒否(DoS)攻撃に対して脆弱になります。強制トランザクションが無効であり、処理が不可能な場合はどうなるでしょうか?

解決策は、強制トランザクションが無効であることを示すゼロ知識証明を用意することです。これにより、サーバーには3つの選択肢が与えられます。

  • 強制トランザクションを処理し、それが処理されたことのゼロ知識証明と新しい状態のハッシュを提供する。
  • 強制トランザクションを拒否し、トランザクションが無効である(未知のアドレス、不正なナンス、または残高不足)ことを示すゼロ知識証明をコントラクトに提供する。
  • 強制トランザクションを無視する。サーバーにトランザクションを実際に処理させる方法はありませんが、これはシステム全体が利用できなくなることを意味します。

可用性ボンド

実際のシステム実装では、サーバーを稼働させ続けるための何らかの利益動機がおそらく存在するでしょう。サーバーに可用性ボンドを預けさせ、強制トランザクションが一定期間内に処理されない場合に誰でもそれをバーンできるようにすることで、このインセンティブを強化できます。

不正なNoirコード

通常、スマート・コントラクトを信頼してもらうために、ソースコードをブロック・エクスプローラー (opens in a new tab)にアップロードします。しかし、ゼロ知識証明の場合、それだけでは不十分です。

Verifier.sol には、Noirプログラムの関数である検証キーが含まれています。しかし、そのキーはNoirプログラムが何であったかを教えてくれません。実際に信頼できるソリューションを得るには、Noirプログラム(およびそれを作成したバージョン)をアップロードする必要があります。そうしないと、ゼロ知識証明がバックドアを持つ別のプログラムを反映している可能性があります。

ブロック・エクスプローラーがNoirプログラムのアップロードと検証を許可し始めるまでは、自分自身で(できればIPFSに)行う必要があります。そうすれば、高度な知識を持つユーザーはソースコードをダウンロードし、自分でコンパイルして Verifier.sol を作成し、それがオンチェーンのものと同一であることを検証できるようになります。

結論

プラズマ型のアプリケーションは、情報ストレージとして中央集権的なコンポーネントを必要とします。これにより潜在的な脆弱性が生じる可能性がありますが、その代わりに、ブロックチェーン自体では利用できない方法でプライバシーを保護することができます。ゼロ知識証明を用いることで、整合性を確保し、中央集権的なコンポーネントを運営する者が可用性を維持することを経済的に有利にすることが可能になります。

私の他の記事についてはこちらをご覧ください (opens in a new tab)

謝辞

  • Josh Crites はこの記事の草稿を読み、厄介な Noir の問題について助けてくれました。

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