Solidity Gas Optimizations Cheat Sheet
There are a number of things you should keep in mind to write the best and most gas efficient solidity code. In this post you can find a list of low-hanging fruits you can apply to your contracts today.
Solidity is currently the most popular language for the EVM. However, Solidity's compiler is still in its early stages, meaning there are a number of things you should keep in mind while using it to write the best and most efficient code.
One of those things is gas optimizations. On Ethereum, gas is very expensive. While we don't recommend going crazy for minimal savings, below is a list of low-hanging fruit you can apply to your contracts today.
đź’ˇ Also see Gas Numbers Every Solidity Dev Should Know
1. Packing of storage variables
Pack variables in one slot by defining them in a lower data type. Packing only helps when multiple variables of the packed slot are being accessed in the same call. If not done correctly, it increases gas costs instead, due to shifts required.
Before:
contract MyContract {
uint32 x; // Storage slot 0
uint256 y; // Storage slot 1
uint32 z; // Storage slot 2
}
After:
contract MyContract {
uint32 x; // Storage slot 0
uint32 z; // Storage slot 0
uint256 y; // Storage slot 1
}
2. Local variable assignment
Catch frequently used storage variables in memory/stack, converting multiple SLOAD
into 1 SLOAD
.
Before:
uint256 length = 10;
function loop() public {
for (uint256 i = 0; i < length; i++) {
// do something here
}
}
After:
uint256 length = 10;
function loop() {
uint256 l = length;
for (uint256 i = 0; i < l; i++) {
// do something here
}
}
3. Use fixed size bytes array rather than string or bytes[]
If the string you are dealing with can be limited to max of 32 characters, use bytes[32]
instead of dynamic bytes
array or string
.
Before:
string a;
function add(string str) {
a = str;
}
After:
bytes32 a;
function add(bytes32 str) public {
a = str;
}
4. Use immutable and constant
Use immutable if you want to assign a permanent value at construction. Use constants if you already know the permanent value. Both get directly embedded in bytecode, saving SLOAD
.
contract MyContract {
uint256 constant y = 10;
uint256 immutable x;
constructor() {
x = 5;
}
}
5. Using unchecked
Use unchecked for arithmetic where you are sure it won't over or underflow, saving gas costs for checks added from solidity v0.8.0.
In the example below, the variable i
cannot overflow because of the condition i < length
, where length
is defined as uint256
. The maximum value i
can reach is max(uint)-1
. Thus, incrementing i
inside unchecked
block is safe and consumes lesser gas.
function loop(uint256 length) public {
for (uint256 i = 0; i < length; ) {
// do something
unchecked {
i++;
}
}
}
6. Use calldata instead of memory for function parameters [Ref]
It is generally cheaper to load variables directly from calldata, rather than copying them to memory. Only use memory if the variable needs to be modified.
Before:
function loop(uint[] memory arr) external pure returns (uint sum) {
for (uint i = 0; i < arr.length; i++) {
sum += arr[i];
}
}
After:
function loop(uint[] calldata arr) external pure returns (uint sum) {
for (uint i = 0; i < arr.length; i++) {
sum += arr[i];
}
}
7. Use custom errors to save deployment and runtime costs in case of revert
Instead of using strings for error messages (e.g., require(msg.sender == owner, “unauthorized”)
), you can use custom errors to reduce both deployment and runtime gas costs. In addition, they are very convenient as you can easily pass dynamic information to them.
Before:
function add(uint256 _amount) public {
require(msg.sender == owner, "unauthorized");
total += _amount;
}
After:
error Unauthorized(address caller);
function add(uint256 _amount) public {
if (msg.sender != owner)
revert Unauthorized(msg.sender);
total += _amount;
}
8. Refactor a modifier to call a local function instead of directly having the code in the modifier, saving bytecode size and thereby deployment cost [Ref]
Modifiers code is copied in all instances where it's used, increasing bytecode size. By doing a refractor to the internal function, one can reduce bytecode size significantly at the cost of one JUMP. Consider doing this only if you are constrained by bytecode size.
Before:
modifier onlyOwner() {
require(owner() == msg.sender, "Ownable: caller is not the owner");
_;
}
After:
modifier onlyOwner() {
_checkOwner();
_;
}
function _checkOwner() internal view virtual {
require(owner() == msg.sender, "Ownable: caller is not the owner");
}
9. Use indexed events as they are less costly compared to non-indexed ones [Ref]
Using the indexed
keyword for value types such as uint, bool, and address saves gas costs, as seen in the example below. However, this is only the case for value types, whereas indexing bytes and strings are more expensive than their unindexed version.
Before:
event Withdraw(uint256, address);
function withdraw(uint256 amount) public {
emit Withdraw(amount, msg.sender);
}
After:
event Withdraw(uint256 indexed, address indexed);
function withdraw(uint256 amount) public {
emit Withdraw(amount, msg.sender);
}
10. Use struct when dealing with different input arrays to enforce array length matching
When the length of all input arrays needs to be the same, use a struct
to combine multiple input arrays so you don't have to manually validate their lengths.
Before:
function vote(uint8[] calldata v, bytes[32] calldata r, bytes[32] calldata s) public {
require(v.length == r.length == s.length, "not matching");
}
After:
struct Signature {
uint8 v;
bytes32 r;
bytes32 s;
}
function vote(Signature[] calldata sig) public {
// no need for length check
}
11. Provide methods for batch operations
If applicable, provide a method for batch action to users, reducing the overall gas costs.
For example: In the following case, if the user wants to call doSomething()
for 10 different inputs. In the optimized version, the user would need to spend the fixed gas cost of the transaction and the gas cost for msg.sender check
only once.
Before:
function doSomething(uint256 x, uint256 y, uint256 z) public {
require msg.sender == registeredUser
....
}
After:
function batchDoSomething(uint256[] x, uint256[] y, uint256[] z) public {
require msg.sender == registeredUser
loop
_doSomething(x[i], y[i], z[i])
}
function doSomething(uint256 x, uint256 y, uint256 z) public {
require msg.sender == registeredUser
_doSomething(x, y, z);
}
function _doSomething(uint256 x, uint256 y, uint256 z) internal {
....
}
12. Short-circuit with || and &&
For || and && operators, the second case is not checked if the first case gives the result of the logical expression. Â Put the lower-cost expression first so the higher-cost expression may be skipped (short-circuit).
Before:
case 1: 100 gas
case 2: 50 gas
if(case1 && case2) revert
if(case1 || case2) revert
After:
if(case2 && case1) revert
if(case2 || case1) revert
General Rules
Optimize For
1. Common cases >> Rare cases
One should optimize for common cases. Optimizing for rare cases can increase gas costs for all other cases. For example, skipping balance update in case of 0 amount transfer.
As 0 amount transfer is not common, it doesn't make sense to add the cost of one condition for all other cases.
However, if the case is common, like transfer in case of infinite allowance, one should do it.
Unlimited ERC20 token allowance · Issue #717 · ethereum/EIPs
2. Runtime cost >> Deployment cost
Deployment costs are one-time, so always optimize for runtime costs.
If you are constrained by bytecode size, then optimize for deployment cost.
3. User Interaction >> Admin Interaction
As user interaction is most common, prioritize optimizing for it compared to admin actions.
Soon we will follow up with degen gas optimizations, which go to extreme lengths to save gas costs.
For example: Function Ordering, ≥ and > comparison, payable, address with leading zeros…
We hope you found this post helpful. If you want to get in touch with us to chat about audits, please click the link below:
References :