ROOTVIELROOTVIEL
← Academy

Module 8 · Ethereum, the EVM & on-chain zero-knowledge

delegatecall, proxies and the Diamond (EIP-2535) pattern

Deployed contract code is immutable, yet real systems must evolve. The escape hatch is delegatecall: an opcode that borrows another contract's code but executes it in the caller's storage, with the caller's msg.sender and address(this) preserved. A thin proxy that holds state and delegatecalls a swappable implementation is the whole idea behind upgradeability. The same mechanism that enables upgrades also causes its worst failures — storage collisions and hijacked uninitialized implementations — and the transparent, UUPS and Diamond patterns are three answers to "how do we make this safe at scale". This lesson is the engineering groundwork for the Module 8 coercion-resistant-privacy case study, which deploys EIP-2535 in production.

Learning objective

Understand how delegatecall runs foreign bytecode against the caller's own storage and context; why this one opcode makes upgradeable contracts possible yet opens storage-collision and uninitialized-implementation risks; how the transparent, UUPS and Diamond (EIP-2535) proxy patterns differ; and how the Diamond composes many facets to escape the EIP-170 24 KB bytecode limit.

Track
Engineering
Module
8 · Ethereum, the EVM & on-chain zero-knowledge
Prerequisites
The EVM execution model · Contract storage layout and collisions

Conceptual introduction

Once a contract is deployed its bytecode is frozen forever — there is no edit button on the EVM. But protocols need to fix bugs, add features and respond to incidents. The entire upgradeability toolkit in Ethereum is built on a single opcode that lets one contract borrow another contract's logic while keeping its own data: delegatecall.

The mental model is a thin, permanent "front desk" (the proxy) that never changes address and holds all the state, pointing at a replaceable "back office" (the implementation) that holds only the logic. Every call hits the front desk, which forwards it to the current back office via delegatecall. Swap the back office and the contract behaves differently, yet its address, balance and stored data are untouched. Users keep talking to the same address; the brains behind it can be replaced.

Note

delegatecall is described as the most powerful and the most dangerous opcode in the EVM for good reason: it deliberately runs untrusted-or-swappable code directly against your storage. Every upgradeable contract inherits both the power and the danger.

Technical foundation: what delegatecall actually does

A normal CALL runs the target's code in the target's context: the target's storage is touched, msg.sender becomes the caller, and address(this) is the target. delegatecall inverts the part that matters: it runs the target's code but in the caller's context. The storage read and written is the caller's. msg.sender and msg.value are preserved from the original call. address(this) stays the caller. Only the code comes from the target.

Proxy
The contract users call. It permanently owns the address, the ETH balance and all persistent storage, and forwards unknown calls to the implementation via delegatecall in its fallback.
Implementation (logic contract)
The contract whose code is borrowed. It defines behaviour but, when reached through the proxy, reads and writes the proxy's storage, not its own.
Preserved context
Under delegatecall, msg.sender, msg.value and address(this) are those of the original caller/proxy — so the borrowed code sees the world as if it were the proxy itself.
Storage slot
EVM storage is a flat map from 256-bit slot numbers to 256-bit words. The compiler assigns state variables to slots by declaration order. Because delegatecall shares the proxy's slots, the two contracts must agree on what lives in each slot.
Note

A historical note: CALLCODE was the deprecated precursor to delegatecall. It also borrowed code but reset msg.sender to the caller; delegatecall replaced it precisely so that the original msg.sender and msg.value survive the hop, which is what makes transparent proxying possible.

Mechanism: the minimal proxy and the collision bug

A proxy's fallback copies the incoming calldata, delegatecalls the implementation, and then forwards the returned bytes (or bubbles up the revert). The subtlety is never in the forwarding — it is in the storage the borrowed code writes to. Both contracts lay out their variables starting at slot 0, so if the proxy keeps its own bookkeeping (the implementation address, an admin) in low slots, the implementation's variables land on top of them. This is storage collision — historically the single most common and most costly bug in proxy systems.

synthetic — how a naive proxy collides with its implementation
// PROXY stores its bookkeeping in low slots contract NaiveProxy { address implementation; // slot 0 address admin; // slot 1 fallback() external payable { // delegatecall(implementation, msg.data), forward return/revert } } // IMPLEMENTATION also starts at slot 0 contract Token { uint256 totalSupply; // slot 0 <-- same slot as 'implementation' address owner; // slot 1 <-- same slot as 'admin' // ... } // When Token.mint() runs via delegatecall in the proxy's storage: // writing totalSupply overwrites the proxy's 'implementation' pointer // => the proxy now points its logic at a garbage address and bricks itself.

The fix is to stop putting proxy bookkeeping in sequential low slots and instead pin it to pseudo-random, standardized slots that normal variables will essentially never occupy. That standard is EIP-1967.

EIP-1967: giving proxy data a safe home

