コールデータを最適化するための簡潔なABI
はじめに
この記事では、オプティミスティック・ロールアップとは何か、オプティミスティック・ロールアップにおけるトランザクションコスト、および、様々なコスト構造に応じてイーサリアム・メインネット上の様々な事項をいかに最適化すべきかについて学びます。 さらに、この最適化の実装方法についても紹介します。
開示情報
筆者は、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つの要素で構成されます:
- L2上の処理コスト。通常、非常に安価です。
- 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)を見てみましょう。 コールデータは、以下のように分割されます:
セクション | 長さ | バイト | 浪費バイト | 浪費ガス | 必須バイト | 必須ガス |
---|---|---|---|---|---|---|
関数セレクタ | 4 | 0~3 | 3 | 48 | 1 | 16 |
ゼロ値 | 12 | 4~15 | 12 | 48 | 0 | 0 |
送信先アドレス | 20 | 16~35 | 0 | 0 | 20 | 320 |
金額 | 32 | 36~67 | 17 | 64 | 15 | 240 |
合計 | 68 | 160 | 576 |
説明:
- 関数セレクター: このコントラクトに含まれる関数は256未満であるため、1バイトで区別できます。 これらのバイトは通常0バイトではないので、16ガス(opens in a new tab)がかかります。
- 0バイト :これらのバイトは常にゼロです。と言うのも、20バイトのアドレスを保持するためには32バイトのワードを必要としないからです。 0バイトのコストは、4ガスです(イエローペーパー(opens in a new tab)の27ページにあるAppendix Gで、
G
txdatazero
の値について確認してください)。 - 金額:このコントラクトの
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 with3 */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: Unlicense2pragma solidity ^0.8.0;345import { OrisUselessToken } from "./Token.sol";コピー
呼び出し方法を知るには、トークン関数が必要です。
1contract CalldataInterpreter {23 OrisUselessToken public immutable token;コピー
私たちがプロキシとなるトークンのアドレスです。
12 /**3 * @dev Specify the token address4 * @param tokenAddr_ ERC-20 contract address5 */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;23 require(length < 0x21,4 "calldataVal length limit is 32 bytes");56 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>...
となります。
12 _retVal = _retVal >> (256-length*8);コピー
このフィールドに含まれるのは最も重要なlength
のバイトだけなので、右シフトクリック(opens in a new tab)で他の値を削除します。 この方法は、値をフィールドの右側に移動するという追加の利点があるので、256xを掛けた値ではなく、値そのものになります。
12 return _retVal;3 }456 fallback() external {コピー
Solidityコントラクトへの呼び出しがどの関数の署名とも一致しない場合、 fallback()
関数(opens in a new tab)を呼び出します(存在する場合)。 CalldataInterpreter
の場合、他のexternal
またはpublic
の関数がないため、すべての呼び出しがここに到達します。
1 uint _func;23 _func = calldataVal(0, 1);コピー
この関数を返すコールデータの最初の1バイトを読み取ります。 ここで関数が取得できないのには、2つの理由があります:
pure
またはview
の関数の場合。これらの関数は状態を変更しないため、ガスが発生しません(オフチェーンで呼び出す場合)。 ですから、ガス代を節約する必要がありません。msg.sender
(opens in a new tab)に依存した関数。msg.sender
の値は、呼び出し元のアドレスではなく、CalldataInterpreter
のアドレスになります。
残念ながら、ERC-20の仕様(opens in a new tab)を確認すると、残りの関数はtransfer
のみです。 つまり、呼び出し可能な関数は、transfer
(transferFrom
を呼び出す)と、faucet
(呼び出し元のアドレスにトークンを送信する)になります。
12 // Call the state changing methods of token using3 // information from the calldata45 // faucet6 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バイトのコールデータが発生します。
セクション | 長さ | バイト |
---|---|---|
関数セレクタ | 1 | 0 |
送信先アドレス | 32 | 1~32 |
金額 | 2 | 33~34 |
1 } // fallback23} // 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");23describe("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)910 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)1415 const signer = await ethers.getSigner()すべて表示コピー
まず、両方のコントラクトをデプロイします。
1 // Get tokens to play with2 const faucetTx = {
ここではABIを使用しないため、トランザクションを作成するために通常用いる高度な関数(token.faucet()
など)を使用できません。 その代わりに、トランザクションをマニュアルで作成し、送信する必要があります。
1 to: cdi.address,2 data: "0x01"
トランザクションには、次の2つのパラメータが必要です:
to
:送信先のアドレスです。 これは、コールデータのインタープリタのアドレスです。data
:送信するコールデータです。 フォーセットを呼び出す場合、データは1バイト(0x01
)です。
12 }3 await (await signer.sendTransaction(faucetTx)).wait()
すでに送信先(faucetTx.to
)を指定しており、トランザクションに対して署名を得る必要があるため、署名者のsendTransaction
メソッド(opens in a new tab)を呼び出します。
1// Check the faucet provides the tokens correctly2expect(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 tokens2const 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()23 // Check that we have 256 tokens less4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)56 // And that our destination got them7 expect (await token.balanceOf(destAddr)).to.equal(256)8 }) // it9}) // describeすべて表示
例
これらのファイルにつき、自ら実行せず、どのように動作するのか確認したい場合は、以下のリンクにアクセスしてください:
- アドレス
0x950c753c0edbde44a74d3793db738a318e9c8ce8
(opens in a new tab)に対するOrisUselessToken
(opens in a new tab)のデプロイメント。 - アドレス
0x16617fea670aefe3b9051096c0eb4aeb4b3a5f55
(opens in a new tab)に対するCalldataInterpreter
(opens in a new tab)のデプロイメント。 faucet()
(opens in a new tab)の呼び出し。OrisUselessToken.approve()
(opens in a new tab)の呼び出し。 処理がmsg.sender
に依存しているため、この呼び出しは、直接トークンコントラクトで行う必要があります。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 address2 address owner;34 // The CalldataInterpreter address5 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 owner4 */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");89 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 */23 function transferProxy(address from, address to, uint256 amount)4 public virtual onlyProxy() returns (bool)5 {6 _transfer(from, to, amount);7 return true;8 }910 function approveProxy(address from, address spender, uint256 amount)11 public virtual onlyProxy() returns (bool)12 {13 _approve(from, spender, amount);14 return true;15 }1617 function transferFromProxy(18 address spender,19 address from,20 address to,21 uint256 amount22 ) public virtual onlyProxy() returns (bool)23 {24 _spendAllowance(from, spender, amount);25 _transfer(from, to, amount);26 return true;27 }すべて表示コピー
以下は、トークンを送信する/アローワンスを承認するエンティティから直接メッセージを受信する際に通常必要となる3つの操作です。 ここでは、以下の特徴を持つプロキシバージョンを使います:
onlyProxy()
で修正されており、他のユーザーが管理権限を持たない。- 追加のパラメータとして、通常
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 }910 // approve11 if (_func == 3) {12 token.approveProxy(13 msg.sender,14 address(uint160(calldataVal(1, 20))),15 calldataVal(21, 2)16 );17 }1819 // transferFrom20 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)23// Need two signers to verify allowances4const signers = await ethers.getSigners()5const signer = signers[0]6const poorSigner = signers[1]コピー
approve()
とtransferFrom()
を確認するには、第2の署名者が必要です。 第2の署名者は、トークンを受け取らないため(もちろん、ETHを所有する必要はあります)にpoorSigner
と呼びます。
1// Transfer tokens2const 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 transferFrom2const approveTx = {3 to: cdi.address,4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",5}6await (await signer.sendTransaction(approveTx)).wait()78const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"910const 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()1516// Check the approve / transferFrom combo was done correctly17expect(await token.balanceOf(destAddr2)).to.equal(255)1819No key20Text21XPath: /pre[38]/codeすべて表示コピー
新たに追加した2つの関数をテストします。 transferFromTx
のアドレスには、アローワンスの提供元と受領者という2つパラメータが要求される点に注意してください。
実例
これらのファイルにつき、自ら実行せず、どのように動作するのか確認したい場合は、以下のリンクにアクセスしてください:
0xb47c1f550d8af70b339970c673bbdb2594011696
(opens in a new tab)のアドレスに対するOrisUselessToken-2
のデプロイメント(opens in a new tab)。0x0dccfd03e3aaba2f8c4ea4008487fd0380815892
(opens in a new tab)のアドレスに対するCalldataInterpreter
のデプロイメント(opens in a new tab)。setProxy()
の呼び出し(opens in a new tab)。faucet()
の呼び出し(opens in a new tab)。transferProxy()
の呼び出し(opens in a new tab)。approveProxy()
の呼び出し(opens in a new tab)。transferFromProxy()
の呼び出し(opens in a new tab)。 この呼び出しは、他のアドレスとは異なるアドレスからのものであることに注意してください (signer
の代わりにpoorSigner
) 。
まとめ
Optimism(opens in a new tab)とArbitrum(opens in a new tab)はどちらも、L1に書き込まれるコールデータのサイズを削減し、トランザクションコストを抑える方法を提供することを目指しています。 インフラプロバイダーが汎用性が高いソリューションを追求する一方で、デベロッパの能力には限界があります。 Dappのデベロッパーは、開発するアプリケーションについて具体的な知識を持つため、汎用性のソリューションよりも効率的にコールデータの最適化を実現できるのです。 この記事が、皆さんのニーズに合わせた理想的なソリューションを見出す上で役立つことを願っています。
最終編集者: @Shiva-Sai-ssb(opens in a new tab), 2024年6月30日