Menu

english flag

Ethereum Virtual Machine

Ethereum Virtual Machine

Ethereum Virtual Machine (EVM) est une machine virtuelle qui permet de gérer des transactions dans la blockchain Ethereum par le biais de smarts contracts. C’est un composant essentiel au fonctionnement de Ethereum que nous allons tenter de comprendre ensemble.

EVM

Pour exécuter des smart contracts (des programmes dans le monde Ethereum), des règles doivent être suivies. Ces règles sont en partie décrites dans le Yellow Paper de Ethereum, et peuvent être implémentées par n’importe qui dans n’importe quel langage. Il existe ainsi une version python de EVM (py-evm), une version Rust (revm), ou encore une version Go (go-evm). Cette liste n’est évidemment pas exhaustive.

Opcodes

Un des éléments essentiels de l’EVM (comme tout ordinateur, en soit) est de pouvoir lire et exécuter des instructions, ou opcodes. Les instructions Ethereum sont décrites dans le site officiel de Ethereum, Opcodes for the EVM. Le site evm.codes est également très bien fait.

C’est ce type de code qui est compris par l’EVM. Il est généré lorsqu’un langage haut niveau est compilé. L’un des langages les plus utilisés pour écrire des smart contracts est Solidity.

Voici un exemple très simple de smart contract écrit avec Solidity.

// SPDX-License-Identifier: GPL-3.0

pragma solidity 0.8.18;

contract HackndoMembers {
    // Déclaration de variables persistantes dans la blockchain
    address public owner;

    address[] public members;
    uint private memberCount;

    // Constructeur, exécuté lors du déploiement du smart contract
    constructor() {
        owner = msg.sender;
    }

    // Fonction exposée publiquement pour s'ajouter en tant que membre
    function becomeMember() external {
        members.push(msg.sender);
        memberCount++;
    }

    // Fonction exposée publiquement permettant de trouver un membre
    function getMember(uint _id) external view returns(address member) {
        require(_id < memberCount, "id too big");
        require(members[_id] != 0x00, "Not a member");

        member = members[_id];
    }

    // Fonction uniquement accessible au créateur du smart contract pour supprimer un membre
    function removeMember(uint _id) external {
        require(msg.sender == owner, "Owner only");
        members[_id] = address(0x0);
    }
}

Une fois compilé, ce programme sera une suite d’instructions compris par l’EVM. L’outil solc permet de compiler du Solidity.

$ solc contract.sol --bin        

======= contract.sol:HackndoMembers =======
Binary:
608060405234801561001057600080fd5b5033600080610100[...]

Il permet d’ailleurs de voir les instructions générées.

$ solc contract.sol --opcodes

======= contract.sol:HackndoMembers =======
Opcodes:
PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH2 [...]

Parmi ces instructions, certaines permettent d’effectuer des opérations mathématiques, comme add, sub, mul, ou encore div par exemple. D’autres permettent de comparer des éléments comme lt (Lower Than), gt (Greater Than) ou eq.

Il est possible de lire et d’écrire dans différentes zones de stockage, telles que la memory avec mLoad, mStore, ou le storage avec sLoad, sStore par exemple.

La gestion de la stack (autre zone mémoire) est effectuée avec des opcodes tels que push1, push2, …, push32, et pop.

Ces différents types de stockages seront abordés plus tard dans cet article.

Un contrat peut faire des appels à d’autres fonctions, potentiellement d’autres contrats, via call, staticCall et delegateCall.

Enfin, l’instruction revert permet d’effectuer une sorte d’exception qui met fin à l’appel en cours. Dans la plupart des cas, la transaction sera considérée comme invalide, et aucun changement ne sera effectué.

Ces différents exemples sont loin d’être exhaustifs, mais ils donnent une idée sur ce que l’EVM doit traiter lorsqu’un smart contract est exécuté.

Gas

