Gas Optimization in Smart Contracts
The programming of smart contracts has a unique essence as each activity that requires changing the blockchain costs gas, equating to a portion of the currency that a particular blockchain uses and, consequently, to actual money. Thus, understanding how to apply gas optimizations in Solidity will significantly improve your project. This blog seeks to offer a collection of methods and little changes you can make in your code to save gas while creating Smart Contracts on EVM chains.
What is Gas
Gas is essential to the Ethereum Network; it is the fuel that allows it to operate in the same way that a car needs gasoline to run.
In technical terms, gas is the unit that measures the amount of computational effort required to execute specific operations on the Ethereum network. The EVM provides opcodes for various operations such as ADD
which consumes 3 Gas units, MUL
consuming 5 Gas units. The more the complexity of the process, the more the Gas.
Gas Price is the amount of gwei (1e-9 ETH ) paid for every unit of Gas. Gas price is determined based on the supply and demand on the blockchain.
Total Transaction Fees = Gas Used * Gas Price
The total fees can get very high both for the user and the deployer when writing complex contracts, thus Gas Optimization becomes a crucial part of developing Smart Contracts. To be successful, we need to learn how Solidity handles our variables and functions under the hood.
Packing of Variables
In Ethereum, the variables are packed in contiguous Storage Slots of 256 bits(32 bytes); the gas is paid for each slot, not each variable. While coding, one has to just declare packable variables consecutively, and the packing is done automatically by the solidity compiler and optimizer.
Poor code example:
uint8 a;
uint256 b;
uint8 c;
This code will consume gas equivalent to 3 storage slots as the two uint8
variables are not packed together.
Efficient Code example:
uint8 a;
uint8 c;
uint256 b;
This code will consume only 2 storage slots thus saving gas.
Note: It is better to use uint256
for the lone small-sized variable if there is no opportunity of packing small-sized variables. Also, packing occurs only in Storage, you will not save space trying to pack function arguments or local variables.
Mappings vs Arrays
In Solidity mostly mappings are less expensive than arrays. Arrays can be packed, while Mappings cannot be packed. Thus Arrays are cheaper while using smaller elements such as uint8
, thanks to the variable packing feature. The primary factor in deciding between the two is the use-case, You can parse through all the elements in Array while in Mapping you can search values using the key. Thus you might be forced to use an Array even though it might cost you more gas.
Storage vs Memory
The variables stored in Storage are the ones declared outside the function and they take up the 256 bits Storage slots in the contract. While the variables declared in functions are stored in memory and are local to the function, i.e., they cease to exist once the function terminates. Storing and retrieving data from Storage costs a lot of Gas — Opcode SLOAD
(Load word from storage) costs 800 Gas and SSTORE
(Save word to storage) costs 20000 Gas. Hence we should avoid operating on the Storage variables as much as possible. To do this, we can assign the value of the Storage variable to a Memory variable, operate on the Memory variable, and update the Storage variable in the end. By doing this we are reading and writing the Storage variable only once.
Note: Mappings can’t be in memory!!
Immutable and Constant Keywords
The Constant variables are initialized at the time of declaration and remain constant throughout the contract, while the Immutable variables are assigned values in the Constructor of the contract. Immutable variables can be assigned only once and can, from that point on, be read even during construction time. Both of these variables don’t take Storage Slots, the constant variables are directly encoded in the byte code of the contract. Thus using these keywords for appropriate variables can save a considerable amount of Gas.
Using Appropriate Function Visibility Specifiers
Calling internal
functions is cheaper than calling public functions because when you call a public function, all the parameters are copied into memory and passed to the function. But in the case of internal functions, only the references of the variables are passed, and variables are not copied into memory.
When a function is called only from external contracts, it is better to make it external
as its parameters are not copied to memory but are read from calldata directly.
Hence internal
and external
functions save a lot of Gas, especially when the parameters are huge.
Using Custom Errors
Starting from Solidity version 0.8.4, we can call Custom Errors in revert
statements. Custom errors are a better alternative for throwing errors than using the require
statements. This is mainly because you have to store and emit large-sized strings in the require
statements which of course, consumes a lot of Gas. Also, the Custom Errors once declared can be used multiple times.
Below is a code showing the usage of custom errors in a modifier:
Fixed vs Dynamic Variables
Fixed-size variables are always cheaper than dynamic ones. So if we know the size of an Array it is better to specify it, also it is better to use bytes32
while using short strings instead of string
or bytes
variable which are dynamically allocated.
Deleting Variables
Ethereum gives you a gas refund when you delete variables that are no longer needed. The refund acts as an incentive to save space on the blockchain. The “delete” keyword assigns the initial value for the data type, for example, 0 for integers.
Make Fewer External Calls
Calls to an external contract consume a lot of Gas, so it is a good practice to call one function and have it return all the data you need rather than calling a separate function for every piece of data.
Use Short Circuiting To Your Advantage
While using the logical OR ( | | ) or AND (&&) operator, ensure you arrange the operands correctly. For the OR operator if the first operand is true
others will not be checked, thus saving gas. Similarly for AND if the first one is false
other will not be checked.
Solidity Gas Optimizer
Make sure Solidity’s optimizer is enabled and customize the value of runs
which represents the number of times you expect to call functions in that contract. So if you want to gas optimize for contract deployment (costs less to deploy a contract) then set runs
to a lower number. If you want to optimize for run-time gas costs (when functions are called on a contract) then set the runs
to a higher number.
Use Latest Solidity Compilers
Using newer compiler versions and the optimizer gives gas optimizations and additional safety checks for free!
For Example, The advantages of versions 0.8.* over <0.8.0 are:
Safemath
by default from 8.0(can be more gas efficient than some library-based safemath.)- Low-Level inliner from 8.2, leads to cheaper runtime gas.
- Optimizer improvements in packed structs 6:Before 8.3, storing packed structs, in some cases, used additional storage read operation. After EIP-2929 3, 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 an additional cost of 100gas alongside the same unnecessary stack operations and extra deploy time costs.
- Custom errors 19 from 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 with custom errors.
Caching the length in for loops
Consider a generic example of an array arr
and the following loop:
for (uint i = 0; i < arr.length; i++) {
// do something that doesn't change arr.length
}
In the above case, the solidity compiler will always read the length of the array during each iteration. That is, it will generate extra sload/mload/calldataload and hence, use extra gas in each iteration.
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
}
Some Other Tricks
- When dealing with unsigned integer types, comparisons with
!=
0 are cheaper then with> 0
. - Use shift right/left instead of division/multiplication if possible
- Negative values are more expensive in calldata because they are prepended with
0xf
bytes. E.g.abi.encode(int(-256))
leads0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffef0d4
. And from Ethereum's yellow paper we know that zeros are cheaper than non-zero values in the hex representation. - The fallback function (and sig-less functions in Yul) save gas because they don’t require a function signature to be called.
Tip: Use Hardhat Gas Reporter
The hardhat gas reporter plugin is a great tool to estimate the Gas consumed in development. You can see how much Gas is consumed by each of your functions and deployments, using this data and Gas Optimizing Techniques you reduce your Gas consumption.
Installation: npm i hardhat-gas-reporter