Blockchain : Modifier un contrat immuable

le 11/09/2017 par Philippe Prados
Tags: Software Engineering

Pourquoi et comment modifier un contrat immuable ? C'est ce que nous allons étudier.

Les différentes technologies de Blockchain (bitcoin, ethereum, etc.) consistent à produire un consensus, entre de nombreuses parties, sur un état stable. Pour Bitcoin, il s’agit de se mettre d’accord sur l’état d’un livre de compte ; pour Ethereum, de se mettre d’accord sur l’état d’un ordinateur virtuel mondial.

Ethereum a la prétention de permettre l’exécution de contrats numériques sous le contrôle du réseau distribué et non d’une entité centralisée. Des fonds (en monnaie électronique Ether) peuvent être sous la responsabilité du contrat, et le code décide ensuite leur distribution. Typiquement, le contrat peut servir de notaire, en gardant des fonds jusqu'à ce qu’une condition nécessaire soit validée (délai, preuve de livraison, etc.).

Chaque contrat peut posséder une quantité de crypto-monnaie appelée éther. Les ethers permettent, entre autre, de financer la communauté pour l’exécution de l’ordinateur mondial.

Le principe de base d’un contrat Ethereum est d’avoir un code immuable, impossible à modifier ou à supprimer. Si le développeur ne l’a pas prévu, il n’est pas possible d'arrêter un contrat qui s'exécute dès lors qu’il est sollicité.

En effet, lorsqu’un contrat est déposé dans la chaîne de blocs, il y est pour toujours. Les instructions qui le compose peuvent être invoquées à tout moment, entraînant éventuellement une modification d’état du contrat, d’autres contrats liés, voire le transfert des Ethers associés au contrat vers un autre compte ou un autre contrat.

  • Un contrat déposé dans la blockchain ne peut pas être supprimé
  • Il peut être invoqué par n’importe quel utilisateur ou autre contrat
  • Le code d’un contrat ne peut pas évoluer
  • Seules ses données peuvent évoluer

Modifier l’état d’un contrat, c’est écrire les différences dans le prochain bloc.

Cette immutabilité est une excellente chose, car elle permet de garantir qu’aucune des parties prenantes du contrat ne pourra revenir sur ses engagements. C’est un élément essentiel de la sécurité d’Ethereum et cela contribue à sa valeur.

Si j’accepte de signer un contrat où je m’engage à rembourser telle somme en Ether si une condition ne s’est pas produite à telle date, il m’est impossible de répudier cela. Lorsque le délai sera passé, l’autre partie pourra déclencher le versement des Ethers présents en caution dans le contrat.

Personne, même pas un pirate, ne pourra apporter des modifications au contrat. A tel point que si le contrat est mal écrit, des Ethers peuvent devenir inaccessibles.

1 Amender un contrat ?

Un contrat est immuable, mais parfois, on aimerait bien pouvoir le modifier, avec l’accord de toutes les parties. On aimerait pouvoir publier une nouvelle version du contrat.

Par exemple, il devrait être possible d’ajouter un avenant à un contrat. Ou de le modifier, car ce qui était permis ne l’est plus, des paramètres extérieurs ont évolués, de nouvelles règles sont imposées par la loi, etc.

Du point de vue des scénarios d’usage, on peut imaginer la situation suivante : deux personnes signent un contrat numérique qui est régi par la loi. La loi évolue, remettant en cause le contrat : il faut donc le modifier, avec l’accord des deux personnes.

Il est également possible d’ajouter un utilisateur représentant une puissance étatique : l’État, une instance territoriale, une institution publique, etc.

Le contrat est alors construit avec trois propriétaires : les deux personnes et, par exemple, l’État. Il est paramétré pour que deux propriétaires seulement soient nécessaires pour modifier le contrat.

Avec un scénario d’habilitation “deux sur trois”, deux partenaires parmis les trois peuvent se mettre d’accord pour modifier le contrat. Dans les faits, soit les deux personnes trouvent un nouveau terrain d’entente, soit l’une d’elles, avec l’accord de l’état, peut modifier le contrat.

Modèle d'exécution du contrat

