メインコンテンツへスキップ

Optimism標準ブリッジコントラクトのウォークスルー

Solidity
ブリッジ
レイヤー2
中級
Ori Pomerantz
2022年3月30日
52 分の読書

Optimism (opens in a new tab)オプティミスティック・ロールアップです。 オプティミスティック・ロールアップは、ネットワーク上のすべてのノードではなく一部のノードのみでトランザクションが処理されるため、イーサリアムメインネット(レイヤー1またはL1とも呼ばれる)よりもはるかに低い価格でトランザクションを処理できます。 同時に、すべてのデータがL1に書き込まれるため、メインネットの完全性と可用性の保証の元で、すべてを証明、再構築することが可能です。

Optimism(またはその他のL2)でL1アセットを使用するには、アセットをブリッジする必要があります。 これを実現する一つの方法は、ユーザーがL1でアセット(最も一般的なのはETHとERC-20トークンです)をロックし、L2で使用する同等のアセットを受け取ることです。 最終的に、それらのアセットを手にした人は、L1にブリッジして戻したいと思うかもしれません。 このとき、L2のアセットはバーンされ、L1でユーザーに返還されます。

これが、Optimism標準ブリッジ (opens in a new tab)の仕組みです。 この記事では、そのブリッジのソースコードをレビューし、その仕組みを確認し、適切に記述されたSolidityコードの例として学習します。

制御フロー

ブリッジには、2つの主要なフローがあります:

  • デポジット (L1からL2へ)
  • 引き出し (L2からL1へ)

デポジットフロー

レイヤー1

  1. ERC-20をデポジットする場合、デポジットする人は、デポジットされる金額を使用する権限をブリッジに与えます。
  2. デポジットする人はL1ブリッジを呼び出します(depositERC20depositERC20TodepositETH、またはdepositETHTo)
  3. L1ブリッジは、ブリッジされた資産の所有権を取得します。
    • ETH: アセットは呼び出しの一部として、デポジットする人によって転送されます。
    • ERC-20: アセットは、デポジットする人から提供された権限を使用して、ブリッジによってそれ自体に転送されます。
  4. L1ブリッジは、クロスドメインメッセージメカニズムを使用して、L2ブリッジのfinalizeDepositを呼び出します。

レイヤー2

  1. L2ブリッジはfinalizeDepositへの呼び出しが正当なものであることを検証します:
    • クロスドメインメッセージコントラクトからの呼び出しであること
    • もともとL1のブリッジからの呼び出しであること
  2. L2ブリッジは、L2上のERC-20トークンコントラクトが正しいものであるかを確認します:
    • L2コントラクトは、そのL1の対応物がL1から来たトークンと同じものであることを報告します。
    • L2コントラクトは正しいインターフェースをサポートしていることを報告します(ERC-165を使用 (opens in a new tab))。
  3. L2コントラクトが正しい場合、それを呼び出して適切な数のトークンを適切なアドレスにミントします。 そうでない場合、ユーザーがL1でトークンを要求できるように、引き出しプロセスを開始します。

引き出しフロー

レイヤー2

  1. 引き出す人はL2ブリッジを呼び出します(withdrawまたはwithdrawTo)
  2. L2ブリッジは、msg.senderに属する適切な数のトークンをバーンします。
  3. L2ブリッジは、クロスドメインメッセージメカニズムを使用して、L1ブリッジでfinalizeETHWithdrawalまたはfinalizeERC20Withdrawalを呼び出します。

レイヤー1

  1. L1ブリッジは、finalizeETHWithdrawalまたはfinalizeERC20Withdrawalへの呼び出しが正当であることを検証します:
    • クロスドメインメッセージメカニズムからの呼び出しであること
    • もともとL2のブリッジからの呼び出しであること
  2. L1ブリッジは、適切な資産(ETHまたはERC-20)を適切なアドレスに転送します。

レイヤー1コード

これは、L1であるイーサリアムメインネットで実行されるコードです。

