はじめに

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

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

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

ICMとは

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

Avalanche ICMについて詳しくは以下の記事を読んでください。

前提

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

P-Chain

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

バリデータ

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

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

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

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

BLS署名

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
Source Chain & Destination Chainの図 Source Chain & Destination Chainの図 Source Chain & Destination Chainの図
リクエストを送るL1チェーン 送付するメッセージ リクエストの送り先L1チェーン

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

通常、Ava CloudなどでL1チェーン作成時にP-Chainに登録されます。

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

Source Chain & Destination Chainの図

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

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

「Teleporter」について詳しく知りたい方はは以下の記事を読んでください。

Message

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

シンプルなデータ

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

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

「エンコード(encode)」とは、あるデータを特定の形式(フォーマット)に変換することを意味します。

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

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

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

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

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

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

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

Destinationチェーンのデコードを行うコントラクトは、エンコード前のデータがどういった型のデータかを事前に知っておく必要あります。 なぜなら、デコード時に abi.decode に message のエンコードされる前の型情報がないと適切にデコードができないからです。

関数実行データ

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

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

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

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

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

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

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

text
enum CalculatorAction {
    add,
    concatenate
}

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

bash
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)でエンコードしています。 デコードする時は、まず以下のように関数名と関数の引数に渡すデータ(バイトデータ)の型情報を渡してデータを取得します。

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

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

text
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

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

詳しくは以下の記事を読んでください。

Warp Precompile

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

「Warp Precompile」やPrecompileについて、詳しくは以下の記事を読んでください。

データ

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

