Implementation-grade · EVM internals & upgradeability

The Book of Proxy

Twenty modules from a storage slot to a diamond. The layout rules that delegatecall silently depends on, ABI encoding and the four-byte selector, every shape a revert can take, then the patterns built on top: ERC-1967 slots, transparent and UUPS proxies, ERC-7201 namespaces, initializers, beacons, EIP-1167 clones, MetaProxy, the fallback extension, upgrade-safety tooling, noDelegateCall, and EIP-2535. Worked through the RareSkills Book of Proxy Patterns.

Storage layoutdelegatecallERC-1967TransparentUUPSERC-7201BeaconEIP-1167Diamond

MODULE 00 Storage slots in Solidity

Where state variables actually live: sequential slot allocation, the packing rules, and reading or writing a slot directly with sload and sstore.

Every proxy bug is, underneath, a storage bug. So the book starts where the EVM starts: a contract's storage is a mapping from a 256-bit key to a 256-bit value — 2²⁵⁶ slots, all of them zero until written. Solidity hands out those slots at compile time, and it is the slot number, never the variable name, that survives into the deployed bytecode.

Sequential allocation

State variables are assigned slots in declaration order: the first gets slot 0, the second slot 1, and so on. The assignment is permanent — recompiling with the variables reordered produces a contract that reads different data out of the same storage.

solidity
contract Sequential {
    uint256 a;   // slot 0
    uint256 b;   // slot 1
    uint256 c;   // slot 2
}

Packing

Variables smaller than 32 bytes share a slot when they are declared consecutively and their sizes add up to 256 bits or fewer. Solidity fills a slot from the least significant (rightmost) byte leftwards. A variable that does not fit in the space left over starts a fresh slot — it is never split across two.

Type Size Type Size
bool, uint8 1 byte address 20 bytes
uint32 4 bytes uint256, bytes32 32 bytes
uint128 16 bytes bytes4 4 bytes
Worked example — declaration order is not free

These two contracts hold identical data and cost different amounts to deploy and to use:

solidity
contract Wasteful {
    uint16  a;   // slot 0  (2 bytes used, 30 wasted)
    uint256 x;   // slot 1  (a full slot must start fresh)
    uint32  b;   // slot 2  (2 bytes used, 28 wasted)
}                // → 3 slots

contract Packed {
    uint256 x;   // slot 0
    uint16  a;   // slot 1, bytes 0-1
    uint32  b;   // slot 1, bytes 2-5
}                // → 2 slots

The compiler will not reorder your variables for you. Packing is entirely a consequence of the order you wrote them in.

Reading and writing slots directly

Yul gives you three tools. x.slot is compile-time information — the slot index a variable was assigned. sload(slot) returns all 32 bytes of that slot as a bytes32. sstore(slot, value) overwrites all 32 bytes.

solidity
contract Direct {
    uint256 x = 42;

    function slotOfX() external pure returns (uint256 s) {
        assembly { s := x.slot }          // → 0
    }

    function readRaw(uint256 s) external view returns (bytes32 v) {
        assembly { v := sload(s) }        // no type checking whatsoever
    }

    function writeRaw(uint256 s, uint256 v) external {
        assembly { sstore(s, v) }         // overwrites the WHOLE slot
    }
}
Gotcha

sstore is all-or-nothing on a 32-byte slot. If three packed variables share slot 0, writing slot 0 in assembly clobbers all three. Touching one packed variable safely means sload, mask out the target bytes, OR in the new value, sstore back — the compiler normally does this for you, and assembly does not.

Why this matters for proxies

A proxy and its implementation are two separately compiled contracts that share one storage layout — the proxy's. Nothing links them but the slot numbers. Every rule on this page is the contract between them, and every later chapter is a strategy for not breaking it.

Summary
  • Slots are handed out sequentially in declaration order and are fixed at compile time.
  • Consecutive variables under 32 bytes pack into one slot, filling from the right; a variable that does not fit starts the next slot rather than splitting.
  • sload/sstore read and write a whole slot with no type checking — the fastest way to corrupt packed state.
Self-check
  1. Why does inserting a variable at the top of a contract break a live proxy?
  2. address owner; bool paused; uint32 t; — how many slots, and in what order within them?
  3. What happens if you sstore(0, x) in the contract from question 2?
Show answers

1) Every following variable shifts down one slot, but the proxy's storage still holds the old values at the old slots — so each variable now reads its neighbour's data. 2) One slot: owner in bytes 0-19, paused in byte 20, t in bytes 21-24. 3) All three are overwritten at once, because they live in the same slot.


MODULE 01 Storage slots of dynamic types

Mappings, arrays, strings and bytes do not get a slot for their data — they get a keccak256 formula. Here are all of them.

A uint256 has a fixed size, so the compiler can reserve a slot for it. A mapping or a dynamic array cannot — their size is unknown at compile time. Solidity solves this by giving the variable a base slot and deriving the data locations by hashing, which spreads them across the 2²⁵⁶ address space so densely that a collision is not a practical concern.

Mappings

slot of mapping[key]

slot = keccak256(abi.encode(key, baseSlot))

  • key and baseSlot are each padded to 32 bytes, concatenated, then hashed
  • the base slot itself stores nothing — a mapping has no length
solidity
contract MappingSlot {
    mapping(address => uint256) balance;   // base slot 0

    function slotOf(address key) public pure returns (bytes32) {
        return keccak256(abi.encode(key, uint256(0)));
    }

    function readVia(address key) public view returns (uint256 v) {
        bytes32 s = slotOf(key);
        assembly { v := sload(s) }
    }
}

Nested mappings just apply the formula again, once per level:

mapping(a => mapping(b => v))

h₁ = keccak256(abi.encode(key1, baseSlot))
slot = keccak256(abi.encode(key2, h₁))

Dynamic arrays

The base slot is used here — it holds the array's length. The elements start at the hash of the base slot and run consecutively:

array[i]

length lives at baseSlot
slot = keccak256(abi.encode(baseSlot)) + i

solidity
// uint256[] arr at base slot 2, reading arr[3]
uint256 s = uint256(keccak256(abi.encode(uint256(2)))) + 3;
assembly { value := sload(s) }

Elements pack the same way struct fields do — types of 128 bits or less share slots; an address (20 bytes) does not pack with another address. Nested arrays hash twice:

array[i][j]

s₁ = keccak256(abi.encode(baseSlot)) + i
slot = keccak256(abi.encode(s₁)) + j

Strings and bytes

These have two encodings and a one-bit tag to tell them apart.

Length Where the data lives What the base slot holds
≤ 31 bytes in the base slot, packed from the left the data, with length × 2 in the rightmost byte
> 31 bytes keccak256(abi.encode(baseSlot)) + chunk length × 2 + 1
Why the ×2 and the +1

Doubling the length frees the low bit as a flag, and long strings set it. So the check for "is this a long string" is a single bit test — length & 1 — and the real length is stored >> 1. A short string can never set that bit because its length is at most 31, and 31 × 2 = 62 is even.

bytes uses the identical scheme. Fixed-size bytes1bytes32 are value types: they take one slot (packing where they can) and are indexed with value[0].

Dynamic types inside structs

A struct's fields are laid out from the struct's base slot as though they were top-level variables. A mapping at field index k therefore has base slot structBase + k, and the usual mapping formula applies from there.

Why this matters for proxies

Hashed locations are the reason a proxy can add a mapping without disturbing anything: its data lands in a pseudorandom region, not in the next sequential slot. It is also the trick ERC-1967 and ERC-7201 borrow — pick a slot nobody can reach by accident, because nobody knows a preimage for it.

Summary
  • Mapping: keccak256(abi.encode(key, baseSlot)), once per nesting level; the base slot is unused.
  • Array: length at the base slot, elements at keccak256(abi.encode(baseSlot)) + i.
  • String/bytes: inline when ≤ 31 bytes with len × 2; otherwise len × 2 + 1 at the base and data at the hash — the low bit is the tag.
Self-check
  1. Two contracts declare a mapping at slot 0 and a uint256 at slot 0. Do they collide?
  2. A string's base slot reads 0x2A (42). How long is the string and where is it?
  3. Why does an array store its length in the base slot but a mapping store nothing there?
Show answers

1) The uint256 occupies slot 0 itself; the mapping's data lives at hashed slots, so writes to the integer do not touch mapping entries — but the mapping's base slot 0 is shared, which is harmless only because the mapping never reads it. 2) 42 is even, so it is a short string: length 42 / 2 = 21 bytes, stored in that same slot. 3) An array can be iterated and needs a bound; a mapping has no enumerable key set, so there is nothing to count.


MODULE 02 ABI encoding for function calls