IL1ERC20Bridge

このインターフェースはここで定義されています (opens in a new tab)。 これには、ERC-20トークンのブリッジングに必要な関数と定義が含まれています。

// SPDX-License-Identifier: MIT

OptimismのコードのほとんどはMITライセンスの下でリリースされています (opens in a new tab)

pragma solidity >0.5.0 <0.9.0;

執筆時点で、Solidityの最新バージョンは0.8.12です。 バージョン0.9.0がリリースされるまで、このコードに互換性があるかどうかはわかりません。

Optimismのブリッジ用語では、「デポジット」はL1からL2への転送を意味し、「引き出し」はL2からL1への転送を意味します。

        address indexed _l1Token,
        address indexed _l2Token,

ほとんどの場合、L1上のERC-20のアドレスは、L2上の同等のERC-20のアドレスとは異なります。 トークンアドレスのリストはこちらで確認できます (opens in a new tab)chainIdが1のアドレスはL1 (メインネット) 上にあり、chainIdが10のアドレスはL2 (Optimism) 上にあります。 他の2つのchainIdの値は、Kovanテストネットワーク(42)とOptimistic Kovanテストネットワーク(69)のものです。

        address indexed _from,
        address _to,
        uint256 _amount,
        bytes _data
    );

転送にメモを追加することが可能で、その場合、それらを報告するイベントに追加されます。

    event ERC20WithdrawalFinalized(
        address indexed _l1Token,
        address indexed _l2Token,
        address indexed _from,
        address _to,
        uint256 _amount,
        bytes _data
    );

同じブリッジコントラクトが、両方向の転送を処理します。 L1ブリッジの場合、これはデポジットの開始と引き出しの完了を意味します。

この関数は、L2では事前にデプロイされたコントラクトであるため、実際には必要ありません。したがって、常にアドレス0x4200000000000000000000000000000000000010にあります。 これはL2ブリッジとの対称性のためにあります。なぜなら、L1ブリッジのアドレスは簡単にはわからないからです。

_l2Gasパラメータは、トランザクションが使用できるL2ガスの量です。 一定の(高い)制限まで、これは無料です (opens in a new tab)。そのため、ミント時にERC-20コントラクトが本当に奇妙なことをしない限り、問題にはならないはずです。 この関数は、ユーザーが異なるブロックチェーン上の同じアドレスに資産をブリッジするという、一般的なシナリオに対応します。

この関数はdepositERC20とほぼ同じですが、ERC-20を異なるアドレスに送信できます。

Optimismでの引き出し(およびL2からL1への他のメッセージ)は、2段階のプロセスです:

  1. L2での開始トランザクション。
  2. L1での完了または請求トランザクション。 このトランザクションは、L2トランザクションのフォールトチャレンジ期間 (opens in a new tab)が終了した後に実行される必要があります。

IL1StandardBridge

このインターフェースはここで定義されています (opens in a new tab)。 このファイルには、ETHのイベントと関数の定義が含まれています。 これらの定義は、上記のIL1ERC20Bridgeで定義されたERC-20のものと非常によく似ています。

ブリッジインターフェースは2つのファイルに分かれています。なぜなら、一部のERC-20トークンはカスタム処理が必要で、標準ブリッジでは処理できないからです。 これにより、そのようなトークンを処理するカスタムブリッジは、IL1ERC20Bridgeを実装でき、ETHもブリッジする必要がありません。

このイベントは、ERC-20バージョン(ERC20DepositInitiated)とほぼ同じですが、L1とL2のトークンアドレスがない点が異なります。 他のイベントや関数についても同様です。

CrossDomainEnabled

このコントラクト (opens in a new tab)は、両方のブリッジ(L1L2)によって継承され、他のレイヤーにメッセージを送信します。

// SPDX-License-Identifier: MIT
pragma solidity >0.5.0 <0.9.0;

/* Interface Imports */
import { ICrossDomainMessenger } from "./ICrossDomainMessenger.sol";

