🎓 A1Learner
🔍
0 of 48 lessons completed

Solidity Storage Layout Risks

Storage layout risk is the danger that an upgraded logic contract reads old values in the wrong slots. Slots are fixed positions in storage, so a new variable inserted in the middle shifts every value after it.

Appending a variable keeps the old value safe

The new version adds its variable at the end. The old value reads back exactly as before.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract V1 {
    uint256 public amount;
    function setAmount(uint256 v) public {
        amount = v;
    }
}


contract V2Safe {
    uint256 public amount;
    uint256 public bonus;
    function setAmount(uint256 v) public {
        amount = v;
    }
    function setBonus(uint256 v) public {
        bonus = v;
    }
}


contract Proxy {
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    bytes32 constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
    constructor(address impl) {
        _setImplementation(impl);
        _setOwner(msg.sender);
    }
    function implementation() public view returns (address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { impl := sload(slot) }
    }
    function owner() public view returns (address admin) {
        bytes32 slot = ADMIN_SLOT;
        assembly { admin := sload(slot) }
    }
    function _setImplementation(address impl) internal {
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, impl) }
    }
    function _setOwner(address admin) internal {
        bytes32 slot = ADMIN_SLOT;
        assembly { sstore(slot, admin) }
    }
    function upgrade(address impl) public {
        require(msg.sender == owner(), "only owner");
        _setImplementation(impl);
    }
    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()) }
        }
    }
}

Inserting a variable corrupts the old read

The new version puts its variable before the old one. The old value now appears under the new variable name.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract V1 {
    uint256 public amount;
    function setAmount(uint256 v) public {
        amount = v;
    }
}


contract V2Bad {
    uint256 public bonus;
    uint256 public amount;
}


contract Proxy {
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    bytes32 constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
    constructor(address impl) {
        _setImplementation(impl);
        _setOwner(msg.sender);
    }
    function implementation() public view returns (address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { impl := sload(slot) }
    }
    function owner() public view returns (address admin) {
        bytes32 slot = ADMIN_SLOT;
        assembly { admin := sload(slot) }
    }
    function _setImplementation(address impl) internal {
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, impl) }
    }
    function _setOwner(address admin) internal {
        bytes32 slot = ADMIN_SLOT;
        assembly { sstore(slot, admin) }
    }
    function upgrade(address impl) public {
        require(msg.sender == owner(), "only owner");
        _setImplementation(impl);
    }
    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()) }
        }
    }
}

A middle insert shifts every later value

The new version inserts a variable between two old ones. The second old value moves and reads as the new variable.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract V1Two {
    uint256 public first;
    uint256 public second;
    function setBoth(uint256 a, uint256 b) public {
        first = a;
        second = b;
    }
}


contract V2Mid {
    uint256 public first;
    uint256 public inserted;
    uint256 public second;
}


contract Proxy {
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    bytes32 constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
    constructor(address impl) {
        _setImplementation(impl);
        _setOwner(msg.sender);
    }
    function implementation() public view returns (address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { impl := sload(slot) }
    }
    function owner() public view returns (address admin) {
        bytes32 slot = ADMIN_SLOT;
        assembly { admin := sload(slot) }
    }
    function _setImplementation(address impl) internal {
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, impl) }
    }
    function _setOwner(address admin) internal {
        bytes32 slot = ADMIN_SLOT;
        assembly { sstore(slot, admin) }
    }
    function upgrade(address impl) public {
        require(msg.sender == owner(), "only owner");
        _setImplementation(impl);
    }
    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()) }
        }
    }
}

Example

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract V1Vault {
    uint256 public saved;
    function addSaved(uint256 v) public {
        saved += v;
    }
}


contract V2Vault {
    uint256 public saved;
    uint256 public bonus;
    function addSaved(uint256 v) public {
        saved += v;
    }
    function setBonus(uint256 v) public {
        bonus = v;
    }
    function total() public view returns (uint256) {
        return saved + bonus;
    }
}


contract Proxy {
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    bytes32 constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
    constructor(address impl) {
        _setImplementation(impl);
        _setOwner(msg.sender);
    }
    function implementation() public view returns (address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { impl := sload(slot) }
    }
    function owner() public view returns (address admin) {
        bytes32 slot = ADMIN_SLOT;
        assembly { admin := sload(slot) }
    }
    function _setImplementation(address impl) internal {
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, impl) }
    }
    function _setOwner(address admin) internal {
        bytes32 slot = ADMIN_SLOT;
        assembly { sstore(slot, admin) }
    }
    function upgrade(address impl) public {
        require(msg.sender == owner(), "only owner");
        _setImplementation(impl);
    }
    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()) }
        }
    }
}

Key points

  • Storage slot: a fixed position where one state value lives.
  • Append safely: a new variable added at the end leaves old values in place.
  • Insert danger: a new variable added in the middle shifts later values to new slots.
  • Corrupted read: after a bad insert, an old value can appear under a new variable name.
  • Proxy rule: the proxy and every logic version must agree on slot order.

In this example, a vault starts with a saved value and gains a bonus value at the end of its layout. The saved value survives the upgrade, and the total adds the two values together.

Note

slot layout is a compiler detail, so prove layout only by values read back and never by slot numbers.

Try it Yourself

Edit the code, then press Run.

Running is simulated · Remix IDE

Exercise

A new variable is inserted before an old value in an upgrade. What happens to the old value?