はじめに

初めまして。 『DApps開発入門』という本や色々記事を書いているかるでねです。

以下でも情報発信しているので、興味ある記事があればぜひ読んでみてください!

ERC8290は、あるプライバシープールで使えない状態にした資産記録を証明し、別のプライバシープールで同じ資産として受け入れるための仕組みです。 公開アドレスを経由して引き出し直す代わりに、送信元の記録、送信先、受取人、資産の種類をゼロ知識証明で結び付けます。

ERC8290の仕様は以下です。

概要

提案の簡単な理解

ERC8290は、利用者の資産や取引の対応関係を公開しないまま、あるサービスから別のサービスへ資産を移すためのEthereumの提案です。

従来は、いったん公開されたアドレスへ資産を引き出し、移動先へ預け直す必要がありました。 この方法では、移動前後の取引を結び付けられる可能性があります。

ERC8290では、移動元で使えなくした資産だけを、移動先が確認して受け入れます。 移動元と移動先で内部のデータ形式が異なっていても、必要な確認をそろえれば連携できます。

仕組みは3段階です。 移動元は、移動先と受取人を指定した資産記録を作り、移動元では使えない状態にします。 移動先は、移動元にその記録があったこと、受け入れてよい移動元であること、同じ記録を使っていないことを確認します。 確認できた時だけ、移動先は受取人のための新しい資産記録を作ります。

flowchart LR
    A[移動元で使えない状態にする] --> B[移動先が3つの条件を確認する]
    B --> C[受取人のための新しい記録を作る]

プライバシープールは、資産を預かり、誰のどの資産かを公開せずに使えるようにする仕組みです。 利用者が資産を預けると、プールは秘密の資産記録を利用者へ渡し、その記録から計算した公開値だけをオンチェーンへ追加します。

ゼロ知識証明は、秘密そのものを見せずに、ある条件を満たすことだけを証明する暗号技術です。 利用者は資産記録を公開せずに、「このプールに記録済みの資産を使う資格がある」ことをコントラクトへ示せます。

プライバシープールが資産を記録する方法

noteは、資産の種類や金額と、それを使える人を特定する秘密の値を持つ記録です。 利用者はnoteを自分で保管します。 プールはnoteの中身を保管せず、そこから計算したコミットメントをオンチェーンへ記録します。

利用者が100 USDCを預ける時、noteには以下の値を入れます。

js
note = {
  token: "USDC",
  amount: "100",
  ownerSecret: "0x8f…c2",
  blinding: "0xa4…19"
}

tokenとamountは何をどれだけ預けたかを表します。 ownerSecretは、そのnoteを使える人だけが持つ秘密です。 blindingは、同じ資産と金額でも公開値が単純に一致しないように加える秘密の乱数です。

flowchart LR
    A[利用者が保管するnote] -->|秘密の値を含めて計算する| B[コミットメント]
    B -->|オンチェーンへ記録する| C[プライバシープール]
    A -.->|中身は公開しない| D[ownerSecretとblinding]

コミットメントは、秘密の記録の内容から計算して公開する、改ざんを検出できる短い値です。 利用者はnoteを使う時に、秘密の値を使って「このコミットメントは自分のnoteから作られた」と証明します。

先ほどの100 USDCのnoteから計算した公開値は以下です。

js
commitment = "0x5a…e9"

オンチェーンに追加するのはこのcommitmentです。 token、amount、ownerSecret、blindingの中身は追加しません。 同じ100 USDCでもblindingが違えば別のcommitmentになります。

プールは多数のコミットメントをMerkle treeへまとめます。 Merkle treeは、多数の記録を1つのハッシュ値で表せるツリー構造です。 プールはツリー全体を表すrootをオンチェーンへ記録します。 利用者は自分のコミットメントからrootまでの経路を証明に入れ、コントラクトはその経路を確認します。

例えば、プールに3件のcommitmentがある時、オンチェーンには3件の中身ではなくツリー全体を表すrootを記録します。