The calldata a proxy forwards byte for byte: selector derivation, the head/tail layout for dynamic types, and which abi.encode* helper to reach for.

A proxy's whole job is to hand somebody else's code the calldata it received, unmodified. To reason about that — and about selector clashes, which cause several of the bugs later in this book — you need to know exactly what calldata is.

The function selector

The first four bytes of calldata are bytes4(keccak256(signature)), where the signature is the function name and its parameter types with no spaces and no parameter names.

solidity
bytes4(keccak256("transfer(address,uint256)")) == 0xa9059cbb

Canonicalisation rules when building the signature string:

  • struct becomes a tuple — (uint256,address)
  • address payable becomes address
  • enum becomes uint8
  • user-defined value types resolve to their underlying type
  • memory / calldata / storage are ignored

Head and tail

Arguments are encoded in two regions. Static types (uint256, bool, address, fixed arrays of statics, structs with only static fields) are padded to 32 bytes and written inline in the head. Dynamic types (string, bytes, dynamic arrays, and anything containing them) write a 32-byte offset in the head, and the real data goes in the tail at that offset.

calldata
foo(uint256,string)  with  (5, "hi")

0x  a1b2c3d4                                                          selector
    0000000000000000000000000000000000000000000000000000000000000005  5
    0000000000000000000000000000000000000000000000000000000000000040  offset -> 0x40
    0000000000000000000000000000000000000000000000000000000000000002  length 2
    6869000000000000000000000000000000000000000000000000000000000000  "hi", right-padded
Gotcha

Offsets are measured from the start of the region that introduced that nesting level, not from the position of the offset word itself. Get this wrong when hand-encoding nested arrays and you will read the wrong tail. Also note that a fixed-size array whose elements are dynamic is itself dynamic.

Which helper to use

Call Produces Notes
abi.encode(a, b) padded args, no selector what the slot formulas in module 01 use
abi.encodePacked(a, b) unpadded, concatenated ambiguous for two dynamic args — a hash-collision footgun
abi.encodeWithSignature("f(uint256)", x) selector + args signature is a string, so typos compile fine
abi.encodeWithSelector(IFoo.f.selector, x) selector + args selector checked, arguments still not
abi.encodeCall(IFoo.f, (x)) selector + args type-checked end to end — prefer this
Gas note

Calldata costs 16 gas per non-zero byte and 4 per zero byte. All that zero padding is deliberately cheap — which is why the ABI pads rather than packs.

Summary
  • Selector = first 4 bytes of the keccak of the canonicalised signature.
  • Static args sit inline; dynamic args write an offset and park their data in the tail.
  • abi.encodeCall is the only helper that type-checks both the function and its arguments.
Self-check
  1. What is the signature string for f(MyEnum e, address payable p)?
  2. Why can abi.encodePacked(string,string) produce the same bytes for different inputs?
  3. Only four bytes identify a function. Why should a proxy author care?
Show answers

1) f(uint8,address). 2) Packed encoding drops the lengths, so ("ab","c") and ("a","bc") both become abc. 3) Because a proxy's own public functions are matched before the fallback — a 4-byte collision between a proxy function and an implementation function silently makes the implementation function unreachable. That is the problem the transparent proxy in module 09 exists to solve.


MODULE 03 Low level call vs high level call

Both compile to the same opcode. The difference is the safety code the compiler wraps around it — and a low-level call to an empty address returning true.

Calling IFoo(target).bar() and calling target.call(...) both end in the CALL opcode. Everything that differs is bytecode Solidity generates around it.

Failure handling

A high-level call through an interface reverts automatically when the callee reverts — the compiler emits the check. A low-level call does not: per the Solidity docs, low-level calls "return false as their first return value in case of an exception instead of bubbling up". Ignoring that boolean is one of the most common findings in an audit.

solidity
// low level — you own the check
(bool ok, bytes memory ret) = target.call(abi.encodeCall(IFoo.bar, ()));
if (!ok) revert CallFailed();

// high level — the compiler owns the check
IFoo(target).bar();

The empty-address trap

Gotcha

A low-level call to an address with no code succeeds. The CALL opcode has nothing to execute, so it returns 1 with empty return data — indistinguishable from a function that returned nothing.

High-level calls do not have this problem because the compiler first runs EXTCODESIZE and reverts when the target has no code. If you are writing low-level calls, you have to do it yourself:

solidity
function safeCall(address target, bytes memory data) internal returns (bytes memory) {
    require(target.code.length > 0, "no code at target");
    (bool ok, bytes memory ret) = target.call(data);
    if (!ok) {
        assembly { revert(add(ret, 0x20), mload(ret)) }   // bubble the real reason
    }
    return ret;
}

The three variants

Variant Storage written msg.sender seen by callee Can send value
call the callee's the caller contract yes
delegatecall the caller's preserved from the outer call no (value is preserved, not sent)
staticcall none — state change reverts the caller contract no

Gas is forwarded under EIP-150's 63/64 rule: a call gets all but a sixty-fourth of the remaining gas unless you cap it explicitly. That reserved sixty-fourth is what lets the caller still handle a failed subcall rather than dying with it.

Summary
  • Low-level calls return (bool, bytes) and never revert on your behalf.
  • A low-level call to a codeless address returns true; high-level calls EXTCODESIZE-check first.
  • delegatecall is the only variant that writes the caller's storage — which is the entire basis of proxies.
Self-check
  1. A proxy delegatecalls an implementation that has been selfdestructed. What does the user see?
  2. Why bubble revert data with assembly instead of revert(string(ret))?
  3. Why must a proxy use a low-level call rather than an interface?
Show answers

1) The delegatecall returns success with no return data, so every call appears to work and silently does nothing — the reason OpenZeppelin's proxy checks the implementation has code. 2) The callee may have reverted with a custom error or a Panic, which is not a string; re-emitting the raw bytes preserves the original error for the caller to decode. 3) The proxy does not know the implementation's ABI — it forwards arbitrary calldata, so there is no interface to call through.


MODULE 04 Try/catch and all the ways Solidity can revert

Every revert shape a proxy might have to forward: empty, Error(string), Panic(uint256) and custom errors — plus what try/catch cannot catch.

A proxy has to forward whatever the implementation threw, byte for byte. That means knowing the four shapes revert data comes in.

The four shapes

Source Return data
revert(), require(false) empty — 0x
revert("reason"), require(false, "reason") Error(string) — selector 0x08c379a0 + ABI-encoded string
assert(false), compiler-inserted checks Panic(uint256) — selector 0x4e487b71 + code
revert Unauthorized() 4-byte custom selector, plus ABI-encoded args if any

Panic codes

Panics come from the safety checks the compiler inserts, not from your requires:

Code Meaning Code Meaning
0x01 assert failed 0x21 invalid enum value
0x11 arithmetic over/underflow 0x31 pop() on empty array
0x12 division or modulo by zero 0x32 array index out of bounds
0x22 badly encoded storage byte array 0x41 out of memory / too much allocated
Note

These checks exist at the Solidity level, not the assembly level. Arithmetic written in an assembly block wraps silently — it will not panic.

try / catch

solidity
try IFoo(target).risky() returns (uint256 v) {
    // success path — v is available here
} catch Error(string memory reason) {
    // revert("...") and require(..., "...")
} catch Panic(uint256 code) {
    // assert, overflow, division by zero, ...
} catch (bytes memory data) {
    // custom errors and empty reverts — decode `data` yourself
}
What try/catch cannot catch
  • Anything that reverts inside the try or catch block itself — only the external call is protected.
  • Failures decoding the return value when the callee returned the wrong shape.
  • Compiler-inserted checks around the call, such as the EXTCODESIZE existence check from module 03.
  • Custom errors by name — there is no catch MyError(...) syntax. They arrive as raw bytes and must be matched on their selector.
solidity
catch (bytes memory data) {
    if (bytes4(data) == IFoo.Unauthorized.selector) {
        // handle the specific custom error
    }
}
Summary
  • Four revert shapes: empty, Error(string), Panic(uint256), and custom-error selectors.
  • assert and the compiler's own checks panic; require and revert do not.
  • try/catch guards only the external call, and custom errors land in the catch-all bytes block.
Self-check
  1. Which catch block receives revert MyError(42)?
  2. Why is a custom error cheaper than require(false, "long reason string")?
  3. What does an out-of-gas failure inside the try block do?
Show answers

1) catch (bytes memory data) — there is no named syntax for custom errors. 2) The custom error is four bytes of return data plus packed args; the string version must encode the whole reason string, and that string also sits in deployed bytecode. 3) Because of the 63/64 rule the caller keeps a sliver of gas, so the catch block can run — but it will usually run out too. Never rely on catching OOG.