Nous souhaitons donc pouvoir apporter toutes les modifications possibles à un contrat. Nous vous proposons ci-dessous l’approche retenue par OCTO, et permettant de minimiser les impacts sur le reste de l’écosystème :

  • Un autre contrat ou une application externe à la blockchain ne doit pas être modifiée lors de la publication d’une nouvelle version du contrat
  • Il ne faudrait pas avoir à transférer l’état d’une version d’un contrat vers la suivante, car cela peut consommer plus d’ether que le maximum autorisé par l’utilisateur du contrat et peut compromettre la sécurité, en ouvrant une brèche permettant le transfert des ethers appartenants au contrat :
    • La nouvelle version doit avoir accès à tous les attributs du contrat d’origine.
    • Il faut également pouvoir transférer les fonds de la version 1 vers la version 2. Mais le contrat version 1 ne peut avoir connaissance de la version 2.
  • Il doit être possible d’ajouter de nouveaux attributs et de nouvelles méthodes dans la nouvelle version du contrat : La nouvelle version du contrat ne doit pas avoir de contraintes
  • Les événements publiés par les différentes versions doivent être émis par un seul et unique contrat
  • Les applications externes à l’écoute du contrat (JavaScript ou autres) ne doivent pas être modifiées suite à un changement de version
  • Le solde d’Ethers porté par le contrat une version doit être disponible pour la nouvelle version

Pour cela, inspiré des travaux de Martin Swende, avec le langage Solidity, nous allons utiliser un contrat Proxy qui hérite d’un contrat Versionable. La première version du contrat devra également hériter de Versionable.

Le Proxy va exploiter la fonction fallback de Solidity, sans nom ni paramètre, qui est invoquée lorsqu’aucune méthode présente dans le contrat ne correspond. Le code de cette méthode va déléguer les data de la transaction au contrat référencé par le Proxy, mais en demandant à la machine virtuelle Ethereum (EVM) d’utiliser le contexte du contrat Proxy pour stocker les attributs (pour les détails techniques, référez-vous à cet article).

Afin de posséder l’intégralité des attributs de la première version, ContractV2 hérite de ContractV1. Cette nouvelle version peut alors ajouter de nouveaux attributs et proposer d’autres implémentations des méthodes. Il est également possible d’ajouter de nouvelles méthodes.

Avec cette approche, seul le code des versions du contrat est utilisé par le Proxy. Les données entre les versions sont mutualisées dans le Proxy. Ainsi, il n’est pas nécessaire de migrer les données du contrat V1 vers le contrat V2. Elles sont déjà présentes !

Entrons dans la technique

Pour implémenter cela, il faut écrire un peu d’assembleur EVM.

contract Versionable {
  event VersionChanged(Versionable version);

  /** The current version. */
  Versionable internal currentVersion;
  }

  contract Proxy is Versionable {

  /**
   * Create a proxy to delegate call to the current version of contract.
   * @param _currentVersion The first version to use.
   */
  function Proxy(Versionable _currentVersion) {
    currentVersion = _currentVersion;
  }

  /**
   * Change the current version.
   * @param _newVersion The new version.
   */
  function changeVersion(Versionable _newVersion) { // TODO: Add privilege
    currentVersion.kill(this);
    currentVersion = _newVersion;
    VersionChanged(_newVersion);
  }

  /**
   * Propagate the current call to another contract.
   * Use TARGET code with THIS storage, but also keep caller and callvalue.
   * Invoke delegateCall().
   * In order for this to work with callcode,
   * the data-members needs to be identical to the target contract.
   *
   * @param target The target contract.
   * @param returnSize The maximum return size of all methods invoked.
   */
  function propagateDelegateCall(address target, int returnSize) internal {
    assembly {
      let brk := mload(0x40) // Special solidity slot with top memory
      calldatacopy(brk, 0, calldatasize) // Copy data to memory at offset brk
      let retval := delegatecall(sub(gas,150)
        ,target //address
        ,brk // memory in
        ,calldatasize // input size
        ,brk // reuse mem
        ,returnSize) // arbitrary return size
      // 0 == it threw, by jumping to bad destination (00)
      jumpi(0x00, iszero(retval)) // Throw (access invalid code)
      return(brk, returnSize) // Return returnSize from memory to the caller
    }
  }

  function () payable {
    /* 32 is the maximum return size for all methods in all versions. */
    propagateDelegateCall(currentVersion,32);
  }
}

