Optimistic rollups で Ethereum のガス代が節約出来るかを検討する

前回は → Ethereum のスマートコントラクトで対戦ゲームを作るとどうなるか

1. Ethereum のガス代は何故高いのか

前回の記事で、dApps対戦ゲームを作成し、Ethereum(の Test Network)にデプロイして分かった事は、 その法外(と言っても差支えないでしょう)な手数料の存在です。 「1回10円もいかないだろう~」とか思ってましたが甘かったです。 まさか数千円とは・・・。

一瞬、担がれてるのかとも思いましたが、現状は本当にそんな感じみたいです。 それだけの手数料を支払って取引がなされているようです。

なぜこんなにも手数料が高いのでしょうか?

前回も説明しましたが、Ethereum の Gas Price は市場原理に基づいて相場が形成されています。 ネットワークのトランザクション処理能力に限界がある中で、より高い Gas Price を提示したトランザクションが優先処理されるため、 素早く取引を終えたいユーザーは、平均よりも高い価格を提示してトランザクションを作成します。 全く割に合わないくらいの Gas Price になれば取引自体をやめるユーザーも出て来るでしょうが、 Gas Price は一向に下がらないので、それだけの手数料を問題にしない取引が相当数存在する証拠でもあります。

この問題の中心となっている事情は、ネットワークの処理能力に供給限界がある中で需要が爆増している事です。 処理能力が乏しいのは、現在採用されている合意形成アルゴリズムの仕組みに原因があります。

現在採用されているのは PoW(Proof of Work)という計算量が多いアルゴリズムです。 このアルゴリズムでは、ネットワーク全体で処理できるトランザクション数が、1秒当たり数十件程度にしかなりません。 そしてまずいことに、ブロックチェーンの分散ネットワークは、改竄を防止するセキュリティ能力が高くとも、 計算性能を拡大するスケーリングに関しては全く寄与しません。ノードがいくら増えようが性能はほとんど変わりません。

さて、この状況はマイナーにとってうれしい悲鳴です。 ガス代はそのままマイナーへの報酬に充てられるため、Gas Price が高ければ高い程、ノードを動かし続けるメリットが高まります。 マイナーにとっては、トランザクションの処理にかかる時間が1分だろうが10分だろが、直接的には関係ないので 需要が高まっているなら供給を増やさずに価格を釣り上げたほうが得策です。 そもそもネットワーク的な処理限界に達しているので、マイナー自身では供給を増やしようもないのですが。

まとめると、

  1. 手数料は事実上の時価であり、市場原理に基づいて増減する
  2. ネットワークの処理能力は限界があり、供給は簡単に増やせない仕組みである
  3. 暗号資産ブームで需要が爆増しており、多少の手数料は問題とならないくらいの価値が流通している

この3つの要因により、一般庶民には不当と思えるような価値観までガス代が高騰しているのです。

1.1. ガス代が決まる仕組み

より高い Gas Price を提示したトランザクションが優先して処理されると説明しました。 では、Gas Price はどのように決まるのでしょうか。 ほとんどの人は「ガソリンスタンド」を想像し、その時々の値段が掲示されるのだと考えるでしょう。カード会員は〇〇円/Lみたいな感じで。

実際は、そのような値段は掲示されません。値段はユーザーが好きに決められます。 1 Unit あたり 1 Gwei でトランザクションを作ることもできますし、1 Unit あたり 1,000 Gwei(1 szabo)で作ることもできます。

問題は、冒頭で述べた「優先処理」です。 値段を決めるためには、現在どんな Gas Price のトランザクションが、ネットワーク上にいくつ存在し、 価格帯によってどれぐらいの待ち時間でトランザクションが完了するのか知る必要があります。

その為、ネットワークでは現在の Gas Price を推定する「Gas Price Oracle」という機能を提供しています。 ブロックチェーンを過去に向かって走査し、直近の取引から現在有効と思われる Gas Price を推定します。 ユーザーはそれをもとに Gas Price を設定してトランザクションを作成するわけです。

ブロックチェーンの情報は誰でも閲覧できるので、Web上では Gas Price を教えてくれるサービスがいくつもあり、 代表的なのは Etherscan の Ethereum Gas Tracker です。

Low Speed、Average、High Speed の3段階に分けて Gas Price を提案してくれます。取引の要件に応じて Gas Price を選びましょう。

ちなみに、飛び抜けて高い Gas Price を設定したからと言って プレミアムなサービスが受けられるわけではありません

1.2. ガス代をケチるとどうなるか

「別に時間が掛かってもいいんですよ。そんなに急いでないし」という状況なら、ガス代を 1 Gwei に設定すればいいのでは、と思う方もいるでしょう。

それについては Etherscan で面白いものが見られます。 Pending Transaction を見てみましょう。 此処には面構えの違うトランザクション達が並びます。極小の Gas Price でトランザクションを通すべく、4日も5日も待機し続けるトランザクション達が。

「Last Seen」列の (!) にマウスオーバーする事でペンディングされている日数がわかります。

このように安すぎる Gas Price のトランザクションは後回しにされ続け、いつまで経っても処理されないという状況に陥ります。 これらのトランザクションが何時処理されるのかは、一切の保証がありません。

いつか処理されるかもしれないし、されないかもしれない。 ここ最近の混み具合だと、されない可能性が極めて高いかも?

ところで、あなたのアカウントにペンディングされた状態のトランザクションがある場合、そのアカウントでは、それ以降の如何なるトランザクションもペンディングされます。 トランザクションには nonce という通し番号が振られ、必ず nonce の順番に処理されますから、より前の nonce のトランザクションが承認されるまで、 Ethereum ネットワークで取引することが出来なくなります。

ただし、この状況を抜け出す術が無い訳ではなく、同じ nonce でトランザクションを上書きすることが出来ます。 従って、同じ取引内容を同じ nonce で、Gas Price だけ増額したトランザクションを発行するか、 0 ETHを送金するトランザクションに差し替えてキャンセルすると言った方法で状況を打開してください。

まとめると、Ethereum のネットワークでうまくやっていくには、Gas Price は適切な値に設定しましょう、という事です。

1.3. ガス代はもう安くならないの?

ブロックチェーンに状態をコミットする際のガス代は避けられません。 これは変えられない大自然の摂理です。

