はじめに

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

今回は、EVMチェーン同士を効率よく接続し、複数のブリッジが同じメッセージを共有できる仕組みを提案しているERC7533についてまとめていきます!

以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。

他にも様々なEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。

概要

ERC7533が提案するPCP(Public Cross Port)は、複数のEVM互換チェーンを「安全かつ効率的に」つなぐことを目的にした仕組みです。 特徴は、これまで一般的だった「送信側から各チェーンへ個別にメッセージを送りつける方式(プッシュ型)」ではなく、「受信側が複数チェーンからメッセージをまとめて取りに行く方式(プル型)」を採用している点にあります。

プル型にすることで、必要なクロスチェーンブリッジの本数を大きく減らせるため、ガスコストを抑えられます。 さらに、さまざまなブリッジプロジェクトがPCPの上に乗るほど、全体としての安全性も高まるように設計されています。

PCPでは、チェーン間メッセージを扱う基本レイヤーに「SendPort」コントラクトと「IReceivePort」インターフェースを定義します。 アプリケーション側は、この基盤の上に「トークンブリッジ」、「NFTブリッジ」、「クロスチェーンスワップ」などを自由に実装していくイメージです。

図にすると、以下のような3層構造になります。

アプリ層(例) メッセージブリッジ層 ポート層
Token cross / NFT cross / Cross swap Message bridge A / Message bridge B SendPort / IReceivePort

上の層ほどアプリに近く、下の層ほど共通インフラに近いイメージです。 下のポート層だけを標準化することで、上の層は各プロジェクトが自由に差別化しつつも、安全性と相互運用性を得られる構造になっています。

動機

L2同士を直接つなぐとブリッジが爆発する問題

現在は、L2とL1の間には公式ブリッジが用意されているケースが多い一方で、L2同士を直接つなぐ仕組みは整っていません。 もし L2チェーンが10本あって、それぞれが相互にクロスチェーン通信したいとします。 素直にすべての組み合わせで専用ブリッジを作ると、必要なブリッジ数は次のようになります。

  • 10本のチェーン同士の組み合わせ = 10 × 9 = 90 本のクロスチェーンブリッジ

90本というのは、運用・監査コストもセキュリティリスクも非常に高くなります。 また、ブリッジごとにトランザクションを投げる必要があるため、ユーザーが支払うガスコストも大きくなります。

PCPはここで発想を変え、「それぞれのチェーンが他のチェーンのメッセージを『取りに行く』」というプル型の仕組みを導入します。 具体的には、1つのチェーンが他の9本のチェーンのメッセージをまとめて取り込み、それを自分のチェーン上で1つのトランザクションとして同期します。 この方式では、各チェーンが自分用のブリッジだけを持てばよいので、必要なブリッジ数は次のようになります。

  • 各チェーンが「自分側への入り口」を1つ持つだけ → 合計10本のクロスチェーンブリッジ

つまり、90本必要だったブリッジが10本で済み、ブリッジ数とガスコストを大幅に削減できます。

冗長なブリッジを「無駄」ではなく「安全性」に変える

現在のクロスチェーン界隈では、似たようなブリッジが多く乱立しています。 しかし、それぞれが独自にチェーンをつないでいるだけでは、安全性が足し合わされるわけではありません。 むしろ、どれか1つのブリッジが攻撃されるだけで資産が危険にさらされる、という問題があります。

PCPは、この「冗長さ」を安全性に変える狙いがあります。 基礎レイヤーとして標準化されたSendPortコントラクトを使うことで、同じクロスチェーンメッセージを複数のブリッジが運ぶことができます。 そして、到着先のチェーンではIReceivePortインターフェースを通じて、そのメッセージが正しいかどうかを検証します。

重要なのは、同じメッセージであれば、どのブリッジ経由で運ばれてきても検証結果が同じになるように設計されている点です。 そのため、複数のブリッジから同じ結果が返ってきたかどうかを照合することで、「1本のブリッジだけを信用する」よりも、はるかに高い安全性を実現できます。

PCPは、より多くのクロスチェーンブリッジプロジェクトにこの標準に参加してもらうことで、今ある「重複したブリッジ」を、セキュリティを高めるための「多重チェック」に変えていくことを狙っています。

メッセージ追加コストを抑えるためのHash MerkleTree

ブリッジプロジェクトに参加してもらうには、「安全になるが負担は増える」という状態では続きません。 PCPでは、SendPort側のデータ構造として「ハッシュを使ったマークルツリー(Hash MerkleTree)」を採用することで、クロスチェーンメッセージをいくつ追加しても、ブリッジ側の負担がほとんど増えないようにしています。

マークルツリーは、多数のデータを階層的にハッシュでまとめ、最終的に1つのルートハッシュに集約する構造です。 PCPでは、個々のクロスチェーンメッセージをツリーの葉に置き、それらをまとめた1つの「ルートハッシュ」だけをブリッジで運べばよい形になっています。

