Contract Bytecode Optimization

Prerequisite: Blockchain Fundamentals

Your stablecoin contract compiles cleanly, passes every test, and deploys to your local Anvil fork without issue. You push it to Sepolia and the deployment reverts with no error message. The transaction runs out of gas, but raising the gas limit does not help. The contract’s deployed bytecode is 25,102 bytes. EIP-170 enforces a hard ceiling of 24,576 bytes. Ethereum rejects the deployment at the protocol level — there is no override, no flag, no exception.

Payment contracts hit this limit more often than DeFi contracts because they carry more infrastructure per contract. A stablecoin token with role-based access control, pausability, blacklisting, ERC-20 plus ERC-2612 permit, proxy-compatible initializers, and mint/burn logic accumulates bytecode from every feature. Each OpenZeppelin mixin adds 1–4 KB of deployed bytecode. A contract inheriting five mixins starts at 12–18 KB before a single line of business logic.

Measure Before You Optimize

Every optimization decision starts with measurement. Foundry reports deployed bytecode sizes for every contract in the build.

forge build --sizes

The output shows contract name, deployed size in bytes, and whether it exceeds the limit. The number includes the CBOR-encoded metadata hash appended by the compiler — typically 43–53 bytes — but excludes constructor bytecode, which runs once at deployment and is not stored on-chain.

| Contract             | Size (B) | Margin  |
|----------------------|----------|---------|
| FiatToken            |   22,841 |  1,735  |
| FiatTokenV2          |   24,930 |   -354  |  ← exceeds limit
| MintController       |    4,211 | 20,365  |

Run this after every change. Optimizing without measuring is guessing — you do not know which change recovered bytes until the number moves.

The --sizes flag reports deployed bytecode, not creation bytecode. A contract can deploy successfully and still be too large if the constructor generates deployed bytecode exceeding 24,576 bytes. The deployment transaction itself has a separate gas limit that constrains creation bytecode, but EIP-170 is the binding constraint for what lands on-chain.

Compiler Optimizer Settings

The Solidity compiler’s optimizer accepts a runs parameter that controls the tradeoff between deployment cost (bytecode size) and runtime cost (gas per call). Lower values produce smaller bytecode that costs more gas to execute. Higher values produce larger bytecode that is cheaper to call.

runs value Bytecode size Runtime gas cost Use case
1 Smallest Highest Size-constrained contracts near the limit
200 (default) Moderate Moderate General purpose
1,000 Larger Lower Frequently called utility contracts
10,000+ Largest Lowest Hot-path contracts (DEX routers)

For a payment contract at 25,100 bytes, dropping runs from 200 to 1 typically recovers 500–2,000 bytes. The cost is higher gas per function call — roughly 5–15% more gas on complex functions. For a stablecoin transfer that executes a few thousand times per day, the per-call gas increase is negligible compared to the deployment blocker.

# foundry.toml
[profile.default]
optimizer = true
optimizer_runs = 1

This is the first lever to pull because it requires no code changes. If it brings you under the limit, stop. Every subsequent optimization adds code complexity for marginal bytecode savings.

Custom Errors vs Require Strings

Every require statement with a string message embeds that string in the deployed bytecode. Each character costs one byte. A contract with 40 require statements averaging 25-character messages burns 1,000 bytes on error strings alone.

Custom errors, introduced in Solidity 0.8.4, replace string messages with typed error selectors — 4 bytes each regardless of the error’s descriptive name.

// Before: ~30 bytes per require
require(balance >= amount, "Insufficient balance");
require(msg.sender == minter, "Caller is not minter");

// After: 4 bytes per error selector
error InsufficientBalance(uint256 available, uint256 requested);
error UnauthorizedMinter(address caller);

if (balance < amount) revert InsufficientBalance(balance, amount);
if (msg.sender != minter) revert UnauthorizedMinter(msg.sender);
Approach Bytecode per check 40 checks total Debugging
require with string 25–50 bytes 1,000–2,000 bytes Human-readable in block explorers
Custom error 4 bytes + params 160–400 bytes Requires ABI to decode

Replacing all require strings with custom errors in a typical payment contract recovers 800–1,600 bytes. The tradeoff is debuggability — custom errors display as hex selectors in block explorers unless the ABI is verified. With Etherscan verification, custom errors decode to their typed names and parameters.

Visibility Optimization

Solidity function visibility affects bytecode size through ABI encoding behavior.

public → external. A public function can be called externally and internally. When called internally, Solidity copies calldata parameters to memory — the function must support both calling conventions. An external function only supports external calls and reads parameters directly from calldata. For functions with array or struct parameters, this eliminates a memory copy operation and its associated bytecode.

// public: generates both internal and external calling conventions
function processPayments(address[] memory recipients) public { ... }

// external: calldata only, smaller bytecode
function processPayments(address[] calldata recipients) external { ... }

Savings per function: 50–200 bytes depending on parameter types. Across 15 public functions that are never called internally, this recovers 750–3,000 bytes.

internal → private. An internal function is visible to inheriting contracts. If no child contract overrides or calls it, declaring it private allows the compiler to inline it more aggressively or eliminate dead code in derived contracts. The savings are smaller — typically 20–50 bytes per function — but they compound across deep inheritance trees.

Modifier Inlining

Every modifier applied to a function copies the modifier’s bytecode into that function’s body at compile time. A modifier with 100 bytes of logic applied to 20 functions produces 2,000 bytes of duplicated bytecode.