しかし、将来的に Ethereum 2.0 で合意形成アルゴリズムが変更され、シャーディングも導入されるため、トランザクション処理能力が飛躍的に向上するとされています。

直近の改善としては、7月頃に予定されているアップデート(「ロンドン」と呼ばれている)で実装される、ネットワークの込み具合で上下する標準の手数料が挙げられます。 また、ガス代として支払われた Ether の一部が焼却されることによって、マイナーに支払われる報酬が抑えられる事になります。

最終的にどれぐらいまで下がるのかは分かりませんが、現実的な範囲まで落とし込むことを目指しているでしょうから、筆者はある程度の期待をしています。

2. Ethereum のガス代を節約する方法

とは言え、dApps を開発するうえで、直近のガス代問題を解決する方法を模索しなければなりません。 多くの場合、短期的な「とりあえずの」解決策を導入しています。

そのすべてが、Ethereum の外にトランザクションをオフロードする「レイヤー2」と呼ばれる手法です。

2.1. 代替の方法で追及されるのは「セキュリティの確保」

技術的に言えば、レイヤー2で処理して結果を「レイヤー1(Ethereum ブロックチェーン)」に反映すること自体は、 幾らでも・どの様にでも実装可能で、技術的な課題はありません。

問題はセキュリティであり、ブロックチェーンが「トラストレス(相手を信用する必要がない事)」を前提としている点です。 ユーザーは不正をするかもしれないという性悪説に立った仕組みにしなければなりません。

従って、全ての技術的課題は「如何に不正を防いで取引履歴の正当性を保つか(あるいは妥協するか)」に有ります。

2.2. チャンネル

参加者 A と B の間に専用の取引チャンネルを開きます。 チャンネルの実装はオフチェーン(Ethereum 外)で行われ、チャンネルが開かれている間はその中だけで自由に取引を展開できます。 チャンネルを開く際に Ether やトークン等の資産をコントラクトにデポジット&ロックし、閉じる時(Ethereum に結果を反映する時)に清算するため、 最低でも 2 トランザクション分のガス代がかかります。 片方が不正行為を働いた場合は、もう片方が不正を証明する事でコントラクトにロックされた資産を取り戻すことが出来ます。 つまり、これがセキュリティとなり、不正をすることには、不正した側がデポジットした資産を失うというリスクを負う恰好になります。

言うまでもない事ですが、結果を反映するには参加者両名の同意が必要です。 他の暗号資産(例:Bitcoin)の場合は、マルチシグという、アクセスに複数の署名を必要とするアカウントを用いて全参加者の同意を得るのですが、 Ethereum の場合はマルチシグの機能がない為、スマートコントラクトで代用します。

公式の解説

2.3. サイドチェーン

サイドチェーンは、メインチェーンとなる Ethereum ブロックチェーンと互換性を持つブロックチェーンを別に作る手法です。

メインチェーン上でサイドチェーンに転送したい資産をロックし、サイドチェーン上で任意のトランザクションを安価な手数料を支払って展開します。 最終的に資産をメインチェーンへ戻す場合は、サイドチェーンで資産を焼却し、焼却した証明をメインチェーンに提出することで、ロックされた資産が解放されます。

ブリッジとなるメインチェーン上のコントラクトがすべての資産の出入りを監視しますから、辻褄が合う仕組みです。

トークン等の資産は必ずメインチェーンとサイドチェーン間で1対1のマッピングがされていなければなりません。 つまり、サイドチェーンだけで資産が生み出されることはありません。

メインチェーンから見たセキュリティは資産の量的辻褄の保証だけで、取引の内容(つまりサイドチェーン内のトランザクション)には関知しません。 利用者からみたセキュリティは、サイドチェーンが採用する合意形成アルゴリズムとネットワーク運用に左右されます。 サイドチェーンの運営は、ほとんどが中央集権的となります。

公式の解説

2.4. Plasma

Ethereum のブロックチェーンをルートチェーンとし、その子となるサブチェーン(Plasmaチェーン)を作成し、 本来ルートチェーンで処理されるべきトランザクションをPlasmaチェーンにオフロードします。

ここまではサイドチェーンと同じですが、 ブロックヘッダーによるマークル木を構成することにより、Plasmaチェーンの下に更にPlasmaチェーンを作ることが出来ます。 Plasmaチェーンからはルートチェーンへは(負荷をかけない様に控えめに)ブロックヘッダーを通知するので、セキュリティがルートチェーンによって保護されます。

Plasmaチェーンの実体はカスタムされたブロックチェーンプログラムであり、任意の機能を実装できます。

サイトチェーンとの違いは、常にルートチェーンに報告することでセキュリティを確保している点と、 ルートチェーン(Ethereum)で動かしているスマートコントラクトがそっくりそのまま動くわけではないという点です。

ルートチェーンに資産を戻す場合は、異議申し立て期間として、数日(7日から14日)を要します。 不正があった場合は、不正の証拠をルートチェーンに提出することで、不正ブロックの直前に巻き戻します。

上記は非常にざっくりとした Plasma フレームワークの説明ですが、その実装は多様に存在します。 Plasma という固有の実装はありません。

掛かるガス代は実装によってまちまちですが、最低でもPlasmaチェーンへの資産移行とルートチェーンへの引出し申請にそれぞれ1回ずつ、 異議申立期間後の引出しを含めて、合計3回分のガス代が発生します。 また、Plasmaチェーンにおいても(非常に安価だとは思いますが)幾許かの利用料が掛かる事でしょう。

セキュリティは、ルートチェーンに担保されますが、Plasmaチェーンの運営には中央集権的組織が関わることとなります。

公式の解説

2.5. Rollups

Rollups は簡単に言えば、1 回に多くのトランザクションを詰め込んで送信し、メインチェーンの Rollups コントラクト内で分解して検証させる事です。 トランザクションの実行はレイヤー2となるオフチェーンまたはサイドチェーンで行い、状態を Rollups コントラクトに通知します。 ユーザーが資産をレイヤー2に移動させる必要があるのは、これまでの技法と同じです。 つまりレイヤー2への移行と引出しの最低 2 回は正規のガス代が掛かります。