MODULE 05 Assembly revert

revert(offset, size) in Yul, how to hand-encode each error shape, and the bubble-up idiom every proxy fallback ends with.

The Yul revert takes a memory offset and a byte length: everything in that window becomes the return data. It is how a proxy forwards an implementation's error without knowing what that error was.

the opcode

revert(startingMemoryOffset, totalSizeInBytes)

  • revert(0, 0) — halt with no return data at all
  • state changes in the current call frame are rolled back; remaining gas is refunded

Writing the data first

mstore(p, v) writes 32 bytes at p, left-padding anything shorter. So mstore(0, 0xff) actually writes 0x00…00ff across bytes 0–31 — the ff lands at byte 31, not byte 0. mstore8 writes a single byte when you need that precision.

Hand-encoding each shape

solidity
// 1. no reason
assembly { revert(0, 0) }

// 2. custom error, no args:  error Unauthorized();
//    selector must sit in the FIRST four bytes, so shift it left
assembly {
    mstore(0x00, 0x82b4290000000000000000000000000000000000000000000000000000000000)
    revert(0x00, 0x04)
}

// 3. custom error with args: error BadAmount(uint256 got);
assembly {
    mstore(0x00, 0x1f2a200000000000000000000000000000000000000000000000000000000000)
    mstore(0x04, got)
    revert(0x00, 0x24)          // 4 selector + 32 arg
}

// 4. Error(string) — selector, offset, length, data
assembly {
    mstore(0x00, 0x08c379a000000000000000000000000000000000000000000000000000000000)
    mstore(0x04, 0x20)          // offset to the string
    mstore(0x24, 5)             // length
    mstore(0x44, "oops")        // the bytes, right-padded
    revert(0x00, 0x64)
}

Bubbling up return data

This is the idiom that closes every proxy fallback in the rest of the book. returndatasize() gives the length of whatever the last call returned, and returndatacopy(dest, offset, size) copies it into memory:

solidity
assembly {
    // forward all calldata to the implementation
    calldatacopy(0, 0, calldatasize())
    let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)

    // copy back whatever came out — return data or revert data
    returndatacopy(0, 0, returndatasize())

    switch ok
    case 0 { revert(0, returndatasize()) }   // re-throw the original error
    default { return(0, returndatasize()) }  // pass the result through
}
Why memory 0 is safe here

Scratch space at 0x000x3f is normally reserved for hashing, but the fallback above never returns to Solidity — it always ends in return or revert. Clobbering the free-memory pointer therefore costs nothing.

Gotcha

A custom-error selector must occupy the first four bytes of the reverted window. Writing mstore(0x00, 0x82b42900) right-pads it into bytes 28–31, so revert(0x00, 0x04) returns four zero bytes. Shift the selector to the high end, as above.

Summary
  • revert(offset, size) returns an arbitrary memory window as revert data.
  • mstore left-pads to 32 bytes, so selectors must be written high-aligned.
  • returndatacopy + revert re-throws a callee's error untouched — the closing move of every proxy fallback.
Self-check
  1. Why does a proxy re-throw raw bytes instead of require(ok)?
  2. What does revert(0, returndatasize()) return when the callee reverted with no data?
  3. Why is 0x24 the size for a custom error with one uint256?
Show answers

1) require(ok) throws away the implementation's error; users would see a bare failure instead of the real reason. 2) returndatasize() is 0, so it reverts with empty data — faithfully reproducing the callee. 3) Four bytes of selector plus one 32-byte word: 4 + 32 = 36 = 0x24.


MODULE 06 Delegatecall

Borrow another contract's code, keep your own storage, sender and balance. The one opcode every pattern in this book is built on — and the one that makes them dangerous.

delegatecall executes the target's bytecode in the caller's context. The target supplies logic and nothing else: no storage, no balance, no identity.

What is preserved

Under call Under delegatecall
msg.sender = the calling contract msg.sender = the original caller, unchanged
msg.value = value passed in this call msg.value = the outer call's value
address(this) = the callee address(this) = the caller
storage read/written = the callee's storage read/written = the caller's
solidity
contract Logic {
    uint256 public number;                       // slot 0 — of whoever calls it

    function setNumber(uint256 n) external { number = n; }
    function whoAmI() external view returns (address) { return address(this); }
}

contract Caller {
    uint256 public number;                       // slot 0 — the one actually written
    address public logic;

    function set(uint256 n) external {
        (bool ok,) = logic.delegatecall(abi.encodeCall(Logic.setNumber, (n)));
        require(ok);
        // Logic's bytecode ran; Caller's slot 0 changed. Logic.number is untouched.
    }
}

Storage collision — the central danger

The implementation's variable names are irrelevant. Only slot numbers cross the delegatecall boundary. If the two layouts disagree, writes land on the wrong variable:

Worked example — a one-line layout change bricks a proxy
solidity
contract ProxyV1 {
    address implementation;   // slot 0
    address admin;            // slot 1
}

contract LogicV1 {
    uint256 counter;          // slot 0  ← same slot as `implementation`
    function inc() external { counter++; }
}

// inc() writes 1 into slot 0.
// The proxy now believes its implementation lives at address 0x…01.
// Every subsequent call delegatecalls to an address with no code — and succeeds silently.

Immutables and constants are the exception

Neither occupies a slot — their values are baked into the implementation's bytecode and travel with it. Under delegatecall they therefore report the implementation's values, not the proxy's. codesize() behaves the same way. Module 18's noDelegateCall is built directly on this quirk.

Security pitfalls
  • Never delegatecall an address a user controls. The target can write any slot, including your owner and implementation pointers.
  • A delegatecall reaching selfdestruct destroys the calling contract. This is how uninitialised UUPS implementations were bricked in 2021 (module 12).
  • Delegatecall inside a payable loop re-reads the same msg.value each iteration — the Parity-style double-spend.
  • Because the implementation address is read from storage, any bug that writes slot 0 is an instant takeover.
Summary
  • delegatecall runs the target's code against the caller's storage, sender, value and address.
  • Only slot numbers are shared — names, types and visibility mean nothing across the boundary.
  • Immutables and constants come from the implementation's bytecode, not the proxy.
Self-check
  1. An implementation reads address(this).balance. Whose balance is it?
  2. Why can a proxy never store its implementation pointer at slot 0 safely?
  3. Why is immutable unaffected by the proxy's storage?
Show answers

1) The proxy's — address(this) is the caller under delegatecall. 2) Because a naively written implementation puts its own first variable there and will overwrite the pointer. ERC-1967 (module 08) is the fix. 3) Immutables are substituted into the bytecode at deploy time, so they are read from code, not storage, and the proxy's storage is irrelevant to them.


MODULE 07 Introduction to proxies

Putting modules 00-06 together: a contract that keeps the storage, a second that keeps the logic, and a fallback that welds them into an upgradeable whole.

Contracts are immutable. A proxy works around that by splitting a system in two: the proxy holds the storage and the address users interact with, and an implementation holds the logic. Upgrading means pointing the proxy at new logic — the address and all the state stay put.

Why storage must live in the proxy

Deploying a new implementation gives you a fresh, empty contract. If balances lived there, an upgrade would orphan every user's funds. Keeping state in the proxy — which never gets redeployed — is what makes an upgrade a change of behaviour rather than a migration.

The fallback

The proxy declares almost nothing. Anything it does not recognise hits the fallback, which forwards the calldata verbatim:

solidity
contract Proxy {
    // slot chosen by ERC-1967 so an implementation can never collide with it — module 08
    bytes32 private constant _IMPL =
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    constructor(address impl) { _setImplementation(impl); }

    fallback() external payable { _delegate(_implementation()); }
    receive()  external payable { _delegate(_implementation()); }

    function _implementation() internal view returns (address impl) {
        assembly { impl := sload(_IMPL) }
    }

    function _setImplementation(address impl) private {
        require(impl.code.length > 0, "not a contract");
        assembly { sstore(_IMPL, impl) }
    }

    function _delegate(address impl) internal {
        assembly {
            calldatacopy(0, 0, calldatasize())
            let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch ok
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }
}

Every line of that assembly comes from module 05: copy the calldata in, delegatecall, copy the result out, and either return it or re-throw it. The return is why this must be assembly — a Solidity fallback() cannot return dynamic data.

The two hazards

Storage collision

If the proxy declares address implementation; address admin; as ordinary variables, they take slots 0 and 1 — exactly where an implementation's first two variables go. The implementation overwrites the pointer on its first write. ERC-1967 fixes this by moving proxy metadata to unreachable slots.

Selector clash

Public functions on the proxy are matched before the fallback. If the proxy exposes upgradeTo(address) and the implementation happens to have a function with the same four-byte selector, that implementation function becomes permanently unreachable. With 4.29 billion selectors the odds per pair are small, but a real system has many pairs — and an attacker can grind a colliding name deliberately. Modules 09 and 10 are two different answers to this.

Upgrading

An upgrade is one sstore behind an access-control check. In practice it is paired with a delegatecall so new state can be initialised atomically — upgradeToAndCall, which module 12 explains.

Summary
  • Proxy = storage + stable address; implementation = logic only, no state.
  • The fallback delegatecalls with the raw calldata and bubbles the result back in assembly.
  • Two structural hazards fall out of this design — storage collision and selector clash — and the rest of the book is patterns for eliminating them.
Self-check
  1. Why does the fallback need assembly rather than plain Solidity?
  2. Why check impl.code.length > 0 before storing?
  3. Where does an implementation's constructor run in this design?
Show answers

1) Solidity's fallback() has no return type, so it cannot return the implementation's arbitrary-length return data; only return(ptr, len) in Yul can. 2) Because delegatecalling a codeless address silently succeeds (module 03), so the mistake would be invisible until users noticed nothing worked. 3) Against the implementation's storage at its own deployment — which the proxy never sees. That is why initializers exist (module 12).