js
commitments = ["0x5a…e9", "0x24…71", "0xb8…0c"]
root = "0x67…af"

利用者は自分のcommitment: "0x5a…e9"からroot: "0x67…af"までの経路だけを証明へ入れます。 他の2件の秘密のnoteを知る必要はありません。

flowchart BT
    C1[commitment<br/>0x5a…e9] --> H1[中間ハッシュ]
    C2[commitment<br/>0x24…71] --> H1
    C3[commitment<br/>0xb8…0c] --> H2[中間ハッシュ]
    H1 --> R[root<br/>0x67…af]
    H2 --> R

利用者はcommitment: "0x5a…e9"からrootまでにつながるハッシュだけを使います。 送信先はその経路を確認して、記録がこのツリーに入っていたことを確かめます。

ゼロ知識証明とは?

利用者は秘密のnoteを入力にして証明を作ります。 コントラクトが受け取るのは証明、公開されたroot、使用済みを表す値です。 コントラクトは資産記録が存在し未使用であることを確認できますが、どのnoteかや誰の資産かは知りません。

コミットメントと記録のツリー構造とは?

コミットメントは1件の秘密の記録に対応する公開値です。 Merkle treeは多数のコミットメントをまとめる構造で、プールが管理します。 利用者は自分のコミットメントがツリーに入っている経路を証明し、コントラクトは公開されたrootと照合します。

ERC8290が行うこと

別のプライバシープールへ移る時は、送信元から公開アドレスへ引き出し、送信先へ預け直す方法が一般的です。 この2つのトランザクションを並べると、資産、金額、時刻、送信元と送信先の組み合わせを追跡しやすくなります。

ERC8290では、送信元が送信先専用のnoteを作ります。 送信先は、そのnoteが送信元に存在したこと、送信元を信頼できること、同じ記録をすでに使っていないことを確認します。 確認が終わると、送信先は対応する新しいnoteを作るか、送信先の規則に従って引き出しを処理します。

flowchart LR
    A[送信元プール] -->|送信先専用の記録を作る| B[ゼロ知識証明]
    B -->|送信元の記録と信頼する送信元を確認する| C[送信先プール]
    C -->|新しい記録を作る または 引き出しを処理する| D[受取人]

この仕組みで送信元から送信先へ資産そのものを送るわけではありません。 送信先は、送信元の資産記録が自分向けに作られ、正しく使えない状態になったことを検証してから、価値を受け入れます。

送信元の記録を証明で送信先へ取り込む全体像

動機

プライバシープールごとに、noteの作り方、コミットメントの計算方法、Merkle tree、使用済みを表す値が異なります。 そのため、あるプールのnoteを別のプールへそのまま渡しても、送信先は記録の正しさを確認できません。

全てのプールへ同じ形式を使わせると、既存のプールは参加しにくくなります。 ERC8290は、送信元ごとの違いをアダプターで扱い、送信先がどの送信元を受け入れるかを別の仕組みで決めます。

一方で、送信元でも使えるnoteを送信先で受け入れると、同じ価値を2回使えます。 ERC8290は、送信元で通常利用できないこと、送信元の記録が存在したこと、送信先が信頼する送信元であることをまとめて確認します。

仕様

送信先専用のnoteを作る

送信元は、通常の受取人の代わりにburn addressを使ってnoteを作ります。 burn addressは、送信先のチェーンID、送信先プール、受取人、今回だけの秘密値から計算する値です。

js
burnAddress = H(
    chainId,
    dstPoolAddress,
    receiver,
    burnSecret,
    "ZKTELEPORT"
)

Hはプールの回路で使うハッシュ関数です。 burnSecretは今回の移動だけに使う秘密の乱数です。 仕様は少なくとも128ビットの秘密値を使うことを勧めています。

送信先がAvalanche上のプールで、受取人が0x9b…71だとします。 その時、burn addressには以下の値を入力します。

