Securite·9 min de lecture·Par Solingo

ERC-7201 Namespaced Storage for Safe Upgradeable Contracts

Storage collisions are the silent killer of proxy upgrades. ERC-7201 namespaced storage gives each module its own collision-resistant slot region, and here is exactly how the formula works.

# ERC-7201 Namespaced Storage for Safe Upgradeable Contracts

Upgradeable contracts split logic from state. The proxy holds the storage, the implementation holds the code, and every upgrade swaps the code while keeping the same storage. That separation is powerful, but it introduces a danger that has bricked real contracts: storage collisions. ERC-7201 ("Namespaced Storage Layout") is the standard answer. This article shows where collisions come from, how the ERC-7201 base slot is computed, and how to use it correctly, including the OpenZeppelin convention.

Why proxy upgrades collide

In the EVM, contract state lives in a flat key-value store of 32-byte slots. Solidity assigns sequential slots to state variables in declaration order: the first variable goes to slot 0, the next to slot 1, and so on (with packing for small types). Mappings and dynamic arrays derive their slots from a hash, but their length and base pointer still occupy a sequential slot.

With a proxy, the implementation contract reads and writes those slots, but the data physically lives in the proxy. So both contracts must agree, byte for byte, on what lives at slot 0, slot 1, and so on. Problems appear in two classic shapes:

  • Inheritance reordering. You add a new base contract, or reorder existing ones. Slot 3 used to be totalSupply; after the change it is owner. The old stored value is now reinterpreted as a different variable.
  • Module overlap. You combine several independent modules (an ERC20, an access-control module, a pausable module). Each one naively starts counting from slot 0, so they all want slot 0 for different data.
  • The older mitigation was storage gaps: declaring uint256[50] private __gap; at the end of each base contract to reserve room for future variables. Gaps work, but they are fragile. You have to manually shrink the gap when you add a variable, and a single miscount silently corrupts state. ERC-7201 removes the guesswork.

    The core idea: give every namespace its own region

    Instead of packing all state into the sequential slots starting at 0, ERC-7201 places each logical group of variables in a struct, and anchors that struct at a pseudo-random base slot derived from a human-readable namespace id. Because the base slots are spread across the 256-bit slot space by a hash, two different namespaces practically never overlap, and they never collide with the sequential layout at slot 0.

    A namespace id is a dotted string such as myapp.storage.Vault. The convention used by OpenZeppelin is .storage., for example openzeppelin.storage.ERC20.

    The formula

    The ERC-7201 base slot for a namespace id N is:

    bytes32 slot = keccak256(abi.encode(uint256(keccak256(bytes(N))) - 1)) & ~bytes32(uint256(0xff));

    Read it inside out:

  • keccak256(bytes(N)) hashes the namespace string.
  • Cast to uint256 and subtract 1. This - 1 step prevents anyone from finding a preimage that maps a mapping or array element onto the namespace slot, because mapping slots are themselves keccak256 outputs and the offset breaks that alignment.
  • abi.encode(...) left-pads the value to 32 bytes, then keccak256 hashes again. The double hash is what makes the result collision resistant against the standard Solidity slot derivation.
  • & ~bytes32(uint256(0xff)) masks off the last byte (sets it to zero). This aligns the base slot to a 256-slot boundary, leaving room so multi-slot structs and the per-variable offsets stay inside one clean region and remain friendly to storage-access optimizations.
  • The result is deterministic. The same id always yields the same slot, on any chain, in any compiler version.

    Verifying with the command line

    You can reproduce the slot offline. With Foundry's cast:

    cast index-erc7201 "openzeppelin.storage.ERC20"
    

    # 0x52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00

    Notice the trailing 00: that is the masked last byte. Always cross-check the value your contract uses against an independent computation. A typo in the namespace string produces a completely different, valid-looking slot, and the bug stays invisible until two contracts disagree.

    Struct-based storage in practice

    Here is a self-contained pattern you can apply without any library. Each module defines a struct, an annotated constant, and a private accessor that loads the struct from its fixed slot using inline assembly.

    // SPDX-License-Identifier: MIT
    

    pragma solidity ^0.8.20;

    contract VaultUpgradeable {

    /// @custom:storage-location erc7201:myapp.storage.Vault

    struct VaultStorage {

    mapping(address => uint256) balances;

    uint256 totalDeposits;

    address asset;

    }

    // keccak256(abi.encode(uint256(keccak256("myapp.storage.Vault")) - 1)) & ~bytes32(uint256(0xff))

    bytes32 private constant VAULT_STORAGE_LOCATION =

    0x590a9a5de9ed35bc0349b8615141727573f4e89701ca33d602fbe9de39aaa900;

    function _vault() private pure returns (VaultStorage storage $) {

    assembly {

    $.slot := VAULT_STORAGE_LOCATION

    }

    }

    function deposit(uint256 amount) external {

    VaultStorage storage $ = _vault();

    $.balances[msg.sender] += amount;

    $.totalDeposits += amount;

    }

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

    return _vault().balances[account];

    }

    }

    Three details make this safe and idiomatic:

    • The @custom:storage-location erc7201:... NatSpec tag lets the Solidity compiler emit a storage layout that tooling (and the compiler's own collision checks) can verify. Without it, the struct is just a struct.
    • The $ variable name is the community convention for the namespaced storage pointer. It keeps accessors short and recognizable.
    • The struct is anchored once. Inside the struct, the mapping and the two scalars get their own offsets relative to the base slot, exactly as normal Solidity layout would assign them, but isolated inside this namespace.

    Because every variable now lives inside a namespaced struct, you can add a new field to the end of VaultStorage in a future upgrade without touching any other module, and reorder modules freely. There is no shared sequential counter to keep in sync.

    OpenZeppelin usage

    The OpenZeppelin Upgradeable contracts adopt ERC-7201 throughout. Each base contract defines its own namespaced struct and accessor. For example, the upgradeable OwnableUpgradeable uses the namespace openzeppelin.storage.Ownable, and ERC20Upgradeable uses openzeppelin.storage.ERC20. You inherit them exactly as you would any other contract:

    // SPDX-License-Identifier: MIT
    

    pragma solidity ^0.8.20;

    import {ERC20Upgradeable} from

    "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";

    import {OwnableUpgradeable} from

    "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

    import {Initializable} from

    "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

    contract MyToken is Initializable, ERC20Upgradeable, OwnableUpgradeable {

    /// @custom:oz-upgrades-unsafe-allow constructor

    constructor() {

    _disableInitializers();

    }

    function initialize(address owner_) external initializer {

    __ERC20_init("MyToken", "MTK");

    __Ownable_init(owner_);

    }

    function mint(address to, uint256 amount) external onlyOwner {

    _mint(to, amount);

    }

    }

    Since each parent keeps its state in a distinct namespace, the inheritance order of ERC20Upgradeable and OwnableUpgradeable no longer affects the storage layout. That is the practical payoff: you stop reasoning about slot arithmetic across the inheritance graph.

    A few rules still apply:

    • Use initializer and __X_init functions, not constructors, to set initial state, because constructor code never runs in the proxy context.
    • Call _disableInitializers() in the implementation constructor so the implementation cannot be initialized directly.
    • The OpenZeppelin Upgrades plugins read the @custom:storage-location annotations to validate that an upgrade does not move or remove a namespaced variable. Keep the plugin in your CI.

    A short checklist

    • Group related state into one struct per module, tagged with @custom:storage-location erc7201:.
    • Compute the base slot with the exact formula, and verify it with an independent tool before deploying.
    • Only ever append fields to the end of a namespaced struct across upgrades.
    • Keep a storage-layout validator (the OpenZeppelin Upgrades plugin) in CI.

    Practice it

    Reading the formula is one thing; feeling a collision corrupt your state and then fixing it with a namespace is what makes it stick. On Solingo you can write an upgradeable contract, trigger a deliberate storage collision in a proxy, then refactor it to ERC-7201 and watch the slots line up. Try the upgradeable-storage exercises at app.solingo-blockchain.xyz and compute a base slot yourself before the answer is revealed.

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement