Solidity External Libraries: Beating the 24 KB Contract Size Limit
I added two features to a lending protocol's core contract. Both compiled fine. All 1,270 tests passed. Then forge build --sizes printed 25,267 bytes and the contract couldn't deploy.
The compiler was already tuned for minimum size (optimizer_runs = 1, viaIR = true). There was nothing left to squeeze. The bytecode had to get smaller structurally.
I ended up using external Solidity libraries: same library keyword, but with external functions instead of internal ones. That single visibility change makes the compiler emit a DELEGATECALL instead of inlining the code, and the function body stops counting toward the caller's bytecode. This post walks through how that works, what it means when your contract already sits behind a proxy, and what the deployment and upgrade story looks like.
Table of Contents
- What is EIP-170 and why does it reject your contract?
- Why do Solidity contracts get so large?
- What is the difference between internal and external libraries?
- How do external library calls compile to DELEGATECALL?
- What happens when a proxy already uses DELEGATECALL?
- Is storage safe across the double DELEGATECALL?
- How do you decide what to extract?
- Why didn't compiler optimizations fix this?
- How do you deploy and link an external library?
- What are the upgrade implications?
- What were the actual results?
What is EIP-170 and why does it reject your contract?
EIP-170, introduced in the Spurious Dragon hard fork (November 2016), caps runtime bytecode at 24,576 bytes per contract. Runtime bytecode is the compiled machine code that stays on-chain after deployment (as opposed to the constructor/init code that only runs once). Anything larger gets rejected by the EVM during CREATE or CREATE2. No transaction-level workaround exists.
The original motivation was DoS prevention: without a cap, someone could deploy a contract with megabytes of bytecode, forcing every node to load and store it.
In Foundry, forge build --sizes shows where you stand:
| Contract | Runtime Size (B) | Margin (B) |
|-----------------------|------------------|------------|
| LendingLogicsManager | 25,267 | -691 | ← cannot deploy
691 bytes over. Compiles, tests pass, deploys nowhere.
Why do Solidity contracts get so large?
A few things compound:
Inheritance. LendingLogicsManager inherits from LendingLogicsInternal. Every function in the parent ends up in the child's bytecode.
Internal library inlining. SafeERC20, Math.mulDiv, and any utility library with internal functions get their code copied into every contract that uses them. Five contracts importing SafeERC20 means five copies of the same transfer wrappers in five different bytecodes.
Feature accumulation. In my case, two features pushed the contract past the limit. The grace period feature added a conditional to the health check:
uint256 effectiveGrace = MathLib.max(loan.gracePeriod, $.minGracePeriod);
MathLib.max enforces a protocol-wide minimum grace period. Even if a lender sets a shorter one, borrowers always get at least minGracePeriod.
The minimum interest feature added early-repayment fee math to repayLoan:
if (loan.minInterestBps > 0 && block.timestamp < loan.startTime + loan.duration) {
uint256 fullTermInterestForRepay =
(repayAmount * loan.interestRateBps * loan.duration) / (BASIS_POINTS * 365 days);
uint256 minInterestForRepay =
Math.mulDiv(fullTermInterestForRepay, loan.minInterestBps, BASIS_POINTS);
if (minInterestForRepay > interestToPay) {
earlyRepaymentFee = minInterestForRepay - interestToPay;
}
}
Neither is large. Together they were 691 bytes too many.
What is the difference between internal and external libraries?
Both use the library keyword. They compile differently based on function visibility.
Internal libraries (internal/private functions): the compiler copies the function body into every calling contract. No deployment, no on-chain address. The library is a source-level abstraction. At the bytecode level, it's inlined.
External libraries (external/public functions): the compiler emits a DELEGATECALL to the library's deployed address. The library lives on-chain as a separate contract. The caller's bytecode contains only the dispatch instruction.
The protocol already had six utility libraries (MathLib, IntentLib, ValidationLib, ErrorsLib, etc.), all internal. None of them helped with contract size because their code was being copied in.
LendingCalcLib, the library created to solve this, uses external functions. It deploys separately, gets linked at compile time, and its bytecode doesn't count toward the caller.
How do external library calls compile to DELEGATECALL?
In LendingLogicsInternal, the repayment function became a one-liner:
function _repayLoan(uint256 loanId, uint256 repayAmount, uint256 maxTotalRepayment) internal {
LendingCalcLib.repayLoan(loanId, repayAmount, maxTotalRepayment);
}
The compiler doesn't inline LendingCalcLib.repayLoan. It emits:
DELEGATECALL(gas, LIBRARY_ADDRESS, inputData, inputSize, outputOffset, outputSize)
The library address starts as a placeholder in the compiled bytecode (__$a1b2c3d4...$__). At deployment, Foundry's --libraries flag (or Hardhat's linker config) substitutes the real deployed address.
DELEGATECALL preserves the calling context. In Solidity, msg.sender is the address that initiated the call and address(this) is the contract whose code is running. With DELEGATECALL:
msg.sender= the original caller (unchanged)address(this)= the calling contract, not the library- Storage reads/writes target the calling contract's storage, not the library's
This is the same opcode ERC-1967 proxies use. The difference: proxies resolve the target at runtime from a storage slot. Libraries have it hardcoded at compile time.
The actual library function:
library LendingCalcLib {
function repayLoan(uint256 loanId, uint256 repayAmount, uint256 maxTotalRepayment) external {
LendingStorageLib.LendingStorage storage $ = LendingStorageLib._getLendingStorage();
Loan storage loan = $.loans[loanId];
// ... ~60 lines of repayment logic
}
}
If that external were internal, the ~60 lines would be inlined into LendingLogicsManager's bytecode. The visibility keyword is what controls this.
What happens when a proxy already uses DELEGATECALL?
Most upgradeable contracts use a proxy pattern: a lightweight contract (the proxy) holds the state and forwards every call to a separate implementation contract. The proxy uses DELEGATECALL for this forwarding, so the implementation code runs against the proxy's storage. UUPS and Transparent are the two common proxy flavors.
When your implementation also calls an external library, that's a second DELEGATECALL, creating a chain.
The call path for repayLoan:
- User calls
repayLoan()on the proxy address. - Proxy reads the implementation address from ERC-1967 storage and DELEGATECALLs to
LendingLogicsManager. - LendingLogicsManager (via
LendingLogicsInternal) callsLendingCalcLib.repayLoan(). - The compiler emits a second DELEGATECALL to
LendingCalcLib's hardcoded address.
At step 4, the execution context is still the proxy's. msg.sender is still the user. address(this) is still the proxy. Storage operations still hit the proxy's storage.
This works because DELEGATECALL is transitive: if A DELEGATECALLs B, and B DELEGATECALLs C, C runs in A's context.
Is storage safe across the double DELEGATECALL?
Yes, thanks to ERC-7201 namespaced storage.
Quick background: Solidity stores contract variables in numbered "slots" starting at 0. If two contracts running via DELEGATECALL both use slot 0 for different things, they'll overwrite each other. ERC-7201 solves this by deriving a slot from a unique namespace string, so collisions are practically impossible.
All protocol state lives at a slot derived from a namespace string:
/// @dev keccak256(abi.encode(uint256(keccak256("floe.storage.LendingStorage")) - 1))
/// & ~bytes32(uint256(0xff))
bytes32 internal constant LENDING_STORAGE_SLOT =
0xe8764bbf0e6271e91784e9c270c3677040190f13325dc23c4114eba2201a4d00;
Every function that touches state calls the same accessor:
function _getLendingStorage() internal pure returns (LendingStorage storage $) {
assembly {
$.slot := LENDING_STORAGE_SLOT
}
}
The slot is a compile-time constant derived from a hash, not from contract identity. It produces the same value whether the code is running inside LendingLogicsInternal, LendingLogicsManager, or LendingCalcLib.
Since DELEGATECALL preserves the storage context (the proxy's), and the slot is constant, the library reads and writes the same storage locations as if the code were inlined.
No storage fields were added or changed by the extraction. The library has no storage layout of its own. If it used positional storage (slot 0, slot 1) instead of namespaced storage, this would break. ERC-7201 is what makes it safe.
How do you decide what to extract?
Not every function is a good candidate. Here's what to look for:
Self-contained functions. Functions that read storage, compute, write storage, and emit events without calling back into the parent. repayLoan, liquidateLoan, createMarket, setMarket, setPauseStatus all fit. They don't depend on other functions in LendingLogicsInternal.
Large functions. _prepareLiquidation is ~100 lines of arithmetic (solvent vs underwater paths, collateral seizure, fee splits). It contributed the most bytecode.
Move private helpers alongside their callers. repayLoan needs _accruedInterest and _getValidatedPrice. Those became private functions inside the library.
Don't move functions with many internal dependencies. _matchLoanIntents is the largest function, but it calls intent validation, hook execution, asset transfers, and loan recording. Moving it would mean moving half the contract, which defeats the purpose.
Duplicate shared helpers rather than creating cross-dependencies. _accruedInterest is needed by both the library (repayment, liquidation) and LendingLogicsInternal (collateral withdrawal health checks). I kept a copy in each place.
| Moved to LendingCalcLib | Stayed in LendingLogicsInternal |
|---|---|
repayLoan (~60 lines) | _matchLoanIntents (~120 lines) |
liquidateLoan (~15 lines) | Intent registration/revocation |
liquidateWithCallback (~25 lines) | Collateral management |
createMarket (~35 lines) | Hook execution |
setMarket, setPauseStatus | Asset transfers |
_prepareLiquidation (~100 lines) | Intent validation |
_isHealthy, _accruedInterest | |
_getValidatedPrice |
Why didn't compiler optimizations fix this?
My foundry.toml was already configured for minimum bytecode:
optimizer = true
optimizer_runs = 1 # minimum: favors size over gas
viaIR = true # Yul pipeline: better dead code elimination
bytecode_hash = "none" # removes CBOR metadata (~50 bytes)
optimizer_runs = 1 favors smaller bytecode over cheaper runtime gas. Going from the default (200) to 1 can save 1-3 KB on large contracts. I was already at 1.
viaIR = true enables the Yul intermediate representation pipeline, which does better cross-function optimization than the legacy pipeline. Already enabled.
The optimizer can only reduce redundancy within reachable code. It can't remove function bodies that are actually callable. Every function in LendingLogicsManager is reachable through its external entry points, so the optimizer had nothing to cut.
Once compiler settings are exhausted, the remaining option is structural: move code out of the contract entirely.
How do you deploy and link an external library?
External libraries deploy as standalone contracts with their own address and independently verifiable bytecode.
Order matters: library first, then the contract that references it.
# 1. Deploy the library
forge create src/libraries/LendingCalcLib.sol:LendingCalcLib \
--rpc-url $RPC_URL --private-key $PRIVATE_KEY --verify
# 2. Deploy LogicsManager with library linking
forge create src/core/LendingLogicsManager.sol:LendingLogicsManager \
--rpc-url $RPC_URL --private-key $PRIVATE_KEY \
--libraries src/libraries/LendingCalcLib.sol:LendingCalcLib:0x82B6...1c05 \
--verify
The --libraries flag tells Foundry to replace the placeholder in the bytecode with the real address. Format: path:LibraryName:address.
You can confirm the address is embedded:
cast code 0xLogicsManagerAddress --rpc-url $RPC_URL | grep -qi "82b6"
For forge script, set it via the FOUNDRY_LIBRARIES environment variable:
export FOUNDRY_LIBRARIES="src/libraries/LendingCalcLib.sol:LendingCalcLib:0x82B6...1c05"
forge script script/Deploy.s.sol --broadcast
For Hardhat, pass the library address via ethers.getContractFactory("Contract", { libraries: { LendingCalcLib: "0x82B6...1c05" } }).
What are the upgrade implications?
The library address is baked into the calling contract's bytecode. No storage slot, no admin function, no way to change it after deployment.
To upgrade library logic:
- Deploy a new
LendingCalcLib. - Deploy a new
LendingLogicsManagerlinked to the new library. - Call
setLogicsManager()on the proxy to point to the new implementation.
That's a two-contract redeployment. The proxy's bytecode and address stay the same, but the implementation it delegates to changes.
The library itself has no upgrade mechanism. It's not a proxy. A bug in the library means redeploying both the library and the implementation.
In my protocol, setLogicsManager() is gated behind a Gnosis Safe. The process is identical to any other implementation update.
My full test suite (1,270 tests) passed without modification after the extraction, which confirmed behavioral equivalence.
What were the actual results?
| Contract | Runtime Size (B) | Margin (B) |
|-----------------------|------------------|------------|
| LendingLogicsManager | 21,292 | +3,284 | ← 3.2 KB headroom
| LendingCalcLib | 6,138 | +18,438 | ← standalone library
Before: 25,267 bytes. Cannot deploy.
After: 21,292 + 6,138 = 27,430 bytes across two contracts. Both under the limit.
The total bytecode increased by ~2,163 bytes (DELEGATECALL dispatch overhead, ABI encoding at the boundary, duplicated private helpers). But each contract individually fits within EIP-170.