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 !
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 :
Les inconvénients de cette approche sont les suivants :
init()
returns
(cf. paramètre returnSize
)Pour résoudre ce dernier point, une proposition d’évolution de l’EVM est en discussion (EIP-5).
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 :
init()
doSomething()
pour récupérer 1001 (version 1 du traitement)user1_changeToV2()
)changeVersion()
avec strictement les mêmes paramètres, mais via l’utilisateur 2 ( user2_changeToV2()
)doSomething()
pour récupérer 1102 (version 2 du traitement)doOtherthing()
pour confirmer qu’il est possible d’ajouter une nouvelle méthodeCette 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 :
Proxy
comme un ContractV1
ou ContractV2
0x40
pour identifier une zone mémoire disponible pour déléguer le traitementProxy
OCTO propose une solution générique, de quelques lignes, permettant de limiter au maximum les impacts de la mise à jour d’un contrat.