Rollups を実現をするために 2 種類のユーザーが必要となります。1 つはトランザクタで、もう 1 つがリレイヤー(アグリゲーターとも)です。 トランザクタは、その名の通りトランザクションを作成してネットワークに送信します。 リレイヤーは多くのトランザクションを 1 つに纏め上げる「ロールアップ」を実行します。 トランザクタはリレイヤーに手数料を支払い、リレイヤーが Ethereum のガス代を払います。 複数のトランザクションを一手に引き受けるリレイヤーが払うガス代は安くないのですが、 仮に 1 回のロールアップで数百件のトランザクションを纏められれば、 トランザクション 1 つにかかるガス代は単体で支払うよりも安くなります。 また、リレイヤーには誰でもなれますが、不正を防止する観点から、リレイヤーとなるには一定の資産を Rollups コントラクトに預ける必要があります。

現在、Rollups にはセキュリティの実装に違いがある二通りの種類があります。 ゼロ知識証明(Zero knowledge、ZK とも)Rollupsと、楽観的(Optimistic)Rollupsです。 前者はトランザクション毎に Rollups コントラクトで検証を行う方式で、 後者はトランザクションを楽観的に取扱い、毎度の検証は行わず、結果に異議が出た場合のみ検証を行う方式です。

Optimistic rollups は CALLDATA と呼ばれるコントラクト呼び出しデータを、 そのままメインチェーンに保存しているだけなので実装にフレキシビリティがあり、 OVM という EVM 互換の仮想マシンが開発されたため、レイヤー2で任意のコントラクトを実行出来るようになりました。

ただし、検証は行われていないので、レイヤー1に資産を引き出す際、Plasmaと同様に異議申し立ての期間(1週間~)を設ける必要があります。 異議申し立てには、メインチェーンに保存された CALLDATA を再度実行して同じ結果になるか(あるいは異なるか)を証明する方法が用いられます。

ZK rollups は、今のところ簡単な転送や、前もって決められた処理に限られますが、 トランザクションは常に検証済みなので、資産を素早くレイヤー1に戻す事が出来ます。 また、今後技術開発が進めば、ZK rollups でも任意のコントラクトを実行できる可能性があります。

公式の解説

3. どれが一番ベストか?

単に 1 回だけの取引を考える場合、どの様な手段を取ったとしても同じだけガス代が掛かります。 スケールメリットが生まれるのは、何度も取引する事を想定した場合です。 一旦資産をデポジットしてしまえば、後はレイヤー2で処理する事により、安価な手数料で繰り返し取引することが出来ます。

翻って眺めてみれば、セキュリティの違いだけで、どれも方法としては一長一短といった印象です。

これらの中で、最も Ethereum ネットワークの堅牢性の恩恵を受けられるのは ZK rollups だと思います。

対して最もスケールするのはサイドチェーン、またはPlasmaです。 レイヤー2を如何様にも実装できるので、大手決済会社並みのスループットを実現することも可能です。 気前が良ければ手数料を只にすることもできます。

しかし、サイドチェーンは中央集権的で、基本的にEthereumのセキュリティから恩恵を受けられません。

チャンネルはそもそもオープン参加が出来ないので論外として、 Plasma は中央集権的で、途中のトランザクションデータがメインチェーンに公開されない(可用性が低い)という問題があり、 ルートチェーンに定期報告すると言った様な実装の手間も多そうです。 Optimistic rollups は Plasma のデメリットを解消した方法と言えます。

ZK rollups は発展途上と言った状況を踏まえると、今のところの最適解としては「Optimistic rollups」かなと思いました。

Optimistic rollups は実装がほぼ終わっていますが、メインネットでの稼働は一部のアプリケーションに限定されています。 テストネットは解放されているので、今回は「Optimistic rollups」の導入を進めてみたいと思います。

DeJunkeng みたいなセキュリティなんてどうでもよさそうな dApps は「サイドチェーン」でええやん、と思われるかもしれませんが、 DeJunkeng はあくまでもサンプルなので、「これはセキュリティが重要なアプリである」という前提で考えます。

と言うか、サイドチェーンを使うなら普通の Web アプリとして実装して Firebase 辺りにデプロイする・・・

4. DeJunkeng に導入してみる

それでは実際に Optimistic rollups の実装である Optimistic Etehreum を使用して、 DeJunkeng をレイヤー2にオフロードしてみたいと思います。

DeJunkeng は GitHub リポジトリの master ブランチ から修正を加える想定で説明します。

アーキテクチャとしては以下の様になります。

4.1. スマートコントラクトを修正

Optimistic rollups では、OVMというEVM(Ethereum Virtual Machine)互換のバーチャルマシンでスマートコントラクトを実行できます。 これらは厳密に言えば違う物なので、EVM用のSolidityコンパイラでコンパイルしたバイトコードは実行できません。 したがって、レイヤー2にデプロイするスマートコントラクトはOVM用の専用コンパイラでコンパイルします。

4.1.1. JunkCoinDepositedERC20.sol

まず、レイヤー2 で JunkCoin トークンを管理するコントラクト JunkCoinDepositedERC20 を作成します。

このコントラクトは、レイヤー1 から移行された全ての JunkCoin を受け取って管理します。 つまり、全てのコインをレイヤー2でMint(造幣)するのではなく、レイヤー1で発行してレイヤー2にデポジットするという手法を取ります。

準備として以下のモジュールとプラグインを導入します。

$ yarn add -D @eth-optimism/contracts @eth-optimism/plugins

hardhat.config.ts の冒頭(import 文が並んでるところ)に以下の一行を追加してプラグインを有効にしてください。

import "@eth-optimism/plugins/hardhat/compiler";

@eth-optimism/contracts には、Optimistic rollups に必要なコントラクトがすべて同封されています。

このモジュールから Abs_L2DepositedToken を拝借して以下の様なコントラクトを作成しました。

// SPDX-License-Identifier: MIT
pragma solidity >=0.6.0 <0.8.0;

import { JunkCoinERC20 } from "./JunkCoinERC20.sol";
import { Abs_L2DepositedToken } from "@eth-optimism/contracts/build/contracts/OVM/bridge/tokens/Abs_L2DepositedToken.sol";

/**
 * Runtime target: OVM
 */
