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

コールデータを最適化するための簡潔なABI

レイヤー2Optimism
中級
Ori Pomerantz
2022年4月1日
25 分の読書 minute read

はじめに

この記事では、オプティミスティック・ロールアップとは何か、オプティミスティック・ロールアップにおけるトランザクションコスト、および、様々なコスト構造に応じてイーサリアム・メインネット上の様々な事項をいかに最適化すべきかについて学びます。 さらに、この最適化の実装方法についても紹介します。

開示情報

筆者は、Optimism(opens in a new tab)のフルタイム従業員であり、この記事に含まれる実例はすべて Optimism で実行されます。 ただし、紹介するテクニックは他のロールアップでも問題なく実行できます。

用語

ロールアップの議論において、「レイヤー 1」は、イーサリアムネットワークの本番環境であるメインネットを指します。 「レイヤー 2」(L2)という用語は、ロールアップまたはセキュリティのために L1 に依存しているが、そのほとんどをオフチェーンで処理する他のシステムに使用されます。

L2 上のトランザクションコストをさらに引き下げる方法

オプティミスティック・ロールアップでは、すべてのユーザーが過去のトランザクションを参照し、現在の状態が正しいことを検証できるように、過去のすべてのトランザクション記録を保存する必要があります。 イーサリアムメインネットにデータを書き込む最も安価な方法は、コールデータとして書き込む方法です。 Optimism(opens in a new tab)Arbitrum(opens in a new tab)はいずれも、コールデータのソリューションを採用しています。

L2 トランザクションのコスト

L2 トランザクションのコストは、以下の 2 つの要素で構成されます:

  1. L2 上の処理コスト。通常、非常に安価です。
  2. L1 上のストレージコスト。これは、メインネットのガス代と連動します。

この記事の執筆時点の Optimism で、L2 ガス代は、0.001Gwei です。 一方、L1 のガス代は約 40Gwei です。 リアルタイムの価格はこちら(opens in a new tab)で確認できます。

1 バイトのコールデータのコストは、4 ガス (0 バイトの場合) または 16 ガス (それ以外) のいずれかです。 EVM で最も費用が高い操作のひとつは、ストレージへの書き込みです。 L2 上で 32 バイトのワードを書き込む場合、最大コストは 22100 ガスです。 現在のレートでは、22.1 gwei になります。 したがって、1 つのコールデータをゼロバイトに節約できれば、約 200 バイトをストレージに書き込むことができ、まだお釣りが来ます。

ABI

大多数のトランザクションは、外部所有アカウントからコントラクトにアクセスします。 ほとんどのコントラクトは Solidity で書かれており、データフィールドはアプリケーション・バイナリ・インターフェイス(ABI) (opens in a new tab)で解釈されます。

ただし ABI は、1 バイトのコールデータがほぼ 4 回の算術演算のコストと同じになる L1 を念頭に置いて設計されていますが、L2 では、1 バイトのコールデータのコストで算術演算を 1000 回以上実行することができます。 例えば、この ERC-20 の送信トランザクション(opens in a new tab)を見てみましょう。 コールデータは、以下のように分割されます:

セクション長さバイト浪費バイト浪費ガス必須バイト必須ガス
関数セレクタ40 ~ 3348116
ゼロ値124 ~ 15124800
送信先アドレス2016 ~ 350020320
金額3236 ~ 67176415240
合計68160576

説明:

  • 関数セレクター: このコントラクトに含まれる関数は 256 未満であるため、1 バイトで区別できます。 これらのバイトは通常 0 バイトではないので、16 ガス(opens in a new tab)がかかります。
  • 0 バイト :これらのバイトは常にゼロです。と言うのも、20 バイトのアドレスを保持するためには 32 バイトのワードを必要としないからです。 0 バイトのコストは、4 ガスです(イエローペーパー(opens in a new tab)の 27 ページにある Appendix G で、Gtxdatazeroの値について確認してください)。
  • 金額:このコントラクトのdecimalsが 18(通常値)であり、送信できるトークンの上限が 1018だとすると、金額の上限は 1036になります。 25615 > 1036のため、必要なバイト数は 15 になります。

通常、L1 上で 160 ガスを浪費するのは無視できる範囲です。 1 件のトランザクションには最低でも21,000 ガス(opens in a new tab)が必要であるため、追加の 0.8%はほとんど問題になりません。 しかし、L2 では問題になります。 L2 におけるほぼすべてのコストは、L1 への書き込みで発生します。 トランザクションのコールデータに加えて、トランザクションのヘッダー(送信先アドレス、署名など)で 109 バイトが必要になります。 従って、L2 おける総コストは109*16+576+160=2480となり、浪費分が全体の 6.5%に達するのです。

