Hardhat: Optimizing the size of smart contracts
In this tutorial, you'll learn how to profile and optimize smart contract sizes with Hardhat and the Hardhat Contract Sizer plugin.
Objectives
By the end of this tutorial you should be able to:
- Use Hardhat Contract Sizer plugin to profile contract size
- Describe common strategies for managing the contract size limit
- Describe the impact that inheritance has on the byte code size limit
- Describe the impact that external contracts have on the byte code size limit
- Describe the impact of using libraries has on the byte code size limit
- Describe the impact of using the Solidity optimizer
Overview
In the world of blockchain and Ethereum, optimizing smart contract sizes is crucial. Smaller contracts consume less gas during deployment and execution, which is translated into gas costs savings for your users. Fortunately, you can use in Hardhat the hardhat-contract-sizer
plugin that helps you analyze and optimize the size of your smart contracts.
Setting up the Hardhat Contract Sizer plugin
Hardhat Contract Sizer is a community-developed plugin that enables the profiling of smart contract by printing the size of your smart contracts in the terminal. This is helpful during development since it allows you to immediately identify potential issues with the size of your smart contracts. Keep in mind that the maximum size of a smart contract in Ethereum is 24 KiB.
To install, run npm install -D hardhat-contract-sizer
.
Then, import hardhat-contract-sizer
in hardhat.config.ts
:
import "hardhat-contract-sizer"
When finished, you are ready to use the plugin.
Your first size profiling
Similar to the previous tutorials, you begin by profiling the smart contract Lock.sol
.
Run npx hardhat size-contracts
, which is a task added to Hardhat once you set up and configure the hardhat-contract-sizer
plugin.
You are then able to see:
·------------------------|--------------------------------|--------------------------------·
| Solc version: 0.8.18 · Optimizer enabled: false · Runs: 200 │
·························|································|·································
| Contract Name · Deployed size (KiB) (change) · Initcode size (KiB) (change) │
·························|································|·································
| BalanceReader · 0.612 () · 0.644 () │
·························|································|·································
| Lock · 1.009 () · 1.461 () │
Although your contract is simple, you can see immediately the power of the hardhat-contract-sizer
plugin, since it show you the size of your contracts.
Common strategies to optimize contract sizes
In order to illustrate some of the strategies to optimize the size of your contracts, create two smart contracts, Calculator.sol
and ScientificCalculator.sol
, with the following:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
contract Calculator {
function add(uint256 a, uint256 b) external pure returns(uint256) {
require(a > 0 && b > 0, "Invalid values");
return a + b;
}
function sub(uint256 a, uint256 b) external pure returns(uint256) {
require(a > 0 && b > 0, "Invalid values");
return a - b;
}
function mul(uint256 a, uint256 b) external pure returns(uint256) {
require(a > 0 && b > 0, "Invalid values");
return a * b;
}
function div(uint256 a, uint256 b) external pure returns(uint256) {
require(a > 0 && b > 0, "Invalid values");
return a / b;
}
}
contract ScientificCalculator is Calculator {
function power(uint256 base, uint256 exponent) public pure returns (uint256) {
require(base > 0 && exponent > 0, "Invalid values");
return base ** exponent;
}
}
Then, run the command npx hardhat size-contracts
again and you should be able to see:
·------------------------|--------------------------------|--------------------------------·
| Solc version: 0.8.18 · Optimizer enabled: false · Runs: 200 │
·························|································|·································
| Contract Name · Deployed size (KiB) (change) · Initcode size (KiB) (change) │
·························|································|·································
| BalanceReader · 0.612 (0.000) · 0.644 (0.000) │
·························|································|·································
| Lock · 1.009 (0.000) · 1.461 (0.000) │
·························|································|·································
| Calculator · 1.299 () · 1.330 () │
·························|································|·································
| ScientificCalculator · 1.827 () · 1.858 () │
·------------------------|--------------------------------|--------------------------------·
Notice how the size of ScientificCalculator
is bigger than Calculator
. This is because ScientificCalculator
is inheriting the contract Calculator
, which means all of its functionality and code is available in ScientificCalculator
and that will influence its size.
Code abstraction and modifiers
At this point as a smart contract developer, you can review your smart contract code and look for ways into you can optimize it.
The first thing you notice in the source code is the extensive use of:
require(a > 0 && b > 0, "Invalid values");
A possible optimization is to abstract repetitive code into modifiers, such as the following:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
contract Calculator {
error InvalidInput();
function add(uint256 a, uint256 b) external pure onlyValidInputs(a,b) returns(uint256) {
return a + b;
}
function sub(uint256 a, uint256 b) external pure onlyValidInputs(a,b) returns(uint256) {
return a - b;
}
function mul(uint256 a, uint256 b) external pure onlyValidInputs(a,b) returns(uint256) {
return a * b;
}
function div(uint256 a, uint256 b) external pure onlyValidInputs(a,b) returns(uint256) {
return a / b;
}
modifier onlyValidInputs(uint256 a, uint256 b) {
if(a == 0 && b == 0){
revert InvalidInput();
}
_;
}
}
And for ScientificCalculator
:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "./Calculator.sol";
contract ScientificCalculator is Calculator {
function power(uint256 base, uint256 exponent) public pure onlyValidInputs(base,exponent) returns (uint256) {
return base ** exponent;
}
}
Notice the usage of the modifier and the replacement of the require to use a custom error.
When you run the npx hardhat size-contracts
command, you should be able to see:
·------------------------|--------------------------------|--------------------------------·
| Solc version: 0.8.18 · Optimizer enabled: false · Runs: 200 │
·························|································|·································
| Contract Name · Deployed size (KiB) (change) · Initcode size (KiB) (change) │
·························|································|·································
| BalanceReader · 0.612 (0.000) · 0.644 (0.000) │
·························|································|·································
| Lock · 1.009 (0.000) · 1.461 (0.000) │
·························|································|·································
| Calculator · 1.165 (0.000) · 1.196 (0.000) │
·························|································|·································
| ScientificCalculator · 1.690 (0.000) · 1.722 (0.000) │
·------------------------|--------------------------------|--------------------------------·
Although the optimization is small, you can see that there are some improvements.
You can continue this process until you feel comfortable with the size of the contract.
Split into multiple contracts
It is common to split your smart contracts into multiple contracts, not only because of the size limitations but to create better abstractions, to improve readability, and to avoid repetition.
From a contract size perspective, having multiple independent contracts will reduce the size of each contract. For example, the original size of a smart contract was 30 KiB: by splitting into 2, you will end up with 2 smart contracts of ~15 KiB that are within the limits of Solidity. Keep in mind that this will influence gas costs during the execution of the contract because it will require it to call an external contract.
In order to explain this example, create a contract called Computer
that contains a function called executeProcess
:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "hardhat/console.sol";
contract Computer {
function executeProcess() external view {
// ...logic to be implemented
}
}
In this example, the executeProcess
function of Computer
requires certain functionality of Calculator
and a new contract called Printer
:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "hardhat/console.sol";
contract Printer {
function print(string memory _content) external view {
require(bytes(_content).length > 0, "invalid length");
console.log(_content);
}
}
The easiest way for Computer
to access both functionalities is to inherit; however, as all of these contracts continue adding functionality, the size of the code will also increase. You will reach the contract size issue at some point, since you are copying the entire functionality into your contract. You can better allow that functionality to be kept with their specific contracts and if the Computer
requires to access that functionality, you could call the Calculator
and Printer
contracts.
But in this example, there is a process that must call both Calculator
and Printer
:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "hardhat/console.sol";
import "./Calculator.sol";
import "./Printer.sol";
contract Computer {
Calculator calculator;
Printer printer;
constructor(address _calculator, address _printer) {
calculator = Calculator(_calculator);
printer = Printer(_printer);
}
function executeProcess() external view {
// call Calculator contract, i.e calculator.add(a, b);
// call Printer contract, i.e printer.print("value to print");
}
}
If you run the contract sizer plugin, you get:
·------------------------|--------------------------------|--------------------------------·
| Solc version: 0.8.18 · Optimizer enabled: true · Runs: 10000 │
·························|································|·································
| Contract Name · Deployed size (KiB) (change) · Initcode size (KiB) (change) │
·························|································|·································
| console · 0.084 (0.000) · 0.138 (0.000) │
·························|································|·································
| Computer · 0.099 (0.000) · 0.283 (0.000) │
·························|································|·································
| Calculator · 0.751 (0.000) · 0.782 (0.000) │
·························|································|·································
| Printer · 0.761 (0.000) · 0.792 (0.000) │
·························|································|·································
| ScientificCalculator · 1.175 (0.000) · 1.206 (0.000) │
·------------------------|--------------------------------|--------------------------------·
Notice how your Computer
contract is very small but still has the capability to access all the functionality of Printer
and Calculator
.
Although this will reduce the size of each contract, the costs of this are discussed more deeply in the Gas Optimization article.
Using libraries
Libraries are another common way to encapsulate and abstract common functionality that can be shared across multiple contracts. This can significantly impact the bytecode size of the smart contracts. Remember that in Solidity, libraries can be external and internal.
The way internal libraries affect the contract size is very similar to the way inherited contracts affects a contract's size; this is because the internal functions of the library is included within the final bytecode.
But when the libraries are external, the behavior is different: the way Solidity calls external libraries is by using a special function called delegate call.
External libraries are commonly deployed independently and can be reused my multiple contracts. Since libraries don't keep a state, they behave like pure functions in the Blockchain.
In this example, your computer will use the Calculator
library only. Then, you would have the following:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
library Calculator {
error InvalidInput();
function add(uint256 a, uint256 b) external pure onlyValidInputs(a,b) returns(uint256) {
return a + b;
}
function sub(uint256 a, uint256 b) external pure onlyValidInputs(a,b) returns(uint256) {
return a - b;
}
function mul(uint256 a, uint256 b) external pure onlyValidInputs(a,b) returns(uint256) {
return a * b;
}
function div(uint256 a, uint256 b) external pure onlyValidInputs(a,b) returns(uint256) {
return a / b;
}
modifier onlyValidInputs(uint256 a, uint256 b) {
if(a == 0 && b == 0){
revert InvalidInput();
}
_;
}
}
Then, Computer
is:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "hardhat/console.sol";
import "./Calculator.sol";
import "./Printer.sol";
contract Computer {
using Calculator for uint256;
function executeProcess() external view {
uint256 a = 1;
uint256 b = 2;
uint256 result = a.add(b);
// ... logic to be implemented
}
}
Notice how you instructing the smart contract to use the Calculator
library for uint256
and how in the executeProcess
function, you can now use the add
function from the Calculator
library in all of the uint256
.
If you run the npx hardhat size-contracts
command, you then get:
·------------------------|--------------------------------|--------------------------------·
| Solc version: 0.8.18 · Optimizer enabled: true · Runs: 10000 │
·························|································|·································
| Contract Name · Deployed size (KiB) (change) · Initcode size (KiB) (change) │
·························|································|·································
| Calculator · 0.761 · 0.817 │
·························|································|·································
| Printer · 0.771 · 0.827 │
·························|································|·································
| Computer · 0.961 · 0.992 │
·------------------------|--------------------------------|--------------------------------·
In order to compare the impact, you can modify the external modifier from all of the Calculator
library functions and you will then have:
·------------------------|--------------------------------|--------------------------------·
| Solc version: 0.8.18 · Optimizer enabled: true · Runs: 10000 │
·························|································|·································
| Contract Name · Deployed size (KiB) (change) · Initcode size (KiB) (change) │
·························|································|·································
| Calculator · 0.084 · 0.138 │
·························|································|·································
| Printer · 0.084 · 0.138 │
·························|································|·································
| Computer · 1.139 · 1.170 │
·------------------------|--------------------------------|--------------------------------·
Which demonstrates why using external libraries can be a good option in order to optimize the size of your contracts.
Using the Solidity compiler optimizer
Another way to optimize the size of the smart contracts is to simply use the Solidity optimizer.
From the Solidity official docs:
Overall, the optimizer tries to simplify complicated expressions, which reduces both code size and execution cost.
You can enable the solidity optimizer in hardhat by simply adding the following to the hardhat.config.ts
file:
const config: HardhatUserConfig = {
solidity: {
version: "0.8.18",
settings: {
optimizer: {
enabled: true,
runs: 200
}
}
},
...
}
Notice the optimizer is enabled and has a parameter runs
. If you run the contract sizer command again, you will see the following:
·------------------------|--------------------------------|--------------------------------·
| Solc version: 0.8.18 · Optimizer enabled: true · Runs: 200 │
·························|································|·································
| Contract Name · Deployed size (KiB) (change) · Initcode size (KiB) (change) │
·························|································|·································
| BalanceReader · 0.351 (-0.262) · 0.382 (-0.262) │
·························|································|·································
| Lock · 0.471 (-0.538) · 0.661 (-0.800) │
·························|································|·································
| Calculator · 0.604 (-0.561) · 0.636 (-0.561) │
·························|································|·································
| ScientificCalculator · 0.930 (-0.761) · 0.961 (-0.761) │
·------------------------|--------------------------------|--------------------------------·
Notice the bigger improvement, but see what happens if you increase the runs
parameter value to 1000:
·------------------------|--------------------------------|--------------------------------·
| Solc version: 0.8.18 · Optimizer enabled: true · Runs: 1000 │
·························|································|·································
| Contract Name · Deployed size (KiB) (change) · Initcode size (KiB) (change) │
·························|································|·································
| BalanceReader · 0.400 (+0.050) · 0.432 (+0.050) │
·························|································|·································
| Lock · 0.537 (+0.066) · 0.728 (+0.066) │
·························|································|·································
| Calculator · 0.604 (0.000) · 0.636 (0.000) │
·························|································|·································
| ScientificCalculator · 0.945 (+0.016) · 0.977 (+0.016) │
·------------------------|--------------------------------|--------------------------------·
The size of the contract increased, however this means your code will be more efficient across the lifetime of the contract because the higher the runs
value the more efficient during execution but more expensive during deployment. You can read more in the Solidity documentation.
Conclusion
In this tutorial, you've learned how to profile and optimise smart contracts using the Hardhat development environment and the Hardhat Contract Sizer plugin. By focusing on the critical aspect of contract size, we've equipped ourselves with tools and strategies to create more efficient Solidity code.
As you continue your journey in smart contract development, keep in mind that optimizing contract sizes is a continuous process that requires careful consideration of trade-offs between size, readability, and gas efficiency.