contract JunkCoinDepositedERC20 is Abs_L2DepositedToken, JunkCoinERC20 {

    address admin;
    address dispenser;

    constructor(
        address _l2CrossDomainMessenger,
        string memory _name,
        string memory _symbol
    )
        Abs_L2DepositedToken(_l2CrossDomainMessenger)
        JunkCoinERC20(_name, _symbol, 0)
    {
        admin = msg.sender;
    }

    /**
     * Associate dispenser account/contract
     */
    function setDispenser(address _dispenser) external {
        require(msg.sender == admin, "Permission denied.");
        dispenser = _dispenser;
    }

    function _handleInitiateWithdrawal(
        address _to,
        uint _amount
    )
        internal
        override
    {
        require(msg.sender == _to || (dispenser != address(0) && msg.sender == dispenser), "Permission denied.");
        _burn(_to, _amount);
    }

    function _handleFinalizeDeposit(
        address _to,
        uint _amount
    )
        internal
        override
    {
        _mint(_to, _amount);
    }
}

トークンとしての機能は全て、元となる JunkCoinERC20 から継承しています。 ただし Mint は行いたくないので、コンストラクタで initialSupply を指定できるようにして 0 を設定しています。

dispenser は JunkCoin をコントラクトから直接払い出せるアカウントまたはコントラクトを指定する為に設けました。 従って、レイヤー1 に JunkCoin を転送できるのは JunkCoin の持ち主か、あるいは dispenser で指定するアカウントないしコントラクトのみとなります。

_l2CrossDomainMessenger は、レイヤー1 と通信を行うための Optimistic rollups 組み込みコントラクトです。

4.1.2. JunkCoinERC20.sol

JunkCoinERC20 は以下の様に修正しました。 コンストラクタで設定項目を注入できるようにしただけですね。 尚、こちらのコントラクトはレイヤー1にデプロイされます。

// SPDX-License-Identifier: MIT
pragma solidity >=0.6.0 <0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

/**
 * Runtime target: EVM
 */
contract JunkCoinERC20 is ERC20 {

    constructor(string memory _name, string memory _symbol, uint _initialSupplies) ERC20(_name, _symbol) {
        _mint(msg.sender, _initialSupplies);
    }

    function decimals() public view override returns (uint8) {
        return 0;
    }
}

4.1.3. Junkeng.sol

驚くべきことに Junkeng コントラクトには ほとんど変更の必要がありません。

UX を考慮し、ゲットした JunkCoin を引き出す関数「withdraw」に改造を加え、 レイヤー2 のウォレットではなく、レイヤー1 のウォレットに直接引き出せるように書き換えます。

import { iOVM_L2DepositedToken } from "@eth-optimism/contracts/build/contracts/iOVM/bridge/tokens/iOVM_L2DepositedToken.sol";

