💸

【Solidity v0.8対応】EthereumでのGas代の節約Tips

2023/01/16に公開

256bitスロットを意識して変数を使う

EVMでは256bit(32byte)単位でデータを扱うため、256bitでない値は一度変換されます。仮に、uint8のデータを記録する場合、一度uint256に変換してからデータを記録するため、無駄なコストがかかってしまいます。これを防ぐため、基本的には変換が不要な値(uint256bytes32)を使うべきです。

// 良い例
uint256 a;
// 悪い例
uint128 b;

ただし、これは変数を一つとした場合の例であり、複数の変数を使用する場合には、次項のパッキングも合わせて意識する必要があります。

パッキング

Solidityはstorage変数が256bit未満の値の場合、データをパッキングして格納します。前項でも述べたように、EVMは256bit単位でデータを処理(≒256bit単位でGas代の計算を行う)するため、uint128の変数を二つ定義した場合、それらの変数は1つのスロットに格納されるため、Gas代を節約することができます。

// 良い例
uint256 a; // slot 0
uint128 b; // slot 1
uint128 c; // slot 1

ただし、ここでも注意点があり、適切な順番で変数を配置しないと、上手くデータがパッキングされません。

// 悪い例
uint128 b; // slot 0
uint256 a; // slot 1
uint128 c; // slot 2

上記のコードではuint256の変数auint128の変数bの順番を入れ替えてしまったため、256bit単位でのパッキングがされておらず、余分にGas代がかかってしまいます。

注意点: この話はstorage変数にのみ適応され、memoryやcalldata変数には適応されません。

function doSomething() public {
    // memory変数であるため、256bit*4として認識される
    uint8[4] memory d;
}

// storage変数の場合: 256bit*3として認識される
// memory変数の場合: 256bit*4として認識される
struct S {
    uint256 a;
    uint256 b;
    uint8 c;
    uint8 d;
}

参考
https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html

静的な値を適切に記録する

静的な値は定数としてコントラクト内に保持する(バイトコードとして記録する)か、変数として記録すると、値の保存と読み込みにかかるコストを大幅に削減できます。

定数と不変変数の違い

仕様

  • 定数: コンパイル時に決定している値
  • 不変変数: コンパイル時に値が決まっている必要はないが代入後の変更はできない値

コスト
定数 < 不変変数

contract Something {
    uint constant x = 20;
    
    uint immutable y;
    
    constructor(uint _y) {
        y = _y
    }
}

参考
https://docs.soliditylang.org/en/v0.6.5/contracts.html#constant-and-immutable-state-variables

例外処理には原則requireかrevertを使う

Solidityの例外処理にはassertrequirerevertの3つがあります。このうちrequirerevertは、例外になった時点で処理を中断し、残りのGas代を呼び出し元に返却しますが、assertは全てのGasを消費してしまうため、引数チェック等のほとんどの例外処理ではrequirerevertを使い、assertは内部エラーのテストと、不変条件のチェックにのみ使うべきです。

// 良い例
function requireDoSomething(uint256 calldata n) external {
    require(n < 1);
    ...
}
// 悪い例
function revertDoSomething(uint256 calldata n) external {
    assert(n < 1);
    ...
}

参考
https://docs.soliditylang.org/en/latest/control-structures.html#error-handling-assert-require-revert-and-exceptions

関数には適切な修飾子を使う

ステートを変更しない場合(読み取り)

view < pure
引数の値のみを使って計算する場合はpureを使い、ネットワーク上のデータ(コントラクト内のデータ含む)を使う場合はviewを使う。

ステートを変更する場合(書き込み)

public < external
外部から呼び出す関数はexternal、そのコントラクト内でも使う関数はpublicを使う。
publicの場合は引数をメモリに保存し、externalは保存しないため、その分Gas代を節約できる。

private < internal

参考
https://ethereum.stackexchange.com/questions/113221/what-is-the-purpose-of-unchecked-in-solidity

デフォルト値で変数を初期化しない

