🌉

Avalanche ICM

に公開

はじめに

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

https://amzn.asia/d/gxvJ0Pw

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

https://cardene.notion.site/ERC-EIP-2a03fa3ea33d43baa9ed82288f98d4a9?pvs=4

https://twitter.com/cardene777

https://chaldene.net/

https://qiita.com/cardene

https://cardene.substack.com/

https://mirror.xyz/0xcE77b9fCd390847627c84359fC1Bc02fC78f0e58

今回は『Avalanche ICM』についてまとめていきます。
Avalache ICMの構成要素や処理フローについてまとめているので、この記事を読むことで非エンジニアでも全体像が掴めます。
また、エンジニア向けに詳しく説明しているので、技術的にも理解を深められると思います。

ICMとは

Avalanche ICM(Interchain Messaging)とは、AvalancheのL1ブロックチェーン間でメッセージを送受信する仕組みです。
ここでのメッセージとは、単純な文字列や関数の実行情報などです。
例えば、単純な文字列を宛先L1上のコントラクトに送付したり、リクエスト元のL1上でERC20トークンをBurnして、宛先L1上でERC20トークンをMintするなどの処理を実行できます。

https://zenn.dev/heku/books/f8e824e148b5de

前提

Avalanche ICMのフローを確認する前に、前提として知っておくべき情報をまとめて説明していきます。

P-Chain

Avalancheには、C-Chainや作成されたL1ブロックチェーンの他に「P-Chain」が存在します。
このP-Chainは、各C-Chainや各L1のブロック生成などを行うバリデータの管理を行ったり、バリデータがステーキングしたAVAXの管理などを行います。
また、新たにL1を作成するとき、P-Chainにその情報(バリデータやチェーンIDなど)を登録する必要があります。
このように、P-ChainはAvalanche上でのバリデータ管理・ステーキング・L1のライフサイクル管理を担う中核的なチェーンです。

バリデータ


https://build.avax.network/academy/interchain-messaging/02-interoperability/03-multi-chain-networks

Avalanche上のL1には、それぞれバリデータが存在します。
このバリデータがブロックの生成などを行っています。


https://medium.com/coinmonks/avalanche-fundamentals-subnet-architecture-explained-e69f5aa3d75a

このバリデータは1つのL1のバリデータにしかなれないわけではなく、複数のL1のバリデータになることができます。


https://build.avax.network/blog/etna-upgrade-motivation

各L1のバリデータになるには、各L1が定めているバリデータになるために必要なそのL1のネイティブトークンのステーキング要件を満たす必要があります。


https://build.avax.network/blog/etna-upgrade-motivation

また、P-Chainにも情報登録料として、毎月少額のAVAXを支払う必要があります。

BLS署名


https://build.avax.network/academy/avalanche-fundamentals/05-interoperability/08-signature-aggregation

BLS署名(Boneh–Lynn–Shacham Signature) は、複数の署名を1つにまとめられる(集約できる)暗号署名方式です。
Avalanche ICM以外にEthereum2.0(BeaconChain)でも採用されています。

  1. 署名の集約
    複数の署名者が同じメッセージに署名した場合、それらを1つの署名にまとめることができます。
    ネットワーク上のデータ量を削減でき、ブロックサイズの効率化につながります。
  2. 検証コストの削減
    集約署名は1回の検証で済むため効率的です。
  3. ペアリング暗号
    BLS署名は「ペアリング」と呼ばれる特殊な暗号計算を利用して署名の正当性を確認します。

例えば、3人のバリデータ(Validator A, B, C)が同じメッセージ "Block123" に署名する場合を考えます。

  1. 各バリデータが秘密鍵で署名します。
    sigA = sign(privA, "Block123")
    sigB = sign(privB, "Block123")
    sigC = sign(privC, "Block123")
  2. それぞれの署名を1つにまとめます(集約)。
    aggSig = aggregate(sigA, sigB, sigC)
  3. 検証者は1回の検証で確認できます。
    verify([pubA, pubB, pubC], "Block123", aggSig)
    これにより、「A・B・Cが同じメッセージに署名している」ことを1回の演算で確かめられます。

