コールデータ最適化のための短いABI
はじめに
この記事では、オプティミスティック・ロールアップ、そこでのトランザクションのコスト、そしてその異なるコスト構造がイーサリアム・メインネットとは異なる最適化をどのように要求するかについて学びます。 また、この最適化を実装する方法についても学びます。
情報開示
私はオプティミズム (opens in a new tab)のフルタイム従業員であるため、この記事の例はオプティミズム上で実行されます。 しかし、ここで説明する手法は他のロールアップでも同様に機能するはずです。
用語
ロールアップについて議論する際、「レイヤー1 (L1)」という用語は、本番のイーサリアム・ネットワークであるメインネットを指すために使用されます。 「レイヤー2 (L2)」という用語は、セキュリティをL1に依存しつつ、処理の大部分をオフチェーンで行うロールアップやその他のシステムを指すために使用されます。
L2トランザクションのコストをさらに削減するにはどうすればよいか?
オプティミスティック・ロールアップは、誰でも履歴を確認して現在の状態が正しいことを検証できるように、すべての過去のトランザクションの記録を保存する必要があります。 イーサリアム・メインネットにデータを取り込む最も安価な方法は、それをコールデータとして書き込むことです。 この解決策は、オプティミズム (opens in a new tab)とアービトラム (opens in a new tab)の両方で採用されています。
L2トランザクションのコスト
L2トランザクションのコストは、次の2つの要素で構成されています。
- L2の処理(通常は非常に安価)
- L1のストレージ(メインネットのガスコストに連動)
この記事を書いている時点では、オプティミズムでのL2ガスのコストは0.001Gweiです。 一方、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は、コールデータの1バイトが算術演算4回分とほぼ同じコストであるL1向けに設計されたものであり、コールデータの1バイトが算術演算1000回分以上のコストとなるL2向けではありません。 コールデータは次のように分割されます。
| セクション | 長さ | バイト | 無駄なバイト | 無駄なガス | 必要なバイト | 必要なガス |
|---|---|---|---|---|---|---|
| 関数セレクタ | 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バイトで区別できます。 これらのバイトは通常ゼロではないため、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が存在する場合は便利です。
/**
* @dev 呼び出し元に遊ぶための1000トークンを与えます
*/
function faucet() external {
_mint(msg.sender, 1000);
} // function faucet
CalldataInterpreter.sol
これは、トランザクションが短いコールデータで呼び出すことを想定しているコントラクトです (opens in a new tab)。 1行ずつ見ていきましょう。
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
import { OrisUselessToken } from "./Token.sol";
呼び出し方を知るために、トークン関数が必要です。
コントラクト CalldataInterpreter {
OrisUselessToken public immutable token;
プロキシとして機能するトークンのアドレスです。
/**
* @dev トークンのアドレスを指定します
* @param tokenAddr_ ERC-20コントラクトのアドレス
*/
コンストラクタ(
address tokenAddr_
) {
token = OrisUselessToken(tokenAddr_);
} // constructor
トークンアドレスは、指定する必要がある唯一のパラメータです。
function calldataVal(uint startByte, uint length)
private pure returns (uint) {
コールデータから値を読み取ります。
uint _retVal;
require(length < 0x21,
"calldataVal length limit is 32 bytes");
require(length + startByte <= msg.data.length,
"calldataVal trying to read beyond calldatasize");
1つの32バイト(256ビット)ワードをメモリにロードし、目的のフィールドの一部ではないバイトを削除します。 このアルゴリズムは32バイトより長い値には機能せず、もちろんコールデータの末尾を超えて読み取ることはできません。 L1ではガスを節約するためにこれらのテストをスキップする必要があるかもしれませんが、L2ではガスが非常に安価であるため、考えられるあらゆる健全性チェックを有効にすることができます。
assembly {
_retVal := calldataload(startByte)
}
呼び出しから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>...)です。
_retVal = _retVal >> (256-length*8);
最上位のlengthバイトのみがフィールドの一部であるため、右シフト (opens in a new tab)して他の値を取り除きます。
これには、値をフィールドの右側に移動させるという追加の利点があるため、値に256何かを掛けたものではなく、値そのものになります。
return _retVal;
}
fallback() external {
Solidityコントラクトへの呼び出しがどの関数シグネチャとも一致しない場合、fallback()関数 (opens in a new tab)が呼び出されます(存在すると仮定して)。
CalldataInterpreterの場合、他のexternalやpublic関数がないため、_すべての_呼び出しがここに到達します。
uint _func;
_func = calldataVal(0, 1);
コールデータの最初のバイトを読み取ります。これにより、関数がわかります。 ここで関数が利用できない理由は2つあります。
pureまたはviewである関数は、状態を変更せず、(オフチェーンで呼び出された場合)ガスを消費しません。 それらのガスコストを削減しようとすることは無意味です。msg.sender(opens in a new tab)に依存する関数。msg.senderの値は、呼び出し元ではなくCalldataInterpreterのアドレスになります。
残念ながら、ERC-20の仕様を見ると (opens in a new tab)、残る関数はtransferの1つだけです。
これにより、残る関数は2つだけになります。transfer(transferFromを呼び出せるため)とfaucet(呼び出し元にトークンを送金し直すことができるため)です。
// 以下を使用してトークンの状態を変更するメソッドを呼び出します
// コールデータからの情報
// faucet
if (_func == 1) {
パラメータを持たないfaucet()への呼び出し。
token.faucet();
token.transfer(msg.sender,
token.balanceOf(address(this)));
}
token.faucet()を呼び出した後、トークンを取得します。しかし、プロキシ・コントラクトとして、私たちはトークンを必要としません。
私たちを呼び出したEOA(外部所有アカウント)またはコントラクトが必要としています。
そのため、すべてのトークンを呼び出し元に送金します。
// 送金(そのためのアローワンスがあると仮定します)
if (_func == 2) {
トークンの送金には、宛先アドレスと金額の2つのパラメータが必要です。
token.transferFrom(
msg.sender,
呼び出し元が所有するトークンのみを送金できるようにします。
address(uint160(calldataVal(1, 20))),
宛先アドレスはバイト#1から始まります(バイト#0は関数です)。 アドレスとして、長さは20バイトです。
calldataVal(21, 2)
この特定のコントラクトでは、誰かが送金したいと思うトークンの最大数が2バイト(65536未満)に収まると仮定します。
);
}
全体として、送金には35バイトのコールデータが必要です。
| セクション | 長さ | バイト |
|---|---|---|
| 関数セレクタ | 1 | 0 |
| 宛先アドレス | 32 | 1-32 |
| 金額 | 2 | 33-34 |
} // fallback
} // contract CalldataInterpreter
test.js
このJavaScriptの単体テスト (opens in a new tab)は、このメカニズムの使用方法(および正しく機能することの検証方法)を示しています。 chai (opens in a new tab)とethers (opens in a new tab)を理解していると仮定し、コントラクトに特に関連する部分のみを説明します。
const { expect } = require("chai");
describe("CalldataInterpreter", function () {
it("Should let us use tokens", async function () {
const Token = await ethers.getContractFactory("OrisUselessToken")
const token = await Token.deploy()
await token.deployed()
console.log("Token addr:", token.address)
const Cdi = await ethers.getContractFactory("CalldataInterpreter")
const cdi = await Cdi.deploy(token.address)
await cdi.deployed()
console.log("CalldataInterpreter addr:", cdi.address)
const signer = await ethers.getSigner()
両方のコントラクトをデプロイすることから始めます。
// 遊ぶためのトークンを取得します
const faucetTx = {
ABIに従っていないため、トランザクションを作成するために通常使用する高レベルの関数(token.faucet()など)を使用することはできません。
代わりに、自分でトランザクションを構築して送信する必要があります。
to: cdi.address,
data: "0x01"
トランザクションに提供する必要があるパラメータは2つあります。
to、宛先アドレス。 これはコールデータ・インタープリター・コントラクトです。data、送信するコールデータ。 フォーセット呼び出しの場合、データは1バイトの0x01です。
}
await (await signer.sendTransaction(faucetTx)).wait()
すでに宛先(faucetTx.to)を指定しており、トランザクションに署名する必要があるため、署名者のsendTransactionメソッド (opens in a new tab)を呼び出します。
// faucetがトークンを正しく提供するか確認します
expect(await token.balanceOf(signer.address)).to.equal(1000)
ここで残高を検証します。
view関数でガスを節約する必要はないため、通常通り実行します。
// CDIにアローワンスを与えます(承認はプロキシできません)
const approveTX = await token.approve(cdi.address, 10000)
await approveTX.wait()
expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)
送金できるように、コールデータ・インタープリターにアローワンスを与えます。
// トークンを送金します
const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
const transferTx = {
to: cdi.address,
data: "0x02" + destAddr.slice(2, 42) + "0100",
}
送金トランザクションを作成します。最初のバイトは「0x02」で、その後に宛先アドレス、最後に金額(0x0100、10進数で256)が続きます。
await (await signer.sendTransaction(transferTx)).wait()
// トークンが256個減っていることを確認します
expect (await token.balanceOf(signer.address)).to.equal(1000-256)
// そして宛先がそれらを受け取ったことを確認します
expect (await token.balanceOf(destAddr)).to.equal(256)
}) // it
}) // describe
宛先コントラクトを制御できる場合のコスト削減
宛先コントラクトを制御できる場合は、コールデータ・インタープリターを信頼しているため、msg.senderのチェックをバイパスする関数を作成できます。
これがどのように機能するかの例は、こちらのcontrol-contractブランチで確認できます (opens in a new tab)。
コントラクトが外部トランザクションにのみ応答する場合、コントラクトを1つ持つだけで済みます。 しかし、それではコンポーザビリティが損なわれます。 通常のERC-20呼び出しに応答するコントラクトと、短いコールデータを持つトランザクションに応答する別のコントラクトを持つ方がはるかに優れています。
Token.sol
この例では、Token.solを変更できます。
これにより、プロキシのみが呼び出すことができるいくつかの関数を持つことができます。
新しい部分は次のとおりです。
// CalldataInterpreterのアドレスを指定できる唯一のアドレス
address owner;
// CalldataInterpreterのアドレス
address proxy = address(0);
ERC-20コントラクトは、承認されたプロキシのIDを知る必要があります。 しかし、まだ値がわからないため、コンストラクタでこの変数を設定することはできません。 プロキシはコンストラクタでトークンのアドレスを期待するため、このコントラクトが最初にインスタンス化されます。
/**
* @dev ERC-20のコンストラクタを呼び出します。
*/
constructor(
) ERC20("Oris useless token-2", "OUT-2") {
owner = msg.sender;
}
作成者のアドレス(ownerと呼ばれる)は、プロキシを設定できる唯一のアドレスであるため、ここに保存されます。
/**
* @dev プロキシ(CalldataInterpreter)のアドレスを設定します。
* オーナーによって1回だけ呼び出すことができます
*/
function setProxy(address _proxy) external {
require(msg.sender == owner, "Can only be called by owner");
require(proxy == address(0), "Proxy is already set");
proxy = _proxy;
} // function setProxy
プロキシはセキュリティチェックをバイパスできるため、特権アクセスを持ちます。
プロキシを信頼できるようにするために、ownerのみがこの関数を呼び出せるようにし、しかも1回だけにします。
proxyが実際の値(ゼロではない)を持つと、その値は変更できないため、所有者が悪意を持ったり、ニーモニックが漏洩したりしても、安全です。
/**
* @dev 一部の関数はプロキシによってのみ呼び出すことができます。
*/
modifier onlyProxy {
これはmodifier関数 (opens in a new tab)であり、他の関数の動作を変更します。
require(msg.sender == proxy);
まず、プロキシから呼び出されたこと、そして他の誰からも呼び出されていないことを検証します。
そうでない場合は、revertします。
_;
}
そうであれば、変更する関数を実行します。
/* プロキシが実際にアカウントのプロキシとして機能できるようにする関数 */
function transferProxy(address from, address to, uint256 amount)
public virtual onlyProxy() returns (bool)
{
_transfer(from, to, amount);
return true;
}
function approveProxy(address from, address spender, uint256 amount)
public virtual onlyProxy() returns (bool)
{
_approve(from, spender, amount);
return true;
}
function transferFromProxy(
address spender,
address from,
address to,
uint256 amount
) public virtual onlyProxy() returns (bool)
{
_spendAllowance(from, spender, amount);
_transfer(from, to, amount);
return true;
}
これらは、通常、トークンを送金したりアローワンスを承認したりするエンティティから直接メッセージが来ることを必要とする3つの操作です。 ここでは、これらの操作のプロキシバージョンを用意しています。これは次のことを行います。
onlyProxy()によって変更され、他の誰もそれらを制御できないようにします。- 通常は
msg.senderとなるアドレスを、追加のパラメータとして取得します。
CalldataInterpreter.sol
コールデータ・インタープリターは、プロキシされた関数がmsg.senderパラメータを受け取り、transferのアローワンスが不要であることを除いて、上記のものとほぼ同じです。
// 送金(アローワンスは不要です)
if (_func == 2) {
token.transferProxy(
msg.sender,
address(uint160(calldataVal(1, 20))),
calldataVal(21, 2)
);
}
// approve
if (_func == 3) {
token.approveProxy(
msg.sender,
address(uint160(calldataVal(1, 20))),
calldataVal(21, 2)
);
}
// transferFrom
if (_func == 4) {
token.transferFromProxy(
msg.sender,
address(uint160(calldataVal( 1, 20))),
address(uint160(calldataVal(21, 20))),
calldataVal(41, 2)
);
}
Test.js
以前のテストコードと今回のテストコードの間には、いくつかの変更点があります。
const Cdi = await ethers.getContractFactory("CalldataInterpreter")
const cdi = await Cdi.deploy(token.address)
await cdi.deployed()
await token.setProxy(cdi.address)
どのプロキシを信頼するかをERC-20コントラクトに伝える必要があります。
console.log("CalldataInterpreter addr:", cdi.address)
// アローワンスを検証するために2つの署名者が必要です
const signers = await ethers.getSigners()
const signer = signers[0]
const poorSigner = signers[1]
approve()とtransferFrom()をチェックするには、2番目の署名者が必要です。
トークンを一切受け取らないため、これをpoorSignerと呼びます(もちろん、ETHを持っている必要はあります)。
// トークンを送金します
const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
const transferTx = {
to: cdi.address,
data: "0x02" + destAddr.slice(2, 42) + "0100",
}
await (await signer.sendTransaction(transferTx)).wait()
ERC-20コントラクトはプロキシ(cdi)を信頼しているため、送金を中継するためのアローワンスは必要ありません。
// 承認とtransferFrom
const approveTx = {
to: cdi.address,
data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",
}
await (await signer.sendTransaction(approveTx)).wait()
const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"
const transferFromTx = {
to: cdi.address,
data: "0x04" + signer.address.slice(2, 42) + destAddr2.slice(2, 42) + "00FF",
}
await (await poorSigner.sendTransaction(transferFromTx)).wait()
// approve / transferFrom の組み合わせが正しく行われたか確認します
expect(await token.balanceOf(destAddr2)).to.equal(255)
2つの新しい関数をテストします。
transferFromTxには、アローワンスの付与者と受信者の2つのアドレスパラメータが必要であることに注意してください。
おわりに
オプティミズム (opens in a new tab)とアービトラム (opens in a new tab)はどちらも、L1に書き込まれるコールデータのサイズを縮小し、それによってトランザクションのコストを削減する方法を模索しています。 しかし、汎用的な解決策を模索するインフラストラクチャ・プロバイダーとして、私たちの能力には限界があります。 dapp開発者であるあなたは、アプリケーション固有の知識を持っているため、私たちが汎用的な解決策で行うよりもはるかにうまくコールデータを最適化できます。 この記事が、あなたのニーズに合った理想的な解決策を見つけるのに役立つことを願っています。
私の他の記事はこちらをご覧ください (opens in a new tab)。
ページの最終更新: 2026年4月3日