Chaque instruction exécutée sur les noeuds du réseau a un prix, dont l’unité est le gas. A titre d’exemple, exécuter un add coûte 3 gas, un pop n’en coûte que 2.

Lors de l’appel à une fonction d’un smart contract, un utilisateur doit payer le prix nécessaire à l’exécution des instructions. Il doit donc fournir suffisamment de gas lors de sa transaction. S’il en a trop fourni, ce n’est pas grave, le surplus lui sera remboursé.

S’il n’en a pas fourni assez, en revanche, les instructions vont être exécutées jusqu’à ce que les ressources en gas s’épuisent. Lorsque c’est le cas, la transactions est annulée, et le gas fourni par l’utilisateur est perdu. En effet, bien que la transaction soit annulée, il a quand même fallut des ressources pour s’en rendre compte, c’est donc trop tard.

Cette notion de gas a été introduite pour éviter que des ressources soient utilisées inutilement, notamment pour éviter des boucles infinies ou des attaques qui encombreraient le réseau. Il existe d’ailleurs un maximum de gas possible dans un même bloc (actuellement 30 millions de gas).

Solidity

Pour la suite de cet article, ayez en tête que l’EVM, finalement, ne fait qu’exécuter des opcodes, les uns après les autres. Elle offre également différents espaces de stockage vides qui peuvent être utilisés, et c’est tout. Comment ces opcodes sont organisés ou comment les données sont structurées, c’est au rôle du compilateur de gérer tout ça.

Ce que nous allons voir dans cet article concerne le compilateur (et le langage) Solidity. Les compilateurs des autres langages se sont souvent référés à Solidity et reproduisent les même conventions, mais ce n’est pas toujours le cas.

Variables globales

Lorsqu’un smart contract est écrit avec Solidity, il existe trois variables globales, accessible au smart contract, qui lui permettent d’avoir des informations sur le contexte dans lequel il est exécuté :

  • Block (block) : Cette variable contient des informations sur le bloc dans lequel a été validé la transaction. On trouvera par exemple le numéro du bloc, le moment où il a été ajouté à la blockchain, ou encore son hash.
  • Transaction (tx) : Des informations relatives à la transaction en cours sont disponibles dans cette variable. C’est ici qu’on saura par exemple qui est à l’origine de la transaction (et non pas à l’origine du dernier message), donc ce sera toujours un EOA.
  • Message (msg) : Plusieurs messages peuvent être envoyés au sein d’une transaction. Dans ces messages, on peut savoir qui a envoyé le message, combien d’Ether ont été fournis, les données jointes au message, etc. En fonction du contexte et du message, la variable msg peut évoluer. Par exemple, quand un contrat appelle un autre contrat, l’attribut msg.sender sera modifié.

Stockage

Le code du smart contract (composé des instructions telles que celles que nous avons introduites) doit être stocké quelque part, tout comme les variables du contrat, ou d’autres données temporaires ou non, nécessaires à sa bonne exécution. Pour cela, l’EVM dispose de différents types de stockages, permanents ou non, pour différents objectifs.

EVM Storage

Stockage permanent

Il existe deux types de stockages permanents. Ce sont les endroits dans lesquels des informations sont stockées par les noeuds, et persistants lors de l’exécution de transactions. Ainsi, quand une transaction est terminée, ce stockage sera enregistré, et pourra être utilisé lors de la prochaine transaction. Pratique !

Bytecode

Le code du smart contract est stocké de manière permanente, mais ne peut pas être modifié. C’est du read-only. Si un problème est détecté dans le code du smart contract après son déploiement, c’est trop tard. Il faut déployer un nouveau smart contract avec sa correction, et prévenir les utilisateurs que l’adresse du smart contract a changé.

Il existe des moyens de gérer ce problème avec des smart contracts qui prennent le rôle de proxy, mais ce n’est pas le sujet, et ces contrats peuvent également posséder des bugs.

Account storage

