Un contrat d'implémentation qui ne modifie que son propre champ owner peut écrire une valeur parasite à la place de l'adresse d'administration du contrat proxy. La cause de ce type d'incident tient généralement à la manière dont Solidity range les variables d'état. Il n'existe pas d'index d'exécution faisant correspondre un nom de variable à une position de stockage : le compilateur place les variables, dans leur ordre de déclaration, dans des slots de 32 octets (32 bytes). delegatecall fait exécuter le code du contrat d'implémentation sur le stockage du proxy ; dès que les deux côtés ne s'accordent pas sur l'allocation des slots, l'écriture atterrit sur les données de l'autre.
Le slot est à la fois l'unité d'adressage du stockage et la granularité minimale à laquelle la couche d'exécution enregistre un accès à l'état. Le slot dans lequel tombe une variable, le partage éventuel d'un même slot après compactage et la façon dont les éléments d'un tableau ou d'un mapping dérivent leur position à partir d'une clé déterminent deux questions très concrètes : quel champ du proxy une transaction va corrompre, et quelle est exactement la taille du read/write set d'une transaction.
Les variables occupent linéairement des slots de 32 octets dans l'ordre de déclaration
D'après la documentation officielle de Solidity, « Layout of State Variables in Storage and Transient Storage », hormis les tableaux dynamiques et les mappings, les variables d'état sont rangées de manière contiguë à partir de la première déclaration, la première variable tombant dans le slot 0. Chaque variable occupe un nombre d'octets déterminé par son type ; des variables consécutives totalisant moins de 32 octets sont regroupées dans un même slot, selon cinq règles : le premier élément d'un slot est rangé dans les octets de poids faible (lower-order aligned) ; un type valeur n'occupe que les octets dont il a réellement besoin ; une variable qui ne tient pas dans l'espace restant du slot courant passe au slot suivant ; les données d'une structure et d'un tableau commencent toujours dans un nouveau slot ; les variables qui suivent une structure ou un tableau repartent elles aussi d'un nouveau slot.
Deux exceptions faciles à négliger influent sur le calcul des numéros de slot. Une variable constant n'occupe pas de slot de stockage : sa valeur est inlinée à l'endroit de son utilisation ; une variable immutable est encodée dans le bytecode de déploiement et ne lit pas le stockage à l'exécution. Prenons le contrat d'exemple C de la documentation officielle : la constante c et la variable immuable d ne participent pas au placement, et si on les comptait dans l'ordre de déclaration, tous les numéros de slot suivants seraient décalés. Le stockage transitoire (transient storage) relève d'un placement distinct : les règles sont les mêmes, mais l'espace est totalement séparé, si bien que dans un même contrat les variables d'état ordinaires et les variables transitoires peuvent s'entrelacer librement sans s'influencer.
Le compactage économise des slots, pas nécessairement du gaz
Les petits types partagent un même slot : c'est le compactage (packing). Les deux déclarations ci-dessous ne diffèrent que par l'ordre des variables, avec un slot d'écart :
// occupe 3 slots
uint128 a; // slot 0, décalage 0
uint256 b; // 32 octets ne tiennent pas dans l'espace restant du slot 0, passe au slot 1
uint128 c; // le slot 1 est plein, passe au slot 2
// occupe 2 slots
uint128 a; // slot 0, décalage 0
uint128 b; // slot 0, décalage 16
uint256 c; // slot 1
La documentation officielle indique clairement que l'usage d'éléments de moins de 32 octets peut augmenter la consommation de gaz : l'EVM opère par unités de 32 octets, et le traitement des petits types exige des opérations supplémentaires de troncature ou de décalage. Le gain réel du compactage tient à « un seul accès à un slot pour récupérer plusieurs valeurs », les lectures et écritures étant facturées au slot. L'inverse est tout aussi vrai : si une portion de logique n'écrit qu'une seule de ces variables, l'EVM doit d'abord lire tout le slot, modifier les octets concernés, puis réécrire l'ensemble, faute de quoi elle écraserait les autres variables du même slot. Entasser dans un même slot des variables rarement accédées ensemble transforme une écriture en une lecture suivie d'une écriture.
Voilà déjà un point d'observation lié à la concurrence : vu à l'échelle du slot, le compactage lie plusieurs variables logiquement indépendantes en un même objet inscriptible.
Les tableaux de taille fixe sont inlinés, les tableaux dynamiques et les mappings dérivent leur position par hachage
Les éléments d'un tableau de taille fixe (comme uint256[3]) sont inlinés dans les slots dans l'ordre, exactement comme des variables déclarées une à une ; lorsque le nombre total d'octets dépasse 32, ils s'étalent naturellement sur plusieurs slots. Il en va de même pour une structure, à ceci près qu'elle-même et les variables qui la suivent doivent repartir d'un nouveau slot.
Les tableaux dynamiques et les mappings ne peuvent pas être inlinés, leur taille n'étant pas prévisible. Leur approche consiste à n'occuper qu'un seul slot p dans le placement ; ce slot ne contient pas les données, dont la position est calculée par keccak256.
Le slot p d'un tableau dynamique contient la longueur du tableau ; les éléments commencent à keccak256(p) et s'enchaînent selon les règles des tableaux de taille fixe, plusieurs éléments pouvant partager un slot tant qu'ils ne dépassent pas 16 octets. Les tableaux dynamiques imbriqués appliquent récursivement la même règle : pour un x de type uint24[][] déclaré au slot p, l'élément x[i][j] se trouve dans le slot keccak256(keccak256(p) + i) + floor(j / floor(256 / 24)).
Le slot p d'un mapping reste vide, mais il doit être réservé : c'est précisément ce slot réservé qui garantit que les données de deux mappings adjacents ne se chevauchent pas. La valeur associée à une clé k se trouve à keccak256(h(k) . p), où . désigne une concaténation et h applique à la clé un traitement dépendant du type : les types valeur sont complétés à 32 octets comme pour le stockage en mémoire ; les clés de type string et bytes ne sont pas complétées. L'exemple imbriqué donné par la documentation permet de le vérifier directement : pour uint x; mapping(uint => mapping(uint => S)) data;, le slot de data[4][9].c est keccak256(uint256(9) . keccak256(uint256(4) . uint256(1))) + 1 ; le +1 final provient du décalage du membre de structure c dans S, tandis que les deux uint16 a et b ont déjà été compactés dans un même slot.
L'encodage de bytes et de string mérite une explication à part, car il n'est pas un simple emballage de bytes1[]. Lorsque les données ne dépassent pas 31 octets, elles-mêmes et la longueur tiennent dans un même slot : les données sont alignées à gauche dans les octets de poids fort, et l'octet de poids faible contient length * 2. Lorsque les données atteignent ou dépassent 32 octets, le slot p contient length * 2 + 1, et les données proprement dites commencent dans la zone à partir de keccak256(p). Les deux cas se distinguent par le bit de poids faible : 0 pour les données courtes, 1 pour les données longues.
Le placement des types composites suit la même récursivité. En appliquant les règles ci-dessus, lorsqu'un uint8[4] est un élément de tableau dynamique, ses quatre valeurs d'un octet tiennent exactement dans un slot ; pour un uint[3][], chaque élément est un tableau de taille fixe occupant 3 slots, les éléments s'égrènent à intervalle de 3 slots et l'élément x[i] commence à keccak256(p) + 3 * i. Ces deux exemples sont des cas particuliers déduits des règles : la documentation officielle ne donne que l'exemple imbriqué uint24[][], sans énoncer explicitement ces deux cas.
Tout cela a une conséquence directe : la position des slots des éléments de tableau et des valeurs de mapping dépend de clés ou d'indices connus seulement à l'exécution ; elle n'est pas énumérable à la compilation, et une analyse statique qui ne lit que le code source ne peut pas non plus en déduire l'ensemble complet.
Le placement est une sortie du compilateur, exportable et comparable
Les numéros de slot n'ont pas besoin d'être devinés par déduction. L'interface JSON standard de Solidity permet d'exporter le placement de stockage d'un contrat ; la sortie contient deux clés, storage et types : chaque entrée du tableau storage fournit astId, contract, label, offset, slot et type, tandis que types décrit l'encodage de chaque type, la valeur inplace désignant un rangement inline, mapping et dynamic_array une dérivation par keccak256, et bytes un choix entre slot unique et zone hachée selon la longueur. La valeur de slot peut être très grande et est représentée par une chaîne de caractères en JSON. La documentation rappelle en outre que ce format de sortie est encore considéré comme expérimental et peut changer dans une version non cassante de Solidity : il convient donc comme outil de vérification ponctuelle, et non comme interface sur laquelle s'appuyer durablement.
L'ordre d'héritage et les frontières de slots déterminent la faisabilité d'une mise à niveau
Dans un contrat recourant à l'héritage, l'ordre des variables d'état est déterminé par l'ordre de linéarisation C3 (C3-linearized) des contrats, en partant du contrat le plus basal de la chaîne d'héritage. Lorsque le compactage est permis, des variables issues de contrats différents partagent toujours un même slot : une variable de classe de base et une variable dérivée peuvent cohabiter dans un même slot.
Cette règle explique deux catégories d'incidents de mise à niveau. La première consiste à ajouter une variable dans une classe de base : dès que la classe dérivée a déjà déclaré ses propres variables, la variable ajoutée à la base prend la place de slot d'une variable existante de la dérivée, et les anciennes données sont relues comme la nouvelle variable. La seconde consiste à insérer une variable avant une variable existante ou à changer le type d'une variable, avec le même effet. La documentation de mise à niveau d'OpenZeppelin formule cette contrainte sans détour : une nouvelle variable ne peut être ajoutée qu'à la fin ; si l'on supprime une variable en fin de liste, le stockage n'est pas effacé et une variable ultérieure ajoutée au même emplacement lira des valeurs résiduelles.
Pour les cas où l'on veut contrôler activement le placement, Solidity permet de déclarer un point de départ de placement personnalisé sur un contrat. L'exemple de la documentation officielle s'écrit pragma solidity ^0.8.29; et contract C is A, B layout at 42 ; les numéros de slot de toutes les variables statiques de l'arbre d'héritage sont décalés en bloc. La documentation ne précise pas à partir de quelle version du compilateur cette capacité a été introduite, et nous ne nous risquons pas ici à une affirmation de version. Cette déclaration ne vaut que pour cet arbre d'héritage : lorsque A et B sont déployés séparément, leur placement repart toujours du slot 0. Il faut noter qu'il s'agit d'un simple décalage du point de départ : la position des données des tableaux dynamiques et des mappings change elle aussi, par effet d'entraînement, avec le slot de base.
Mise à niveau par proxy : pourquoi la storage collision est inévitable et comment les slots réservés l'évitent
Le mécanisme d'un proxy de mise à niveau est le suivant : le proxy détient le stockage et le solde, et exécute le code du contrat d'implémentation via delegatecall. Comme delegatecall conserve le contexte de stockage de l'appelant, les slot 0 et slot 1 vus par le contrat d'implémentation sont ceux du proxy. Si le proxy déclare lui-même un address public admin;, celui-ci occupe le slot 0, tandis que la première variable d'état du contrat d'implémentation occupe elle aussi le slot 0 : toute écriture d'un côté écrase l'autre. C'est la storage collision (collision de stockage). Le problème ne vient pas d'une erreur de code d'un côté ou de l'autre : le stockage partagé ajouté à deux compilations indépendantes suffit, à lui seul, à produire un conflit.
La solution de l'EIP-1967 consiste à ne pas utiliser les slots alloués par le compilateur, mais à fixer un ensemble de slots conventionnels :
| Usage | Position du slot | Mode de dérivation |
|---|---|---|
| Adresse du contrat d'implémentation | 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc | bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) |
| Adresse du contrat beacon | 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50 | bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1) |
| Adresse d'administration | 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 | bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1) |
Le choix des paramètres de ces nombres a une raison précise. La position de slot est tirée du keccak256 d'une chaîne de caractères, et cette chaîne ne commence pas par un indice de stockage : elle ne peut donc pas coïncider avec les slots alloués par le compilateur en incrémentant à partir de 0. La valeur elle-même est très grande, ce qui l'éloigne encore de la plage des variables ordinaires. La soustraction de 1 en fin de calcul rend l'antécédent du hachage inconnaissable, empêchant quiconque de construire une clé de mapping qui écrirait exactement dans ce slot via keccak256(h(k) . p). L'EIP-1967 recommande par ailleurs que toute fonction réécrivant ces slots émette l'événement correspondant, car la surveillance on-chain a du mal à suivre directement les changements d'un slot arbitraire.
Outre l'EIP-1967, deux approches sont courantes. La plus ancienne est le fossé de stockage (storage gap) : on déclare à la fin d'une classe de base un tableau de taille fixe, par exemple uint256[49] __gap;, pour réserver des slots aux variables futures ; on réduit __gap d'autant lorsqu'on ajoute une variable à la base. La documentation d'OpenZeppelin indique que cela n'augmente pas la consommation de gaz, mais son mode de défaillance est très concret : oublier de réduire le fossé, ou ajouter une variable à une classe de base alors que la classe dérivée en a déjà, réintroduit un conflit. L'approche plus récente est le placement de stockage par espace de noms d'ERC-7201 : on regroupe un ensemble de variables dans une structure, annotée avec @custom:storage-location erc7201:<NAMESPACE_ID>, et la position est calculée par keccak256(keccak256(id) - 1) & ~0xff ; le & ~0xff final aligne l'espace de noms sur 256 slots, la documentation justifiant ce choix comme une optimisation future, au cas où une règle de gaz faisant chauffer 256 slots d'un coup apparaîtrait après la migration vers l'arbre d'état Verkle. Les versions upgradables d'OpenZeppelin Contracts depuis la 5.0 adoptent cette convention.
Il reste à ajouter une prémisse : la documentation de Solidity considère le placement de stockage comme une partie de l'interface externe du langage, car des pointeurs de stockage peuvent être passés à des fonctions de bibliothèque, et toute modification des règles de placement compte donc comme un changement cassant. La faisabilité des solutions de proxy qui codent en dur des adresses par slot repose sur la promesse d'une stabilité durable des règles de placement.
L'unité minimale du read/write set est le slot
En réunissant les règles ci-dessus, on voit que, du point de vue de la facturation des accès, la plus petite unité d'accès à l'état observable par la couche d'exécution est le couple adresse + slot. L'EIP-2929 en est la preuve la plus directe : elle maintient pour chaque transaction deux ensembles, accessed_addresses et accessed_storage_keys, le second ayant pour type d'élément Set[Tuple[Address, Bytes32]], et facture à cette granularité. Le premier accès à un slot de stockage coûte COLD_SLOAD_COST, soit 2100 gaz ; un slot déjà accédé coûte WARM_STORAGE_READ_COST, soit 100 gaz ; le premier accès à une adresse de compte coûte COLD_ACCOUNT_ACCESS_COST, soit 2600 gaz. La facturation froid/chaud a le slot pour unité, ce qui montre que la couche d'exécution prend elle-même le slot comme objet d'accès.
On peut en tirer trois déductions d'ingénierie liées à la concurrence.
L'EIP-2929 définit l'unité minimale de facturation des accès, mais la spécification ne définit pas la granularité à retenir pour la détection des conflits lors d'une exécution parallèle. Le point suivant est une déduction tirée de la granularité de facturation : le compactage lie de petites variables dans un même slot, ce qui signifie que si le read/write set est enregistré à l'échelle du slot, deux transactions écrivant des variables différentes d'un même slot restent en conflit ; pour éliminer ces faux conflits, la détection devrait descendre jusqu'au décalage dans le slot et à la différence d'octets, au prix d'un surcoût à chaque comparaison.
La position de slot des tableaux dynamiques et des mappings dépend de valeurs de clés ; le read/write set n'est pas connaissable à la compilation et ne peut être enregistré qu'en cours d'exécution, puis vérifié après. C'est aussi pour cette raison que le contrôle de concurrence optimiste (optimistic concurrency control, OCC) a besoin d'un read set et d'un write set et ne peut pas reprendre la méthode des bases de données, où le développeur les déclare à l'avance (voir sur ce site « Introduction au contrôle de concurrence optimiste (OCC) : des bases de données à l'exécution on-chain » et « Points chauds de conflit et charges de travail : quand l'EVM parallèle aide vraiment »).
Un read/write set au niveau du slot est une borne supérieure, pas un ensemble exact. Une transaction peut ne modifier qu'un octet d'un slot tout en étant enregistrée comme ayant écrit le slot entier ; elle peut aussi accéder à un slot sans que cela prenne effet, parce qu'un chemin ultérieur a fait revert. Prendre directement ce read/write set comme critère de conflit donne un taux de conflit trop élevé, qu'il faut résorber par des rollbacks et des réexécutions.
Il faut aussi préciser les limites. Si un contrat d'implémentation adopte entièrement le placement par espace de noms d'ERC-7201, il n'entre en principe plus en conflit avec les slots du proxy, au prix que les numéros de slot arbitraires ne se déduisent plus de l'ordre du code source et que les outils qui ne connaissent que cet ordre (certains analyseurs statiques, explorateurs de blocs) deviennent inopérants. En outre, un sstore en assembleur inline peut écrire dans n'importe quel slot hors du placement du compilateur, et aucun outil de placement qui se contente d'analyser l'AST ne peut couvrir ce type d'écriture : c'est aussi une source d'incertitude récurrente lorsqu'on analyse le comportement de stockage d'un contrat.
Sources
- Documentation Solidity, Layout of State Variables in Storage and Transient Storage : https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html
- EIP-1967, Proxy Storage Slots : https://eips.ethereum.org/EIPS/eip-1967
- ERC-7201, Namespaced Storage Layout : https://eips.ethereum.org/EIPS/eip-7201
- EIP-2929, Gas cost increases for state access opcodes : https://eips.ethereum.org/EIPS/eip-2929
- Documentation OpenZeppelin, Writing Upgradeable Contracts : https://docs.openzeppelin.com/upgrades-plugins/writing-upgradeable