Securite·9 min de lecture·Par Solingo

Storage Slot Collisions in Upgradeable Contracts

Upgradeable contracts share storage with proxies. Get the layout wrong and funds are lost.

# Storage Slot Collisions in Upgradeable Contracts

Upgradeable contracts use delegatecall to separate logic and state. But delegatecall does not change where storage is read — it always uses the proxy's storage slots.

Get the layout wrong and you corrupt state. Get it very wrong and you lose funds.

How Proxy Storage Works

When a proxy delegates to an implementation:

contract Proxy {

address public implementation; // slot 0

fallback() external payable {

address impl = implementation;

assembly {

calldatacopy(0, 0, calldatasize())

let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)

returndatacopy(0, 0, returndatasize())

switch result

case 0 { revert(0, returndatasize()) }

default { return(0, returndatasize()) }

}

}

}

contract ImplementationV1 {

address public implementation; // MUST match proxy slot 0

uint256 public balance; // slot 1

}

If the implementation does not reserve slot 0, balance would overwrite implementation and the proxy would break.

Storage Collision Example

Here is a dangerous upgrade:

// V1

contract TokenV1 {

address public owner; // slot 0

uint256 public totalSupply; // slot 1

}

// V2 — WRONG

contract TokenV2 {

uint256 public decimals; // slot 0 — COLLIDES WITH owner

address public owner; // slot 1 — COLLIDES WITH totalSupply

uint256 public totalSupply; // slot 2

}

If you upgrade to V2, decimals overwrites owner. The contract is now controlled by address(0).

EIP-1967 Standard Slots

EIP-1967 defines fixed storage slots for proxy metadata:

// Implementation slot

bytes32 constant IMPLEMENTATION_SLOT =

bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);

// Admin slot

bytes32 constant ADMIN_SLOT =

bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1);

These slots are far from slot 0, so they never collide with normal state variables.

function _setImplementation(address newImplementation) internal {

assembly {

sstore(IMPLEMENTATION_SLOT, newImplementation)

}

}

Unstructured Storage Pattern

OpenZeppelin's proxy uses unstructured storage — state variables are stored at computed slots instead of sequential slots.

contract MyImplementation {

bytes32 constant OWNER_SLOT = keccak256("mycontract.owner");

function owner() public view returns (address) {

bytes32 slot = OWNER_SLOT;

address value;

assembly {

value := sload(slot)

}

return value;

}

function setOwner(address newOwner) internal {

bytes32 slot = OWNER_SLOT;

assembly {

sstore(slot, newOwner)

}

}

}

This guarantees no collision with proxy slots.

Diamond Storage (ERC-7201)

For contracts with multiple facets (like EIP-2535 Diamonds), Diamond Storage isolates each facet's state:

library TokenStorage {

bytes32 constant STORAGE_SLOT = keccak256("diamond.token.storage");

struct Layout {

mapping(address => uint256) balances;

uint256 totalSupply;

}

function layout() internal pure returns (Layout storage l) {

bytes32 slot = STORAGE_SLOT;

assembly {

l.slot := slot

}

}

}

contract TokenFacet {

function balanceOf(address user) external view returns (uint256) {

return TokenStorage.layout().balances[user];

}

}

Each facet gets its own namespace.

Tools to Detect Collisions

1. Foundry vm.load

Read storage slots directly in tests:

function testStorageLayout() public {

address proxy = address(myProxy);

bytes32 implSlot = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);

address impl = address(uint160(uint256(vm.load(proxy, implSlot))));

assertEq(impl, address(implementation));

}

2. OpenZeppelin Storage Layout Hash

forge inspect MyContract storage-layout --pretty

This shows the exact slot for every variable.

3. Slither Upgradeability Checks

slither . --detect upgrade-layout

Slither detects layout changes between versions.

Real-World Incidents

1. Parity Multisig (2017)

The Parity wallet library was used as an implementation via delegatecall. A user called initWallet() directly on the library, making themselves the owner. They then called kill(), destroying the library and bricking all proxies.

Loss: 513k ETH (~$150M at the time)

2. OUSD (2020)

Origin Dollar's upgrade added a new state variable at the wrong position. Rebase rewards were written to the wrong slot, corrupting balances.

Loss: $7M

Best Practices

  • Never change the order of existing state variables
  • Use unstructured storage for proxy metadata
  • Run forge inspect storage-layout before every upgrade
  • Use Slither or similar tools to detect layout changes
  • Test upgrades on a fork before deploying
  • Summary

    Storage collisions are silent and catastrophic. EIP-1967 and unstructured storage solve the proxy collision problem. But you still need discipline when adding new state variables. Always validate storage layout before upgrading.

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement