Storage Layout and Collisions

When a proxy delegatecalls into an implementation, the implementation's reads and writes land in the proxy's storage. The implementation's source code declares variables in a certain order; the EVM assigns each one a storage slot based on that order; and the proxy faithfully stores whatever the implementation writes to whatever slot it computed. Nothing in the EVM verifies that the variables and slots agree across upgrades — that contract is enforced entirely by convention and tooling.

Get it right and upgrades work invisibly. Get it wrong and the post-upgrade contract reads totalSupply from the slot that used to hold owner, or writes a new paused flag on top of the old feeRecipient. The damage is silent, immediate, and often unrecoverable.

How Solidity Lays Out Storage

A quick refresher on the rules every auditor should have memorized:

  • State variables are assigned to storage slots in declaration order, starting at slot 0.
  • Each variable occupies enough consecutive slots to hold it (most basic types: one slot; structs and fixed arrays: as many as needed; dynamic arrays and mappings: one slot for metadata, with elements stored at keccak256-derived locations).
  • Multiple variables can be packed into a single slot if their sizes add up to ≤ 32 bytes and they are declared adjacently.
  • Inherited contracts' variables come first, in linearization order (C3 linearization).

This means the layout of an implementation depends on:

  1. The declaration order in the implementation contract.
  2. The declaration order in every base contract it inherits from.
  3. The full inheritance graph and its linearization.

Any change to any of these — adding a base contract, reordering inheritance, adding a variable above an existing one, changing a uint128 to a uint256 — shifts subsequent slots and corrupts state.

The Canonical Failure Modes

1. Inserting a Variable Above Existing Ones

The textbook mistake. V1 declares:

contract V1 {
    address public owner;     // slot 0
    uint256 public totalSupply; // slot 1
}

V2 adds a new variable at the top:

contract V2 {
    uint256 public newField;    // slot 0  <-- was owner
    address public owner;       // slot 1  <-- was totalSupply
    uint256 public totalSupply; // slot 2  <-- previously unused
}

After upgrade, newField reads what used to be owner. owner reads what used to be totalSupply. The contract is permanently corrupted from the user's perspective even though no transaction modified the underlying storage. Append-only addition (the new variable goes at the bottom) is the universal rule.

2. Removing or Changing Type of an Existing Variable

Removing slot 0 shifts every subsequent slot up by one. Replacing a uint256 with two uint128s changes packing assumptions for everything that follows. Either is equivalent in damage to inserting a variable.

The rules for a safe upgrade:

  • Never reorder existing state variables.
  • Never remove a state variable; mark it unused if necessary.
  • Never change the type of a state variable in a way that changes its size.
  • Always append new variables at the end.

@openzeppelin/upgrades-core (used by both Hardhat and Foundry plugins) validates these constraints; runs as a CI step should be a checklist item.

3. Inheritance Reordering

Even when the implementation contract itself looks unchanged, modifying its inheritance list can break it. The two equivalent-looking declarations below produce different storage layouts:

contract A { uint256 a; }
contract B { uint256 b; }

contract V1 is A, B { uint256 v; }   // layout: a, b, v
contract V2 is B, A { uint256 v; }   // layout: b, a, v  -- corruption

The fix: never change the order or set of base contracts in an upgrade. If a new base must be added, it goes at the end, and any state variables in it must be considered as appended.

4. Storage Collisions Between Proxy and Implementation

Older proxy designs stored the implementation address and admin address at fixed low slots (e.g. slot 0). Any implementation that also declared a state variable at slot 0 would clobber the implementation address on its first write — making subsequent calls to the proxy delegatecall to address(0) and revert.

EIP-1967 solved this by pinning proxy-internal slots to pseudo-random, high-entropy locations:

  • Implementation: keccak256("eip1967.proxy.implementation") - 1
  • Admin: keccak256("eip1967.proxy.admin") - 1
  • Beacon: keccak256("eip1967.proxy.beacon") - 1

Auditors should verify that every upgradeable proxy in scope uses EIP-1967 slots (or a documented equivalent), and that the implementation does not also write to those slots via unconstrained sstore.