Avalanche ICMのフロー

Avalanche ICMのフローについて詳しく説明していきます。

構成要素

まずは構成要素について紹介していきます。

Source Chain & Destination Chain

Source Chain Message Destination Chain
リクエストを送るL1チェーン 送付するメッセージ リクエストの送り先L1チェーン

https://build.avax.network/academy/interchain-messaging/02-interoperability/02-source-message-destination

メッセージの送付を行うL1チェーンは「Source Chain」と呼ばれ、メッセージを受け取るL1チェーンを「Destination Chain」と呼びます。
この2つのL1チェーンは事前にP-Chainに登録をしておく必要があります。

https://www.avacloud.io/


https://build.avax.network/academy/interchain-messaging/05-two-way-communication/01-two-way-communication

Source Chainから、ユーザーがDestination Chainに送りたいメッセージのリクエストを行います。

Source ChainとDestination Chain上には、メッセージを他のL1チェーンに送受信したいコントラクト(図の「Cross-Subnet dApp」)とメッセージの送受信を中継するTeleporterというコントラクトがデプロイされている必要があります。

Cross-Subnet dApp」は、複数のL1チェーン上にデプロイされているERC20こんとらくとや他のL1チェーンとメッセージをやり取りするコントラクトやそのコントラクトとやり取りするオフチェーンアプリケーションです。
Teleporter」は、他のL1チェーンにメッセージを送付するときのメッセージを含むデータを適切な形式にフォーマットしてくれたり、メッセージの送付が失敗した時に再送する処理を行います。

https://zenn.dev/heku/books/fe10e09f39a517

Message

送付するメッセージの形式について説明します。

シンプルなデータ

送付するメッセージは、そのままの文字列ではなくエンコードを行う必要があります。


https://build.avax.network/academy/interchain-messaging/04-icm-basics/02-recap-bytes-encoding-decoding

上記では、「test」と「42」という2つのデータをまとめてエンコードしています。

図では、「("test", 42)」というメッセージをバイトデータに変換しています。
バイトデータに変換することで、メッセージの取り扱いやすくしています。

string memory someString = "test";
uint someNumber = 42;
 
bytes message = abi.encode(someString, someNumber);

上記では、someString という値に test という文字列データを格納しています。
加えて、someNumber という値に 42 という数値データを格納しています。
someStringsomeNumber の2つの値を abi.encode を使用してエンコードしています。

エンコードしたデータは、宛先のL1チェーンで元のデータに戻す(デコード)する必要があります。
デコードする時は、以下のようにデータの型情報とエンコードしたバイトデータを使用します。

( 
  string memory someString,
  uint someNumber
) = abi.decode(message, (string, uint));

上記は、先ほどエンコードしたデータである message とエンコードしたデータの型情報である stringuintabi.decode に渡すことで、元のデータである someStringsomeNumber を取得しています。
someStringsomeNumber には、それぞれ、 test42 の値が入っています。

このようにして、SourceチェーンからDestinationチェーンにメッセージを送付しています。

関数実行データ

宛先チェーンに関数の実行データをメッセージとして渡したい場合もあります。


https://build.avax.network/academy/interchain-messaging/06-invoking-functions/01-invoking-functions

この時、データの渡し方として以下の2つの方法があります。

  • 関数の引数データをメッセージとして渡す
  • 実行したい関数の名前と関数の引数データをメッセージとして渡す


https://build.avax.network/academy/interchain-messaging/06-invoking-functions/02-encoding-multiple-values

関数の引数データをメッセージとして渡す場合、「シンプルなデータ」の章で説明したように、関数に渡したい引数のデータをエンコードします。

string someString = "test";
uint someNumber = 43;
address someAddress = address(0);

bytes message = abi.encode(
  someString,
  someNumber,
  someAddress
);


https://build.avax.network/academy/interchain-messaging/06-invoking-functions/06-encoding-function-name

もう1つの方法が、実行したい関数名と関数の引数データをメッセージとして渡す方法です。

enum CalculatorAction {
    add,
    concatenate
}

