Dans le monde de la blockchain, tout est immuable. Même les smart contracts ! Et, cela pose quelques problèmes.
Impossible de mettre à jour un smart contract pour une évolution ou une correction sans devoir déployer un nouveau contrat et rediriger nos utilisateurs dessus.
Et dans la mesure où un smart contract contient également les données sur lesquelles il travaille, un autre problème se pose :
Comment récupérer les données d’un smart contract si nous déployons une nouvelle version ?
Nous allons étudier ici, en Solidity, 2 patterns qui nous permettent de rendre nos smart contracts évolutifs :
- Le pattern Proxy, qui va nous permettre d’exposer un smart contract proxy, qui jouera le rôle de façade vers le smart contract qui contiendra le code que nous voulons exécuter. La façade ne changera pas, mais nous pourrons déployer plusieurs versions de l’implémentation en “branchant” le proxy sur celle que nous voulons utiliser ;
- Le pattern Eternal Storage, qui va s’appuyer sur le déploiement d’un smart contract dédié au stockage des données, que va utiliser notre smart contract responsable de la logique métier. Quand nous déployons une nouvelle version de la logique métier, nous pouvons utiliser le même smart contract de stockage des données, et ainsi les conserver de version en version.
Fonctionnement de Eternal Storage
Nous allons simplement définir un smart contract qui va contenir une map pour chaque type de données à stocker. Il contiendra des getters et setters afin d’actualiser, ou récupérer, une donnée dans ces maps.
Le smart contract V1 stockera toutes ses données dans ce contrat. Un smart contract V2, avec le même mode de fonctionnement, pourra venir s’y connecter afin de travailler avec les mêmes données, de la même façon.
Chaque version du smart contract possédera donc une instance du même Eternal Storage qu’elle appellera pour lire ou écrire une donnée.
Fonctionnement de Proxy
Le Proxy va se positionner en façade de l’implémentation que l’on veut exécuter. Nous allons déployer différentes implémentations au fil du temps et brancher le proxy sur celle à utiliser.
Les utilisateurs enverront leurs transactions et appels vers lui et il exécutera le code de l’implémentation active du moment.
Attirons l’attention sur le fonctionnement du proxy. Il peut facilement nous piéger si nous ne prenons pas garde à certaines choses.
Voici le code utilisé pour router l’appel vers le contrat d’implémentation :
assembly { // (1) copie des données en entrée calldatacopy(0, 0, calldatasize()) // (2) appel du contrat d'implémentation let result := delegatecall(gas(), implementationAddress, 0, calldatasize(), 0, 0) // (3) récupération des données reçues returndatacopy(0, 0, returndatasize()) // (4) retour du résultat à l'appelant switch result case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } }
Nous allons utiliser de l’assembly
fin de pouvoir exécuter des commandes plus finement au niveau de la machine virtuelle Ethereum directement, grâce au langage YUL.
Le proxy se base sur l’instruction delegatecall
, qui va exécuter le code cible dans le contexte courant.
C’est-à-dire que le proxy va exécuter le code de l’implémentation dans son propre contexte. Le proxy porte la responsabilité du stockage des données.
En conséquence, si l’implémentation est amenée à modifier, par exemple, son slot mémoire N°2 (voir l’article sur la gestion de la mémoire), c’est le slot N°2 du proxy qui sera modifié, et non celui de l’implémentation.
Attention aux collisions mémoire !
En général, notre proxy possèdera en slot N°1 l’adresse de l’implémentation. Par conséquent, si l’implémentation modifie son slot N°1, le proxy écrasera cette adresse et il ne fonctionnera plus.
Il y a deux façons de gérer ce problème :
- De façon un peu brute, en ajustant la position des attributs entre le proxy et l’implémentation pour éviter les collisions. Par exemple, en créant dans le slot 1 de l’implémentation une donnée qui ne sera jamais utilisée, afin de ne pas écraser le slot 1 du proxy. C’est le plus facile et rapide à faire, mais ça deviendra assez vite difficile à gérer pour un grand nombre d’attributs, ou avec des évolutions de l’implémentation fréquentes, où il faudra bien faire attention d’ajouter les nouveaux attributs au bon endroit.
- En stockant les données de notre proxy à un endroit spécifique plus loin dans sa mémoire, afin d’en laisser tout le début disponible pour les résultats de l’exécution du code de l’implémentation. Cette façon de faire est définie par le standard ERC-1967.
Avec tout ça, nous allons devoir adapter un peu l’utilisation de Eternal Storage par rapport à ce que nous avons décrit précédemment. C’est bien le proxy qui va devoir porter la gestion du stockage commun et non l’implémentation.
Il existe plusieurs façons de gérer un Proxy ainsi que plusieurs standards pour le mettre en œuvre. On pourra, par exemple, gérer les mises à jour côté proxy ou côté implémentation. Nous allons rester simples dans nos exemples et l’exploiter de manière basique.
Dans notre exemple, nous allons nous contenter d’ajuster les positions des attributs pour éviter les collisions.
Architecture cible
Voilà pour la vision logique.
En réalité, notre schéma sera plutôt :
Mise en œuvre de notre exemple
Nous allons partir d’un contrat Hello
qui possède un attribut name
. Une méthode setName
le mettra à jour et une méthode getName
nous retournera la chaîne “Hello name”.
Nous allons déployer une première version, protégée par le Proxy
et qui exploite Eternal Storage
pour stocker name
. Puis, nous allons mettre à jour notre contrat avec une v2.
Nous verrons que grâce au Proxy
, les utilisateurs ne seront pas impactés par cette évolution. Et, grâce à Eternal Storage
, notre nouvelle version peut démarrer avec les mêmes données que la v1.
Les contrats de logique métier
Voici notre contrat SafeHelloV1
.
Nous lui définissons un attribut non utilisé dans le slot 1 afin de protéger le slot 1 du proxy, qui contient l’adresse de l’implémentation.
Nous allons positionner nos attributs sur le même modèle que ceux du proxy afin de ne pas créer de collisions en mémoire.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; import './GenericStorage.sol'; contract SafeHelloV1 { address useless1; GenericStorage private genericStorage; bytes32 private constant NAME_KEY = keccak256(abi.encodePacked("NAME")); event NameChanged(string newName); function setStorageAddress(address storageAddress) public { genericStorage = GenericStorage(storageAddress); } /* Business logic */ function setName(string memory newName) public { genericStorage.setString(NAME_KEY, newName); emit NameChanged(newName); } function getName() public view returns (string memory) { return string.concat("Hello ", genericStorage.getString(NAME_KEY)); } }
Notre contrat SafeHelloV2
sera identique à la V1
à l’exception de la méthode getName
qui rajoute quelque chose dans la valeur de retour afin de la différencier :
function getName() public view returns (string memory) { return string.concat("Hello V2 ", genericStorage.getString(NAME_KEY)); }
Le contrat Eternal Storage
Commençons par détailler notre implémentation d’Eternal storage
. Nous avons ici une version très simple qui ne gère que des données de type string
dans un mapping
.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; contract GenericStorage { // the storage mapping(bytes32 => string) internal stringStorage; function setString(bytes32 key, string memory value) external { stringStorage[key] = value; } function getString(bytes32 key) external view returns (string memory) { return stringStorage[key]; } }
Le contrat Proxy
Maintenant, notre contrat Proxy
.
Il va contenir l’adresse du contrat d’implémentation à utiliser ainsi qu’une instance de GenericStorage
définit ci-dessus.
Il possède les méthodes permettant de définir les contrats d’implémentation et de stockage. Il possède bien entendu une fonction qui exploite le delegatecall
, qui est appelée dans la fonction de fallback
qui est activée à chaque fois que l’on appelle un contrat sans méthode précise.
On définit aussi une constant NAME_KEY
qui sert à stocker la clé de mapping de la donnée que nous exploitons dans le contrat de stockage.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; import './GenericStorage.sol'; contract HelloProxy { address implementation; GenericStorage private genericStorage; bytes32 private constant NAME_KEY = keccak256(abi.encodePacked("NAME")); function setImplementation(address newImplementation) public { implementation = newImplementation; } function setStorageAddress(address storageAddress) public { genericStorage = GenericStorage(storageAddress); } function _call(address _implem) internal { require(_implem != address(0), "No implementation defined"); assembly { // (1) copy incoming call data calldatacopy(0, 0, calldatasize()) // (2) forward call to logic contract let result := delegatecall(gas(), _implem, 0, calldatasize(), 0, 0) // (3) retrieve return data returndatacopy(0, 0, returndatasize()) // (4) forward return data back to caller switch result case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } } fallback() external payable { _call(implementation); } }
Test
Nous pouvons maintenant tester tout ça. Voici un scénario qui peut être lancé dans Truffle. Chacun peut déployer les contrats présentés ci-dessus puis exécuter le scénario de tests suivant :
const { assert } = require("chai"); const HelloProxy = artifacts.require("HelloProxy"); const SafeHelloV1 = artifacts.require("SafeHelloV1"); const SafeHelloV2 = artifacts.require("SafeHelloV2"); const GenericStorage = artifacts.require("GenericStorage"); contract("HelloProxy", (accounts) => { it("Should be able to upgrade to V2 with same data", async () => { // Initialisation des contrats const helloProxyInstance = await HelloProxy.deployed(); const safeHelloV1Instance = await SafeHelloV1.deployed(); const safeHelloV2Instance = await SafeHelloV2.deployed(); const genericStorageInstance = await GenericStorage.deployed(); /* * Pour appeler un contrat via un proxy via les frameworks JS, il faut * instancier le contrat avec l'ABI de l'implémentation, mais à l'adresse du proxy. */ const safeHelloViaProxy = await SafeHelloV1.at(HelloProxy.address); // Initialisation de l'implémentation v1 et du storage await helloProxyInstance.setImplementation(SafeHelloV1.address); await helloProxyInstance.setStorageAddress(GenericStorage.address); // Test #1, vérification de la donnée initiale (vide) assert.equal(await safeHelloViaProxy.getName(), "Hello "); console.log("getName initial : "+ await safeHelloViaProxy.getName()); // Mise à jour du nom avec "foo" await safeHelloViaProxy.setName("foo"); // Test #2, vérification que le nouveau nom soit pris en compte assert.equal(await safeHelloViaProxy.getName(), "Hello foo"); console.log("getName avec 'foo' : "+ await safeHelloViaProxy.getName()); // Mise à jour vers la v2 await helloProxyInstance.setImplementation(SafeHelloV2.address); // Test #3, vérification que la V2 utilise les mêmes données que la v1 // (name = foo) mais avec les règles de la v2 ("Hello V2" au lieu de "Hello"). assert.equal(await safeHelloViaProxy.getName(), "Hello V2 foo"); console.log("getName avec 'foo' en v2 : "+ await safeHelloViaProxy.getName()); // Nouvelle mise à jour du nom avec "bar" await safeHelloViaProxy.setName("bar"); // Test #4, vérification que le nom soit aussi mis à jour en v2 assert.equal(await safeHelloViaProxy.getName(), "Hello V2 bar"); console.log("getName avec 'bar' en v2 : "+ await safeHelloViaProxy.getName()); }); })
Tous ces tests doivent se terminer avec succès.
Le résultat en console :
Contract: HelloProxy getName initial : Hello getName avec 'foo' : Hello foo getName avec 'foo' en v2 : Hello V2 foo getName avec 'bar' en v2 : Hello V2 bar
Nous constatons que le test se déroule avec succès.
Notre proxy permet donc de changer d’implémentation en conservant la même adresse d’appel. Et, les données sont passées d’une implémentation à l’autre via le contrat de stockage.
Mission accomplie !
Conclusion
Nous avons démontré comment utiliser simplement les patterns Proxy
et Eternal storage
pour rendre nos smart contracts Solidity évolutifs.
Les exemples peuvent être sécurisés en ajoutant l’implémentation du pattern Owner
afin de limiter les possibilités de mise à jour au seul administrateur de ces contrats.
Nous pouvons aussi implémenter le pattern Pausable
afin de désactiver l’exécution du contrat v1 quand v2 est déployé. En effet, même si le proxy ne l’appelle plus, il reste toujours déployé et utilisable pour ceux qui connaissent son adresse. Il faut donc pouvoir bloquer son utilisation.