Storage Gaps

The classical mitigation for inheritance-based layout fragility is the storage gap: each base contract leaves a known-size empty array at the end of its layout, so that descendants can append variables without shifting later slots when the base is upgraded.

contract BaseV1 {
    address public owner;
    // 49 reserved slots; bring total to 50
    uint256[49] private __gap;
}

// Later, BaseV2 wants to add a new variable:
contract BaseV2 {
    address public owner;
    uint256 public newField;      // consumes one slot
    uint256[48] private __gap;    // gap shrinks by one
}

Audit notes:

  • Every upgradeable base contract should have a __gap (or equivalent named) array.
  • The size of the gap should be documented and checked by tooling.
  • When a base is upgraded with new variables, the gap must shrink by the same number of slots; not shrinking it shifts everything that follows.

This pattern works but is brittle: every contributor must understand the discipline, and tooling must enforce it. The increasingly-recommended alternative is namespaced storage.

Namespaced Storage (ERC-7201)

ERC-7201 standardizes the long-standing "diamond storage" pattern for use across all proxy designs. Each module stores its state in a struct kept at a deterministic, namespaced slot:

/// @custom:storage-location erc7201:my.protocol.Vault
struct VaultStorage {
    uint256 totalAssets;
    mapping(address => uint256) balances;
    // freely extensible: structs can grow at the end
}

bytes32 constant VAULT_STORAGE_SLOT =
    keccak256(abi.encode(uint256(keccak256("my.protocol.Vault")) - 1)) & ~bytes32(uint256(0xff));

function _vault() internal pure returns (VaultStorage storage $) {
    bytes32 slot = VAULT_STORAGE_SLOT;
    assembly { $.slot := slot }
}

Benefits for upgrade safety:

  • Modules are independent. Adding state to one namespaced struct never affects another.
  • Inheritance order does not affect storage. Each module's state is identified by its namespace hash, not its position.
  • New fields can always be appended to a namespaced struct safely (with the same "never reorder, never remove, never resize" rules within the struct).

Audit notes:

  • Each namespaced struct should have a unique @custom:storage-location erc7201:... tag and a derived constant slot that matches the spec.
  • The struct itself follows the same append-only rules as a contract layout.
  • Mixing ERC-7201 namespaced storage with classical slot-0-onwards storage in the same proxy is dangerous and rarely justified.
  • Tooling (@openzeppelin/upgrades-core ≥ 1.32) increasingly validates ERC-7201 layouts.

OpenZeppelin's v5 upgradeable contracts have adopted ERC-7201 throughout. For new upgradeable code, this is the recommended pattern; for legacy code, storage gaps remain in widespread use.

What an Auditor Should Verify

For every upgradeable contract in scope, confirm:

  1. The storage layout of the current implementation has been validated against the prior implementation with @openzeppelin/upgrades-core (or equivalent).
  2. Either every base contract has a documented __gap, or the protocol uses ERC-7201 namespaced storage consistently.
  3. The proxy uses EIP-1967 slots; no implementation variable can land on those slots.
  4. CI enforces layout compatibility on every PR that modifies an upgradeable contract.
  5. There is a documented, tested upgrade procedure that runs the validation as a hard gate before any on-chain upgradeTo call.

A protocol that cannot answer these questions confidently has an upgrade pathway that is, in practice, untested.

Detection Tooling

  • @openzeppelin/upgrades-core — programmatic layout validation; the engine behind the Hardhat and Foundry upgrades plugins.
  • slither-check-upgradeability — Slither subcommand that reports layout differences and proxy-pattern anti-patterns.
  • forge inspect <Contract> storage-layout — emits the current layout as JSON; pairs with a stored "golden" layout file in version control and a CI diff check.
  • hardhat-storage-layout plugin — equivalent for Hardhat projects.

Running these as part of every commit is the cheapest, highest-yield upgrade-safety investment a team can make. An audit finding of "no storage-layout CI check" is appropriate for any upgradeable system without one.