[Solidity]実例から学ぶ、ガス最適化のTips

こんにちは、ブロックチェーンコースに所属しているMassunです。
前回の投稿では、スマートコントラクトのProxyパターンであるTransparent Upgradable Proxyについてまとめました.

今回は、主にガスの節約のためのTipsを実際に使われているコントラクトから学びたいと思います。すべてを紹介することは難しいので、簡単なものからいくつか挙げていきます。

ストレージレイアウトに関係するTips

EVMのストレージは、1slotが32bytesのkey-valueストアです。まず、ストレージに関係するTipsを紹介しましょう。

Structやステート変数を定義するときはパッキンングを活用しろ

Solidityのドキュメントに書いてありますが、変数が32bytes未満の値は可能な場合にはパッキングされて格納されます。これを利用しない手はないです。

下のコードで例を挙げました。前者では、uint128bcが同じスロットに入りますが、後者では順番が悪いために、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を一度に処理するので、uint8uint256に変換され、その際追加のガスがかかります。そのため、パッキングなどができないときは、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_ENTEREDZeronon-Zeroではなく、non-Zeronon-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を返します。そのため、あえてcallstaticcallのような低レベルな関数を使う際には、あらかじめ呼び出し先のアドレスが存在するかどうかをチェックする必要があります。

しかし、下記の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とします。)

  1. ユーザーがデポジットして残高がZeroからnon-Zeroへ値をセットされた後、送金で残高がnon-ZeroからZeroにセットされる場合(工夫しない場合)

  2. ユーザーがデポジットして残高が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などのオペコードのガスの計算方法をよく知ることが初めの一歩だと思いました。

参考

Evm.codes
GitHub - wolflo/evm-opcodes#A7 SSTORE

Solidity gas optimization tips - Mudit Gupta’s Blog
Tips for optimizing gas usage in solidity | Reduce transaction costs in ethereum by optimizing your solidity code to use less gas | Blog by Mudit Gupta