Storage Collisions

Dynamic slot assignment for storage variables is a procedure that allows us to use shared storage modules but improper slot assignment or change of variables can lead to catastrophic corruption of data.

This method is already known and used by several projects, however we introduce a clear semantic for improving the safety of storage slots and lowering the risk of user-introduced collisions.

Slots

Storage is laid out as 32-byte addressable blocks. Solidity assigns slots to variables in the order that they are declared in your contract.

contract Contract {
    uint256 _0; // occupies slot 0
    uint256 _1; // occupies slot 1
}

When the variable gets accessed, Solidity knows where to read the variable from in storage.

Such variable-declaration cannot be used by Extensions as various extensions will define their own variables and Solidity will believe that multiple variables all share slot 0 which cannot happen. The variable will be rewritten, or parsed as a wrong type and all sorts of things will go wrong.

To avoid that we use shared storage where variables are declared in libraries which return the slot that they are written to. This creates a deterministic slot for which such variables will live in any Extendable contract.

Dynamic slot assignment

In storage modules we declare the variables we intend to store in a struct and define where in storage it can be addressed when we need to read and write to it.

Since these slots are deterministic and will be shared by any Extendable contract (state itself is not shared, but the slot addresses are), we need to ensure that Extensions are able to freely assign their slot whilst avoiding potential collisions with other Extensions.

/**
 * @dev Storage struct used to hold state for Extendable contracts
 */
struct ExtendableState {
    // Array of full interfaceIds extended by the Extendable contract instance
    bytes4[] implementedInterfaces;

    // Array of function selectors extended by the Extendable contract instance
    mapping(bytes4 => bytes4[]) implementedFunctionsByInterfaceId;

    // Mapping of interfaceId/functionSelector to the extension address that implements it
    mapping(bytes4 => address) extensionContracts;
}

/**
 * @dev Storage library to access storage slot for the state struct
 */
library ExtendableStorage {
    bytes32 constant private STORAGE_NAME = keccak256("extendable.framework.v1:extendable-state");

    function _getState()
        internal 
        view
        returns (ExtendableState storage extendableState) 
    {
        bytes32 position = keccak256(abi.encodePacked(address(this), STORAGE_NAME));
        assembly {
            extendableState.slot := position
        }
    }
}

The above snippet is the ExtendableStorage.sol file that declares the variables we are storing in a struct called ExtendableState and is accessed through the library function _getState() that is called by Extensions that want to access that state.

STORAGE_NAME is the variable that seeds our slot address generation through pseudorandom hash function. When defining our storage module, we must choose a unique but predictable human-readable name for the slot, bonus points if its also descriptive about the contents of such state.

As a suggestion, use a STORAGE_NAME of the following format:

project_name.project_module.version:state_subset

This allows you to be much clearer about what state is being stored at that location whilst preventing the use of common phrases that could result in another developer picking the same.

The STORAGE_NAME is then used in conjunction with the keccak256 hash function and the address of your Extendable contract to generate the random slot where state variables will live.

Potential Collisions

Following the semantics outlined above for naming your storage slots reduces the likelihood of storage collisions but does not reduce it to zero. Are there still risks of collisions?

The simple answer is no.

The more nuanced answer is yes, but only to a similar order of magnitude of likelihood of hash collisions which is a widely accepted assumption by Ethereum or the system would not function the way it does today.

Let's work it out.

By modelling the storage space as an address range of a 32-byte hash, there are 2^256 - 1 potential slots, that's over 1.15e+77. Variables that occupy more than a 32-bytes will overlap multiple slots. By doing some pessimistic math where we expect an Extendable contract to use 1000 Extensions, each accessing 1000 slots, the probability of storage collision is still more than 1 in 1e+70, astronomically unlikely.

Changing struct layout

A more likely cause of concern is the change of the struct that declares the variables to be stored.

In the case where your contracts are already deployed and an update is required during runtime that adds more variables or changes existing ones, any change must be done very carefully to avoid completely destroying existing state.

Generally, it is safe to perform append-only modifications to the state struct. That is, adding variables to the end of the struct is considered safe as it does not conflict with the existing layout of variables.

struct PreviousState {
    string[] name;
    uint256[] age;
}

struct NewState {
    string[] name;
    bytes newEntry; // this is not safe
    uint256[] age;
    bytes newEntry; // this is safe
}

However if you wish to add variables in between existing ones or modify the state completely, then you will need to perform a state migration.

State migration

If you are attempting to administer a breaking change to the state struct, you will need to migrate the old state to the new struct storage layout in order to keep existing smart contract data.

The exact details of how to achieve this depends heavily on the structure of the state but an example approach would be the following:

struct PreviousState {
    string[] name;
    uint256[] age;
}

struct NewState {
    mapping(string => uint256) ageByName;
}

function migrateFrom(bytes32 oldSlot, bytes32 newSlot) {
    PreviousState storage oldState = _getStateFrom(oldSlot);
    NewState storage newState = _getStateFrom(newSlot);
    
    for (uint256 i = 0; i < oldState.name.length; i++) {
        newState.ageByName[oldState.name[i]] = oldState.age[i];
    }
}

Once migrated your contract can be used as normal by specifying the new slot that should be used to access your reformatted state struct.

Last updated