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

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

レイヤー2
中級
Ori Pomerantz
2022年4月1日
23 分の読書

はじめに

この記事では、オプティミスティック・ロールアップやそのトランザクションコスト、そしてその異なるコスト構造が、Ethereumメインネットとは異なるものの最適化をどのように要求するかについて学びます。 また、この最適化を実装する方法についても学びます。

完全な情報開示

筆者はOptimism (opens in a new tab)のフルタイム従業員であるため、この記事の例はOptimismで実行されます。 ただし、ここで説明するテクニックは、他のロールアップでも同様に機能するはずです。

用語

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

L2トランザクションのコストをさらに削減するには

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

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

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

  1. L2処理。通常は極めて安価です。
  2. L1ストレージ。メインネットのガス代に連動します。

これを書いている時点では、OptimismでのL2のガス代は0.001 Gweiです。 一方、L1のガス代は、約40 gweiです。 現在の価格はこちらで確認できます (opens in a new tab)

コールデータの1バイトは、それがゼロの場合は4ガス、その他の値の場合は16ガスのコストがかかります。 EVMで最も高価な操作の1つは、ストレージへの書き込みです。 L2でストレージに32バイトのワードを書き込む最大コストは22100ガスです。 現在、これは22.1 gweiです。 したがって、コールデータのゼロ値のバイトを1つでも節約できれば、ストレージに約200バイトを書き込んでも、まだ利益が出ます。

ABI

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

しかし、ABIはL1向けに設計されており、そこではコールデータの1バイトのコストが約4回の算術演算に相当しますが、L2では1バイトのコストが1000回以上の算術演算に相当します。 コールデータは次のように分割されます。

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

説明:

  • 関数セレクタ:このコントラクトには256未満の関数しかないため、1バイトで区別できます。 これらのバイトは通常ゼロ以外であるため、16ガスのコストがかかります (opens in a new tab)
  • ゼロ:20バイトのアドレスを保持するのに32バイトのワードは必要ないため、これらのバイトは常にゼロです。 ゼロを保持するバイトのコストは4ガスです(イエローペーパー (opens in a new tab)の付録G、 27ページの Gtxdatazero の値参照)。
  • 金額:このコントラクトで decimals が18 (通常値) であり、送金するトークンの最大量が1018であると仮定すると、最大量は1036になります。 25615 > 1036なので、15バイトで十分です。

L1上での160ガスの浪費は、通常は無視できます。 トランザクションには少なくとも21,000ガス (opens in a new tab)がかかるため、0.8%の追加は問題になりません。 しかし、L2では事情が異なります。 トランザクションのコストのほぼ全体が、L1への書き込みによるものです。 トランザクションのコールデータに加えて、109バイトのトランザクションヘッダー (送信先アドレス、署名など) があります。 したがって、総コストは 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コントラクトは役に立たなくなりますが、ERC-20がテストを容易にするためだけに存在する場合、作業が楽になります。

1 /**
2 * @dev 呼び出し元に試用のための1000トークンを与えます
3 */
4 function faucet() external {
5 _mint(msg.sender, 1000);
6 } // function faucet

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 * @dev トークンアドレスを指定します
3 * @param tokenAddr_ ERC-20コントラクトアドレス
4 */
5 constructor(
6 address tokenAddr_
7 ) {
8 token = OrisUselessToken(tokenAddr_);
9 } // 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ビット) ワードをメモリにロードし、目的のフィールドの一部でないバイトを削除します。 このアルゴリズムは、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)して他の値を取り除きます。 これには、値をフィールドの右側に移動させるという追加の利点があり、値自体が256somethingを掛けたものではなく、値そのものになります。

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

Solidityコントラクトへの呼び出しがどの関数シグネチャとも一致しない場合、fallback()関数 (opens in a new tab)を呼び出します (存在する場合)。 CalldataInterpreterの場合、他のexternalpublic関数がないため、_どんな_呼び出しもここに到達します。

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

コールデータの最初のバイトを読み取ります。これにより関数がわかります。 ここで関数が利用できない理由は2つあります。

  1. pureまたはviewの関数は状態を変更せず、ガス代もかかりません (オフチェーンで呼び出された場合)。 これらのガス代を削減しようとしても意味がありません。
  2. msg.sender (opens in a new tab)に依存する関数。 msg.senderの値は、呼び出し元ではなくCalldataInterpreterのアドレスになります。

残念ながら、ERC-20の仕様 (opens in a new tab)を見ると、残っている関数はtransferのみです。 これにより、残る関数はtransfer (transferFromを呼び出せるため)とfaucet (transferFromを呼び出せるため)の2つだけになります。