上記のようにEnum型で関数名を定義しておきます。

function encodeAddData(uint256 a, uint256 b) public pure returns (bytes memory) {
    bytes memory paramsData = abi.encode(a, b);
    return abi.encode(CalculatorAction.add, paramsData);
}

function encodeConcatenateData(string memory a, string memory b) public pure returns (bytes memory) {
    bytes memory paramsData = abi.encode(a, b);
    return abi.encode(CalculatorAction.concatenate, paramsData);
}

そして、上記のような関数をコントラクト内で用意しておき、先ほど定義した関数名もメッセージ内に含めます。
paramsData 内に関数の引数に渡したいデータが格納され、さらにそのバイトデータと関数名(CalculatorAction.add もしくは CalculatorAction.concatenate)でエンコードしています。
デコードする時は、まず以下のように関数名と関数の引数に渡すデータ(バイトデータ)の型情報を渡してデータを取得します。

(CalculatorAction actionType, bytes memory paramsData) = abi.decode(message, (CalculatorAction, bytes));

宛先チェーンのコントラクトでも、関数名を一覧にしたEnum型を定義しておき、その型情報と関数の引数に渡すデータの型情報(2重でエンコードしたのでここでは bytes 型)を abi.decode に渡して、関数名(actionType)と関数の引数に渡すデータのバイトデータ(paramsData)を取得します。

if (actionType == CalculatorAction.add) {
        (uint256 a, uint256 b) = abi.decode(paramsData, (uint256, uint256));
        _calculatorAdd(a, b);
    } else if (actionType == ...) {
        (string memory text1, string memory text2) = abi.decode(paramsData, (string, string));
        _calculatorConcatenateStrings(text1, text2);
    } else {
        revert("CalculatorReceiverOnDispatch: invalid action");
    }

実行する関数は、受け取った関数名によって分岐させます。
最後に関数の引数に渡すデータをデコードして取得します。

このようにメッセージをバイトデータにすることで、柔軟に様々なデータを宛先L1チェーンに送付することができます。
また、先ほど2重にエンコードしましたが、3重、4重...とエンコードすることもでき、複雑な構造にすることも可能です。
ただし、その分処理が重くなったり、管理が複雑になるので注意が必要です。

Relayer


https://build.avax.network/academy/interchain-messaging/08-avalanche-warp-messaging/04-awm-relayer

実際に2つのL1間でメッセージを中継するのがRelayerになります。
これはオフチェーンのコンポーネントで、接続している各L1チェーンからメッセージの送付依頼がないか監視して、メッセージを見つけたら各ノードからBLS署名を収集して、宛先L1チェーンにメッセージを送信します。

https://zenn.dev/heku/books/cf97fca64ead59

Warp Precompile

AvalancheのPrecompileは、L1チェーン作成時にデフォルトで組み込まれているスマートコントラクトです。
Warp Precompile」は、「Teleporter」コントラクトから渡されたメッセージを元にRelayerに処理を中継します。
また、宛先L1チェーンの「Warp Precompile」は、Relayerから渡されたデータの検証を行います。

https://zenn.dev/heku/books/f18290c7ddac09/viewer/11e259

データ

宛先チェーンに何らかのデータを渡す時は、メッセージだけでは不十分です。
例えば、どのL1チェーンに送るのか、どのコントラクトを呼び出すのかなどがわからないためです。
Teleporter」コントラクトでは、このように他のL1チェーンに送付する時に必要となるデータを受け取って処理をするように定義されています。
Cross-Subnet dApp」のコントラクトは、以下の情報を「Teleporter」コントラクトに渡す必要があります。

