プライバシーを保護するアプリケーション固有のプラズマを作成する
導入
ロールアップとは対照的に、プラズマはイーサリアム・メインネットを完全性のために使用しますが、可用性のためには使用しません。この記事では、プラズマのように振る舞うアプリケーションを作成します。イーサリアムは完全性(不正な変更がないこと)を保証しますが、可用性(中央集権型のコンポーネントがダウンしてシステム全体が無効になる可能性があること)は保証しません。
ここで作成するアプリケーションは、プライバシーを保護する銀行です。異なるアドレスが残高を持つアカウントを持ち、他のアカウントに送金(ETH)することができます。銀行は状態(アカウントとその残高)およびトランザクションのハッシュを投稿しますが、実際の残高はオフチェーンに保持し、プライバシーを保ちます。
設計
これは本番環境に対応したシステムではなく、学習ツールです。そのため、いくつかの単純化された仮定に基づいて作成されています。
-
固定のアカウントプール。特定の数のアカウントがあり、各アカウントはあらかじめ決められたアドレスに属しています。ゼロ知識証明で可変サイズのデータ構造を扱うのは難しいため、これによりシステムがはるかにシンプルになります。本番環境に対応したシステムでは、状態のハッシュとしてマークル・ルートを使用し、必要な残高に対するマークル証明を提供することができます。
-
メモリストレージ。本番システムでは、再起動時に備えてすべてのアカウント残高をディスクに書き込む必要があります。ここでは、情報が単に失われても問題ありません。
-
送金のみ。本番システムでは、銀行に資産を入金し、出金する方法が必要になります。しかし、ここでの目的は単に概念を説明することであるため、この銀行は送金に限定されています。
ゼロ知識証明
基本的なレベルでは、ゼロ知識証明は、プルーバーが公開データ_Datapublic_と_Dataprivate_の間に_Relationship_という関係があるような、あるデータ_Dataprivate_を知っていることを示します。検証者は_Relationship_と_Datapublic_を知っています。
プライバシーを保護するためには、状態とトランザクションを非公開にする必要があります。しかし、完全性を確保するためには、状態の暗号化ハッシュ (新しいタブで開きます)を公開する必要があります。トランザクションを送信した人々に、そのトランザクションが実際に発生したことを証明するために、トランザクション・ハッシュも投稿する必要があります。
ほとんどの場合、_Dataprivate_はゼロ知識証明プログラムへの入力であり、_Datapublic_は出力です。
Dataprivateのフィールドは以下の通りです:
- Staten、古い状態
- Staten+1、新しい状態
- Transaction、古い状態から新しい状態に変更するトランザクション。このトランザクションには以下のフィールドを含める必要があります:
- 送金を受け取る_宛先アドレス_
- 送金される_金額_
- 各トランザクションが一度しか処理されないことを保証する_ナンス_。 送信元アドレスは署名から復元できるため、トランザクションに含める必要はありません。
- Signature、トランザクションを実行する権限を持つ署名。今回の場合、トランザクションを実行する権限を持つ唯一のアドレスは送信元アドレスです。ゼロ知識システムがそのように機能するため、イーサリアムの署名に加えて、アカウントの公開鍵も必要です。
Datapublicのフィールドは以下の通りです:
- Hash(Staten) 古い状態のハッシュ
- Hash(Staten+1) 新しい状態のハッシュ
- Hash(Transaction) 状態を_Staten_から_Staten+1_に変更するトランザクションのハッシュ。
関係はいくつかの条件をチェックします:
- 公開ハッシュがプライベートフィールドの正しいハッシュであること。
- トランザクションを古い状態に適用すると、新しい状態になること。
- 署名がトランザクションの送信元アドレスからのものであること。
暗号化ハッシュ関数の特性により、これらの条件を証明するだけで完全性を確保するのに十分です。
データ構造
主要なデータ構造は、サーバーが保持する状態です。すべてのアカウントについて、サーバーはアカウント残高と、リプレイ攻撃 (新しいタブで開きます)を防ぐために使用されるナンス (新しいタブで開きます)を追跡します。
コンポーネント
このシステムには2つのコンポーネントが必要です:
- トランザクションを受信して処理し、ゼロ知識証明とともにハッシュをチェーンに投稿する_サーバー_。
- ハッシュを保存し、ゼロ知識証明を検証して状態遷移が正当であることを保証する_スマート・コントラクト_。
データと制御フロー
これらは、あるアカウントから別のアカウントに送金するために、さまざまなコンポーネントが通信する方法です。
-
Webブラウザは、署名者のアカウントから別のアカウントへの送金を要求する署名付きトランザクションを送信します。
-
サーバーはトランザクションが有効であることを検証します:
- 署名者が銀行に十分な残高を持つアカウントを持っていること。
- 受取人が銀行にアカウントを持っていること。
-
サーバーは、署名者の残高から送金金額を差し引き、受取人の残高に加算することで、新しい状態を計算します。
-
サーバーは、状態の変更が有効であることを示すゼロ知識証明を計算します。
-
サーバーは、以下を含むトランザクションをイーサリアムに送信します:
- 新しい状態のハッシュ
- トランザクション・ハッシュ(トランザクションの送信者が処理されたことを知ることができるようにするため)
- 新しい状態への遷移が有効であることを証明するゼロ知識証明
-
スマート・コントラクトはゼロ知識証明を検証します。
-
ゼロ知識証明が確認された場合、スマート・コントラクトは以下の処理を実行します:
- 現在の状態のハッシュを新しい状態のハッシュに更新する
- 新しい状態のハッシュとトランザクション・ハッシュを含むログエントリを発行する
ツール
クライアント側のコードには、Vite (新しいタブで開きます)、React (新しいタブで開きます)、Viem (新しいタブで開きます)、およびWagmi (新しいタブで開きます)を使用します。これらは業界標準のツールです。これらに慣れていない場合は、このチュートリアルを使用できます。
サーバーの大部分は、Node (新しいタブで開きます)を使用してJavaScriptで記述されています。ゼロ知識の部分はNoir (新しいタブで開きます)で記述されています。バージョン1.0.0-beta.10が必要なので、指示に従ってNoirをインストール (新しいタブで開きます)した後、以下を実行します:
noirup -v 1.0.0-beta.10
使用するブロックチェーンは、Foundry (新しいタブで開きます)の一部であるローカルテスト用ブロックチェーンのanvilです。
実装
これは複雑なシステムであるため、段階的に実装します。
ステージ1 - 手動のゼロ知識
最初のステージでは、ブラウザでトランザクションに署名し、その情報を手動でゼロ知識証明に提供します。ゼロ知識のコードは、その情報をserver/noir/Prover.toml(こちら (新しいタブで開きます)にドキュメント化されています)で受け取ることを想定しています。
実際の動作を確認するには:
-
Node (新しいタブで開きます)とNoir (新しいタブで開きます)がインストールされていることを確認してください。できれば、macOS、Linux、またはWSL (新しいタブで開きます)などのUNIXシステムにインストールしてください。
-
ステージ1のコードをダウンロードし、クライアントコードを提供するためのWebサーバーを起動します。
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ここでWebサーバーが必要な理由は、特定の種類の詐欺を防ぐために、多くのウォレット(メタマスクなど)がディスクから直接提供されるファイルを受け付けないためです。
-
ウォレットがインストールされたブラウザを開きます。
-
ウォレットで新しいパスフレーズを入力します。これにより既存のパスフレーズが削除されるため、必ずバックアップを取っておいてください。
パスフレーズは
test test test test test test test test test test test junkで、anvilのデフォルトのテスト用パスフレーズです。 -
クライアント側のコード (新しいタブで開きます)にアクセスします。
-
ウォレットに接続し、宛先アカウントと金額を選択します。
-
Signをクリックし、トランザクションに署名します。
-
Prover.tomlという見出しの下にテキストがあります。
server/noir/Prover.tomlをそのテキストに置き換えます。 -
ゼロ知識証明を実行します。
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) -
最後の2つの値をWebブラウザに表示されているハッシュと比較して、メッセージが正しくハッシュ化されているか確認します。
server/noir/Prover.toml
このファイル (新しいタブで開きます)は、Noirが想定する情報のフォーマットを示しています。
message="send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 500 finney (milliEth) 0 "
メッセージはテキスト形式であり、ユーザーが理解しやすく(署名時に必要)、Noirのコードが解析しやすくなっています。金額は、一方では端数の送金を可能にし、他方では読みやすくするために、フィニーで表記されています。最後の数字はナンス (新しいタブで開きます)です。
文字列の長さは100文字です。ゼロ知識証明は可変サイズのデータをうまく処理できないため、データをパディング(埋め草を追加)することがよく必要になります。
pubKeyX=["0x83",...,"0x75"]
pubKeyY=["0x35",...,"0xa5"]
signature=["0xb1",...,"0x0d"]
これら3つのパラメータは固定サイズのバイト配列です。
[[accounts]]
address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
balance=100_000
nonce=0
[[accounts]]
address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
balance=100_000
nonce=0
これは構造体の配列を指定する方法です。各エントリについて、アドレス、残高(milliETH、別名フィニー (新しいタブで開きます))、および次のナンス値を指定します。
client/src/Transfer.tsx
このファイル (新しいタブで開きます)はクライアント側の処理を実装し、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フック (新しいタブで開きます)を使用すると、viem (新しいタブで開きます)ライブラリとウォレットにアクセスできます。
const message = `send ${toAccount} ${ethAmount*1000} finney (milliEth) ${nonce}`.padEnd(100, " ")
これはスペースでパディングされたメッセージです。useState (新しいタブで開きます)変数のいずれかが変更されるたびに、コンポーネントが再描画され、messageが更新されます。
const sign = async () => {
この関数は、ユーザーがSignボタンをクリックしたときに呼び出されます。メッセージは自動的に更新されますが、署名にはウォレットでのユーザーの承認が必要であり、必要な場合以外は要求したくありません。
const signature = await wallet.signMessage({
account: fromAccount,
message,
})
ウォレットにメッセージへの署名 (新しいタブで開きます)を要求します。
const hash = hashMessage(message)
メッセージのハッシュを取得します。これをユーザーに提供することは、(Noirコードの)デバッグに役立ちます。
const pubKey = await recoverPublicKey({
hash,
signature
})
公開鍵を取得します (新しいタブで開きます)。これはNoirのecrecover (新しいタブで開きます)関数に必要です。
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 (新しいタブで開きます))フォーマットです。
server/noir/src/main.nr
このファイル (新しいタブで開きます)は実際のゼロ知識のコードです。
use std::hash::pedersen_hash;
Pedersenハッシュ (新しいタブで開きます)はNoir標準ライブラリ (新しいタブで開きます)で提供されています。ゼロ知識証明では一般的にこのハッシュ関数が使用されます。標準のハッシュ関数と比較して、算術回路 (新しいタブで開きます)内で計算するのがはるかに簡単です。
use keccak256::keccak256;
use dep::ecrecover;
これら2つの関数は、Nargo.toml (新しいタブで開きます)で定義されている外部ライブラリです。これらはまさにその名前が示す通り、keccak256ハッシュ (新しいタブで開きます)を計算する関数と、イーサリアムの署名を検証して署名者のイーサリアム・アドレスを復元する関数です。
global ACCOUNT_NUMBER : u32 = 5;
NoirはRust (新しいタブで開きます)にインスパイアされています。変数はデフォルトで定数です。このようにしてグローバル設定の定数を定義します。具体的には、ACCOUNT_NUMBERは保存するアカウントの数です。
u<number>という名前のデータ型は、そのビット数の符号なし整数です。サポートされている型はu8、u16、u32、u64、およびu128のみです。
global FLAT_ACCOUNT_FIELDS : u32 = 2;
この変数は、後述するようにアカウントのPedersenハッシュに使用されます。
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の署名 (新しいタブで開きます)には、26バイトのプレフィックス、ASCIIでのメッセージ長、そして最後にメッセージ自体を含むバッファが必要です。
struct Account {
balance: u128,
address: Field,
nonce: u32,
}
アカウントについて保存する情報。Field (新しいタブで開きます)は通常最大253ビットの数値であり、ゼロ知識証明を実装する算術回路 (新しいタブで開きます)で直接使用できます。ここでは、160ビットのイーサリアム・アドレスを保存するためにFieldを使用します。
struct TransferTxn {
from: Field,
to: Field,
amount: u128,
nonce: u32
}
送金トランザクションのために保存する情報。
fn flatten_account(account: Account) -> [Field; FLAT_ACCOUNT_FIELDS] {
関数定義。パラメータはAccount情報です。結果は、長さがFLAT_ACCOUNT_FIELDSのField変数の配列です。
let flat = [
account.address,
((account.balance << 32) + account.nonce.into()).into(),
];
配列の最初の値はアカウントのアドレスです。2番目の値には残高とナンスの両方が含まれます。.into()の呼び出しは、数値を必要なデータ型に変更します。account.nonceはu32値ですが、u128値であるaccount.balance << 32に加算するには、u128である必要があります。それが最初の.into()です。2番目の.into()は、u128の結果をFieldに変換して配列に収まるようにします。
flat
}
Noirでは、関数は最後にのみ値を返すことができます(早期リターンはありません)。戻り値を指定するには、関数の閉じ括弧の直前でそれを評価します。
fn flatten_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] {
この関数はアカウント配列をField配列に変換し、Petersenハッシュの入力として使用できるようにします。
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ループを処理する際、コンパイラは単にその中のコードを反復ごとに複数回配置します。
let fields = flatten_account(accounts[i]);
for j in 0..FLAT_ACCOUNT_FIELDS {
flat[i*FLAT_ACCOUNT_FIELDS + j] = fields[j];
}
}
flat
}
fn hash_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> Field {
pedersen_hash(flatten_accounts(accounts))
}
最後に、アカウント配列をハッシュ化する関数に到達しました。
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 (新しいタブで開きます)関数は、アサーションが偽の場合にゼロ知識証明をクラッシュさせます。この場合、該当するアドレスを持つアカウントが見つからない場合です。アドレスを報告するために、フォーマット文字列 (新しいタブで開きます)を使用します。
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文字目から始まります。
result *= 0x10;
if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-9
result += (messageBytes[i]-48).into();
}
if messageBytes[i] >= 65 & messageBytes[i] <= 70 { // A-F
result += (messageBytes[i]-65+10).into()
}
if messageBytes[i] >= 97 & messageBytes[i] <= 102 { // a-f
result += (messageBytes[i]-97+10).into()
}
}
result
}
fn readAmountAndNonce(messageBytes: [u8; MESSAGE_LENGTH]) -> (u128, u32)
メッセージから金額とナンスを読み取ります。
{
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番目の数字はナンスです。それらの間にあるテキストは無視されます。
for i in 48..MESSAGE_LENGTH {
if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-9
let digit = (messageBytes[i]-48);
if stillReadingAmount {
amount = amount*10 + digit.into();
}
if lookingForNonce { // ちょうど見つけました
stillReadingNonce = true;
lookingForNonce = false;
}
if stillReadingNonce {
nonce = nonce*10 + digit.into();
}
} else {
if stillReadingAmount {
stillReadingAmount = false;
lookingForNonce = true;
}
if stillReadingNonce {
stillReadingNonce = false;
}
}
}
(amount, nonce)
}
タプル (新しいタブで開きます)を返すことは、Noirで関数から複数の値を返す方法です。
fn readTransferTxn(message: str<MESSAGE_LENGTH>) -> TransferTxn
{
let mut txn: TransferTxn = TransferTxn { from: 0, to: 0, amount:0, nonce:0 };
let messageBytes = message.as_bytes();
txn.to = readAddress(messageBytes);
let (amount, nonce) = readAmountAndNonce(messageBytes);
txn.amount = amount;
txn.nonce = nonce;
txn
}
この関数はメッセージをバイトに変換し、次に金額をTransferTxnに変換します。
// ViemのhashMessageに相当するもの
// https://viem.sh/docs/utilities/hashMessage#hashmessage
fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {
アカウントはゼロ知識証明の内部でのみハッシュ化されるため、アカウントにPedersenハッシュを使用することができました。しかし、このコードでは、ブラウザによって生成されたメッセージの署名をチェックする必要があります。そのためには、EIP-191 (新しいタブで開きます)のイーサリアム署名フォーマットに従う必要があります。つまり、標準のプレフィックス、ASCIIでのメッセージ長、およびメッセージ自体を含む結合バッファを作成し、それをハッシュ化するためにイーサリアム標準のkeccak256を使用する必要があります。
// ASCIIプレフィックス
let prefix_bytes = [
0x19, // \x19
0x45, // 'E'
0x74, // 't'
0x68, // 'h'
0x65, // 'e'
0x72, // 'r'
0x65, // 'e'
0x75, // 'u'
0x6D, // 'm'
0x20, // ' '
0x53, // 'S'
0x69, // 'i'
0x67, // 'g'
0x6E, // 'n'
0x65, // 'e'
0x64, // 'd'
0x20, // ' '
0x4D, // 'M'
0x65, // 'e'
0x73, // 's'
0x73, // 's'
0x61, // 'a'
0x67, // 'g'
0x65, // 'e'
0x3A, // ':'
0x0A // '\n'
];
アプリケーションがトランザクションやその他の目的に使用できるメッセージに署名するようユーザーに求めるケースを避けるため、EIP-191では、すべての署名付きメッセージが文字0x19(有効なASCII文字ではない)で始まり、その後にEthereum Signed Message:と改行が続くことを規定しています。
let mut buffer: [u8; HASH_BUFFER_SIZE] = [0u8; HASH_BUFFER_SIZE];
for i in 0..26 {
buffer[i] = prefix_bytes[i];
}
let messageBytes : [u8; MESSAGE_LENGTH] = message.as_bytes();
if MESSAGE_LENGTH <= 9 {
for i in 0..1 {
buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
}
for i in 0..MESSAGE_LENGTH {
buffer[i+26+1] = messageBytes[i];
}
}
if MESSAGE_LENGTH >= 10 & MESSAGE_LENGTH <= 99 {
for i in 0..2 {
buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
}
for i in 0..MESSAGE_LENGTH {
buffer[i+26+2] = messageBytes[i];
}
}
if MESSAGE_LENGTH >= 100 {
for i in 0..3 {
buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
}
for i in 0..MESSAGE_LENGTH {
buffer[i+26+3] = messageBytes[i];
}
}
assert(MESSAGE_LENGTH < 1000, "Messages whose length is over three digits are not supported");
最大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値で提供されます。これは、プログラムの残りの部分でバイト配列よりも使いやすいためです。
フィールド計算は大きな数を法(モジュロ) (新しいタブで開きます)として行われますが、その数は通常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();
}
hash1とhash2を可変変数として指定し、ハッシュをバイトごとに書き込みます。
(
ecrecover::ecrecover(pubKeyX, pubKeyY, signature, hash),
これはSolidityのecrecover (新しいタブで開きます)に似ていますが、2つの重要な違いがあります:
- 署名が有効でない場合、呼び出しは
assertに失敗し、プログラムは中止されます。 - 公開鍵は署名とハッシュから復元できますが、これは外部で行うことができる処理であるため、ゼロ知識証明の内部で行う価値はありません。誰かがここで不正をしようとした場合、署名の検証は失敗します。
hash1,
hash2
)
}
fn main(
accounts: [Account; ACCOUNT_NUMBER],
message: str<MESSAGE_LENGTH>,
pubKeyX: [u8; 32],
pubKeyY: [u8; 32],
signature: [u8; 64],
) -> pub (
Field, // 古いアカウント配列のハッシュ
Field, // 新しいアカウント配列のハッシュ
Field, // メッセージハッシュの最初の16バイト
Field, // メッセージハッシュの最後の16バイト
)
最後に、main関数に到達します。アカウントのハッシュを古い値から新しい値に有効に変更するトランザクションを持っていることを証明する必要があります。また、送信した人が自分のトランザクションが処理されたことを知ることができるように、この特定のトランザクション・ハッシュを持っていることも証明する必要があります。
{
let mut txn = readTransferTxn(message);
メッセージから送信元アドレスを読み取るのではなく、署名から読み取るため、txnを可変にする必要があります。
let (fromAddress, txnHash1, txnHash2) = signatureToAddressAndHash(
message,
pubKeyX,
pubKeyY,
signature);
txn.from = fromAddress;
let newAccounts = apply_transfer_txn(accounts, txn);
(
hash_accounts(accounts),
hash_accounts(newAccounts),
txnHash1,
txnHash2
)
}
ステージ2 - サーバーの追加
第2ステージでは、ブラウザから送金トランザクションを受信して実装するサーバーを追加します。
実際の動作を確認するには:
-
Viteが実行中の場合は停止します。
-
サーバーを含むブランチをダウンロードし、必要なすべてのモジュールがあることを確認します。
git checkout 02-add-server cd client npm install cd ../server npm installNoirコードをコンパイルする必要はありません。ステージ1で使用したコードと同じです。
-
サーバーを起動します。
npm run start -
別のコマンドラインウィンドウでViteを実行し、ブラウザコードを提供します。
cd client npm run dev -
http://localhost:5173 (新しいタブで開きます) のクライアントコードにアクセスします。
-
トランザクションを発行する前に、ナンスと送金可能な金額を知る必要があります。この情報を取得するには、Update account dataをクリックしてメッセージに署名します。
ここでジレンマがあります。一方で、再利用可能なメッセージ(リプレイ攻撃 (新しいタブで開きます))に署名したくないため、そもそもナンスが必要なのです。しかし、まだナンスを持っていません。解決策は、一度しか使用できず、現在の時刻など、両側ですでに持っているナンスを選択することです。
この解決策の問題は、時刻が完全に同期されていない可能性があることです。そのため代わりに、1分ごとに変化する値に署名します。これは、リプレイ攻撃に対する脆弱性のウィンドウが最大でも1分であることを意味します。本番環境では署名されたリクエストがTLSによって保護されること、そしてトンネルの反対側であるサーバーがすでに残高とナンスを開示できる(機能するためにはそれらを知っている必要がある)ことを考慮すると、これは許容できるリスクです。
-
ブラウザが残高とナンスを受け取ると、送金フォームが表示されます。宛先アドレスと金額を選択し、Transferをクリックします。このリクエストに署名します。
-
送金を確認するには、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
このファイル (新しいタブで開きます)にはサーバープロセスが含まれており、main.nr (新しいタブで開きます)にあるNoirコードと対話します。興味深い部分についての説明は以下の通りです。
import { Noir } from '@noir-lang/noir_js'
noir.js (新しいタブで開きます)ライブラリは、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を実行するようになったため、クライアントではなくサーバーで公開鍵を取得できます。
let noirResult
try {
noirResult = await noir.execute({
message,
signature: signature.slice(2,-2).match(/.{2}/g).map(x => `0x${x}`),
pubKeyX,
pubKeyY,
accounts: Accounts
})
noir.executeはNoirプログラムを実行します。パラメータはProver.toml (新しいタブで開きます)で提供されるものと同等です。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 - イーサリアムのスマート・コントラクト
-
サーバーとクライアントのプロセスを停止します。
-
スマート・コントラクトを含むブランチをダウンロードし、必要なすべてのモジュールがあることを確認します。
git checkout 03-smart-contracts cd client npm install cd ../server npm install -
別のコマンドラインウィンドウで
anvilを実行します。 -
検証キーと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 -
スマート・コントラクトに移動し、
anvilブロックチェーンを使用するように環境変数を設定します。cd ../../smart-contracts export ETH_RPC_URL=http://localhost:8545 ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -
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 -
ZkBankコントラクトをデプロイします。ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'` echo $ZKBANK_ADDRESS0x199..67bという値は、Accountsの初期状態のPedersonハッシュです。server/index.mjsでこの初期状態を変更した場合、トランザクションを実行して、ゼロ知識証明によって報告される初期ハッシュを確認できます。 -
サーバーを起動します。
cd ../server npm run start -
別のコマンドラインウィンドウでクライアントを実行します。
cd client npm run dev -
いくつかのトランザクションを実行します。
-
状態がオンチェーンで変更されたことを確認するには、サーバープロセスを再起動します。トランザクション内の元のハッシュ値がオンチェーンに保存されているハッシュ値と異なるため、
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
server/index.mjs
このファイルの変更は、主に実際の証明の作成とオンチェーンへの送信に関連しています。
import { exec } from 'child_process'
import util from 'util'
const execPromise = util.promisify(exec)
オンチェーンに送信する実際の証明を作成するには、Barretenbergパッケージ (新しいタブで開きます)を使用する必要があります。このパッケージは、コマンドラインインターフェース(bb)を実行するか、JavaScriptライブラリのbb.js (新しいタブで開きます)を使用することで利用できます。JavaScriptライブラリはネイティブにコードを実行するよりもはるかに遅いため、ここではexec (新しいタブで開きます)を使用してコマンドラインを使用します。
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}`)
各アドレスは各ナンスを一度しか使用しないため、ウィットネスファイルと出力ディレクトリの一意の識別子としてfromAddressとnonceの組み合わせを使用できます。
try {
await zkBank.write.processTransaction([
proof, publicFields])
} catch (err) {
console.log(`Verification error: ${err}`)
throw Error("Can't verify the transaction onchain")
}
.
.
.
}
トランザクションをチェーンに送信します。
smart-contracts/src/ZkBank.sol
これはトランザクションを受信するオンチェーンのコードです。
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.21;
import {HonkVerifier} from "./Verifier.sol";
contract ZkBank {
HonkVerifier immutable myVerifier;
bytes32 currentStateHash;
constructor(address _verifierAddress, bytes32 _initialStateHash) {
currentStateHash = _initialStateHash;
myVerifier = HonkVerifier(_verifierAddress);
}
オンチェーンのコードは、検証者(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);
検証者コントラクトを呼び出して、ゼロ知識証明を検証します。ゼロ知識証明が間違っている場合、このステップはトランザクションを元に戻します。
currentStateHash = _publicFields[1];
emit TransactionProcessed(
_publicFields[2]<<128 | _publicFields[3],
_publicFields[0],
_publicFields[1]
);
}
}
すべてが確認されたら、状態のハッシュを新しい値に更新し、TransactionProcessedイベントを発行します。
中央集権型コンポーネントによる悪用
情報セキュリティは3つの属性で構成されています:
- 機密性、ユーザーは読む権限のない情報を読むことができません。
- 完全性、情報は許可されたユーザーによって許可された方法でのみ変更できます。
- 可用性、許可されたユーザーはシステムを使用できます。
このシステムでは、完全性はゼロ知識証明を通じて提供されます。銀行は各アカウントの残高とすべてのトランザクションを知る必要があるため、可用性を保証するのははるかに難しく、機密性は不可能です。情報を持っているエンティティがその情報を共有するのを防ぐ方法はありません。
ステルスアドレス (新しいタブで開きます)を使用して真に機密性の高い銀行を作成することは可能かもしれませんが、それはこの記事の範囲外です。
虚偽の情報
サーバーが完全性に違反する1つの方法は、データが要求された (新しいタブで開きます)ときに虚偽の情報を提供することです。
これを解決するために、アカウントをプライベート入力として受け取り、情報が要求されたアドレスを公開入力として受け取る2つ目のNoirプログラムを作成できます。出力は、そのアドレスの残高とナンス、およびアカウントのハッシュです。
もちろん、ナンスと残高をオンチェーンに投稿したくないため、この証明はオンチェーンで検証することはできません。しかし、ブラウザで実行されているクライアントコードによって検証することはできます。
強制トランザクション
L2で可用性を確保し、検閲を防ぐための通常のメカニズムは強制トランザクション (新しいタブで開きます)です。しかし、強制トランザクションはゼロ知識証明と組み合わせることができません。サーバーはトランザクションを検証できる唯一のエンティティです。
smart-contracts/src/ZkBank.solを変更して強制トランザクションを受け入れ、それらが処理されるまでサーバーが状態を変更できないようにすることができます。しかし、これにより単純なサービス拒否(DoS)攻撃に対して無防備になります。強制トランザクションが無効であり、したがって処理が不可能な場合はどうなるでしょうか?
解決策は、強制トランザクションが無効であることを示すゼロ知識証明を持つことです。これにより、サーバーには3つの選択肢が与えられます:
- 強制トランザクションを処理し、それが処理されたというゼロ知識証明と新しい状態のハッシュを提供する。
- 強制トランザクションを拒否し、トランザクションが無効である(不明なアドレス、不正なナンス、または残高不足)というゼロ知識証明をコントラクトに提供する。
- 強制トランザクションを無視する。サーバーに実際にトランザクションを処理させる方法はありませんが、それはシステム全体が利用できないことを意味します。
可用性ボンド(保証金)
実際の実装では、サーバーを稼働させ続けるための何らかの利益動機がおそらくあるでしょう。強制トランザクションが一定期間内に処理されない場合、誰でもバーンできる可用性ボンドをサーバーに投稿させることで、このインセンティブを強化できます。
不正なNoirコード
通常、スマート・コントラクトを人々に信頼してもらうために、ソースコードをブロック・エクスプローラー (新しいタブで開きます)にアップロードします。しかし、ゼロ知識証明の場合、それでは不十分です。
Verifier.solには検証キーが含まれており、これはNoirプログラムの関数です。しかし、そのキーはNoirプログラムが何であったかを教えてくれません。実際に信頼できるソリューションを持つには、Noirプログラム(およびそれを作成したバージョン)をアップロードする必要があります。そうしないと、ゼロ知識証明がバックドアを持つ別のプログラムを反映している可能性があります。
ブロック・エクスプローラーがNoirプログラムのアップロードと検証を許可し始めるまでは、自分で行う必要があります(できればIPFSに)。そうすれば、洗練されたユーザーはソースコードをダウンロードし、自分でコンパイルしてVerifier.solを作成し、それがオンチェーンのものと同一であることを検証できるようになります。
結論
プラズマ型のアプリケーションは、情報ストレージとして中央集権型のコンポーネントを必要とします。これにより潜在的な脆弱性が生じますが、その見返りとして、ブロックチェーン自体では利用できない方法でプライバシーを保護することができます。ゼロ知識証明を使用することで、完全性を確保し、中央集権型のコンポーネントを運営する人が可用性を維持することを経済的に有利にする可能性があります。
私の他の作品についてはこちらをご覧ください (新しいタブで開きます)。
謝辞
- Josh Critesは、この記事の草稿を読み、Noirの厄介な問題について私を助けてくれました。
残っている誤りはすべて私の責任です。