このインターフェース (opens in a new tab)は、クロスドメインメッセンジャーを使用して、他のレイヤーにメッセージを送信する方法をコントラクトに伝えます。 このクロスドメインメッセンジャーはまったく別のシステムであり、それ自体で記事にする価値があるため、将来的に書きたいと思っています。

コントラクトが知る必要がある唯一のパラメータは、このレイヤー上のクロスドメインメッセンジャーのアドレスです。 このパラメータはコンストラクタで一度設定され、変更されることはありません。

クロスドメインメッセージングは、実行されているブロックチェーン(イーサリアムメインネットまたはOptimism)上のどのコントラクトからもアクセスできます。 しかし、各側のブリッジが、他の側のブリッジから来た場合にのみ特定のメッセージを信頼するようにする必要があります。

        require(
            msg.sender == address(getCrossDomainMessenger()),
            "OVM_XCHAIN: messenger contract unauthenticated"
        );

適切なクロスドメインメッセンジャー(以下で見るようにmessenger)からのメッセージのみが信頼できます。


        require(
            getCrossDomainMessenger().xDomainMessageSender() == _sourceDomainAccount,
            "OVM_XCHAIN: wrong sender of cross-domain message"
        );

クロスドメインメッセンジャーが、他のレイヤーでメッセージを送信したアドレスを提供する方法は、.xDomainMessageSender()関数 (opens in a new tab)です。 メッセージによって開始されたトランザクションで呼び出される限り、この情報を提供できます。

受け取ったメッセージが他のブリッジから来たことを確認する必要があります。

この関数は、クロスドメインメッセンジャーを返します。 変数messengerではなく関数を使用するのは、これから継承するコントラクトが、どのクロスドメインメッセンジャーを使用するかを指定するアルゴリズムを使用できるようにするためです。

最後に、他のレイヤーにメッセージを送信する関数です。

    ) internal {
        // slither-disable-next-line reentrancy-events, reentrancy-benign

Slither (opens in a new tab)は、Optimismがすべてのコントラクトで実行し、脆弱性やその他の潜在的な問題を検出するための静的アナライザーです。 この場合、次の行は2つの脆弱性を引き起こします:

  1. 再入可能性イベント (opens in a new tab)
  2. 良性の再入可能性 (opens in a new tab)
        getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);
    }
}

この場合、getCrossDomainMessenger()が信頼できるアドレスを返すことがわかっているため、再入可能性について心配する必要はありません。たとえSlitherがそれを知る方法がなくてもです。

L1ブリッジコントラクト

このコントラクトのソースコードはこちらです (opens in a new tab)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

インターフェースは他のコントラクトの一部になる可能性があるため、幅広いSolidityバージョンをサポートする必要があります。 しかし、ブリッジ自体は私たちのコントラクトであり、使用するSolidityバージョンについて厳密にすることができます。

/* Interface Imports */
import { IL1StandardBridge } from "./IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "./IL1ERC20Bridge.sol";

IL1ERC20BridgeIL1StandardBridgeについては、上記で説明しました。

import { IL2ERC20Bridge } from "../../L2/messaging/IL2ERC20Bridge.sol";

このインターフェース (opens in a new tab)により、L2の標準ブリッジを制御するためのメッセージを作成できます。

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

このインターフェース (opens in a new tab)により、ERC-20コントラクトを制御できます。 詳細はこちらで読むことができます

/* Library Imports */
import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";

上で説明したように、このコントラクトはレイヤー間メッセージングに使用されます。

import { Lib_PredeployAddresses } from "../../libraries/constants/Lib_PredeployAddresses.sol";

Lib_PredeployAddressesには、常に同じアドレスを持つL2コントラクトのアドレスが含まれています。 これにはL2の標準ブリッジが含まれます。

import { Address } from "@openzeppelin/contracts/utils/Address.sol";

