Optimism標準ブリッジコントラクトのウォークスルー
Optimismopens 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
- ERC-20をデポジットする場合、デポジットする人は、デポジットされる金額を使用する権限をブリッジに与えます。
- デポジットする人はL1ブリッジを呼び出します(
depositERC20、depositERC20To、depositETH、またはdepositETHTo) - L1ブリッジは、ブリッジされた資産の所有権を取得します。
- ETH: アセットは呼び出しの一部として、デポジットする人によって転送されます。
- ERC-20: アセットは、デポジットする人から提供された権限を使用して、ブリッジによってそれ自体に転送されます。
- L1ブリッジは、クロスドメインメッセージメカニズムを使用して、L2ブリッジの
finalizeDepositを呼び出します。
レイヤー2
- L2ブリッジは
finalizeDepositへの呼び出しが正当なものであることを検証します:- クロスドメインメッセージコントラクトからの呼び出しであること
- もともとL1のブリッジからの呼び出しであること
- L2ブリッジは、L2上のERC-20トークンコントラクトが正しいものであるかを確認します:
- L2コントラクトは、そのL1の対応物がL1から来たトークンと同じものであることを報告します。
- L2コントラクトは正しいインターフェースをサポートしていることを報告します(ERC-165を使用opens in a new tab)。
- L2コントラクトが正しい場合、それを呼び出して適切な数のトークンを適切なアドレスにミントします。 そうでない場合、ユーザーがL1でトークンを要求できるように、引き出しプロセスを開始します。
引き出しフロー
レイヤー2
- 引き出す人はL2ブリッジを呼び出します(
withdrawまたはwithdrawTo) - L2ブリッジは、
msg.senderに属する適切な数のトークンをバーンします。 - L2ブリッジは、クロスドメインメッセージメカニズムを使用して、L1ブリッジで
finalizeETHWithdrawalまたはfinalizeERC20Withdrawalを呼び出します。
レイヤー1
- L1ブリッジは、
finalizeETHWithdrawalまたはfinalizeERC20Withdrawalへの呼び出しが正当であることを検証します:- クロスドメインメッセージメカニズムからの呼び出しであること
- もともとL2のブリッジからの呼び出しであること
- L1ブリッジは、適切な資産(ETHまたはERC-20)を適切なアドレスに転送します。
レイヤー1コード
これは、L1であるイーサリアムメインネットで実行されるコードです。
IL1ERC20Bridge
このインターフェースはここで定義されていますopens in a new tab。 これには、ERC-20トークンのブリッジングに必要な関数と定義が含まれています。
1// SPDX-License-Identifier: MITOptimismのコードのほとんどはMITライセンスの下でリリースされていますopens in a new tab。
1pragma solidity >0.5.0 <0.9.0;執筆時点で、Solidityの最新バージョンは0.8.12です。 バージョン0.9.0がリリースされるまで、このコードに互換性があるかどうかはわかりません。
1/**2 * @title IL1ERC20Bridge3 */4interface IL1ERC20Bridge {5 /**********6 * イベント *7 **********/89 event ERC20DepositInitiated(すべて表示Optimismのブリッジ用語では、「デポジット」はL1からL2への転送を意味し、「引き出し」はL2からL1への転送を意味します。
1 address indexed _l1Token,2 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)のものです。
1 address indexed _from,2 address _to,3 uint256 _amount,4 bytes _data5 );転送にメモを追加することが可能で、その場合、それらを報告するイベントに追加されます。
1 event ERC20WithdrawalFinalized(2 address indexed _l1Token,3 address indexed _l2Token,4 address indexed _from,5 address _to,6 uint256 _amount,7 bytes _data8 );同じブリッジコントラクトが、両方向の転送を処理します。 L1ブリッジの場合、これはデポジットの開始と引き出しの完了を意味します。
12 /********************3 * 公開関数 *4 ********************/56 /**7 * @dev 対応するL2ブリッジコントラクトのアドレスを取得します。8 * @return 対応するL2ブリッジコントラクトのアドレス。9 */10 function l2TokenBridge() external returns (address);すべて表示この関数は、L2では事前にデプロイされたコントラクトであるため、実際には必要ありません。したがって、常にアドレス0x4200000000000000000000000000000000000010にあります。
これはL2ブリッジとの対称性のためにあります。なぜなら、L1ブリッジのアドレスは簡単にはわからないからです。
1 /**2 * @dev L2の呼び出し元残高にERC20の金額をデポジットします。3 * @param _l1Token デポジットするL1 ERC20のアドレス4 * @param _l2Token L1の各L2 ERC20のアドレス5 * @param _amount デポジットするERC20の金額6 * @param _l2Gas L2でデポジットを完了するために必要なガスリミット。7 * @param _data L2に転送するオプションのデータ。このデータは、外部コントラクトの便宜のためにのみ提供されます。8 * 最大長を強制する以外、これらのコントラクトはその内容について何の保証も提供しません。9 */10 function depositERC20(11 address _l1Token,12 address _l2Token,13 uint256 _amount,14 uint32 _l2Gas,15 bytes calldata _data16 ) external;すべて表示_l2Gasパラメータは、トランザクションが使用できるL2ガスの量です。
一定の(高い)制限まで、これは無料ですopens in a new tab。そのため、ミント時にERC-20コントラクトが本当に奇妙なことをしない限り、問題にはならないはずです。
この関数は、ユーザーが異なるブロックチェーン上の同じアドレスに資産をブリッジするという、一般的なシナリオに対応します。
1 /**2 * @dev L2の受取人の残高にERC20の金額をデポジットします。3 * @param _l1Token デポジットするL1 ERC20のアドレス4 * @param _l2Token L1の各L2 ERC20のアドレス5 * @param _to 引き出しの入金先L2アドレス。6 * @param _amount デポジットするERC20の金額。7 * @param _l2Gas L2でデポジットを完了するために必要なガスリミット。8 * @param _data L2に転送するオプションのデータ。このデータは、外部コントラクトの便宜のためにのみ提供されます。9 * 最大長を強制する以外、これらのコントラクトはその内容について何の保証も提供しません。10 */11 function depositERC20To(12 address _l1Token,13 address _l2Token,14 address _to,15 uint256 _amount,16 uint32 _l2Gas,17 bytes calldata _data18 ) external;すべて表示この関数はdepositERC20とほぼ同じですが、ERC-20を異なるアドレスに送信できます。
1 /*************************2 * クロスチェーン関数 *3 *************************/45 /**6 * @dev L2からL1への引き出しを完了し、受取人のL1 ERC20トークン残高に資金を入金します。7 * この呼び出しは、L2からの初期化された引き出しが完了していない場合、失敗します。8 *9 * @param _l1Token finalizeWithdrawalの対象となるL1トークンのアドレス。10 * @param _l2Token 引き出しが開始されたL2トークンのアドレス。11 * @param _from 転送を開始するL2アドレス。12 * @param _to 引き出しの入金先L1アドレス。13 * @param _amount デポジットするERC20の金額。14 * @param _data L2の送信者から提供されたデータ。このデータは、外部コントラクトの便宜のためにのみ提供されます。15 * 最大長を強制する以外、これらのコントラクトはその内容について何の保証も提供しません。16 */17 function finalizeERC20Withdrawal(18 address _l1Token,19 address _l2Token,20 address _from,21 address _to,22 uint256 _amount,23 bytes calldata _data24 ) external;25}すべて表示Optimismでの引き出し(およびL2からL1への他のメッセージ)は、2段階のプロセスです:
- L2での開始トランザクション。
- L1での完了または請求トランザクション。 このトランザクションは、L2トランザクションのフォールトチャレンジ期間opens in a new tabが終了した後に実行される必要があります。
IL1StandardBridge
このインターフェースはここで定義されていますopens in a new tab。
このファイルには、ETHのイベントと関数の定義が含まれています。
これらの定義は、上記のIL1ERC20Bridgeで定義されたERC-20のものと非常によく似ています。
ブリッジインターフェースは2つのファイルに分かれています。なぜなら、一部のERC-20トークンはカスタム処理が必要で、標準ブリッジでは処理できないからです。
これにより、そのようなトークンを処理するカスタムブリッジは、IL1ERC20Bridgeを実装でき、ETHもブリッジする必要がありません。
1// SPDX-License-Identifier: MIT2pragma solidity >0.5.0 <0.9.0;34import "./IL1ERC20Bridge.sol";56/**7 * @title IL1StandardBridge8 */9interface IL1StandardBridge is IL1ERC20Bridge {10 /**********11 * イベント *12 **********/13 event ETHDepositInitiated(14 address indexed _from,15 address indexed _to,16 uint256 _amount,17 bytes _data18 );すべて表示このイベントは、ERC-20バージョン(ERC20DepositInitiated)とほぼ同じですが、L1とL2のトークンアドレスがない点が異なります。
他のイベントや関数についても同様です。
1 event ETHWithdrawalFinalized(2 .3 .4 .5 );67 /********************8 * 公開関数 *9 ********************/1011 /**12 * @dev L2の呼び出し元残高にETHの金額をデポジットします。13 .14 .15 .16 */17 function depositETH(uint32 _l2Gas, bytes calldata _data) external payable;1819 /**20 * @dev L2の受取人の残高にETHの金額をデポジットします。21 .22 .23 .24 */25 function depositETHTo(26 address _to,27 uint32 _l2Gas,28 bytes calldata _data29 ) external payable;3031 /*************************32 * クロスチェーン関数 *33 *************************/3435 /**36 * @dev L2からL1への引き出しを完了し、受取人のL1 ETHトークン残高に資金を入金します。37 * この関数はxDomainMessengerのみが呼び出せるため、引き出しが完了する前に呼び出されることはありません。38 .39 .40 .41 */42 function finalizeETHWithdrawal(43 address _from,44 address _to,45 uint256 _amount,46 bytes calldata _data47 ) external;48}すべて表示CrossDomainEnabled
このコントラクトopens in a new tabは、両方のブリッジ(L1とL2)によって継承され、他のレイヤーにメッセージを送信します。
1// SPDX-License-Identifier: MIT2pragma solidity >0.5.0 <0.9.0;34/* Interface Imports */5import { ICrossDomainMessenger } from "./ICrossDomainMessenger.sol";このインターフェースopens in a new tabは、クロスドメインメッセンジャーを使用して、他のレイヤーにメッセージを送信する方法をコントラクトに伝えます。 このクロスドメインメッセンジャーはまったく別のシステムであり、それ自体で記事にする価値があるため、将来的に書きたいと思っています。
1/**2 * @title CrossDomainEnabled3 * @dev クロスドメイン通信を実行するコントラクトのヘルパーコントラクト4 *5 * 使用されるコンパイラ: 継承するコントラクトによって定義6 */7contract CrossDomainEnabled {8 /*************9 * 変数 *10 *************/1112 // 他のドメインからメッセージを送受信するために使用されるメッセンジャーコントラクト13 address public messenger;1415 /***************16 * コンストラクタ *17 ***************/1819 /**20 * @param _messenger 現在のレイヤー上のCrossDomainMessengerのアドレス21 */22 constructor(address _messenger) {23 messenger = _messenger;24 }すべて表示コントラクトが知る必要がある唯一のパラメータは、このレイヤー上のクロスドメインメッセンジャーのアドレスです。 このパラメータはコンストラクタで一度設定され、変更されることはありません。
12 /**********************3 * 関数修飾子 *4 **********************/56 /**7 * 変更された関数が特定のクロスドメインアカウントによってのみ呼び出し可能であることを強制します。8 * @param _sourceDomainAccount この関数を呼び出すことが認証されている、発信元ドメインの唯一のアカウント。9 */10 modifier onlyFromCrossDomainAccount(address _sourceDomainAccount) {すべて表示クロスドメインメッセージングは、実行されているブロックチェーン(イーサリアムメインネットまたはOptimism)上のどのコントラクトからもアクセスできます。 しかし、各側のブリッジが、他の側のブリッジから来た場合にのみ特定のメッセージを信頼するようにする必要があります。
1 require(2 msg.sender == address(getCrossDomainMessenger()),3 "OVM_XCHAIN: messenger contract unauthenticated"4 );適切なクロスドメインメッセンジャー(以下で見るようにmessenger)からのメッセージのみが信頼できます。
12 require(3 getCrossDomainMessenger().xDomainMessageSender() == _sourceDomainAccount,4 "OVM_XCHAIN: wrong sender of cross-domain message"5 );クロスドメインメッセンジャーが、他のレイヤーでメッセージを送信したアドレスを提供する方法は、.xDomainMessageSender()関数opens in a new tabです。
メッセージによって開始されたトランザクションで呼び出される限り、この情報を提供できます。
受け取ったメッセージが他のブリッジから来たことを確認する必要があります。
12 _;3 }45 /**********************6 * 内部関数 *7 **********************/89 /**10 * 通常はストレージからメッセンジャーを取得します。この関数は、子コントラクトがオーバーライドする必要がある場合に公開されます。11 * @return 使用すべきクロスドメインメッセンジャーコントラクトのアドレス。12 */13 function getCrossDomainMessenger() internal virtual returns (ICrossDomainMessenger) {14 return ICrossDomainMessenger(messenger);15 }すべて表示この関数は、クロスドメインメッセンジャーを返します。
変数messengerではなく関数を使用するのは、これから継承するコントラクトが、どのクロスドメインメッセンジャーを使用するかを指定するアルゴリズムを使用できるようにするためです。
12 /**3 * 他のドメインのアカウントにメッセージを送信します。4 * @param _crossDomainTarget 宛先ドメインの意図した受信者5 * @param _message ターゲットに送信するデータ(通常は`onlyFromCrossDomainAccount()`を持つ関数へのcalldata)6 * @param _gasLimit ターゲットドメインでのメッセージのレシートのgasLimit。7 */8 function sendCrossDomainMessage(9 address _crossDomainTarget,10 uint32 _gasLimit,11 bytes memory _messageすべて表示最後に、他のレイヤーにメッセージを送信する関数です。
1 ) internal {2 // slither-disable-next-line reentrancy-events, reentrancy-benignSlitheropens in a new tabは、Optimismがすべてのコントラクトで実行し、脆弱性やその他の潜在的な問題を検出するための静的アナライザーです。 この場合、次の行は2つの脆弱性を引き起こします:
1 getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);2 }3}この場合、getCrossDomainMessenger()が信頼できるアドレスを返すことがわかっているため、再入可能性について心配する必要はありません。たとえSlitherがそれを知る方法がなくてもです。
L1ブリッジコントラクト
このコントラクトのソースコードはこちらですopens in a new tab。
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;インターフェースは他のコントラクトの一部になる可能性があるため、幅広いSolidityバージョンをサポートする必要があります。 しかし、ブリッジ自体は私たちのコントラクトであり、使用するSolidityバージョンについて厳密にすることができます。
1/* Interface Imports */2import { IL1StandardBridge } from "./IL1StandardBridge.sol";3import { IL1ERC20Bridge } from "./IL1ERC20Bridge.sol";IL1ERC20BridgeとIL1StandardBridgeについては、上記で説明しました。
1import { IL2ERC20Bridge } from "../../L2/messaging/IL2ERC20Bridge.sol";このインターフェースopens in a new tabにより、L2の標準ブリッジを制御するためのメッセージを作成できます。
1import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";このインターフェースopens in a new tabにより、ERC-20コントラクトを制御できます。 詳細はこちらで読むことができます。
1/* Library Imports */2import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";上で説明したように、このコントラクトはレイヤー間メッセージングに使用されます。
1import { Lib_PredeployAddresses } from "../../libraries/constants/Lib_PredeployAddresses.sol";Lib_PredeployAddressesには、常に同じアドレスを持つL2コントラクトのアドレスが含まれています。 これにはL2の標準ブリッジが含まれます。
1import { Address } from "@openzeppelin/contracts/utils/Address.sol";OpenZeppelinのアドレスユーティリティopens in a new tab。 これは、コントラクトアドレスと外部所有アカウント(EOA)に属するアドレスを区別するために使用されます。
これは、直接の呼び出しとコントラクトのコンストラクタからの呼び出しを区別する方法がないため、完璧な解決策ではないことに注意してください。しかし、少なくともこれにより、一般的なユーザーエラーを特定し、防ぐことができます。
1import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";ERC-20標準opens in a new tabは、コントラクトが失敗を報告する2つの方法をサポートしています:
- 元に戻す
falseを返す
両方のケースを処理するとコードが複雑になるため、代わりにOpenZeppelinのSafeERC20opens in a new tabを使用します。これにより、すべての失敗が revert になるopens in a new tabことが保証されます。
1/**2 * @title L1StandardBridge3 * @dev L1 ETHおよびERC20ブリッジは、デポジットされたL1資金と、L2で使用されている標準トークンを保存するコントラクトです。4 * 対応するL2ブリッジと同期し、デポジットを通知し、新しく完了した引き出しをリッスンします。5 *6 */7contract L1StandardBridge is IL1StandardBridge, CrossDomainEnabled {8 using SafeERC20 for IERC20;この行は、IERC20インターフェースを使用するたびにSafeERC20ラッパーを使用するように指定する方法です。
12 /********************************3 * 外部コントラクト参照 *4 ********************************/56 address public l2TokenBridge;L2StandardBridgeのアドレス。
12 // L1トークンをL2トークンにマッピングし、デポジットされたL1トークンの残高にマッピングします。3 mapping(address => mapping(address => uint256)) public deposits;このような二重のマッピングopens in a new tabは、2次元スパース配列opens in a new tabを定義する方法です。
このデータ構造の値は、deposit[L1トークンアドレス][L2トークンアドレス]として識別されます。
デフォルト値はゼロです。
異なる値に設定されたセルのみがストレージに書き込まれます。
12 /***************3 * コンストラクタ *4 ***************/56 // このコントラクトはプロキシの背後にあるため、コンストラクタのパラメータは使用されません。7 constructor() CrossDomainEnabled(address(0)) {}ストレージ内のすべての変数をコピーすることなく、このコントラクトをアップグレードできるようにしたいです。
そのためには、Proxyopens in a new tabを使用します。これは、delegatecallopens in a new tabを使用して、プロキシコントラクトによってアドレスが保存されている別のコントラクトに呼び出しを転送するコントラクトです(アップグレード時に、プロキシにそのアドレスを変更するように指示します)。
delegatecallを使用すると、ストレージは呼び出し元コントラクトのストレージのままになるため、すべてのコントラクトの状態変数の値は影響を受けません。
このパターンの1つの効果は、delegatecallの呼び出し先であるコントラクトのストレージが使用されないため、それに渡されるコンストラクタの値は重要ではないということです。
これが、CrossDomainEnabledコンストラクタに無意味な値を提供できる理由です。
また、以下の初期化がコンストラクタから分離されている理由でもあります。
1 /******************2 * 初期化 *3 ******************/45 /**6 * @param _l1messenger クロスチェーン通信に使用されるL1メッセンジャーアドレス。7 * @param _l2TokenBridge L2標準ブリッジアドレス。8 */9 // slither-disable-next-line external-functionすべて表示このSlitherテストopens in a new tabは、コントラクトコードから呼び出されず、したがってpublicではなくexternalとして宣言できる関数を特定します。
external関数のガス代は、calldataでパラメータを提供できるため、低くなる可能性があります。
publicと宣言された関数は、コントラクト内からアクセス可能である必要があります。
コントラクトは自身のcalldataを変更できないため、パラメータはメモリに保存する必要があります。
そのような関数が外部から呼び出される場合、calldataをメモリにコピーする必要があり、ガス代がかかります。
この場合、関数は一度しか呼び出されないため、非効率性は問題になりません。
1 function initialize(address _l1messenger, address _l2TokenBridge) public {2 require(messenger == address(0), "Contract has already been initialized.");initialize関数は、一度だけ呼び出す必要があります。
L1クロスドメインメッセンジャーまたはL2トークンブリッジのアドレスが変更された場合、新しいプロキシとそれを呼び出す新しいブリッジを作成します。
これは、システム全体がアップグレードされる場合を除き、起こる可能性は低く、非常にまれな出来事です。
この関数には、誰が呼び出せるかを制限するメカニズムがないことに注意してください。
つまり理論的には、攻撃者はプロキシとブリッジの最初のバージョンがデプロイされるのを待ち、正当なユーザーがinitialize関数にアクセスする前にフロントランopens in a new tabを実行することができます。 しかし、これを防ぐ方法は2つあります:
- コントラクトがEOAによって直接デプロイされるのではなく、別のコントラクトがそれらを作成するトランザクションopens in a new tabでデプロイされる場合、プロセス全体がアトミックになり、他のトランザクションが実行される前に完了することができます。
initializeへの正当な呼び出しが失敗した場合、新しく作成されたプロキシとブリッジを無視して、新しいものを作成することは常に可能です。
1 messenger = _l1messenger;2 l2TokenBridge = _l2TokenBridge;3 }これらは、ブリッジが知る必要がある2つのパラメータです。
12 /**************3 * デポジット *4 **************/56 /** @dev 送信者がEOAであることを要求する修飾子。このチェックは、悪意のあるコントラクトによって7 * initcode経由で回避される可能性がありますが、私たちが避けたいユーザーエラーに対応します。8 */9 modifier onlyEOA() {10 // コントラクトからのデポジットを停止するために使用(誤って失われたトークンを避けるため)11 require(!Address.isContract(msg.sender), "Account not EOA");12 _;13 }すべて表示これが、OpenZeppelinのAddressユーティリティが必要だった理由です。
1 /**2 * @dev この関数は、データを指定せずに呼び出すことができ、L2の呼び出し元残高にETHの金額をデポジットします。3 * receive関数はデータを取らないため、保守的なデフォルト金額がL2に転送されます。4 */5 receive() external payable onlyEOA {6 _initiateETHDeposit(msg.sender, msg.sender, 200_000, bytes(""));7 }この関数は、テスト目的で存在します。 インターフェース定義には表示されないことに注意してください。通常の使用のためではありません。
1 /**2 * @inheritdoc IL1StandardBridge3 */4 function depositETH(uint32 _l2Gas, bytes calldata _data) external payable onlyEOA {5 _initiateETHDeposit(msg.sender, msg.sender, _l2Gas, _data);6 }78 /**9 * @inheritdoc IL1StandardBridge10 */11 function depositETHTo(12 address _to,13 uint32 _l2Gas,14 bytes calldata _data15 ) external payable {16 _initiateETHDeposit(msg.sender, _to, _l2Gas, _data);17 }すべて表示これら2つの関数は、実際のETHデポジットを処理する関数である_initiateETHDepositのラッパーです。
1 /**2 * @dev ETHを保存し、L2 ETHゲートウェイにデポジットを通知することで、デポジットのロジックを実行します。3 * @param _from L1でデポジットを引き出すアカウント。4 * @param _to L2でデポジットを与えるアカウント。5 * @param _l2Gas L2でデポジットを完了するために必要なガスリミット。6 * @param _data L2に転送するオプションのデータ。このデータは、外部コントラクトの便宜のためにのみ提供されます。7 * 最大長を強制する以外、これらのコントラクトはその内容について何の保証も提供しません。8 */9 function _initiateETHDeposit(10 address _from,11 address _to,12 uint32 _l2Gas,13 bytes memory _data14 ) internal {15 // finalizeDeposit呼び出しのcalldataを構築16 bytes memory message = abi.encodeWithSelector(すべて表示クロスドメインメッセージの仕組みは、宛先コントラクトがメッセージをcalldataとして呼び出されることです。
Solidityコントラクトは、常にABI仕様opens in a new tabに従ってcalldataを解釈します。
Solidity関数abi.encodeWithSelectoropens in a new tabは、そのcalldataを作成します。
1 IL2ERC20Bridge.finalizeDeposit.selector,2 address(0),3 Lib_PredeployAddresses.OVM_ETH,4 _from,5 _to,6 msg.value,7 _data8 );ここでのメッセージは、これらのパラメータでfinalizeDeposit関数opens in a new tabを呼び出すことです:
| パラメータ | 値 | 意味 |
|---|---|---|
| _l1Token | address(0) | L1上のETH(ERC-20トークンではない)を表す特別な値 |
| _l2Token | Lib_PredeployAddresses.OVM_ETH | OptimismでETHを管理するL2コントラクト、0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000(このコントラクトはOptimism内部でのみ使用されます) |
| _from | _from | L1でETHを送信するアドレス |
| _to | _to | L2でETHを受信するアドレス |
| 金額 | msg.value | 送信されたweiの量(すでにブリッジに送信済み) |
| _data | _data | デポジットに添付する追加データ |
1 // calldataをL2に送信2 // slither-disable-next-line reentrancy-events3 sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);クロスドメインメッセンジャーを介してメッセージを送信します。
1 // slither-disable-next-line reentrancy-events2 emit ETHDepositInitiated(_from, _to, msg.value, _data);3 }この転送をリッスンしている分散型アプリケーションに通知するためにイベントを発行します。
1 /**2 * @inheritdoc IL1ERC20Bridge3 */4 function depositERC20(5 .6 .7 .8 ) external virtual onlyEOA {9 _initiateERC20Deposit(_l1Token, _l2Token, msg.sender, msg.sender, _amount, _l2Gas, _data);10 }1112 /**13 * @inheritdoc IL1ERC20Bridge14 */15 function depositERC20To(16 .17 .18 .19 ) external virtual {20 _initiateERC20Deposit(_l1Token, _l2Token, msg.sender, _to, _amount, _l2Gas, _data);21 }すべて表示これら2つの関数は、実際のERC-20デポジットを処理する_initiateERC20Deposit関数のラッパーです。
1 /**2 * @dev L2デポジットトークンコントラクトにデポジットを通知し、ハンドラを呼び出してL1資金をロックするロジックを実行します。(例: transferFrom)3 *4 * @param _l1Token デポジットするL1 ERC20のアドレス5 * @param _l2Token L1の各L2 ERC20のアドレス6 * @param _from L1でデポジットを引き出すアカウント7 * @param _to L2でデポジットを与えるアカウント8 * @param _amount デポジットするERC20の金額。9 * @param _l2Gas L2でデポジットを完了するために必要なガスリミット。10 * @param _data L2に転送するオプションのデータ。このデータは、外部コントラクトの便宜のためにのみ提供されます。11 * 最大長を強制する以外、これらのコントラクトはその内容について何の保証も提供しません。12 */13 function _initiateERC20Deposit(14 address _l1Token,15 address _l2Token,16 address _from,17 address _to,18 uint256 _amount,19 uint32 _l2Gas,20 bytes calldata _data21 ) internal {すべて表示この関数は上記の_initiateETHDepositに似ていますが、いくつかの重要な違いがあります。
最初の違いは、この関数がトークンアドレスと転送量をパラメータとして受け取ることです。
ETHの場合、ブリッジへの呼び出しには、すでにブリッジアカウントへの資産の移転(msg.value)が含まれています。
1 // L1でデポジットが開始されると、L1ブリッジは将来の引き出しのために資金を自身に転送します。2 // safeTransferFromは、コントラクトにコードがあるかどうかもチェックするため、_fromがEOAまたはaddress(0)の場合、これは失敗します。3 // slither-disable-next-line reentrancy-events, reentrancy-benign4 IERC20(_l1Token).safeTransferFrom(_from, address(this), _amount);ERC-20トークンの転送は、ETHとは異なるプロセスに従います:
- ユーザー(
_from)は、適切なトークンを転送するための権限をブリッジに与えます。 - ユーザーは、トークンコントラクトのアドレス、金額などでブリッジを呼び出します。
- ブリッジは、デポジットプロセスの一環として、トークンを(自身に)転送します。
最初のステップは、最後の2つのステップとは別のトランザクションで行われる場合があります。
ただし、_initiateERC20Depositを呼び出す2つの関数(depositERC20とdepositERC20To)は、_fromパラメータとしてmsg.senderを使用してこの関数を呼び出すだけなので、フロントランニングは問題になりません。
1 // _l2Token.finalizeDeposit(_to, _amount)のcalldataを構築2 bytes memory message = abi.encodeWithSelector(3 IL2ERC20Bridge.finalizeDeposit.selector,4 _l1Token,5 _l2Token,6 _from,7 _to,8 _amount,9 _data10 );1112 // calldataをL2に送信13 // slither-disable-next-line reentrancy-events, reentrancy-benign14 sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);1516 // slither-disable-next-line reentrancy-benign17 deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] + _amount;すべて表示デポジットされたトークンの量をdepositsデータ構造に追加します。
L2には同じL1 ERC-20トークンに対応する複数のアドレスが存在する可能性があるため、ブリッジのL1 ERC-20トークン残高を使用してデポジットを追跡するだけでは不十分です。
12 // slither-disable-next-line reentrancy-events3 emit ERC20DepositInitiated(_l1Token, _l2Token, _from, _to, _amount, _data);4 }56 /*************************7 * クロスチェーン関数 *8 *************************/910 /**11 * @inheritdoc IL1StandardBridge12 */13 function finalizeETHWithdrawal(14 address _from,15 address _to,16 uint256 _amount,17 bytes calldata _dataすべて表示L2ブリッジはL2クロスドメインメッセンジャーにメッセージを送信し、これによりL1クロスドメインメッセンジャーがこの関数を呼び出します(もちろん、メッセージを完了するトランザクションopens in a new tabがL1で送信された後)。
1 ) external onlyFromCrossDomainAccount(l2TokenBridge) {これが、クロスドメインメッセンジャーから来て、L2トークンブリッジから発信された正当なメッセージであることを確認してください。 この関数はブリッジからETHを引き出すために使用されるため、承認された呼び出し元によってのみ呼び出されることを確認する必要があります。
1 // slither-disable-next-line reentrancy-events2 (bool success, ) = _to.call{ value: _amount }(new bytes(0));ETHを転送する方法は、msg.valueにweiの量を指定して受信者を呼び出すことです。
1 require(success, "TransferHelper::safeTransferETH: ETH transfer failed");23 // slither-disable-next-line reentrancy-events4 emit ETHWithdrawalFinalized(_from, _to, _amount, _data);引き出しに関するイベントを発行します。
1 }23 /**4 * @inheritdoc IL1ERC20Bridge5 */6 function finalizeERC20Withdrawal(7 address _l1Token,8 address _l2Token,9 address _from,10 address _to,11 uint256 _amount,12 bytes calldata _data13 ) external onlyFromCrossDomainAccount(l2TokenBridge) {すべて表示この関数は上記のfinalizeETHWithdrawalに似ていますが、ERC-20トークンに必要な変更が加えられています。
1 deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] - _amount;depositsデータ構造を更新します。
1 // L1で引き出しが完了すると、L1ブリッジは資金を引き出し人に転送します。2 // slither-disable-next-line reentrancy-events3 IERC20(_l1Token).safeTransfer(_to, _amount);45 // slither-disable-next-line reentrancy-events6 emit ERC20WithdrawalFinalized(_l1Token, _l2Token, _from, _to, _amount, _data);7 }8910 /*****************************11 * 一時的 - ETHの移行 *12 *****************************/1314 /**15 * @dev アカウントにETH残高を追加します。これは、古いゲートウェイから新しいゲートウェイにETHを移行できるようにすることを目的としています。16 * 注意: これは、古いコントラクトから移行されたETHを受け取ることができるように、1回のアップグレードのみに残されます。17 */18 function donateETH() external payable {}19}すべて表示ブリッジの以前の実装がありました。
その実装からこの実装に移行したとき、すべての資産を移動する必要がありました。
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を提供する必要があります。これには、標準ブリッジが必要とする関数とイベントが含まれています。
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;34import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";標準のERC-20インターフェースopens in a new tabには、mintおよびburn関数は含まれていません。
これらのメソッドは、ERC-20標準opens in a new tabでは要求されておらず、トークンを作成および破棄するメカニズムは指定されていません。
1import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol";ERC-165インターフェースopens in a new tabは、コントラクトが提供する関数を指定するために使用されます。 こちらで標準を読むことができますopens in a new tab。
1interface IL2StandardERC20 is IERC20, IERC165 {2 function l1Token() external returns (address);この関数は、このコントラクトにブリッジされたL1トークンのアドレスを提供します。 逆方向の同様の関数がないことに注意してください。 L2サポートが実装時に計画されていたかどうかに関係なく、任意のL1トークンをブリッジできる必要があります。
12 function mint(address _to, uint256 _amount) external;34 function burn(address _from, uint256 _amount) external;56 event Mint(address indexed _account, uint256 _amount);7 event Burn(address indexed _account, uint256 _amount);8}トークンをミント(作成)およびバーン(破棄)するための関数とイベント。 トークンの数が正しいこと(L1にロックされているトークンの数と等しいこと)を保証するため、ブリッジはこれらの関数を実行できる唯一のエンティティである必要があります。
L2StandardERC20
これはIL2StandardERC20インターフェースの実装ですopens in a new tab。
何らかのカスタムロジックが必要でない限り、これを使用する必要があります。
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;34import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";OpenZeppelin ERC-20コントラクトopens in a new tab。 Optimismは、特に車輪が十分に監査され、資産を保持するのに十分信頼できる必要がある場合に、車輪を再発明することを信じていません。
1import "./IL2StandardERC20.sol";23contract L2StandardERC20 is IL2StandardERC20, ERC20 {4 address public l1Token;5 address public l2Bridge;これらは、私たちが要求し、通常ERC-20が必要としない2つの追加の設定パラメータです。
12 /**3 * @param _l2Bridge L2標準ブリッジのアドレス。4 * @param _l1Token 対応するL1トークンのアドレス。5 * @param _name ERC20名。6 * @param _symbol ERC20シンボル。7 */8 constructor(9 address _l2Bridge,10 address _l1Token,11 string memory _name,12 string memory _symbol13 ) ERC20(_name, _symbol) {14 l1Token = _l1Token;15 l2Bridge = _l2Bridge;16 }すべて表示まず、継承元のコントラクトのコンストラクタ(ERC20(_name, _symbol))を呼び出し、次に独自の変数を設定します。
12 modifier onlyL2Bridge() {3 require(msg.sender == l2Bridge, "Only L2 Bridge can mint and burn");4 _;5 }678 // slither-disable-next-line external-function9 function supportsInterface(bytes4 _interfaceId) public pure returns (bool) {10 bytes4 firstSupportedInterface = bytes4(keccak256("supportsInterface(bytes4)")); // ERC16511 bytes4 secondSupportedInterface = IL2StandardERC20.l1Token.selector ^12 IL2StandardERC20.mint.selector ^13 IL2StandardERC20.burn.selector;14 return _interfaceId == firstSupportedInterface || _interfaceId == secondSupportedInterface;15 }すべて表示これがERC-165opens in a new tabの仕組みです。 すべてのインターフェースは、サポートされている関数の数であり、それらの関数のABI関数セレクタopens in a new tabの排他的論理和opens in a new tabとして識別されます。
L2ブリッジは、ERC-165をサニティチェックとして使用して、資産を送信するERC-20コントラクトがIL2StandardERC20であることを確認します。
注: 不正なコントラクトがsupportsInterfaceに偽の回答を提供することを防ぐものはないため、これはサニティチェックメカニズムであり、セキュリティメカニズムではありません。
1 // slither-disable-next-line external-function2 function mint(address _to, uint256 _amount) public virtual onlyL2Bridge {3 _mint(_to, _amount);45 emit Mint(_to, _amount);6 }78 // slither-disable-next-line external-function9 function burn(address _from, uint256 _amount) public virtual onlyL2Bridge {10 _burn(_from, _amount);1112 emit Burn(_from, _amount);13 }14}すべて表示資産をミントおよびバーンできるのは、L2ブリッジのみです。
_mintと_burnは、実際にはOpenZeppelin ERC-20コントラクトで定義されています。
そのコントラクトは、トークンをミントおよびバーンする条件がERC-20の使用方法と同じくらい多様であるため、それらを外部に公開しないだけです。
L2ブリッジコード
これは、Optimismでブリッジを実行するコードです。 このコントラクトのソースはこちらですopens in a new tab。
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;34/* Interface Imports */5import { IL1StandardBridge } from "../../L1/messaging/IL1StandardBridge.sol";6import { IL1ERC20Bridge } from "../../L1/messaging/IL1ERC20Bridge.sol";7import { IL2ERC20Bridge } from "./IL2ERC20Bridge.sol";IL2ERC20Bridgeopens in a new tabインターフェースは、上で見たL1の同等のものと非常に似ています。 2つの大きな違いがあります:
- L1では、デポジットを開始し、引き出しを完了します。 ここでは、引き出しを開始し、デポジットを完了します。
- L1では、ETHとERC-20トークンを区別する必要があります。 L2では、Optimismの内部ETH残高はアドレス0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000opens in a new tabのERC-20トークンとして処理されるため、両方に同じ関数を使用できます。
1/* ライブラリのインポート */2import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol";3import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";4import { Lib_PredeployAddresses } from "../../libraries/constants/Lib_PredeployAddresses.sol";56/* コントラクトのインポート */7import { IL2StandardERC20 } from "../../standards/IL2StandardERC20.sol";89/**10 * @title L2StandardBridge11 * @dev L2標準ブリッジは、L1標準ブリッジと連携して、L1とL2間のETHおよびERC20の移行を可能にするコントラクトです。12 * このコントラクトは、L1標準ブリッジへのデポジットを聞くと、新しいトークンのミンターとして機能します。13 * このコントラクトは、引き出しを意図したトークンのバーナーとしても機能し、L1ブリッジにL1資金を解放するように通知します。14 */15contract L2StandardBridge is IL2ERC20Bridge, CrossDomainEnabled {16 /********************************17 * 外部コントラクト参照 *18 ********************************/1920 address public l1TokenBridge;すべて表示L1ブリッジのアドレスを追跡します。 L1の同等のものとは対照的に、ここではこの変数が必要であることに注意してください。 L1ブリッジのアドレスは事前にわかりません。
12 /***************3 * コンストラクタ *4 ***************/56 /**7 * @param _l2CrossDomainMessenger このコントラクトで使用されるクロスドメインメッセンジャー。8 * @param _l1TokenBridge メインチェーンにデプロイされたL1ブリッジのアドレス。9 */10 constructor(address _l2CrossDomainMessenger, address _l1TokenBridge)11 CrossDomainEnabled(_l2CrossDomainMessenger)12 {13 l1TokenBridge = _l1TokenBridge;14 }1516 /***************17 * 引き出し *18 ***************/1920 /**21 * @inheritdoc IL2ERC20Bridge22 */23 function withdraw(24 address _l2Token,25 uint256 _amount,26 uint32 _l1Gas,27 bytes calldata _data28 ) external virtual {29 _initiateWithdrawal(_l2Token, msg.sender, msg.sender, _amount, _l1Gas, _data);30 }3132 /**33 * @inheritdoc IL2ERC20Bridge34 */35 function withdrawTo(36 address _l2Token,37 address _to,38 uint256 _amount,39 uint32 _l1Gas,40 bytes calldata _data41 ) external virtual {42 _initiateWithdrawal(_l2Token, msg.sender, _to, _amount, _l1Gas, _data);43 }すべて表示これら2つの関数は、引き出しを開始します。 L1トークンアドレスを指定する必要はないことに注意してください。 L2トークンは、L1の同等のアドレスを教えてくれることが期待されています。
12 /**3 * @dev トークンをバーンし、L1トークンゲートウェイに引き出しを通知することで、引き出しのロジックを実行します。4 * @param _l2Token 引き出しが開始されたL2トークンのアドレス。5 * @param _from L2で引き出しを引き出すアカウント。6 * @param _to L1で引き出しを与えるアカウント。7 * @param _amount 引き出すトークンの量。8 * @param _l1Gas 未使用ですが、将来の互換性の考慮事項のために含まれています。9 * @param _data L1に転送するオプションのデータ。このデータは、外部コントラクトの便宜のためにのみ提供されます。10 * 最大長を強制する以外、これらのコントラクトはその内容について何の保証も提供しません。11 */12 function _initiateWithdrawal(13 address _l2Token,14 address _from,15 address _to,16 uint256 _amount,17 uint32 _l1Gas,18 bytes calldata _data19 ) internal {20 // 引き出しが開始されると、その後のL2での使用を防ぐために、引き出し人の資金をバーンします。21 // slither-disable-next-line reentrancy-events22 IL2StandardERC20(_l2Token).burn(msg.sender, _amount);すべて表示_fromパラメータに依存するのではなく、偽造するのがはるかに難しい(私の知る限り不可能) msg.senderに依存していることに注意してください。
12 // l1TokenBridge.finalizeERC20Withdrawal(_to, _amount)のcalldataを構築3 // slither-disable-next-line reentrancy-events4 address l1Token = IL2StandardERC20(_l2Token).l1Token();5 bytes memory message;67 if (_l2Token == Lib_PredeployAddresses.OVM_ETH) {L1では、ETHとERC-20を区別する必要があります。
1 message = abi.encodeWithSelector(2 IL1StandardBridge.finalizeETHWithdrawal.selector,3 _from,4 _to,5 _amount,6 _data7 );8 } else {9 message = abi.encodeWithSelector(10 IL1ERC20Bridge.finalizeERC20Withdrawal.selector,11 l1Token,12 _l2Token,13 _from,14 _to,15 _amount,16 _data17 );18 }1920 // メッセージをL1ブリッジに送信21 // slither-disable-next-line reentrancy-events22 sendCrossDomainMessage(l1TokenBridge, _l1Gas, message);2324 // slither-disable-next-line reentrancy-events25 emit WithdrawalInitiated(l1Token, _l2Token, msg.sender, _to, _amount, _data);26 }2728 /************************************29 * クロスチェーン関数: デポジット *30 ************************************/3132 /**33 * @inheritdoc IL2ERC20Bridge34 */35 function finalizeDeposit(36 address _l1Token,37 address _l2Token,38 address _from,39 address _to,40 uint256 _amount,41 bytes calldata _dataすべて表示この関数はL1StandardBridgeによって呼び出されます。
1 ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {メッセージのソースが正当であることを確認してください。
この関数は_mintを呼び出し、ブリッジがL1で所有するトークンでカバーされていないトークンを与えるために使用できるため、これは重要です。
1 // ターゲットトークンが準拠していることを確認し、2 // L1でデポジットされたトークンがここのL2デポジットトークン表現と一致することを検証します。3 if (4 // slither-disable-next-line reentrancy-events5 ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&6 _l1Token == IL2StandardERC20(_l2Token).l1Token()サニティチェック:
- 正しいインターフェースがサポートされていること
- L2 ERC-20コントラクトのL1アドレスが、トークンのL1ソースと一致すること
1 ) {2 // デポジットが完了すると、L2のアカウントに同額のトークンを入金します。3 // slither-disable-next-line reentrancy-events4 IL2StandardERC20(_l2Token).mint(_to, _amount);5 // slither-disable-next-line reentrancy-events6 emit DepositFinalized(_l1Token, _l2Token, _from, _to, _amount, _data);サニティチェックに合格した場合、デポジットを完了します:
- トークンをミントします。
- 適切なイベントを発行します。
1 } else {2 // デポジット先のL2トークンが、そのL1トークンの正しいアドレスについて同意しないか、正しいインターフェースをサポートしていないかのいずれかです。3 // これは、悪意のあるL2トークンがある場合、またはユーザーが何らかの方法でデポジット先の間違ったL2トークンアドレスを指定した場合にのみ発生するはずです。4 // いずれの場合も、ここでプロセスを停止し、引き出しメッセージを構築して、ユーザーが場合によっては資金を取り出せるようにします。5 // 悪意のあるトークンコントラクトを完全に防ぐ方法はありませんが、これによりユーザーエラーが制限され、悪意のあるコントラクトの動作のいくつかの形態が軽減されます。ユーザーが間違ったL2トークンアドレスを使用して検出可能なエラーを犯した場合、デポジットをキャンセルしてL1でトークンを返したいです。 これをL2から行う唯一の方法は、フォールトチャレンジ期間を待つ必要があるメッセージを送信することですが、それはユーザーがトークンを永久に失うよりもはるかに良いです。
1 bytes memory message = abi.encodeWithSelector(2 IL1ERC20Bridge.finalizeERC20Withdrawal.selector,3 _l1Token,4 _l2Token,5 _to, // ここで_toと_fromを切り替えて、デポジットを送信者に跳ね返す6 _from,7 _amount,8 _data9 );1011 // メッセージをL1ブリッジに送信12 // slither-disable-next-line reentrancy-events13 sendCrossDomainMessage(l1TokenBridge, 0, message);14 // slither-disable-next-line reentrancy-events15 emit DepositFailed(_l1Token, _l2Token, _from, _to, _amount, _data);16 }17 }18}すべて表示結論
標準ブリッジは、資産転送のための最も柔軟なメカニズムです。 しかし、非常に汎用的であるため、必ずしも最も使いやすいメカニズムではありません。 特に、出金に関しては、ほとんどのユーザーが、チャレンジ期間を待つ必要がなく、出金をファイナライズするためにマークル証明を必要としないサードパーティ製ブリッジopens in a new tabを使用することを好みます。
これらのブリッジは通常、L1に資産を持ち、それを少額の手数料(多くの場合、標準ブリッジの引き出しのガス代よりも安い)ですぐに提供することで機能します。 ブリッジ(またはそれを運営する人々)がL1の資産が不足すると予想する場合、L2から十分な資産を転送します。 これらは非常に大きな引き出しであるため、引き出しコストは多額にわたって償却され、はるかに小さい割合になります。
この記事が、レイヤー2の仕組みと、明確で安全なSolidityコードの書き方について、より理解を深めるのに役立ったことを願っています。
私の他の作品はこちらでご覧いただけますopens in a new tab.
最終更新: 2025年10月22日