js
destination = {
  chainId: "43114",
  pool: "0x6e…24",
  receiver: "0x9b…71",
  burnSecret: "0x3d…8a"
}

この4つの値のどれかを変えると、計算されるburn addressも変わります。 そのため、送信先チェーン、送信先プール、受取人を入れ替えて同じ記録を使うことはできません。

送信先の情報からburn addressを計算する

送信先チェーン、送信先プール、受取人をburn addressへ入れるため、別のチェーン、別のプール、別の受取人に同じ証明を持ち込めません。 送信元で作るコミットメントもこの値を含むため、送信先だけが取り込める記録になります。

標準的な送信元プールでは、受取人を隠した値と資産データからコミットメントを作ります。 ERC8290では、この受取人にburn addressを入れます。

js
blindedOwner = H(ownerAddress, blinding)
commitment = H(blindedOwner, data)

// ERC8290の送信元では ownerAddress = burnAddress

回路が扱うハッシュ関数、数値の形式、入力をバイト列にする規則は、実装ごとに一致させます。 検証コントラクトへ公開入力を渡す時は32バイトの表現を使います。 短い値は左側を0で埋め、長い値はアダプターまたは送信先が定めた変換かハッシュで1つの表現に決めます。

送信元で作ったnoteを通常の送付に使えない状態にする方法は、プールごとに異なります。 所有者を使えない値へ変える方法、送信元の使用済み値を消費したことを示す方法、送信元独自のlock条件を示す方法があります。 送信先は、その方法を定めたアダプターを持つ送信元だけを受け入れます。

例えば送信元がlockを使うプールなら、移動の前後は以下です。

stateDiagram-v2
    [*] --> 通常のnote
    通常のnote --> 送信先専用のnote: burn addressで作る
    送信先専用のnote --> 送信元で使えない: lockを確認する
    送信元で使えない --> teleport proofを作る

送信先は、最後の状態だけを受け入れます。 まだ通常の送付に使えるnoteは取り込みません。

送信元の記録と信頼する送信元を確認する

teleport proofは、送信元のnoteを公開せずに2つの所属関係を示すゼロ知識証明です。 1つ目は、burn addressを使って作ったコミットメントが送信元プールの記録に入っていたことです。 2つ目は、その送信元のsource rootを送信先が受け入れていることです。

送信先が見る公開値は以下です。

js
teleportInput = {
  burnCommitment: "0x5a…e9",
  sourceRoot: "0x67…af",
  canonicalRoot: "0x9d…31",
  destinationPool: "0x6e…24"
}

証明の作成に使うnote、burnSecret、ツリーの経路は利用者側に残します。 送信先は公開値と証明を照合し、記録の中身を見ずに2つの所属関係を確認します。

flowchart LR
    A[burn commitment] -->|送信元の記録ツリーに含まれる| B[source root]
    B -->|信頼する送信元ツリーに含まれる| C[canonical root]
    C -->|送信先が受け入れる| D[teleport proofを検証]

送信元の記録と送信先が信頼する記録を二段で確かめる

送信元プールは、コミットメントをMerkle treeへ追加し、ツリー全体を表すsource rootを持ちます。 利用者は、burn commitmentからsource rootまでの経路を証明に入れます。 送信先はこの経路を検証して、送信元にその記録があったことを確認します。

canonical treeは、送信先が受け入れてよいsource rootをまとめたものです。 送信先プールは、証明に含まれるsource rootが、送信先が信頼するcanonical treeに含まれるかを確認します。

canonical treeには、送信元チェーンID、送信元プールのアドレス、ブロック番号、資産情報、source rootを結び付けた値を入れられます。 そのため送信先は、単に見覚えのあるハッシュ値ではなく、どのプールのどの時点の記録を受け入れるかまで決められます。

例えば送信先が受け入れる送信元は、以下の情報を1組として登録できます。

js
trustedSource = {
  sourceChainId: "1",
  sourcePool: "0x47…a0",
  blockNumber: "20800000",
  asset: "USDC",
  sourceRoot: "0x6c…ef"
}

