3.8.9 Storage & Delegatecall
delegatecall is the most consequential opcode in Solidity for smart contract security. It is the foundation of every proxy-based upgrade pattern, every library that holds state, every diamond facet system. Without delegatecall, contracts could not be upgraded; with it misused, contracts can be utterly destroyed. The Parity Multi-Sig Wallet kill of November 2017 — over $280M of ETH frozen permanently — was a single delegatecall to a self-destructing library. The vulnerability fit in a tweet.
This section covers the specific bug patterns that arise from delegatecall and storage layout. The patterns share a single mechanic: when contract A delegate-calls contract B, B's code executes against A's storage and A's msg.sender. Every bug in this section is a consequence of that mechanic — either an unintended write to A's storage by B's code, or an unintended execution of B's code in A's privileged context.
The mental model that prevents most of these bugs: think of delegatecall not as "calling another contract" but as "copying code from another contract and executing it as my own." The called contract's bytecode runs, but it sees and modifies the caller's state, the caller's address, the caller's balance, the caller's storage. The "called" contract is essentially a code library that happens to live at its own address.
Section 3.7.2 covered Explicit Storage Buckets as the developer-facing pattern for safe storage in upgradeable contexts. Section 3.5 covers the broader upgradeability patterns (proxies, UUPS, diamonds). This section covers the specific bug patterns — what goes wrong, with concrete code, and how to prevent it.
The Storage Collision Pattern
A proxy contract's storage layout must be compatible with the implementation contract's storage layout. Compatibility means the slot positions of all state variables match between the two contracts. When they don't match, the implementation's code reads and writes slots in the proxy's storage that don't correspond to what the implementation thinks they correspond to.
The result is silent state corruption. The contract appears to function; reads return values; writes don't revert. But the values being read and written are not the values the code intends. The contract is no longer doing what its source code says it's doing.
Vulnerable Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// Proxy contract
contract Proxy {
address public implementation;
address public admin;
constructor(address _implementation, address _admin) {
implementation = _implementation;
admin = _admin;
}
fallback() external payable {
address impl = implementation;
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
// Implementation contract — BUG: storage layout doesn't match the proxy
contract Implementation {
address public owner; // slot 0 in implementation
mapping(address => uint256) public balances; // slot 1
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function changeOwner(address newOwner) external {
require(msg.sender == owner, "not owner");
owner = newOwner;
}
}
When a user calls deposit() through the Proxy, the EVM:
- Loads the Proxy's storage slot 0 (which holds
implementation's address) — but the Implementation contract's code thinks slot 0 isowner - Loads the Proxy's storage slot 1 (which holds
admin's address) — but the Implementation thinks slot 1 is thebalancesmapping
When deposit() runs balances[msg.sender] += msg.value, it computes keccak256(abi.encode(msg.sender, 1)) and uses that slot for the user's balance. The mapping itself is at slot 1 — which in the Proxy is the admin variable. The mapping computation works (the slot derivation is correct math), but anyone calling changeOwner(newOwner) modifies what the implementation calls owner — which is actually the proxy's implementation address.
A single changeOwner() call can replace the implementation address with an attacker-controlled contract, taking over all future operations.
Fixed Example: EIP-1967 Standard Slots
The standard fix is EIP-1967, which assigns proxy-internal state to high-numbered, hashed slots that no normal Solidity declaration would use:
contract EIP1967Proxy {
// Computed: bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)
bytes32 private constant IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
// Computed: bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1)
bytes32 private constant ADMIN_SLOT =
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
constructor(address impl, address admin) {
assembly {
sstore(IMPLEMENTATION_SLOT, impl)
sstore(ADMIN_SLOT, admin)
}
}
fallback() external payable {
assembly {
let impl := sload(IMPLEMENTATION_SLOT)
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
The implementation now uses slots 0, 1, 2 ... normally. The proxy's state lives at deterministic high-numbered hashed slots that no implementation contract would generate by normal declaration. Collision is mathematically impossible (assuming keccak256 collision resistance).
OpenZeppelin's TransparentUpgradeableProxy and ERC1967Proxy implement this pattern correctly. For any proxy in production, use these implementations rather than rolling your own — the slot math is error-prone and the failure mode is silent state corruption.
Fixed Example: ERC-7201 for Implementation State
For implementations themselves — when a single contract is deployed multiple ways (standalone vs. behind a proxy, or in deep inheritance chains), using ERC-7201 namespaced storage prevents slot conflicts:
contract VaultImpl {
/// @custom:storage-location erc7201:myapp.vault
struct VaultStorage {
mapping(address => uint256) balances;
uint256 totalDeposits;
bool paused;
}
// Computed via cast index-erc7201 myapp.vault
bytes32 private constant VAULT_STORAGE_LOCATION =
0x7f5e9c2f8a3e4b6d1c5e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b900;
function _vaultStorage() private pure returns (VaultStorage storage $) {
assembly {
$.slot := VAULT_STORAGE_LOCATION
}
}
function deposit() external payable {
VaultStorage storage $ = _vaultStorage();
$.balances[msg.sender] += msg.value;
$.totalDeposits += msg.value;
}
}
Section 3.7.2 covers this pattern in depth. The point here is that namespaced storage eliminates entire categories of layout-collision bugs by making the slot positions independent of declaration order.
Storage Drift in Upgrades
A related bug: the storage layout changes between versions of an upgradeable contract. V1 declares (owner, totalSupply, balances); V2 inserts paused between owner and totalSupply. Every variable below paused has shifted to a different slot. The contract still reads from the same slot positions, but those positions now hold different data.
Vulnerable V2
// V1 — deployed and storing real state
contract VaultV1 {
address public owner; // slot 0
uint256 public totalSupply; // slot 1
mapping(address => uint256) public balances; // slot 2
}
// V2 — DANGEROUS upgrade
contract VaultV2 {
address public owner; // slot 0 — OK
bool public paused; // slot 1 — was totalSupply
uint256 public totalSupply; // slot 2 — was balances
mapping(address => uint256) public balances; // slot 3 — fresh, empty
}
After upgrading to V2:
pausedreads from slot 1, which holds the oldtotalSupplyvalue. Any non-zero supply makespausedevaluate astrue, and the contract appears paused.totalSupplyreads from slot 2, which held the old mapping's base. The reported supply is meaningless.balancesreads from slot 3, which is empty. Every user's apparent balance is zero.
User funds aren't lost — they're still in the contract, recorded at the old slots. But the new code can't see them. Withdrawals fail because balances appear to be zero.
Fixed Approach: Append-Only Layout
When using sequential storage layout, new state variables must be appended to the end of the existing declaration order:
// V2 — CORRECT upgrade
contract VaultV2 {
address public owner; // slot 0 — unchanged
uint256 public totalSupply; // slot 1 — unchanged
mapping(address => uint256) public balances; // slot 2 — unchanged
bool public paused; // slot 3 — new, appended
}
The existing slots retain their original meaning. The new variable lives at a fresh slot that V1 never used (since V1 never wrote to slot 3, that slot is zero — which is false for a bool, the correct initial state).
Fixed Approach: Storage Gaps
OpenZeppelin's pre-v5 pattern uses __gap arrays to reserve future slots:
contract VaultV1 {
address public owner; // slot 0
uint256 public totalSupply; // slot 1
mapping(address => uint256) public balances; // slot 2
uint256[49] private __gap; // slots 3-51 reserved for future
}
In V2, additions take slots from the gap:
contract VaultV2 {
address public owner;
uint256 public totalSupply;
mapping(address => uint256) public balances;
bool public paused; // slot 3 (was first slot of __gap)
uint256[48] private __gap; // slots 4-51 still reserved
}
The trade-off is upfront storage cost (the gap reserves slots even if unused) and the operational discipline of decrementing the gap size when adding fields. OpenZeppelin v5+ deprecated this in favor of ERC-7201 namespaced storage for new code.
Tooling: Verify Layout Before Upgrade
OpenZeppelin Upgrades Plugin (for Hardhat and Foundry) verifies storage compatibility before deployment. The plugin compares the new contract's layout against the deployed contract's storage and refuses to upgrade if the layout has changed incompatibly. For any production upgradeable contract, this check should be mandatory in the deployment workflow.
Delegatecall to Attacker-Controlled Contracts
A contract that performs delegatecall to an address derived from user input (or governance, or any other path attacker-influenceable) is delegating execution to attacker-controlled code. The attacker's code runs against the contract's storage and the contract's msg.sender — the attacker can do anything the contract itself could do.
Vulnerable Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableForwarder {
address public owner;
mapping(address => uint256) public balances;
constructor() {
owner = msg.sender;
}
// BUG: caller-supplied target is delegate-called
function forward(address target, bytes calldata data) external {
(bool ok, ) = target.delegatecall(data);
require(ok);
}
}
The attacker:
- Deploys their own contract:
contract Hijacker { address public owner; // slot 0, same as VulnerableForwarder function steal() external { owner = msg.sender; } } - Calls
VulnerableForwarder.forward(hijacker, abi.encodeWithSignature("steal()")) Hijacker.steal()executes againstVulnerableForwarder's storageVulnerableForwarder.owneris now the attacker
The attacker now owns the contract. If owner controls privileged functions (withdrawal, parameter changes, fund movements), the attacker has full control.
The pattern generalizes: delegate-calling any address that wasn't part of the contract's verified deployment is a security hole. Even if the immediate target is "verified," an unverified path to the target (a configurable address, an admin-changeable implementation, a user-provided argument) reintroduces the risk.
Fixed Example: Whitelist Allowed Targets
contract SafeForwarder {
address public owner;
mapping(address => bool) public allowedTargets;
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function addAllowedTarget(address target) external onlyOwner {
allowedTargets[target] = true;
}
function forward(address target, bytes calldata data) external {
require(allowedTargets[target], "target not allowed");
(bool ok, ) = target.delegatecall(data);
require(ok);
}
}
The whitelist limits delegate-call targets to known-safe contracts. The contract is still doing delegate-call, which still means trust in the target's behavior, but now the trust is auditable: only specific, vetted targets are allowed.
For most use cases, the better answer is don't delegate-call user-provided addresses at all. If the forwarding mechanism is genuinely needed, use plain call (which doesn't share storage) instead of delegatecall. Reserve delegatecall for verified proxy patterns where the implementation address is set by privileged governance, not by user input.
The Parity Wallet Pattern (Library Self-Destruct)
The November 2017 Parity Multi-Sig kill is the canonical worst-case delegatecall failure. The mechanic combines several of the bug patterns above — uninitialized state, unprotected functions, delegatecall, and selfdestruct — into a single attack that froze $280M+ permanently.
The Setup
Parity deployed a single "library" contract containing the multi-sig wallet logic. Each user's wallet was a tiny "stub" contract that delegate-called the library for all operations. The library was shared across many wallets to save on deployment cost.
// Simplified Parity library (vulnerable version)
contract WalletLibrary {
address public owner;
// BUG: no initializer modifier
function initWallet(address _owner) external {
owner = _owner;
}
function execute(address to, uint256 value) external {
require(msg.sender == owner);
payable(to).transfer(value);
}
function kill() external {
require(msg.sender == owner);
selfdestruct(payable(owner)); // BUG: kills the library
}
}
// User's wallet stub
contract Wallet {
address public library = 0x...; // points to WalletLibrary
fallback() external payable {
address lib = library;
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), lib, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
The Attack
When the library was deployed, owner was uninitialized (default address(0)). The library itself was not a wallet — it was the shared code that wallets delegate-called. But the library still had public functions.
An attacker called initWallet(<attacker_address>) directly on the library contract (not through any wallet stub). With no initializer modifier and no other authorization, this set the library's own owner to the attacker.
Then the attacker called kill() directly on the library. The library's selfdestruct ran, removing all code at the library's address.
Every user's wallet stub still pointed to that library address — but the library no longer existed. Every delegatecall from a wallet stub to the now-empty library address succeeded (because empty addresses don't revert) but did nothing. Funds in wallets could no longer be moved, voted, or recovered.
The Lessons
Three independent bugs combined to create the catastrophe:
-
The library had a publicly-callable initializer with no
initializermodifier. Anyone could become the library's "owner," even though the library was never intended to be used as a wallet itself. -
The library could
selfdestructbased only on the library's own state. Even after the first bug let the attacker take ownership, the library should not have been killable by anyone — there was no business reason for a shared library to self-destruct. -
The wallet stubs trusted the library address as effectively immutable. When the library was killed, the stubs had no fallback, no upgrade path, and no way to recover.
Modern Defenses
For shared library / implementation patterns today:
import "@openzeppelin/contracts/proxy/utils/Initializable.sol";
contract WalletImplementation is Initializable {
address public owner;
// Critical: disable initializers on the implementation contract during construction.
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address _owner) external initializer {
owner = _owner;
}
function execute(address to, uint256 value) external {
require(msg.sender == owner);
payable(to).transfer(value);
}
// NO selfdestruct function — even if it seems useful, the risk outweighs the benefit
}
The _disableInitializers() call in the implementation's constructor sets a flag that makes initialize revert. The implementation itself is never initializable; only proxies that delegate-call it can be initialized through the proxy.
The Wormhole bridge exploit ($325M, February 2022) was the same pattern as Parity — an implementation contract was initializable directly because _disableInitializers() was missing. For every upgradeable contract, the constructor must call _disableInitializers(). No exceptions.
EIP-6780 and the Future of selfdestruct
The Cancun hard fork (March 2024) shipped EIP-6780, which changes selfdestruct semantics. Outside of the contract's creation transaction, selfdestruct no longer destroys the contract — it only transfers the balance to the recipient. Code remains at the address.
This means the literal Parity attack is no longer possible — even if an attacker took ownership of a library and called selfdestruct, the library's code would still exist post-Cancun. The new behavior does not retroactively fix old contracts; Parity's frozen funds are still frozen.
For new contracts, EIP-6780 changes the risk calculus but doesn't eliminate the principle. selfdestruct is still mostly useless as a feature and still introduces complexity to no benefit. Don't use selfdestruct in any new contract. The opcode is deprecated in spirit if not in practice.
Cross-reference: Section 3.8.1 (Solidity Language Pitfalls) covers
_disableInitializers()in the constructor-vs-initializer framing; Section 3.8.4 (Access Control Failures) covers unprotected initializers from the access-control angle. This section covers the storage-and-delegatecall interaction that makes those bugs catastrophic.
Function Selector Collision
A subtle delegate-call issue specific to upgradeable proxies and diamond patterns. When the proxy and the implementation both define functions, and a function on the proxy has the same 4-byte selector as a function on the implementation, the proxy's function may shadow the implementation's — silently breaking the intended call routing.
Vulnerable Pattern
contract VulnerableProxy {
address public implementation;
// BUG: function "owner()" on the proxy
function owner() external view returns (address) {
return msg.sender; // some custom logic
}
fallback() external {
// delegatecall to implementation
}
}
contract Implementation {
address public owner; // selector: 0x8da5cb5b (auto-generated getter)
}
The auto-generated getter for owner in Implementation has the same 4-byte selector as the proxy's owner() function. Any call to proxy.owner() hits the proxy's function directly without going through the fallback — meaning the implementation's owner storage is never read. The proxy returns its own data, which doesn't reflect what the implementation thinks the owner is.
The Diamond Pattern (EIP-2535) has a related concern at scale: with multiple facets, multiple functions, and dynamic routing, selector collisions can occur unintentionally. The standard solution is the DiamondLoupe interface, which exposes the facet-to-selector mappings for explicit inspection.
Fixed Approaches
Transparent Proxy Pattern. OpenZeppelin's TransparentUpgradeableProxy solves selector collision by routing all calls to the admin to admin-only functions, and all other calls (regardless of selector match) to the implementation. Non-admin callers can never hit the proxy's own functions, eliminating the collision risk.
Minimal Proxy Surface. Avoid defining functions on the proxy itself. The proxy should ideally have only a fallback, a receive, and a constructor — no public functions of its own. With no functions on the proxy, no selector collision is possible.
Selector Audit. For diamond patterns or proxies with non-trivial proxy-side logic, audit the union of all selectors across the proxy and all implementations. Tools like Slither and Foundry's forge inspect can list function selectors; comparing them across contracts surfaces collisions.
Foundry Test for Delegatecall Behavior
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/Proxy.sol";
import "../src/Implementation.sol";
contract DelegatecallTest is Test {
Proxy proxy;
Implementation impl;
address user = makeAddr("user");
function setUp() public {
impl = new Implementation();
proxy = new Proxy(address(impl), address(this));
}
function test_implementationStateIsolatedFromProxy() public {
// Set state through the proxy (which delegate-calls)
Implementation(address(proxy)).setValue(42);
// The value lives in proxy storage, not implementation storage
assertEq(Implementation(address(proxy)).getValue(), 42);
assertEq(impl.getValue(), 0, "implementation's storage untouched");
}
function test_storageSlotPositions() public {
// Read raw storage to verify the layout
bytes32 implSlot = vm.load(address(proxy),
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc); // EIP-1967
assertEq(address(uint160(uint256(implSlot))), address(impl));
}
function test_msgSenderPreservedThroughDelegatecall() public {
vm.prank(user);
Implementation(address(proxy)).recordCaller();
assertEq(Implementation(address(proxy)).lastCaller(), user,
"msg.sender should be user, not proxy");
}
}
These tests assert the invariants of correct delegatecall behavior. test_implementationStateIsolatedFromProxy proves the proxy's state stays separate from the implementation's; test_storageSlotPositions proves the EIP-1967 slot layout is in place; test_msgSenderPreservedThroughDelegatecall proves the caller identity propagates correctly. Without tests like these, delegatecall integration bugs are easy to miss.
Quick Reference
| Bug | What goes wrong | Defense |
|---|---|---|
| Storage collision (proxy/impl mismatch) | Proxy state at low slots collides with implementation's variables | EIP-1967 standard slots; OpenZeppelin proxy contracts |
| Storage drift (between versions) | Inserting a variable shifts all later slots; corrupts existing state | Append-only declaration order; or storage gaps; or ERC-7201 namespaced storage |
| Delegatecall to user-controlled address | Attacker's code runs against your storage and msg.sender | Whitelist allowed targets; or use call instead of delegatecall |
| Library self-destruct (Parity pattern) | Public initializer + selfdestruct removes the library; wallets become unusable | _disableInitializers() in implementation constructor; no selfdestruct in implementations |
| Selector collision (proxy ↔ impl) | Proxy function shadows implementation function; routing breaks silently | Transparent proxy pattern; minimal proxy surface; selector audit |
Cross-References
- Storage patterns — Section 3.7.2 covers Explicit Storage Buckets (ERC-7201) as the developer-facing pattern
- Upgradeability — Section 3.5 covers proxy patterns (Transparent, UUPS, Diamond) and the broader upgradeability lifecycle
- Solidity language pitfalls — Section 3.8.1 covers constructor-vs-initializer and
_disableInitializers() - Access control — Section 3.8.4 covers unprotected initializers from the access-control framing
- Real exploits — Section 3.10.2 (Parity Multi-sig) covers the kill-the-library incident in case-study form; the broader pattern of "trust-without-verification on infrastructure contracts" recurred in subsequent incidents including Wormhole's Ethereum-side near-miss (samczsun's
_disableInitializers()whitehat report, 2022) - Auditor's view — Section 4.11.9 covers
delegatecalldetection heuristics during audit - OpenZeppelin —
TransparentUpgradeableProxy,ERC1967Proxy,Initializable, and the Upgrades Plugin are the reference implementations referenced throughout this section