OpenZeppelinのアドレスユーティリティ (opens in a new tab)。 これは、コントラクトアドレスと外部所有アカウント(EOA)に属するアドレスを区別するために使用されます。

これは、直接の呼び出しとコントラクトのコンストラクタからの呼び出しを区別する方法がないため、完璧な解決策ではないことに注意してください。しかし、少なくともこれにより、一般的なユーザーエラーを特定し、防ぐことができます。

import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

ERC-20標準 (opens in a new tab)は、コントラクトが失敗を報告する2つの方法をサポートしています:

  1. 元に戻す
  2. falseを返す

両方のケースを処理するとコードが複雑になるため、代わりにOpenZeppelinのSafeERC20 (opens in a new tab)を使用します。これにより、すべての失敗が revert になる (opens in a new tab)ことが保証されます。

/**
 * @title L1StandardBridge
 * @dev L1 ETHおよびERC20ブリッジは、デポジットされたL1資金と、L2で使用されている標準トークンを保存するコントラクトです。
 * 対応するL2ブリッジと同期し、デポジットを通知し、新しく完了した引き出しをリッスンします。
 *
 */
contract L1StandardBridge is IL1StandardBridge, CrossDomainEnabled {
    using SafeERC20 for IERC20;

この行は、IERC20インターフェースを使用するたびにSafeERC20ラッパーを使用するように指定する方法です。


    /********************************
     * 外部コントラクト参照 *
     ********************************/

    address public l2TokenBridge;

L2StandardBridgeのアドレス。


    // L1トークンをL2トークンにマッピングし、デポジットされたL1トークンの残高にマッピングします。
    mapping(address => mapping(address => uint256)) public deposits;

このような二重のマッピング (opens in a new tab)は、2次元スパース配列 (opens in a new tab)を定義する方法です。 このデータ構造の値は、deposit[L1トークンアドレス][L2トークンアドレス]として識別されます。 デフォルト値はゼロです。 異なる値に設定されたセルのみがストレージに書き込まれます。


    /***************
     * コンストラクタ *
     ***************/

    // このコントラクトはプロキシの背後にあるため、コンストラクタのパラメータは使用されません。
    constructor() CrossDomainEnabled(address(0)) {}

ストレージ内のすべての変数をコピーすることなく、このコントラクトをアップグレードできるようにしたいです。 そのためには、Proxy (opens in a new tab)を使用します。これは、delegatecall (opens in a new tab)を使用して、プロキシコントラクトによってアドレスが保存されている別のコントラクトに呼び出しを転送するコントラクトです(アップグレード時に、プロキシにそのアドレスを変更するように指示します)。 delegatecallを使用すると、ストレージは呼び出し元コントラクトのストレージのままになるため、すべてのコントラクトの状態変数の値は影響を受けません。

このパターンの1つの効果は、delegatecallの呼び出し先であるコントラクトのストレージが使用されないため、それに渡されるコンストラクタの値は重要ではないということです。 これが、CrossDomainEnabledコンストラクタに無意味な値を提供できる理由です。 また、以下の初期化がコンストラクタから分離されている理由でもあります。

このSlitherテスト (opens in a new tab)は、コントラクトコードから呼び出されず、したがってpublicではなくexternalとして宣言できる関数を特定します。 external関数のガス代は、calldataでパラメータを提供できるため、低くなる可能性があります。 publicと宣言された関数は、コントラクト内からアクセス可能である必要があります。 コントラクトは自身のcalldataを変更できないため、パラメータはメモリに保存する必要があります。 そのような関数が外部から呼び出される場合、calldataをメモリにコピーする必要があり、ガス代がかかります。 この場合、関数は一度しか呼び出されないため、非効率性は問題になりません。

    function initialize(address _l1messenger, address _l2TokenBridge) public {
        require(messenger == address(0), "Contract has already been initialized.");

initialize関数は、一度だけ呼び出す必要があります。 L1クロスドメインメッセンジャーまたはL2トークンブリッジのアドレスが変更された場合、新しいプロキシとそれを呼び出す新しいブリッジを作成します。 これは、システム全体がアップグレードされる場合を除き、起こる可能性は低く、非常にまれな出来事です。

この関数には、誰が呼び出せるかを制限するメカニズムがないことに注意してください。 つまり理論的には、攻撃者はプロキシとブリッジの最初のバージョンがデプロイされるのを待ち、正当なユーザーがinitialize関数にアクセスする前にフロントラン (opens in a new tab)を実行することができます。 しかし、これを防ぐ方法は2つあります:

  1. コントラクトがEOAによって直接デプロイされるのではなく、別のコントラクトがそれらを作成するトランザクション (opens in a new tab)でデプロイされる場合、プロセス全体がアトミックになり、他のトランザクションが実行される前に完了することができます。
  2. initializeへの正当な呼び出しが失敗した場合、新しく作成されたプロキシとブリッジを無視して、新しいものを作成することは常に可能です。
        messenger = _l1messenger;
        l2TokenBridge = _l2TokenBridge;
    }

これらは、ブリッジが知る必要がある2つのパラメータです。

これが、OpenZeppelinのAddressユーティリティが必要だった理由です。

    /**
     * @dev この関数は、データを指定せずに呼び出すことができ、L2の呼び出し元残高にETHの金額をデポジットします。
     * receive関数はデータを取らないため、保守的なデフォルト金額がL2に転送されます。
     */
    receive() external payable onlyEOA {
        _initiateETHDeposit(msg.sender, msg.sender, 200_000, bytes(""));
    }

この関数は、テスト目的で存在します。 インターフェース定義には表示されないことに注意してください。通常の使用のためではありません。

これら2つの関数は、実際のETHデポジットを処理する関数である_initiateETHDepositのラッパーです。

クロスドメインメッセージの仕組みは、宛先コントラクトがメッセージをcalldataとして呼び出されることです。 Solidityコントラクトは、常にABI仕様 (opens in a new tab)に従ってcalldataを解釈します。 Solidity関数abi.encodeWithSelector (opens in a new tab)は、そのcalldataを作成します。

            IL2ERC20Bridge.finalizeDeposit.selector,
            address(0),
            Lib_PredeployAddresses.OVM_ETH,
            _from,
            _to,
            msg.value,
            _data
        );

