Last month, I published an article highlighting how developers can significantly reduce gas costs by choosing the right storage types in their Solidity smart contracts. This topic garnered considerable interest, underscoring the ongoing developer quest for more gas-efficient contract operations. As the popularity of Ethereum Virtual Machine (EVM) networks continues to rise, so does the importance of minimizing transaction fees to make Web3 applications more accessible and cost-effective.
In this follow-up article, I will continue exploring gas optimization techniques in Solidity smart contracts. Beyond storage type selection, there are numerous other strategies developers can employ to enhance the efficiency of their smart contracts. By implementing these techniques, developers can not only lower gas fees but also improve the overall performance and user experience of their decentralized applications (DApps). The pursuit of gas optimization is crucial for the scalability and sustainability of EVM networks, making it a key area of focus for the future of Web3 development.
Gas Optimization Techniques
1. Storage areas
As discussed in the previous article, selecting the appropriate storage type is a crucial starting point for optimizing gas costs in blockchain operations. The Ethereum Virtual Machine (EVM) offers five storage areas: storage, memory, calldata, stack, and logs. For more details, please check out my previous article on Optimizing Gas in Solidity Smart Contracts. The approaches discussed there highlight the advantages of using memory over storage. In a practical example, avoiding excessive reading and writing to storage can reduce gas costs by up to half!
2. Constants and Immutable variables
Let’s take the following smart contact as an example:
contract GasComparison { uint256 public value = 250; address public account; constructor() { account = msg.sender; } }
The cost for creating this contract is 174,049 gas. As we can see, we are using storage with the instance variables. To avoid this, we should refactor to use constants and immutable variables.
Constants and immutables are added directly to the bytecode of the smart contract after compilation, so they do not use storage.
The optimized version of the previous smart contract is:
contract GasComparison { uint256 public constant VALUE = 250; address public immutable i_account; constructor() { i_account = msg.sender; } }
This time, the cost of creating the smart contract is 129154 gas, 25% less than the initial value.
3. Private over public variables
Continuing with the previous example, we notice that instance variables are public, which is problematic for two reasons. First, it violates data encapsulation. Second, it generates additional bytecode for the getter function, increasing the overall contract size. A larger contract size means higher deployment costs because the gas cost for deployment is proportional to the size of the contract.
One way to optimize is:
contract GasComparison { uint256 private constant VALUE = 250; address private immutable i_account; constructor() { i_account = msg.sender; } function getValue() public pure returns (uint256) { return VALUE; } }
Making all variables private without providing getter functions would make the smart contract less functional, as the data would no longer be accessible.
Even in this case, the creation cost was reduced to 92,289 gas, 28% lower than the previous case and 46% lower than the first case!
P.S. If we had kept the VALUE variable public and didn’t add the getValue function, the same amount of gas would have been consumed at contract creation.
4. Use interfaces
Using interfaces in Solidity can significantly reduce the overall size of your smart contract’s compiled bytecode, as interfaces do not include the implementation of their functions. This results in a smaller contract size, which in turn lowers deployment costs since gas costs for deployment are proportional to the contract size.
Additionally, calling functions through interfaces can be more gas-efficient. Since interfaces only include function signatures, the bytecode for these calls can be optimized. This optimization leads to potential gas savings compared to calling functions defined directly within a larger contract that contains additional logic and state.
While using interfaces can be beneficial for complex smart contracts and functions, it may not always be advantageous for simpler contracts. In the example discussed in previous sections, adding an interface can actually increase gas costs for straightforward contracts.
5. Inheritance over composition
Continuing the interface idea we get to inheritance. Look at the following smart contracts:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.18; contract Employee { address public account; constructor() { account = msg.sender; } } contract Manager { Employee private employee; constructor(address _employeeAddress) { employee = Employee(_employeeAddress); } function getEmployeeAccount() external view returns (address) { return employee.account(); } } contract Executable { Manager public manager; constructor(address _employeeAddress) { manager = new Manager(_employeeAddress); } function getMangerAccount() external view returns (address) { return manager.getEmployeeAccount(); } }
Here we have 2 smart contracts which interact through composition. The use-case is less important; what I want to underline is the external call which Manager needs to make to get the Employee account. The getManagerAccount called from the Executable account will consume 13,545 gas.
We can optimise this by using inheritance:
contract Employee { address public account; constructor() { account = msg.sender; } } contract Manager is Employee{ } contract Executable { Manager public manager; constructor(){ manager = new Manager(); } function getMangerAccount() external view returns (address) { return manager.account(); } }
This time getManagerAccount will take only 8,014 gas, 40% less than the previous case!
6. Variables size
Bytes and integers are among the most commonly used variable types in Solidity. Although the Ethereum Virtual Machine (EVM) operates with 32-byte lengths, selecting variables of this length for every instance is not ideal if the goal is gas optimization.
Bytes
Let’s take a look at the following smart contract:
contract BytesComparison { bytes32 public constant LONG_MESSAGE="Hello, world! This is a longer ."; bytes32 public constant MEDIUM_MESSAGE="Hello, world!"; bytes32 public constant SHORT_MESSAGE="H"; function concatenateBytes32() public pure returns (bytes memory) { bytes memory concatenated = new bytes(32 * 3); for (uint i = 0; i < 32; i++) { concatenated[i] = LONG_MESSAGE[i]; } for (uint j = 0; j < 32; j++) { concatenated[32 + j] = MEDIUM_MESSAGE[j]; } for (uint k = 0; k < 32; k++) { concatenated[64 + k] = SHORT_MESSAGE[k]; } return concatenated; } }
The execution cost of the concatenateBytes32 is 28,909 gas.
In terms of gas, optimization is recommended when working with bytes to narrow the size to the value used. In this case, an optimised version of this contract would be:
contract BytesComparison { bytes32 public constant LONG_MESSAGE="Hello, world! This is a longer ."; bytes16 public constant MEDIUM_MESSAGE="Hello, world!"; bytes1 public constant SHORT_MESSAGE="H"; function concatenateBytes() public pure returns (bytes memory) { // Create a bytes array to hold the concatenated result bytes memory concatenated = new bytes(32 + 16 + 1); for (uint i = 0; i < 32; i++) { concatenated[i] = LONG_MESSAGE[i]; } for (uint j = 0; j < 16; j++) { concatenated[32 + j] = MEDIUM_MESSAGE[j]; } concatenated[32 + 16] = SHORT_MESSAGE[0]; return concatenated; } }
In this case, the execution of concatenateBytes is 12,011 gas, 59% lower than in the previous case.
Int
However, this does not apply to integer types. While it might seem that using int16 would be more gas-efficient than int256, this is not the case. When dealing with integer variables, it is recommended to use the 256-bit versions: int256 and uint256.
The Ethereum Virtual Machine (EVM) works with 256-bit word size. Declaring them in different sizes will require Solidity to do additional operations to incorporate them in 256-bit word size, resulting in more gas consumption.
Let’s take a look at the following simple smart contract:
contract IntComparison { int128 public a=-55; uint256 public b=2; uint8 public c=1; //Method which does the addition of the variables. }
The creation cost for this will be 147,373 gas. If we optimize it as mentioned above, this is how it will look:
contract IntComparison { int256 public a=-55; uint256 public b=2; uint256 public c=1; //Method which does the addition of the variables. }
The creation cost this time will be 131,632 gas, 10% less than the previous case.
Consider that in the first scenario, we were only creating a simple contract without any complex functions. Such functions might require type conversions, which could lead to higher gas consumption.
Packing instance variables
There are cases where using smaller types for private variables is recommended. These smaller types should be used when they are not involved in logic that requires Solidity to perform additional operations. Additionally, they should be declared in a specific order to optimize storage. By packing them into a single 32-byte storage slot, we can optimize storage and achieve some gas savings.
If the previous smart contract did not involve complex computations, this optimized version using packing is recommended:
contract PackingComparison { uint8 public c=1; int128 public a=-55; uint256 public b=2; }
The creation cost this time will be 125,523 gas, 15% less than the previous case.
7. Fixed-size over dynamic variables
Fixed-size variables consume less gas than dynamic ones in Solidity primarily because of how the Ethereum Virtual Machine (EVM) handles data storage and access. Fixed-size variables have a predictable storage layout. The EVM knows exactly where each fixed-size variable is stored, allowing for efficient access and storage. In contrast, dynamic variables like strings, bytes, and arrays can vary in size, requiring additional overhead to manage their length and location in storage. This involves additional operations to calculate offsets and manage pointers, which increases gas consumption.
Although this is applicable for large arrays and complex operations, in simple cases, we won’t be able to spot any difference.
Use The Optimizer
Enable the Solidity Compiler optimization mode! It streamlines complex expressions, reducing both the code size and execution cost, which lowers the gas needed for contract deployment and external calls. It also specializes and inlines functions. While inlining can increase the code size, it often allows for further simplifications and enhanced efficiency.
Before you deploy your contract, activate the optimizer when compiling using:
solc –optimize –bin sourceFile.sol
By default, the optimizer will optimize the contract, assuming it is called 200 times across its lifetime (more specifically, it assumes each opcode is executed around 200 times). If you want the initial contract deployment to be cheaper and the later function executions to be more expensive, set it to –optimize-runs=1. If you expect many transactions and do not care for higher deployment cost and output size, set –optimize-runs to a high number.
There are various strategies for reducing gas consumption by optimizing Solidity code. The key is to select the appropriate techniques for each specific case requiring optimization. Making the right choices can often reduce gas costs by up to 50%. By applying these optimizations, developers can enhance the efficiency, performance, and user experience of their decentralized applications (DApps), contributing to the scalability and sustainability of Ethereum Virtual Machine (EVM) networks.
As we continue to refine these practices, the future of Web3 development looks increasingly promising.
Cyfrin Blog: Solidity Gas Optimization Tips
Read More: blog.web3labs.com