EIP-1967 defines fixed, collision-resistant slots for the handful of values a proxy itself needs — the implementation address, the admin, and (for beacon proxies) the beacon address. Each slot is computed as the keccak256 hash of a human-readable label, minus one. Because the slot is a hash of a long string, no ordinary Solidity variable laid out from slot 0 will ever land there.

synthetic — EIP-1967 slot derivation and use
// Implementation slot = keccak256("eip1967.proxy.implementation") - 1 bytes32 constant IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; function _implementation() internal view returns (address impl) { bytes32 slot = IMPLEMENTATION_SLOT; assembly { impl := sload(slot) } // read from the fixed slot, not slot 0 } // The "- 1" lets off-chain tools recognise a standard slot: // if keccak256(label) == slot + 1 then it follows EIP-1967.
Note

The deliberate "minus one" is not cryptographic; it exists so tooling can verify a slot follows the standard by re-deriving keccak256(label) and checking it equals slot + 1. The collision resistance comes from the hash, not the offset.

The three live patterns: transparent, UUPS, Diamond

Given a safe place to store the implementation pointer, the remaining design question is where the upgrade logic lives and who may call it. Three patterns dominate.

Transparent proxy
Upgrade functions (upgradeTo, changeAdmin) live in the proxy. The proxy routes admin calls to itself and everyone else's calls to the implementation, so a user can never accidentally hit an admin function and the admin can never accidentally fall through to the logic. Simple to reason about, but every proxy carries the upgrade machinery.
UUPS (Universal Upgradeable Proxy Standard)
The upgrade logic lives in the implementation instead of the proxy, so the proxy is tiny and cheap to deploy. The implementation must inherit an upgradeTo guarded by an _authorizeUpgrade hook with real access control. Forget to guard it and anyone can replace the logic.
Diamond (EIP-2535)
One proxy routes each function selector to one of many implementations called facets. Instead of a single logic address there is a mapping from 4-byte selector to facet address, maintained by a diamondCut operation. This allows a single logical contract of effectively unlimited size and fine-grained, facet-by-facet upgrades.

A beacon proxy is a fourth variant worth knowing: many proxies read their implementation address from one shared "beacon" contract, so upgrading the beacon upgrades every proxy at once — ideal for factories that spin up thousands of identical contracts, at the cost of a single point of compromise.

The Diamond in depth: facets and the 24 KB wall

The Spurious Dragon hard fork introduced EIP-170, which caps deployed contract bytecode at 24576 bytes (24 KB). A complex protocol can simply not fit its logic into one contract under that limit. The Diamond answers this directly: because each function selector can be routed to a different facet, the "contract" is only as large as its routing table, while its code is spread across many separately deployed facets — each of which independently fits under 24 KB.

