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.
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 |
These two contracts hold identical data and cost different amounts to deploy and to use:
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.
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
}
}
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.
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.
- 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/sstoreread and write a whole slot with no type checking — the fastest way to corrupt packed state.
- Why does inserting a variable at the top of a contract break a live proxy?
address owner; bool paused; uint32 t;— how many slots, and in what order within them?- 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 = keccak256(abi.encode(key, baseSlot))
keyandbaseSlotare each padded to 32 bytes, concatenated, then hashed- the base slot itself stores nothing — a mapping has no length
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:
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:
length lives at baseSlot
slot = keccak256(abi.encode(baseSlot)) + i
// 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:
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 |
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 bytes1…bytes32
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.
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.
- 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; otherwiselen × 2 + 1at the base and data at the hash — the low bit is the tag.
- Two contracts declare a mapping at slot 0 and a
uint256at slot 0. Do they collide? - A string's base slot reads
0x2A(42). How long is the string and where is it? - 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.
bytes4(keccak256("transfer(address,uint256)")) == 0xa9059cbb
Canonicalisation rules when building the signature string:
structbecomes a tuple —(uint256,address)address payablebecomesaddressenumbecomesuint8- user-defined value types resolve to their underlying type
memory/calldata/storageare 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.
foo(uint256,string) with (5, "hi")
0x a1b2c3d4 selector
0000000000000000000000000000000000000000000000000000000000000005 5
0000000000000000000000000000000000000000000000000000000000000040 offset -> 0x40
0000000000000000000000000000000000000000000000000000000000000002 length 2
6869000000000000000000000000000000000000000000000000000000000000 "hi", right-padded
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 |
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.
- 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.encodeCallis the only helper that type-checks both the function and its arguments.
- What is the signature string for
f(MyEnum e, address payable p)? - Why can
abi.encodePacked(string,string)produce the same bytes for different inputs? - 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.
// 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
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:
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.
- Low-level calls return
(bool, bytes)and never revert on your behalf. - A low-level call to a codeless address returns
true; high-level callsEXTCODESIZE-check first. delegatecallis the only variant that writes the caller's storage — which is the entire basis of proxies.
- A proxy delegatecalls an implementation that has been
selfdestructed. What does the user see? - Why bubble revert data with assembly instead of
revert(string(ret))? - 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 |
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
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
}
- 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
EXTCODESIZEexistence 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.
catch (bytes memory data) {
if (bytes4(data) == IFoo.Unauthorized.selector) {
// handle the specific custom error
}
}
- Four revert shapes: empty,
Error(string),Panic(uint256), and custom-error selectors. assertand the compiler's own checks panic;requireandrevertdo not.try/catchguards only the external call, and custom errors land in the catch-allbytesblock.
- Which catch block receives
revert MyError(42)? - Why is a custom error cheaper than
require(false, "long reason string")? - 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.
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
// 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:
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
}
Scratch space at 0x00–0x3f 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.
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.
revert(offset, size)returns an arbitrary memory window as revert data.mstoreleft-pads to 32 bytes, so selectors must be written high-aligned.returndatacopy+revertre-throws a callee's error untouched — the closing move of every proxy fallback.
- Why does a proxy re-throw raw bytes instead of
require(ok)? - What does
revert(0, returndatasize())return when the callee reverted with no data? - Why is
0x24the size for a custom error with oneuint256?
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 |
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:
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.
- Never delegatecall an address a user controls. The target can write any slot, including your owner and implementation pointers.
- A delegatecall reaching
selfdestructdestroys the calling contract. This is how uninitialised UUPS implementations were bricked in 2021 (module 12). - Delegatecall inside a
payableloop re-reads the samemsg.valueeach iteration — the Parity-style double-spend. - Because the implementation address is read from storage, any bug that writes slot 0 is an instant takeover.
delegatecallruns 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.
- An implementation reads
address(this).balance. Whose balance is it? - Why can a proxy never store its implementation pointer at slot 0 safely?
- Why is
immutableunaffected 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:
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
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.
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.
- 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.
- Why does the fallback need assembly rather than plain Solidity?
- Why check
impl.code.length > 0before storing? - Where does an implementation's
constructorrun 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
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
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).
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.
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.
- Three fixed slots hold the implementation, admin and beacon pointers.
keccak256(label) - 1yields 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.
- Why not just use
keccak256("...")without the- 1? - Could an implementation reach the ERC-1967 slot deliberately?
- 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.
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
ProxyAdmincontract, fixed forever. ProxyAdminis anOwnablewhose owner can change.- "Changing the proxy admin" means transferring
ProxyAdmin's ownership, not touching the proxy at all.
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
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 |
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.
- 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. upgradeToAndCallswaps the implementation and initialises new state in one atomic transaction.
- Why can the admin not simply use the dApp through the proxy?
- Why is the admin immutable if the standard says to store it in a slot?
- 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.
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.
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:
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.
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.
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.
- UUPS puts
upgradeToAndCallin the implementation, so the proxy has no functions to clash with. - You must override
_authorizeUpgradewith 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.
- Why does
upgradeToAndCallin the implementation modify the proxy's slot? - What exactly does
proxiableUUIDprevent? - 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
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.
/// @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.
- Declare no state variables at all.
- Group every variable as a field of one struct per contract.
- Pick a unique namespace string, conventionally
<project>.storage.<Contract>. - Compute the root with the ERC-7201 formula and store it as a
constant. - Add the
@custom:storage-locationNatSpec tag. - Access everything through the accessor:
$.field.
- 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.
- Why does ERC-7201 hash twice when ERC-1967 hashes once?
- Why must a namespaced contract declare no ordinary state variables?
- 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
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
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:
- Call
initialize()on the implementation, becoming its owner. The proxy is unaffected — its own_initializedflag lives in the proxy's storage. - Use that ownership to reach an
onlyOwnerfunction that performed adelegatecall. - 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
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.
Modern OpenZeppelin keeps _initialized and _initializing in an
ERC-7201 namespaced struct (module 11), so Initializable itself cannot collide
with your variables.
- 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.
- Why does initialising the implementation not affect the proxy?
- Why can a parent not use
initializertoo? - 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.
Standard proxy: proxy → (own ERC-1967 slot) → implementation
Beacon proxy: proxy → beacon → implementation
The two contracts
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
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.
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.
- The implementation address lives in a shared beacon; proxies ask it on every call.
- One
upgradeToon the beacon redirects every proxy at once. - The price is an extra external call per invocation and a more complex deployment.
- Why is
_beaconimmutable rather than a storage variable? - Can one beacon proxy be upgraded independently of its siblings?
- 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
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 |
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
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);
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.
immutablevariables come from the implementation and are therefore identical across all clones — module 15 exists to solve that.
- 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
cloneDeterministicwhen the address must be predictable.
- Why does a clone use
RETURNDATASIZEwhere you would expect a zero? - Why can two clones of the same implementation not have different
immutablevalues? - 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
┌────────────────────┬──────────────────────────┬──────────────────────┐
│ 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:
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 |
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.
- 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.
- Why is the length stored at the end rather than the beginning?
- Why does MetaProxy have no front-running window?
- 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.
Proxy: has no logic; delegatecalls every call
Fallback extension: has its own logic; delegatecalls only the selectors it does
not implement
// 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
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.
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.
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).
- 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.
- Why is this not simply a proxy?
- Which functions belong in the extension?
- 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
forge install OpenZeppelin/openzeppelin-contracts-upgradeable
forge install OpenZeppelin/openzeppelin-foundry-upgrades
# foundry.toml
[profile.default]
build_info = true
extra_output = ["storageLayout"]
ffi = true
ast = 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
// 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:
/// @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 |
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
}
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.
- The plugin deploys and upgrades all three proxy flavours and validates storage layout first.
@custom:oz-upgrades-fromnames the predecessor; without it validation fails.- It mechanically enforces the append-only rule, gap arithmetic, namespace stability and the UUPS compatibility check.
- Why does the plugin need
build_infoandstorageLayout? - Why must a
__gapshrink when you add a variable? - Why does it reject a
constructorin 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:
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.
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
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.
noDelegateCallrecordsaddress(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.
- Why does the check pass on a normal call and fail on a delegatecall?
- Why is
immutablenot merely a gas optimisation here? - 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
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
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:
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-call —
IOther(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.
- 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
DiamondCutlogs, 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.
- A diamond routes each selector to its own facet through a mapping, escaping the 24 KB limit.
diamondCutadds, 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.
- How does a diamond "upgrade" a single function?
- Why is namespaced storage more urgent here than in a normal proxy?
- 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.
- Storage slots in Solidity and storage slots of dynamic types — modules 00–01.
- ABI encoding, low level vs high level calls, try/catch and reverts, assembly revert — modules 02–05.
- Delegatecall and introduction to proxies — modules 06–07.
- EIP-1967, transparent upgradeable proxy, UUPS (ERC-1822) — modules 08–10.
- ERC-7201 storage namespaces and the initializable pattern — modules 11–12.
- Beacon proxy, EIP-1167 minimal proxy, EIP-3448 MetaProxy — modules 13–15.
- The fallback extension pattern, Foundry upgrades with the OpenZeppelin plugin, noDelegateCall, the diamond proxy — modules 16–19.
- Primary sources: EIP-1967, EIP-1822, EIP-1167, EIP-3448, ERC-7201, EIP-2535, and the OpenZeppelin upgradeable contracts.
Code excerpts are abbreviated for teaching — cross-check against the canonical OpenZeppelin source before relying on exact lines in your own implementation.