変数の値が割り当てられていない場合Solidityではデフォルト値(データ型に応じて0, false, 0x0など)を持つとみなされるため、デフォルト値で明示的に初期化するのは、代入コストが無駄にかかるだけです。

// 良い例
uint256 b;
// 悪い例
uint256 a = 0;

チェーン上で必要のないデータはイベントとして保存する

イベントを使用すると、storage変数に比べてはるかに安価なコストでデータを保存することができます。
ただし、スマートコントラクト(=チェーン)上ではこの値を使用することはできず、オフチェーンでのみ読み取ることができます。

この考えを応用すると、イベントで出力された値や、トランザクションデータから読み取った値を別の関数にパラメーターとして渡すことで、storage変数内に値を保存しないステートレスコントラクトを実装することもできます。
https://medium.com/@childsmaidment/stateless-smart-contracts-21830b0cd1b6

require関数のmessageは短く記述する

関数が失敗した理由をクライアントに伝えるために、require関数にはメッセージを添付することができます。しかし、このメッセージはbytecodeとして、少なくとも32byte分記録されるため、メッセージはそのサイズに収まるように記述すべきです。

// 良い例
require(balance >= amount, "Insufficient balance");
// 悪い例
require(balance >= amount, "The function could not be executed successfully because the balance of the ERC20 token is less than the specifiedamount.");

eventのパラメーターにはindexedを使用する

uint, bool, addressなどの値型のプロパティには、indexedで索引づけをすると、イベントをメモリに格納する代わりに、スタックから読み取ることにより、Gas代を節約できます。ただし、これはbytes型やstring型には当てはらないため注意してください。

// 良い例
event DoSomething(uint256 indexed, address indexed);
// 悪い例
event DoSomething(uint256, address);

参考
https://twitter.com/maurelian_/status/1488691543544320000

不要なアンダー・オーバーフローチェックをしない

Solidity v0.8以降、アンダー・オーバーフローが発生した場合、revertするという動きがデフォルトになりました。

v0.8以前のSolidityでのアンダー・オーバーフロー時の動作とその対策法

v0.8以前のSolidityでは、変数が許容できる値を超えた整数値を代入された場合(オーバーフロー)、割り当てられた値が設定されるのではなく、データ型でサポートされている最小値から巻き戻した値が設定されていました。

例えば、0~255までの数値を代入できるuint8型に256を代入した場合、値は巻き戻され、変数には1が代入されていました。また、-2を代入した場合(アンダーフロー時)には254が代入されるという仕様でした。

このような問題からEVM内で計算を行う前には、計算する値が意図通りの値かを確認する必要があり、そのために、加算、減算、乗算、除算などの主要な演算について、整数のオーバーフロー/アンダーフローのチェックをしてくれるOpenZeppelinのSafeMathというコントラクトを使うのが一般的でした。
ただし、Solidity v0.8以降はEVM自身がバイトコードを生成しながらチェックを行いエラーを発生させるようになったため、SafeMathは不要になりました。

この実装で、より安全に数値を扱えるようになりましたが、代わりにコストが高くなってしまいました。そこで、アンダー・オーバーフローのチェックが不要な場合は、その処理をuncheckedブロックで囲むことで、チェックをスキップし、Gas代を節約することができます。

例えば、配列のループ処理を記述する場合、インデックスをインクリメントする度に不要な数値チェックが行われるため、無駄なGas代がかかります。

// 悪い例
for (uint256 i = 0; i < array.length; i++) {
  doSomething(array[i]);
}

以下のコードのように、インクリメント処理をuncheckedブロックで囲み、数値チェックをスキップすることで、大幅にGas代を節約できます。

// 良い例
for (uint256 i = 0; i < array.length;) {
  doSomething(array[i]);
  unchecked{ i++; }
}

関数パラメーターにはmemoryではなくcalldataを使用する

変数をメモリにコピーしてから読み取るよりも、calldataから変数を直接読み取る方がGasコストを抑えることができますが、calldataは値の変更ができないので、その必要がある場合のみmemoryを使用するべきです。

