こんにちは、ブロックチェーンコースに所属しているMassunです。
前回の投稿では、スマートコントラクトのProxyパターンであるTransparent Upgradable Proxyについてまとめました.
今回は、主にガスの節約のためのTipsを実際に使われているコントラクトから学びたいと思います。すべてを紹介することは難しいので、簡単なものからいくつか挙げていきます。
ストレージレイアウトに関係するTips
EVMのストレージは、1slotが32bytesのkey-valueストアです。まず、ストレージに関係するTipsを紹介しましょう。
Structやステート変数を定義するときはパッキンングを活用しろ
Solidityのドキュメントに書いてありますが、変数が32bytes未満の値は可能な場合にはパッキングされて格納されます。これを利用しない手はないです。
下のコードで例を挙げました。前者では、uint128
のb
とc
が同じスロットに入りますが、後者では順番が悪いために、c
が次のスロットに格納されてしまいます。
// Good
uint256 a; // slot 0
uint128 b; // slot 1
uint128 c; // slot 1
// Bad
uint128 b; // slot 0
uint256 a; // slot 1
uint128 c; // slot 2
例えば、CryptoKittiesのKittyのデータを表す構造体は次のようにうまくパッキングされるように要素が並べられています。
これは、Element FinanceのYVaultAssetProxy#L21-L24でも見られます。
struct Kitty {
uint256 genes;
uint64 birthTime;
uint64 cooldownEndBlock;
uint32 matronId;
uint32 sireId;
uint32 siringWithId;
uint16 cooldownIndex;
uint16 generation;
}
しかし、この話はストレージのレイアウトに適用されますが、メモリーのレイアウトには適用されないようです! 下のStructは32*3=96bytesではなく、128bytesを使います。
struct Data {
uint256 a;
uint256 b;
uint8 c;
uint8 d;
}
型に関係するガスの節約
結局、uint256の方がガスがかからないことが多い
これもEVMの仕様に関連します。直感的にはuint8
などの方があまりガスが掛からなそうですが、EVMは32bytesを一度に処理するので、uint8
はuint256
に変換され、その際追加のガスがかかります。そのため、パッキングなどができないときは、unit256
を使った方がいいです。同じことはbool
などにも言えます。
OpenZeppelinのReentrancyGuardでは、エントリーのフラグにbool
ではなく、unit256
を使っています。 bool
ではなくuint256
を使うことで、余計なSLOAD
を回避できます。
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;
modifier nonReentrant() {
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
_status = _ENTERED;
_;
_status = _NOT_ENTERED;
}
さらに、特筆するべき点は、_NOT_ENTERED
と_ENTERED
がZero
とnon-Zero
ではなく、non-Zero
とnon-Zero
の値であるところです。ここで詳しく説明しています。
string
よりもbytes32
を使った方がいい
bytes32
を使った方がstring
のような動的な型よりもガスが安いです。これは、Reflexer Financeなどで見られます。
function modifyParameters(
bytes32 collateralType,
bytes32 parameter,
uint256 data
) external isAuthorized {
require(contractEnabled == 1, "SAFEEngine/contract-not-enabled");
if (parameter == "safetyPrice")
// 省略
}
低レベルのメソッドに関係するガスの節約
オペコードEXTCODESIZE
をバイパスする
アドレス型のメンバ関数である、call
,delegatecall
,staticcall
はEVMの設計上、呼び出し先のアドレスが存在していない場合でも、呼び出しの成功を表すtrue
を返します。そのため、あえてcall
やstaticcall
のような低レベルな関数を使う際には、あらかじめ呼び出し先のアドレスが存在するかどうかをチェックする必要があります。
しかし、下記のUniswapV3のコードではあえてstaticcall
を利用してEXTCODESIZE
のチェックを回避することで、ガスを節約しています。プールのコントラクトが作られる際に、トークンのコントラクトの存在がチェックされているので、セキュリティ的にも問題ないのでしょう。(コントラクトがselfdestuct
した場合も大丈夫?)
/// @dev Get the pool's balance of token0
/// @dev This function is gas optimized
/// to avoid a redundant extcodesize check
/// in addition to the returndatasize check
function balance0() private view returns (uint256) {
(
bool success,
bytes memory data
) = token0.staticcall(
abi.encodeWithSelector(
IERC20Minimal.balanceOf.selector,
address(this)
));
require(success && data.length >= 32);
return abi.decode(data, (uint256));
}
オペコードSSTORE
に関係するガスの節約
トークンの残高をZeroにしない(後で、非Zeroになることが予想されるなら)
これは、Element Financeなどで見られる工夫です。
以下にYVaultAssetProxyコントラクトの関数を抜粋しました。この関数を呼ぶことで、このコントラクトはtoken
を受け取り、このコントラクトのトークン保有量を_setReserves()
で記録します。ただし、この関数を呼ぶ前にこのコントラクトのトークン保有量が0
であった場合、実際の保有量から1
を引いて記録します。こうすることで、保有しているトークンが別のコントラクトへ送られた時に、残高に非ゼロな少量を残すことで、かかるガスを抑えます。
/// @notice This function allows a user to deposit to the reserve
/// ...略...
/// @param _amount The amount of underlying to deposit
function reserveDeposit(uint256 _amount) external {
// Transfer from user
token.transferFrom(msg.sender, address(this), _amount);
// ユーザーからトークンを受け取る前のこのコントラクトの保有量を取得
(uint256 localUnderlying, uint256 localShares) = _getReserves();
...
// もし、保有量が0なら、可能な限り少量をこのコントラクトに残すために
// `_amount`から`1`を引き、のちの送金時に残高をゼロにしないようにする。
if (localUnderlying == 0 && localShares == 0) {
_amount -= 1;
}
// Set the reserves that this contract has more underlying
_setReserves(localUnderlying + _amount, localShares);
...
}
では、どれほど安くなるのか比較してみましょう。
ここで、かかるガスはSSTORE
が支配的なので以下の2つの場合を考えました。(計算を簡略化するため、ストレージスロットは、全てcold
とします。)
-
ユーザーがデポジットして残高が
Zero
からnon-Zero
へ値をセットされた後、送金で残高がnon-Zero
からZero
にセットされる場合(工夫しない場合) -
ユーザーがデポジットして残高が
non-Zero
からnon-Zero
へ値をセットされた後、non-Zero
からnon-Zero
にセットされる場合(少量を残す工夫をした場合)
GitHub - wolflo/evm-opcodes#A7 SSTOREを参考にして計算しました。
「1」の場合 refund分を引いて低く見積もると
22100 + (5000 - 4800) = 22300 gas
かかります。
一方で、「2」の場合はrefundは発生せず、
5000 + 5000 = 10000 gas
かかります。
つまり、あるストレージスロットが後でnon-Zero
の値になることが予想されるなら、Zero
にリセットしないでnon-Zero
のままにしておく方がいいのです
まとめ
今回は、実際にデプロイされて使用されているコントラクトから、主にガスの節約に関する工夫を知りました。
EVMの仕様について理解すること、SSTORE
などのオペコードのガスの計算方法をよく知ることが初めの一歩だと思いました。