Gas fees on the Ethereum mainnet have always been a significant issue, especially during network congestion. During peak times, users often have to pay extremely high transaction fees. Therefore, optimizing gas fees during the smart contract development phase is particularly important. Optimizing gas consumption not only effectively reduces transaction costs but also enhances transaction efficiency, providing users with a more economical and efficient blockchain experience.

This article will outline the gas fee mechanism of the Ethereum Virtual Machine (EVM), related core concepts of gas fee optimization, and best practices for optimizing gas fees when developing smart contracts. It is hoped that these contents can provide inspiration and practical help to developers, while also helping ordinary users better understand how EVM gas fees operate, collectively addressing challenges in the blockchain ecosystem.

Introduction to the EVM Gas Fee Mechanism

In EVM-compatible networks, 'gas' refers to the unit used to measure the computational power required to execute specific operations.

The diagram below illustrates the structural layout of the EVM. In the figure, gas consumption is divided into three parts: operation execution, external message calls, and reading and writing memory and storage.

Source: Ethereum Official Website [1]

Since executing each transaction requires computational resources, a fee is charged to prevent infinite loops and denial-of-service (DoS) attacks. The fee required to complete a transaction is called the 'gas fee.'

Since the implementation of EIP-1559 (London Hard Fork), gas fees have been calculated using the following formula:

Gas fee = units of gas used * (base fee + priority fee)

Base fees are burned, while priority fees serve as incentives to encourage validators to add transactions to the blockchain. Setting a higher priority fee when sending a transaction can increase the likelihood of the transaction being included in the next block. This is similar to a 'tip' paid by users to validators.

1. Understand gas optimization in the EVM

When compiling smart contracts with Solidity, the contracts are converted into a series of 'opcodes'.

Every opcode (such as creating contracts, making message calls, accessing account storage, and executing operations on the virtual machine) has a recognized gas consumption cost, which is recorded in the Ethereum yellow paper [2].

After multiple modifications of EIPs, the gas costs of some opcodes have been adjusted, which may differ from those in the yellow paper. For the latest details on opcode costs, please refer to here [3].

2. Basic concepts of gas optimization

The core concept of gas optimization is to prioritize cost-efficient operations on the EVM blockchain and avoid operations that incur high gas costs.

In the EVM, the following operations have lower costs:

  • Read and write memory variables

  • Read constant and immutable variables

  • Read and write local variables

  • Read calldata variables, such as calldata arrays and structures

  • Internal function calls

Costly operations include:

  • Read and write state variables stored in the contract's storage

  • External function calls

  • Loop operations

Best Practices for Optimizing EVM Gas Fees

Based on the aforementioned basic concepts, we have compiled a list of best practices for gas fee optimization for the developer community. By following these practices, developers can reduce the gas fee consumption of smart contracts, lower transaction costs, and create more efficient and user-friendly applications.

1. Minimize the use of storage as much as possible

In Solidity, storage is a limited resource, and its gas consumption is significantly higher than that of memory. Each time a smart contract reads or writes data from storage, it incurs high gas costs.

According to the definition in the Ethereum yellow paper, the cost of storage operations is more than 100 times higher than that of memory operations. For instance, the OPcodes mload and mstore instructions only consume 3 gas units, while storage operations such as sload and sstore require at least 100 units even in the most ideal situation.

Methods to limit storage usage include:

  • Store non-permanent data in memory

  • Reduce the number of storage modifications: by keeping intermediate results in memory and only assigning the results to storage variables after all calculations are complete.

2. Variable packing

The number of storage slots used in a smart contract and the way developers represent data significantly influence gas fee consumption.

The Solidity compiler will pack consecutive storage variables during the compilation process and use 32-byte storage slots as the basic unit of variable storage. Variable packing refers to arranging variables in such a way that multiple variables can fit into a single storage slot.

On the left is a less efficient implementation that will consume 3 storage slots; on the right is a more efficient implementation.

By adjusting this detail, developers can save 20,000 gas units (storing an unused storage slot consumes 20,000 gas), but now only two storage slots are needed.

Since each storage slot consumes gas, variable packing optimizes gas usage by reducing the number of required storage slots.

3. Optimize data types

A variable can be represented by multiple data types, but the operational costs for different data types also vary. Choosing the appropriate data type helps optimize gas usage.

For example, in Solidity, integers can be subdivided into different sizes: uint8, uint16, uint32, etc. Since the EVM operates in 256-bit increments, using uint8 means the EVM must first convert it to uint256, which incurs additional gas costs.

We can compare the gas costs of uint8 and uint256 using the code in the figure. The UseUint() function consumes 120,382 gas units, while the UseUInt8() function consumes 166,111 gas units.

Individually, using uint256 is cheaper than uint8. However, if we use the variable packing optimization suggested earlier, it would be different. If developers can pack four uint8 variables into one storage slot, the total cost of iterating through them will be lower than that of four uint256 variables. In this way, the smart contract can read and write to one storage slot at once and place the four uint8 variables into memory/storage in one operation.

4. Use fixed-size variables instead of dynamic variables

If data can be kept within 32 bytes, it is recommended to use the bytes32 data type instead of bytes or strings. Generally speaking, fixed-size variables consume less gas than variable-size ones. If the byte length can be limited, choose the smallest length from bytes1 to bytes32.

5. Mappings vs. Arrays

Data lists in Solidity can be represented using two data types: arrays and mappings, but their syntax and structure are entirely different.

