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.