その結果、ブリッジが運ぶデータは小さいサイズのルートハッシュだけで済みます。 同じトランザクションに多くのメッセージを含めても、ブリッジが運ぶデータ量はほぼ一定なので、ガスコストの節約につながります。 つまり、「たくさんのプロジェクトがメッセージを追加しても、ブリッジのオーバーヘッドは増えない」ことを目指した設計になっています。

ユースケース

3層構造での役割分担

PCPは、クロスチェーンのエコシステム全体を3つのレイヤーに分けて考えています。

  1. 最下層: SendPortコントラクトとIReceivePortインターフェース ここが共通インフラとなるレイヤーで、チェーン間メッセージをどう登録し、どう受け取るかを定義します。
  2. 中間層:メッセージブリッジ(Message bridge A / Message bridge B など) 各ブリッジプロジェクトが実装するレイヤーです。 SendPortから取得したルートハッシュを他チェーンへ運び、IReceivePortに渡します。 複数のブリッジが並存し、それぞれが同じメッセージを運ぶこともできます。
  3. 最上層:アプリケーション(Token cross / NFT cross / Cross swap など) 実際のユーザー向け機能を実装するレイヤーです。 トークンの送付、NFT の移転、異なるチェーン同士のスワップなどを、この共通メッセージ基盤の上で構築します。

PCPが標準として定義するのは1のポートレイヤーだけで、2と3の実装はエコシステムの参加プロジェクトに任せる形になっています。 これにより、最低限の共通仕様で相互運用性を確保しつつ、実装の自由度も保てるようになっています。

メッセージブリッジを「サービス」として再利用する

アプリケーションは、ポートレイヤーとメッセージブリッジレイヤーを「メッセージ配送サービス」として扱うことができます。 例えば、トークンのクロスチェーン転送(Token cross)で使っているメッセージブリッジを、そのままNFTのクロスチェーン(NFT cross)でも再利用することができます。

さらに、1つのアプリケーションが複数のメッセージブリッジを同時に利用することも想定されています。 たとえば、NFT crossのアプリケーションが、複数のトークンブリッジのメッセージ機能をまとめて使い、その結果をIReceivePort側で検証に利用する、といった形です。

このときのポイントは、複数のブリッジを併用しても、追加でクロスチェーン手数料や検証コストが発生しないように設計されていることです。 同じメッセージを複数のブリッジが運び、IReceivePortで結果を突き合わせることで、安全性だけを大きく引き上げることができます。

Token crossとNFT crossの関係

Token cross(トークンのクロスチェーン送付)とメッセージブリッジを組み合わせたコード例は、Reference Implementationで示されている想定です。 ここでは、トークン送付ロジックとメッセージ配送ロジックをひとまとめにした実装例になっていますが、これらを分離して実装することもできます。

例えば、NFT crossのアプリケーションを作る場合、トークン用に作られたメッセージブリッジをそのまま流用できます。 また、NFT crossが複数のメッセージブリッジの検証結果を使うことで、単一ブリッジよりも高い安全性を手に入れられます。 その時も、前述のマークルツリー構造とプル型設計のおかげで、ブリッジ側の追加コストはほとんど発生しません。

仕様

クロスチェーン通信は、以下の流れで進みます。

ステップ 説明
1. メッセージ追加 各チェーンのSendPortがイベント(メッセージ)を収集し、MerkleTreeへまとめる
2. ルート取得 & 設定 ブリッジ運搬者(package carrier)が複数チェーンからルートを取得し、IReceivePortに登録する
3. メッセージ検証 IReceivePortが保存しているルートを使い、受け取ったメッセージの正当性を検証する

メッセージ追加

PCPでは各チェーンにSendPortコントラクトを1つデプロイします。 SendPortの役割は「クロスチェーンメッセージを集めてMerkleTreeにまとめること」で、この処理を自動的・無権限で行います。

SendPortの重要なポイントは以下です。

  • パブリックで、権限管理がありません。誰でもメッセージを追加できます。
  • ブリッジ事業者がメッセージを取りに行き、別チェーンへ届ける前提です。
  • メッセージ送信元アドレスを葉(leaf)ノードに含めることで、悪意あるメッセージ送信を防ぎます。

SendPortの動作イメージ

  1. ブリッジ系のコントラクトがイベント(例:USDT を預け入れたという情報)を検知し、そのイベントのハッシュ(msgHash)と宛先チェーン ID(toChainId)をSendPortに渡す。
  2. SendPortは msgHash と送信元のコントラクトアドレスをまとめて1つの葉として配列に追加する。
  3. 一定量または一定時間(例:1分)で区切り、自動的にMerkleTreeを生成してルートを保存する。
  4. 次の収集フェーズが始まる。

なお、SendPortは自律稼働するため、1チェーンに1つだけデプロイすることが推奨されています。

悪意あるメッセージの扱い