// 良い例
function doSomething(uint256 calldata n) public {
    ...
}
// 悪い例
function doSomething(uint256 memory n) public {
    ...
}

参考
https://ethereum.stackexchange.com/questions/74442/when-should-i-use-calldata-and-when-should-i-use-memory

短い文字列はbytesとして扱う

bytes型はstringのような動的な型に比べGas代が低いため、32バイト以内の短い文字列を扱う場合は、bytes型で文字列を記録することで、Gas代を節約することができます。

// 良い例
bytes32 bytesA;
function assign(bytes32 calldata str) public {
    bytesA = str;
}
// 悪い例
string strA;
function assigng(string calldata str) public {
    strA = str;
}

大量の真偽値を扱う場合はuint型を使う

Solidityのbool型は8ビットのストレージサイズを使いますが、uint型を使えば、1ビットで真偽値を表すことができます。つまり、uint256型の変数であれば、256個の真偽値を1つのスロットにパックして記録することが可能です。また、ゼロ値の変数に値を代入するコストが、ゼロ値でない値の変数に値を代入するのに比べてコストが高いことも起因し、大幅なGas代の削減につながります。

参考
https://stackoverflow.com/questions/68213629/solidity-bool16-vs-uint16

コンパイラのoptimizerを有効にする

solc等のコンパイラではoptimizerを有効にし、runsプロパティを適切な値に設定すれば、Gasコストを抑えることができる。
runsプロパティに小さい値を指定すれば、呼び出し時のコストが上がり、コードサイズは下がる、大きい値を指定した場合は、呼び出しあたりのコストは下がるが、コードサイズが増える。
つまり、ERC20のような何度も呼び出されることが想定されるコントラクトはrunsプロパティを高い値に設定し、逆にほとんど呼び出されないコントラクトの場合は、runsプロパティを低めの値にすると良い。

ゼロ値を代入しない

EVMはゼロ値からノンゼロな値を代入する際に一番コストがかかります。

  • zero => nonzero: (NEW VALUE) cost 20000gas
  • nonzero => zero: (DELETE) refund 15000gas, cost 5000gas
  • non zero -> non zero: (CHANGE) cost 5000gas

そのため、後に再代入が予想される変数には、0ではなく0に近い値をあえて代入しておくことで、再代入時のコストを下げることができます。

// 良い例
function reserveDeposit(uint256 memory _amount) external {
    token.transferFrom(msg.sender, address(this), _amount);
    // ユーザーからトークンを受け取る前のこのコントラクトの保有量を取得する
    (uint256 localUnderlying, uint256 localShares) = _getReserves(); 
    // もし保有量が0なら、少量のトークンをコントラクトに残す
    if (localUnderlying == 0 && localShares == 0) {
       _amount -= 1;
    }
    _setReserves(localUnderlying + _amount, localShares);
}
// 悪い例
function reserveDeposit(uint256 memory _amount) external {
    token.transferFrom(msg.sender, address(this), _amount);
    // ユーザーからトークンを受け取る前のこのコントラクトの保有量を取得する
    (uint256 localUnderlying, uint256 localShares) = _getReserves(); 
    _setReserves(localUnderlying + _amount, localShares);
} 

参考
https://github.com/ethereum/go-ethereum/blob/450d771bee9cc8c90d54dce212da2986f9f9bcc1/core/vm/gas_table.go#L125-L134
https://github.com/element-fi/elf-contracts/blob/65fddc8e750e156605b2d7e01ceffd4bbcb8c978/contracts/YVaultAssetProxy.sol#L87-L118

さいごに

ここまでEVMでGas代を抑えるためのSolidityの書き方を紹介してきましたが、このほかにも、インラインアセンブリ使うことで、ビット単位で変数をスロットに詰めたり、マークルツリーを使ってデータ量を削減する等の方法もありますが、いずれも実装の難易度が高かったり、コードの可読性が下がるといったデメリットがあるため、ここでは紹介していません。機会があればまた別の記事として書ければと思います。最後まで読んでいただきありがとうございました。少しでも実装の参考になれば幸いです。

TheCreator

Discussion