Le lieu de stockage persistant pour les smart contract, c’est l’account storage. C’est un peu le disque dur d’un ordinateur. Nous en avons parlé dans l’article sur Ethereum. Dans le world state (l’état global de Ethereum), à chaque adresse sont associés différents éléments, comme le solde d’Ether du compte, mais également, dans le cas des smart contracts, un “espace de stockage” propre au smart contract.

Concrètement, le storage est une base de données clé/valeur. La clé est une valeur de 256 bits, et de même pour la valeur. On peut alors stocker 2**256 clés, largement de quoi faire, normalement. Pour bien comprendre, on peut également considérer ce stockage comme un tableau de 2**256 lignes, et à chaque ligne on peut y assigner une valeur.

Avant que quoique ce soit ne soit exécuté, ce tableau est vide, ce ne sont que des zéros. Donc chaque contrat possède, par défaut, un tableau de 2**256 lignes, et à chaque ligne il y a 2**256 bits à zéro.

Account Storage

Généralement, les premiers slots d’un contrat Solidity contiennent les variables d’état (state variables) du contrat.

Prenons l’exemple suivant :

contract Hackndo {
    /**
     * Variables d'état
     */
    uint256 id = 7; 
    uint256 totalAmount = 1000;

    /**
     * Code du contrat
     */

    constructor() {
        // Code
    }

    function myFunction() external {
        // Code
    }
}

Suite à la création du contrat, le account storage contiendra les clés valeurs suivantes :

Account Storage Updated

Pour parler de clé, la notion de slot est souvent utilisée. Ainsi, dans l’exemple suivant, le slot 0 est celui de la variable id et le slot 1 est associé à la variable totalAmount

Optimisation

Les variables déclarées étaient des uint256, donc 256 bits, ce qui prenait un slot entier, mais si des variables plus petites sont utilisées, le storage sera optimisé par le compilateur de Solidity. Si deux variables rentrent dans un slot, alors elles seront mises dans ce même slot. Nous verrons cela en détails dans un autre article.

Autres formats

Dans cette zone de stockage, on peut enregistrer des entiers, mais aussi des chaines de caractères, des tableaux, des mappings, etc. Chaque type de variable a ses règles de stockage gérées par le compilateur de Solidity pour pouvoir les retrouver. En voici un résumé rapide :

Lorsqu’un tableau est stocké, la taille du tableau est stockée à un certain index qui suit la règle précédente. Pour trouver l’élément N du tableau, il faut alors calculer keccak256(abi.encode(arrayIndex))+N.

keccak256 est une fonction de hash (ancienne version de SHA3). abi.encode permet d’encoder des informations afin de transformer des structures de données potentiellement complexes (comme des tableaux) en une suite d’octets, ce qui permet alors à une fonction de hash de fonctionnement correctement.

Pour un mapping (une association clé-valeur), un slot est réservé pour déterminer son index de base (mais rien n’est stocké à cet endroit, contrairement aux tableaux pour lesquels la taille est stockée), puis pour déterminer où se trouve une valeur du mapping, la fonction keccak256(abi.encode(key, mappingIndex)) doit être appliquée. Elle retourne l’index auquel se trouve la valeur de key.

Les chaines de caractères de moins de 32 octets sont enregistrées dans un slot. Les bits de poids fort sont utilisés pour stocker la chaine, et ceux de poids faible pour indiquer la longueur de la chaine. Si elle fait 32 octets ou plus, alors le même mécanisme que les tableaux s’applique.

Enfin, les variables dans une structure sont stockées les unes à la suite des autres, comme si c’était des variables indépendantes. Si, dans la structure, il y a des types dynamiques (tableau, mapping etc.), alors les règles qu’on a vues s’appliquent.

Stockage volatile

La mémoire volatile, c’est cette mémoire qui, une fois l’exécution du contrat terminée, est effacée, il n’en reste aucune trace. On pourrait comparer cette mémoire avec la mémoire vive (RAM) d’un ordinateur, en quelque sort.

Stack

