Have you heard about this SSTORE quirk, anon?

tl;dr SSTORE is one of the most expensive EVM opcodes. A savvy developer always optimizes their smart contract to use SSTORE in the most efficient manner. However, there’s a catch buried in EIPs that nobody talks about. Or at least, I haven’t encountered any information about it until today.

tl;dr, Read the Conclusion.

Background

There are a ton of resources out there that can teach you how to reduce and optimize the gas consumption of your smart contract. Recently, I went through some of them to improve the smart contract I am working on. Like everyone, I was especially interested in storage optimization which can have a substantial impact on gas costs. Minimizing unnecessary storage modifications and utilizing packed data structures that optimize storage usage can help reduce gas consumption significantly. How surprised I was when I looked at the hardhat-tracer dump of SLOADs and SSTOREs to find out that sometimes their gas cost might not be as one would expect after going through the resources. A direct deep dive into relevant EIPs was needed.

EIP-2929

EIP-2929 was introduced in Berlin hard fork and changed the rules regarding gas cost calculation of, amongst other opcodes, SLOAD and SSTORE. Before Berlin, the cost of SLOAD was simple: it was always cost of 800 gas. SSTORE however, has always been the most complex opcode in terms of gas because its cost depends on things like the original value of the storage slot, current value, new value, and whether it was previously accessed. The rules regarding SSTORE pre-Berlin do not matter in the context of this post, what’s important here is how EIP-2929 changed them. All the resources I found that pertain to SLOAD/SSTORE workings post EIP-2929 explain its specification as follows or very similarly:

source: https://hackmd.io/@fvictorio/gas-costs-after-berlin


From the gas optimization point of view, the most important seems to be the fact that if the value was previously modified during the same transaction, all the subsequent SSTOREs cost 100. It means that if I have a storage variable and I want to change its value multiple times in one transaction, the first write should be the most expensive and all the subsequent ones should cost pennies.

Unintuitive behaviour

Let’s use this simple example to explain the problem. Say you need to use a reentrancy guard in your smart contract. For that, you inherit from ReentrancyGuard from Open-Zeppelin. The contract is as follows:


}// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.9.0) (security/ReentrancyGuard.sol)

pragma solidity ^0.8.20;
/**
 * @dev Contract module that helps prevent reentrant calls to a function.
 *
 * Inheriting from `ReentrancyGuard` will make the {nonReentrant} modifier
 * available, which can be applied to functions to make sure there are no nested
 * (reentrant) calls to them.
 *
 * Note that because there is a single `nonReentrant` guard, functions marked as
 * `nonReentrant` may not call one another. This can be worked around by making
 * those functions `private`, and then adding `external` `nonReentrant` entry
 * points to them.
 *
 * TIP: If you would like to learn more about reentrancy and alternative ways
 * to protect against it, check out our blog post
 * https://blog.openzeppelin.com/reentrancy-after-istanbul/[Reentrancy After Istanbul].
 */
abstract contract ReentrancyGuard {
    // Booleans are more expensive than uint256 or any type that takes up a full
    // word because each write operation emits an extra SLOAD to first read the
    // slot's contents, replace the bits taken up by the boolean, and then write
    // back. This is the compiler's defense against contract upgrades and
    // pointer aliasing, and it cannot be disabled.
    // The values being non-zero value makes deployment a bit more expensive,
    // but in exchange the refund on every call to nonReentrant will be lower in
    // amount. Since refunds are capped to a percentage of the total
    // transaction's gas, it is best to keep them low in cases like this one, to
    // increase the likelihood of the full refund coming into effect.
    uint256 private constant NOT_ENTERED = 1;
    uint256 private constant ENTERED = 2;
    uint256 private _status;
    /**
     * @dev Unauthorized reentrant call.
     */
    error ReentrancyGuardReentrantCall();
    constructor() {
        _status = NOT_ENTERED;
    }
    /**
     * @dev Prevents a contract from calling itself, directly or indirectly.
     * Calling a `nonReentrant` function from another `nonReentrant`
     * function is not supported. It is possible to prevent this from happening
     * by making the `nonReentrant` function external, and making it call a
     * `private` function that does the actual work.
     */
    modifier nonReentrant() {
        _nonReentrantBefore();
        _;
        _nonReentrantAfter();
    }
    function _nonReentrantBefore() private {
        // On the first call to nonReentrant, _status will be NOT_ENTERED
        if (_status == ENTERED) {
            revert ReentrancyGuardReentrantCall();
        }
        // Any calls to nonReentrant after this point will fail
        _status = ENTERED;
    }
    function _nonReentrantAfter() private {
        // By storing the original value once again, a refund is triggered (see
        // https://eips.ethereum.org/EIPS/eip-2200)
        _status = NOT_ENTERED;
    }
    /**
     * @dev Returns true if the reentrancy guard is currently set to "entered", which indicates there is a
     * `nonReentrant` function in the call stack.
     */
    function _reentrancyGuardEntered() internal view returns (bool) {
        return _status == ENTERED;
    }
}