MODULE 08 EIP-1967 storage slots for proxies

Standardised, unreachable slots for the implementation, admin and beacon pointers — and the keccak256(...) - 1 trick that makes them unreachable.

The proxy needs somewhere to keep its implementation pointer that no implementation could ever reach by accident. EIP-1967 standardises three such slots so that wallets and block explorers can also find them.

The three slots

Role Derivation Slot
Implementation keccak256("eip1967.proxy.implementation") - 1 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
Admin keccak256("eip1967.proxy.admin") - 1 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
Beacon keccak256("eip1967.proxy.beacon") - 1 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50

Why subtract one

the derivation

bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)

  • the hash alone would be reachable — it is exactly the slot some mapping or array with that preimage would occupy
  • subtracting one produces a value for which no preimage is known, so no Solidity data structure can be made to land there
  • out of 2²⁵⁶ slots, a pseudorandom pick collides with sequential slots 0, 1, 2 … with negligible probability
solidity
library ERC1967 {
    bytes32 internal constant IMPLEMENTATION_SLOT =
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    event Upgraded(address indexed implementation);

    function getImplementation() internal view returns (address a) {
        assembly { a := sload(IMPLEMENTATION_SLOT) }
    }

    function setImplementation(address newImpl) internal {
        require(newImpl.code.length > 0, "ERC1967: not a contract");
        assembly { sstore(IMPLEMENTATION_SLOT, newImpl) }
        emit Upgraded(newImpl);
    }
}

The events

The standard also fixes the log signatures so off-chain indexers can track upgrades: Upgraded(address indexed implementation), AdminChanged(address previousAdmin, address newAdmin) and BeaconUpgraded(address indexed beacon).

Note

EIP-1967 specifies where these values are stored and what is logged when they change — nothing else. It says nothing about who may upgrade or how. That is why the transparent, UUPS and beacon patterns can all be ERC-1967 compliant while behaving completely differently.

Practical upshot

Etherscan's "Read as Proxy" tab works by reading these exact slots. A proxy that invents its own slot still functions, but tooling will not recognise it — which is why OpenZeppelin's transparent proxy writes the admin to the ERC-1967 slot even though it never reads it back.

Summary
  • Three fixed slots hold the implementation, admin and beacon pointers.
  • keccak256(label) - 1 yields a slot with no known preimage, so no ordinary variable can ever occupy it.
  • The standard covers storage location and events only — not upgrade authorisation.
Self-check
  1. Why not just use keccak256("...") without the - 1?
  2. Could an implementation reach the ERC-1967 slot deliberately?
  3. What does Etherscan do with these slots?
Show answers

1) That slot has a known preimage, so a mapping keyed to produce it would land exactly there. Minus one has no known preimage. 2) Yes — with inline assembly it can sstore any slot it likes. The standard protects against accident, not against a malicious implementation, which is why you must control what you upgrade to. 3) Reads them to detect that a contract is a proxy and to resolve the current implementation's ABI.


MODULE 09 The transparent upgradeable proxy

Route on msg.sender instead of on the selector: the admin gets the upgrade functions, everyone else gets the implementation. No clash is possible.

The transparent pattern removes selector clashes by removing public functions from the proxy entirely. There is only the fallback, and it decides what to do based on who is calling rather than what they called.

solidity
fallback() external payable {
    if (msg.sender == _proxyAdmin()) {
        // the admin may call exactly one thing: upgradeToAndCall
        if (msg.sig != ITransparentUpgradeableProxy.upgradeToAndCall.selector) {
            revert ProxyDeniedAdminAccess();
        }
        _dispatchUpgradeToAndCall();
    } else {
        _delegate(_implementation());   // everyone else always reaches the logic
    }
}

A user can never be routed to upgrade logic, and the admin can never be routed to the implementation. Because no selector is ever compared against the proxy's own functions, a clash cannot occur — the ambiguity has been moved out of the selector space and into the caller.

The ProxyAdmin indirection

That raises a question: if the admin can only ever call upgradeToAndCall, how does an admin who is also a normal user interact with the app? OpenZeppelin's answer is a second contract.

  • The proxy stores its admin as an immutable — a deployed ProxyAdmin contract, fixed forever.
  • ProxyAdmin is an Ownable whose owner can change.
  • "Changing the proxy admin" means transferring ProxyAdmin's ownership, not touching the proxy at all.
call routing

Upgrade: owner → ProxyAdmin.upgradeAndCall() → proxy (admin branch)
Everything else: user → proxy (delegate branch) → implementation

Immutable admin, ERC-1967 slot anyway

Reading the admin from an immutable rather than storage saves the ~2,100 gas of a cold SLOAD on every call. The proxy still writes the admin address into the ERC-1967 admin slot at construction — purely so block explorers recognise it — and then never reads it.

upgradeToAndCall

solidity
function upgradeToAndCall(address newImplementation, bytes memory data) internal {
    _setImplementation(newImplementation);
    emit Upgraded(newImplementation);
    if (data.length > 0) {
        Address.functionDelegateCall(newImplementation, data);
    }
}

Combining the two is what makes an upgrade atomic: the new implementation is installed and its reinitializer runs in the same transaction, so there is no window in which the new code is live but its new state is unset. Note it does not require the implementation to actually change — passing the current one is a way for the admin to execute an arbitrary delegatecall.

The inheritance chain

Contract Adds
Proxy.sol the _delegate assembly and the fallback
ERC1967Proxy.sol reads/writes the implementation at the ERC-1967 slot
TransparentUpgradeableProxy.sol the immutable admin and the msg.sender branch
Trade-off

Every call pays for the admin comparison, and the deployment costs an extra ProxyAdmin. In exchange the implementation carries no upgrade code at all, which matters when it is near the 24 KB limit. UUPS makes the opposite trade.

Summary
  • The proxy has no public functions — the fallback branches on msg.sender, so selector clashes are impossible.
  • The admin is an immutable ProxyAdmin; rotating admins means transferring its ownership.
  • upgradeToAndCall swaps the implementation and initialises new state in one atomic transaction.
Self-check
  1. Why can the admin not simply use the dApp through the proxy?
  2. Why is the admin immutable if the standard says to store it in a slot?
  3. What breaks if the admin is an EOA rather than a ProxyAdmin?
Show answers

1) Every call from the admin is routed to the admin branch, which accepts only upgradeToAndCall — so all other calls revert. That is the price of removing the ambiguity. 2) Gas: an immutable is read from bytecode, saving a cold SLOAD per call. The ERC-1967 slot is still written once, for explorers. 3) Nothing breaks technically, but that EOA can never use the application through the proxy, and admin rotation would require a proxy-level function — which reintroduces the clash problem.


MODULE 10 UUPS: universal upgradeable proxy standard

Move the upgrade function into the implementation. Cheaper calls, a swappable authorisation scheme — and the ability to permanently brick your own contract.

The transparent proxy solves the selector clash by making the proxy smarter. UUPS solves it by making the proxy dumber: the proxy has no upgrade logic at all, so there is nothing to clash with. The upgradeToAndCall function lives in the implementation and runs — via delegatecall — against the proxy's storage, rewriting the proxy's ERC-1967 slot.

where the upgrade logic lives

Transparent: upgrade code in the proxy, checked on every call
UUPS: upgrade code in the implementation, delegatecalled like any other function

_authorizeUpgrade

UUPSUpgradeable leaves exactly one thing for you to write, and it is the access control. An empty override is a total loss of the contract.

