Gas fees on the Ethereum mainnet have been a persistent concern, especially at times of network congestion. High demand for blockspace means high transaction fees for users. Therefore, it is crucial to optimize smart contracts and reduce gas consumption during the contract development phase. Gas optimization can lead to lower transaction costs and improved efficiency, ultimately providing a more affordable and accessible blockchain experience for users.
This article gives an overview of the Ethereum Virtual Machine’s (EVM) gas mechanism, associated key concepts underlying gas optimization, as well as best practices for gas optimization when developing smart contracts.
A Brief Introduction to the EVM’s Gas Mechanism
On EVM-compatible networks, “gas” refers to the unit that measures the computational effort required to execute specific operations.
The following diagram illustrates the layout of the Ethereum Virtual Machine. Here, we can see that the gas consumption is divided into three parts: operation execution, external message calls, and reading and writing from memory and storage.
Since each transaction requires computational resources to execute, fees are charged to prevent endless loops and DoS attacks. The payment required to complete a transaction is referred to as the “gas fee”.
After EIP-1559 (the London hardfork) went alive, the gas fee is calculated via the following formula:
Gas fee = units of gas used * (base fee + priority fee)
The base fee is burnt and the priority fee serves as an incentive for validators to add transactions to the blockchain. Setting a higher priority fee when sending a transaction can increase the likelihood of it being included in the next block. This functions as a sort of “tip” from a user to a validator.
Understanding Gas Optimization in the EVM
When a smart contract is compiled in Solidity, it is converted into a series of “operation codes”, also referred to as opcodes.
Any given fragment of opcode (for example, creating contracts, making message calls, accessing account storage, and executing operations on the virtual machine) has a universally agreed cost in terms of gas, which is recorded in the Ethereum yellow paper.
After several EIPs, some of these gas costs have been changed and might have deviated from the yellow paper. For the latest information regarding costs associated with each opcode, see here.
The Fundamental Concept Behind Gas Optimization
The fundamental concept behind gas optimization is to prioritize cost-efficient actions and avoid expensive ones in terms of gas costs on EVM blockchains.
Cheap operations in the EVM include:
- Reading and writing memory variables
- Reading constants and immutable variables
- Reading and writing local variables
calldatavariables such as calldata arrays and structs
- Internal function calls
Expensive operations include:
- Reading and writing state variables that are stored in contract storage
- External function calls
EVM Gas Optimization Best Practices
In light of the fundamental concept mentioned above, we have compiled a list of gas optimization best practices for the developer community’s reference. By following these practices, developers can reduce the gas consumption of their smart contracts, lower transaction costs, and create more efficient and user-friendly applications.
1: Minimize Storage Usage
Storage is a finite resource and is much more expensive in terms of gas usage compared to
Memory. Each time a smart contract reads from or writes to storage, it incurs significant gas costs.
As defined in the Ethereum yellow paper, storage operations are over 100x more costly than memory operations. OPcodes
mstore only cost 3 gas units while storage operations like
sstore cost at least 100 units, even in the most optimistic situation.
Some ways to limit storage usage include:
- Non-permanent data can be stored in Memory
- Reduce Storage modifications by saving intermediate results in Memory and assign results to Storage variables only after all calculations are completed
2: Variable Packing
The number of Storage slots used and the ways developers represent the data in the smart contract affects gas usage heavily.
The Solidity compiler will pack contiguous storage variables during the compilation process, and use a 32-bytes slot as the base unit for variable storage. Variable packing means arranging variables so that multiple variables can fit in a single slot.
On the left is a poor implementation that will consume 3 storage slots. On the right is a much more efficient implementation.
By making this minor adjustment, developers can save 20,000 units of gas (it costs 20,000 gas to store a storage slot that hasn’t been used before), as it will now only require two slots for storage.
Since each storage slot costs gas, variable packing helps optimize gas usage by reducing the number of slots needed.
3: Save on Data Types
One variable can be represented by multiple data types, but different data types have different operation costs. Choosing the most appropriate type will help optimize gas usage.
For instance, in Solidity, integers can be broken down into different sizes:
unit32, and so on. Since the EVM performs operations in 256-bit chunks, using
uint8 means the EVM has to first convert it to
uint256. This conversion costs extra gas.
We can use the code in this graph to compare the gas cost for
UseUint() function costs 120,382 gas units, while
UseUInt8() costs 166,111 gas units.
In isolation, using
uint256 is cheaper than
uint8 here. But we also prviously recommended variable packing. If developers can pack four
uint8 variables in one slot, the total cost of iterating through them will be cheaper than four
uint256 variables. The smart contract can then read/write a storage slot once and put four
uint8 variables in memory/storage in a single operation.
4: Use Fixed-Size Variables Instead of Dynamic
bytes32 datatype instead of
strings is recommended if the data can fit in 32 bytes. In general, any fixed-size variable is less expensive than a variable-size one. If the length of bytes can be limited, use the lowest amount possible from
5: Mapping vs Array
In Solidity, lists of data can be represented using two data types: arrays and mappings. However, their syntax and structure are quite different.
Mappings are more efficient and less expensive in most cases, while Arrays are iterable and packable. Therefore, it is advised to utilize mappings for managing lists of data, except when iteration is required or it is possible to pack data types.
6: Use Calldata Instead of Memory
Variables declared as function parameters are either stored at
memory. One key difference between
calldata is that
memory can be modified by the function, while
calldata is immutable.
Here the principle is to use
calldata instead of
memory if the function argument is read-only. This avoids unnecessary copies from function calldata to memory.
In this example, which uses the
memory keyword, the array values are kept in encoded
calldata and are copied to memory during ABI decoding. The execution cost is 3,694 gas units for this code block.
In the second example, the value is directly read from
calldata and there are no intermediate memory operations. This adjustment results in an execution cost of only 2,413 gas units for this code block, marking a 35% improvement in gas efficiency.
7: Use Constant/Immutable Keywords Whenever Possible
Constant/Immutable variables are not stored in contract storage. These variables are evaluated at compile-time and are stored in the bytecode of the contract. Therefore they are much cheaper to access compared with storage and it is recommended to use
Constant / Immutable keywords whenever possible.
8: Use Unchecked When Under/Overflow is Impossible
When developers are certain that an arithmetic operation will not result in overflow or underflow, they can use the
unchecked keyword introduced in Solidity v0.8.0, to avoid redundant arithmetic underflow/overflow checks and save on gas costs.
In the example below, the variable
i can never overflow due to the conditional constraint
i < length. Here,
length is defined as
uint256, meaning the maximum value
i can reach is
max(uint) - 1. Therefore, incrementing
i within an unchecked block is deemed safe and is more gas-efficient.
Furthermore, the SafeMath library is no longer needed for compiler versions 0.8.0 and above, as overflow and underflow protection is now built into the compiler itself.
9: Modifier Optimization
The code of modifiers is placed in a modified function, and modifier code is copied in all instances where it’s used. This will increase bytecode size and gas usage.
Below is one way to optimize modifier gas cost.
In this example, a refractor is done to the internal function
_checkOwner(), allowing the internal function to be reused in modifiers. This method saves bytecode size and gas cost.
In the case of
&& operators, the evaluation is short-circuited, meaning the second condition is not evaluated if the first condition already determines the result of the logical expression.
To optimize for lower gas usage, arrange conditions so that the less expensive computation is placed first. This could potentially bypass the execution of the more expensive computation.
Bonus General Recommendations
Remove Useless Code
If a contract has functions or variables that aren’t used, it is advisable to remove them. This is the most straightforward way to reduce contract deployment costs and help keep the contract size small.
Here are some practical recommendations:
- Utilize the most efficient algorithms for performing computations. If the results of certain computations are directly used in the contract, consider removing these redundant calculations. In essence, any unused calculations should be eliminated.
- In Ethereum, developers are rewarded with a gas refund for freeing up storage space. If a variable is no longer needed, developers should remove it using the ‘delete’ keyword or set it to its default value.
- Loop optimization: such as avoiding expensive loop operation, combining loops if possible, and moving repeated computation out of the loop.
Use Precompiled Contracts
Precompiled contracts provide complex library functions such as encryption and hashing. They require less gas because the code doesn’t run on the EVM. Instead, it runs locally on the client node. Using precompiled contracts can save gas by reducing the amount of computational work required to execute a smart contract. Some examples of precompiled contracts include the Elliptic Curve Digital Signature Algorithm (ECDSA) and SHA2–256 hashing algorithm. By utilizing these precompiled contracts in smart contracts, developers can reduce gas costs and improve the efficiency of their application.
See here for a comprehensive list of precompiled contracts supported by the Ethereum network.
Use In-Line Assembly Code
In-line assembly allows developers to write low-level, efficient code that can be executed directly by the EVM without the need for expensive Solidity opcodes. In-line assembly also allows for more precise control over memory and storage usage, which can further reduce gas costs. Additionally, in-line assembly can be used to perform complex operations that would be difficult to achieve with Solidity alone, providing more flexibility for optimizing gas usage.
The following is a code example of saving gas with in-line assembly:
Here we observed that the second case, which employs in-line assembly techniques, demonstrates much better gas efficiency compared to the standard use case.
However, using in-line assembly can also be risky and error-prone, so it should be used with caution and only by experienced developers.
Use Layer 2 Solutions
Use Layer 2 solutions to reduce the amount of data that needs to be stored and computed on the Ethereum mainnet.
Layer 2 solutions like rollups, sidechains, and state channels enable offloading of transaction processing from the main Ethereum chain, resulting in faster and cheaper transactions. By bundling a large number of transactions together, these solutions reduce the number of on-chain transactions, which in turn reduces the gas fees. Using Layer 2 solutions can also improve the scalability of Ethereum, enabling more users and applications to participate in the network without overloading it.
Use Optimization Tools and Libraries
There are several optimization tools available, such as solc optimizer, Truffle’s build optimizer, and Remix’s Solidity compiler.
These tools can help minimize the size of the bytecode, remove dead code, and reduce the number of operations required to execute a smart contract. In combination with other gas optimization libraries, such as “solmate”, developers can effectively reduce gas costs and improve the efficiency of their smart contracts.
Optimizing gas usage is an essential practice for developers to minimize transaction costs and improve the efficiency of smart contracts on EVM compatible networks. By prioritizing cost-efficient actions, minimizing storage usage, utilizing in-line assembly, and following other best practices discussed in this article, developers can effectively reduce the gas consumption of their contracts.
However, it should be noted that during the process of optimization, developers must exercise caution to prevent the introduction of security vulnerabilities. The drive to streamline code and reduce gas consumption should never compromise the inherent security of a smart contract.
As an experienced blockchain security firm, CertiK has significant expertise in this area. Our smart contract auditing services encompass gas optimization recommendations and ensure both performance and robust security. If developers are seeking expert guidance in striking the right balance between efficiency and safety, they should consider engaging us for a comprehensive and professional audit. Our team is committed to aiding developers in achieving secure, efficient, and high-performing smart contracts on all blockchain networks, EVM and otherwise.