この登録がある時だけ、sourceRootを含む証明を受け入れます。

送信先はcanonical rootを自分で保存しても、許可済みのrootを返すコントラクトへ照会しても構いません。 仕様は以下の呼び出しを例にしています。

js
function isValidRoot(bytes32 root) external view returns (bool);

送信先が取り込みを確定する

有効なteleport proofだけでは、送信先で資産を作ってよい理由にはなりません。 送信先は、二重利用の防止、資産の裏付け、受取人の認可を確認してから取り込みを確定します。

送信先が受け入れ前に確認する4つの条件

  • 「送信元で通常利用できないこと」

    送信元でも使えるnoteを送信先で取り込むと、同じ価値を2つの場所で使えます。 アダプターは、送信元でnoteをburnまたはlockしたことを証明に含めます。

  • 「nullifierが未使用であること」

    nullifierは、ある秘密のnoteを使ったことを表す一意の値です。 送信先は取り込みの完了前または完了時にこの値を使用済みとして記録します。 同じnullifierが出た時は受け入れないため、同じ送信元の記録を繰り返し取り込めません。

    1件目の取り込みでnullifier: "0x91…d4"を記録した後、同じ値を含む2件目の取り込みは拒否します。

  • 「価値の裏付けがあること」

    送信先は、エスクロー資金、burnとmintの会計、プロトコルが保有する在庫、ブリッジ決済など、受け入れる価値を何で裏付けるかを定めます。 証明だけを根拠に資産を増やしません。

  • 「受取人が今回の取り込みを認めていること」

    送信先は、受取人が今回の取り込みを認めたことを署名で確認できます。 EIP712形式の署名を使う時は、回路がburn addressに入れた受取人と署名者の一致を確認します。 コントラクトは、証明に入った操作ダイジェストと、受け取った操作内容のダイジェストが一致することを確認します。

EIP712については以下の記事を参考にしてください。

送信先が作る出力は、送信元のasset contextを保ちます。 asset contextは、トークンアドレス、トークンID、額面、金額、送信元プールのアドレス、送信元チェーンIDなど、資産を特定する情報です。 証明はこの情報と送信先の出力を結び付けるため、取り込む人が金額やトークンを変更すると検証に失敗します。

100 USDCの取り込みでは、asset contextを以下の値で扱えます。

js
assetContext = {
  token: "USDC",
  amount: "100",
  sourceChainId: "1",
  sourcePool: "0x47…a0"
}

送信先がamount: "1000"や別のトークンを出力へ入れようとすると、証明が結び付けた値と一致しないため取り込みを完了できません。

形式が異なるプールをアダプターで接続する

既存プールのnote形式は同じではありません。 ERC8290のアダプターは、送信元固有の記録形式を、送信先が検証できる条件へ対応付ける規則です。

送信元ごとの記録形式をアダプターで証明へ読み替える

アダプターは少なくとも、送信元チェーンID、送信元のコミットメントハッシュ、送信元のMerkle treeのハッシュ関数と深さ、canonical treeの証明形式、asset contextの表現、burnまたはlock条件を定めます。

例えば、送信元がsourceLeaf = H(burnAddress, noteSecret)という形式なら、アダプターはその計算方法と送信元ツリーへの所属確認を回路へ組み込みます。 送信元のnote形式が異なっても、送信先チェーン、送信先プール、受取人、送信元rootへの所属、asset contextの保持は弱められません。

設計根拠

移動先を最初から固定する

burn addressへ送信先チェーン、送信先プール、受取人を入れることで、送信元の記録を別の場所へ再利用できなくします。 送信先は、自分向けに作られた記録だけを受け入れます。

信頼する送信元を回路の外で管理する

canonical treeを回路の外側で更新できるようにすると、回路を作り直さずに信頼する送信元の範囲を変更できます。 送信先は、同じnote形式を使うプールだけでなく、アダプターを持つ異なる形式のプールも受け入れられます。