solidity
contract MyLogic is UUPSUpgradeable, OwnableUpgradeable {
    constructor() { _disableInitializers(); }        // module 12

    function initialize(address owner_) external initializer {
        __Ownable_init(owner_);
        __UUPSUpgradeable_init();
    }

    function _authorizeUpgrade(address newImpl) internal override onlyOwner {}
    //                                                          ^^^^^^^^^
    //  forget this modifier and anyone can upgrade the contract to their own code
}

Because the authorisation lives in the implementation, each version can define its own. You can ship v1 gated by an EOA owner, then upgrade to a v2 gated by a timelock or a multisig or a governance vote — without touching the proxy.

proxiableUUID

Before switching, the current implementation asks the candidate which slot it considers the implementation slot:

solidity
function proxiableUUID() external view virtual returns (bytes32) {
    return ERC1967Utils.IMPLEMENTATION_SLOT;   // keccak256("eip1967.proxy.implementation") - 1
}

If the call reverts or returns a different slot, the target is not UUPS-compliant and the upgrade is rejected. This is a guard rail, not a guarantee — it catches the accident of upgrading to a plain non-upgradeable contract.

The bricking risk

UUPS's defining hazard: the upgrade path lives in the code you are replacing. Upgrade to an implementation that does not inherit UUPSUpgradeable and the new code has no upgradeToAndCall — so the proxy can never be upgraded again. The proxiableUUID check exists precisely to make this hard to do by accident, and it is why you should always run the upgrade through the plugin in module 17.

UUPS versus transparent

Transparent UUPS
Runtime gas admin check on every call cheaper — proxy does nothing but delegate
Deployment proxy + ProxyAdmin proxy only
Implementation size no upgrade code carries upgrade code in every version
Upgrade mechanism fixed for the proxy's life swappable per version
Can be bricked no yes — by upgrading to a non-UUPS implementation

If your implementation is pressed against the 24 KB limit, the transparent proxy's ability to keep upgrade code out of it can be the deciding factor.

Historical vulnerability

OpenZeppelin v4.1.0–v4.3.1 combined an unprotected implementation initializer with a delegatecall. An attacker could initialise the implementation, become its owner, then delegatecall a contract containing selfdestruct — destroying the implementation and freezing every proxy pointing at it. The fixes are _disableInitializers() in the constructor and avoiding delegatecall in implementations entirely.

Summary
  • UUPS puts upgradeToAndCall in the implementation, so the proxy has no functions to clash with.
  • You must override _authorizeUpgrade with real access control — an empty body is a total loss.
  • Cheaper per call and per deployment, but the upgrade path can be lost forever by upgrading to a non-UUPS implementation.
Self-check
  1. Why does upgradeToAndCall in the implementation modify the proxy's slot?
  2. What exactly does proxiableUUID prevent?
  3. Why might a large contract prefer a transparent proxy?
Show answers

1) It is reached by delegatecall, so its sstore writes the proxy's storage (module 06). 2) Upgrading to a contract that is not UUPS-aware — it reverts rather than silently installing an implementation with no upgrade path. 3) Because the upgrade machinery would otherwise be duplicated into every implementation version, eating into the 24 KB limit.


MODULE 11 ERC-7201 storage namespaces

Give every contract in an inheritance chain its own hashed storage root, so adding a parent never shifts a child's variables.

Sequential slots have a second failure mode beyond proxy/implementation collision: inheritance collision. Parent variables are laid out before child variables, so adding one field to a base contract shifts everything below it — and in an upgrade, every shifted variable now reads its neighbour's data.

Why storage gaps are not enough

The traditional mitigation is a uint256[50] private __gap; at the end of each base contract: new variables consume gap entries instead of shifting anything. But gaps are finite, easy to mis-size, and cannot help at all when you insert a new parent above an existing one in the linearisation — that shifts everything regardless.

The namespace formula

ERC-7201 storage root

keccak256(abi.encode(uint256(keccak256(namespace)) - 1)) & ~bytes32(uint256(0xff))

  • −1 — destroys the known preimage, as in ERC-1967
  • second keccak — pushes the root away from the slots Solidity's own dynamic-type formulas produce
  • & ~0xff — zeroes the last byte, aligning the root to a 256-slot boundary for forward compatibility with Verkle tries

Structs instead of state variables

Declaring any state variable forces Solidity to start at slot 0. So a namespaced contract declares none: the variables become fields of a struct that is never instantiated, and an accessor points that struct at the computed root.

solidity
/// @custom:storage-location erc7201:openzeppelin.storage.ERC20
struct ERC20Storage {
    mapping(address => uint256) _balances;
    mapping(address => mapping(address => uint256)) _allowances;
    uint256 _totalSupply;
    string  _name;
    string  _symbol;
}

// keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.ERC20")) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant ERC20StorageLocation =
    0x52c63247e1f47db19d5ce0460030c497f73fa268027848dcb15b3c9e2d13ec00;

function _getERC20Storage() private pure returns (ERC20Storage storage $) {
    assembly { $.slot := ERC20StorageLocation }
}

function balanceOf(address a) public view returns (uint256) {
    ERC20Storage storage $ = _getERC20Storage();
    return $._balances[a];
}

The key insight is that the slot formulas from module 01 are relative: a mapping at "field 0" of the struct hashes against the struct's root exactly as it would have hashed against slot 0. The struct simply becomes a new origin. Two contracts with different namespaces therefore have layouts that cannot overlap, no matter how the inheritance graph is rearranged.

The annotation

@custom:storage-location erc7201:<namespace> is not decorative — it is the machine-readable declaration that upgrade-safety tooling (module 17) reads to verify that a new version did not move anything.

Checklist
  1. Declare no state variables at all.
  2. Group every variable as a field of one struct per contract.
  3. Pick a unique namespace string, conventionally <project>.storage.<Contract>.
  4. Compute the root with the ERC-7201 formula and store it as a constant.
  5. Add the @custom:storage-location NatSpec tag.
  6. Access everything through the accessor: $.field.
Summary
  • Inheritance changes shift sequential slots; gaps only partly help and fail for new parents.
  • ERC-7201 gives each contract a hashed, byte-aligned storage root of its own.
  • Variables live as struct fields reached through an assembly accessor, and the NatSpec tag makes the layout checkable by tooling.
Self-check
  1. Why does ERC-7201 hash twice when ERC-1967 hashes once?
  2. Why must a namespaced contract declare no ordinary state variables?
  3. Does a struct field's mapping still use the module 01 formula?
Show answers

1) An ERC-1967 slot holds one value; an ERC-7201 root is the base for a whole struct including dynamic types, whose own hashing could otherwise land on ordinary slots. The extra hash plus the byte alignment keeps the whole region clear. 2) Any state variable anchors the contract at slot 0 and reintroduces the sequential layout you are trying to escape. 3) Yes — keccak256(abi.encode(key, fieldSlot)), where fieldSlot = root + fieldIndex.


MODULE 12 The initializable pattern

Constructors run against the wrong storage, so upgradeable contracts initialise by function call — with three modifiers and one constructor line that stops a real attack.

A constructor runs once, at deployment, against the deploying contract's own storage. Under a proxy that is exactly the wrong contract: the implementation's constructor writes the implementation's storage, which no user ever reads. The proxy's storage stays empty.

Initialise by function instead

solidity
contract MyToken is Initializable, OwnableUpgradeable {
    uint256 public rate;

    constructor() { _disableInitializers(); }        // see below

    function initialize(address owner_, uint256 rate_) external initializer {
        __Ownable_init(owner_);                      // parent: onlyInitializing
        rate = rate_;                                // writes the PROXY's storage
    }
}

The three modifiers

Modifier Used on Effect
initializer the childmost contract's initialize runs once; sets _initializing = true for the duration
onlyInitializing every parent's __X_init runs only while _initializing is true
reinitializer(n) upgrade-time setup runs once per version n, and only if n > current

Two flags rather than one is what makes inheritance work. A single boolean would be set by the child's initializer before the parents ran, locking them out. Instead initializer raises _initializing, every onlyInitializing parent runs inside that window, and on exit the flag drops and _initialized is bumped to the version number.

The uninitialised-implementation attack

Real vulnerability

An initialize function is public, and the implementation is a deployed contract that anyone can call directly — not only through the proxy. In OpenZeppelin UUPS v4.1.0–v4.3.1 an attacker could:

  1. Call initialize() on the implementation, becoming its owner. The proxy is unaffected — its own _initialized flag lives in the proxy's storage.
  2. Use that ownership to reach an onlyOwner function that performed a delegatecall.
  3. Delegatecall a contract containing selfdestruct, erasing the implementation's code.