addMsgHash() はpermissionless(無権限)なため、誤ったデータが送られる可能性そのものは排除できません。 しかし SendPortは「送信者アドレス」も葉に含めてパッキングするため、受信側で「どのコントラクトがそのメッセージを送ったか」を検証できます。 これにより、悪質なメッセージの混入を防ぐ足がかりになります。

ルート取得とルート登録

メッセージを収集し終えた SendPortは新しいMerkleTreeを作り、そのルート(root)を保持します。 ブリッジ運搬者(package carrier)は、複数のチェーンからこのルートを取得し、各チェーンのIReceivePort コントラクトに登録していきます。

IReceivePortはブリッジ運搬者が実装するコントラクトで、ひとつではなく複数存在します。

ルートの特徴

ルートは「1つの送信元チェーンから複数の宛先チェーンへのメッセージ」をまとめています。 そのため、あるブリッジ運搬者にとって次のようなケースがあります。

  • 自分に関係のない宛先チェーンへのメッセージしか含まれていない
  • 一部のチェーンに届ける必要がない

そのため、運搬者はそのルートをどのチェーンに届けるか自由に判断できます。

IReceivePortが複数存在する理由

PCPは1つの受信ポートを中央集権的に決めるのではなく、ブリッジプロジェクトごとにIReceivePortを持つ方式を採用します。 これにより、複数のブリッジが独立して同じSendPortのルートを運搬し、複数のIReceivePortが同じデータを保持する構造になります。

この構造は、次のステップで説明する「セキュリティ強化」の基盤になります。

クロスチェーンメッセージの検証

IReceivePort には、各チェーンから運ばれてきたルートが保存されています。 これにより、送信元チェーンから取り出した完全なメッセージ(msgHash だけでなく、葉のデータすべて)を使って、MerkleTree検証ができます。

重要な点は、ルートそのものにはメッセージ内容は含まれず、あくまで検証のための指紋(ハッシュの集約)であることです。 完全なメッセージは送信元チェーンのSendPortで取得します。

複数IReceivePortの一致がセキュリティを高める

SendPortが1つである以上、そこで生成されるルートは全ブリッジに対して共通のものになります。 そのため、異なるブリッジが運ぶルートは一致しているべきという前提があります。

この仕組みにより、以下のような性質が得られます。

状態 説明
多数のIReceivePortが同じメッセージを正しいと検証 メッセージが真正である可能性が高い
一部のIReceivePortが「正しくない」と判定 そのブリッジに障害または攻撃リスクがある可能性

これはマルチシグに似た考え方で、多数決により不正の検出が可能になります。 単一ブリッジに依存しないため、ブリッジ破綻や攻撃による単一点障害を避けられます。

データの完全性について

SendPortは過去のルートとインデックスを保持し続け、削除や変更を行いません。 IReceivePortもこれに従うべきとされています。

これにより、過去のメッセージ検証が常にできる状態が維持され、改ざんリスクを排除します。

ISendPort

ISendPort.sol
pragma solidity ^0.8.0;

interface ISendPort {
    event MsgHashAdded(uint indexed packageIndex, address sender, bytes32 msgHash, uint toChainId, bytes32 leaf);

    event Packed(uint indexed packageIndex, uint indexed packTime, bytes32 root);

    struct Package {
        uint packageIndex;
        bytes32 root;
        bytes32[] leaves;
        uint createTime;
        uint packTime;
    }

    function addMsgHash(bytes32 msgHash, uint toChainId) external;

    function pack() external;

    function getPackage(uint packageIndex) external view returns (Package memory);

    function getPendingPackage() external view returns (Package memory);
}

イベント

MsgHashAdded
javascript
event MsgHashAdded(
    uint indexed packageIndex,
    address sender,
    bytes32 msgHash,
    uint toChainId,
    bytes32 leaf
);

クロスチェーンメッセージがSendPortに追加された時に発行されるイベント。 addMsgHash() が呼ばれた時に生成されるイベントです。 メッセージのハッシュと宛先チェーンID、および送信者アドレスから計算された leaf 値が含まれます。 どのパッケージに属する追加なのかを識別するために packageIndex が付与されます。

パラメータ

  • packageIndex
    • メッセージが追加されたパッケージ番号。
  • sender
    • メッセージ送信者のコントラクトアドレス。
  • msgHash
    • 外部コントラクトから渡されたメッセージのハッシュ値。
  • toChainId
    • メッセージの宛先となるチェーンID。
  • leaf
    • msgHash・sender・toChainId から計算された leaf ハッシュ。
Packed
javascript
event Packed(
    uint indexed packageIndex,
    uint indexed packTime,
    bytes32 root
);

収集中のパッケージがMerkleTreeにパックされた時に発行されるイベント。 SendPortが保有している pending パッケージが一定周期または手動でパックされると発行されます。 パックされたパッケージにはMerkleTreeのルートが生成され、その値が root として記録されます。