https://github.com/ava-labs/icm-contracts/blob/main/contracts/teleporter/TeleporterMessenger.sol#L450-L460

  • destinationBlockchainID
    宛先L1チェーンのチェーンID。
    これは一般的に呼ばれているチェーンIDではなく、L1チェーンを作成時にP-Chainに登録を行なったトランザクションのハッシュ値です。
  • destinationAddress
    宛先L1チェーン上にデプロイされていて、メッセージを渡したいコントラクトのアドレス。
  • feeInfo
    Relayerに渡す手数料の情報。
    以下のように定義されています。
    https://github.com/ava-labs/icm-contracts/blob/main/contracts/teleporter/ITeleporterMessenger.sol#L44-L47
    • feeTokenAddress
      手数料として支払うERC20トークンのコントラクトアドレスかネイティブトークンのアドレス(0x00...)。
    • amount
      手数料として支払うトークン量。
  • requiredGasLimit
    実行時のガスリミット。
    この値以上のガスを消費しないように設定できます。
  • allowedRelayerAddresses
    このメッセージを中継できるRelayerのリスト。
    デフォルトは空で、どのRelayerがメッセージを処理しても問題ないですが、特定のRelayerにのみメッセージを処理させる必要がある場合に指定します。
  • message
    宛先L1チェーンに送付したいメッセージ。

Relayerはこれらの情報を受け取ってメッセージを宛先L1チェーンに送付しています。
また、「Cross-Subnet dApp」のコントラクトは、この定義をせずとも「Teleporter」コントラクト側で処理を行ってくれるため、必要な情報を渡せば良い設計になっています。

フロー

構成要素について説明してきたので、実際にどのようなフローでICMが処理を実行しているかを見ていきます。

1. メッセージの送付

まずは、Teleporterに準拠したSourceチェーン上のコントラクトからメッセージの送付リクエストをします。

Sourceチェーン上にデプロイされているコントラクトで、以下のようにリクエストに必要な情報をまとめて、Teleporterコントラクトの sendCrossChainMessage 関数を呼び出します。

https://github.com/cardene777/avalanche-sample/blob/main/icm-erc20/src/SimpleSender.sol#L38-L50

2. Teleporterの中継

sendCrossChainMessage 関数が呼び出されると、過去に宛先L1チェーンから現在SourceチェーンとなっているL1チェーンのこのTeleporterコントラクトにメッセージを送付した結果情報を一覧で取得しています。
これは、その処理結果が成功したことを宛先L1チェーンに返すことで、Relayerが報酬を受け取るための処理です。
Relayerはメッセージの送付が完了してから報酬を受け取るため、この処理が必要になります。

https://github.com/ava-labs/icm-contracts/blob/main/contracts/teleporter/TeleporterMessenger.sol#L122-L132

sendCrossChainMessage 関数では、具体的な処理を実行する _sendTeleporterMessage 関数を呼び出しています。
_sendTeleporterMessage 関数では以下の処理を行っています。

  • messageID の取得
    一意のIDとメッセージを紐づけることで、再送など管理をしやすくしています。

https://github.com/ava-labs/icm-contracts/blob/main/contracts/teleporter/TeleporterMessenger.sol#L636-L637

  • TeleporterMessage の構築
    宛先L1チェーンに送付するデータの形式である TeleporterMessage を作成しています。
    originSenderAddress はTeleporterコントラクトを呼び出したコントラクトアドレスが格納されるようになっています。

https://github.com/ava-labs/icm-contracts/blob/main/contracts/teleporter/TeleporterMessenger.sol#L640-L650

  • 手数料徴収
    Teleporterコントラクトを呼び出したコントラクトで設定されている手数料情報を元に、手数料に設定しているERC20トークンをTeleporterコントラクトに送付しています。
    Teleporterコントラクトに送付された手数料トークンは、宛先L1チェーンにメッセージを送付できたことを確認したのち、Relayerに送付されます。

https://github.com/ava-labs/icm-contracts/blob/main/contracts/teleporter/TeleporterMessenger.sol#L656-L667

  • イベントの発行
    他のL1チェーンへメッセージを送付したいということを通知するイベントを発行します。
    Relayerはこのイベントを監視しています。

https://github.com/ava-labs/icm-contracts/blob/main/contracts/teleporter/TeleporterMessenger.sol#L680-L682

  • Warp Precompileの呼び出し
    Warp Precompileを呼び出して、イベントには含めることができなかった以下の情報を別途イベントとして発行するようにしています。
    • sourceChainID
      送信元L1のチェーンID。
    • payload (teleporterMessageBytes)
      送付したいメッセージのバイトデータ。
    • originSenderAddress
      送信元L1でリクエストを投げたコントラクトのアドレス。
    • messageIndex
      一意なID。
    • BLS署名
      Relayerは、Teleporterからのイベントの通知を元にWarp Precompileから発行されている上記の情報を含むイベントを取得しにいきます。

