Common Techniques for Reducing Contract Size

To demonstrate several strategies for optimizing contract size, start by creating two contracts: OptimizedCalculator.sol and AdvancedCalculator.sol, using the following structure:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

contract OptimizedCalculator {
    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;
    }
}
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import "./OptimizedCalculator.sol";

contract AdvancedCalculator is OptimizedCalculator {
    function power(uint256 base, uint256 exponent) public pure returns (uint256) {
        require(base > 0 && exponent > 0, "Invalid values");
        return base ** exponent;
    }
}

Next, execute the command npx hardhat size-contracts once more, and you should see the following output:

Observe that the size of AdvancedCalculator is larger than that of OptimizedCalculator. This increase is due to AdvancedCalculator inheriting from OptimizedCalculator, meaning it includes all the functionality and code from the parent contract—which directly impacts its overall size.

Code Abstraction and Modifiers

At this stage, as a smart contract developer, it's a good idea to review your code and identify opportunities for optimization.

One of the first things you'll likely observe in the codebase is the frequent use of require statements. Instead of writing require(a > 0 && b > 0, "Invalid values"); multiple times, a more efficient approach is to abstract this repetitive logic into a modifier, like the example below:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

contract OptimizedCalculator {
    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 AdvancedCalculator:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import "./OptimizedCalculator.sol";

contract AdvancedCalculator is OptimizedCalculator {
    function power(uint256 base, uint256 exponent) 
        public 
        pure 
        onlyValidInputs(base, exponent) 
        returns (uint256) 
    {
        return base ** exponent;
    }
}

Take note of how the modifier is used along with the replacement of the require statement by a custom error, which is a more gas-efficient approach.

After running the npx hardhat size-contracts command again, you should see the updated size output:

Even though the size reduction is minor, you can already observe some improvement. This optimization process can be repeated until you’re satisfied with the contract’s final size.

Splitting Into Multiple Contracts

A common practice in smart contract development is to break down larger contracts into smaller, modular ones. This isn’t just useful for staying within size limits—it also improves code clarity, promotes better abstraction, and helps avoid redundancy.

From a size optimization standpoint, splitting a large contract into smaller, standalone ones helps ensure each individual contract remains under the Solidity size limit. For instance, if an initial contract is 30 KiB, dividing it into two separate contracts could yield two contracts of around 15 KiB each—both within acceptable limits. However, it’s important to note that this approach may increase gas costs during execution, as calls to external contracts are more expensive.

To demonstrate this, let’s create a contract named Car with a function called startJourney:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

contract Car {
    function startJourney() external pure returns (string memory) {
        return "The journey has started!";
    }
}

In this example, the startJourney function of the Car contract depends on specific functionality provided by two separate contracts: Dashboard for handling speed display logic and Engine for starting the vehicle.

Engine contract:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import "hardhat/console.sol";

contract Engine {
    function ignite() external view {
        console.log("Engine started");
    }
}

Dashboard contract:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

contract Dashboard {
    function showSpeed(uint256 speed) external pure returns (uint256) {
        require(speed > 0, "Speed must be greater than 0");
        return speed;
    }
}

The simplest way for the Car contract to access both the Dashboard and Engine functionalities would be through inheritance. However, as these contracts continue to grow with added features, the overall size of the compiled code will increase. Eventually, you may hit the contract size limit, since all the inherited logic is copied into the Car contract.

A better approach is to keep each functionality within its own dedicated contract. If the Car needs to use those features, it can interact with the Dashboard and Engine contracts via external calls.

In this scenario, the startJourney function within the Car contract needs to call both the Dashboard and Engine to complete its operation.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import "./Engine.sol";
import "./Dashboard.sol";

contract Car {
    Engine private engine;
    Dashboard private dashboard;

    constructor(address _engine, address _dashboard) {
        engine = Engine(_engine);
        dashboard = Dashboard(_dashboard);
    }

    function startJourney() external view {
        engine.ignite();
        dashboard.showSpeed(100);
    }
}

When you run the contract sizer plugin, you'll see the following output:

Observe how the Car contract remains relatively small in size, yet it can still access the full functionality provided by both the Engine and Dashboard contracts.

While this modular approach helps keep each contract within size limits, it's important to note that it may lead to increased gas costs due to external calls—this trade-off is explored in more detail in the Gas Optimization article.

Leveraging Libraries

Libraries are a widely used method for encapsulating and reusing common logic across multiple smart contracts. They can play a major role in reducing contract size and improving code maintainability. In Solidity, libraries can be categorized as either internal or external.

Internal libraries function similarly to inherited contracts—when you use them, their code is copied into the final contract during compilation, which increases the contract’s bytecode size.

On the other hand, external libraries behave differently. Solidity interacts with them through a special low-level operation called delegatecall, allowing the calling contract to execute the library's code in its own context. Because external libraries are stateless, they behave much like pure functions and can be deployed once and reused by multiple contracts.

In the following example, the Car contract will make use of a Calculator library only. Here’s how that would look:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

library Calculator {
    error InvalidInput();

    modifier onlyValidInputs(uint256 a, uint256 b) {
        if (a == 0 || b == 0) {
            revert 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;
    }
}

Than Car is:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import "./Engine.sol";
import "./Dashboard.sol";
import "./Calculator.sol";

contract Car {
    using Calculator for uint256;

    Engine private engine;
    Dashboard private dashboard;
    uint256 private speed;

    constructor(address _engine, address _dashboard) {
        engine = Engine(_engine);
        dashboard = Dashboard(_dashboard);
    }

    function startJourney() external {
        engine.ignite();
        uint256 localSpeed = dashboard.showSpeed(100);

        speed = speed.add(localSpeed);
    }

    function getSpeed() external view returns (uint256) {
        return speed;
    }
}

Observe how the contract is instructed to use the Calculator library for uint256 types. This allows the add function from the Calculator library to be used directly on any uint256 value within the startJourney function.

After running the npx hardhat size-contracts command, you will see the following output:

Enabling the Solidity Compiler Optimizer

An additional method to reduce smart contract size is by enabling the Solidity optimizer.

According to the official Solidity documentation: The optimizer works by simplifying complex expressions, which helps lower both the size of the compiled code and its execution cost.

To activate the optimizer in Hardhat, simply add the following configuration to your hardhat.config.ts file:

require("@nomicfoundation/hardhat-toolbox");
require("hardhat-contract-sizer");
require("dotenv").config();

module.exports = {
  solidity: {
    version: "0.8.20",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200,
      },
      evmVersion: "paris",
    },
  },
  gasReporter: {
    enabled: true,
  },
  networks: {
    ICBTestnet: {
      url: "https://rpc1-testnet.icbnetwork.info",
      chainId: 73114,
      accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
    },
    ICBMainnet: {
      url: "https://rpc2-mainnet.icbnetwork.info",
      chainId: 73115,
      accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
    },
  },
};

With 1,000 runs result will be:

We decrease the number of runs from 1,000 to 200 and you can see some improvements for the Car contract:

The contract size may have increased, but this trade-off typically leads to better runtime efficiency. A higher runs value in the optimizer setting makes execution cheaper, although it results in a more expensive deployment. For more details, refer to the official Solidity documentation.

Final Thoughts

In this tutorial, you explored how to analyze and reduce smart contract sizes using the Hardhat development environment alongside the Hardhat Contract Sizer plugin. By understanding the impact of contract size, you've gained practical tools and techniques to write more efficient and maintainable Solidity code.

As you progress in your smart contract development journey, remember that optimizing for size is an ongoing process. Balancing bytecode size, gas costs, and code readability requires thoughtful design decisions throughout the development lifecycle.

Last updated