A collection of solidity gas optimization examples to see what works and what doesn't with optimizer enabled and disabled.
Ensure you are using a recent version of Foundry that supports Solidity >= 0.8.28.
Compile with: forge build
Every test file has instructions on how to run the individual tests.
Solidity by default initializes variables to default values, so one common recommendation is to not initialize loop variables to their default value:
// both implementations cost the same gas
for(uint256 i=0; i<numIds; i++) {
for(uint256 i; i<numIds; i++) {
Testing shows this suggestion is not cheaper regardless of whether the optimizer is enabled. The code without initialization may still be preferred for its succinctness.
If a variable (especially a storage variable) can be initialized past its default value, this offers significantly cheaper gas cost in the function call that would subsequently change it:
contract IdRegUnop is IIdReg {
// next available id
- uint256 public nextId;
+ uint256 public nextId = 1;
When a function takes an array as input, using calldata
instead of memory for read-only inputs is cheaper:
- function generateIds(uint256 numIds, address[] memory owners) external
+ function generateIds(uint256 numIds, address[] calldata owners) external {
When a storage slot doesn't change it is cheaper to cache the value once then use the cached copy instead of reading it from storage multiple times. Similarly it is cheaper to cache a result during a loop then write the result to storage once than to write to storage during every loop iteration:
function generateIds(uint256 numIds, address[] calldata owners) external {
if(numIds != owners.length)
revert NumIdsOwnersLengthMismatch(numIds, owners.length);
+ @audit read `nextId` from storage once
+ uint256 newId = nextId;
for(uint256 i; i<numIds; i++) {
- // read next id from storage
- uint256 newId = nextId;
// update the mapping
- idToOwner[newId] = owners[i];
+ idToOwner[newId++] = owners[i];
- // update storage to increment next id
- nextId = newId + 1;
}
+ // @audit write final `newId` to `nextId` storage once
+ nextId = newId;
One suggestion is to use the delete
keyword instead of assigning if the assignment would be to the default value, in order to obtain a gas refund. This appears to not be effective; assignment to the default value results in the same gas cost as using the delete
keyword:
// both implementations cost the same gas
function resetId(uint256 id) external {
idToOwner[id] = address(0);
}
function resetId(uint256 id) external {
delete idToOwner[id];
}
It is cheaper to not cache calldata
length:
function getOwnersForIds(uint256[] calldata ids) external view returns(address[] memory) {
- // cache length
- uint256 idsLength = ids.length;
// allocate output array in memory
- address[] memory owners = new address[](idsLength);
+ address[] memory owners = new address[](ids.length);
// populate output array
- for(uint256 i; i<idsLength; i++) {
+ for(uint256 i; i<ids.length; i++) {
owners[i] = idToOwner[ids[i]];
}
// return output array
return owners;
}
It is cheaper to use named return variables and remove explicit return
statements:
- function getOwnersForIds(uint256[] calldata ids) external view returns(address[] memory) {
+ function getOwnersForIds(uint256[] calldata ids) external view returns(address[] memory owners)
// allocate output array in memory
- address[] memory owners = new address[](ids.length);
+ owners = new address[](ids.length);
// populate output array
for(uint256 i; i<ids.length; i++) {
owners[i] = idToOwner[ids[i]];
}
- // return output array
- return owners;
}
Marking a public
function as external
appears to have no effect on gas costs:
// both implementations cost the same gas
function resetId(uint256 id) external {
idToOwner[id] = address(0);
}
function resetId(uint256 id) public {
idToOwner[id] = address(0);
}
When msg.sender
is guaranteed to be owner()
such as inside onlyOwner
functions, it is cheaper to use msg.sender
:
function sendETHToOwner() external virtual onlyOwner {
uint256 ethBal = address(this).balance;
if(ethBal > 0) {
- (bool sent, ) = owner().call{value: ethBal}("");
+ (bool sent, ) = msg.sender.call{value: ethBal}("");
if(!sent) revert EthTransferFailed();
}
}
When sending ETH, it is cheaper to use Solady's safeTransferETH
function:
+ import {SafeTransferLib} from "@solady/utils/SafeTransferLib.sol";
function sendETHToOwner() external virtual onlyOwner {
uint256 ethBal = address(this).balance;
if(ethBal > 0) {
- (bool sent, ) = msg.sender.call{value: ethBal}("");
- if(!sent) revert EthTransferFailed();
+ SafeTransferLib.safeTransferETH(msg.sender, ethBal);
}
}
Using != 0
or > 0
costs the same gas providing no benefit:
uint256 ethBal = address(this).balance;
// both implementations cost the same gas
if(ethBal > 0)
if(ethBal != 0)
Using Solady Ownable
instead of OpenZeppelin resulted in cheaper onlyOwner
modifier execution by 0.03% with optimizer enabled and 0.10% with optimizer disabled:
- import {Ownable} from "@openzeppelin/access/Ownable.sol";
+ import {Ownable} from "@solady/auth/Ownable.sol";
- constructor() Ownable(msg.sender) {}
+ constructor() { _initializeOwner(msg.sender); }
The EIP7201 Gas Trap occurs when:
- developers get into the habit of constantly calling an internal function to retrieve an EIP7201 storage reference
- re-read the same storage values which haven't changed in multiple child functions
An example using only 1 storage slot and a call stack based on one of my private audits:
- createOrder (2 storage reads)
-- beforeOrderCheck (2 storage reads)
--- beforeOrderCheckParent (1 storage read)
-- _afterOrderCheck (1 storage read)
--- _instantSettlement (1 storage read)
---- _partialSettlement (1 storage read)
Each of the above functions was calling the internal function to retrieve an EIP7201 storage reference then re-reading the same storage slot which was never changed during the transaction!
To avoid falling into the EIP7201 Gas Trap, for each major protocol functionality:
- identify which storage slots are not changed but only read for that functionality
- read the storage slots once then pass the cached copies into child functions which also read them
Again using only the 1 storage slot, this produces the following call stack:
- createOrder (1 storage read)
-- beforeOrderCheck(cache) (0 storage reads)
--- beforeOrderCheckParent(cache) (0 storage read)
-- _afterOrderCheck(cache) (0 storage read)
--- _instantSettlement(cache) (0 storage read)
---- _partialSettlement(cache) (0 storage read)
In our simplied example using only 1 storage slot the gas cost was 9.45% cheaper with optimizer enabled and 11.86% cheaper without the optimizer. In real-world protocols where multiple storage slots are not changed but frequently read the gas savings are likely to be even greater.
When an input variable's value doesn't need to be preserved, modifying that input variable is more efficient than using a temporary variable inside the function:
function returnLowest(uint256 input) external view returns(uint256 lowest) {
lowest = input;
uint256 compsLen = comps.length;
for(uint256 i; i<compsLen; i++) {
- uint256 temp = comps[i];
+ input = comps[i];
if(temp < lowest) {
lowest = temp;
}
}
}