La pile, ou la stack, est une zone mémoire qui a un fonctionnement LIFO (Last In, First Out).

Cela veut dire que le dernier élément qui est placé sur la pile sera le premier élément à être dépilé. Pour mieux comprendre, on peut imaginer une pile d’assiette. Si on empile des assiettes les unes sur les autres, il faudra enlever la dernière assiette posée, puis l’avant-dernière etc. pour pouvoir récupérer la première assiette posée. C’est le même principe. (Oui, c’est la même explication qu’ici, et alors.)

Cette zone mémoire est utilisée par le compilateur pour y stocker des informations temporaires, comme les variables locales d’une fonction, ou les arguments d’instructions par exemple. Typiquement, tous les smart contracts compilés avec Solidity commencent par ces 3 instructions pour stocker la valeur 0x80 à l’adresse mémoire 0x40.

PUSH1 0x80  // destination
PUSH1 0x40  // valeur
MSTORE      // mstore(destination, valeur)

Les arguments de la fonction mstore sont poussés sur la pile, dans le sens inverse de leur utilisation. En effet, le premier élément qui sera dépilé sera le dernier élément poussé. On pousse donc d’abord la valeur 0x80 puis la destination 0x40. Lors de l’exécution de mstore, 0x40 (la destination) sera dépilée, puis 0x80 (la valeur).

C’est une zone mémoire qui bouge énormément au fil de l’exécution d’un programme. On peut y stocker jusqu’à 1024 éléments de 256 bits (32 octets).

Attention, seuls les 16 premiers éléments de la stack peuvent être utilisés pour effectuer des opérations, appeler des fonctions, etc. Cela veut dire, par exemple, qu’une fonction ne peut pas avoir plus de 16 arguments, ou plus de 16 variables locales.

Stack

Memory

La memory d’un smart contract est une grande zone mémoire accessible en lecture et écriture sans ordre prédéfini comme la stack. On peut y stocker toute taille d’information, à partir d’un octet, jusqu’à 32 octets. En revanche on ne peut lire des informations que par 256 bits (32 octets). On trouvera ici les variables avec des tailles dynamiques, comme les tableaux ou les mappings par exemple, mais on peut tout à fait y stocker des entiers, ou des booléens.

L’adressage se fait sur 32 octets, ou 256 bits. Donc on peut théoriquement stocker jusqu’à 2**256 bits d’information. En pratique, ça permet surtout d’éviter des collisions lorsqu’on stocke des données de taille dyamique. On utilisera le hash de certains éléments pour décider de la destination de stockage. Avant que deux hash dans un espace de 2**256 soient proches, on a le temps de gagner quelques fois au loto !

Memory

Espaces réservés

es deux premiers octets (aux adresses 0x00 et 0x20) servent au compilateur pour faire des calculs ou opérations temporaires.

Le troisième emplacement (0x40) contient un pointeur vers la prochaine zone mémoire libre, utilisable. C’est le free memory pointer.

Free memory pointer

C’est d’ailleurs ce pointeur qui est initialisé au début de chaque contrat compilé avec Solidity. On l’a vu plus tôt dans cet article. Les opérations suivantes enregistrent 0x80 à l’adresse 0x40.

PUSH1 0x80
PUSH1 0x40
MSTORE

Donc la prochaine zone utilisable pour allouer de la mémoire, c’est l’adresse 0x80. Et pourquoi pas l’adresse 0x60 ? Tout simplement parce que cette adresse est également spéciale, elle vaut toujours 0. Elle peut être copiée pour initialiser un tableau par exemple.

Null data

Stockage des données

Les formats simples comme les entiers sont simplement stockés à l’adresse qui leur est assignée.

Pour les chaines de caractères, lorsqu’on assigne une adresse pour les stocker, la longueur de la chaine est stockée dans les 256 bits commençant à cette adresse, puis la chaîne est stockée.

Pour les tableaux, un espace correspondant au nombre d’éléments est réservé, et les éléments du tableaux sont ajoutés les uns à la suite des autres.