ここでのメッセージは、これらのパラメータでfinalizeDeposit関数 (opens in a new tab)を呼び出すことです:

パラメータ意味
_l1Tokenaddress(0)L1上のETH(ERC-20トークンではない)を表す特別な値
_l2TokenLib_PredeployAddresses.OVM_ETHOptimismでETHを管理するL2コントラクト、0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000(このコントラクトはOptimism内部でのみ使用されます)
_from_fromL1でETHを送信するアドレス
_to_toL2でETHを受信するアドレス
金額msg.value送信されたweiの量(すでにブリッジに送信済み)
_data_dataデポジットに添付する追加データ
        // calldataをL2に送信
        // slither-disable-next-line reentrancy-events
        sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);

クロスドメインメッセンジャーを介してメッセージを送信します。

        // slither-disable-next-line reentrancy-events
        emit ETHDepositInitiated(_from, _to, msg.value, _data);
    }

この転送をリッスンしている分散型アプリケーションに通知するためにイベントを発行します。

これら2つの関数は、実際のERC-20デポジットを処理する_initiateERC20Deposit関数のラッパーです。

この関数は上記の_initiateETHDepositに似ていますが、いくつかの重要な違いがあります。 最初の違いは、この関数がトークンアドレスと転送量をパラメータとして受け取ることです。 ETHの場合、ブリッジへの呼び出しには、すでにブリッジアカウントへの資産の移転(msg.value)が含まれています。

        // L1でデポジットが開始されると、L1ブリッジは将来の引き出しのために資金を自身に転送します。
        // safeTransferFromは、コントラクトにコードがあるかどうかもチェックするため、_fromがEOAまたはaddress(0)の場合、これは失敗します。
        // slither-disable-next-line reentrancy-events, reentrancy-benign
        IERC20(_l1Token).safeTransferFrom(_from, address(this), _amount);