Every proxy pointing at that implementation is now frozen: their delegatecalls hit an empty address and silently do nothing, and UUPS's upgrade function died with the code that held it.

_disableInitializers

solidity
constructor() {
    _disableInitializers();
}

This sets _initialized to type(uint64).max in the implementation's storage, so no initializer can ever run there again. The proxy is untouched — its _initialized is its own — so proxies still initialise normally. Put this constructor in every implementation contract you write; there is no case where you want it absent.

Where the flags live

Modern OpenZeppelin keeps _initialized and _initializing in an ERC-7201 namespaced struct (module 11), so Initializable itself cannot collide with your variables.

Summary
  • Constructors write the implementation's storage, so proxies initialise through a function.
  • initializer / onlyInitializing / reinitializer(n) handle first deployment, parent chains and upgrades respectively.
  • _disableInitializers() in the constructor is mandatory — it closes the implementation-takeover path that led to selfdestruct bricking.
Self-check
  1. Why does initialising the implementation not affect the proxy?
  2. Why can a parent not use initializer too?
  3. When would you use reinitializer(2)?
Show answers

1) The flag and every other variable live in whichever contract's storage is being written — the proxy's when called through the proxy, the implementation's when called directly. 2) The child's initializer would already have consumed the one-shot, so the parent's would revert; onlyInitializing is the version that cooperates. 3) In an upgrade, to populate variables the new version added — invoked atomically by upgradeToAndCall.


MODULE 13 The beacon proxy pattern

One upgrade transaction, thousands of proxies. Move the implementation pointer out of the proxies and into a shared beacon.

If you run a factory that deploys one proxy per user, upgrading means one transaction per proxy. With ten thousand vaults that is not an upgrade, it is a migration. The beacon pattern moves the implementation address into a single shared contract that every proxy queries.

the indirection

Standard proxy: proxy → (own ERC-1967 slot) → implementation
Beacon proxy: proxy → beacon → implementation

The two contracts

solidity
contract UpgradeableBeacon is IBeacon, Ownable {
    address private _implementation;

    function implementation() public view returns (address) { return _implementation; }

    function upgradeTo(address newImplementation) public onlyOwner {
        require(newImplementation.code.length > 0, "not a contract");
        _implementation = newImplementation;
        emit Upgraded(newImplementation);          // one tx upgrades every proxy
    }
}

contract BeaconProxy is Proxy {
    // immutable: read from bytecode, not storage, on every call
    address private immutable _beacon;

    constructor(address beacon, bytes memory data) payable {
        _beacon = beacon;
        ERC1967Utils.upgradeBeaconToAndCall(beacon, data);   // writes the ERC-1967 beacon slot
    }

    function _implementation() internal view override returns (address) {
        return IBeacon(_beacon).implementation();
    }
}

The proxy's _beacon is immutable, so an individual proxy can never be pointed somewhere else — the beacon owner controls all of them or none. The beacon address is additionally written to the ERC-1967 beacon slot so explorers can follow the chain.

The cost

Trade-off

Every single call now makes an extra external call to the beacon before it can delegatecall. That is a cold CALL (~2,600 gas) plus the beacon's own SLOAD on top of the delegatecall you were already paying for. You are buying one-transaction fleet upgrades with a permanent per-call tax.

When to reach for it

Use a beacon when… Use a normal proxy when…
many instances share identical logic but hold separate state you have one instance, or a handful
they must all upgrade together, atomically instances can be upgraded independently or staggered
the per-call overhead is acceptable calls are hot and gas-sensitive

Per-user vaults, vesting schedules and account contracts are the classic fit: dozens or thousands of proxies, one shared logic, and a security fix that must land everywhere at once.

Compare with clones

EIP-1167 clones (module 14) also give you cheap mass deployment, but they bake the implementation into bytecode and can never be upgraded. Beacon proxies cost more per call and per deployment, and buy upgradeability with it.

Summary
  • The implementation address lives in a shared beacon; proxies ask it on every call.
  • One upgradeTo on the beacon redirects every proxy at once.
  • The price is an extra external call per invocation and a more complex deployment.
Self-check
  1. Why is _beacon immutable rather than a storage variable?
  2. Can one beacon proxy be upgraded independently of its siblings?
  3. Where is the beacon address recorded for explorers?
Show answers

1) Gas — reading it from bytecode avoids an SLOAD on every call — and safety, since a storage slot could be collided into by the implementation. 2) No. That is the point: the beacon is the only pointer, so the fleet moves together. 3) In the ERC-1967 beacon slot keccak256("eip1967.proxy.beacon") - 1, written at construction.


MODULE 14 EIP-1167: the minimal proxy (clone)

45 bytes of runtime that do one thing: delegatecall a hard-coded address. The cheapest way to deploy the same contract ten thousand times.

A minimal proxy is not upgradeable and has no admin. It is a fixed forwarder: every call is delegatecalled to one implementation whose address is compiled into the bytecode. Because deployment cost is dominated by bytecode size, cloning a 20 KB contract costs roughly what deploying 45 bytes costs.

The runtime bytecode

bytecode
3d602d80600a3d3981f3  363d3d373d3d3d363d73 <20-byte impl> 5af43d82803e903d91602b57fd5bf3
└──── creation (10) ──┘└──────────────── runtime (45) ─────────────────────────────────┘
Opcodes What they do
36 3d 3d 37 CALLDATASIZE, then RETURNDATASIZE twice as a cheap zero, then CALLDATACOPY — copy all calldata to memory 0
3d 3d 3d 36 3d push the delegatecall arguments: retSize, retOffset, argSize, argOffset
73 <addr> PUSH20 — the implementation address, embedded in code, not storage
5a f4 GAS, DELEGATECALL
3d 82 80 3e RETURNDATASIZE + RETURNDATACOPY — copy the result out
90 3d 91 602b 57 fd 5b f3 branch on success: REVERT or RETURN the return data
The RETURNDATASIZE trick

RETURNDATASIZE (3d) is 2 gas and one byte; PUSH1 0x00 (6000) is 3 gas and two bytes. Before the first call in a frame, return-data size is zero — so the clone uses it as a one-byte way to push zero. That single substitution is most of why the runtime is 45 bytes and not 55.

Deploying with Clones.sol

solidity
using Clones for address;

// non-deterministic
address instance = implementation.clone();

// CREATE2 — address is knowable before deployment
address instance = implementation.cloneDeterministic(salt);
address predicted = implementation.predictDeterministicAddress(salt, address(this));

// clones have no constructor: initialise immediately, in the same transaction
IVault(instance).initialize(msg.sender, params);
Front-running

Deploying and initialising must happen in one transaction. A clone deployed but not yet initialised can be initialised by anyone — the same class of bug as the uninitialised implementation in module 12, with a much shorter window and much worse odds if you leave it to a second transaction.

Limitations

  • Not upgradeable. The address is in the bytecode; changing it means deploying a different clone. Use a beacon (module 13) if the fleet must be upgradeable.
  • No constructor. Initialise by function, exactly as in module 12.
  • Delegatecall overhead on every call — roughly 2,600 gas versus calling the implementation directly.
  • immutable variables come from the implementation and are therefore identical across all clones — module 15 exists to solve that.
Summary
  • 45 runtime bytes: copy calldata, delegatecall a hard-coded address, forward the result.
  • The implementation address lives in code rather than storage, so there is nothing to upgrade and nothing to collide with.
  • Clone and initialise in one transaction; use cloneDeterministic when the address must be predictable.
Self-check
  1. Why does a clone use RETURNDATASIZE where you would expect a zero?
  2. Why can two clones of the same implementation not have different immutable values?
  3. What happens if you clone and initialise in separate transactions?
Show answers

1) It is one byte and 2 gas instead of two bytes and 3 gas, and it is guaranteed zero before any call has been made in the frame. 2) Immutables are baked into the implementation's bytecode, and all clones share that one implementation. EIP-3448 (module 15) works around it by appending per-clone metadata. 3) Anyone can front-run your initialisation and take ownership of the clone.


MODULE 15 EIP-3448: the MetaProxy standard

A clone with per-instance immutable parameters — appended to the bytecode and delivered to the implementation as extra calldata.

Module 14 ended on a limitation: every EIP-1167 clone shares one implementation, so they share its immutables. If you are cloning an ERC-20, all your tokens have the same name. The usual fix is to store name and symbol per clone — but that means an SLOAD forever, plus an initialisation transaction.

EIP-3448 instead appends the parameters to the clone's own bytecode and hands them to the implementation as calldata on every call.

Bytecode layout

layout
┌────────────────────┬──────────────────────────┬──────────────────────┐
│  runtime (54 B)    │  ABI-encoded metadata    │  metadata length     │
│  proxy logic       │  e.g. (name, symbol, ..) │  (32 B, last word)   │
└────────────────────┴──────────────────────────┴──────────────────────┘

