こんにちは、ブロックチェーンコースのMassunです!
今回はOpenZeppelinという非常に有名なライブラリの中のReentrancyGuardコントラクトでのガス最適化を見ていきます。
今回、見ていくレポジトリはSolidity 0.8.xを対象としたこちらのコードです。
このコントラクトの目的は、Reentrancyを防ぐことで、ReentrancyGuardmodifiierを関数に付加することでこれを実現します。関数の呼び出し時に、ロックして関数の実行終了時に、ロックを解除するという決して難しくないシンプルなコードです。しかし、ここにも工夫が施されています!
abstract contract ReentrancyGuard {
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;
constructor() {
_status = _NOT_ENTERED;
}
modifier nonReentrant() {
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
_status = _ENTERED;
_;
_status = _NOT_ENTERED;
}
}
ポイント
細かいことを言えば、constantを使う点やprivate変数として宣言することもガスやコントラクトのバイトコードのサイズなどを抑えられますが、この記事では以下の2つに着目したいと思います。
- Flag(
_status変数)はboolではなく、uint256を使う. _ENTERED,_NOT_ENTEREDのFlagを立てるためにはZero,non-Zeroではなく、non-Zero,non-Zeroのペアを使う
1.Flagはboolではなく、uint256を使う
これはこちらの私が書いた記事でも説明をしているので少し省きます。
今回の場合、Entryしたか、そうでないかの2通りしか存在しないので、boolを使いそうになりますが、今回のケースではガスの観点から見るとboolは適していません。
簡単に説明すると、「uint8やuint64...uint128やboolは必ずしもガスが安いわけではない」ということです。EVMは32bytesを一度に処理するので、boolは一度32bytesに変換される過程で余計なガスがかかってしまいます。そのため、たとえ今回のような2通りしか値を使わない場合でも、uint256を使う方がガスの観点では好ましいのです。
2.Flagを立てるために、Zero,non-Zeroではなく、non-Zero,non-Zeroを使う
ポイントの2つ目について説明します。こちらも私が書いた記事でも少し触れているので、ここではさらに踏み込んで、ガスの消費をどのくらい減らせるのかZero,non-Zeroの場合とnon-Zero,non-Zeroの場合を比較します。
Zeroを使わない理由
コードのコメントによると、
値がゼロでない場合、デプロイは少し高くなりますが、その代わりに
nonReentrantを呼び出すたびにrefundされる量は少なくなります。refundの上限は、トランザクションの総ガス量に対する割合なので、今回のようなケースでは、全額refundされる可能性を高めるために、低めに設定しておくとよいでしょう。
つまり、あえて値をZeroにリセットすることを避けることで、この部分のrefundを低くした方がいいという考えです。(refundは最大でも使用したガスの1/5です。)
以下の計算では、Ethereumを執筆時点のArrowGlacierハードフォーク実行後として計算を行います。DoS対策などでオペコードのガスやrefundの計算式は頻繁に変更されるので、いつのEthereumであるかを述べておくことは重要だからです。
SSTOREのガスが支配的なので、SSTOREにかかるガスを比較することが必要です。SSTOREのガスの計算方法は他のOpecodeと比較するとやや複雑です。
SSTOREのガス
SSTOREのガスは、大まかに次の4つに依存します
- 当該のストレージスロットに現在のトランザクションの実行コンテキストで初めてアクセスするか?
- トランザクション実行前の当該のスロットの元々の値
original_value- 当該のスロットの現在の値
current_value- 当該のスロットに
SSTOREが起こった後の新しい値value
「1」について補足すると、当該のストレージスロットに現在のトランザクションの実行コンテキストで初めてアクセス(読み書き)する場合、そのストレージスロットはcoldといい.coldなスロットにアクセスするとき、+2100gasがかかります。同じトランザクション内で、2回目以降にアクセスする場合には、そのストレージスロットはwarmであり、+100gasかかります。
より詳細な仕様や計算式は、EIP2200やここやここを読むことを強く勧めます。
使用するガスの比較
いよいよ、Zero,non-Zeroの場合とnon-Zero,non-Zeroの場合を比較します。
具体的に、Flag(_status変数)に
Zero,non-Zeroとして、0,1を使用した場合non-Zero,non-Zeroとして、1,2を使用した場合(OpenZeppelinの場合)
以上の2つの場合で考えていきます。
「1」の場合
_status = _ENTEREDにかかるガス
cold? yes, value = 1, current_value = 0, original_value = 0 より、
gas = 20k + 2100 = 22100
refund = 0
_status = _NOT_ENTEREDにかかるガス
cold? no, value = 0, current_value = 1, original_value = 0より、
gas = 100 + 100 = 200
refund = 19900
「2」の場合
_status = _ENTEREDにかかるガス
cold? yes, value = 2, current_value = 1, original_value = 1 より、
gas = 2900 + 2100 = 5000
refund = 0
_status = _NOT_ENTEREDにかかるガス
EIP2200より、元の値に戻るときにはrefundが入ります。
cold? no, value = 1, current_value = 2, original_value = 1より、
gas = 100 + 100 = 200
refund = 2800
「1」「2」の場合を表1にまとめました。
| gas | refund | gas - refund | refund / gas | |
|---|---|---|---|---|
| 方法「1」 | 22300 | 19900 | 2400 | 0.89 |
| 方法「2」 | 5200 | 2800 | 2400 | 0.53 |
表1:方法「1」と「2」のガスとrefundの比較
gas - refund列を見ると、refundが全額有効になる場合の正味のgasはどちらも同じです。しかし、「1」のやり方はrefundの最大比率をすでに大きく上回っています。現在のところ、refundは使用したガスの最大1/5までとなっています。これは、refundで返ってくるgasが少なくなる可能性が高いことを意味し、使用するgasが少なく、refundの割合が小さい「2」の方が優れているといえるでしょう。
結論
OpenZeppelinのReentrancyGuardでは、フラグ_statusにZeroを使わずに、non-Zeroを使います。non-Zero,non-Zeroはデプロイ時のガスは高くなるが、総合的にはガスを抑えられるという結論になります。