First of all, the contract doesn’t use a boolean, but uint; it’s cheaper as per the comment in the code. Second, we set the _status to 1 in the constructor (one-time gas cost of 20,000) so that later on it’s cheaper to make the 1->2 transition in the nonReentrant() modifier. Finally, the _status may be considered a transient variable as it should always be brought back to the initial value within the transaction (the reentrancy lock should always be released). Moreover, because of that final 2->1 transition, we should expect a gas refund which, due to EIP-3529, is capped at 20% of the total transaction’s gas. Hence, it’s best to keep the gas refunds low, otherwise, we may not get the full refund we would be entitled to.

I’ve just said that thanks to setting the _status to 1 in the constructor, it’s cheaper to use the nonReentrant() modifier, but how much gas exactly do we expect to spend using nonReentrant()? Taking into account what’s been considered so far, let’s write some comments in the code as we go:

}modifier nonReentrant() {
    // the first SLOAD should cost us 2100.
    // if we were to call this modifier again, we should pay 100 here
    if (_status == 2) {
        revert ReentrancyGuardReentrantCall();
    }

    // considering we already accessed this storage slot above,
    // we should pay 2900 here if it's the first call of the modifier.
    // if we were to call this modifier again, it would mean it's a
    // subsequent SSTORE hence we should pay 100 here
    _status = 2;

    _;

    // considering we already accessed this storage slot above 
    // and the fact that it's always a subsequent SSTORE, we 
    // should pay 100 here
    _status = 1;
}

To sum up, if it’s the first call of the nonReentrant(), we expect to pay 5100 gas. If it’s a subsequent call in the same transaction, we expect to pay 300 gas. Sounds good, doesn’t it?

If there’s a smart contract out there that integrates with ours and in one transaction it calls a couple of non-reentrant functions from our contract, each subsequent call should be cheaper than the first one. Thanks to that behaviour, our reentrancy protection should not be that costly!

Well, that’s theory. In practice, things appear vastly different. To show that, let’s write two simple contracts and look at the hardhat-tracer dump.

contract MyContract is ReentrancyGuard {
    // some non-reentrant function that does nothing but is protected
    function nonReentrantFunction() public nonReentrant {}
}

contract IntegrationContract {
    MyContract internal immutable myContract;

    constructor(MyContract _myContract) {
        myContract = _myContract;
    }
    
    // a function that calls the protected function twice.
    // we expect that the second call should be cheaper
    function test() external {
        myContract.nonReentrantFunction();
        myContract.nonReentrantFunction();
    }
}

We have the MyContract with a simple reentrancy-protected function and IntegrationContract that simply calls the MyContract.nonReentrantFunction() twice in its test() function. Do you see anything odd? We expected to pay 100 for the 1->2 transition in the second call, instead, we paid 2900. At the same time, we paid expectedly less for the second SLOAD. Why is that?

EIP-2200

When I shared my findings with the team members, some of them said it was illogical, some of them said it was inconsistency, and others claimed it must be a bug. I dived deeper and apparently, everything became clear.

EIP-2200 is heavily referred to by EIP-2929 and pertains to the so-called Wei Tang’s approach to net gas metering. Its implementation predates EIP-2929 and was introduced in the Istanbul hard fork. The idea behind it was to provide a structured definition of net gas metering changes for SSTORE opcode, enabling new usages for contract storage, and reducing excessive gas costs where it doesn’t match how most implementation works. It says: “Usages that benefit from this EIP’s gas reduction scheme include: Subsequent storage write operations within the same call frame. This includes reentry locks, same-contract multi-send, etc.”. As you can see, it explicitly mentions the reentrancy locks gas cost reduction, but has it done the job?

The gas calculation logic proposed by EIP-2200 is as follows:

source: https://eips.ethereum.org/EIPS/eip-1283

source: https://eips.ethereum.org/EIPS/eip-2200


As you can see, the algorithm does not recognize the case in which the original value equals the current value now, but it had been previously set to some other value within the same call frame. It does not care whether the storage slot key was previously accessed. Therefore, when we set our _status variable from 1 to 2, no matter if it’s the same or a subsequent call, we are exercising the same path and pay SSTORE_RESET_GAS for that, which happens to be 2900 after EIP-2929. Bye bye gas savings!

Questionable rationale

EIP-2200 offers a rationale for the above behaviour. It claims that the approach mostly achieves what transient storage tries to do (EIP-1087 and EIP-1153) but without the complexity of introducing the concept of “dirty maps”, or an extra storage struct. Personally, I find this questionable. Controversial EIP-1153 that introduces new opcodes for transient storage — TLOAD and TSTORE — would work far better here and in other scenarios. In cases like the above, each use of nonReentrant() modifier would cost 300 vs. 5100 (or 3100 in the subsequent calls). Moreover, although simplicity shall be praised and “dirty maps” implementation might be indeed tricky, it might sometimes lead to the implementation of ugly design patterns like the one I had to come up with myself. On the other hand, if nobody knows about the SSTORE quirk, nobody except me will implement any ugliness :)

Ugly gas optimizations