https://github.com/ava-labs/icm-contracts/blob/main/contracts/teleporter/TeleporterMessenger.sol#L685

3. イベントの取得

Teleporterが SendCrossChainMessage イベントを発行すると、Relayerはそのイベントを取得しにいきます。
Relayerは接続しているL1チェーン上で SendCrossChainMessage イベントを監視しており、イベントが発行されるとすぐにそれを取得しにいきます。

細かい処理については以下で説明しています。

https://zenn.dev/heku/books/cf97fca64ead59

先ほど説明したように、Relayerはイベントを取得した後、Warp Precompileから発行されている必要な情報を含むイベントも取得します。
Teleporterのイベント発行と同じトランザクション内で、Warp Precompileの呼び出しを行なっているため、同じトランザクション内のイベントを取得しています。
この時、仮に同じトランザクション内に複数のTeleporterからのイベントとWarp Precompileからのイベントがある場合、イベントの発行順と以下の情報を元に導出できる messageID が一致しているかを検証して対応づけしています。

  • networkID
    送信元ネットワークID。
  • sourceChainID
    送信元L1チェーンのID。
  • payload
    TeleporterMessageを含む実際の送信データ。

4. BLS署名の収集


https://build.avax.network/academy/interchain-messaging/08-avalanche-warp-messaging/06-message-pickup

RelayerはSourceチェーン上のバリデータにBLS署名を依頼します。


各バリデータはステーキング額に応じて重みがあります。
各L1チェーンの設定に応じて、一定数の重み以上の署名がないとBLS署名検証時に失敗するようになっています。


https://build.avax.network/academy/avalanche-fundamentals/05-interoperability/08-signature-aggregation

各バリデータから署名を収集して1つにまとめることで、バリデータの数が増えても常に一定の長さの署名に集約することができます。


https://build.avax.network/academy/avalanche-fundamentals/05-interoperability/08-signature-aggregation

また、検証で使用するために、署名に参加したバリデータの公開鍵も集約しておく必要があります。

5. 宛先チェーンにメッセージを送付

BLSの署名を収集できたら、取得したイベントの情報をもとに、指定された宛先L1チェーン上にメッセージを送信します。
この時、宛先L1チェーン上のTeleporterコントラクトを呼び出します。
Relayerは事前に接続しているL1チェーン上のTeleporterコントラクトを登録しています。
各L1チェーンごとにTeleporterコントラクトは1つだけ登録できるため、このTeleporterコントラクトに対してメッセージを送付します。

6. 検証


https://build.avax.network/academy/interchain-messaging/08-avalanche-warp-messaging/07-message-delivery

Relayerが宛先L1チェーン上のTeleporterコントラクトを呼び出した時、receiveCrossChainMessage 関数が呼び出されます。

https://github.com/ava-labs/icm-contracts/blob/main/contracts/teleporter/TeleporterMessenger.sol#L245C14-L326

receiveCrossChainMessage 関数の先頭で、Warp Precompileが呼び出されます。
以下のWarp Precompileの VerifyPredicate 関数でBLSの署名検証や宛先L1チェーンが設定したバリデータの署名が重みを満たしているかを検証しています。

https://github.com/ava-labs/subnet-evm/blob/7d679b76c309013f870f24dc7bc635d0773956e2/precompile/contracts/warp/config.go#L189-L240

BLS署名の検証では、バリデータから集約した署名と公開鍵を元に検証を行います。
加えて、事前に宛先L1チェーン上で設定されている重み以上の署名がされているかの検証も行います。
例えば、閾値が50%以上だった場合、署名を行ったバリデータの合計の重みが全体の50%に満たない時は処理が失敗します。