ERC-20トークンの転送は、ETHとは異なるプロセスに従います:

  1. ユーザー(_from)は、適切なトークンを転送するための権限をブリッジに与えます。
  2. ユーザーは、トークンコントラクトのアドレス、金額などでブリッジを呼び出します。
  3. ブリッジは、デポジットプロセスの一環として、トークンを(自身に)転送します。

最初のステップは、最後の2つのステップとは別のトランザクションで行われる場合があります。 ただし、_initiateERC20Depositを呼び出す2つの関数(depositERC20depositERC20To)は、_fromパラメータとしてmsg.senderを使用してこの関数を呼び出すだけなので、フロントランニングは問題になりません。

デポジットされたトークンの量をdepositsデータ構造に追加します。 L2には同じL1 ERC-20トークンに対応する複数のアドレスが存在する可能性があるため、ブリッジのL1 ERC-20トークン残高を使用してデポジットを追跡するだけでは不十分です。

L2ブリッジはL2クロスドメインメッセンジャーにメッセージを送信し、これによりL1クロスドメインメッセンジャーがこの関数を呼び出します(もちろん、メッセージを完了するトランザクション (opens in a new tab)がL1で送信された後)。

    ) external onlyFromCrossDomainAccount(l2TokenBridge) {

これが、クロスドメインメッセンジャーから来て、L2トークンブリッジから発信された正当なメッセージであることを確認してください。 この関数はブリッジからETHを引き出すために使用されるため、承認された呼び出し元によってのみ呼び出されることを確認する必要があります。

        // slither-disable-next-line reentrancy-events
        (bool success, ) = _to.call{ value: _amount }(new bytes(0));

ETHを転送する方法は、msg.valueにweiの量を指定して受信者を呼び出すことです。

        require(success, "TransferHelper::safeTransferETH: ETH transfer failed");

        // slither-disable-next-line reentrancy-events
        emit ETHWithdrawalFinalized(_from, _to, _amount, _data);

引き出しに関するイベントを発行します。

この関数は上記のfinalizeETHWithdrawalに似ていますが、ERC-20トークンに必要な変更が加えられています。

        deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] - _amount;

depositsデータ構造を更新します。

ブリッジの以前の実装がありました。 その実装からこの実装に移行したとき、すべての資産を移動する必要がありました。 ERC-20トークンは移動するだけです。 ただし、ETHをコントラクトに転送するには、そのコントラクトの承認が必要であり、それがdonateETHが提供するものです。

L2上のERC-20トークン

ERC-20トークンが標準ブリッジに適合するためには、標準ブリッジ、そして標準ブリッジのみがトークンをミントできるようにする必要があります。 これは、Optimismで流通しているトークンの数が、L1ブリッジコントラクト内にロックされているトークンの数と等しいことをブリッジが保証する必要があるためです。 L2にトークンが多すぎると、一部のユーザーは資産をL1に戻すことができなくなります。 信頼できるブリッジの代わりに、私たちは本質的に部分準備銀行制度 (opens in a new tab)を再現することになります。 L1にトークンが多すぎると、L2トークンをバーンしない限り解放する方法がないため、それらのトークンの一部はブリッジコントラクト内に永久にロックされたままになります。

IL2StandardERC20

標準ブリッジを使用するL2上のすべてのERC-20トークンは、このインターフェース (opens in a new tab)を提供する必要があります。これには、標準ブリッジが必要とする関数とイベントが含まれています。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

標準のERC-20インターフェース (opens in a new tab)には、mintおよびburn関数は含まれていません。 これらのメソッドは、ERC-20標準 (opens in a new tab)では要求されておらず、トークンを作成および破棄するメカニズムは指定されていません。

