iTranslated by AI
Reducing Ethereum Smart Contract Size
Ethereum contracts have a size limit of 24.576 KB or less. This rule was added in 2016 via EIP-170 to prevent DoS attacks.
For simple code commonly found online, you likely won't hit this limit, but when building a dApp of a certain scale, you can reach it surprisingly easily. In my case, I encountered this limit while trying to build a full on-chain Twitter-like application on Ethereum.
To circumvent this restriction, I researched various techniques for reducing contract size and have summarized them here.
Measurement
Before optimizing, you can't improve what you can't measure. I used hardhat-contract-sizer to measure the contract size. After installing it and running the command, it displays the contract sizes as shown below. It helpfully provides a warning if you exceed the size limit, allowing you to notice issues immediately.
$ npx hardhat compile && yarn hardhat size-contracts
·······················|··············
| TwitterV1 · 24.962 │
·----------------------|-------------·
Warning: 1 contracts exceed the size limit for mainnet deployment.
Testing
Before refactoring, you should always check if at least the minimum tests are written. Even if you succeed in reducing the size through refactoring, it's all for naught if the behavior itself starts acting unintentionally.
Refactoring
Once you're ready, it's time to start the actual refactoring. Basically, you will be repeating these steps:
-
- Implement an improvement
-
- Measure by running
npx hardhat compile && yarn hardhat size-contracts
- Measure by running
-
- Run tests
Since 2) and 3) are as described above, I will write about the improvements for 1).
Split the contract
If a contract is large, you can reduce its size by splitting it. If multiple parts don't necessarily need to be in the same contract, separate them.
Use libraries
Consider moving logic out into a library. If you define functions as internal, they will be inlined during compilation, defeating the purpose, so define them as public. Note that for simple logic like a basic for-loop, the overhead of making it a library might actually increase the size; it's generally better to move reasonably complex logic into a library.
Stop using unnecessary libraries
For example, when implementing an NFT contract, you might use ERC721Enumerable without much thought, but it might be unnecessary if you only need totalSupply. If you just need to increment an ID, implementing a simple counter is enough. By using ERC721 instead of ERC721Enumerable, you can significantly reduce the size.
Use DELEGATECALL
You can execute functions from external contracts using DELEGATECALL. This allows you to move functions out of your contract, thereby reducing the contract size. However, use it with caution, as it can increase complexity and make it harder to track which contract is modifying which state.
Eliminate useless functions
While reducing comments or variable names doesn't help, reducing the number of functions has a large impact. Logic that was extracted into separate functions for readability is often better off being inlined.
Eliminate useless variable declarations
Minimize redundant declarations as much as possible. For example, variable initialization is often unnecessary. Since every type has a default value, they don't become null; if the default value is sufficient, it's better to use it as is.
Hoge hoge = new Hoge(); // This is often redundant
Hoge hoge; // This is enough
bool flag = false; // This is redundant
bool flag; // This is enough
for (uint i = 0; i < length; i++) {
if (i % 17 == 0) {
flag = true;
}
}
return flag;
Reduce require comments
Shorten the error_message part in require(condition, error_message) or delete it if it's not needed. This alone can reduce the size by dozens of bytes in some cases.
Stop passing structs as arguments
// Redundant struct passing. Only isHolded is used.
function hoge(Token _token) returns (bool) {
return _token.isHolded;
}
hoge(token);
// This is enough
function hoge(bool _isHolded) returns (bool) {
return _isHolded;
}
hoge(token.isHolded);
Make functions private or internal if public isn't necessary
Private or internal functions are more efficient than public ones.
Data type optimization
bytes is often lighter than string, and uint8 can be lighter than bool. These are small adjustments, but they are effective when you are close to the size limit.
Deployment
When deploying, simply enabling the solc optimizer can reduce the size by several KB. In the case of hardhat.config.js, just add the following description. runs represents how many times functions in the contract are expected to be called. Specifying a small value increases the gas cost per call but reduces the code size, while a large value decreases the gas cost per call but increases the code size. It is a trade-off.
{
solidity: {
version: "0.8.4",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
}
}
Side Note
Additionally, there is EIP-2535: Diamonds, Multi-Facet Proxy, which is like an advanced version of the Proxy pattern using DelegateCall. I haven't used it myself, but it seems to separate storage and behavior to make them callable. This explanation was easy to follow:
Also, here is the code from when I actually wrote the full on-chain Twitter-like dApp. With the runs: 200 setting, it came out to 22.5 KB. Since it was originally over 26 KB, I think I was able to reduce it quite a bit. For your reference:
Reference Links
Discussion