Downsizing contracts to fight the contract size limit

Why is there a limit?

On November 22, 2016 the Spurious Dragon hard-fork introduced EIP-170 which added a smart contract size limit of 24.576 kb. For you as a Solidity developer this means when you add more and more functionality to your contract, at some point you will reach the limit and when deploying will see the error:

Warning: Contract code size exceeds 24576 bytes (a limit introduced in Spurious Dragon). This contract may not be deployable on mainnet. Consider enabling the optimizer (with a low "runs" value!), turning off revert strings, or using libraries.

This limit was introduced to prevent denial-of-service (DOS) attacks. Any call to a contract is relatively cheap gas-wise. However, the impact of a contract call for Ethereum nodes increases disproportionately depending on the called contract code's size (reading the code from disk, pre-processing the code, adding data to the Merkle proof). Whenever you have such a situation where the attacker requires few resources to cause a lot of work for others, you get the potential for DOS attacks.

Originally this was less of a problem, because one natural contract size limit is the block gas limit. Obviously a contract needs to be deployed within a transaction that holds all of the contract's bytecode. If you then include only that one transaction into a block, you can use up all of that gas, but it's not infinite. The issue in that case though is that the block gas limit changes over time and is in theory unbounded. At the time of the EIP-170 the block gas limit was only 4.7 million. Now the block gas limit just increased again last month to 11.9 million.

Taking on the fight

Unfortunately, there is no easy way of getting the bytecode size of your contracts. A great tool to help you that is the truffle-contract-size plugin if you're using Truffle.

  1. npm install truffle-contract-size
  2. Add the plugin to the truffle-config.js: plugins: ["truffle-contract-size"]
  3. Run truffle run contract-size

This will help you figure out how your changes are affecting the total contract sizes.

In the following we will look at some methods ordered by their potential impact. Think about it in the terms of weight-loss. The best strategy for someone to hit their target weight (in our case 24kb) is to focus on the big impact methods first. In most cases just fixing your diet will get you there, but sometimes you need a little bit more. Then you might add some exercise (medium impact) or even supplements (small impact).

Big impact

Separate your contracts

This should always be your first approach. How can you separate the contract into multiple smaller ones? It generally forces you to come up with a good architecture for your contracts. Smaller contracts are always preferred from a code readability perspective. For splitting contracts, ask yourself:

  • Which functions belong together? Each set of functions might be best in its own contract.
  • Which functions don't require reading contract state or just a specific subset of the state?
  • Can you split storage and functionality?

Libraries

One simple way to move functionality code away from the storage is using a library. Don't declare the library functions as internal as those will be added to the contract directly during compilation. But if you use public functions, then those will be in fact in a separate library contract. Consider using for to make the use of libraries more convenient.

Proxies

A more advanced strategy would be proxy system. Libraries use DELEGATECALL in the back which simply executes another contract's function with the state of the calling contract. Check out this blog post to learn more about proxy systems. They give you more functionality, e.g., they enable upgradability, but they also add a lot of complexity. I wouldn't add those only to reduce contract sizes unless it's your only option for whatever reason.

Medium impact

Remove functions

This one should be obvious. Functions increase a contract size quite a bit.

  • External: Often times we add a lot of view functions for convenience reasons. That's perfectly fine until you hit the size limit. Then you might want to really think about removing all but absolutely essential ones.
  • Internal: You can also remove internal/private functions and simply inline the code as long the function is called only once.

Avoid additional variables

A simple change like this:

1function get(uint id) returns (address,address) {
2 MyStruct memory myStruct = myStructs[id];
3 return (myStruct.addr1, myStruct.addr2);
4}
5
📋 Copy
1function get(uint id) returns (address,address) {
2 return (myStructs[id].addr1, myStructs[id].addr2);
3}
4
📋 Copy

makes a difference of 0.28kb. Chances are you can find many similar situations in your contracts and those can really add up to significant amounts.

Shorten error message

Long revert messages and in particular many different revert messages can bloat up the contract. Instead use short error codes and decode them in your contract. A long message could be become much shorter:

1require(msg.sender == owner, "Only the owner of this contract can call this function");
2
3
📋 Copy
1require(msg.sender == owner, "OW1");
2
📋 Copy

Consider a low run value in the optimizer

You can also change the optimizer settings. The default value of 200 means that it's trying to optimize the bytecode as if a function is called 200 times. If you change it to 1, you basically tell the optimizer to optimize for the case of running each function only once. An optimized function for running only one time means it is optimized for the deployment itself. Be aware that this increases the gas costs for running the functions, so you may not want to do it.

Small impact

Avoid passing structs to functions

If you are using the ABIEncoderV2, it can help to not pass structs to a function. Instead of passing the parameter as a struct...

1function get(uint id) returns (address,address) {
2 return _get(myStruct);
3}
4
5function _get(MyStruct memory myStruct) private view returns(address,address) {
6 return (myStruct.addr1, myStruct.addr2);
7}
8
📋 Copy
1function get(uint id) returns(address,address) {
2 return _get(myStructs[id].addr1, myStructs[id].addr2);
3}
4
5function _get(address addr1, address addr2) private view returns(address,address) {
6 return (addr1, addr2);
7}
8
📋 Copy

... pass the required parameters directly. In this example we saved another 0.1kb.

Declare correct visibility for functions and variables

  • Functions or variables that are only called from the outside? Declare them as external instead of public.
  • Functions or variables only called from within the contract? Declare them as private or internal instead of public.

Remove modifiers

Modifiers, especially when used intensely, could have a significant impact on the contract size. Consider removing them and instead use functions.

1modifier checkStuff() {}
2
3function doSomething() checkStuff {}
4
📋 Copy
1function checkStuff() private {}
2
3function doSomething() { checkStuff(); }
4
📋 Copy

Those tips should help you to significantly reduce the contract size. Once again, I cannot stress enough, always focus on splitting contracts if possible for the biggest impact.

The future for the contract size limits

There is an open proposal to remove the contract size limit. The idea is basically to make contract calls more expensive for large contracts. It wouldn't be too difficult to implement, has a simple backwards compatibility (put all previously deployed contracts in the cheapest category), but not everyone is convinced.

Only time will tell if those limits will change in the future, the reactions (see image on the right) definitely show a clear requirement for us developers. Unfortunately, it is not something you can expect any time soon.

Sam Richards
Last edit: @samajammin, September 25, 2020
See contributors
Edit page