import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol";

ERC-165インターフェース (opens in a new tab)は、コントラクトが提供する関数を指定するために使用されます。 こちらで標準を読むことができます (opens in a new tab)

interface IL2StandardERC20 is IERC20, IERC165 {
    function l1Token() external returns (address);

この関数は、このコントラクトにブリッジされたL1トークンのアドレスを提供します。 逆方向の同様の関数がないことに注意してください。 L2サポートが実装時に計画されていたかどうかに関係なく、任意のL1トークンをブリッジできる必要があります。


    function mint(address _to, uint256 _amount) external;

    function burn(address _from, uint256 _amount) external;

    event Mint(address indexed _account, uint256 _amount);
    event Burn(address indexed _account, uint256 _amount);
}

トークンをミント(作成)およびバーン(破棄)するための関数とイベント。 トークンの数が正しいこと(L1にロックされているトークンの数と等しいこと)を保証するため、ブリッジはこれらの関数を実行できる唯一のエンティティである必要があります。

L2StandardERC20

これはIL2StandardERC20インターフェースの実装です (opens in a new tab)。 何らかのカスタムロジックが必要でない限り、これを使用する必要があります。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

OpenZeppelin ERC-20コントラクト (opens in a new tab)。 Optimismは、特に車輪が十分に監査され、資産を保持するのに十分信頼できる必要がある場合に、車輪を再発明することを信じていません。

import "./IL2StandardERC20.sol";