価値の裏付けを送信先の責務にする

価値の裏付け方針を仕様が1つに固定しないのは、送信先によって資金の保管、会計、決済の方法が異なるためです。 ただし送信先は、裏付けを明示し確認できた時だけ、新しいnoteまたは引き出しを処理します。

互換性

既存プールを送信元として使う

ERC8290は既存のプライバシープールの入金、送付、引き出しを変えません。 既存プールが送信元として参加するには、そのプールのコミットメント形式とrootへの所属確認をアダプターで表し、送信先が信頼するcanonical treeへ送信元のrootを追加します。

送信先に取り込み処理を追加する

更新可能な送信先プールは、既存の処理を置き換えず、teleport proofを受け入れる取り込み処理を追加できます。

参考実装

証明回路が確認すること

仕様の参考実装は、1件の送信元noteから1件の送信先出力を作る回路を示しています。 回路はburn addressとburn commitmentを計算し、送信元rootとcanonical rootへの所属を順に確認します。

js
let burn_address = hash([
    chain_id as Field,
    dst_pool_address,
    receiver,
    burn_secret,
    Field::from_be_bytes("ZKTELEPORT".as_bytes()),
]);

assert_merkle_leaf_membership(
    src_pool_root,
    burn_commitment,
    note.index,
    note.siblings,
);

assert_merkle_leaf_membership(
    canonical_root,
    canonical_leaf,
    canonical_index,
    canonical_siblings,
);

送信先コントラクトが確認すること

送信先コントラクトは、canonical rootを受け入れるかを確認してから証明を検証します。 続いてnullifierを使用済みにし、価値の裏付けを確認して、送信先のコミットメントまたは引き出しを処理します。

js
function teleport(Joinsplit calldata joinsplit) external {
    require(canonicalRootRegistry.isValidRoot(joinsplit.root), "invalid canonical root");
    _verifyTeleportProof(joinsplit);
    _spendNullifiers(joinsplit.nullifiers);
    _enforceBackingPolicy(joinsplit);
    _insertCommitments(joinsplit.commitments);
    _executeWithdrawals(joinsplit.withdrawals);
}

このコードは処理順を示す疑似コードです。 実際のコントラクト名、型、公開関数のシグネチャはERC8290で固定されていません。

セキュリティ

送信元で使えない記録だけを受け入れる

送信先は、送信元のnoteが通常利用できないことを確認せずに取り込んではいけません。 送信元でも使える記録を送信先で使えるようにすると、同じ価値を2回使えます。

burnSecretを再利用しない

burnSecretは証明を作るまで秘密にします。 同じburnSecretを複数の移動で再利用すると、記録を関連付けられる原因になります。

nullifierと宛先情報で二重取り込みを防ぐ

送信先はnullifierを記録し、同じ値を受け入れません。 さらにburn addressと認可メッセージへ送信先チェーンと送信先プールを入れ、別の送信先への再利用を防ぎます。

アダプターと匿名性の前提を確認する

アダプターのハッシュ関数、バイト順、フィールド要素への変換、トークンの表し方、Merkle treeの深さが送信元とずれると、正しい記録を取り込めないか、検証の前提が崩れます。 送信先はアダプターを独立した信頼対象として確認します。

送信時刻、匿名集合の小ささ、canonical rootの更新時刻から、送信元と送信先の候補を絞られる場合があります。 仕様は、rootの更新と取り込みをまとめて実行する方法を勧めています。

最後に

ERC8290は、プライバシープール間の移動を、公開アドレスを経由する資産送付から、送信元で使えなくした資産記録の証明へ置き換えます。 送信先は、送信先への結び付け、送信元と信頼する送信元の確認、nullifier、資産の裏付け、認可を確認してから取り込みを処理します。 アダプターとcanonical treeを使うことで、異なるnote形式を使う既存プールも、条件を満たせば参加できます。

他でも色々記事を書いているのでぜひよろしければ読んでいってください!