🙆

Ethereumのコントラクトを軽量化する

2021/12/16に公開

Ethereumのコントラクトのサイズは24.576KB以下という制限がある。これはDoS攻撃を避けるために2016年のEIP-170で追加されたルールだ。

巷に転がっているような簡単なコードの場合はこのサイズ制限に引っかかることはないと思うが、ある程度の大きさのdappを作ろうとすると割と簡単に制限に達する。自分の場合はEthereumでフルオンチェーンのTwitterっぽいものを作ろうとしていたときにこの制限にぶち当たった。

この制限をなんとか回避するためにコントラクトの軽量化の技を色々と調べたのでまとめてみる。

計測

軽量化の前にまずは計測できないと改善はできない。自分はコントラクトのサイズを測るのにhardhat-contract-sizerを使った。これをインストールしてコマンドを叩くと下記のようにコントラクトのサイズを表示してくれる。ご丁寧にサイズオーバーしてるでとWarningを出してくれるので問題が発生したらすぐ気付ける。

 $ npx hardhat compile && yarn hardhat size-contracts
  ·······················|··············
  |  TwitterV1           ·     24.962  │
  ·----------------------|-------------·
 Warning: 1 contracts exceed the size limit for mainnet deployment.

テスト

リファクタリングをする前に必ず最低限のテストが書いてあるか確認した方が良い。リファクタリングによって軽量化は出来たものの、振る舞いそのものが意図しない動きをするようになっては元も子もないので。

リファクタリング

準備ができたら実際にリファクタリングしていく。基本的には、

    1. 改善を入れる
    1. npx hardhat compile && yarn hardhat size-contractsを叩いて計測
    1. テストを走らせる

を繰り返す事になる。2),3)については上で書いたとおりなので、残りの1)の改善について書いていく。

コントラクトを分割する

コントラクトが大きいなら分割できれば軽量化できる。同じコントラクトである必要がないのであれば別にする。

libraryにする

処理をまとめてlibraryとして外出しできないかを検討する。internalで定義するとコンパイル時にインライン化されてしまい意味ないのでpublicで定義する。ちなみに簡単なfor文などであればlibrary化すると逆にオーバーヘッドがデカくて無駄にサイズが大きくなってしまうのでまぁまぁ複雑な外出しできる処理をlibraryにすると良さそうだった。

不要なライブラリを使うのをやめる

例えばNFTのコントラクトを実装したい時に何の意図もなしにERC721Enumerableを使っていたりする場合があるが実はtotalSupplyしか使ってないなど不要なことがある。idをincrementするだけで良いのであれば単純にカウンタを実装するだけで済む。そうすればERC721Enumerableの代わりにERC721を使うだけで良くなりサイズが結構減らせる。

DELEGATECALLを使う

DELEGATECALLを使うと外部のコントラクトの関数を実行できる。これを利用するとコントラクト内の関数を外出しできるのでコントラクトサイズが減る。ただし安易に使うとどのコントラクトでどの状態を弄っているのかわからなくなってきたり複雑性が増すので多用しないほうが良さそう。

無駄な関数を消す

コメントや変数名を減らしても意味はないがfunctionを減らせるとインパクトがでかい。可読性目的で関数に切り出していたりする処理はインライン化してしまった方が良い場合が多い。

無駄な変数の宣言を消す

無駄な宣言は極力減らす。例えば変数の初期化はいらない場合が多い。どの型にも初期値があるのでnullになったりしないから初期値で良ければそのまま使った方がいい。

Hoge hoge = new Hoge(); // これは無駄なことが多い
Hoge hoge; // これで十分

bool flag = false; // これは無駄
bool flag; // これで十分
for (uint i = 0; i < length; i++) {
  if (i % 17 == 0) {
    flag = true;
  }
}
return flag;

requireのコメントを減らす

require(condition, error_message)のerror_messageの部分を短くするか必要でないなら消す。これだけでも場合によっては数十バイト減ったりする。

引数のstruct渡しをやめる

// 無駄なstruct渡し。isHoldedしか使ってない。
function hoge(Token _token) returns (bool) {
  return _token.isHolded;
}
hoge(token);

// これで十分
function hoge(bool _isHolded) returns (bool) {
  return _isHolded;
}
hoge(token.isHolded);

publicである必要がないならprivateやinternalにする

privateやinternalの関数の方がpublicよりエコ。

データ型の工夫

stringよりbyte、boolよりuint8の方が軽い場合が多い。ちょっとの工夫だがサイズギリギリの場合は効く。

デプロイ

デプロイするときにsolcのoptimizerを有効にするだけで数KB単位で減らせる。hardhat.config.jsの場合は下記のような記述を追加するだけ.runsはコントラクトで何回ほどfunctionが呼ばれるかを意味する。小さい値を指定すると呼び出しのgas代は上がりコードサイズは減る、大きい値を指定すると呼び出しあたりのgas代は下がるがコードサイズは増える。トレードオフ。

{
  solidity: {
    version: "0.8.4",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200,
      },
    },
  }
}

余談

他にもEIP-2535: Diamonds, Multi-Facet Proxy
というDelegateCallを使ったProxyパターンの進化版みたいなやつもあるとのこと。自分は使ったことはないがStorageと振る舞いを分割して呼び出せるようにしてるっぽい。この解説が読みやすかった。
https://zenn.dev/hkiridera/articles/8fbe39a4e859b7

あとは実際に自分がフルオンチェーンのTwitterライクなdappを書いた時のコード。runs:200の設定で22.5KB。元は26KBとか超えてたのでだいぶ減らせたと思う。参考までに。
https://github.com/YuheiNakasaka/twitter-eth

参考リンク

https://soliditydeveloper.com/max-contract-size
https://blog.polymath.network/solidity-tips-and-tricks-to-save-gas-and-reduce-bytecode-size-c44580b218e6

その他

Discussion