パラメータ

  • packageIndex
    • パックされたパッケージの番号。
  • packTime
    • パッキングされたタイムスタンプ。
  • root
    • パッケージの内容から生成されたMerkleTreeのルートハッシュ。

構造体

Package
javascript
struct Package {
    uint packageIndex;
    bytes32 root;
    bytes32[] leaves;
    uint createTime;
    uint packTime;
}

クロスチェーンメッセージを一定期間まとめて保持する Package という構造体。 SendPortが受け取ったクロスチェーンメッセージを一定周期でまとめるためのデータ構造です。 複数のメッセージ(leaf)を保持し、それらをMerkleTreeにパックした後は root と packTime が設定されます。

パラメータ

  • packageIndex
    • パッケージの番号。
  • root
    • MerkleTreeにパックされた後に生成されるルートハッシュ。
  • leaves
    • 各クロスチェーンメッセージに対応する leaf ハッシュの配列。
  • createTime
    • パッケージが生成された時刻。
  • packTime
    • パッケージがパックされた時刻。

関数

addMsgHash
javascript
function addMsgHash(bytes32 msgHash, uint toChainId) external;

クロスチェーンメッセージのハッシュをSendPortに追加する関数。 外部コントラクトがクロスチェーンメッセージとして送信したい情報を登録するために使用します。 送信者アドレスは明示的に渡さなくても msg.sender から取得されます。 追加された leaf は現在の pending パッケージに格納されます。

引数

  • msgHash
    • メッセージ本体のハッシュ値。
  • toChainId
    • 宛先となるチェーンID。
pack
javascript
function pack() external;

現在のパッケージをMerkleTreeにパックする関数。 通常は最後のメッセージ送信者が自動的にトリガーしますが、一定時間経過してもパッキングが行われない場合は手動で呼び出すことができます。 パック後は Packed イベントが発行され、新しい pending パッケージが開始されます。

getPackage
javascript
function getPackage(uint packageIndex) external view returns (Package memory);

指定したパッケージ情報を取得する関数。 packageIndex に対応するパッケージを返します。 すでにパック済みのパッケージも、まだパックされていない pending パッケージも取得できます。

引数

  • packageIndex
    • 取得したいパッケージ番号。

戻り値

  • Package
    • 要求されたパッケージ情報。
getPendingPackage
javascript
function getPendingPackage() external view returns (Package memory);

現在収集中の pending パッケージを取得する関数。 まだパックされていない最新のパッケージを返します。 現在のメッセージ追加状況を確認したい場合に使用します。

戻り値

  • Package
    • 現在の pending パッケージ。

IReceivePort

IReceivePort.sol
pragma solidity ^0.8.0;

interface IReceivePort {
    event PackageReceived(uint indexed fromChainId, uint indexed packageIndex, bytes32 root);

    struct Package {
        uint fromChainId;
        uint packageIndex;
        bytes32 root;
    }

    function receivePackages(Package[] calldata packages) external;

    function getRoot(uint fromChainId, uint packageIndex) external view returns (bytes32);

    function verify(
        uint fromChainId,
        uint packageIndex,
        bytes32[] memory proof,
        bytes32 msgHash,
        address sender
    ) external view returns (bool);
}

イベント

PackageReceived
javascript
event PackageReceived(
    uint indexed fromChainId,
    uint indexed packageIndex,
    bytes32 root
);

外部チェーンから取得したパッケージのルートが登録された時に発行されるイベント。 ブリッジ運搬者がSendPortから取得したパッケージルートを登録する時に発行されます。 どのチェーンのどのパッケージを受け取ったかを識別できるようになっています。

パラメータ

  • fromChainId
    • パッケージが作られた送信元チェーンID。
  • packageIndex
    • パッケージ番号。
  • root
    • MerkleTreeのルートハッシュ。

構造体

Package
javascript
struct Package {
    uint fromChainId;
    uint packageIndex;
    bytes32 root;
}

外部チェーンのパッケージ情報を保持する Package という構造体。 IReceivePortが保持するパッケージ情報を表します。 各パッケージには送信元チェーンID、パッケージ番号、ルートハッシュが含まれます。

パラメータ

  • fromChainId
    • パッケージの作成元チェーンID。
  • packageIndex
    • パッケージ番号。
  • root
    • MerkleTreeのルートハッシュ。

関数

receivePackages
javascript
function receivePackages(Package[] calldata packages) external;

複数のパッケージルートを受け取り、IReceivePortに登録する関数。 ブリッジ運搬者が複数チェーンから取得したパッケージルートをまとめて登録するために使用します。 登録後は PackageReceived イベントが発行されます。

引数

  • packages
    • 受け取る複数のパッケージ情報。
getRoot
javascript
function getRoot(uint fromChainId, uint packageIndex) external view returns (bytes32);

指定したチェーンの指定パッケージのルートを取得する関数。 登録済みの root 情報を取得するための読み取り関数です。 メッセージ検証や整合性チェックに使用されます。