/**
 * The version 1 of the contract.
 * The attr is initialized to 1000.
 * The method doSomething() return attr + version = 1001
 */
contract ContractV1 is Versionable {
  uint constant private version=1;
  uint public attr;

  /** return attr+version (1001). */
  function doSomething() external
    returns(uint) {
   return attr+version; // 1001
  }

  /** Post-construction. */
  function init() {
    attr=1000;
    isInit=true;
  }
}

/**
 * The version 2 of the contract.
 * To preserve all the attributs from the v1 version, this version IS a ContractV1.
 * All methods can be rewrite, new one can be added and
 * some attributs can be added.
 *
 * The newAttr is initialized to 100.
 * The method doSomething() return attr + newAtttr + version = 1102
 */
contract ContractV2 is ContractV1 {
  uint constant private version=2;
  uint public newAttr;

  /** return attr + newAttr + version (1102). */
  function doSomething() external returns(uint) {
    return attr + newAttr + 2; // 1102
  }

  /** return 42. Another method in version 2. */
  function doOtherThing() external returns(uint) {
    return 42;
  }

  /** Post-construction. */
  function init() {
    newAttr = 100;
    isInit = true;
  }
}

Nous avons négligé les règles de sécurité pour simplifier le code. Il va sans dire que la méthode changeVersion() doit être protégée contre une utilisation par n’importe qui. Sinon, n’importe qui pourrait modifier un contrat.

L’intégralité des sources est disponible ici.

Le proxy ne possède pas de méthode, mais va gérer les attributs du contrat. Les méthodes sont présentes dans les versions du contrat. Si on inspecte l’instance ContractV1, aucun attribut n’est présent. De même pour ContractV2.

Le deuxième effet kisscool de ce modèle est que les events viennent du Proxy et non des contrats. Donc tant que le format des événements n’est pas modifié, les applications à l’écoute du contrat n’ont pas à savoir qu’il a été modifié. Elles écoutent toujours les mêmes événements, venant du même contrat.

Pour le développeur, il teste et conçoit les contrats comme d’habitude. C’est lors du déploiement que la localisation des attributs change.

Il reste à gérer les constructeurs des contrats. En effet, construire une instance est un traitement spécial. Il est envoyé au contrat de numéro zéro de la blockchain. Le code du constructeur n’est pas disponible avec le contrat. Il n’est donc pas possible de le réutiliser pour initialiser le proxy.

Nous devons alors utiliser une méthode init() qui jouera le rôle de constructeur. Il ne faut pas oublier de l’invoquer juste après la création de l’instance du contrat v1 et du contrat v2.

Comment utiliser ce code ? Il suffit de construire une instance du ContractV1, de l’encapsuler dans un Proxy et de caster (transtyper) le proxy en ContractV1.

ContractV1 myContract = ContractV1(new Proxy(new ContractV1()));

Ensuite, il ne faut pas oublier d’invoquer l’initialisation du contrat, en passant bien par le proxy.

myContract.init();

Pour utiliser le contrat, c’est comme d’habitude.

myContract.doSomething();

Et voilà. Pour modifier la version, on recast le contrat en Proxy, afin d’avoir accès à la méthode changeVersion().

Proxy(myContract).changeVersion(new ContractV2());

L’invocation de la nouvelle version est identique et s’effectue toujours avec myContract.

myContract.doSomething();

Ce dernier modèle répond à toutes les exigences :

  • La référence du contrat n’évolue pas, même en cas de changement d’implémentation
  • Il est possible d’ajouter de nouvelles méthodes ou de nouveaux attributs dans une nouvelle version du contrat
  • Il n’est pas nécessaire de migrer les données entre les versions
  • Les événements émis par le code des contrats V1 ou V2 viennent bien du proxy

Les inconvénients de cette approche sont les suivants :

  • Il n’est pas possible de supprimer un attribut dans la définition de la classe
  • Il est nécessaire de séparer le constructeur de la méthode init()
  • Cela présente un surcoût de 735 gaz pour chaque invocation (soit 0,00000252 € avec un Ether à 120 €).
  • Il faut connaître la taille maximum acceptable des returns (cf. paramètre returnSize)