The runtime is 54 bytes rather than 1167's 45 because it must also copy the metadata. Total deployed size is 65 bytes of known, verifiable prefix plus however much metadata you appended — and because the prefix is standard, Etherscan can recognise a MetaProxy and resolve its implementation exactly as it does for a 1167 clone.

How the implementation reads it

The proxy appends the metadata to the incoming calldata before delegatecalling. So from the implementation's point of view, every call arrives with extra bytes on the end, and the last word says how many:

solidity
function getMetadata() private pure returns (bytes memory data) {
    assembly {
        let posOfMetadataSize := sub(calldatasize(), 32)
        let size := calldataload(posOfMetadataSize)
        let dataPtr := sub(posOfMetadataSize, size)
        data := mload(0x40)
        mstore(0x40, add(data, add(size, 0x20)))   // bump the free memory pointer
        mstore(data, size)                          // write the length
        calldatacopy(add(data, 0x20), dataPtr, size)
    }
}

function name() external pure returns (string memory n) {
    (n, , ) = abi.decode(getMetadata(), (string, string, uint256));
}

Against EIP-1167

EIP-1167 clone EIP-3448 MetaProxy
Runtime size 45 bytes 54 bytes + metadata
Per-instance parameters storage + an init transaction in bytecode, no init needed
Reading a parameter SLOAD (~2,100 cold) calldata copy — far cheaper
Front-running risk yes, if init is a separate tx none — nothing to initialise
Parameters mutable yes no — they are bytecode
Use cases

Parameterised token clones (name, symbol, decimals, supply cap), per-market lending pools (collateral asset, oracle, LTV), and vault factories where each vault's underlying asset is fixed at creation. Anywhere the per-instance configuration is genuinely constant.

Summary
  • MetaProxy appends ABI-encoded metadata after the runtime, with the length in the final 32 bytes.
  • The metadata is forwarded as trailing calldata and read back with a short assembly helper.
  • It removes both the storage reads and the initialisation transaction that a 1167 clone needs — at the cost of parameters being permanently immutable.
Self-check
  1. Why is the length stored at the end rather than the beginning?
  2. Why does MetaProxy have no front-running window?
  3. When is a 1167 clone still the better choice?
Show answers

1) The implementation locates it relative to calldatasize(), which it always knows; a prefix would require knowing where the real calldata ended. 2) There is no initializer — the parameters exist from the moment the contract is deployed. 3) When the per-instance configuration must be changeable later, or when there is no per-instance configuration at all.


MODULE 16 The fallback extension pattern

Not every delegatecall is a proxy. When a contract outgrows 24 KB, forward only the selectors it does not implement to an extension.

EIP-170 caps deployed bytecode at 24,576 bytes. A contract that has outgrown it does not necessarily want upgradeability, tooling, admin roles or any of the machinery in the preceding modules — it just needs more room. The fallback extension pattern gives it exactly that.

how it differs from a proxy

Proxy: has no logic; delegatecalls every call
Fallback extension: has its own logic; delegatecalls only the selectors it does not implement

solidity
// Both contracts inherit the SAME storage contract — this is the whole trick
contract Storage {
    uint256 internal count;
    mapping(address => uint256) internal balances;
}

contract Main is Storage {
    address private immutable extension;

    constructor(address ext) { extension = ext; }

    // hot path stays in this contract
    function increment() external { count++; }

    // everything else falls through
    fallback() external payable {
        address ext = extension;
        assembly {
            calldatacopy(0, 0, calldatasize())
            let ok := delegatecall(gas(), ext, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch ok
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }
}

contract Extension is Storage {
    // cold, rarely-called, or view-only functions live here
    function auditReport() external view returns (uint256, uint256) { ... }
}

Storage layout is the requirement

Because the extension runs by delegatecall, it writes Main's storage. The two contracts must agree slot for slot. Duplicating the declarations by hand is how this pattern goes wrong; inheriting a single shared Storage contract — or using ERC-7201 namespaces from module 11 — makes the agreement structural rather than a convention.

Costs and hazards

Gas

Each delegatecall hop costs roughly 2,600 gas. Put the frequently-called functions in the main contract and push cold or off-chain-read functions into the extension. An EIP-2930 access list transaction recovers about 100 gas per cross-contract call by pre-warming the address.

Selector collisions

If a selector exists in both contracts, the main contract wins and the extension's version is unreachable — silently. Any single random pair collides with probability about 1 in 4.3 billion, but the birthday bound over a large ABI makes it a real risk, and no tooling checks this for you. Enumerate and diff your selectors as part of the build.

Not an upgrade mechanism

The extension address here is immutable. Making it mutable turns this into a half-built proxy without any of the storage-safety tooling from module 17. If you want upgradeability, use a real proxy; if you want more than one extension with routing, you want a diamond (module 19).

Summary
  • The main contract keeps its own logic and forwards only unmatched selectors to an extension.
  • Both must share one storage layout — inherit it, do not copy it.
  • Costs ~2,600 gas per hop and carries an unchecked selector-collision risk; it buys bytecode space, not upgradeability.
Self-check
  1. Why is this not simply a proxy?
  2. Which functions belong in the extension?
  3. What happens if both contracts define foo()?
Show answers

1) A proxy holds no logic and delegates everything; here the main contract executes most calls itself and delegates only the leftovers. 2) Cold ones — admin functions, views read off-chain, rarely-exercised branches — so the common path never pays the hop. 3) The main contract's version is matched first and the extension's is dead code, with no warning from the compiler.


MODULE 17 Foundry upgrades with the OpenZeppelin plugin

The tooling that reads your storage layout and refuses the upgrade when it would corrupt state. The part of this book you should never skip.

Everything so far has been a rule you have to remember: do not reorder variables, do not change types, append only, never leave _authorizeUpgrade open. The OpenZeppelin Foundry Upgrades plugin turns those rules into a compile-time check.

Setup

bash
forge install OpenZeppelin/openzeppelin-contracts-upgradeable
forge install OpenZeppelin/openzeppelin-foundry-upgrades
toml
# foundry.toml
[profile.default]
build_info    = true
extra_output  = ["storageLayout"]
ffi           = true
ast           = true
ffi = true

The plugin shells out to the OpenZeppelin upgrades CLI to compare build artefacts, so it needs the foreign function interface enabled. ffi lets test code run arbitrary commands on your machine — enable it in a repository whose dependencies you trust, and know that is what you are turning on.

Deploying and upgrading

solidity
// UUPS
address proxy = Upgrades.deployUUPSProxy(
    "MyTokenV1.sol",
    abi.encodeCall(MyTokenV1.initialize, (owner, 100))
);

// Transparent — the second argument becomes the ProxyAdmin owner
address proxy = Upgrades.deployTransparentProxy(
    "MyTokenV1.sol",
    msg.sender,
    abi.encodeCall(MyTokenV1.initialize, (owner, 100))
);

// Beacon
address beacon = Upgrades.deployBeacon("MyTokenV1.sol", msg.sender);
address proxy  = Upgrades.deployBeaconProxy(beacon, abi.encodeCall(...));

// Upgrade — validated before anything is written on chain
Upgrades.upgradeProxy(proxy, "MyTokenV2.sol", "");

Declaring the predecessor

Validation needs to know what the new version is replacing. That is the @custom:oz-upgrades-from annotation — without it, validation fails rather than silently passing:

solidity
/// @custom:oz-upgrades-from MyTokenV1
contract MyTokenV2 is MyTokenV1 {
    uint256 public newField;              // appended — safe

    function initializeV2() external reinitializer(2) { newField = 1; }
}

What it actually checks

Rule Why
Existing variables may not be reordered or retyped slots are positional — module 00
New variables must be appended at the end inserting shifts everything below it
__gap arrays must shrink by exactly what you added keeps the total offset of following contracts constant
ERC-7201 namespace ids and struct layouts must be unchanged the root is derived from the namespace string — module 11
No constructor, selfdestruct or delegatecall in the implementation modules 06 and 12 — the selfdestruct-bricking class of bug
Implementation must be UUPS-compatible when upgrading a UUPS proxy the bricking risk in module 10
solidity
function testUpgradePreservesState() public {
    address proxy = Upgrades.deployTransparentProxy(
        "ContractA.sol", msg.sender, abi.encodeCall(ContractA.initialize, (10))
    );
    assertEq(ContractA(proxy).value(), 10);

    Upgrades.upgradeProxy(proxy, "ContractB.sol", "", msg.sender);

    assertEq(ContractB(proxy).value(), 10);   // state survived the upgrade
}
Escape hatches