引数

  • fromChainId
    • 送信元チェーンID。
  • packageIndex
    • パッケージ番号。

戻り値

  • root
    • 要求されたパッケージのMerkleTreeルート。
verify
javascript
function verify(
    uint fromChainId,
    uint packageIndex,
    bytes32[] memory proof,
    bytes32 msgHash,
    address sender
) external view returns (bool);

指定したメッセージが送信元チェーンで正しく送られたものかを検証する関数。 fromChainId と packageIndex で取得できるMerkleRootと、与えられた proof を照合して、msgHash が実際にそのパッケージに含まれていたかを検証します。 sender も含めた leaf の一致を確認するため、送信者の偽装を防ぐことができます。

引数

  • fromChainId
    • メッセージが送られたチェーンID。
  • packageIndex
    • 対象パッケージ番号。
  • proof
    • MerkleTreeの検証に必要な proof。
  • msgHash
    • メッセージ本体のハッシュ。
  • sender
    • 送信元のコントラクトアドレス。

戻り値

  • bool
    • メッセージが正しい場合は true、不正または存在しない場合は false。

補足

従来のプッシュ方式

従来のクロスチェーン通信は、送信側チェーンが「他のチェーンへ直接メッセージを送りつける(push)」方式を採用しています。 これは一見シンプルですが、チェーン数が増えるほど必要なブリッジ数が爆発的に増える問題があります。

例えば、6つのチェーンが互いに通信したい場合、各チェーンは残り5つにメッセージを送り出す必要があります。 図にすると以下のようなイメージです。

その結果として必要なブリッジ数は次の計算式になります。

チェーン数 N 必要なブリッジ数
N N × (N - 1)

例えば 6チェーンであれば、6 × 5 = 30 ブリッジ が必要になります。

この方式には以下のような課題があります。

  • チェーンの増加に比例してブリッジが急増する。
  • ブリッジごとにガスコストが発生し、運用も複雑化する。
  • セキュリティ監査やメンテナンスのコストも連動して増える。

プル方式による大幅なブリッジ削減

PCPが採用するのは「受信側が他チェーンのメッセージをまとめて取りに行く(pull)」方式です。

つまり、自分のチェーンに同期したい他チェーンのメッセージを、ひとまとめのパッケージとして取得して1つのトランザクションで取り込みます。

図示すると以下のようになります。

ただし、ポイントは個別に取得するのではなく、まとめて取得できるところです。

これにより、必要なブリッジ数は大幅に減ります。

チェーン数 N 必要なブリッジ数
N N

6チェーンであれば、必要なブリッジ数は6本だけ。 プッシュ型と比べると以下のようになります。

方式 必要ブリッジ数 6チェーンの場合
プッシュ型 N × (N - 1) 30
プル型 N 6

プル型のメリットは以下です。

  • チェーン数が増えても必要なブリッジは線形に増えるだけ。
  • 大量のメッセージを1つのトランザクションとしてまとめられるため、ガスコストを削減できる。
  • ブリッジ間の冗長性を保ちつつ、安全性を高める設計が可能になる。

MerkleTreeによるデータ圧縮

プル型方式を成立させる鍵となるのがMerkleTreeです。

MerkleTreeは、多数のデータを階層的にハッシュ化し、最終的に1つのルートハッシュ(bytes32)にまとめるデータ構造です。

例えば以下のようなイメージです。

text
messages → leaf hashes → internal hashes → root(bytes32)

ポイントは以下です。

  • メッセージが1件でも 1,000件でも、出力されるルートは常に 32 バイト。
  • ブリッジ運搬者が運ぶのはこのルートだけでよい。
  • 実際のメッセージ内容は受信側でMerkle proofによって検証できる。

この仕組みにより、以下の利点が生まれます。

  • どれだけメッセージが増えても、ブリッジが扱うデータ量は一定。
  • データサイズが小さいため、ガスコストが低く、処理も高速。
  • メッセージの改ざん検出が容易になり、セキュリティが向上する。

つまり「大量のメッセージを、32 バイトに圧縮して運べる」という点が、PCPの効率性の根幹を支えています。

全体としての合理性

従来のプッシュ型では、チェーン数の増加がそのままブリッジ数の爆発とコストの増加につながっていました。 一方、プル型ではクロスチェーン通信の本質である「イベントの同期」を効率的に行い、必要なブリッジの本数もメッセージ運搬データ量も大幅に削減できます。

さらに、MerkleTreeによって、以下が実現されるため、安全性を維持しつつスケーラビリティを向上させることができます。

  • メッセージの圧縮
  • 改ざん防止
  • 感度の高い検証能力

PCPは、ブリッジ数・コストの削減と安全性の向上を同時に満たす、合理的なクロスチェーン基盤といえます。

参考実装

SendPort.sol

SendPort.sol
pragma solidity ^0.8.0;

import "./ISendPort.sol";