Pour résoudre ce dernier point, une proposition d’évolution de l’EVM est en discussion (EIP-5).

2 Et la sécurité ?

Pour autoriser la modification de la version du contrat, il faut, par exemple, que n owners parmi m soient d’accord sur la nouvelle implémentation du contrat (n pouvant être égal à m). Lorsqu’une méthode sensible est invoquée, elle n’est pas exécutée directement tant qu’un autre owner n’invoque pas la même méthode avec strictement les mêmes paramètres. Lorsque suffisamment d’owners sont d’accords pour modifier le contrat, alors la version est modifiée.

OCTO propose les contrats MultiOwned, Versionable et Proxy que vous pouvez utiliser pour tous vos nouveaux contrats.

Vous retrouverez ici la version protégée du Proxy, permettant à plusieurs owners de se mettre d’accord sur la nouvelle version du contrat. La liste des owners est à spécifier lors de la création du Proxy et peut ensuite être modifiée avec l’accord de tous.

Tous les sources sont disponibles ici.

Nous proposons un exemple d’utilisation de ce modèle avec les sources. Pour le tester, il faut :

  • Créer une instance du test unitaire
  • Invoquer init()
  • Invoquer doSomething() pour récupérer 1001 (version 1 du traitement)
  • Demander le changement de version via l’utilisateur 1 ( user1_changeToV2() )
  • Confirmer la demande de changement en invoquant de même changeVersion() avec strictement les mêmes paramètres, mais via l’utilisateur 2 ( user2_changeToV2() )
  • Invoquer doSomething() pour récupérer 1102 (version 2 du traitement)
  • et enfin doOtherthing() pour confirmer qu’il est possible d’ajouter une nouvelle méthode

3 Dans le détail du code assembleur

Cette implémentation se base sur quelques subtilités de l’Ethereum Virtual Machine et de Solidity. Si vous souhaitez plus de détails techniques, nous vous invitons à regarder cet autre article de blog.

L’invocation d’une méthode est intégralement décrite dans le paramètre data d’une transaction. Il est donc possible de proposer la même invocation à un autre contrat. Pour cela, nous devons utiliser l’instruction delegatecall. Cette dernière nécessite d’indiquer une zone en mémoire avec les paramètres de l’invocation, et une autre zone en mémoire pour récupérer le résultat de l’invocation. Pour utiliser une zone mémoire disponible, nous commençons par récupérer la valeur à l’index 0x40, utilisé par Solidity. Elle indique la dernière adresse mémoire utilisée par le programme. Nous utilisons alors cette zone vierge pour y répliquer les données de msg.data. Nous indiquons la même zone mémoire pour récupérer le résultat de l’invocation. En cas d’erreur, nous lançons un jump vers une zone de code invalide. Cela est l’équivalent à un throw sous Solidity. Enfin, nous retournons la zone mémoire valorisée par l’invocation du delegateCall.

Nous plaçons cette méthode spéciale dans la méthode de repli de Solidity, afin que toutes les méthodes absentes du contrat Proxy soient déléguées à l’instance portant la version du contrat. Toutes les modifications des attributs s’effectuent sur l’instance Proxy.

Nous vous proposons une solution générique. Elle utilise les spécificités de la machine virtuelle et des choix d’implémentations de Solidity :

  • Utiliser le fait qu’un Cast est possible vers n’importe quelle adresse de contrat. Cela permet de faire passer le Proxy comme un ContractV1 ou ContractV2
  • Utiliser la méthode par défaut, lorsqu’une méthode n’est pas identifiée par le contrat
  • Utiliser l’attribut à l’adresse 0x40 pour identifier une zone mémoire disponible pour déléguer le traitement
  • Utiliser l’assembleur pour invoquer une méthode d’un autre contrat, et récupérer la valeur de retour avant de la propager à l’appelant
  • Utiliser la délégation pour que les événements des différentes implémentations viennent bien du Proxy

OCTO propose une solution générique, de quelques lignes, permettant de limiter au maximum les impacts de la mise à jour d’un contrat.