receiveCrossChainMessage 関数では、他に以下のような検証も行なっています。

  • Teleporterコントラクトからの実行か
    呼び出し元のTeleporterコントラクトは、呼び出されているTeleporterコントラクトと同じコントラクトアドレスかを検証しています。
    通常、TeleporterコントラクトはCREATE2という仕組みを使用して、同じコントラクトアドレスで各L1チェーンにデプロイされるため、この検証を行なっています。

https://github.com/cardene777/smart-contract/blob/develop/docs/create2.md

  • 宛先チェーンの検証
    メッセージが呼び出されたTeleporterコントラクトが存在しているL1チェーン宛かを検証します。

  • messageID の検証
    既に実行されている messageID ではないかを検証します。
    messageID は一意であるため、2回実行あsれることはありません。

  • allowedRelayerAddresses の検証
    メッセージ内に含まれている allowedRelayerAddresses でRelayerを指定している場合、指定されたRelayerからの実行かを確認しています。

これらの検証が全て問題なければメッセージが指定されたコントラクトに渡されます。

7. メッセージの受信

宛先L1チェーン上の指定されたコントラクトが呼びだれます。
この時、receiveTeleporterMessage という関数が呼び出されるため、呼び出されるContractではこの関数を実装しておく必要があります。

https://github.com/cardene777/avalanche-sample/blob/main/icm-erc20/src/SimpleReceiver.sol#L46-L63

receiveTeleporterMessage 関数の引数は以下の3つです。

  • originChainID
    送信元L1チェーンのチェーンID。
  • originSenderAddress
    送信元L1チェーンでメッセージ送付のリクエストを行なったコントラクトのアドレス。
  • message
    送付されたメッセージ。

その他

手数料のフロー


https://build.avax.network/academy/interchain-messaging/11-incentivizing-a-relayer/02-fee-data-flow

Sourceチェーンでメッセージを送付するときに、手数料を設定することができます。
Relayerによっては、一定額の手数料を渡さないと処理を行わないなどの制限をかけることもできるため、この手数料が必須な場合もあります。
この手数料は、実際に宛先L1チェーンにメッセージが送付されたことを確認したのち、Relauerに支払われます。
図のように、まずリクエストされてからSourceチェーンに一度指定されたERC20トークンが預けられます。
その後、宛先L1チェーンにメッセージが送付され、その情報をレシートとしてTeleporterコントラクトに渡されてから、最終的にRelayerに手数料が支払われます。

ICM Registry

デフォルトでは、TeleporterコントラクトはUpgradeできないようになっています。
しかし、今後Teleporterの仕様が変化したり、何かしらの脆弱性が見つかるなどした場合に、TeleporterコントラクトをUpgradeしたくなることがあります。
そのために用意されているのが「ICM Registry」です。


https://build.avax.network/academy/interchain-messaging/07-icm-registry/02-how-the-icm-registry-works

上記の図では、Teleporter Registryというコントラクトを定義して、getLatestTeleporter という関数を呼び出すことで、最新のTeleporterコントラクトのアドレスが取得できるようになっています。

https://github.com/ava-labs/icm-contracts/blob/main/contracts/teleporter/registry/TeleporterRegistry.sol#L133C14-L135

各Dappのコントラクトは、このgetLatestTeleporter 関数を呼び出すことで、常に最新のバージョンのTeleporterコントラクトにアクセスすることができるようになります。
新しいバージョンのTeleporterコントラクトをデプロイした時は、Teleporter Registryコントラクトにそのアドレスを登録するだけで、そのコントラクトを最新のTeleporterコントラクトとして使用することができます。

https://zenn.dev/heku/books/fe10e09f39a517/viewer/7b4fb2

最後に

今回は『Avalanche ICM』についてまとめてきました。

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

https://amzn.asia/d/gxvJ0Pw

https://cardene.notion.site/EIP-2a03fa3ea33d43baa9ed82288f98d4a9?source=copy_link

https://qiita.com/cardene

https://mirror.xyz/0xcE77b9fCd390847627c84359fC1Bc02fC78f0e58

https://chaldene.net/

https://twitter.com/cardene777

https://cardene.substack.com/

Discussion