Solidity Libraries: When Delegatecall Saves Gas and When It Burns You
Solidity libraries cut bytecode via delegatecall, but they run in the caller's storage context—creating silent corruption risks when layouts diverge. A worked example shows how a nonce library breaks when a mapping shifts slots, plus gas numbers to decide when libraries actually pay off.
02 May 2026, 10:37 UTC

The Problem: Duplicate Logic Across Contracts
You're maintaining three contracts that each need safe ERC20 transfer logic, role-based access checks, and a handful of math utilities. Copy-pasting works until a bug fix means updating five files. Solidity libraries promise a fix: write once, link everywhere, save bytecode. But the mechanism—delegatecall—changes the execution context in ways that bite teams during audits and production incidents.
What Delegatecall Actually Does
When Contract A calls a library function via delegatecall, the library's code runs inside Contract A's storage, msg.sender, and balance. No new contract is created. The library has no persistent storage of its own—since 0.6.12, library addresses must be immutable or constant, and they cannot declare mapping or other state variables that would occupy their own slots.
This means every state write in a library function lands in the caller's layout. If the library expects a uint256 at slot 0 but the caller has a mapping there, you get silent corruption. The compiler does not validate layout compatibility across the library/caller boundary.
Worked Example: A Math Library That Shifts Slots
Consider a library that tracks a nonce for replay protection:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
library NonceLib {
struct Nonce { uint256 value; }
function increment(Nonce storage self) internal returns (uint256) {
return ++self.value;
}
}
Contract A uses it like this:
contract Wallet {
using NonceLib for NonceLib.Nonce;
NonceLib.Nonce public nonce; // slot 0
address public owner; // slot 1
function doSomething() external {
uint256 n = nonce.increment();
// ...
}
}Now Contract B adds a mapping before the nonce:
contract Escrow {
mapping(address => uint256) public balances; // slot 0
using NonceLib for NonceLib.Nonce;
NonceLib.Nonce public nonce; // slot 1
function doSomething() external {
uint256 n = nonce.increment(); // writes to slot 1, but library expects slot 0
}
}
The library's increment increments whatever sits at the struct's base slot. In Wallet that's slot 0 (the nonce). In Escrow it's slot 1—but the library code still writes to the first slot of the struct reference, which now overlaps balances's internal structure. The bug is silent until a withdrawal fails or an attacker notices corrupted accounting.
Fix: Never pass storage pointers to libraries unless you control every caller's layout. Prefer passing values and returning results, or enforce a base contract that fixes the layout for all consumers.
Gas Reality Check
Libraries reduce deployed bytecode because the library body lives once on-chain. But each call adds delegatecall overhead (~700–1,000 gas) and prevents inlining. For hot paths—tight loops, per-transfer math—the overhead often exceeds the savings from deduplication.
Run a quick comparison in Foundry:
forge test --gas-report
Compare a contract with inlined SafeMath.add versus one calling MathLib.add via delegatecall. In 0.8.20 with optimizer runs=200, the inlined version typically saves 300–600 gas per call. Libraries win when the logic is large (hundreds of opcodes) and called from many contracts, not for three-line helpers.
Ether and Debugging Limitations
Libraries cannot receive Ether directly. A payable library function can forward value, but the msg.value belongs to the caller. If you need a library to hold funds—say, a shared escrow module—you must route deposits through a proxy contract that owns the balance and delegatecalls into the library. That adds a layer of indirection and a new trust assumption.
Debugging is harder: stack traces show the caller's context, not the library's source line. console.log inside a library prints the caller's address. Tenderly and Foundry's debugger can step into delegatecall frames, but variable names map to the caller's storage layout, not the library's struct definitions.
When to Use Libraries (and When to Skip)
- Use for large, stateless utilities (merkle proofs, signature verification, complex encoding) shared across ≥3 contracts where bytecode savings outweigh delegatecall cost.
- Use when you can enforce a single storage layout via a base contract or interface that all callers inherit.
- Skip for small helpers (<20 opcodes), hot paths, or any logic that writes to storage pointers passed from callers with divergent layouts.
- Skip if the library needs to hold Ether or maintain its own mapping state—use a singleton contract with
delegatecallproxy instead.
Actionable Next Step
Audit your current library usage: list every using X for Y and every storage pointer passed to a library function. For each, verify that all calling contracts have identical storage layouts up to that pointer's slot. If you can't verify, refactor to pass calldata/value types and return results. Run the gas report before and after to confirm the trade-off is worth it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.