contract SendPort is ISendPort {
    uint public constant PACK_INTERVAL = 6000;
    uint public constant MAX_PACKAGE_MESSAGES = 100;

    uint public pendingIndex = 0;

    mapping(uint => Package) public packages;

    constructor() {
        packages[0] = Package(0, bytes32(0), new bytes32[](0), block.timestamp, 0);
    }

    function addMsgHash(bytes32 msgHash, uint toChainId) public {
        bytes32 leaf = keccak256(
            abi.encodePacked(msgHash, msg.sender, toChainId)
        );
        Package storage pendingPackage = packages[pendingIndex];
        pendingPackage.leaves.push(leaf);

        emit MsgHashAdded(pendingPackage.packageIndex, msg.sender, msgHash, toChainId, leaf);

        if (pendingPackage.leaves.length >= MAX_PACKAGE_MESSAGES) {
            console.log("MAX_PACKAGE_MESSAGES", pendingPackage.leaves.length);
            _pack();
            return;
        }

        // console.log("block.timestamp", block.timestamp);
        if (pendingPackage.createTime + PACK_INTERVAL <= block.timestamp) {
            console.log("PACK_INTERVAL", pendingPackage.createTime, block.timestamp);
            _pack();
        }
    }

    function pack() public {
        require(packages[pendingIndex].createTime + PACK_INTERVAL <= block.timestamp, "SendPort::pack: pack interval too short");

       _pack();
    }

    function getPackage(uint packageIndex) public view returns (Package memory) {
        return packages[packageIndex];
    }

    function getPendingPackage() public view returns (Package memory) {
        return packages[pendingIndex];
    }

    function _pack() internal {
        Package storage pendingPackage = packages[pendingIndex];
        bytes32[] memory _leaves = pendingPackage.leaves;
        while (_leaves.length > 1) {
            _leaves = _computeLeaves(_leaves);
        }
        pendingPackage.root = _leaves[0];
        pendingPackage.packTime = block.timestamp;

        emit Packed(pendingPackage.packageIndex, pendingPackage.packTime, pendingPackage.root);

        pendingIndex = pendingPackage.packageIndex + 1;
        packages[pendingIndex] = Package(pendingIndex, bytes32(0), new bytes32[](0), pendingPackage.packTime, 0);
    }

    function _computeLeaves(bytes32[] memory _leaves) pure internal returns (bytes32[] memory _nextLeaves) {
        if (_leaves.length % 2 == 0) {
            _nextLeaves = new bytes32[](_leaves.length / 2);
            bytes32 computedHash;
            for (uint i = 0; i + 1 < _leaves.length; i += 2) {
                computedHash = _hashPair(_leaves[i], _leaves[i + 1]);
                _nextLeaves[i / 2] = computedHash;
            }

        } else {
            bytes32 lastLeaf = _leaves[_leaves.length - 1];
            _nextLeaves = new bytes32[]((_leaves.length / 2 + 1));
            bytes32 computedHash;
            for (uint i = 0; i + 1 < _leaves.length; i += 2) {
                computedHash = _hashPair(_leaves[i], _leaves[i + 1]);
                _nextLeaves[i / 2] = computedHash;
            }
            _nextLeaves[_nextLeaves.length - 1] = lastLeaf;
        }
    }

    function _hashPair(bytes32 a, bytes32 b) private pure returns (bytes32) {
        return a < b ? _efficientHash(a, b) : _efficientHash(b, a);
    }

    function _efficientHash(bytes32 a, bytes32 b) private pure returns (bytes32 value) {
        /// @solidity memory-safe-assembly
        assembly {
            mstore(0x00, a)
            mstore(0x20, b)
            value := keccak256(0x00, 0x40)
        }
    }
}

ReceivePort.sol

js
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/access/Ownable.sol";
import "./IReceivePort.sol";

abstract contract ReceivePort is IReceivePort, Ownable {

    //fromChainId => packageIndex => root
    mapping(uint => mapping(uint => bytes32)) public roots;

    constructor() {}

    function receivePackages(Package[] calldata packages) public onlyOwner {
        for (uint i = 0; i < packages.length; i++) {
            Package calldata p = packages[i];
            require(roots[p.fromChainId][p.packageIndex] == bytes32(0), "ReceivePort::receivePackages: package already exist");
            roots[p.fromChainId][p.packageIndex] = p.root;

            emit PackageReceived(p.fromChainId, p.packageIndex, p.root);
        }
    }

    function getRoot(uint fromChainId, uint packageIndex) public view returns (bytes32) {
        return roots[fromChainId][packageIndex];
    }

    function verify(
        uint fromChainId,
        uint packageIndex,
        bytes32[] memory proof,
        bytes32 msgHash,
        address sender
    ) public view returns (bool) {
        bytes32 leaf = keccak256(
            abi.encodePacked(msgHash, sender, block.chainid)
        );
        return _processProof(proof, leaf) == roots[fromChainId][packageIndex];
    }

    function _processProof(bytes32[] memory proof, bytes32 leaf) internal pure returns (bytes32) {
        bytes32 computedHash = leaf;
        for (uint256 i = 0; i < proof.length; i++) {
            computedHash = _hashPair(computedHash, proof[i]);
        }
        return computedHash;
    }

    function _hashPair(bytes32 a, bytes32 b) private pure returns (bytes32) {
        return a < b ? _efficientHash(a, b) : _efficientHash(b, a);
    }

    function _efficientHash(bytes32 a, bytes32 b) private pure returns (bytes32 value) {
        /// @solidity memory-safe-assembly
        assembly {
            mstore(0x00, a)
            mstore(0x20, b)
            value := keccak256(0x00, 0x40)
        }
    }
}

