Solidity Gas Optimizations, The Innovative & Dangerous

Introduction

Thanks to growing demand on the Ethereum network, smart contract developers are continuing to research and implement new methods of reducing transaction costs. In the last year alone, enormous progress has been made to eliminate unnecessary opcode executions as well as optimizing storage in contracts.

While pushing hard for the lowest possible execution costs is breeding creativity and innovation, it is putting some protocols at risk due to unsafe implementations and unforeseen problems.

This post will get you up-to-date with the newest developments and recent zero-day vulnerabilities while also giving a security researcher’s perspective on each.

Vulnerabilities

Dangers of sstore()

Earlier this month, user 0xriptide submitted a vulnerability via Immunefi regarding the Arbitrum bridge smart contract, featuring a bug involving an uninitialized proxy contract due to a vulnerable implementation of postUpgradeInit. You can see his original post here.

In the below code snippets, the public initialize() function that is standard in the TransparentUpgradeableProxy uses the initializer modifier that checks the first two storage slots to determine whether the function has already been called. That is all well and good, until we get to the “optimized” postUpgradeInit() function.

function initialize(IBridge _bridge, ISequencerInbox _sequencerInbox)
  external
  initializer
  onlyDelegated
{
  bridge = _bridge;
  sequencerInbox = _sequencerInbox;
  allowListEnabled = false;
  __Pausable_init();
}

postUpgradeInit() (as seen below) … wipes slots 0, 1 & 2 and sets the bridge and allowListEnabled slots to new values — but leaves sequencerInbox and the two booleans set by the intializer modifier empty!” (0xriptide)

function postUpgradeInit(IBridge _bridge)
  external
  onlyDelegated
  onlyProxyOwner
{
  uint8 slotsToWipe = 3;
  for (uint8 i = 0; i < slotsToWipe; i++) {
    assembly {
      sstore(i, 0)
      }
  }
  allowListEnabled = false;
  bridge = _bridge;
}

sstore() and assembly in general are great tools to reduce costs, as the unnecessary checks that the EVM does to ensure execution goes according to plan are not used. As 0xriptide states in his post, the bridge contract was in danger also in part because Arbitrum removed this moot check (seen below) that ensured that the bridge address variable was set in order to presumably save on gas during initialization.

if (address(bridge) != address(0)) revert AlreadyInit();

Dangers of “unchecked{}”

This next vulnerability was submitted by a number of auditors during OpenSea’s Seaport audit contest on Code4rena. It involves the classic overflowing as well as typecasting; explicitly converting one type to another.

In every case, you must ensure that user input is handled accordingly to avoid truncating, that is in this case - losing data due to mismanaged types. If you aren’t familiar with these terms, follow along with the next exploit.

This vulnerability is located in OrderValidator.sol, which as the name implies, “contains functionality related to validating orders and updating their status”. More specifically this is in the _validateOrderAndUpdateStatus() function. It is a very complex function, so I did my best to keep the snippet brief.

struct AdvancedOrder {
  OrderParameters parameters;
  uint120 numerator;
  uint120 denominator;
  bytes signature;
  bytes extraData;
}

function _validateOrderAndUpdateStatus(
  AdvancedOrder memory advancedOrder,
  CriteriaResolver[] memory criteriaResolvers,
  bool revertOnInvalid,
  bytes32[] memory priorOrderHashes
)
  internal
  ...
{
  ...
  OrderParameters memory orderParameters = advancedOrder.parameters;
  ...
  bytes32 orderHash = _assertConsiderationLengthAndGetNoncedOrderHash(orderParameters);
  ...
  uint256 filledNumerator = orderStatus.numerator;
  uint256 filledDenominator = orderStatus.denominator;

  if (filledDenominator != 0) {
    ...
    unchecked {
      _orderStatus[orderHash].isValidated = true;
      _orderStatus[orderHash].isCancelled = false;
      _orderStatus[orderHash].numerator = uint120(filledNumerator +
      numerator);
      _orderStatus[orderHash].denominator = uint120(denominator);
    }
  } 
  else {
      _orderStatus[orderHash].isValidated = true;
      _orderStatus[orderHash].isCancelled = false;
      _orderStatus[orderHash].numerator = uint120(numerator);
      _orderStatus[orderHash].denominator = uint120(denominator);
    }
  ...
}

The issue lies in the fact that the user controlled parameter AdvancedOrder can be given a numerator and/or denominator value that is close to overflow like 2**118. In turn, when someone wants to fill the order, it does so by cross multiplying and

“[b]ecause … the uint120 truncation in OrderValidator.sol#L228-L248, the numerator and denominator are truncated to 0 and 0 respectively.”(SpearbitDAO)

Ultimately, this small opening allowed for a potential attacker to drain any approved tokens for the same consideration amount.