contracts/teleporter/TeleporterMessenger.sol#L450-L460
                requiredGasLimit: uint256(0),
                allowedRelayerAddresses: allowedRelayerAddresses,
                message: new bytes(0)
            }),
            receiptsToSend
        );
    }

    /**
     * @dev Requirements:
     *
ava-labs/icm-contracts の元のファイルを開く
  • 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 関数を呼び出します。

icm-erc20/src/SimpleSender.sol#L38-L50
        teleporterMessenger.sendCrossChainMessage(
            TeleporterMessageInput({
                destinationBlockchainID: destinationChainID,
                destinationAddress: destinationAddress,
                feeInfo: TeleporterFeeInfo({
                    feeTokenAddress: address(0),
                    amount: 0
                }),
                requiredGasLimit: 200000,
                allowedRelayerAddresses: new address[](0),
                message: abi.encode(message)
            })
        );
cardene777/avalanche-sample の元のファイルを開く

2. Teleporterの中継

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

contracts/teleporter/TeleporterMessenger.sol#L122-L132
        // Get the outstanding receipts for messages that have been previously received
        // from the destination chain but not yet acknowledged, and attach the receipts
        // to the Teleporter message to be sent.
        return _sendTeleporterMessage(
            messageInput,
            receiptQueues[messageInput.destinationBlockchainID].getOutstandingReceiptsToSend()
        );
    }

    /**
     * @dev Emits a {SendCrossChainMessage} event.
ava-labs/icm-contracts の元のファイルを開く

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

  • messageID の取得 一意のIDとメッセージを紐づけることで、再送など管理をしやすくしています。
contracts/teleporter/TeleporterMessenger.sol#L636-L637
            messageNonce: messageNonce_,
            originSenderAddress: msg.sender,
ava-labs/icm-contracts の元のファイルを開く
  • TeleporterMessage の構築 宛先L1チェーンに送付するデータの形式である TeleporterMessage を作成しています。 originSenderAddress はTeleporterコントラクトを呼び出したコントラクトアドレスが格納されるようになっています。
contracts/teleporter/TeleporterMessenger.sol#L640-L650
            requiredGasLimit: messageInput.requiredGasLimit,
            allowedRelayerAddresses: messageInput.allowedRelayerAddresses,
            receipts: receipts,
            message: messageInput.message
        });
        bytes memory teleporterMessageBytes = abi.encode(teleporterMessage);

        // If the fee amount is non-zero, transfer the asset into control of this TeleporterMessenger contract instance.
        // The fee is allowed to be 0 because it's possible for someone to run their own relayer and deliver their own messages,
        // which does not require further incentivization. They still must pay the transaction fee to submit the message, so
        // this is not a DOS vector in terms of being able to submit zero-fee messages.
ava-labs/icm-contracts の元のファイルを開く
  • 手数料徴収 Teleporterコントラクトを呼び出したコントラクトで設定されている手数料情報を元に、手数料に設定しているERC20トークンをTeleporterコントラクトに送付しています。 Teleporterコントラクトに送付された手数料トークンは、宛先L1チェーンにメッセージを送付できたことを確認したのち、Relayerに送付されます。
contracts/teleporter/TeleporterMessenger.sol#L656-L667
                "TeleporterMessenger: zero fee asset contract address"
            );

            adjustedFeeAmount = SafeERC20TransferFrom.safeTransferFrom(
                IERC20(messageInput.feeInfo.feeTokenAddress), messageInput.feeInfo.amount
            );
        }

        // Store the fee asset and amount to be paid to the relayer of this message upon receiving the receipt.
        // Also store the message hash so that it can be retried until a receipt of its delivery is received back.
        TeleporterFeeInfo memory adjustedFeeInfo = TeleporterFeeInfo({
            feeTokenAddress: messageInput.feeInfo.feeTokenAddress,
ava-labs/icm-contracts の元のファイルを開く
  • イベントの発行 他のL1チェーンへメッセージを送付したいということを通知するイベントを発行します。 Relayerはこのイベントを監視しています。
contracts/teleporter/TeleporterMessenger.sol#L680-L682
        WARP_MESSENGER.sendWarpMessage(teleporterMessageBytes);

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

3. イベントの取得

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

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

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

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

4. BLS署名の収集

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

4. BLS署名の収集の図 4. BLS署名の収集の図

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

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

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

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

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

6. 検証

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

contracts/teleporter/TeleporterMessenger.sol#L245-L326
    ) external receiverNonReentrant {
        // Verify and parse the cross chain message included in the transaction access list
        // using the warp message precompile.
        (WarpMessage memory warpMessage, bool success) =
            WARP_MESSENGER.getVerifiedWarpMessage(messageIndex);
        require(success, "TeleporterMessenger: invalid warp message");

        // Only allow for messages to be received from the same address as this teleporter contract.
        // The contract should be deployed using the universal deployer pattern, such that it knows messages
        // received from the same address on other chains were constructed using the same bytecode of this contract.
        // This allows for trusting the message format and uniqueness as specified by sendCrossChainMessage.
        require(
            warpMessage.originSenderAddress == address(this),
            "TeleporterMessenger: invalid origin sender address"
        );

        // Parse the payload of the message.
        TeleporterMessage memory teleporterMessage =
            abi.decode(warpMessage.payload, (TeleporterMessage));

        // If the blockchain ID has yet to be initialized, do so now.
        bytes32 blockchainID_ = initializeBlockchainID();

        // Require that the message was intended for this blockchain.
        require(
            teleporterMessage.destinationBlockchainID == blockchainID_,
            "TeleporterMessenger: invalid destination chain ID"
        );

        // Calculate the message ID of the message given the source blockchain ID and message nonce.
        bytes32 messageID = calculateMessageID(
            warpMessage.sourceChainID, blockchainID_, teleporterMessage.messageNonce
        );

        // Require that the message has not been received previously.
        require(!_messageReceived(messageID), "TeleporterMessenger: message already received");

        // Check that the caller is allowed to deliver this message.
        require(
            _checkIsAllowedRelayer(msg.sender, teleporterMessage.allowedRelayerAddresses),
            "TeleporterMessenger: unauthorized relayer"
        );

        // Mark the message as received.
        _markMessageReceived(messageID, teleporterMessage.messageNonce);

        // Store the relayer reward address if non-zero.
        if (relayerRewardAddress != address(0)) {
            _relayerRewardAddresses[messageID] = relayerRewardAddress;
        }

        // Process the receipts that were included in the teleporter message by paying the
        // fee for the messages are reward to the given relayers.
        uint256 length = teleporterMessage.receipts.length;
        for (uint256 i; i < length; ++i) {
            _markReceipt(blockchainID_, warpMessage.sourceChainID, teleporterMessage.receipts[i]);
        }

        // Store the receipt of this message delivery.
        receiptQueues[warpMessage.sourceChainID].enqueue(
            TeleporterMessageReceipt({
                receivedMessageNonce: teleporterMessage.messageNonce,
                relayerRewardAddress: relayerRewardAddress
            })
        );

        emit ReceiveCrossChainMessage(
            messageID,
            warpMessage.sourceChainID,
            msg.sender,
            relayerRewardAddress,
            teleporterMessage
        );

        // Execute the message.
        if (teleporterMessage.message.length > 0) {
            _handleInitialMessageExecution(messageID, warpMessage.sourceChainID, teleporterMessage);
        }
    }

    /**
     * @dev A Teleporter message has an associated `requiredGasLimit` that is used to execute the message.
ava-labs/icm-contracts の元のファイルを開く

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

precompile/contracts/warp/config.go#L189-L240
func (c *Config) VerifyPredicate(predicateContext *precompileconfig.PredicateContext, pred predicate.Predicate) error {
	unpackedPredicateBytes, err := pred.Bytes()
	if err != nil {
		return fmt.Errorf("%w: %w", errInvalidPredicateBytes, err)
	}

	// Note: PredicateGas should be called before VerifyPredicate, so we should never reach an error case here.
	warpMsg, err := warp.ParseMessage(unpackedPredicateBytes)
	if err != nil {
		return fmt.Errorf("%w: %w", errCannotParseWarpMsg, err)
	}

	quorumNumerator := WarpDefaultQuorumNumerator
	if c.QuorumNumerator != 0 {
		quorumNumerator = c.QuorumNumerator
	}

	log.Debug("verifying warp message", "warpMsg", warpMsg, "quorumNum", quorumNumerator, "quorumDenom", WarpQuorumDenominator)

	// Wrap validators.State on the chain snow context to special case the Primary Network
	state := warpValidators.NewState(
		predicateContext.SnowCtx.ValidatorState,
		predicateContext.SnowCtx.SubnetID,
		warpMsg.SourceChainID,
		c.RequirePrimaryNetworkSigners,
	)

	validatorSet, err := warp.GetCanonicalValidatorSetFromChainID(
		context.Background(),
		state,
		predicateContext.ProposerVMBlockCtx.PChainHeight,
		warpMsg.UnsignedMessage.SourceChainID,
	)
	if err != nil {
		log.Debug("failed to retrieve canonical validator set", "msgID", warpMsg.ID(), "err", err)
		return fmt.Errorf("%w: %w", errCannotRetrieveValidatorSet, err)
	}

	err = warpMsg.Signature.Verify(
		&warpMsg.UnsignedMessage,
		predicateContext.SnowCtx.NetworkID,
		validatorSet,
		quorumNumerator,
		WarpQuorumDenominator,
	)
	if err != nil {
		log.Debug("failed to verify warp signature", "msgID", warpMsg.ID(), "err", err)
		return fmt.Errorf("%w: %w", errFailedVerification, err)
	}

	return nil
}
ava-labs/subnet-evm の元のファイルを開く

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

重みとは、バリデータがそのL1チェーン上でステーキングしているトークン量に応じて決まる値です。 ステーク量が多いほど重みは大きくなります。

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

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

CREATE2については以下を読んでください。

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

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

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

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

7. メッセージの受信

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

icm-erc20/src/SimpleReceiver.sol#L46-L63
    function receiveTeleporterMessage(
        bytes32 originChainID,
        address originSenderAddress,
        bytes calldata message
    ) external onlyTeleporter {
        string memory decodedMessage = abi.decode(message, (string));

        lastMessage = decodedMessage;
        lastSender = originSenderAddress;
        lastOriginChainID = originChainID;

        emit MessageReceived(
            originChainID,
            originSenderAddress,
            decodedMessage
        );
    }
}
cardene777/avalanche-sample の元のファイルを開く

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

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

その他

手数料のフロー

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

注意が必要なのが、レシートはDestinationチェーンのTeleporterコントラクトが呼び出された時に、やっとSourceチェーンのTeleporterコントラクトに送付される点です。 そのため、DestinationチェーンのTeleporterコントラクトが呼び出されない限り、一生手数料が支払われないことになります。

ICM Registry

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

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

contracts/teleporter/registry/TeleporterRegistry.sol#L133-L135

    /**
     * @dev Gets the {ITeleporterMessenger} contract of the given `version`.
ava-labs/icm-contracts の元のファイルを開く

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

Teleporter Registryについて、詳しくは以下の記事を読んでください。

最後に

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

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