synthetic — a schematic Diamond router and FacetCut
contract Diamond { // selector (first 4 bytes of calldata) -> facet that implements it mapping(bytes4 => address) facets; struct FacetCut { address facetAddress; bytes4[] selectors; } // add / replace / remove the functions a facet serves function diamondCut(FacetCut[] calldata cuts) external { require(msg.sender == owner()); // access control is essential for (uint i; i < cuts.length; i++) for (uint j; j < cuts[i].selectors.length; j++) facets[cuts[i].selectors[j]] = cuts[i].facetAddress; } fallback() external payable { address facet = facets[msg.sig]; // route by selector require(facet != address(0), "Function not found"); // delegatecall(facet, msg.data) in the Diamond's own storage, forward result } }
  1. 1A call arrives; the Diamond reads its 4-byte selector (msg.sig).
  2. 2It looks up which facet owns that selector in the routing map.
  3. 3It delegatecalls that facet, so the facet's code runs against the Diamond's single shared storage.
  4. 4To upgrade, the owner submits a diamondCut adding, replacing or removing selectors — changing only the routing table, not the storage or the address.
  5. 5Because many facets share one storage space, each facet must confine itself to a disjoint, deliberately namespaced region (the "Diamond Storage" discipline) so facets never trample each other's slots.
Note

The Diamond's flexibility multiplies the storage-collision problem: every facet writes to the same slots, so storage layout discipline across facets is the difference between a clean upgrade and a silent corruption. This is exactly why the Module 8 privacy case study treats facet storage namespacing as a first-class design concern.

Initialization: why constructors fail and implementations get hijacked

A constructor runs once, at deployment, and writes to the storage of the contract being deployed. But a proxy delegatecalls the implementation at runtime — the implementation's constructor already ran against the implementation's own storage, never the proxy's. So proxies cannot use constructors to set up their state. Instead they call an ordinary initialize function through the proxy, once, guarded so it cannot be called twice.

This creates a notorious trap. The implementation contract sits deployed on-chain with an uninitialized initialize function. If that function is left callable directly on the implementation (not just through the proxy), an attacker can call it, make themselves the owner of the implementation, and — in a UUPS design where upgrade logic lives in the implementation — invoke upgradeTo to point it at malicious code. A well-known cross-chain bridge incident in 2022, publicly reported at roughly $320 million, followed exactly this class of bug: a deployed-but-uninitialized implementation whose initializer an attacker seized to drive an upgrade.

synthetic — the uninitialized-implementation bug class and its fix
// VULNERABLE: implementation left directly initializable contract LogicV1 { address public owner; function initialize(address _owner) external { require(owner == address(0), "done"); // only guards re-init on THIS storage owner = _owner; // attacker calls this on the impl itself } function upgradeTo(address newImpl) external { require(msg.sender == owner); // attacker is now owner -> full takeover // ... points logic at attacker code } } // FIXED: disable initializers on the implementation at construction time contract LogicV1Safe { constructor() { _disableInitializers(); } // the impl can never be initialized directly function initialize(address _owner) external initializer { /* ... */ } }
Note

Lesson, stated generically: an upgradeable system is only as safe as the implementation sitting beside the proxy. Protecting the proxy is not enough; the bare implementation must be made impossible to initialize directly, or an attacker initializes it and upgrades out from under you.

Risks & limitations

  • ▸Storage collision: proxy and implementation (or two facets) that disagree on slot layout silently corrupt each other. EIP-1967 slots and disciplined, namespaced storage are mandatory, not optional.
  • ▸Uninitialized implementations: a deployed logic contract whose initializer is still callable directly can be hijacked and used to drive an upgrade — the bug class behind a well-known bridge drain. Disable initializers on implementations.
  • ▸Unguarded upgrade authority: in UUPS the upgrade path lives in the implementation; a missing or empty _authorizeUpgrade lets anyone replace the logic. Upgrade power is the keys to the kingdom and needs access control, ideally a timelock and multisig.
  • ▸Function selector clashing: two functions sharing the same 4-byte selector create ambiguity about which runs — acute in transparent proxies and in Diamonds where selectors are the routing key.
  • ▸delegatecall to attacker-controlled targets, or an implementation containing selfdestruct, can take over or destroy the proxy itself; one historical library self-destruct froze around $150M. Never delegatecall an address a user can choose; whitelist trusted targets.
  • ▸Upgradeability is a trust assumption, not just an engineering feature: a contract that can be upgraded can be changed by whoever holds the upgrade key. A valid, successful upgrade proves the key-holder acted, not that the new code is safe or that users consented.

Exercise

Note

You are handed a UUPS implementation contract deployed on-chain. Its proxy is correctly initialized and locked down, but the audit flags the bare implementation address itself as high risk. Explain the concrete takeover an attacker can perform against the implementation, why protecting only the proxy did not help, and the single change that closes the hole.

Expected direction: Because the implementation was deployed but never initialized on its own storage, an attacker can call initialize directly on the implementation address and become its owner. In UUPS the upgrade logic (upgradeTo / _authorizeUpgrade keyed on owner) lives in the implementation, so as owner the attacker can trigger an upgrade and, depending on the setup, destroy or repoint the logic — the uninitialized-implementation bug class behind a well-known bridge incident. Protecting the proxy did not help because the proxy and the implementation are separate deployed contracts with separate storage; the attack targets the implementation's own storage, which the proxy's guards never touch. The fix is to call _disableInitializers() in the implementation's constructor (or otherwise make initialize permanently uncallable on the implementation), so it can only ever be initialized through the proxy.

Check your understanding

Under delegatecall, whose storage, msg.sender and address(this) are in effect — the caller's or the target's?
Storage, msg.sender, msg.value and address(this) are all the caller's (the proxy's); only the executed code comes from the target. That preserved context is exactly what makes proxying work.
What is a storage collision and how does EIP-1967 prevent it?
A storage collision is when the proxy's own variables share slots with the implementation's variables (both start at slot 0), so one silently overwrites the other. EIP-1967 stores proxy data at fixed slots derived from keccak256 of a label minus one, which normal sequential variables will never occupy.
How does the Diamond (EIP-2535) pattern let a logical contract exceed the EIP-170 24 KB bytecode limit?
The Diamond stores only a selector-to-facet routing table and delegatecalls the right facet per call. The actual logic is spread across many separately deployed facets, each independently under 24 KB, so the overall system is effectively unbounded in size.
Why can an upgradeable contract not use a constructor to set up its state, and what replaces it?
A constructor runs at deployment against the deploying contract's own storage; the proxy only delegatecalls the implementation later, so the constructor never wrote to the proxy's storage. Upgradeable contracts instead use an initialize function called once through the proxy, guarded against re-initialization, and disable initializers on the bare implementation.

References

  1. [1]EIP-2535: Diamonds, Multi-Facet Proxy (Nick Mudge)
  2. [2]EIP-1967: Standard Proxy Storage Slots
  3. [3]EIP-170: Contract code size limit (Spurious Dragon hard fork)
  4. [4]Solidity documentation — Security Considerations and the delegatecall / libraries sections
  5. [5]Antonopoulos & Wood — Mastering Ethereum (O'Reilly, 2018)

References point to public, well-known technical sources for further study.

Related articles