adllm Insights logo adllm Insights logo

The Achilles' Heel of Upgradeable Proxies: Solidity Storage Layout Collisions

Published on by The adllm Team. Last modified: . Tags: solidity smart-contracts proxies upgradeability delegatecall evm security

Upgradeable smart contracts are a cornerstone of modern decentralized application development, offering a path to fix bugs and introduce new features post-deployment. The dominant mechanism for achieving this is the proxy pattern, where a persistent proxy contract delegates its logic calls to an evolving implementation contract using the EVM’s delegatecall opcode. However, this powerful pattern harbors a subtle yet critical vulnerability: storage layout collisions.

Failure to manage storage layout meticulously across upgrades can lead to data corruption, broken invariants, and severe security exploits. This article provides a comprehensive understanding of how these collisions occur and the essential best practices to prevent them.

The delegatecall Deception: Shared Storage, Separate Views

At the heart of the issue is delegatecall. When a proxy contract P makes a delegatecall to an implementation contract I, I’s code executes in the context of P. This means:

  • msg.sender and msg.value remain those of the original caller to P.
  • Crucially, I reads from and writes to P’s storage, not its own.

Solidity assigns storage slots to state variables sequentially based on their declaration order and type. The implementation contract I operates on P’s storage as if that storage conforms to I’s own declared layout. If a new implementation I_v2 changes this layout relative to I_v1 (e.g., by reordering variables, changing types incompatibly, or inserting new variables in the middle), I_v2 will misinterpret the data previously written by I_v1 into P’s storage.

This misalignment is a storage layout collision, and it’s where things go catastrophically wrong.

A Simple Collision Scenario

Consider an initial implementation LogicV1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// LogicV1.sol
contract LogicV1 {
    address public owner;       // Slot 0
    uint256 public value;      // Slot 1
    bool public isInitialized;  // Slot 2 (first item)

    function initialize(address _owner, uint256 _value) public {
        // In a real scenario, use an initializer guard
        owner = _owner;
        value = _value;
        isInitialized = true;
    }
    // ... other functions
}

The proxy’s storage after initialization might look like this:

  • Slot 0: 0x_owner_address_
  • Slot 1: 0x_value_
  • Slot 2: 0x...01 (true for isInitialized)

Now, we deploy LogicV2 with a seemingly innocuous change – adding a creationTimestamp before isInitialized:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// LogicV2.sol - DANGEROUS UPGRADE
contract LogicV2 {
    address public owner;           // Slot 0 (Consistent with V1)
    uint256 public value;          // Slot 1 (Consistent with V1)
    uint256 public creationTimestamp; // NEW: Slot 2
    bool public isInitialized;      // SHIFTED: Now expects Slot 3

    function initializeV2(address _owner, uint256 _value) public {
        // V1's initialize already called by proxy.
        // This is a new initializer for V2 specific state, if any.
        // Or, a re-initializer if allowed (complex and risky).
        owner = _owner; // Usually already set
        value = _value; // Usually already set
        creationTimestamp = block.timestamp;
        isInitialized = true; // Problem here!
    }

    function setTimestamp(uint256 _ts) public {
        creationTimestamp = _ts; // Writes to Slot 2
    }

    function getIsInitialized() public view returns (bool) {
        return isInitialized; // Reads from Slot 3
    }
}

When the proxy is upgraded to point to LogicV2:

  • LogicV2 expects owner at slot 0 (Correctly reads V1’s owner).
  • LogicV2 expects value at slot 1 (Correctly reads V1’s value).
  • LogicV2 expects creationTimestamp at slot 2. When setTimestamp is called, it overwrites the isInitialized value (0x...01) from V1 with a timestamp.
  • LogicV2 expects isInitialized at slot 3. When getIsInitialized is called, it reads from an uninitialized slot (likely returning false or garbage).

The isInitialized flag from V1 has been corrupted, and its meaning lost.

Unbreakable Rules for Safe Storage Upgrades