contract Junkeng {
    // ... 中略 ...

    /**
     * Withdraw JunkCoin
     */
    function withdraw() public timeout haveCoins phaseAdvance {
        uint amount = coinStock[msg.sender];
        coinStock[msg.sender] = 0;
        IERC20(coin).transferFrom(admin, msg.sender, amount);

        iOVM_L2DepositedToken(coin).withdrawTo(msg.sender, amount);

        emit Withdrew(msg.sender, amount);
    }

4.1.4. コンパイル

レイヤー 1 のコントラクトはそのまま従来通り npx hardhat compile でコンパイルすれば OK ですが、 レイヤー 2 は専用の OVM コンパイラでバイトコードに変換する必要があります。

その為に TARGET=ovm という環境変数を設定しなければなりません。 環境変数のOS依存を無くすため cross-env を導入します。

$ yarn add -D cross-env

コンパイル用の以下のコマンドを package.json に追加しました。 frontend 自体はレイヤー2で動作させるため、レイヤー1のコントラクトがtypechain(自動生成されるTypeScript用の型定義ファイル)を汚染しない様に、 --no-typechain オプションを指定します。

  "scripts": {
    ...
    "compile": "npm run compile:evm && npm run compile:ovm",
    "compile:evm": "npx hardhat compile --no-typechain",
    "compile:ovm": "cross-env TARGET=ovm npx hardhat compile",

yarn compile でレイヤー1用・レイヤー2用が一度にコンパイル出来、 yarn compile:evm でレイヤー1(EVM)用、yarn compile:ovm でレイヤー2(OVM)用が個別にコンパイル出来ます。

4.2. 単体テスト

現在の hardhat で複数レイヤー構成のブロックチェーンを取り扱うのは難しいので、 テストはモックを用いて hardhat local network の単一レイヤーで動作するようにして単体テストを実施します。

OVM でコンパイルする様に説明しといてなんですが、バイトコードは EVM 用を使用します。

deploy/(hardhat-deployプラグイン用)にデプロイスクリプトは置けないので、テストコード内に専用のデプロイコードを書きます。

必要となるモックは mockOVM_CrossDomainMessenger で、@eth-optimism/contracts に含まれています。 以下はテスト環境をセットアップするコードです。

import { ContractFactory } from "ethers";
import {getContractFactory} from "@eth-optimism/contracts";
import {TransactionResponse} from "@ethersproject/abstract-provider";
import hre from "hardhat";

import * as JunkCoinERC20 from '../artifacts/contracts/JunkCoinERC20.sol/JunkCoinERC20.json';
import * as JunkCoinDepositedERC20 from '../artifacts/contracts/JunkCoinDepositedERC20.sol/JunkCoinDepositedERC20.json';
import * as Junkeng from '../artifacts/contracts/Junkeng.sol/Junkeng.json';


export const setupLocal = hre.deployments.createFixture(async () => {
    const { deployer } = await hre.ethers.getNamedSigners();

    // Mock of CrossDomainMessenger
    const L1CrossDomainMessengerFactory = getContractFactory('mockOVM_CrossDomainMessenger', deployer);
    const L1CrossDomainMessenger = await L1CrossDomainMessengerFactory.deploy(0);
    await L1CrossDomainMessenger.deployTransaction.wait();
    console.log('(L1)CrossDomainMessenger: ' + L1CrossDomainMessenger.address);

    const L2CrossDomainMessengerFactory = getContractFactory('mockOVM_CrossDomainMessenger', deployer);
    const L2CrossDomainMessenger = await L2CrossDomainMessengerFactory.deploy(0);
    await L2CrossDomainMessenger.deployTransaction.wait();

    await L1CrossDomainMessenger.setTargetMessengerAddress(L2CrossDomainMessenger.address)
        .then((tx: TransactionResponse) => tx.wait());
    await L2CrossDomainMessenger.setTargetMessengerAddress(L1CrossDomainMessenger.address)
        .then((tx: TransactionResponse) => tx.wait());
    console.log('(L2)CrossDomainMessenger: ' + L2CrossDomainMessenger.address);

    // Deploy L1 contracts
    const L1JunkCoinERC20Factory = new ContractFactory(JunkCoinERC20.abi, JunkCoinERC20.bytecode, deployer);
    const L1JunkCoinERC20 = await L1JunkCoinERC20Factory.deploy(
        "JunkCoin",
        "JKC",
        100000000,
    )
    await L1JunkCoinERC20.deployTransaction.wait();
    console.log('(L1)JunkCoinERC20: ' + L1JunkCoinERC20.address);

    // Deploy L2 contracts
    const L2JunkCoinDepositedERC20Factory = new ContractFactory(JunkCoinDepositedERC20.abi, JunkCoinDepositedERC20.bytecode, deployer);
    const L2JunkCoinDepositedERC20 = await L2JunkCoinDepositedERC20Factory.deploy(
        L2CrossDomainMessenger.address,
        "JunkCoin",
        "JKC"
    )
    await L2JunkCoinDepositedERC20.deployTransaction.wait();
    console.log('(L2)JunkCoinDepositedERC20: ' + L2JunkCoinDepositedERC20.address);

    const L2JunkengFactory = new ContractFactory(Junkeng.abi, Junkeng.bytecode, deployer);
    const L2Junkeng = await L2JunkengFactory.deploy(
        L2JunkCoinDepositedERC20.address
    )
    await L2Junkeng.deployTransaction.wait();
    console.log('(L2)Junkeng: ' + L2Junkeng.address);

    // Deploy L1 contracts
    const L1ERC20GatewayFactory = getContractFactory('OVM_L1ERC20Gateway', deployer);
    const L1ERC20Gateway = await L1ERC20GatewayFactory.deploy(
        L1JunkCoinERC20.address,
        L2JunkCoinDepositedERC20.address,
        L1CrossDomainMessenger.address,
    )
    await L1ERC20Gateway.deployTransaction.wait();
    console.log('(L1)OVM_L1ERC20Gateway: ' + L1ERC20Gateway.address);

    // Init L2 contracts
    await L2JunkCoinDepositedERC20.init(L1ERC20Gateway.address, { gasLimit: 1000000 })
        .then((tx: TransactionResponse) => tx.wait());
    await L2JunkCoinDepositedERC20.increaseAllowance(L2Junkeng.address, 100000000, { gasLimit: 1000000 })
        .then((tx: TransactionResponse) => tx.wait());
    await L2JunkCoinDepositedERC20.setDispenser(L2Junkeng.address, { gasLimit: 1000000 })
        .then((tx: TransactionResponse) => tx.wait());
    console.log('(L2)JunkCoinDepositedERC20 has been initialized.');

    // Deposit full balance of token
    await L1JunkCoinERC20.increaseAllowance(L1ERC20Gateway.address, 100000000, { gasLimit: 1000000 })
        .then((tx: TransactionResponse) => tx.wait());
    await L1ERC20Gateway.deposit(100000000, { gasLimit: 1000000 })
        .then((tx: TransactionResponse) => tx.wait());
    await L2CrossDomainMessenger.relayNextMessage({ gasLimit: 9500000 })
        .then((tx: TransactionResponse) => tx.wait());
    console.log('(L1)JunkCoinERC20 has been deposited.');

    return {
        L1CrossDomainMessenger,
        L2CrossDomainMessenger,
        L1JunkCoinERC20,
        L2JunkCoinDepositedERC20,
        L2Junkeng,
        L1ERC20Gateway,
    }
})

@eth-optimism/contracts の型定義ファイルがないので、プロジェクトルートに index.d.ts を作って

declare module "@eth-optimism/contracts" {
  import { ContractFactory, Signer } from 'ethers'

  export function getContractFactory(
      name: string,
      signer?: Signer,
      ovm?: boolean
  ): ContractFactory;
}

と書き込んでください。 また、tsconfig.jsonincludeindex.d.ts を追加してください。

レイヤー1 では、OVM_L1ERC20Gateway というコントラクトで JunkCoinERC20 をラップしていますが、 このコントラクトが資産をロックしてレイヤー2に転送します。 当然、レイヤー2から戻ってきた資産を所有者アカウントに渡す役割も担います。

mockOVM_CrossDomainMessenger はメッセージを送信キューに入れるだけで、 本来はシステムが勝手にやってくれるメッセージの配信まではしてくれないので、 relayNextMessage を手動で呼び出して、レイヤー2のコントラクト(ここでは JunkCoinDepositedERC20 )にメッセージを着信させます。

createFixture でラップしているので、テストコードの beforeEach で呼び出しておけば、 2回目以降は hardhat がセットアップ済みのスナップショットを復元してくれるので、 高速に単体テストを実行できます。

テストコード側では、

describe('Junkeng', () => {
    let contracts: {[name: string]: Contract };

    beforeEach(async () => {
        contracts = await setupLocal();
    })

と書いておけばデプロイ済みのコントラクトにアクセスできるようになります。

全ての単体テストコードは こちら にあります。

yarn test でテストが実行できるように package.json に以下を追加しました。

"scripts": {
    ...
    "test": "npm run compile:evm && npx hardhat test --no-compile --network hardhat",

4.3. デプロイスクリプトの作成

フロントエンド用に frontend/src/hardhat/deployments を自動生成したいので、hardhat-deploy は引き続き使用します。 ただし、hardhat-deploy がデプロイ対象とするのはレイヤー2のみにします。 レイヤー1のコントラクトは hardhat-deploy の管理外でデプロイして、コントラクトアドレスだけ使用します。

また、hardhat-deploy の OVM コンパイラ対応は不完全で、OVM 用のバイトコードを引っ張って来ない様なので、 自前で artifacts(コンパイラの生成物)をインポートしてデプロイします。

以下は、実際のデプロイスクリプト deploy/Junkeng.ts です。

import {HardhatRuntimeEnvironment} from 'hardhat/types';
import {DeployFunction} from 'hardhat-deploy/types';
import assert from "assert";
import {JsonRpcProvider} from "@ethersproject/providers";
import {Contract, ContractFactory, Wallet} from "ethers";
import {getContractFactory} from "@eth-optimism/contracts";
import {TransactionResponse} from "@ethersproject/abstract-provider";

import * as JunkCoinERC20 from "../artifacts/contracts/JunkCoinERC20.sol/JunkCoinERC20.json";
import * as JunkCoinDepositedERC20 from '../artifacts-ovm/contracts/JunkCoinDepositedERC20.sol/JunkCoinDepositedERC20.json';
import * as Junkeng from '../artifacts-ovm/contracts/Junkeng.sol/Junkeng.json';


const deployL1JuncCoinERC20 = async (l1Wallet: Wallet): Promise<Contract> => {
    // Deploy L1 contracts
    const L1JunkCoinERC20Factory = new ContractFactory(JunkCoinERC20.abi, JunkCoinERC20.bytecode, l1Wallet);
    const L1JunkCoinERC20 = await L1JunkCoinERC20Factory.deploy(
        "JunkCoin",
        "JKC",
        100000000,
    )
    await L1JunkCoinERC20.deployTransaction.wait();
    console.log('(L1)JunkCoinERC20: ' + L1JunkCoinERC20.address);

    return L1JunkCoinERC20;
}

const deployL1ERC20Gateway = async (
    L1MessengerAddress: string,
    L1JunkCoinERC20Address: string,
    L2JunkCoinDepositedERC20Address:
    string, l1Wallet: Wallet
): Promise<Contract> => {
    // Deploy L1 contracts
    const L1ERC20GatewayFactory = getContractFactory('OVM_L1ERC20Gateway');
    const L1ERC20Gateway = await L1ERC20GatewayFactory.connect(l1Wallet).deploy(
        L1JunkCoinERC20Address,
        L2JunkCoinDepositedERC20Address,
        L1MessengerAddress,
    )
    await L1ERC20Gateway.deployTransaction.wait();
    console.log('(L1)OVM_L1ERC20Gateway: ' + L1ERC20Gateway.address);

    return L1ERC20Gateway;
}

// Deposit full balance of token
const depositL1JunkCoinERC20 = async (L1JunkCoinERC20: Contract, L1ERC20Gateway: Contract) => {
    await L1JunkCoinERC20.increaseAllowance(L1ERC20Gateway.address, 100000000, { gasLimit: 1000000 })
        .then((tx: TransactionResponse) => tx.wait());
    await L1ERC20Gateway.deposit(100000000, { gasLimit: 1000000 })
        .then((tx: TransactionResponse) => tx.wait());
    console.log('(L1)JunkCoinERC20 has been deposited.');
}


const deploy: DeployFunction = async function ({
    getNamedAccounts,
    deployments,
    getChainId,
    getUnnamedAccounts
}: HardhatRuntimeEnvironment) {
    assert(process.env.WALLET_PRIVATE_KEY_DEPLOYER);
    assert(process.env.L1_WEB3_URL);
    assert(process.env.L1_MESSENGER_ADDRESS);
    assert(process.env.L2_MESSENGER_ADDRESS);

    const { deploy, execute, save } = deployments;
    const { deployer } = await getNamedAccounts();

    const l1Provider = new JsonRpcProvider(process.env.L1_WEB3_URL);
    const l1Wallet = new Wallet(process.env.WALLET_PRIVATE_KEY_DEPLOYER, l1Provider);

    const L1JunkCoinERC20 = await deployL1JuncCoinERC20(l1Wallet);

    const L2JunkCoinDepositedERC20 = await deploy("JunkCoinDepositedERC20", {
        from: deployer,
        gasLimit: 8000000,
        args: [process.env.L2_MESSENGER_ADDRESS, "JunkCoin", "JKC"],
        contract: {
            abi: JunkCoinDepositedERC20.abi,
            bytecode: JunkCoinDepositedERC20.bytecode,
            deployedBytecode: JunkCoinDepositedERC20.deployedBytecode,
        },
        skipIfAlreadyDeployed: false,
    })
    console.log('(L2)JunkCoinDepositedERC20: ' + L2JunkCoinDepositedERC20.address);

    const L1ERC20Gateway = await deployL1ERC20Gateway(
        process.env.L1_MESSENGER_ADDRESS, L1JunkCoinERC20.address, L2JunkCoinDepositedERC20.address, l1Wallet);

    const junkeng = await deploy("Junkeng", {
        from: deployer,
        gasLimit: 8000000,
        args: [L2JunkCoinDepositedERC20.address],
        contract: {
            abi: Junkeng.abi,
            bytecode: Junkeng.bytecode,
            deployedBytecode: Junkeng.deployedBytecode,
        },
        skipIfAlreadyDeployed: false,
    })
    console.log('(L2)Junkeng: ' + junkeng.address);

    await execute("JunkCoinDepositedERC20", {from: deployer, gasLimit: 8000000}, 'init', L1ERC20Gateway.address);
    console.log('(L2)Executed JunkCoinDepositedERC20.init()');
    await execute("JunkCoinDepositedERC20", {from: deployer, gasLimit: 8000000}, 'approve', junkeng.address, '100000000');
    console.log('(L2)Executed JunkCoinDepositedERC20.approve()');
    await execute("JunkCoinDepositedERC20", {from: deployer, gasLimit: 8000000}, 'setDispenser', junkeng.address);
    console.log('(L2)Executed JunkCoinDepositedERC20.setDispenser()');

    await depositL1JunkCoinERC20(L1JunkCoinERC20, L1ERC20Gateway);
}

export default deploy;

単体テストの時に書いたデプロイコードと似たような手順を踏んでいますが、実際にレイヤー1とレイヤー2で別々のブロックチェーンにデプロイしています。 (これが非常にややこしい!)

実際にデプロイする前には、

  1. WALLET_PRIVATE_KEY_DEPLOYER レイヤー1へデプロイするときに使うアカウントの秘密鍵
  2. L1_WEB3_URL レイヤー1の JsonRpc URL
  3. L1_MESSENGER_ADDRESS レイヤー1のCrossDomainMessengerコントラクトアドレス
  4. L2_MESSENGER_ADDRESS レイヤー2のCrossDomainMessengerコントラクトアドレス

上記の環境変数を必要とします。 .env を作って指定してください。

4.4. ローカルテスト環境へのデプロイ

Optimistic Ethereum では専用のローカルテスト環境が用意されています。 git コマンドと Docker を使うため、予めインストールをしてください。 (めんどくさければ optimism-integration の導入を飛ばして、設定と Test Network へのデプロイに進んでください)

4.4.1. optimism-integration の導入

GitHub からクローンしてセットアップします。

git clone git@github.com:ethereum-optimism/optimism-integration.git --recurse-submodules
cd optimism-integration
docker-compose pull

ノードを立ち上げるときは以下のコマンドです。

$ ./up.sh

localhost:8545 にレイヤー2、localhost:9545 にレイヤー1のノードが起動します。

尚、現在 optimism-integration は開発アクティビティが高いので、 たまに最新バージョンが動作しなくなる事もあるかもしれません。

その場合はもの凄いスピードで流れていくエラーメッセージを確認して、問題個所を特定しなければなりません! 経験上ですが、大抵は docker-compose.env.yml の記述ミスが原因です。

4.4.2. 設定してデプロイ

プロジェクトルートにある dot.env.env にリネームして、内容を以下の様に修正してください。 尚、ウォレットの秘密鍵は、ノードを立ち上げたときに表示されるもので、レイヤー1では 10000ETH が予め付与されます。

# Default account on optimism-integration
WALLET_PRIVATE_KEY_DEPLOYER=0x754fde3f5e60ef2c7649061e06957c29017fe21032a8017132c0078e37f6193a
WALLET_PRIVATE_KEY_SEQUENCER=0xd2ab07f7c10ac88d5f86f1b4c1035d5195e81f27dbe62ad65e59cbf88205629b
WALLET_PRIVATE_KEY_TESTER1=0x23d9aeeaa08ab710a57972eb56fc711d9ab13afdecc92c89586e0150bfa380a6
WALLET_PRIVATE_KEY_TESTER2=0x5b1c2653250e5c580dcb4e51c2944455e144c57ebd6a0645bd359d2e69ca0f0c

# Default settings on optimism-integration
L1_WEB3_URL=http://localhost:9545
L2_WEB3_URL=http://localhost:8545
L1_MESSENGER_ADDRESS=0x6418E5Da52A3d7543d393ADD3Fa98B0795d27736
L2_MESSENGER_ADDRESS=0x4200000000000000000000000000000000000007

hardhat.config.ts の networks に layer1 と layer2 を追加し、環境変数を読み込みます。

import dotenv from "dotenv";
dotenv.config();
// ... 中略 ...

import assert from "assert";

assert(process.env.WALLET_PRIVATE_KEY_DEPLOYER);
assert(process.env.WALLET_PRIVATE_KEY_SEQUENCER);
assert(process.env.WALLET_PRIVATE_KEY_TESTER1);
assert(process.env.WALLET_PRIVATE_KEY_TESTER2);
assert(process.env.L1_WEB3_URL);
assert(process.env.L2_WEB3_URL);
assert(process.env.L1_MESSENGER_ADDRESS);
assert(process.env.L2_MESSENGER_ADDRESS);

// ... 中略 ...

const config: HardhatUserConfig = {
    react: {
        providerPriority: ["web3modal", "hardhat"],
    },
    defaultNetwork: "layer2",
    networks: {
        // ... 中略 ...
        layer2: {
            url: process.env.L2_WEB3_URL,
            accounts: [
                process.env.WALLET_PRIVATE_KEY_DEPLOYER,
                process.env.WALLET_PRIVATE_KEY_SEQUENCER,
                process.env.WALLET_PRIVATE_KEY_TESTER1,
                process.env.WALLET_PRIVATE_KEY_TESTER2,
            ],
        },
        layer1: {
            url: process.env.L1_WEB3_URL,
            accounts: [
                process.env.WALLET_PRIVATE_KEY_DEPLOYER,
                process.env.WALLET_PRIVATE_KEY_SEQUENCER,
                process.env.WALLET_PRIVATE_KEY_TESTER1,
                process.env.WALLET_PRIVATE_KEY_TESTER2,
            ],
        },

デプロイコマンドを package.json に仕込みます。

  "scripts": {
    ...
    "deploy": "npm run compile:evm && cross-env TARGET=ovm npx hardhat deploy --reset --network layer2",

コマンドラインからは yarn deploy で実行できます。

network として layer2 を指定しているのと、デプロイの再利用をしない様に --reset を使用しています。 従って、デプロイを実行する度に、全コントラクトのアドレスが変更になります。

layer2 を指定していますが、依存する layer1 のコントラクトも一緒にデプロイされます。

frontend/src/hardhat/deployments が更新され、フロントエンドにもコントラクトアドレスが反映されます。

また、デプロイ時に表示されるコントラクトアドレスは、後々の為にメモ帳などへ控えておいてください。

4.4.3. 動作確認

コンソールでコントラクトにアクセスして動作確認をしてみましょう。

$ npx hardhat console --no-compile --network layer2

hardhat コンソールが起動したら、以下の順でコマンドを打ち込んでください。

> const signers = await ethers.getSigners()
> const JunkCoinDepositedERC20 = await ethers.getContract('JunkCoinDepositedERC20', signers[0])
> (await JunkCoinDepositedERC20.totalSupply()).toNumber()
100000000

正常にデプロイされ、JunkCoin がデポジットされていますね。

4.4.4. フロントエンドからアクセス

幸運にしてフロントエンドには何も修正を加える必要がありません。 単にプロジェクトルートで、

$ yarn frontend

を実行してテストサーバーを起動しましょう。

MetaMask でカスタムネットワークの追加が必要になります。 以下の様に追加してください。 必要なのは layer2 だけで、layer1 は必須ではありません。

MetaMask で layer2 のネットワークに切り替えて Join game してみましょう。

ローカルで起動した layer2 ノードでは Gas Price を 0 にしてもトランザクションが通ります。 従って ETH は必要ありません!

さて、きちんと動作したでしょうか。エラーが出て進行しない場合は、デプロイからやり直してみてください。 また、アカウントのリセット(MetaMask の Settings → Advanced → Reset Account )も行ってください。

入手したコインをレイヤー1に引き出すには、単に Withdraw をクリックします。 テスト用のローカルノードでは、ゼロ遅延でレイヤー1に転送されます(本来は1週間かかります)

入手したコインの残高を確認するには、デプロイ時に表示された JunkCoinERC20 のアドレスを、Add Token で MetaMask に追加してください。 その際、忘れずにネットワークを layer1 へ切り替えてください。

4.5. Test Network にデプロイ

Optimistic Ethereum では Kovan Test Network に接続されたテストネットが公開されているので、 レイヤー2用のスマートコントラクトをパブリックにデプロイ出来ます。

未だ Mainnet にはデプロイできません。

4.5.1. ETH の入手

前準備として Kovan Test Network 上の ETH をこちら で入手してください。 (GitHub のアカウントが必要です)

4.5.2. ノードの準備

前回も使った Alchemy で Kovan Test Network のノードを立ち上げて(アプリを作成して) API URL を取得してください。 レイヤー1用のコントラクトを Kovan Test Network へデプロイするのに必要です。

4.5.3. .env の編集とデプロイ実行

.evn を以下の様に編集してください。

WALLET_PRIVATE_KEY_DEPLOYER=アカウントの秘密鍵に置換(Kovan Test Network 上で ETH が必要です)
WALLET_PRIVATE_KEY_SEQUENCER=アカウントの秘密鍵に置換(適当なアカウント)
WALLET_PRIVATE_KEY_TESTER1=アカウントの秘密鍵に置換(適当なアカウント)
WALLET_PRIVATE_KEY_TESTER2=アカウントの秘密鍵に置換(適当なアカウント)

L1_WEB3_URL=用意した Kovan Test Network ノードの API URL
L2_WEB3_URL=https://kovan.optimism.io
L1_MESSENGER_ADDRESS=0xb89065D5eB05Cac554FDB11fC764C679b4202322
L2_MESSENGER_ADDRESS=0x4200000000000000000000000000000000000007

以上でデプロイの準備が整いました。 yarn deploy で実行です。

4.5.4. MetaMask の設定

Kovan Test Network はデフォルトでネットワークリストに含まれています。 レイヤー2 を以下の様に追加してください。

あとは切り替えて使用します。

パブリックの Optimistic Ethereum テストサーバーも、Gas Price 0 でトランザクションを通すことが出来ます。 実際は幾許かの手数料が掛かりますが、非常に安価になり、今の様な高騰が起こらないものと思われます。

手数料は ETH で支払いますが、その為の資金はレイヤー1からデポジットしてくる必要があるでしょう(今回はその手順は省かれています) 従って、デポジット用の UI を別途実装する必要がありそうです。

テストサーバーでは、レイヤー2からレイヤー1に資産を持ち出そうとすると、待ち時間が発生するようです (筆者のテストも待ち時間中なので、どれくらいの時間掛かるのか確認できてない)

5. 実装してみた所感・問題点

5.1. 「タイムスタンプ」問題

こちら に書かれているのですが、 DeJunkeng も時間に依存するdAppsです。 Optimistic rollups のレイヤー2ではこれが深刻な問題で、block.timestamp に大きな遅延が生じる為、 ゲーム開始してから5分間の制限時間を正確に測ることが出来ません。

レイヤー2 における block.timestamp は、レイヤー1へトランザクションを最後にロールアップした時間 に設定されるため、 実時間から最大10分程度のずれが生じます。

Optimistic Ethereum のレイヤー2には「ブロック」という概念は存在せず、有るのは単なるトランザクションのリストです(即ちブロックチェーンですらない)

もともと Ethereum のスマートコントラクトにおける時間の扱いには課題があり、 ブロックの生成毎に更新される block.timestamp の精度は荒いもので、 これに依存する事は推奨されていませんでした。

時間をセキュアに扱うためには、中央集権的なサーバーを用意するか、別のソリューションを考え出す必要がありそうです。

5.2. トランザクション承認が早すぎるので想定外の状態になる

これはフロントエンドのコードを直せばいい話ですが、 トランザクションを発行してからほぼラグ無く承認されるので、 変な状態に入るバグが生まれてしまいました。

スループットが高くなったが故に露見したバグというやつですね。

5.3. レイヤー2 の資産をレイヤー1 に持ち出す際の待ち時間

レイヤー2にある資産をレイヤー1に持ち出そうとすると、7日程度の待ち時間が発生するのは UX として厳しいかもしれないです。 これが単なるEtherやERC20トークンと言った代替え可能な資産の場合は、引き出そうとする資産を担保に同じものを短期貸付する形で、 遅延を短くする仕組みが作れそうですが、NFT(非代替性トークン)のような一品物は難しいですね。

この待ち時間中は、引き出そうとする資産が消えたような状態になるので、「Ether・トークンは転送中です」と分かる何らかの仕組みが必要かなと思いました。

5.4. MetaMask でネットワークを切り替えるのは面倒

MetaMask でカスタムネットワークを追加してレイヤー2に切り替えるという操作を必要とするのは UX として厳しいかもしれないです。

間違ったネットワークを選択していて気付かないという事も発生して、問い合わせ頻度が爆増しそうです。

dApps のフロントエンドに独自のウォレットを実装して、MetaMask は切り替えさせないというのが UX にとってはベストな案かも。

5.5. 開発環境(hardhat)の対応は未完全

レイヤー2 と OVM コンパイラをもっと上手く扱えるように hardhat-deploy プラグインの「プラグイン」を開発する必要がありそうです。

5.6. 実際どれくらい安くなるかはやってみないと分からない

Test Network では Gas Price 0 でトランザクションが通るので、実際幾らくらい掛かるのかは見積もれませんでした。 Optimistic rollups レイヤー2 の主な維持コストは、システム維持費とレイヤー1へのロールアップ時に発生するガス代でしょうから、 利用者が多ければスケールメリットが得られそうですが、今のところ良くわからないですね。 しかしよく考えれば、レイヤー2は誰でも立ち上げられる仕組みなので、レイヤー2運営者が任意に料金を設定すればいいという話なのかもしれません。 そう考えると、レイヤー2で展開する dApps の収益性によっては無料に出来る物もあるでしょう。

6. 今回はここまで

今回は、Ethereum に起こっている「ガス高騰問題」への対策を検討しつつ、 現在進行形で開発が進んでいるレイヤー2技術の一つ「Optimistic rollups」を実際に使ってみることで紹介しました。

完成すれば Ethereum でセキュリティを確保したまま、安価な利用料で dApps が使える独自プラットフォームを展開する有効な手段になりそうです。 弊社としても引き続き追っていきたいと考えています。

今回はここまでとします。それではまた!

7. ソースコードは GitHub で公開中

MITライセンスで公開中です。 ご利用は自己責任でどうぞ。 ( optimism ブランチにアップされています)

バグなど見つけたら Issue までどうぞ。