BridgeExample.sol

BridgeExample.sol
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "./ISendPort.sol";
import "./ReceivePort.sol";

contract BridgeExample is ReceivePort {
    using SafeERC20 for IERC20;

    ISendPort public sendPort;

    mapping(bytes32 => bool) public usedMsgHashes;

    mapping(uint => address) public trustBridges;

    mapping(address => address) public crossPairs;

    constructor(address sendPortAddr) {
        sendPort = ISendPort(sendPortAddr);
    }

    function setTrustBridge(uint chainId, address bridge) public onlyOwner {
        trustBridges[chainId] = bridge;
    }

    function setCrossPair(address fromTokenAddr, address toTokenAddr) public onlyOwner {
        crossPairs[fromTokenAddr] = toTokenAddr;
    }

    function getLeaves(uint packageIndex, uint start, uint num) view public returns(bytes32[] memory) {
        ISendPort.Package memory p = sendPort.getPackage(packageIndex);
        bytes32[] memory result = new bytes32[](num);
        for (uint i = 0; i < p.leaves.length && i < num; i++) {
            result[i] = p.leaves[i + start];
        }
        return result;
    }

    function transferTo(
        uint toChainId,
        address fromTokenAddr,
        uint amount,
        address receiver
    ) public {
        bytes32 msgHash = keccak256(
            abi.encodePacked(toChainId, fromTokenAddr, amount, receiver)
        );
        sendPort.addMsgHash(msgHash, toChainId);

        IERC20(fromTokenAddr).safeTransferFrom(msg.sender, address(this), amount);
    }

    function transferFrom(
        uint fromChainId,
        uint packageIndex,
        bytes32[] memory proof,
        address fromTokenAddr,
        uint amount,
        address receiver
    ) public {
        bytes32 msgHash = keccak256(
            abi.encodePacked(block.chainid, fromTokenAddr, amount, receiver)
        );

        require(!usedMsgHashes[msgHash], "transferFrom: Used msgHash");

        require(
            verify(
                fromChainId,
                packageIndex,
                proof,
                msgHash,
                trustBridges[fromChainId]
            ),
            "transferFrom: verify failed"
        );

        usedMsgHashes[msgHash] = true;

        address toTokenAddr = crossPairs[fromTokenAddr];
        require(toTokenAddr != address(0), "transferFrom: fromTokenAddr is not crossPair");
        IERC20(toTokenAddr).safeTransfer(receiver, amount);
    }
}

セキュリティ

クロスチェーンブリッジ間の競合と二重支払い

SendPortとブリッジの役割分担

まず前提として、SendPortとクロスチェーンブリッジの責務は明確に分かれています。

コンポーネント 役割
SendPort クロスチェーン対象となるメッセージを集約し、パッケージとしてMerkleTreeにまとめる。
各クロスチェーンブリッジ SendPortから root を取得し、他チェーンへ運び、IReceivePortで検証・処理する。

PCPは、「どのブリッジが root を運ぶか」、「誰が検証するか」については制約を置かず、各ブリッジプロジェクトが独立に実装できるようになっています。 重要なのは、全てのブリッジが同じSendPortから同じメッセージを取得するため、ソース側のメッセージ集合が一致するという点です。

この設計により、以下のような性質が生まれます。

  • あるブリッジにバグや不具合があっても、その影響は基本的にそのブリッジ自身の利用者に限定される。
  • 別のブリッジは同じ SendPort から正しいデータを取得できるため、連鎖的に壊れることはない。
  • 「誰が運ぶか」を巡ってブリッジ同士が競合する必要がなく、それぞれが独立して動作できる。

実装上の推奨事項(競合・二重支払い対策)

仕様の中で挙げられている具体的な推奨事項は以下の3点です。

項目 内容
IReceivePort.receivePackages() の呼び出し制御 誰でも自由に呼べるようにはしないこと。
検証済み msgHash の保存 一度検証・処理したメッセージは記録し、再利用(=二重支払い)を防ぐこと。
MerkleTreeの sender を無条件に信用しない MerkleTreeに含まれる全ての sender アドレスを正しいとはみなさないこと。
IReceivePort.receivePackages() を誰でも呼べないようにする理由

