Using newer compiler versions and the optimizer gives gas optimizations and additional safety checks for free!
The advantages of versions 0.8.*
over <0.8.0
are:
- Safemath by default from
0.8.0
(can be more gas efficient than some library based safemath). - Low level inliner from
0.8.2
, leads to cheaper runtime gas. Especially relevant when the contract has small functions. For example, OpenZeppelin libraries typically have a lot of small helper functions and if they are not inlined, they cost an additional 20 to 40 gas because of 2 extra jump instructions and additional stack operations needed for function calls. - Optimizer improvements in packed structs: Before
0.8.3
, storing packed structs, in some cases used an additional storage read operation. After EIP-2929, if the slot was already cold, this means unnecessary stack operations and extra deploy time costs. However, if the slot was already warm, this means additional cost of 100 gas alongside the same unnecessary stack operations and extra deploy time costs. - Custom errors from
0.8.4
, leads to cheaper deploy time cost and run time cost. Note: the run time cost is only relevant when the revert condition is met. In short, replace revert strings by custom errors. - Solidity
0.8.10
has a useful change which reduced gas costs of external calls which expect a return value. Code Generator skips existence check for external contract if return data is expected. In this case, the ABI decoder will revert if the contract does not exist.0.8.10
also enables the new EVM code generator for pure Yul mode. - Improved Inlining Heuristics in Yul Optimizer. The compiler used to be very conservative before Solidity version
0.8.15
in deciding whether to inline a function or not. This was necessary due to the fact that inlining may easily increase stack pressure and lead to the dreadedStack too deep
error. In0.8.15
the conditions necessary for inlining are relaxed. Benchmarks show that the change significantly decreases the bytecode size (which impacts the deployment cost) while the effect on the runtime gas usage is smaller. - Overflow checks on multiplication more efficient in Solidity v0.8.17. Yul Optimizer: Prevent the incorrect removal of storage writes before calls to Yul functions that conditionally terminate the external EVM call; Simplify the starting offset of zero-length operations to zero. Code Generator: More efficient overflow checks for multiplication.
- Caching the length in for loops
Reading array length at each iteration of the loop takes 6 gas (3 for mload
and 3 to place memory_offset
) in the stack.
Caching the array length in the stack saves around 3 gas per iteration.
I suggest storing the array’s length in a variable before the for-loop.
Example of an array arr and the following loop:
for (uint i = 0; i < length; i++) {
// do something that doesn't change the value of i
}
In the above case, the solidity compiler will always read the length of the array during each iteration.
- If it is a storage array, this is an extra
sload
operation (100 additional extra gas (EIP-2929) for each iteration except for the first), - If it is a
memory
array, this is an extramload
operation (3 additional gas for each iteration except for the first), - If it is a
calldata
array, this is an extracalldataload
operation (3 additional gas for each iteration except for the first) This extra costs can be avoided by caching the array length (in stack):
uint length = arr.length;
for (uint i = 0; i < length; i++) {
// do something that doesn't change arr.length
}
In the above example, the sload
or mload
or calldataload
operation is only called once and subsequently replaced by a cheap dupN
instruction. Even though mload
, calldataload
and dupN
have the same gas cost, mload
and calldataload
needs an additional dupN
to put the offset in the stack, i.e., an extra 3 gas.
This optimization is especially important if it is a storage array or if it is a lengthy for loop.
- The increment in for loop post condition can be made unchecked
In Solidity 0.8+, there’s a default overflow check on unsigned integers. It’s possible to uncheck this in for-loops and save some gas at each iteration, but at the cost of some code readability, as this uncheck cannot be made inline.
Example for loop:
for (uint i = 0; i < length; i++) {
// do something that doesn't change the value of i
}
In this example, the for loop post condition, i.e., i++
involves checked arithmetic, which is not required. This is because the value of i is always strictly less than length <= 2**256 - 1
. Therefore, the theoretical maximum value of i to enter the for-loop body is 2**256 - 2
. This means that the i++
in the for loop can never overflow. Regardless, the overflow checks are performed by the compiler.
Unfortunately, the Solidity optimizer is not smart enough to detect this and remove the checks. You should manually do this by:
for (uint i = 0; i < length; i = unchecked_inc(i)) {
// do something that doesn't change the value of i
}
function unchecked_inc(uint i) returns (uint) {
unchecked {
return i + 1;
}
}
Or just:
for (uint i = 0; i < length;) {
// do something that doesn't change the value of i
unchecked { i++; }
}
Note that it’s important that the call to unchecked_inc
is inlined. This is only possible for solidity versions starting from 0.8.2
.
Gas savings: roughly speaking this can save 30-40 gas per loop iteration. For lengthy loops, this can be significant! (This is only relevant if you are using the default solidity checked arithmetic.)
++i
costs less gas compared toi++
ori += 1
++i
costs less gas compared to i++
or i += 1
for unsigned integer, as pre-increment is cheaper (about 5 gas per iteration). This statement is true even with the optimizer enabled.
Example:
i++
increments i
and returns the initial value of i
. Which means:
uint i = 1;
i++; // == 1 but i == 2
But ++i
returns the actual incremented value:
uint i = 1;
++i; // == 2 and i == 2 too, so no need for a temporary variable
In the first case, the compiler has to create a temporary variable (when used) for returning 1 instead of 2
- No need to explicitly initialize variables with default values
If a variable is not set/initialized, it is assumed to have the default value (0 for uint, false for bool, address(0) for address…). Explicitly initializing it with its default value is an anti-pattern and wastes gas.
As an example:
for (uint256 i = 0; i < numIterations; ++i) {
should be replaced with:
for (uint256 i; i < numIterations; ++i) {
- Don't remove initialization of
i
varible in for loops
I see a lot of projects where developers mistakenly believe that the removal of i
vatiable outside of the for loop will save gas. In following snippets you can see that this is wrong:
function loopCheck1(uint256[] memory arr) external returns (uint256[] memory) {
gas = gasleft(); // 29863 gas
uint length = arr.length;
for (uint i; i < length;) {
unchecked { ++i; }
}
return arr;
gas -= gasleft();
}
function loopCheck2(uint256[] memory arr) external returns (uint256[] memory) {
gas = gasleft();
uint i;
uint length = arr.length;
for (; i < length;) { // 29912 gas
unchecked { ++i; }
}
return arr;
gas -= gasleft();
}
- To sum up, the best gas optimized loop will be:
uint length = arr.length;
for (uint i; i < length;) {
unchecked { ++i; }
}
In some cases, having function arguments in calldata
instead of memory
is more optimal. When arguments are read-only on external functions, the data location should be calldata
.
Example:
contract C {
function add(uint[] memory arr) external returns (uint sum) {
uint length = arr.length;
for (uint i = 0; i < arr.length;) {
sum += arr[i];
unchecked { ++i; }
}
}
}
In the above example, the dynamic array arr has the storage location memory
. When the function gets called externally, the array values are kept in calldata
and copied to memory
during ABI decoding (using the opcode calldataload
and mstore
). And during the for loop, arr[i]
accesses the value in memory
using a mload
. However, for the above example this is inefficient. Consider the following snippet instead:
contract C {
function add(uint[] calldata arr) external returns (uint sum) {
uint length = arr.length;
for (uint i = 0; i < arr.length;) {
sum += arr[i];
unchecked { ++i; }
}
}
}
In the above snippet, instead of going via memory
, the value is directly read from calldata
using calldataload
. That is, there are no intermediate memory
operations that carries this value.
Gas savings: In the former example, the ABI decoding begins with copying value from calldata
to memory
in a for loop. Each iteration would cost at least 60 gas. In the latter example, this can be completely avoided. This will also reduce the number of instructions and therefore reduces the deploy time cost of the contract.
In short, use calldata
instead of memory
if the function argument is only read.
Note that in older Solidity versions, changing some function arguments from memory
to calldata
may cause “unimplemented feature error”. This can be avoided by using a newer (0.8.*
) Solidity compiler.
Solidity 0.6.5 introduced immutable as a major feature. It allows setting contract-level variables at construction time which gets stored in code rather than storage.
Example:
contract C {
/// The owner is set during contruction time, and never changed afterwards.
address public owner = msg.sender;
}
In the above example, each call to the function owner() reads from storage, using a sload
. After EIP-2929, this costs 2100 gas cold or 100 gas warm. However, the following snippet is more gas efficient:
contract C {
/// The owner is set during contruction time, and never changed afterwards.
address public immutable owner = msg.sender;
}
In the above example, each storage read of the owner state variable is replaced by the instruction push32
value, where value is set during contract construction time. Unlike the last example, this costs only 3 gas.
Use of constant keccak variables results in extra hashing (and so gas).
This results in the keccak operation being performed whenever the variable is used, increasing gas costs relative to just storing the output hash. Changing to immutable
will only perform hashing on contract deployment which will save gas.
You should use immutables
until the referenced issues are implemented, then you only pay the gas costs for the computation at deploy time.
Example:
contract Immutables is AccessControl {
uint256 public gas;
bytes32 public immutable MANAGER_ROLE_IMMUT;
bytes32 public constant MANAGER_ROLE_CONST = keccak256('MANAGER_ROLE');
constructor(){
MANAGER_ROLE_IMMUT = keccak256('MANAGER_ROLE');
_setupRole(MANAGER_ROLE_CONST, msg.sender);
_setupRole(MANAGER_ROLE_IMMUT, msg.sender);
}
function immutableCheck() external {
gas = gasleft();
require(hasRole(MANAGER_ROLE_IMMUT, msg.sender), 'Caller is not in manager role'); // 24408 gas
gas -= gasleft();
}
function constantCheck() external {
gas = gasleft();
require(hasRole(MANAGER_ROLE_CONST, msg.sender), 'Caller is not in manager role'); // 24419 gas
gas -= gasleft();
}
}
As you can see on the compiler version 0.8.26
and with optimizator on 200 runs immutables are cheaper, and saves you about 20 gas. For other variables, constants
are equal to immutables
. Works only with the optimizer on.
See: (ethereum/solidity#9232 (comment), Inefficient Hash Constants)
Consider the following require statement:
// condition is boolean
// str is a string
require(condition, str)
The string str is split into 32-byte sized chunks and then stored in memory
using mstore
, then the memory
offsets are provided to revert(offset, length)
. For chunks shorter than 32 bytes, and for low --optimize-runs value (usually even the default value of 200), instead of push32
val, where val is the 32 byte hexadecimal representation of the string with 0 padding on the least significant bits, the solidity compiler replaces it by shl(value, short-value))
. Where short-value does not have any 0 padding. This saves the total bytes in the deploy code and therefore saves deploy time cost, at the expense of extra 6 gas during runtime. This means that shorter revert strings saves deploy time costs of the contract. Note that this kind of saving is not relevant for high values of --optimize-runs as push32
value will not be replaced by a shl(..., ...)
equivalent by the Solidity compiler.
Going back, each 32 byte chunk of the string requires an extra mstore
. That is, additional cost for mstore
, memory
expansion costs, as well as stack operations. Note that, this runtime cost is only relevant when the revert condition is met.
Overall, shorter revert strings can save deploy time as well as runtime costs.
Note that if your contracts already allow using at least Solidity 0.8.4
, then consider using Custom errors. This is more gas efficient, while allowing the developer to describe the errors in detail using NatSpec. A disadvantage to this approach is that, some tooling may not have proper support for this.
Example:
function _executeTransfer(address _owner, uint256 _idx) internal {
(bytes32 salt, ) = precompute(_owner, _idx);
new FlashEscrow{salt: salt}( //gas: deployment can cost less through clones
nftAddress,
_encodeFlashEscrowPayload(_idx)
);
}
There’s a way to save a significant amount of gas on deployment using Clones: OpenZeppelin video This is a solution that was adopted, as an example, by Porter Finance. They realized that deploying using clones was 10x cheaper. I suggest applying a similar pattern in factory contracts.
See: porter-finance/v1-core#15 (comment) porter-finance/v1-core#34
Example of two contracts with modifiers and internal view function:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
contract Inlined {
function isNotExpired(bool _true) internal view {
require(_true == true, "Exchange: EXPIRED");
}
function foo(bool _test) public returns(uint){
isNotExpired(_test);
return 1;
}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
contract Modifier {
modifier isNotExpired(bool _true) {
require(_true == true, "Exchange: EXPIRED");
_;
}
function foo(bool _test) public isNotExpired(_test)returns(uint){
return 1;
}
}
Differences:
Deploy Modifier.sol
108727
Deploy Inlined.sol
110473
Modifier.foo
21532
Inlined.foo
21556
This with 0.8.9
compiler and optimization enabled.
As you can see it's cheaper to deploy with modifier, and it will save you about 30 gas. But sometimes modifiers increase code size of the contract.
It is recommended to move the modifiers require statements into an internal virtual function
. This reduces the size of compiled contracts that use the modifiers. Putting the require in an internal function decreases contract size when modifier is used multiple times. There is no difference in deployment gas cost with private
and internal
functions.
With Solidity 0.8.14
and optimisations on (200):
contract Ownable is Context {
address public owner = _msgSender();
modifier onlyOwner() {
require(owner == _msgSender(), "Ownable: caller is not the owner");
_;
}
}
contract Ownable2 is Context {
address public owner = _msgSender();
modifier onlyOwner() {
_checkOwner();
_;
}
function _checkOwner() internal view virtual {
require(owner == _msgSender(), "Ownable: caller is not the owner");
}
}
// This is deployment gas cost for each function
// 0: 107172
// 1: 145772
// 2: 181610
// 3: 198170
// 4: 214532
// 5: 241059
contract T1 is Ownable {
event Call(bytes4 selector);
function f0() external onlyOwner() { emit Call(this.f0.selector); }
function f1() external onlyOwner() { emit Call(this.f1.selector); }
function f2() external onlyOwner() { emit Call(this.f2.selector); }
function f3() external onlyOwner() { emit Call(this.f3.selector); }
function f4() external onlyOwner() { emit Call(this.f4.selector); }
}
// 0: 107172
// 1: 147908
// 2: 165818
// 3: 183506
// 4: 192500
// 5: 211682
contract T2 is Ownable2 {
event Call(bytes4 selector);
function f0() external onlyOwner() { emit Call(this.f0.selector); }
function f1() external onlyOwner() { emit Call(this.f1.selector); }
function f2() external onlyOwner() { emit Call(this.f2.selector); }
function f3() external onlyOwner() { emit Call(this.f3.selector); }
function f4() external onlyOwner() { emit Call(this.f4.selector); }
}
See: Optimize Ownable and Pausable modifiers' size impact #3347 Reduce contract size and deployment gas for onlyOwner modifier
Non-strict inequalities (>=
) are cheaper than strict ones (>
). This is due to some supplementary checks (ISZERO
, 3 gas)).
uint256 public gas;
function checkStrict() external {
gas = gasleft();
require(999999999999999999 > 1); // gas 5017
gas -= gasleft();
}
function checkNonStrict() external {
gas = gasleft();
require(999999999999999999 >= 1); // gas 5006
gas -= gasleft();
}
Gas savings: non-strict inequalities will save you 15-20 gas.
!= 0
costs less gas compared to > 0
for unsigned integers in require statements with the optimizer enabled.
But > 0
is cheaper than !=
, with the optimizer enabled and outside a require statement. https://twitter.com/gzeon/status/1485428085885640706
Example with optimizer disabled:
uint256 public gas;
function check1() external {
gas = gasleft();
require(99999999999999 != 0); // gas 22136 --disabled optimizer
gas -= gasleft();
}
function check2() external {
gas = gasleft();
require(99999999999999 > 0); // gas 22136 --disabled optimizer
gas -= gasleft();
}
function check3() external {
gas = gasleft();
if (99999999999999 != 0){ // 22149 gas --disabled optimizer
uint256 i = 123;
}
gas -= gasleft();
}
function check4() external {
gas = gasleft();
if (99999999999999 > 0){ // 22152 gas --disabled optimizer
uint256 i = 123;
}
gas -= gasleft();
}
Example with optimizer enabled:
uint256 public gas;
function check1() external {
gas = gasleft();
require(99999999999999 != 0); // gas 22106 --enabled optimizer
gas -= gasleft();
}
function check2() external {
gas = gasleft();
require(99999999999999 > 0); // gas 22117 --enabled optimizer
gas -= gasleft();
}
function check3() external {
gas = gasleft();
if (99999999999999 != 0){ // 22106 gas --enabled optimizer
uint256 i = 123;
}
gas -= gasleft();
}
function check4() external {
gas = gasleft();
if (99999999999999 > 0){ // 22105 gas --enabled optimizer
uint256 i = 123;
}
gas -= gasleft();
}
Gas savings: it will save you about 10 gas.
To sum up on 0.8.15:
Without optimizer:
In require:
`> 0` equals to `!= 0`
Outside require:
`> 0` more expensive than `!= 0`
With optimizer:
In require:
`> 0` more expensive than `!= 0`
Outside require:
`> 0` cheaper than `!= 0`
See: https://twitter.com/gzeon/status/1485428085885640706
A division/multiplication by any number x being a power of 2 can be calculated by shifting log2(x) to the right/left.
While the DIV
opcode uses 5 gas, the SHR
opcode only uses 3 gas. Furthermore, Solidity's division operation also includes a division-by-0 prevention which is bypassed using shifting.
contract Requires {
uint256 public gas;
function check1(uint x) public {
gas = gasleft();
require(x == 0 && x < 1 ); // gas cost 22156
gas -= gasleft();
}
function check2(uint x) public {
gas = gasleft();
require(x == 0); // gas cost 22148
require(x < 1);
gas -= gasleft();
}
}
Gas savings: Usage of double require will save you around 10 gas with the optimizer enabled.
I already mentioned it earlier, and I strongly recommend use this. Custom errors from Solidity 0.8.4
are cheaper than revert strings (cheaper deployment cost and runtime cost when the revert condition is met)
Source: https://blog.soliditylang.org/2021/04/21/custom-errors/:
Starting from Solidity v0.8.4
, there is a convenient and gas-efficient way to explain to users why an operation failed through the use of custom errors. Until now, you could already use strings to give more information about failures (e.g., revert("Insufficient funds.");
), but they are rather expensive, especially when it comes to deploy cost, and it is difficult to use dynamic information in them.
Custom errors are defined using the error statement, which can be used inside and outside of contracts (including interfaces and libraries).
There is no difference in gas cost between public
/external
/internal
/private
for vairables and functions
It's still better to define function visibility strictly because of security reasons, but it won't affect gas usage. But it would affect deployment gas cost for such functions. There is no difference in internal
and private
functions in deployment gas cost.
contract Requires {
uint256 public gas;
function check1(uint x) public {
gas = gasleft();
require(x == 0 && x < 1 ); // gas cost 5131
x += 1;
gas -= gasleft();
}
function check2(uint x) public {
gas = gasleft();
require(x == 0 && x < 1 ); // gas cost 5131
x += 1;
gas -= gasleft();
}
function check3(uint x) public {
gas = gasleft();
_check3(x); // gas cost 5157
gas -= gasleft();
}
function _check3(uint x) internal pure {
require(x == 0 && x < 1 );
x += 1;
}
function check4(uint x) public {
gas = gasleft();
_check4(x); // gas cost 5157
gas -= gasleft();
}
function _check4(uint x) private pure {
require(x == 0 && x < 1 );
x += 1;
}
function check5(uint x) public {
gas = gasleft();
_check5(x); // gas cost 5157
gas -= gasleft();
}
function _check5(uint x) internal {
require(x == 0 && x < 1 );
x += 1;
}
function check6(uint x) public {
gas = gasleft();
_check6(x); // gas cost 5157
gas -= gasleft();
}
function _check6(uint x) private {
require(x == 0 && x < 1 );
x += 1;
}
}
Public
variables cost the same amount as the internal
and private
ones:
uint256 public gas;
uint256 public x1 = 9999999999999;
uint256 private x2 = 9999999999999;
uint256 internal x3 = 9999999999999;
function check1() public {
gas = gasleft();
x1 += 1; // public - 10108 gas
gas -= gasleft();
}
function check2() public {
gas = gasleft();
x2 += 1; // private - 10108 gas
gas -= gasleft();
}
function check3() public {
gas = gasleft();
x3 += 1; // internal - 10108 gas
gas -= gasleft();
By the way, variable visability affect on deploy gas. public
variables cost more than private
or internal
, but there is no difference in deployment gas cost between private
and internal
.
Anytime you are reading from storage
more than once, it is cheaper in gas cost to cache the variable in memory
: a SLOAD
cost 100gas, while MLOAD
and MSTORE
cost 3 gas.
Gas savings: at least 97 gas.
Sometimes it's better to use storage
instead of copying struct
in memory
.
Example:
// struct LockPosition use 3 slots
// struct LockPosition {
// address owner;
// uint256 unlockAt;
// uint256 lockAmount;
//}
function unlock(uint256 _nftIndex) external nonReentrant {
LockPosition memory position = positions[_nftIndex]; // gas: costing 3 SLOADs while only lockAmount is needed twice.
//Replace "memory" with "storage" and cache only position.lockAmount
require(position.owner == msg.sender, "unauthorized");
require(position.unlockAt <= block.timestamp, "locked");
delete positions[_nftIndex];
jpeg.safeTransfer(msg.sender, position.lockAmount);
emit Unlock(msg.sender, _nftIndex, position.lockAmount);
}
Here, a copy in memory
is costing 3 SLOADs
and 3 MSTORES
. The, 2 variables are only read once through MLOAD (position.owner and position.unlockAt
) and one is read twice (position.lockAmount
). It's better to replace the memory
keyword with storage
and only copying position.lockAmount
in memory
.
To help the optimizer, declare a storage
type variable and use it instead of repeatedly fetching the reference in a map or an array.
The effect can be quite significant.
function borrow(
Position storage position = positions[_nftIndex];
When your smart contract does an ERC-20 transfer, always leave 1 unit of the smallest denomination of the token in your smart contract balance. It’ll save gas the next time you interact with the token.
Well if you have 2 addresses - 0x000000a4323… and 0x0000000000f38210 because of the leading zeroes you can pack them both into the same storage slot, then just prepend the necessary amount of zeroes when using them. This saves you storage when doing things such as checking the owner of a contract.
EIP - 2200 changed a lot with gas, and now if you hold 1 Wei of a token it’s cheaper to use the token than if you hold 0. There is a lot to unpack here so just google EIP 2200 and learn if you want, but in general, if you need to use a storage slot, don’t empty it if you plan to re-fill it later.
A basic optimization but important to know, structs should be organized so that they sequentially add up to multiples of 256 bits in size. So use:
Struct {
uint112
uint112
uint256
}
Instead of:
Struct {
uint112
uint256
uint112
}
The same thing with the order of variables in contract, follow the the optimized order to save gas for deployments. So use:
contract Test {
uint256 // 2 storage slots
address // address is 20 bytes and bool is 1 byte are packed in one slot
bool
}
Instead of:
contract Test {
address // 3 storage slots
uint256
bool
}
Use gasleft()
to measure used gas instead of checking transatcion gas cost, in order to find gas optimization
While auditing code for gas optimization improvements it's very convinient to have hardhat gas reporter. It will show gas usage per unit test. But if you don't have time to initialize new project and write tests for your function, or you want to check a myth about gas optimization, you shouldn't look on a gas cost of the transaction. Don't forget that in Solidity function names and their order in the contract take different amount of gas.
Example:
contract Test {
function a() public { // 125 gas
}
function b() public { // 147 gas
}
}
That's why you should wrap the body of the transaction as:
contract Test {
uint256 public gas;
function a() public {
gas = gasleft();
doStuff();
gas -= gasleft();
}
function b() public {
gas = gasleft();
doStuff();
gas -= gasleft();
}
}
And don't forget to test both functions with the same conditions. Remember the tip "Writing to an existing Storage Slot is cheaper than using a new one".
This is gold, thanks!