Une structure est organisée de la même manière qu’un tableau.

Memory string array

Calldata

Lors d’un appel à une fonction d’un smart contract, cet appel doit être créé par le client avant même d’avoir envoyé la transaction, donc avant même que l’EVM soit instanciée quelque part. Les paramètres de la fonction ne peuvent donc pas être dans une stack ou en mémoire de l’EVM.

La fonction, et ses arguments, sont envoyés dans le champ data de la transaction, comme nous l’avons brièvement vu dans l’article sur Ethereum. Lorsque le contract va effectivement être instancié et exécuté dans la machine virtuelle de Ethereum, ce qui a été envoyé dans data va être copié dans la zone mémoire appelée calldata.

Cette zone mémoire, calldata est utilisée lors de l’appel d’une fonction par un client Ethereum, mais pas uniquement. Elle l’est à chaque fois qu’un message est envoyé, que ce soit d’un EOA vers un contrat, ou d’un contrat vers un contrat.

D’un point de vue mémoire, calldata est très similaire à la memory.

  • Elle est linéaire
  • L’adressage se fait à l’octet
  • On ne peut lire que 32 octets par appel

En revanche, contrairement à la memory, cette zone mémoire est en lecture seule. On ne peut pas écrire dans cette zone mémoire. C’est l’EVM qui se charge de copier les paramètres qu’a envoyés la source du message.

Sélecteur de fonction

Les 4 premiers octets sont réservés au sélecteur de la fonction. Je rappelle ce qui a été expliqué dans l’article sur Ethereum, le sélecteur de la fonction est calculé en hashant la signature de la fonction, et en ne retenant que les 4 premiers octets.

Par exemple, imaginons la fonction suivante :

function getItemValue(string calldata _itemName, uint256 _itemId) public returns(uint256 value) {
  // Code de la fonction
}

La signature de la fonction est :

getItemValue(string,uint256)

Et le sélecteur :

bytes4(keccak256("getItemValue(string,uint256)"));
// Output:
0xc2e58fec

La suite de cette zone mémoire est dédiée aux arguments de la fonction.

Stockage des arguments

Les formats simples comme les entiers sont stockés tels quels.

Pour les chaines de caractères, on trouve l’offset de là où elle se trouve vraiment. Cet offset permet de trouver la chaine, en commençant par sa taille (sur 256 bits) puis les caractères de la chaine.

Pour les tableaux, de même on trouve l’offset de là où se trouve le tableau. A cet offset seront ensuite mis les différents éléments du tableau.

Une structure est organisée de la même manière qu’un tableau.

Prenons le même exemple quand dans l’article précédent :

getItemValue("pixis", 8);

Le contenu de calldata sera :

0xc2e58fec0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000057069786973000000000000000000000000000000000000000000000000000000

Ce qui peut être découpé de la manière suivante :

Calldata

PC - Program Counter

Pour information, il existe aussi une zone mémoire appelée le Program Counter ou PC. Pour ceux qui connaissent le monde Intel, c’est l’équivalent de “EIP” (ou “RIP”). C’est une zone mémoire dans laquelle il y a l’adresse de la prochaine instruction à exécuter. Ca permet donc à la machine virtuelle de savoir où elle en est. Souvent, cette adresse augmente petit à petit, et parfois, lorsqu’il y a un saut (jump), la destination du jump est assignée au PC, ce qui fera que la prochaine instruction exécutée sera la destination du jump.

Gas

Enfin, l’EVM maintient à jour le nombre de gas consommés, afin de vérifier que le gas fourni par l’utilisateur lors de l’appel de la fonction est suffisant.

Calls

Après avoir étudié les différentes zones mémoires qui permettent à l’EVM de fonctionner, nous terminerons en parlant des différents types d’appels qui permettent de demander à un smart contract d’exécuter du code. Ces appels, ou calls, permettent d’exécuter une fonction d’un smart contract, avec des arguments si nécessaire.