1
2 // コールデータの情報を使用して
3 // トークンの状態変更メソッドを呼び出します
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 (そのためのアローワンスがあると仮定します)
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 // 試用のためのトークンを取得
2 const faucetTx = {

通常使用する高レベルの関数 (例: token.faucet()) は、ABIに従っていないため、トランザクションの作成には使用できません。 代わりに、自分でトランザクションを作成してから送信する必要があります。

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// フォーセットがトークンを正しく提供することを確認
2expect(await token.balanceOf(signer.address)).to.equal(1000)

ここで残高を確認します。 view関数ではガスを節約する必要がないので、通常どおり実行します。

1// CDIにアローワンスを与える (承認はプロキシできません)
2const approveTX = await token.approve(cdi.address, 10000)
3await approveTX.wait()
4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

コールデータのインタープリタに送金できるようにアローワンスを与えます。

1// トークンを送金
2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
3const transferTx = {
4 to: cdi.address,
5 data: "0x02" + destAddr.slice(2, 42) + "0100",
6}

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

1 await (await signer.sendTransaction(transferTx)).wait()
2
3 // 256トークン少なくなっていることを確認
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // そして、送信先がそれらを受け取ったことを確認
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe
すべて表示

送信先コントラクトを制御できる場合のコスト削減

送信先コントラクトを制御できる場合、コールデータのインタープリタを信頼するため、msg.senderチェックをバイパスする関数を作成できます。 control-contractブランチで、これがどのように機能するかの例をこちらで確認できます (opens in a new tab)

コントラクトが外部トランザクションにのみ応答する場合、1つのコントラクトだけで済みます。 しかし、それでは構成可能性が損なわれます。 通常のERC-20の呼び出しに応答するコントラクトと、短いコールデータを持つトランザクションに応答する別のコントラクトを持つ方がはるかに優れています。

Token.sol

この例では、Token.solを修正できます。 これにより、プロキシだけが呼び出せる多数の関数を持つことができます。 新しい部分は次のとおりです。

1 // CalldataInterpreterアドレスを指定できる唯一のアドレス
2 address owner;
3
4 // CalldataInterpreterアドレス
5 address proxy = address(0);

ERC-20コントラクトは、承認されたプロキシのIDを知る必要があります。 しかし、まだ値がわからないため、コンストラクタでこの変数を設定することはできません。 このコントラクトは、プロキシがコンストラクタでトークンのアドレスを期待するため、最初にインスタンス化されます。

1 /**
2 * @dev ERC20コンストラクタを呼び出します。
3 */
4 constructor(
5 ) ERC20("Oris useless token-2", "OUT-2") {
6 owner = msg.sender;
7 }

作成者 (「owner」と呼ばれる) のアドレスは、プロキシを設定できる唯一のアドレスであるため、ここに保存されます。

1 /**
2 * @dev プロキシ (CalldataInterpreter) のアドレスを設定します。
3 * オーナーが1回だけ呼び出し可能
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
すべて表示

プロキシはセキュリティチェックをバイパスできるため、特権アクセスを持ちます。 プロキシを信頼できることを確認するために、ownerだけがこの関数を1回だけ呼び出せるようにします。 一度 proxy が実際の値 (ゼロではない) を持つと、その値は変更できないため、オーナーが悪意を持ったり、そのニーモニックが漏洩したりしても、安全です。

1 /**
2 * @dev 一部の関数はプロキシによってのみ呼び出し可能です。
3 */
4 modifier onlyProxy {

これはmodifier関数 (opens in a new tab)であり、他の関数の動作を変更します。

1 require(msg.sender == proxy);

まず、プロキシによって呼び出され、他の誰にも呼び出されていないことを確認します。 そうでなければ、revertします。

1 _;
2 }

もしそうなら、修正する関数を実行します。

1 /* プロキシが実際にアカウントのプロキシとして機能できるようにする関数 */
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 (アローワンスは不要)
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// アローワンスを確認するには2つの署名者が必要
4const signers = await ethers.getSigners()
5const signer = signers[0]
6const poorSigner = signers[1]

approve()transferFrom()を確認するには、2人目の署名者が必要です。 これは私たちのトークンを一切受け取らないため、poorSignerと呼びます (もちろん、ETHは持っている必要があります)。

1// トークンを送金
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// 承認と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// approve / transferFrom の組み合わせが正しく行われたことを確認
17expect(await token.balanceOf(destAddr2)).to.equal(255)
すべて表示

2つの新しい関数をテストします。 transferFromTxには、アローワンスの提供者と受領者の2つのアドレスパラメータが必要であることに注意してください。

結論

Optimism (opens in a new tab)Arbitrum (opens in a new tab)はどちらも、L1に書き込まれるコールデータのサイズ、ひいてはトランザクションのコストを削減する方法を模索しています。 しかし、汎用的なソリューションを探しているインフラプロバイダーとして、私たちの能力には限界があります。 dapp開発者であるあなたは、アプリケーション固有の知識を持っているため、汎用的なソリューションよりもはるかに優れた方法でコールデータを最適化できます。 この記事が、あなたのニーズに合った理想的なソリューションを見つけるのに役立つことを願っています。

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

最終更新: 2025年8月22日

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