This last post in our series of gas-saving articles will discuss simple Solidity functionalities that developers often ignore. We will explore two significant Solidity mindsets for gas optimization: “Freeing Storage” and Solidity’s “Optimizer.“
Freeing Storage Pattern
Certain storage variables within a smart contract may become obsolete as time passes, yet they persistently consume valuable space on the blockchain. However, the Ethereum network presents a solution to this issue by encouraging the reduction of blockchain bloat by providing gas refunds to free up storage space. Using Solidity’s delete
keyword, developers can use this incentive to eliminate unnecessary storage variables. This proactive approach enables developers to streamline their contracts, free blockchain congestion, and optimize resource utilization on the Ethereum network.
Example and Scenario
Let’s look at the example of a Uniswap v3 factory to familiarize ourselves with how the creators use the delete keyword to free storage and save gas when deploying every new trading pool.
pragma solidity ^0.8.0;
contract UniswapV3Factory {
/// @inheritdoc IUniswapV3Factory
function createPool(
address tokenA,
address tokenB,
uint24 fee
) external override noDelegateCall returns (address pool) {
require(tokenA != tokenB);
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
require(token0 != address(0));
int24 tickSpacing = feeAmountTickSpacing[fee];
require(tickSpacing != 0);
require(getPool[token0][token1][fee] == address(0));
pool = deploy(address(this), token0, token1, fee, tickSpacing);
getPool[token0][token1][fee] = pool;
// populate mapping in the reverse direction, deliberate choice to avoid the cost of comparing addresses
getPool[token1][token0][fee] = pool;
emit PoolCreated(token0, token1, fee, tickSpacing, pool);
}
function deploy(
address factory,
address token0,
address token1,
uint24 fee,
int24 tickSpacing
) internal returns (address pool) {
parameters = Parameters({factory: factory, token0: token0, token1: token1, fee: fee, tickSpacing: tickSpacing});
pool = address(new UniswapV3Pool{salt: keccak256(abi.encode(token0, token1, fee))}());
delete parameters;
}
}
The deploy
function uses a temporary Parameters
structure to store the variables needed for contract creation. After the function deploys the new contract, it deletes the parameter’s struct using the delete
keyword. This process ensures that we free up the storage immediately after the function uses the parameters and they’re no longer needed. By deleting the parameters, the function ensures that it does not leave any unnecessary data in storage, which can help reduce the overall gas cost.
Knowing that you can receive a gas refund of 15,000 units for resetting a storage slot to zero is beneficial. However, you must utilize this refund within the same transaction, and carrying it over to future transactions will not be possible. Specifically, the gas refund can cover up to half of the total gas consumed, allowing you to offset some of your gas costs through efficient storage cleanup, enabling more operations within the same transaction.
It’s important to note that the refunded gas will not be returned to you in Ether but instead helps reduce the immediate gas expenditure.
Solidity Optimizer
Optimizing Solidity code to save gas is a considerable feat. It’s a delicate balancing act for developers, who must ensure their smart contracts are efficient and uphold their intended purpose. One standard tool in the developer’s arsenal is the Solidity Optimizer. Integrated into Solidity compilers, this feature works behind the scenes to fine-tune code efficiency through various optimization techniques. However, relying solely on the optimizer isn’t the silver bullet. While it does an admirable job, developers should augment its efforts with manual design patterns. These patterns target inefficiencies that may slip past the compiler’s gaze, ensuring a thorough optimization approach for smart contracts.
The Solidity documentation offers a comprehensive section dedicated to the Optimizer and its various use cases. As the Optimizer is undergoing continuous development and updates, it’s crucial to stay updated with the latest information from the docs. By doing so, you can actively participate in the evolution of this useful tool, which we will introduce in this section.
Solidity optimization is a multi-layered process that enhances code efficiency and reduces gas costs. Optimizations take place at three distinct levels of execution, starting with code generation, followed by transformations on Yul IR (Intermediate Representation) code, and culminating in opcode-level optimizations. The opcode-based optimizer simplifies opcodes, combines similar code sets, and removes unused code. It can be activated through the `—optimize` option and the `solc` as a command line argument.
Solc —optimize
If you use tools like Truffle to develop or deploy your contracts, update your truffle-config.js
to the example configuration below. It’s worth noting that you can experiment with different values for the optimizer.runs
parameter to find the optimal balance between gas savings and compilation time.
module.exports = {
// ...
compilers: {
solc: {
optimizer: {
enabled: true,
runs: 200
}
}
}
}
The Solidity compiler (solc) doesn’t just generate bytecode; it does so in an advanced way by utilizing an intermediate representation (IR). Meanwhile, the Yul-based optimizer operates across function calls, allowing for more advanced optimizations like reordering and removing side-effect-free function calls. Targeting the solidity source, we can use solc --ir-optimized --optimize
for an optimized Yul IR or alternatively for a stand-alone Yul mode
use solc --strict-assembly --optimize
. On the other hand, for tools like truffle
or hardhat
we can look deeper into the Solidity compiler’s input and output JSON description and this cutting-edge compilation mode, known as ‘via IR,’ can be activated through the viaIR
setting in your Truffle
or Hardhat
configuration.
So, here is what we can set in a truffle configuration.
module.exports = {
// ...
compilers: {
solc: {
version: "0.8.0", // Specify the version of solc you want to use
settings: {
optimizer: {
enabled: true,
runs: 200
},
viaIR: true, // This enables the Yul IR optimizer, which is False by default.
outputSelection: {
"*": {
"*": ["ir", "irOptimized"] // Output both IR and optimized IR
}
}
}
}
}
};
The ir
value in the outputSelection
is the Yul intermediate representation of the code before optimization and irOptimized
which is the Yul-optimized version. Also, Truffle doesn’t have a direct way to enable --strict-assembly
mode through the configuration yet. You might need to use a custom script or a plugin to achieve this option.
If you use Hardhat
, the following example is configured for the compiler in your hardhat.config.js
or hardhat.config.ts
file. Currently, the viaIR
option performs optimally when used with the Solidity optimizer. However, Hardhat’s functionality notably features like stack traces
, is more reliable when the Optimizer is turned off. Consequently, full support for the viaIR
option in Hardhat is still a work in progress. Despite these limitations, you can enable viaIR to compile your contracts and run tests, but be aware that certain Hardhat features may not function perfectly.
module.exports = {
// ...
solidity: {
version: "0.8.0",
settings: {
optimizer: {
enabled: true,
runs: 200,
details: {
yulDetails: {
optimizerSteps: "u", // Use all optimization steps
},
},
},
viaIR: true,
}
}
};
Final words
As Solidity evolves, staying informed about the latest optimization techniques and compiler updates is crucial. By combining these strategies, you can significantly enhance the performance and cost-effectiveness of your smart contracts, contributing to a more efficient and scalable ecosystem. Keep experimenting, stay updated, and always optimize your Solidity code for the best results.