At first glance, these kinds of issues are truly difficult to understand due to the fact that these protocols are quite complex. It is important to recognize where the most common mistakes are made in order to discover vulnerabilities the developers may have missed. If you want to learn more about this issue, here is the original submission by SpearbitDAO with an included PoC.

The Gas Optimizor’s Handbook

Custom Errors (solidity >0.8.4)

// Un-optimized 
revert("Unauthorized");

// Yul equivalent
let free_mem_ptr := mload(64)
mstore(free_mem_ptr, 0x08c379a000000000000000000000000000000000000000000000000000000000)
mstore(add(free_mem_ptr, 4), 32)
mstore(add(free_mem_ptr, 36), 12)
mstore(add(free_mem_ptr, 68), "Unauthorized")
revert(free_mem_ptr, 100)

// Peak gas savings
revert Unauthorized();

// Yul equivalent
let free_mem_ptr := mload(64)
mstore(free_mem_ptr, 0x82b4290000000000000000000000000000000000000000000000000000000000)
revert(free_mem_ptr, 4)

Using custom errors is cheaper than a string error on both deployment and execution. Below the example of each is the same thing just in Yul - you can see that the revert(“Unauthorized”) uses 3 additional of our precious mstore()s.

Indexed Events

Events are a great way to keep track of things that happen on-chain. They are quite efficient in their own right, but one thing you can do to make your events even more optimized that many people do not know is that you can use the indexed keyword when logging events with values of type uint, bool, and/or address to save on some gas costs.

This next example comes courtesy of the Art Gobblers project led by the team at Paradigm.

// Un-optimized
event GobblerClaimed(address user, uint256 gobblerId);
  ...
currentNonLegendaryId++;
gobblerId = currentNonLegendaryId;
emit GobblerClaimed(msg.sender, gobblerId);

// Peak gas savings
event GobblerClaimed(address indexed user, uint256 indexed gobblerId);
  ...
emit GobblerClaimed(msg.sender, gobblerId = ++currentNonLegendaryId);

One funky thing you may have also noticed in this example is the second parameter of the bottom event emission. Lets take a closer look:

emit GobblerClaimed(msg.sender, gobblerId = ++currentNonLegendaryId)

It is doing 3 things here:

  1. updating gobblerId’s value

  2. incrementing currentNonLegendaryId

  3. using the updated gobblerId as the input

Impressive stuff.
Impressive stuff.

“for()” Loops and “unchecked{}” ( solidity >0.8.0 )

// Un-optimized 
for (uint256 i = 0; i < 10; i++) {
  ...
}

// Peak gas savings
for (uint256 i; i < 10;) {
  ...
  unchecked {
    ++i;
   }
}

In every case, using unchecked{ ++i; } is cheaper due to a few things:

  1. It doesn’t use a default value of 0 for i, saves 3 gas per declaration

  2. Using the iterator ++i ( ++i uses 5 gas less than i++ )

  3. Using the unchecked{} block - as it is impossible for the iterator to overflow or underflow. A combination of this and ++i saves at least 25 gas, however can be up to 80 gas under certain conditions

Bytes Constants > String Constants

// Un-optomized
string internal constant _NAME = "Berry";

// Peak gas savings
bytes32 internal constant _NAME = "Berry";

Storing string constants are more expensive than bytes constants because they are dynamically sized and have certain limitations in the context of the EVM.

If you can fit a string literal into a bytes32 do it - using fixed sized variables are always cheaper. You will find most large protocols practically never use strings because of this exact reason.

Limit the Scope of Functions

When creating functions, it is always best to limit the accessibility to only where the function is actually called from. internal and private are both cheaper than public and external when called from inside the contract in some cases.

People have noted that public is more expensive than external when called, but from my testing in versions solidity >0.8.0 that is not the case ( perhaps in newer versions? ).

Use “payable” When Possible

When a function without payable is called, one of the first operations is to ensure msg.value == 0 and will revert the call if any `ether` is sent with the transaction.

Using the payable modifier negates that and the contract will accept the transaction regardless of msg.value. This is notably useful for the constructor function.

Each instance of the payable modifier saves 24 gas over the alternative.

Closing Thoughts

The effort to reduce transaction costs with new techniques can often be like playing with fire. Some people are being burned because they dropped one require() statement to save 5 gwei to deploy.

Regardless, security ALWAYS comes first. If you are 99.9% positive that your withdraw() function is not susceptible to a re-entrancy attack, that’s enough reason to keep your ReentrancyGuard modifier on your function. Users safety is much more important than saving a couple gwei.

Stay responsible, keep hunting, and keep learning. Learning small things at a time will yield large results.

You’ll find that crit soon Anon, cheers.

Subscribe to haruxe
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.