contract L2StandardERC20 is IL2StandardERC20, ERC20 {
    address public l1Token;
    address public l2Bridge;

これらは、私たちが要求し、通常ERC-20が必要としない2つの追加の設定パラメータです。

まず、継承元のコントラクトのコンストラクタ(ERC20(_name, _symbol))を呼び出し、次に独自の変数を設定します。

これがERC-165 (opens in a new tab)の仕組みです。 すべてのインターフェースは、サポートされている関数の数であり、それらの関数のABI関数セレクタ (opens in a new tab)排他的論理和 (opens in a new tab)として識別されます。

L2ブリッジは、ERC-165をサニティチェックとして使用して、資産を送信するERC-20コントラクトがIL2StandardERC20であることを確認します。

注: 不正なコントラクトがsupportsInterfaceに偽の回答を提供することを防ぐものはないため、これはサニティチェックメカニズムであり、セキュリティメカニズムではありません。

資産をミントおよびバーンできるのは、L2ブリッジのみです。

_mint_burnは、実際にはOpenZeppelin ERC-20コントラクトで定義されています。 そのコントラクトは、トークンをミントおよびバーンする条件がERC-20の使用方法と同じくらい多様であるため、それらを外部に公開しないだけです。

L2ブリッジコード

これは、Optimismでブリッジを実行するコードです。 このコントラクトのソースはこちらです (opens in a new tab)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

/* Interface Imports */
import { IL1StandardBridge } from "../../L1/messaging/IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "../../L1/messaging/IL1ERC20Bridge.sol";
import { IL2ERC20Bridge } from "./IL2ERC20Bridge.sol";

IL2ERC20Bridge (opens in a new tab)インターフェースは、上で見たL1の同等のものと非常に似ています。 2つの大きな違いがあります:

  1. L1では、デポジットを開始し、引き出しを完了します。 ここでは、引き出しを開始し、デポジットを完了します。
  2. L1では、ETHとERC-20トークンを区別する必要があります。 L2では、Optimismの内部ETH残高はアドレス0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (opens in a new tab)のERC-20トークンとして処理されるため、両方に同じ関数を使用できます。

L1ブリッジのアドレスを追跡します。 L1の同等のものとは対照的に、ここではこの変数が必要であることに注意してください。 L1ブリッジのアドレスは事前にわかりません。

これら2つの関数は、引き出しを開始します。 L1トークンアドレスを指定する必要はないことに注意してください。 L2トークンは、L1の同等のアドレスを教えてくれることが期待されています。

_fromパラメータに依存するのではなく、偽造するのがはるかに難しい(私の知る限り不可能) msg.senderに依存していることに注意してください。


        // l1TokenBridge.finalizeERC20Withdrawal(_to, _amount)のcalldataを構築
        // slither-disable-next-line reentrancy-events
        address l1Token = IL2StandardERC20(_l2Token).l1Token();
        bytes memory message;

        if (_l2Token == Lib_PredeployAddresses.OVM_ETH) {

L1では、ETHとERC-20を区別する必要があります。

この関数はL1StandardBridgeによって呼び出されます。

    ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {

メッセージのソースが正当であることを確認してください。 この関数は_mintを呼び出し、ブリッジがL1で所有するトークンでカバーされていないトークンを与えるために使用できるため、これは重要です。

        // ターゲットトークンが準拠していることを確認し、
        // L1でデポジットされたトークンがここのL2デポジットトークン表現と一致することを検証します。
        if (
            // slither-disable-next-line reentrancy-events
            ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&
            _l1Token == IL2StandardERC20(_l2Token).l1Token()

サニティチェック:

  1. 正しいインターフェースがサポートされていること
  2. L2 ERC-20コントラクトのL1アドレスが、トークンのL1ソースと一致すること
        ) {
            // デポジットが完了すると、L2のアカウントに同額のトークンを入金します。
            // slither-disable-next-line reentrancy-events
            IL2StandardERC20(_l2Token).mint(_to, _amount);
            // slither-disable-next-line reentrancy-events
            emit DepositFinalized(_l1Token, _l2Token, _from, _to, _amount, _data);

サニティチェックに合格した場合、デポジットを完了します:

  1. トークンをミントします。
  2. 適切なイベントを発行します。
        } else {
            // デポジット先のL2トークンが、そのL1トークンの正しいアドレスについて同意しないか、正しいインターフェースをサポートしていないかのいずれかです。
            // これは、悪意のあるL2トークンがある場合、またはユーザーが何らかの方法でデポジット先の間違ったL2トークンアドレスを指定した場合にのみ発生するはずです。
            // いずれの場合も、ここでプロセスを停止し、引き出しメッセージを構築して、ユーザーが場合によっては資金を取り出せるようにします。
            // 悪意のあるトークンコントラクトを完全に防ぐ方法はありませんが、これによりユーザーエラーが制限され、悪意のあるコントラクトの動作のいくつかの形態が軽減されます。

ユーザーが間違ったL2トークンアドレスを使用して検出可能なエラーを犯した場合、デポジットをキャンセルしてL1でトークンを返したいです。 これをL2から行う唯一の方法は、フォールトチャレンジ期間を待つ必要があるメッセージを送信することですが、それはユーザーがトークンを永久に失うよりもはるかに良いです。

結論

標準ブリッジは、資産転送のための最も柔軟なメカニズムです。 しかし、非常に汎用的であるため、必ずしも最も使いやすいメカニズムではありません。 特に、出金に関しては、ほとんどのユーザーが、チャレンジ期間を待つ必要がなく、出金をファイナライズするためにマークル証明を必要としないサードパーティ製ブリッジ (opens in a new tab)を使用することを好みます。

これらのブリッジは通常、L1に資産を持ち、それを少額の手数料(多くの場合、標準ブリッジの引き出しのガス代よりも安い)ですぐに提供することで機能します。 ブリッジ(またはそれを運営する人々)がL1の資産が不足すると予想する場合、L2から十分な資産を転送します。 これらは非常に大きな引き出しであるため、引き出しコストは多額にわたって償却され、はるかに小さい割合になります。

この記事が、レイヤー2の仕組みと、明確で安全なSolidityコードの書き方について、より理解を深めるのに役立ったことを願っています。

私の他の作品はこちらでご覧いただけます (opens in a new tab).

ページの最終更新: 2026年4月3日

このチュートリアルは役に立ちましたか?