送信先を限定しない場合のコスト削減方法

送信先のコントラクトを制御できない場合でも、こちら(opens in a new tab)のようなソリューションを活用できます。 関連するファイルを確認しておきましょう。

Token.sol

これ(opens in a new tab)は、送信先のコントラクトです。 標準的な ERC-20 コントラクトですが、機能が 1 つ追加されています。 faucet関数により、すべてのユーザーがトークンを取得できるようになっています。 本番環境の ERC-20 コントラクトでは使えませんが、テスト環境では有益でしょう。

1 /**
2 * @dev Gives the caller 1000 tokens to play with
3 */
4 function faucet() external {
5 _mint(msg.sender, 1000);
6 } // function faucet
コピー

こちら(opens in a new tab)で、このコントラクトのデプロイ実例を確認できます。

CalldataInterpreter.sol

これ(opens in a new tab)は、より短いコールデータでトランザクションを呼び出すことが想定されているコントラクトです。 一行ずつ見ていきましょう。

1//SPDX-License-Identifier: Unlicense
2pragma solidity ^0.8.0;
3
4
5import { OrisUselessToken } from "./Token.sol";
コピー

呼び出し方法を知るには、トークン関数が必要です。

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;
コピー

私たちがプロキシとなるトークンのアドレスです。

1
2 /**
3 * @dev Specify the token address
4 * @param tokenAddr_ ERC-20 contract address
5 */
6 constructor(
7 address tokenAddr_
8 ) {
9 token = OrisUselessToken(tokenAddr_);
10 } // constructor
すべて表示
コピー

指定が必要なパラメータは、トークンアドレスのみです。

1 function calldataVal(uint startByte, uint length)
2 private pure returns (uint) {
コピー

コールデータから値を読み込みます。

1 uint _retVal;
2
3 require(length < 0x21,
4 "calldataVal length limit is 32 bytes");
5
6 require(length + startByte <= msg.data.length,
7 "calldataVal trying to read beyond calldatasize");
コピー

32 バイト(256 ビット)を持つ 1 つのワードをメモリにロードして、必要なフィールドに含まれない部分のバイトを削除します。 このアルゴリズムは、32 バイト以上の値に対しては機能せず、コールデータの末尾を越えたデータを読むこともできません。 L1 では、ガスを節約するためにこれらのテストを省略すべきかもしれませんが、L2 のガス代はとても安価なので、あらゆるサニティチェックを実行することができます。

1 assembly {
2 _retVal := calldataload(startByte)
3 }
コピー

fallback()への呼び出しからデータをコピーしてもよいのですが(以下を参照) 、EVM のアセンブリ言語であるYul(opens in a new tab)を使用する方が楽でしょう。

ここでは、CALLDATALOAD のオペコード(opens in a new tab)を使用して、startByteから startByte+31までのバイトをスタックへ読み込みます。 一般に、Yul のオペコードの構文は<opcode name>(<first stack value, if any>,<second stack value, if any>...となります。

1
2 _retVal = _retVal >> (256-length*8);
コピー

このフィールドに含まれるのは最も重要なlengthのバイトだけなので、右シフトクリック(opens in a new tab)で他の値を削除します。 この方法は、値をフィールドの右側に移動するという追加の利点があるので、256xを掛けた値ではなく、値そのものになります。

1
2 return _retVal;
3 }
4
5
6 fallback() external {
コピー

Solidity コントラクトへの呼び出しがどの関数の署名とも一致しない場合、 fallback()関数(opens in a new tab)を呼び出します(存在する場合)。 CalldataInterpreterの場合、他のexternalまたはpublicの関数がないため、すべての呼び出しがここに到達します。

1 uint _func;
2
3 _func = calldataVal(0, 1);
コピー

この関数を返すコールデータの最初の 1 バイトを読み取ります。 ここで関数が取得できないのには、2 つの理由があります:

  1. pureまたはviewの関数の場合。これらの関数は状態を変更しないため、ガスが発生しません(オフチェーンで呼び出す場合)。 ですから、ガス代を節約する必要がありません。
  2. msg.sender(opens in a new tab)に依存した関数。 msg.senderの値は、呼び出し元のアドレスではなく、CalldataInterpreterのアドレスになります。

残念ながら、ERC-20 の仕様(opens in a new tab)を確認すると、残りの関数はtransferのみです。 つまり、呼び出し可能な関数は、transfertransferFromを呼び出す)と、faucet (呼び出し元のアドレスにトークンを送信する)になります。

1
2 // Call the state changing methods of token using
3 // information from the calldata
4
5 // faucet
6 if (_func == 1) {
コピー

次は、パラメータを持たないfaucet()を呼び出すコードです。

1 token.faucet();
2 token.transfer(msg.sender,
3 token.balanceOf(address(this)));
4 }
コピー

token.faucet()を呼び出すと、トークンを取得します。 しかし、プロキシのコントラクトにおいてトークンは必要ありません。 トークンが必要なのは、外部所有アカウント(EOA)あるいは呼び出し元のコントラクトです。 ですから、所有するトークンをすべて呼び出し元アドレスに送信します。

1 // transfer (assume we have an allowance for it)
2 if (_func == 2) {
コピー

トークンを送信する場合、送信先アドレスと金額という 2 つのパラメータが必要です。

1 token.transferFrom(
2 msg.sender,
コピー

送信できるトークンは、呼び出し元が所有するトークンのみです。

1 address(uint160(calldataVal(1, 20))),
コピー

送信先アドレスは、#1 のバイトから始まります(#0 のバイトは、関数が使用します)。 アドレスの長さは、20 バイトです。

1 calldataVal(21, 2)
コピー

このコントラクトでは、送信可能なトークンの最大数が 2 バイト以内(65536 未満)に収まると想定します。

1 );
2 }
コピー

1 件の送信につき、35 バイトのコールデータが発生します。

セクション長さバイト
関数セレクタ10
送信先アドレス321 ~ 32
金額233 ~ 34
1 } // fallback
2
3} // contract CalldataInterpreter
コピー