Chaque type de call a ses spécificités. Pour bien comprendre de quoi il en retourne, il faut d’abord expliquer qu’un contrat s’exécute dans un certain contexte. Parfois, lorsqu’une fonction est appelée, une nouvelle instance d’EVM est déployée pour exécuter le code de la fonction. Parfois, les zones mémoires sont différentes, parfois partagées. Les informations globales (comme la source du message) peuvent également varier ou non, selon le type d’appel.

Nous ferons un tableau récapitulatif suite aux détails des différents appels.

Calls internes

Le plus simple, ce sont les appels internes. C’est ce qu’il se passe quand un smart contract fait appel à une de ses propres fonctions, ou à une fonction d’un contract dont il hérite. En terme d’opcode, quand un appel interne est effectué, c’est un saut (jump) qui va être exécuté. Il n’y a aucun changement de contexte, on reste dans le même contrat, dans la même instance de machine virtuelle. La fonction appelée partage les mêmes informations, les mêmes zones de stockage que la fonction appelante.

Voici deux exemples d’appels internes, l’un pour une fonction du même contrat (functionA()) et l’autre qui appelle une fonction d’un contrat parent (functionParent()).

contract Parent {
    function functionParent() internal pure {
    }
}

contract Child is Parent {
    function functionA() internal pure {

    }

    function functionB() external pure {
        // Appel interne à une fonction du même contrat
        functionA();
    }

    function functionChild() external pure {
        // Appel interne à une fonction du contrat parent
        functionParent();
    }

Le contenu de functionA() aurait pu être mis dans functionB(), ça n’aurait pas changé grand chose.

Calls externes

Les calls externes sont plus intéressants. Ils permettent d’appeler les fonctions d’autres contrats. Il existe 3 types d’appels externes différents.

En réalité, il en existe un 4ème, callcode, mais il a été déprécié en faveur de delegatecall donc nous n’en parlerons pas ici.

call

Le call est l’appel de base. Il permet d’appeler une fonction d’un autre contrat. Cette fonction sera exécutée dans une nouvelle instance d’EVM, avec ses propres zones mémoires (stack, memory, …). Le code appelé peut alors faire ce qu’il souhaite, modifier sa propre mémoire, mettre à jour ses variables, etc. Comprenez cependant que les variables du contrat appelé sont complètement indépendantes des variables du contrat appelant. Chacun chez soi, et les moutons seront bien gardés.

Par ailleurs, les données du message sont mises à jour. Ainsi, l’adresse de provenance (msg.sender) devient celle du contrat appelant, et la valeur incluse dans le message (msg.value) est mise à jour également.

On peut également envoyer des Ethers via un call.

Voici un exemple

contract ContractA {
    uint public callCounter;
    function functionA() external payable {
        callCounter++;
    }
}

contract ContractB {

    ContractA contractA = new ContractA();
    
    function functionB() external {
        // call car functionA modifie des informations dans le storage, en l'occurrence sa variable "callCounter"
        contractA.functionA();
    }
}

Il est possible d’utiliser la fonction call explicitement, de la manière suivante :

(bool success,bytes memory data) = address(contractA).call{value: 0.1 ether}(abi.encodeWithSignature("functionA()"));

L’appel renverra un status booléen sur la bonne exécution du call ainsi que de la donnée optionnellement renvoyée par la fonction appelée. On note également que, dans cet exemple, nous avons envoyé 0.1 ether au contrat appelé.

Call

staticcall

Le staticcall est en tous points similaire au call, cependant la fonction appelée ne peut pas effectuer de modifications sur la blockchain, ni son storage, ni son solde d’ether. C’est une sorte d’appel en lecture seule.

contract ContractA {
    function functionA() external view {
        // Du code
    }
}

contract ContractB {

    ContractA contractA = new ContractA();
    
    function functionB() external view {
        // staticcall car functionA est déclarée comme "view", donc ne fera aucune modification dans le storage
        contractA.functionA();
    }
}

Comme cet appel ne peut pas modifier la blockchain, le solde du contrat appelé ne peut pas être modifié. Ainsi, il n’est pas possible d’envoyer des Ethers via cet appel. Il est également possible d’appeler la fonction staticcall explicitement, de la manière suivante :

(bool success,bytes memory data) = address(contractA).staticcall(abi.encodeWithSignature("functionA()"));

Static Call

delegateCall

L’appel delegateCall est très particulier. Il peut se révéler extrêmement utile, mais extrêmement dangereux. Alors que pour les appels call et staticcall, les zones mémoires étaient distinctes entre l’appelant et l’appelé, ce n’est pas complètement le cas pour le delegateCall.

Dans ce cas, toutes les zones mémoire volatiles (stack, memory, PC) sont propres au contrat appelé, le contrat B, cependant :

  • Les lectures et écritures dans le storage seront faites dans le storage du contrat A
  • L’adresse de provenance du message (msg.sender) et la valeur (msg.value) ne vont pas être mis à jour. Donc si un EOA appelle un contrat A, et que contrat A effectue un delegateCall vers un contrat B, msg.sender sera toujours l’EOA lorsque le contrat B exécutera son code.
contract ContractA {
    uint private secretNumber;

    function updateSecret() public payable {
        secretNumber = 1337;
    }
}

contract ContractB {
    uint private secretNumber = 42;
    ContractA contractA = new ContractA();


    function callContractA() public payable {
        // Le storage de ContractB est mis à jour avec ce delegatecall
        (bool success, bytes memory data) = address(contractA).delegatecall(abi.encodeWithSignature("updateSecret()"));
    }

    function getSecretNumber() external view returns(uint) {
        return secretNumber;
    }
}

Dans cet exemple, le ContractB possède une variable de storage privée, secretNumber, valant 42. En effectuant un delegatecall vers ContractA, ContractA va mettre à jour la variable secretNumber. Cette mise à jour est faite dans le storage de ContractB. Donc, suite à cet appel, la fonction getSecretNumber() renverra 1337, et non plus 42.

DelegateCall

Un cas d’usage classique de ce type d’appel est le principe des contrats proxy. Lorsqu’un développeur veut mettre à jour son contrat, il devra à nouveau le déployer, et fournir la nouvelle adresse à ses utilisateurs.

Une solution est alors de créer un contrat proxy, dans lequel toutes les informations de son application sont stockées, et ce contrat effectue des delegateCall vers la vraie application. Le développeur communique l’adresse du proxy à tous ses utilisateurs.

Si un jour, l’application doit être mise à jour, il suffit d’appeler une fonction du proxy qui permette de mettre à jour l’adresse de l’application. Cette mise à jour est transparente pour les utilisateurs, puisque le proxy n’a pas été modifié.

Résumé des calls

Voici un petit tableau récapitulatif des différents types de call.

Call de contrat A vers contrat B Nouvelle EVM Storage msg.sender/msg.value Modification de la blockchain
call Oui Contrat B Mis à jour Possible
staticcall Oui Contrat B Mis à jour Impossible
delegatecall Oui Contrat A Non mis à jour Possible

Conclusion

Cet article nous a permis de faire un tour d’horizon de l’EVM, Ethereum Virtual Machine. Des opcodes sont exécuté par la machine virtuelle, dans la limite du gas envoyé par l’utilisateur, puisque l’exécution de code a un coût.

Pour correctement fonctionner, l’EVM utilise différentes zones mémoires pour stocker des informations temporaires et permanentes.

Enfin, afin que des contrats puissent s’appeller entre eux, différents appels, ou calls, sont gérés par l’EVM.

Ces bases devraient être suffisantes pour aborder serainement les vulnérabilités rencontrées dans les smart contracts dans les prochains articles.


hackndo logo
Auteur : Pixis
Créateur du blog, suivez-moi sur twitter ou discord