// This modifier's body is copied into every function that uses it
modifier onlyMinter() {
    require(minters[msg.sender], "Not a minter");
    require(!paused(), "Contract is paused");
    require(!blacklisted[msg.sender], "Sender blacklisted");
    _;
}

// Applied to 8 functions = 8 copies of the checks
function mint(address to, uint256 amount) external onlyMinter { ... }
function batchMint(...) external onlyMinter { ... }

Replacing the modifier with an internal function call produces one copy of the logic and a JUMP instruction at each call site.

function _requireMinter() internal view {
    require(minters[msg.sender], "Not a minter");
    require(!paused(), "Contract is paused");
    require(!blacklisted[msg.sender], "Sender blacklisted");
}

function mint(address to, uint256 amount) external {
    _requireMinter();
    ...
}

Each call site shrinks from the full modifier body (~80–150 bytes) to a single internal function call (~10 bytes). With 8 call sites, the savings are 560–1,120 bytes. The runtime gas cost increases by one JUMP and JUMPDEST — roughly 10 gas per call, negligible for a function that moves money.

Library Extraction

Moving logic into an external library linked via DELEGATECALL removes that logic from the calling contract’s bytecode entirely. The library deploys separately, and the calling contract contains only the 20-byte library address and the DELEGATECALL instruction.

library PaymentValidation {
    function validateTransfer(
        address from,
        address to,
        uint256 amount,
        mapping(address => bool) storage blacklist
    ) internal view {
        // validation logic lives in the library's bytecode
    }
}

The tradeoff depends on parameter types. Value-type parameters (address, uint256, bool) pass efficiently through DELEGATECALL — the ABI encoding overhead is 32 bytes per parameter. Memory parameters (string, bytes, arrays) require full ABI encoding and decoding, which can add more bytecode to the calling contract than the extraction saved.

Parameter type ABI encoding cost Net savings from extraction
Value types only 32 bytes/param High — extraction pays off
One memory param 64+ bytes + length encoding Moderate — depends on function size
Multiple memory params 128+ bytes + encoding logic Low or negative — can increase caller size

Extract functions with value-type parameters first. Functions with complex memory parameters are extraction candidates only if the function body itself is large (200+ bytes of logic).

See Blockchain Fundamentals. DELEGATECALL executes the library’s code in the calling contract’s storage context. The library cannot be upgraded independently — its address is embedded in the calling contract’s bytecode at link time. Changing the library requires redeploying the calling contract or using a proxy pattern.

Interface Segregation

When a single contract exceeds the size limit after all bytecode-level optimizations, the remaining option is splitting the contract into multiple focused contracts.

A payment token contract can separate into:

Contract Responsibility Typical size
TokenCore ERC-20 transfers, balances, allowances 6–8 KB
MintBurn Mint and burn logic, supply management 4–6 KB
AccessControl Role management, blacklisting, pausing 3–5 KB
Router Forwards calls to the correct implementation 2–3 KB

The EIP-2535 Diamond Standard formalizes this pattern. A diamond proxy delegates calls to multiple “facet” contracts based on function selectors. Each facet stays well under the size limit. The diamond itself is a small dispatcher (~3 KB) that maps selectors to facet addresses.

The cost is complexity. Cross-facet calls require DELEGATECALL hops. Shared storage must use a structured storage pattern (EIP-7201 namespaced storage or diamond storage) to prevent slot collisions. Debugging spans multiple contracts. Auditors must review the interaction surface between facets, not just individual contracts.

For payment contracts, a simpler two-contract split — core token logic in one contract, administrative functions in a separate admin contract — often suffices. The admin contract calls privileged functions on the token contract through its external interface. This avoids diamond complexity while keeping both contracts under the size limit.

Metadata Stripping

The Solidity compiler appends a CBOR-encoded metadata hash to every contract’s bytecode. This metadata contains the IPFS or Swarm hash of the contract’s compilation metadata (source code hash, compiler version, optimization settings). It occupies 43–53 bytes.

# foundry.toml — strip metadata
[profile.default]
cbor_metadata = false

Removing it saves 43–53 bytes. The cost: Etherscan’s automatic source code verification relies on this metadata hash to match deployed bytecode against uploaded source code. Without it, verification requires the “Standard JSON Input” method instead of the simpler “Solidity Single File” or “Multi-Part” methods. Verification still works — it requires more manual steps.

For a contract that is 200 bytes over the limit and has already exhausted other optimizations, metadata stripping is worth it. For a contract that is 2,000 bytes over, the 50-byte savings is irrelevant — focus on the higher-impact techniques first.

Worked Example: 26 KB to 22 KB

A stablecoin payment contract starts at 26,102 bytes — 1,526 bytes over the EIP-170 limit.

Optimization Bytes recovered Running total
optimizer_runs 200 → 1 1,240 24,862
Custom errors (38 require strings) 980 23,882
Modifier → internal function (3 modifiers, 14 uses) 840 23,042
public → external (11 functions) 620 22,422
Library extraction (validation logic) 380 22,042
Metadata stripping 48 21,994

The contract is now 21,994 bytes — 2,582 bytes under the limit with room for future features.

The first three optimizations recovered 3,060 bytes and required minimal code changes. The library extraction added deployment complexity (a separate library contract to deploy and link). Metadata stripping was unnecessary after the other optimizations but was applied as margin.

The order matters. Measure after each change. If the first optimization brings you under the limit, the rest is unnecessary complexity. A contract at 24,000 bytes does not need library extraction. A contract at 25,500 bytes might need only the compiler flag and custom errors. Optimize until you clear the limit with a reasonable margin — 1,000 to 2,000 bytes — and stop.