🎓 A1Learner
🔍
0 of 45 lessons completed

Solidity Storage Gaps

A storage gap is a reserved block of empty slots in a base contract, kept for future variables. A later version adds its new variables into the gap, so variables declared after the base contract keep their slots.

A gap upgrade keeps the old values safe

The base gains a fee inside its gap, shrinking the gap by one. The amount and the later bonus read back unchanged.

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

contract BaseV1 {
    uint256 public amount;
    uint256[50] private __gap;
}

contract VaultV1 is BaseV1 {
    uint256 public bonus;
    function setAmount(uint256 v) public {
        amount = v;
    }
    function setBonus(uint256 v) public {
        bonus = v;
    }
}

contract BaseV2 {
    uint256 public amount;
    uint256 public fee;
    uint256[49] private __gap;
}

contract VaultV2 is BaseV2 {
    uint256 public bonus;
    function setAmount(uint256 v) public {
        amount = v;
    }
    function setBonus(uint256 v) public {
        bonus = v;
    }
    function setFee(uint256 v) public {
        fee = v;
    }
}

contract Proxy {
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    bytes32 constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
    constructor(address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, impl) }
        bytes32 adminSlot = ADMIN_SLOT;
        address deployer = msg.sender;
        assembly { sstore(adminSlot, deployer) }
    }
    function implementation() public view returns (address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { impl := sload(slot) }
    }
    function upgrade(address impl) public {
        bytes32 adminSlot = ADMIN_SLOT;
        address adm;
        assembly { adm := sload(adminSlot) }
        require(msg.sender == adm, "only owner");
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, 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 gapless insert corrupts the later value

The new base inserts a fee with no gap to absorb it. The amount survives, but the later bonus reads zero.

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

contract BaseV1 {
    uint256 public amount;
    uint256[50] private __gap;
}

contract VaultV1 is BaseV1 {
    uint256 public bonus;
    function setAmount(uint256 v) public {
        amount = v;
    }
    function setBonus(uint256 v) public {
        bonus = v;
    }
}

contract BadBaseV2 {
    uint256 public amount;
    uint256 public fee;
}

contract BadVaultV2 is BadBaseV2 {
    uint256 public bonus;
}

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

Appending at the end is safe without a gap

The new version only adds a fee after every old variable. Nothing shifts, so the old values read back unchanged.

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

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

contract SimpleV2 {
    uint256 public amount;
    uint256 public bonus;
    uint256 public fee;
    function setAmount(uint256 v) public {
        amount = v;
    }
    function setBonus(uint256 v) public {
        bonus = v;
    }
    function setFee(uint256 v) public {
        fee = v;
    }
}

contract Proxy {
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    bytes32 constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
    constructor(address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, impl) }
        bytes32 adminSlot = ADMIN_SLOT;
        address deployer = msg.sender;
        assembly { sstore(adminSlot, deployer) }
    }
    function implementation() public view returns (address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { impl := sload(slot) }
    }
    function upgrade(address impl) public {
        bytes32 adminSlot = ADMIN_SLOT;
        address adm;
        assembly { adm := sload(adminSlot) }
        require(msg.sender == adm, "only owner");
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, 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 VaultBaseV1 {
    uint256 public amount;
    uint256[50] private __gap;
}

contract VaultV1 is VaultBaseV1 {
    uint256 public bonus;
    function setAmount(uint256 v) public {
        amount = v;
    }
    function setBonus(uint256 v) public {
        bonus = v;
    }
}

contract VaultBaseV2 {
    uint256 public amount;
    uint256 public fee;
    uint256 public rate;
    uint256[48] private __gap;
}

contract VaultV2 is VaultBaseV2 {
    uint256 public bonus;
    function setAmount(uint256 v) public {
        amount = v;
    }
    function setBonus(uint256 v) public {
        bonus = v;
    }
    function setFee(uint256 v) public {
        fee = v;
    }
    function setRate(uint256 v) public {
        rate = v;
    }
    function total() public view returns (uint256) {
        return amount + bonus + fee + rate;
    }
}

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

  • Gap declaration: a gap is declared as an array of empty slots, such as uint256[50] private __gap.
  • Gap use: a later version adds a new variable into the gap and shrinks the gap by one slot for each variable added.
  • Later variables: variables declared after the base contract keep their slots when the gap absorbs the new variables.
  • No gap danger: a version without a gap that inserts a variable shifts every later variable and corrupts its reads.
  • Append rule: adding a variable at the very end is also safe, because nothing sits after it to shift.

In this example, VaultBaseV1 holds an amount and a gap of 50 slots, and VaultV1 adds a bonus after the base. VaultBaseV2 adds a fee and a rate inside the gap and shrinks it to 48, so the bonus still sits in its old slot. After the upgrade the vault keeps 100 and 7, and its total reaches 112 once the fee is 3 and the rate is 2.

Note

a gap only protects variables declared after it, so the gap must sit in the base contract that gains variables.

Try it Yourself

Edit the code, then press Run.

Running is simulated · Remix IDE

Exercise

Where should a new variable go when a base contract is upgraded?