test.js

この JavaScript による単体テスト(opens in a new tab)では、このメカニズムを使用する方法(および、メカニズムが適切に動作していることをを確認する方法)を示します。 ここでは、Chai(opens in a new tab)およびEthers(opens in a new tab)についてよく理解しているという前提に基づき、特にコントラクトに関連する部分のみを説明します。

1const { expect } = require("chai");
2
3describe("CalldataInterpreter", function () {
4 it("Should let us use tokens", async function () {
5 const Token = await ethers.getContractFactory("OrisUselessToken")
6 const token = await Token.deploy()
7 await token.deployed()
8 console.log("Token addr:", token.address)
9
10 const Cdi = await ethers.getContractFactory("CalldataInterpreter")
11 const cdi = await Cdi.deploy(token.address)
12 await cdi.deployed()
13 console.log("CalldataInterpreter addr:", cdi.address)
14
15 const signer = await ethers.getSigner()
すべて表示
コピー

まず、両方のコントラクトをデプロイします。

1 // Get tokens to play with
2 const faucetTx = {

ここでは ABI を使用しないため、トランザクションを作成するために通常用いる高度な関数(token.faucet()など)を使用できません。 その代わりに、トランザクションをマニュアルで作成し、送信する必要があります。

1 to: cdi.address,
2 data: "0x01"

トランザクションには、次の 2 つのパラメータが必要です:

  1. to:送信先のアドレスです。 これは、コールデータのインタープリタのアドレスです。
  2. data:送信するコールデータです。 フォーセットを呼び出す場合、データは 1 バイト(0x01)です。
1
2 }
3 await (await signer.sendTransaction(faucetTx)).wait()

すでに送信先(faucetTx.to)を指定しており、トランザクションに対して署名を得る必要があるため、署名者のsendTransactionメソッド(opens in a new tab)を呼び出します。

1// Check the faucet provides the tokens correctly
2expect(await token.balanceOf(signer.address)).to.equal(1000)

ここでは、残高を確認します。 view関数ではガスを節約する必要がないので、単純に実行します。

1// Give the CDI an allowance (approvals cannot be proxied)
2const approveTX = await token.approve(cdi.address, 10000)
3await approveTX.wait()
4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

コールデータのインタープリタが送信できるように、アローワンスを設定します。

1// Transfer tokens
2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
3const transferTx = {
4 to: cdi.address,
5 data: "0x02" + destAddr.slice(2, 42) + "0100",
6}

送信トランザクションを作成します。 最初のバイトは「0x02」で、次に送信先アドレスを置き、最後に金額(10 進法で 256 である 0x0100)を置きます。

1 await (await signer.sendTransaction(transferTx)).wait()
2
3 // Check that we have 256 tokens less
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // And that our destination got them
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe
すべて表示

これらのファイルにつき、自ら実行せず、どのように動作するのか確認したい場合は、以下のリンクにアクセスしてください:

  1. アドレス0x950c753c0edbde44a74d3793db738a318e9c8ce8(opens in a new tab)に対する OrisUselessToken(opens in a new tab)のデプロイメント。
  2. アドレス0x16617fea670aefe3b9051096c0eb4aeb4b3a5f55(opens in a new tab)に対するCalldataInterpreter(opens in a new tab)のデプロイメント。
  3. faucet()(opens in a new tab)の呼び出し。
  4. OrisUselessToken.approve()(opens in a new tab)の呼び出し。 処理がmsg.senderに依存しているため、この呼び出しは、直接トークンコントラクトで行う必要があります。
  5. transfer()(opens in a new tab)の呼び出し。

送信先コントラクトを制限する場合にコストを削減する方法

送信先コントラクトを制限できる場合、コールデータのインタープリタが信頼されるため、msg.senderチェックを省略する関数を作成することができます。 control-contractのブランチから、動作例を確認できます(opens in a new tab)

コントラクトが外部のトランザクションのみに応答する場合、1 つのコントラクトのみで対応することができます。 しかし、この方法ではコンポーザビリティが失われます。 通常の ERC-20 の呼び出しに応答するコントラクトと、短いコールデータを持つトランザクションに応答するコントラクトを共に用意する方が優れた方法だと言えます。

Token.sol

この例では、Token.solを修正します。 これにより、このプロキシだけが呼び出せる一連の関数を設定することができます。 以下は、追加の関数です:

1 // The only address allowed to specify the CalldataInterpreter address
2 address owner;
3
4 // The CalldataInterpreter address
5 address proxy = address(0);
コピー

ERC-20 コントラクトは、許可されたプロキシの身元を知る必要があります。 しかし、この時点では値が不明なため、コンストラクタで変数を設定できません。 プロキシは、コンストラクタにおいてトークンのアドレスを要求するため、まずこのコントラクトのインスタンスが実行されます。

1 /**
2 * @dev Calls the ERC20 constructor.
3 */
4 constructor(
5 ) ERC20("Oris useless token-2", "OUT-2") {
6 owner = msg.sender;
7 }
コピー

作成者(オーナーと呼ぶ)のアドレスは、プロキシを設定することが許可された唯一のアドレスであるため、ここに保存されます。

1 /**
2 * @dev set the address for the proxy (the CalldataInterpreter).
3 * Can only be called once by the owner
4 */
5 function setProxy(address _proxy) external {
6 require(msg.sender == owner, "Can only be called by owner");
7 require(proxy == address(0), "Proxy is already set");
8
9 proxy = _proxy;
10 } // function setProxy
すべて表示
コピー

プロキシは特権アクセスを持つため、セキュリティチェックが省略されます。 このプロキシが信頼できることを確認するには、オーナーに対し、1 回のみこの関数を呼び出すことを許可します。 proxyが (ゼロではない)実際の値を持つと同時に、この値は変更不可となるため、オーナーが悪意のユーザーになった場合やそのニーモニックが明らかになった場合でも、安全性が維持されます。

1 /**
2 * @dev Some functions may only be called by the proxy.
3 */
4 modifier onlyProxy {
コピー

これは、他の関数の動作を修正するmodifier関数(opens in a new tab)です。

1 require(msg.sender == proxy);
コピー

まず、呼び出し元がプロキシであり、その他のユーザーではないことを確認します。 プロキシ以外から呼び出された場合は、 revertします。

1 _;
2 }
コピー

プロキシからの呼び出しであれば、修正する関数を実行します。

1 /* Functions that allow the proxy to actually proxy for accounts */
2
3 function transferProxy(address from, address to, uint256 amount)
4 public virtual onlyProxy() returns (bool)
5 {
6 _transfer(from, to, amount);
7 return true;
8 }
9
10 function approveProxy(address from, address spender, uint256 amount)
11 public virtual onlyProxy() returns (bool)
12 {
13 _approve(from, spender, amount);
14 return true;
15 }
16
17 function transferFromProxy(
18 address spender,
19 address from,
20 address to,
21 uint256 amount
22 ) public virtual onlyProxy() returns (bool)
23 {
24 _spendAllowance(from, spender, amount);
25 _transfer(from, to, amount);
26 return true;
27 }
すべて表示
コピー

以下は、トークンを送信する/アローワンスを承認するエンティティから直接メッセージを受信する際に通常必要となる 3 つの操作です。 ここでは、以下の特徴を持つプロキシバージョンを使います:

  1. onlyProxy()で修正されており、他のユーザーが管理権限を持たない。
  2. 追加のパラメータとして、通常msg.senderであるアドレスを取得する。

CalldataInterpreter.sol

コールデータのインタープリタは、送信先を限定しない場合とほぼ同一ですが、プロキシの関数ではmsg.senderパラメータを受け取るため、transferのアローワンスが必要ない点が異なります。

1 // transfer (no need for allowance)
2 if (_func == 2) {
3 token.transferProxy(
4 msg.sender,
5 address(uint160(calldataVal(1, 20))),
6 calldataVal(21, 2)
7 );
8 }
9
10 // approve
11 if (_func == 3) {
12 token.approveProxy(
13 msg.sender,
14 address(uint160(calldataVal(1, 20))),
15 calldataVal(21, 2)
16 );
17 }
18
19 // transferFrom
20 if (_func == 4) {
21 token.transferFromProxy(
22 msg.sender,
23 address(uint160(calldataVal( 1, 20))),
24 address(uint160(calldataVal(21, 20))),
25 calldataVal(41, 2)
26 );
27 }
すべて表示
コピー

Test.js

送信先を限定しない場合とは、いくつかの点が異なります。

1const Cdi = await ethers.getContractFactory("CalldataInterpreter")
2const cdi = await Cdi.deploy(token.address)
3await cdi.deployed()
4await token.setProxy(cdi.address)
コピー

ERC-20 コントラクトに対し、どのプロキシを信頼するかを伝える必要があります。

1console.log("CalldataInterpreter addr:", cdi.address)
2
3// Need two signers to verify allowances
4const signers = await ethers.getSigners()
5const signer = signers[0]
6const poorSigner = signers[1]
コピー

approve()transferFrom()を確認するには、第 2 の署名者が必要です。 第 2 の署名者は、トークンを受け取らないため(もちろん、ETH を所有する必要はあります)にpoorSignerと呼びます。

1// Transfer tokens
2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
3const transferTx = {
4 to: cdi.address,
5 data: "0x02" + destAddr.slice(2, 42) + "0100",
6}
7await (await signer.sendTransaction(transferTx)).wait()
コピー

ERC-20 コントラクトは、プロキシ (cdi) を信頼するため、送信をリレーするためのアローワンスは必要ありません。

1// approval and transferFrom
2const approveTx = {
3 to: cdi.address,
4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",
5}
6await (await signer.sendTransaction(approveTx)).wait()
7
8const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"
9
10const transferFromTx = {
11 to: cdi.address,
12 data: "0x04" + signer.address.slice(2, 42) + destAddr2.slice(2, 42) + "00FF",
13}
14await (await poorSigner.sendTransaction(transferFromTx)).wait()
15
16// Check the approve / transferFrom combo was done correctly
17expect(await token.balanceOf(destAddr2)).to.equal(255)
18
19No key
20Text
21XPath: /pre[38]/code
すべて表示
コピー

新たに追加した 2 つの関数をテストします。 transferFromTxのアドレスには、アローワンスの提供元と受領者という 2 つパラメータが要求される点に注意してください。

実例

これらのファイルにつき、自ら実行せず、どのように動作するのか確認したい場合は、以下のリンクにアクセスしてください:

  1. 0xb47c1f550d8af70b339970c673bbdb2594011696(opens in a new tab)のアドレスに対するOrisUselessToken-2のデプロイメント(opens in a new tab)
  2. 0x0dccfd03e3aaba2f8c4ea4008487fd0380815892(opens in a new tab)のアドレスに対する CalldataInterpreterのデプロイメント(opens in a new tab)
  3. setProxy()の呼び出し(opens in a new tab)
  4. faucet()の呼び出し(opens in a new tab)
  5. transferProxy()の呼び出し(opens in a new tab)
  6. approveProxy()の呼び出し(opens in a new tab)
  7. transferFromProxy()の呼び出し(opens in a new tab)。 この呼び出しは、他のアドレスとは異なるアドレスからのものであることに注意してください (signerの代わりにpoorSigner) 。

まとめ

Optimism(opens in a new tab)Arbitrum(opens in a new tab)はどちらも、L1 に書き込まれるコールデータのサイズを削減し、トランザクションコストを抑える方法を提供することを目指しています。 インフラプロバイダーが汎用性が高いソリューションを追求する一方で、デベロッパの能力には限界があります。 Dapp のデベロッパーは、開発するアプリケーションについて具体的な知識を持つため、汎用性のソリューションよりも効率的にコールデータの最適化を実現できるのです。 この記事が、皆さんのニーズに合わせた理想的なソリューションを見出す上で役立つことを願っています。

最終編集者: @HiroyukiNaito(opens in a new tab), 2023年8月15日

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