To prevent collisions, the storage layout of new implementations must remain compatible with all previous versions.

  1. Append, Never Prepend or Reorder State Variables:

    • New state variables in an upgraded contract must always be declared after all existing state variables from the previous version.
    • CORRECT LogicV2:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      
      // LogicV2.sol - SAFE UPGRADE
      contract LogicV2 {
          address public owner;           // Slot 0
          uint256 public value;          // Slot 1
          bool public isInitialized;      // Slot 2
          uint256 public creationTimestamp; // NEW & Appended: Slot 3
      
          // ... initializers and functions ...
      }
      
  2. Never Remove an Existing State Variable:

    • Removing a variable causes subsequent variables to “shift up,” breaking their slot assignments. If a variable is no longer needed, deprecate it with comments and stop using it in the logic, but leave its declaration in place.
  3. Never Change Variable Types Incompatibly:

    • Changing a variable’s type to one that uses a different number of bytes or has different packing rules can corrupt its data or shift subsequent variables.
    • Unsafe: uint128 data; to uint256 data; if data was packed with another uint128.
    • Often Safe: uint128 data; to uint256 data; if data was the only variable in its slot or the last packed one. Changing address to address payable is generally safe. Always verify with layout tools.
  4. Manage Inheritance Carefully:

    • The C3 linearization order of inherited contracts determines the final storage layout. Define core state variables in base contracts. New versions should inherit from these base storage contracts and append new variables in derived contracts.
    • Example:
       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      
      contract BaseStorage {
          uint256 varA;
      }
      contract LogicV1 is BaseStorage {
          uint256 varB; // varA slot 0, varB slot 1
      }
      // For V2:
      contract LogicV2 is BaseStorage /* or LogicV1 directly */ {
          uint256 varB; // Must match V1 if inheriting BaseStorage
                        // Or inherit LogicV1 to get varB implicitly
          uint256 varC; // New variable, appended
      }
      
      A common pattern is LogicV2 is LogicV1 { uint256 varC; }.
  5. Utilize Storage Gaps for Future Expansion (Unstructured Storage):

    • Explicitly reserve storage slots for future use by declaring an unused array, often named __gap.
    •  1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      
      contract MyContractV1 {
          uint256 importantData;
          // Reserve 50 slots for future state variables
          uint256 private __gap;
      }
      // In MyContractV2, you can "use" some of the gap:
      contract MyContractV2 is MyContractV1 { // MyContractV1 is now a storage base
          // To "use" the first gap slot for newVar1:
          // uint256 newVar1; // This would implicitly use the first slot of the parent's gap
          // How this is managed depends on how you structure inheritance.
          // OpenZeppelin's recommendation is to add new variables to new inherited
          // contracts. If MyContractV1 had the gap, then in V2:
          // contract MyContractV2 {
          //   uint256 importantData; // from V1
          //   uint256 newVar1;       // "uses" __gap
          //   uint256 newVar2;       // "uses" __gap
          //   uint256 private __gap; // The remaining gap
          // }
          // This requires careful management and is why OpenZeppelin's tooling is vital.
          // The plugins help manage this by ensuring new variables are added to new slots.
          // The primary goal of the gap is to ensure variables DECLARED AFTER the gap in V1
          // maintain their original slot position in V2 even if V2 adds variables
          // that conceptually fill the gap.
      

    OpenZeppelin Upgrades Plugins help manage this. New variables effectively consume slots from the end of the previous layout or these reserved gaps.

Essential Tooling and Practices

  1. OpenZeppelin Upgrades Plugins: For Hardhat (hardhat-upgrades) and Truffle (truffle-upgrades). These tools are indispensable. They automatically validate storage layout compatibility between your current implementation and a proposed new one. Heed their warnings.

  2. solc --storage-layout Contract.sol: The Solidity compiler can output a JSON representation of a contract’s storage layout. Diffing these JSON files for V1 and V2 can reveal discrepancies.

  3. Static Analyzers: Tools like Slither can perform upgradeability checks.

  4. Initializer Functions and Guards:

    • Upgradeable contracts use initialize functions instead of constructors.
    • Protect initializers with a modifier (e.g., OpenZeppelin’s initializer) to prevent them from being called multiple times.
    • Be mindful of initializing state for newly added variables in upgrade functions or subsequent initializers.
  5. Rigorous Testing in Forked Environments:

    • Use Hardhat or Foundry to fork mainnet (or your testnet).
    • Deploy your V1 contract and proxy. Interact with it to set state.
    • Perform the upgrade to V2 on the forked environment.
    • Thoroughly test all V2 functions, especially those interacting with state variables that existed in V1 or are newly added, to ensure data integrity and expected behavior.

Conclusion: Vigilance is Non-Negotiable

Storage layout collisions are a silent killer in upgradeable smart contracts. Because delegatecall operates on a shared storage space with the implementation’s view of that space, any uncoordinated change to variable order, type, or existence can lead to immediate and irreversible data corruption.

The principles are simple but strict: append new variables, never remove or reorder existing ones, and be exceedingly cautious with type changes. Embrace tooling like OpenZeppelin Upgrades Plugins and conduct thorough testing of every upgrade. In the immutable world of blockchain, a mistake in storage layout during an upgrade can be a permanent disaster. Vigilance and adherence to these best practices are not just recommended; they are fundamental to the security and reliability of your upgradeable smart contracts.