Options.unsafeAllow and the @custom:oz-upgrades-unsafe-allow annotation exist for the cases where you genuinely know better — an implementation that really does need a constructor, for instance. Treat every use as something to justify in a code review, not as a way to make a failing check go away.

Summary
  • The plugin deploys and upgrades all three proxy flavours and validates storage layout first.
  • @custom:oz-upgrades-from names the predecessor; without it validation fails.
  • It mechanically enforces the append-only rule, gap arithmetic, namespace stability and the UUPS compatibility check.
Self-check
  1. Why does the plugin need build_info and storageLayout?
  2. Why must a __gap shrink when you add a variable?
  3. Why does it reject a constructor in an implementation?
Show answers

1) It diffs the compiler's own layout output between the two versions — without those artefacts there is nothing to compare. 2) The gap reserves a fixed number of slots; if you add two variables and do not shrink the gap by two, every contract after it shifts down two slots. 3) Constructor logic runs against the implementation's storage and never executes for the proxy, so it is silently ineffective — and in the worst case leaves the implementation initialisable (module 12).


MODULE 18 noDelegateCall explained

Uniswap V3's one-line defence against being cloned: compare address(this) to an immutable recorded at deployment.

Everything in this book so far assumed you want your code delegatecalled. Sometimes you do not. Uniswap V3's source was released under a business licence, and a fork could have sidestepped it by deploying minimal proxies that delegatecall the deployed V3 pool code — inheriting the logic without copying the bytecode. noDelegateCall closes that door.

The mechanism

Module 06 established that under delegatecall address(this) is the caller's address, while immutable values are read from the executing contract's bytecode and therefore stay constant. Put those together and you get a check that can tell the two contexts apart:

solidity
abstract contract NoDelegateCall {
    /// @dev The original address of this contract, fixed at deployment.
    address private immutable original;

    constructor() {
        // address(this) here is the real deployed address; it is immutable
        // so it lives in the bytecode and is inlined at every use site.
        original = address(this);
    }

    function checkNotDelegateCall() private view {
        require(address(this) == original);
    }

    modifier noDelegateCall() {
        checkNotDelegateCall();
        _;
    }
}

Called normally, address(this) is the pool and matches original. Called through a proxy, address(this) is the proxy while original is still the pool's own address — the comparison fails and the call reverts.

Why the check is in a private function

A modifier's body is inlined at every use site. Wrapping the require in a private function means only a JUMP is duplicated rather than the whole check, which keeps the contract under the size limit when the modifier is used many times.

Why immutable is load-bearing

If it were a storage variable

A storage original would be read from the proxy's storage during a delegatecall — and the attacker controls the proxy's storage. They would simply write the pool's address into the matching slot and the check would pass. Immutability is what makes the value un-forgeable: it is compiled into the pool's bytecode, so it travels with the code rather than with the storage.

Summary
  • noDelegateCall records address(this) as an immutable at construction and rejects any call where the two differ.
  • It works because delegatecall changes address(this) but not the executing code's immutables.
  • A storage variable would be attacker-controlled during the delegatecall and would defeat the check entirely.
Self-check
  1. Why does the check pass on a normal call and fail on a delegatecall?
  2. Why is immutable not merely a gas optimisation here?
  3. Would this stop someone copying the source and redeploying it?
Show answers

1) Normally address(this) is the contract itself; under delegatecall it is the caller, while the immutable still holds the original deployment address. 2) A storage slot would be read from the attacker's proxy, and they can set it to whatever passes. 3) No — it only blocks reusing the deployed bytecode via delegatecall. Copying the source is a licensing matter, not a technical one.


MODULE 19 The diamond proxy (EIP-2535)

One proxy, many implementations. A selector-to-facet mapping, an upgrade primitive with three actions, and the introspection functions that keep it auditable.

Every proxy so far had exactly one implementation, and therefore inherited its 24 KB limit. A diamond maps each function selector to its own implementation contract — a facet — so the system as a whole has no practical size ceiling.

Routing

solidity
fallback() external payable {
    DiamondStorage storage ds;
    bytes32 position = DIAMOND_STORAGE_POSITION;
    assembly { ds.slot := position }

    address facet = ds.selectorToFacet[msg.sig].facetAddress;
    require(facet != address(0), "Diamond: function does not exist");

    assembly {
        calldatacopy(0, 0, calldatasize())
        let ok := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
        returndatacopy(0, 0, returndatasize())
        switch ok
        case 0 { revert(0, returndatasize()) }
        default { return(0, returndatasize()) }
    }
}

Same fallback as module 07, with one extra step: a mapping lookup on msg.sig instead of a single stored address. Routing is O(1) — there is no chain of conditionals.

diamondCut

solidity
enum FacetCutAction { Add, Replace, Remove }

struct FacetCut {
    address facetAddress;
    FacetCutAction action;
    bytes4[] functionSelectors;
}

/// @param _init      contract to delegatecall for initialisation (address(0) to skip)
/// @param _calldata  the initialisation call
function diamondCut(FacetCut[] calldata _cut, address _init, bytes calldata _calldata) external;

event DiamondCut(FacetCut[] _cut, address _init, bytes _calldata);
Action Effect
Add register selectors that do not yet exist
Replace repoint existing selectors at a different facet — this is the upgrade
Remove delete selectors; a facet disappears when its last selector goes

The _init / _calldata pair is delegatecalled in the same transaction, giving the atomic upgrade-and-initialise property that upgradeToAndCall gives a single-implementation proxy.

The loupe

Because there is no single implementation to point tooling at, EIP-2535 mandates four view functions so explorers and auditors can reconstruct the diamond without replaying every DiamondCut log:

solidity
function facets() external view returns (Facet[] memory);
function facetFunctionSelectors(address _facet) external view returns (bytes4[] memory);
function facetAddresses() external view returns (address[] memory);
function facetAddress(bytes4 _selector) external view returns (address);

Storage: DiamondStorage and AppStorage

Multiple facets sharing one storage multiplies the collision problem from module 06. The two conventional answers both amount to namespacing, and both are ERC-7201 in spirit:

  • DiamondStorage — each facet defines its own struct at its own hashed position, exactly as in module 11. Facets cannot corrupt each other because their roots are unrelated.
  • AppStorage — one shared application struct that every facet declares as its first state variable, so it lands at slot 0 for all of them. Simpler to write and read; it forces every facet to agree on one layout, so appending fields is the only safe change.

Facets cannot call each other directly

A facet is not deployed with the diamond's storage, so calling another facet's function as an internal function would run against the wrong context. The three options:

  • Self-callIOther(address(this)).f(), which re-enters the diamond's fallback and routes correctly. Costs a full external call.
  • Direct delegatecall — look the facet up and delegatecall it manually. Cheaper, more code.
  • Shared internal library — duplicate the logic into both facets at compile time. No runtime cost, more bytecode.
Criticisms
  • Storage discipline across many facets is the hardest part, and getting it wrong is catastrophic in the module 06 sense.
  • The loupe duplicates information already in the DiamondCut logs, so upgrades pay to store data only off-chain consumers read. Minimal-storage reference implementations make cuts cheap and loupe calls expensive; that is usually the right trade.
  • The standard specifies no permission model for diamondCut — you design it, and you own the consequences.
  • Deployment and upgrade orchestration is genuinely complex, and the plugin support that exists for transparent and UUPS proxies (module 17) does not cover diamonds.

The honest summary: reach for a diamond when your logic genuinely exceeds what one implementation can hold. Below that threshold, a UUPS or transparent proxy is less machinery and less risk.

Summary
  • A diamond routes each selector to its own facet through a mapping, escaping the 24 KB limit.
  • diamondCut adds, replaces and removes selectors, with an optional atomic initialisation delegatecall.
  • The loupe makes the layout introspectable; namespaced storage is mandatory in practice, and inter-facet calls need a deliberate strategy.
Self-check
  1. How does a diamond "upgrade" a single function?
  2. Why is namespaced storage more urgent here than in a normal proxy?
  3. Why can a facet not just call another facet's internal function?
Show answers

1) diamondCut with Replace, repointing that one selector at a new facet — the rest of the system is untouched. 2) A normal proxy has one implementation whose layout you control end to end; a diamond has many, written and upgraded at different times, and any two of them sharing sequential slots will collide. 3) The facet is a separate deployed contract; its internal functions would execute in whatever context called them, and a facet is never called directly with the diamond's storage — so it must go back through the diamond via self-call or delegatecall.


References

Every module above follows a chapter of the RareSkills Book of Proxy Patterns and Delegatecall, in its original order.

Code excerpts are abbreviated for teaching — cross-check against the canonical OpenZeppelin source before relying on exact lines in your own implementation.