# ERC-7201 : stockage par namespace pour des contrats upgradeables surs
Les contrats upgradeables separent la logique de l'etat. Le proxy detient le stockage, l'implementation detient le code, et chaque upgrade remplace le code tout en gardant le meme stockage. Cette separation est puissante, mais elle introduit un danger qui a deja brique de vrais contrats : les collisions de stockage. ERC-7201 ("Namespaced Storage Layout") est la reponse standard. Cet article montre d'ou viennent les collisions, comment se calcule le slot de base ERC-7201, et comment l'utiliser correctement, y compris la convention OpenZeppelin.
Pourquoi les upgrades de proxy entrent en collision
Dans l'EVM, l'etat d'un contrat vit dans un stockage cle-valeur plat de slots de 32 octets. Solidity attribue des slots sequentiels aux variables d'etat dans l'ordre de declaration : la premiere variable va au slot 0, la suivante au slot 1, et ainsi de suite (avec du packing pour les petits types). Les mappings et les tableaux dynamiques derivent leur slot d'un hash, mais leur longueur et leur pointeur de base occupent quand meme un slot sequentiel.
Avec un proxy, le contrat d'implementation lit et ecrit ces slots, mais les donnees vivent physiquement dans le proxy. Les deux contrats doivent donc s'accorder, octet pour octet, sur ce qui se trouve au slot 0, au slot 1, et ainsi de suite. Les problemes apparaissent sous deux formes classiques :
totalSupply ; apres le changement c'est owner. La valeur stockee auparavant est maintenant reinterpretee comme une autre variable.L'ancienne parade etait les storage gaps : declarer uint256[50] private __gap; a la fin de chaque contrat de base pour reserver de la place aux futures variables. Les gaps fonctionnent, mais ils sont fragiles. Il faut reduire le gap manuellement quand on ajoute une variable, et une seule erreur de comptage corrompt silencieusement l'etat. ERC-7201 supprime ce travail de devinette.
L'idee centrale : donner a chaque namespace sa propre region
Au lieu d'empiler tout l'etat dans les slots sequentiels a partir de 0, ERC-7201 place chaque groupe logique de variables dans une struct, et ancre cette struct a un slot de base pseudo-aleatoire derive d'un identifiant de namespace lisible. Comme les slots de base sont disperses dans l'espace de slots de 256 bits par un hash, deux namespaces differents ne se chevauchent quasiment jamais, et ils n'entrent jamais en collision avec la disposition sequentielle au slot 0.
Un identifiant de namespace est une chaine a points comme myapp.storage.Vault. La convention utilisee par OpenZeppelin est , par exemple openzeppelin.storage.ERC20.
La formule
Le slot de base ERC-7201 pour un identifiant de namespace N est :
bytes32 slot = keccak256(abi.encode(uint256(keccak256(bytes(N))) - 1)) & ~bytes32(uint256(0xff));
Lisez-la de l'interieur vers l'exterieur :
keccak256(bytes(N)) hash la chaine du namespace.uint256 puis soustraction de 1. Ce - 1 empeche quiconque de trouver une preimage qui ferait pointer un element de mapping ou de tableau sur le slot du namespace, car les slots de mapping sont eux-memes des sorties de keccak256 et le decalage casse cet alignement.abi.encode(...) complete la valeur a 32 octets a gauche, puis keccak256 re-hash. Ce double hash rend le resultat resistant aux collisions face a la derivation de slot standard de Solidity.& ~bytes32(uint256(0xff)) masque le dernier octet (le met a zero). Cela aligne le slot de base sur une frontiere de 256 slots, laissant de la place pour que les structs multi-slots et les offsets par variable restent dans une region propre et compatibles avec les optimisations d'acces au stockage.Le resultat est deterministe. Le meme identifiant donne toujours le meme slot, sur n'importe quelle chaine, dans n'importe quelle version du compilateur.
Verifier en ligne de commande
Vous pouvez reproduire le slot hors chaine. Avec cast de Foundry :
cast index-erc7201 "openzeppelin.storage.ERC20"
# 0x52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00
Remarquez le 00 final : c'est le dernier octet masque. Verifiez toujours la valeur que votre contrat utilise contre un calcul independant. Une faute de frappe dans la chaine de namespace produit un slot completement different mais d'apparence valide, et le bug reste invisible jusqu'a ce que deux contrats soient en desaccord.
Le stockage par struct en pratique
Voici un pattern autonome applicable sans aucune bibliotheque. Chaque module definit une struct, une constante annotee, et un accesseur prive qui charge la struct depuis son slot fixe via de l'assembleur inline.
// 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];
}
}
Trois details rendent ceci sur et idiomatique :
- Le tag NatSpec
@custom:storage-location erc7201:...permet au compilateur Solidity d'emettre une disposition de stockage que l'outillage (et les verifications de collision du compilateur lui-meme) peut valider. Sans lui, la struct n'est qu'une struct.
- Le nom de variable
$est la convention de la communaute pour le pointeur de stockage par namespace. Il garde les accesseurs courts et reconnaissables.
- La struct est ancree une seule fois. A l'interieur de la struct, le mapping et les deux scalaires recoivent leurs propres offsets relatifs au slot de base, exactement comme la disposition normale de Solidity les attribuerait, mais isoles dans ce namespace.
Comme chaque variable vit desormais dans une struct par namespace, vous pouvez ajouter un nouveau champ a la fin de VaultStorage lors d'un futur upgrade sans toucher aucun autre module, et reordonner les modules librement. Il n'y a aucun compteur sequentiel partage a synchroniser.
Utilisation avec OpenZeppelin
Les contrats OpenZeppelin Upgradeable adoptent ERC-7201 partout. Chaque contrat de base definit sa propre struct par namespace et son accesseur. Par exemple, OwnableUpgradeable utilise le namespace openzeppelin.storage.Ownable, et ERC20Upgradeable utilise openzeppelin.storage.ERC20. Vous en heritez exactement comme de n'importe quel autre contrat :
// 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);
}
}
Comme chaque parent garde son etat dans un namespace distinct, l'ordre d'heritage de ERC20Upgradeable et OwnableUpgradeable n'affecte plus la disposition du stockage. C'est le gain concret : vous cessez de raisonner sur l'arithmetique des slots a travers le graphe d'heritage.
Quelques regles restent valables :
- Utilisez les fonctions
initializeret__X_init, pas des constructeurs, pour fixer l'etat initial, car le code de constructeur ne s'execute jamais dans le contexte du proxy.
- Appelez
_disableInitializers()dans le constructeur de l'implementation pour que l'implementation ne puisse pas etre initialisee directement.
- Les plugins OpenZeppelin Upgrades lisent les annotations
@custom:storage-locationpour valider qu'un upgrade ne deplace ni ne supprime une variable par namespace. Gardez le plugin dans votre CI.
Une courte checklist
- Regroupez l'etat lie dans une struct par module, taguee avec
@custom:storage-location erc7201:.
- Calculez le slot de base avec la formule exacte, et verifiez-le avec un outil independant avant le deploiement.
- N'ajoutez jamais que des champs a la fin d'une struct par namespace lors des upgrades.
- Gardez un validateur de disposition de stockage (le plugin OpenZeppelin Upgrades) dans la CI.
Mettez-le en pratique
Lire la formule est une chose ; sentir une collision corrompre votre etat puis la corriger avec un namespace, voila ce qui fait que ca rentre. Sur Solingo vous pouvez ecrire un contrat upgradeable, declencher une collision de stockage deliberee dans un proxy, puis le refactoriser en ERC-7201 et regarder les slots s'aligner. Essayez les exercices sur le stockage upgradeable sur app.solingo-blockchain.xyz et calculez vous-meme un slot de base avant que la reponse ne soit revelee.