Module 8 · Ethereum, the EVM & on-chain zero-knowledge
Contract storage layout and collisions
A contract storage is a sparse map from 256-bit keys to 256-bit values. Solidity lays simple variables out in declaration order and packs those that fit together, while mappings and dynamic arrays are placed at keccak256-derived slots so they never collide with each other. When a proxy delegatecalls an implementation, both contracts share one storage space, and any disagreement about which variable owns which slot corrupts state — the single most expensive bug class in upgradeable contracts.
Learning objective
See storage the way the EVM does: a 2^256-slot key/value space; how Solidity assigns slots in declaration order and packs small variables; how mapping and dynamic-array slots are derived with keccak256; the SLOAD/SSTORE cost model; and storage collision — the costliest class of proxy bug — building the intuition for why delegatecall-based upgrades are dangerous.
- Track
- Engineering
- Module
- 8 · Ethereum, the EVM & on-chain zero-knowledge
- Prerequisites
- The EVM execution model · Hash functions
Conceptual introduction
Storage is where a contract state lives between transactions, and it is where the most expensive bugs in the ecosystem have happened. Solidity presents storage as named variables, mappings and arrays; the EVM sees none of that. It sees a flat map from a 256-bit key to a 256-bit value. To audit upgradeable contracts you must learn to see storage as the machine does — as numbered slots — not as the tidy variable names the source shows you.
This lesson builds that view from the ground up: the raw slot model, how Solidity decides which variable goes in which slot, how it hashes keys to place mappings and arrays, what reading and writing cost, and finally how two contracts sharing one storage space through delegatecall can silently destroy each other data. That last point is the bridge to the proxy and diamond patterns covered in the next lesson.
A helpful reframing: Solidity variable names are a compile-time convenience. On-chain there are only slots. Two contracts that agree on names but disagree on slot assignment do not actually agree on anything the EVM can see.
The slot model
A contract storage is a key-value store with 256-bit keys and 256-bit (32-byte) values. There are 2^256 possible slots, and every one of them reads as zero until it is written. Storage persists across transactions and blocks — it is the permanent state of the contract.
- Slot
- One 32-byte cell addressed by a 256-bit key. Slot 0, slot 1, and so on through slot 2^256 - 1.
- Sparse
- Although there are 2^256 slots, you only pay gas and consume state for slots you actually write. You can write slot number 2^200 without paying for any of the lower slots.
- Default zero
- Any slot that has never been written reads as zero. There is no "uninitialized" state distinct from zero.
The sparseness is what makes the keccak256-based placement of mappings and arrays safe: even though a mapping is conceptually scattered all over the 2^256 space, only the handful of keys you actually use ever occupy real slots. The address space is large enough that hashing keys into it effectively never collides by accident.
Slot assignment and variable packing
Solidity assigns storage slots to state variables in the order they are declared, starting from slot 0. Each variable normally begins a new slot — except when consecutive variables are small enough to share one. This sharing is called packing, and it is governed by a simple rule.
- Declaration order
- Variables get slots in the order they appear in the contract. Slot 0 is the first declared state variable, slot 1 the next (unless it packs), and so on.
- Packing
- Consecutive variables whose sizes sum to 32 bytes or less share one slot, aligned to the low-order (right) side. A uint128 followed by another uint128 occupies a single slot; a uint256 always takes a whole slot of its own.
- New-slot types
- Structs and arrays always start a fresh slot. A uint256, being a full word, cannot be packed with anything.
illustrative — declaration order changes how many slots you use (synthetic)// WASTEFUL: 3 slots uint128 a; // slot 0, lower 16 bytes (upper 16 wasted) uint256 b; // slot 1 (a uint256 cannot share slot 0's leftover) uint128 c; // slot 2, lower 16 bytes (upper 16 wasted) // EFFICIENT: 2 slots uint128 a; // slot 0, bytes 0-15 uint128 c; // slot 0, bytes 16-31 <- packed with a uint256 b; // slot 1 // reordering so the two uint128s are adjacent lets them share a slot
Packing is why variable order matters for gas, but it matters far more for safety in upgradeable contracts. Because slot assignment follows declaration order, reordering variables, changing a type, or inserting a variable in the middle shifts every variable after it onto a different slot. In a normal contract that is harmless; in an upgrade that reuses existing storage, it means the new code reads and writes the wrong data.
Mappings and dynamic arrays: keccak256-derived slots
Simple variables sit at fixed, sequential slots. Mappings and dynamic arrays cannot: a mapping can hold arbitrarily many keys, and an array can grow, so neither can be given a fixed range at compile time. Solidity instead reserves one base slot and derives the real location of each entry by hashing. This is where keccak256 becomes part of the storage model itself.
- Mapping
- For a mapping declared at base slot p, the value for key k lives at slot keccak256(k . p), where . is concatenation and both operands are padded to 32 bytes. The base slot p itself never holds data; it is only a seed.
- Nested mapping
- For mapping(K1 => mapping(K2 => V)) at base slot p, the value at [k1][k2] lives at keccak256(k2 . keccak256(k1 . p)) — the inner hash becomes the base for the outer key.
- Dynamic array
- For an array declared at base slot p, slot p stores the length, and the elements start at keccak256(p): element i is at keccak256(p) + i (for 32-byte elements; smaller elements pack several per slot).
illustrative — deriving a mapping slot (synthetic Yul)// mapping(address => uint) balances; declared at base slot 0 // to find the slot for balances[addr]: mstore(0x00, addr) // key, left-padded to 32 bytes mstore(0x20, 0) // base slot p = 0 let slot := keccak256(0x00, 0x40) // slot = keccak256(addr . 0) // sload(slot) now reads balances[addr]
Because element locations come from a hash of the base slot, a dynamic array conceptually spans a vast region starting at keccak256(p). In very old Solidity, underflowing an array length could make the array appear to cover the entire storage space, letting a crafted index write to any slot — including one holding an owner address. Modern compilers bound this, but it shows why the derivation rules are security-relevant, not just implementation detail.
SLOAD and SSTORE costs
Storage is the most expensive region, and its cost follows the cold/warm model introduced for state access. Reading a slot for the first time in a transaction (cold) costs far more than reading it again (warm). Writing is more complex still: the price depends on what is already there and what you are writing.
- SLOAD cold
- First read of a slot in the transaction — roughly 2100 gas (illustrative).
- SLOAD warm
- A subsequent read of the same slot — roughly 100 gas (illustrative).
- SSTORE zero to non-zero
- Creating a value in a previously empty slot is the most expensive write (on the order of 20000+ gas).
- SSTORE non-zero to non-zero
- Overwriting an existing value is cheaper; clearing a slot to zero can earn a bounded gas refund.
illustrative — cache storage reads in locals (synthetic)// WASTEFUL: three SLOADs of the same slot require(balance[msg.sender] >= 100); balance[msg.sender] -= 100; emit Transfer(balance[msg.sender]); // EFFICIENT: one SLOAD, one SSTORE uint bal = balance[msg.sender]; // 1 SLOAD require(bal >= 100); bal -= 100; balance[msg.sender] = bal; // 1 SSTORE emit Transfer(bal);
Refunds for clearing storage are capped (a transaction can only recover a bounded fraction of its own gas), which removed the old "gas token" trick of hoarding and releasing storage to arbitrage gas. As with all gas figures, treat the constants as version-specific and reason about relative cost: reading storage dwarfs reading memory, and the first touch dwarfs later ones.
Storage collisions: the costliest proxy bug
Now the payoff. An upgradeable contract is usually a proxy: a thin contract that holds the state and uses DELEGATECALL to run the logic of a separate implementation contract. Recall from the execution-model lesson that DELEGATECALL runs the callee code in the caller storage. So the proxy and the implementation share one storage space — the proxy slots. They must therefore agree, slot for slot, on what every variable means.
A storage collision happens when the proxy and the implementation assign different variables to the same slot. Because the implementation writes into the proxy slots, writing what the implementation thinks of as variable X overwrites what the proxy thinks of as variable Y. There is no error and no warning; the EVM only sees slot numbers, and both contracts are writing valid slots. The data is simply, silently corrupted.
illustrative — a catastrophic proxy/implementation collision (synthetic)contract Proxy { address implementation; // slot 0 address admin; // slot 1 fallback() external { implementation.delegatecall(msg.data); } } contract Implementation { uint value; // slot 0 <-- collides with 'implementation' address owner; // slot 1 <-- collides with 'admin' function setValue(uint v) external { value = v; } } // proxy.delegatecall -> setValue(12345) writes 12345 into SLOT 0, // which the proxy uses as 'implementation'. The proxy now points at // address(12345) -- a non-contract -- and is permanently bricked.
- 1The proxy receives a call and delegatecalls the implementation, so implementation code runs against proxy storage.
- 2The implementation writes its variable value, which it believes lives in slot 0.
- 3Slot 0 in the proxy is actually the implementation address pointer.
- 4The write overwrites that pointer with the garbage value, so the proxy now delegatecalls into a meaningless address.
- 5Every subsequent call fails: the contract is bricked, and any funds it guarded may be unrecoverable.
The defence is to keep proxy bookkeeping out of the sequential slots the implementation will use. The EIP-1967 standard places the implementation address, admin and beacon at fixed, deliberately obscure slots derived as keccak256 of a namespaced string (minus one), so that ordinary variables starting at slot 0 cannot collide with them. The related storage-gap and unstructured-storage patterns reserve or hash-isolate space for the same reason. These conventions, and the diamond pattern that extends them, are the subject of the next lesson.
Upgrade discipline follows directly from the slot model: never reorder existing variables, never change their types, and only append new variables (consuming reserved gap slots). Each of these rules exists because it would otherwise shift a later variable onto a slot that already holds live data — a self-inflicted storage collision.
Application to security and agents
For an auditor, storage layout is the first thing to reconstruct when reviewing any upgradeable system. Lay the proxy and every implementation side by side as numbered slots and check that they agree; verify that proxy metadata uses EIP-1967 slots; confirm that each new version only appends variables. A single mismatched slot is enough to brick a contract or hand an attacker control, so this is not optional polish — it is the core of the review.
For autonomous agents that interact with or deploy upgradeable contracts, the same discipline applies to the agent reasoning. An agent proposing an upgrade must reason about slots, not names: it has to prove the new layout is a strict append to the old one. An agent that calls into a proxy inherits whatever storage correctness (or corruption) that proxy carries, so verifying the upgrade history becomes part of trusting the contract at all.
Risks and limitations
- ▸Variable names are compile-time only; on-chain there are just numbered slots, and two contracts can agree on names while disagreeing on slots.
- ▸Reordering, retyping, or inserting a state variable shifts every later variable onto a new slot — harmless in a fresh contract, catastrophic in an upgrade over existing storage.
- ▸DELEGATECALL makes a proxy and its implementation share one storage space; any slot disagreement silently corrupts data with no error from the EVM.
- ▸Proxy metadata placed in sequential slots (slot 0, 1, ...) invites collision; EIP-1967 exists precisely to move it to hash-derived slots.
- ▸Storage is the most expensive region, with cold-first-touch and write-transition pricing; refunds for clearing slots are capped and cannot be arbitraged.
- ▸Historic array-length underflow bugs show that the hash-derivation rules are themselves attack surface, not mere implementation trivia.
Exercise
A team ships version 2 of an upgradeable contract. V1 declared, in order: "uint256 totalSupply; mapping(address => uint256) balances;". V2, wanting a pause switch, declares: "bool paused; uint256 totalSupply; mapping(address => uint256) balances;". The proxy keeps its existing storage. Explain precisely what goes wrong at the slot level and how the upgrade should have been written.
Expected direction: Slot assignment follows declaration order, so in V1 totalSupply is slot 0 and the balances mapping base is slot 1. V2 inserts paused at the front, so paused takes slot 0, totalSupply moves to slot 1, and the balances base moves to slot 2. Because the proxy reuses existing storage, slot 0 still physically holds the old totalSupply value, which V2 now interprets as the boolean paused (and the balances mapping, whose entries were derived from base slot 1, are now looked up from base slot 2, so every balance appears to vanish). Nothing reverts; the data is silently misread. The correct approach is to never prepend or insert: append the new variable after all existing ones (bool paused declared last, consuming a reserved storage-gap slot), so every pre-existing variable keeps its original slot. Proxy metadata should additionally live in EIP-1967 hash-derived slots to avoid colliding with any of these.
Check your understanding
- How does Solidity assign storage slots to simple state variables, and when do two share a slot?
- In declaration order starting at slot 0; consecutive variables whose sizes sum to 32 bytes or less are packed into one slot (low-order aligned). A uint256, struct or array always starts a fresh slot.
- Where is the value for key k of a mapping at base slot p stored?
- At slot keccak256(k . p) — the 32-byte-padded key concatenated with the base slot, hashed. The base slot p itself holds no data; it is only a seed.
- Why does a storage collision in a delegatecall proxy corrupt data silently?
- DELEGATECALL runs the implementation code against the proxy storage, so both contracts share one slot space. If they map different variables to the same slot, writing one overwrites the other. The EVM only sees valid slot writes, so there is no error — just corruption.
- What rule does EIP-1967 establish and why?
- It places proxy metadata (implementation, admin, beacon) at fixed slots derived as keccak256 of a namespaced string minus one, so these values cannot collide with ordinary implementation variables that start at slot 0.
References
- [1]Solidity documentation — "Layout of State Variables in Storage"
- [2]EIP-1967 — standard proxy storage slots
- [3]Ethereum Yellow Paper (Wood) — storage and the state trie
- [4]Mastering Ethereum (Antonopoulos & Wood), contract state and storage
- [5]evm.codes — SLOAD/SSTORE semantics and gas
References point to public, well-known technical sources for further study.
Related articles