receivePackages() は、外部からパッケージの root を登録するための入口です。 これを誰でも呼べるようにすると、以下のようなリスクがあります。

  • 不正なブリッジや攻撃者が、改ざんされた root を勝手に登録する。
  • 意図しない古い root を流し込み、検証ロジックを混乱させる。

そのため、オーナーコントラクトや信頼されたブリッジアドレスのみに制限するなど、アクセス制御を必ず入れるべきです。

検証済み msgHash を保存して二重支払いを防ぐ理由

同じクロスチェーンメッセージを、複数回正しいと検証できてしまうと、ユーザーが二重に引き出しを行える可能性があります。 これを防ぐには、検証時に次のような処理を行うのが推奨されています。

  1. verify() 相当の検証で msgHash が正当であることを確認する。
  2. その msgHash を「すでに処理済み」としてストレージに記録する。
  3. 次回以降、同じ msgHash が来た場合は拒否する。

これにより、「同じクロスチェーン転送を何度も使い回す」といった攻撃を防ぐことができます。

MerkleTree内の sender を無条件に信用しない理由

MerkleTreeの leaf には msgHash と sender、toChainId などがまとめられていますが、SendPortは誰でも使える公開コントラクトです。 そのため、MerkleTreeに含まれる sender アドレスの中には、攻撃者アドレスが紛れ込んでいる可能性があります。

このことから、以下の点が強調されています。

  • 「MerkleTreeに含まれているから安全」という考え方は危険。
  • アプリ側で「どの sender からのメッセージを受け入れるか」を個別に制御する必要がある。

クロスチェーンメッセージのなりすましについて

SendPortはパブリックでpermissionlessなコントラクトとして設計されています。 つまり、「誰でも好きなクロスチェーンメッセージを送ることができる」という前提があります。

この時、メッセージなりすましに対しての防御としてPCPが取っているのが、「msg.sender を leaf に含める」という設計です。

なりすましがどう記録されるか

SendPortはメッセージを受け取る際、以下のものを leaf としてパッキングします。

  • msgHash(メッセージの内容を表すハッシュ)
  • toChainId(宛先チェーン ID)
  • sender(msg.sender のアドレス)

もし攻撃者が偽のメッセージを送信した場合、その leaf には攻撃者のアドレスがそのまま記録されます。 このため、検証時には以下のようなことができます。

  • Merkle proofで leaf がパッケージ内に存在することを確認する。
  • その leaf の sender が「許可されたブリッジコントラクト」や「正規のアプリケーションコントラクト」かどうかをチェックする。

この仕組みにより、不審な sender からのメッセージを検知して弾くことが可能になります。

ここであらためて、

Don’t trust all senders in the MerkleTree.

という注意書きの意味が明確になります。 MerkleTreeは「その leaf がSendPortによってパッキングされた」という事実を保証するだけで、「その sender が信用できるかどうか」までは保証しません。 そこはIReceivePortやアプリケーション側がポリシーを決めて判断する必要があります。

メッセージ順序に関する注意点

最後に、メッセージの順序に関するポイントです。

SendPortは、受け取ったクロスチェーンメッセージを時間順に並べて処理しますが、検証時にその順序が必ずしも再現されるわけではありません。

送信側と受信側の順序のズレ

例として、あるユーザーが以下のような操作をしたとします。

  1. チェーンA から、チェーンBへ 10 ETH をクロスチェーン転送。
  2. 続けて、チェーンA からチェーンBへ 20 USDT をクロスチェーン転送。

SendPort上では、上記の 1 → 2 の順番でメッセージが記録されます。 しかし、チェーンB側で実際に引き出しが行われる順序は次のどちらにもなりえます。

  • 先に 20 USDT を引き出し、その後 10 ETH を引き出す。
  • 先に 10 ETH を引き出し、その後 20 USDT を引き出す。

これは、検証や実行の順序がIReceivePortの実装に依存するためです。

アプリケーション側での注意点

この性質から、アプリケーション設計では次の点に注意する必要があります。

  • 「トランザクションの順序」に依存したビジネスロジックを組まないこと。
  • 同じユーザーのメッセージであっても、受信側での処理順序は保証されない前提で設計すること。
  • 順序が重要な要件(例:段階的な状態遷移)がある場合は、アプリ側でシーケンス番号などを別途設けて制御すること。

PCP自体は「メッセージが確かに存在するか」を証明するレイヤーであり、「どの順番で実行されるか」までは保証しません。 そのため、順序依存のロジックはアプリケーション層で慎重に設計する必要があります。

引用

George (@JXRow), Zisu (@lazy1523), "ERC-7533: Public Cross Port [DRAFT]," Ethereum Improvement Proposals, no. 7533, October 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7533.

最後に

今回は「EVMチェーン同士を効率よく接続し、複数のブリッジが同じメッセージを共有できる仕組みを提案しているERC7533」についてまとめてきました! いかがだったでしょうか?

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!

他の媒体でも情報発信しているのでぜひ他も見ていってください!