iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🙆

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:

    1. Implement an improvement
    1. Measure by running npx hardhat compile && yarn hardhat size-contracts
    1. 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:
https://zenn.dev/hkiridera/articles/8fbe39a4e859b7

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:
https://github.com/YuheiNakasaka/twitter-eth

Reference Links

https://soliditydeveloper.com/max-contract-size
https://blog.polymath.network/solidity-tips-and-tricks-to-save-gas-and-reduce-bytecode-size-c44580b218e6

Others

Discussion