10 Expert Solidity Gas Optimization Techniques
Ethereum gas fees have long been a source of concern for users, and while the recent Ethereum Proof-of-Stake merge introduced a more energy-efficient system, there was little effect on gas fees. To maintain high standards, minimize risk, write clean code, and create secure, cost-effective smart contracts, it is critical to know the techniques for optimizing gas with Solidity.
This article will give you a near-complete understanding of the key concepts underlying gas optimization with Solidity, examples of optimized (and sub-optimal) smart contract code, and tips on how to integrate these Solidity gas optimization concepts into your web3 project today.
What is gas and gas optimization in Solidity?
Gas is the unit of measurement for the amount of computational effort required to carry out specific operations on the Ethereum network, and Solidity gas optimization is the process of making your Solidity smart code less expensive to execute.
Because each Ethereum transaction necessitates the use of computational resources, each transaction necessitates a fee. The fee required to complete an Ethereum transaction is referred to as gas.
When a smart contract is compiled in Solidity, it is converted into a series of "operation codes," also referred to as opcodes. Each opcode is given a predefined amount of gas, which represents the computing work necessary to carry out that specific operation.
Opcodes and bytecodes are similar, however, bytecodes use hexadecimal integers to represent them. Bytecodes are executed by the Ethereum Virtual Machine, also known as the EVM, which is a piece of software that executes smart contracts and sits atop the Ethereum node and network layers.
The goal of optimization is to reduce the overall number of operations needed to run a smart contract, and optimized smart contracts not only reduce the gas needed to process transactions, they are also a protection against malicious misuse.
Top 10 Solidity Gas Optimization Techniques
1. Use Mappings Instead of Arrays
There are two data types to describe lists of data in Solidity, arrays and maps, and their syntax and structure are quite different, allowing each to serve a distinct purpose. While arrays are packable and iterable, mappings are less expensive.
For example, creating an array of cars in Solidity might look like this:
Let’s see how to create a mapping for cars:
When using the mapping keyword, you will specify the data type for the key (uint) and the value (string). Then you can add some data using the constructor function.
Except where iteration is required or data types can be packed, it is advised to use mappings to manage lists of data in order to conserve gas. This is beneficial for both memory and storage.
An integer index can be used as a key in a mapping to control an ordered list. Another advantage of mappings is that you can access any value without having to iterate through an array as would otherwise be necessary.
2. Enable the Solidity Compiler Optimizer
The Solidity compiler optimizer works to make complex expressions simpler, which minimizes the size of the code and the cost of execution via inline operations, deployments costs, and function call costs.
The Solidity optimizer specializes in inline operations. Even though an action like inlining functions can result in significantly larger code, it is frequently used because it creates the potential for additional simplifications.
Deployment costs and function call costs are two more areas where the compiler optimizer impacts your smart contracts’ gas.
For example, deployment costs decrease with the decrease in "runs"—which specifies how often each opcode will be executed over the life of a contract. The impact on function call costs, however, increases with the number of runs. That’s because code optimized for more runs costs more to deploy and less after deployment.
In the examples below, runs are set at 200 and 10,000:
Increasing runs to 10,000 and setting the default value to true:
3. Minimize On-Chain Data
Because on-chain data is limited to what can be created natively inside a blockchain network (e.g. state, account addresses, balances, etc.), you can reduce unnecessary operations and complex computations by saving less data in storage variables, batching operations, and avoiding looping.
The less data you save in storage variables, the less gas you'll need. Keep all data off-chain and only save the smart contract’s critical info on-chain. Developers can create more complex applications, including prediction markets, stablecoins, and parametric insurance, by integrating off-chain data into a blockchain network.
Using events to store data is a popular, but ill-advised method for gas optimization because, while it is less expensive to store data in events relative to variables, the data in events cannot be accessed by other smart contracts on-chain.
Batching operations enables developers to batch actions by passing dynamically sized arrays that can execute the same functionality in a single transaction, rather than requiring the same method several times with different values.
Consider the following scenario: a user wants to call getData() with five different inputs. In the streamlined form, the user would only need to pay the transaction's fixed gas cost and the gas for the msg.sender check once.
Avoid looping through lengthy arrays; not only will it consume a lot of gas, but if gas prices rise too much, it can even prevent your contract from being carried out beyond the block gas limit.
Instead of looping over an array until you locate the key you need, use mappings, which are hash tables that enable you to retrieve any value using its key in a single action.
4. Use Indexed Events
Events are used to let users know when something occurs on the blockchain, as smart contracts cannot hear events on their own because contract data lives in the States trie, and event data is stored in the Transaction Receipts trie.
Events in Solidity are a shortcut to speed up the development of external systems working in combination with smart contracts. All information in the blockchain is public, and any activity can be detected by closely examining the transactions.
Including a mechanism to keep track of a smart contract's activity after it is deployed is helpful in reducing overall gas. While looking at all of the contract's transactions is one way to keep track of the activity, because message calls between contracts are not recorded on the blockchain, that approach might not be sufficient.
You can search for logged events using the indexed parameters as filters for those events.
5. Use uint8 Can Increase Gas Cost
A smart contract's gas consumption can be higher if developers use items that are less than 32 bytes in size because the Ethereum Virtual Machine can only handle 32 bytes at a time. In order to increase the element's size to the necessary size, the EVM has to perform additional operations.
The cost in the above example is 22,150 + 2,000 gas, compared with 7,050 gas when using a type higher than 32 bytes.
Only when you’re working with storage values is it advantageous to utilize reduced-size parameters because the compiler will compress several elements into one storage slot, combining numerous reads or writes into a single operation.
Smaller-size unsigned integers, such as uint8, are only more effective when multiple variables can be stored in the same storage space, like in structs. Uint256 uses less gas than uint8 in loops and other situations.
6. Pack Your Variables
When processing data, the EVM adopts a novel approach: each contract has a storage location where data is kept permanently, as well as a persistent storage space where data can be read, written, and updated.
There are 2,256 slots in the storage, each of which holds 32 bytes. Depending on their particular nature, the "state variables," or variables declared in a smart contract that are not within any function, will be stored in these slots.
Smaller-sized state variables (i.e. variables with less than 32 bytes in size), are saved as index values in the sequence in which they were defined, with 0 for position 1, 1 for position 2, and so on. If small values are stated sequentially, they will be stored in the same slot, including very small values like uint64.
Consider the following example:
Small values are not stored sequentially and use unnecessary storage space.
Small values are stored sequentially and use less storage space because they are packed together.
7. Free Up Unused Storage
Deleting your unused variables helps free up space and earns a gas refund. Deleting unused variables has the same effect as reassigning the value type with its default value, such as the integer's default value of 0, or the address zero for addresses.
Mappings, however, are unaffected by deletion, as the keys of mappings may be arbitrary and are generally unknown. Therefore, if you delete a struct, all of its members that are not mappings will reset and also recurse into its members. However, individual keys and the values they relate to can be removed.
8. Store Data in calldata Instead of Memory for Certain Function Parameters
Instead of copying variables to memory, it is typically more cost-effective to load them immediately from calldata. If all you need to do is read data, you can conserve gas by saving the data in calldata.
Because the values in calldata cannot be changed while the function is being executed, if the variable needs to be updated when calling a function, use memory instead.
9. Use immutable and constant
Immutable and constant are keywords that can be used on state variables to limit changes to their state. Constant variables cannot be changed after being compiled, whereas immutable variables can be set within the constructor. Constant variables can also be declared at the file level, such as in the example below:
10. Use the external Visibility Modifier
Use the external function visibility for gas optimization because the public visibility modifier is equivalent to using the external and internal visibility modifier, meaning both public and external can be called from outside of your contract, which requires more gas.
Remember that of these two visibility modifiers, only the public modifier can be called from other functions inside of your contract.
Test Your Smart Contract Before Deployment
This code will always produce the same gas used in Hardhat, Rinkeby, and Mainnet, regardless of the environment in which it is run. When testing your functionalities, pay special attention to the ones that are most similar to the mint function because those are the ones that your users will access most frequently.
In this guide, we covered the importance of gas optimization, the value it gives developers, and ten techniques for writing gas-optimized smart contracts with Solidity.
As a web3 and blockchain developer, optimizing gas cost in Solidity smart contracts is one of the most challenging and important aspects of creating a high-quality, efficient project. It requires practice and a thorough understanding of both the concepts and practicalities of Ethereum and Solidity. Gas optimization is a benefit not just to your project, but the blockchain ecosystem at large.