# [ERC8290] プライバシープール間で資産を移す仕組みを理解しよう！

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

- URL: https://cardene.dev/articles/c6172fb33abb45b11fa6
- 公開日: 2026-08-14
- 更新日: 2026-08-14
- トピック: Blockchain
- タグ: erc, blockchain, smartcontract, zkp

## はじめに

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

https://amzn.asia/d/gxvJ0Pw

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

https://cardene.notion.site/EIP-2a03fa3ea33d43baa9ed82288f98d4a9

https://twitter.com/cardene777

https://zenn.dev/heku

https://cardene.substack.com/

https://mirror.xyz/0xcE77b9fCd390847627c84359fC1Bc02fC78f0e58

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

ERC8290の仕様は以下です。

https://eips.ethereum.org/EIPS/eip-8290

## 概要

#### 提案の簡単な理解

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

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

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

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

```mermaid
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`は、同じ資産と金額でも公開値が単純に一致しないように加える秘密の乱数です。

```mermaid
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`を知る必要はありません。

```mermaid
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`までにつながるハッシュだけを使います。
送信先はその経路を確認して、記録がこのツリーに入っていたことを確かめます。

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

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

#### ERC8290が行うこと

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

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

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

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

![送信元の記録を証明で送信先へ取り込む全体像](/images/qiita-bb01f9b0-8bfd-4324-b68d-db5864bcdfae.webp)

## 動機

プライバシープールごとに、`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を計算する](/images/qiita-7f216818-e41e-4f00-849e-125e75eba3f7.webp)

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

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

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

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

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

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

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

```mermaid
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つの所属関係を確認します。

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

![送信元の記録と送信先が信頼する記録を二段で確かめる](/images/qiita-2e246646-6225-4b89-b806-c5130cd596fa.webp)

送信元プールは、コミットメントを`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つの条件](/images/qiita-c7d93aea-6e32-4f75-8c9b-4e1009ff2f56.webp)

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

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

- 「**`nullifier`が未使用であること**」

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

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

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

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

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

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

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

[\[EIP712\] 型付き構造化データのハッシュ化と署名の仕組みを理解しよう！](/articles/f54bc85cb66de5a54257)

送信先が作る出力は、送信元の`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のアダプターは、送信元固有の記録形式を、送信先が検証できる条件へ対応付ける規則です。

![送信元ごとの記録形式をアダプターで証明へ読み替える](/images/qiita-4606b163-3569-422c-b985-cb47ea21bc12.webp)

アダプターは少なくとも、送信元チェーン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`形式を使う既存プールも、条件を満たせば参加できます。

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

https://amzn.asia/d/gxvJ0Pw

https://cardene.notion.site/EIP-2a03fa3ea33d43baa9ed82288f98d4a9

https://zenn.dev/heku

https://mirror.xyz/0xcE77b9fCd390847627c84359fC1Bc02fC78f0e58

https://twitter.com/cardene777

https://cardene.substack.com/
