こんにちは、ブロックチェーンコースのMassunです!
今回はOpenZeppelinという非常に有名なライブラリの中のReentrancyGuardコントラクトでのガス最適化を見ていきます。
今回、見ていくレポジトリはSolidity 0.8.xを対象としたこちらのコードです。
このコントラクトの目的は、Reentrancyを防ぐことで、ReentrancyGuard
modifiierを関数に付加することでこれを実現します。関数の呼び出し時に、ロックして関数の実行終了時に、ロックを解除するという決して難しくないシンプルなコードです。しかし、ここにも工夫が施されています!
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
なスロットにアクセスするとき、+2100
gasがかかります。同じトランザクション内で、2回目以降にアクセスする場合には、そのストレージスロットはwarm
であり、+100
gasかかります。
より詳細な仕様や計算式は、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
はデプロイ時のガスは高くなるが、総合的にはガスを抑えられるという結論になります。