Mappings are generally more efficient and cost-effective, but arrays have iterability and support data type packing. Therefore, it is recommended to prioritize using mappings when managing data lists, unless iteration is needed or gas consumption can be optimized through data type packing.

6. Use calldata instead of memory

Variables declared in function parameters can be stored in calldata or memory. The main difference between the two is that memory can be modified by functions, whereas calldata is immutable.

Remember this principle: if function parameters are read-only, prefer using calldata over memory. This avoids unnecessary copying from function calldata to memory.

Example 1: Using memory

When using the memory keyword, the values of arrays are copied from the encoded calldata to memory during ABI decoding. The execution cost of this code block is 3,694 gas units.

Example 2: Using calldata

When reading values directly from calldata, skip the intermediate memory operations. This optimization reduces the execution cost to just 2,413 gas units, resulting in a 35% improvement in gas efficiency.

7. Use the Constant/Immutable keywords whenever possible

Constant/Immutable variables are not stored in the contract's storage. These variables are computed at compile time and stored in the contract's bytecode. Therefore, their access costs are much lower compared to storage, and it is recommended to use the Constant or Immutable keywords whenever possible.

8. Use unchecked when overflow/underflow will not occur

When developers can ensure that arithmetic operations will not result in overflow or underflow, they can use the unchecked keyword introduced in Solidity v0.8.0 to avoid unnecessary overflow or underflow checks, thereby saving gas costs.

In the diagram below, constrained by condition i

Moreover, compilers from version 0.8.0 and above no longer require the use of the SafeMath library, as the compiler itself has built-in overflow and underflow protection.

9. Optimize modifiers

The code of modifiers is embedded into the modified function, and each time a modifier is used, its code is copied. This increases the size of the bytecode and raises gas consumption. Here is a method to optimize the gas cost of modifiers:

Before optimization:

Optimized:

In this example, by restructuring the logic into the internal function _checkOwner(), the internal function can be reused in the modifier, reducing bytecode size and lowering gas costs.

10. Short-circuit optimization

For || and && operators, logical operations undergo short-circuit evaluation, meaning that if the first condition can already determine the result of the logical expression, the second condition will not be evaluated.

To optimize gas consumption, conditions with low computational costs should be placed first, allowing for the possibility of skipping costly computations.

Additional general advice

1. Remove unused code

If there are unused functions or variables in the contract, it is recommended to delete them. This is the most direct way to reduce contract deployment costs and keep the contract size small.

Here are some practical suggestions:

Use the most efficient algorithms for calculations. If certain calculations are used directly in the contract, redundant calculations should be removed. Essentially, any unused computations should be eliminated.

In Ethereum, developers can receive gas rewards by freeing up storage space. When a variable is no longer needed, it should be deleted using the delete keyword or set to its default value.

Loop optimization: avoid costly loop operations, merge loops whenever possible, and move repeated calculations out of the loop body.

2. Use precompiled contracts

Precompiled contracts provide complex library functions, such as encryption and hashing operations. Since the code is not run on the EVM but executed locally on client nodes, less gas is required. Using precompiled contracts can save gas by reducing the computational workload needed to execute smart contracts.

Examples of precompiled contracts include the Elliptic Curve Digital Signature Algorithm (ECDSA) and the SHA2-256 hash algorithm. By using these precompiled contracts in smart contracts, developers can reduce gas costs and improve the efficiency of applications.

For a complete list of precompiled contracts supported by the Ethereum network, please refer to here [4].

3. Use inline assembly code

Inline assembly allows developers to write low-level yet efficient code that can be executed directly by the EVM, without the need for expensive Solidity opcodes. Inline assembly also allows for more precise control over memory and storage usage, further reducing gas fees. Additionally, inline assembly can perform complex operations that are difficult to achieve using Solidity alone, providing more flexibility for optimizing gas consumption.

Here is an example code that saves gas using inline assembly:

From the diagram above, it can be seen that the second use case with inline assembly technology has higher gas efficiency compared to the standard use case.

However, using inline assembly may also carry risks and is prone to errors. Therefore, it should be used with caution and limited to experienced developers.

4. Use Layer 2 solutions

Using Layer 2 solutions can 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 can offload transaction processing from the main Ethereum chain, achieving faster and cheaper transactions.

By bundling a large number of transactions together, these solutions reduce the number of on-chain transactions, thereby lowering gas fees. Using Layer 2 solutions also enhances the scalability of Ethereum, allowing more users and applications to participate in the network without causing congestion due to overload.

5. Use optimization tools and libraries

There are several optimization tools available, such as the solc optimizer, Truffle's build optimizer, and Remix's Solidity compiler.

These tools can help minimize the size of bytecode, remove unused code, and reduce the number of operations required to execute smart contracts. Combined with other gas optimization libraries, such as 'solmate', developers can effectively reduce gas costs and improve the efficiency of smart contracts.

Conclusion

Optimizing gas consumption is an important step for developers, as it minimizes transaction costs and improves the efficiency of smart contracts on EVM-compatible networks. By prioritizing cost-saving operations, reducing storage usage, utilizing inline assembly, and following other best practices discussed in this article, developers can effectively reduce the gas consumption of contracts.

However, it must be noted that during the optimization process, developers must act cautiously to prevent introducing security vulnerabilities. The inherent security of smart contracts should never be sacrificed during the process of optimizing code and reducing gas consumption.

[1]: https://ethereum.org/en/developers/docs/gas/

[2]: https://ethereum.github.io/yellowpaper/paper.pdf

[3]: https://www.evm.codes/

[4]: https://www.evm.codes/precompiled