Imagine you have a function where reentrancy is allowed. Every time it happens you just increase the callDepth counter to be able to revert at some point. At the same time, you have a critical section that you want to protect from reentrancy, you use the criticalLock for that. The critical code should only be executed once when the call stack unwinds back to the top-level function call. You implement the smart contract as follows:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

contract UglyContract {
    error CriticalLockViolation();
    error CallDepthViolation();

    struct ContextStruct {
        bool criticalLock;
        uint8 callDepth;
        uint8 stamp;
        uint8 dummy;
    }

    ContextStruct internal context;
    
    constructor() {
        // set the storage slot to non-zero so that it's cheaper to alter it later
        context.dummy = 1;
    }

    // this modifier is used to protect critical sections of the code
    modifier critical() {
        if (context.criticalLock) {
            revert CriticalLockViolation();
        }
        _;
    }

    function prettyFunction(address target, address criticalTarget) external critical {
        uint8 callDepth = context.callDepth;

        if (callDepth >= 5) {
            revert CallDepthViolation();
        }

        unchecked {
            context.callDepth = callDepth + 1;
        }

        // do something here: make external calls, allow reentrancy back into this function.
        // low-level call used to avoid compiler optimization and show the issue
        target.call("");

        context.callDepth = callDepth;

        if (callDepth == 0) {
            context.criticalLock = true;
            
            // do some critical stuff here: make external calls, but disallow reentrant callback into this function.
            // low-level call used to avoid compiler optimization and show the issue
            criticalTarget.call("");
            
            context.criticalLock = false;
        }
    }

    function uglyFunction(address target, address criticalTarget) external critical {
        uint8 callDepth = context.callDepth;

        if (callDepth >= 5) {
            revert CallDepthViolation();
        }

        unchecked {
            context.callDepth = callDepth + 1;

            // gas optimization to keep the slot in altered state until the end of the uglyFunction
            context.stamp = 1;
        }

        // do something here: make external calls, allow reentrancy back into this function.
        // low-level call used to avoid compiler optimization and show the issue
        target.call("");

        context.callDepth = callDepth;

        if (callDepth == 0) {
            context.criticalLock = true;
            
            // do some critical stuff here: make external calls, but disallow reentrant callback into this function.
            // low-level call used to avoid compiler optimization and show the issue
            criticalTarget.call("");
            
            context.criticalLock = false;

            // bring the slot back to the original state
            context.stamp = 0;
        }
    }
}

As you can see, we have two functions that do exactly the same thing. Gas optimized uglyFunction() and not optimized prettyFunction(). To avoid the user being charged additional gas, in the uglyFunction() the stamp field is set to 1 at the top and reset at the end of the transaction. Savings? 2800 for each call. Of course one can argue here that the final storage slot value will be equal to the original value hence gas refund is expected. In this case, you would be right, but it’s only because the context is transient in nature which does not have to be the case. Also, you should not rely on the gas refunds as they are capped at 20% of the total transaction’s gas as I already mentioned before.

Conclusion

It appears that due to EIP-2200, when it comes to the gas cost for the SSTORE opcode, within a transaction, the EVM only cares about the current vs. final value of the storage slot and not intermediate values. In scenarios when your smart contract sets the storage slot from X to Y, then back to X, and from X to Z you will not pay the reduced gas cost of 100 for changing the storage slot from X to Z, even if you’re within the same call frame. You will pay a full cost of 2900 or 20,000 if X is 0. Amongst others, it has implications for reentrancy locks, toggled boolean flags, counters incremented and decremented within the same call frame, etc. Maybe after all controversial EIP-1153 would be a good idea?

This piece is provided by Euler Labs Ltd. for informational purposes only and should not be interpreted as investment, tax, legal, insurance, or business advice. Euler Labs Ltd. and The Euler Foundation are independent entities.

Neither Euler Labs Ltd., The Euler Foundation, nor any of their owners, members, directors, officers, employees, agents, independent contractors, or affiliates are registered as an investment advisor, broker-dealer, futures commission merchant, or commodity trading advisor or are members of any self-regulatory organization.

The information provided herein is not intended to be, and should not be construed in any manner whatsoever, as personalized advice or advice tailored to the needs of any specific person. Nothing on the Website should be construed as an offer to sell, a solicitation of an offer to buy, or a recommendation for any asset or transaction.

This post reflects the current opinions of the authors and is not made on behalf of Euler Labs, The Euler Foundation, or their affiliates and does not necessarily reflect the opinions of Euler Labs, The Euler Foundation, their affiliates, or individuals associated with Euler Labs or The Euler Foundation.

Euler Labs Ltd. and The Euler Foundation do not represent or speak for or on behalf of the users of Euler Finance. The commentary and opinions provided by Euler Labs Ltd. or The Euler Foundation are for general informational purposes only, are provided "AS IS," and without any warranty of any kind. To the best of our knowledge and belief, all information contained herein is accurate and reliable and has been obtained from public sources believed to be accurate and reliable at the time of publication.

The information provided is presented only as of the date published or indicated and may be superseded by subsequent events or for other reasons. As events and markets change continuously, previously published information and data may not be current and should not be relied upon.

The opinions reflected herein are subject to change without being updated.

2024 Euler © All Rights Reserved