Expérimentations avec les smart contracts d'Ethereum
1. Prérequis et avant-propos
Cet article passera en revue des sujets liés à la blockchain tels que les preuves à connaissance nulle (PoW, PoS, etc.) ou la cryptographie asymétrique. J'écrirai également des smart contracts en Solidity; par conséquent, un jargon lié à la programmation peut apparaître. Il existe cependant plusieurs niveaux de difficulté auxquels cet article peut être lu, ce qui en fait une bonne ressource à consulter dans le cas où vous acquerriez à l'avenir des connaissances supplémentaires dans le domaine. Pas d'inquiétude cependant, je ferai une brève introduction en des termes simples avant chaque concept qui pourrait en nécessiter une. Je suis ravi de partager mon expérience et mes connaissances dans un tel domaine, car il est assez unique et à l'intersection de plusieurs intérêts de niche. Pour illustrer cela, permettez-moi simplement de dire que je ne connais actuellement personne dans mon réseau qui code des smart contracts. Si c'est le cas par contre, n'hésitez pas à me contacter!
2. Que diable est un smart contract ?
Je trouve la définition donnée sur Wikipédia claire assez.
Les contrats intelligents (en anglais : smart contracts) sont des protocoles informatiques qui facilitent, vérifient et exécutent la négociation ou l'exécution d'un contrat, ou qui rendent une clause contractuelle inutile (car rattachée au contrat intelligent).
Un simple exemple de smart contract (hors blockchain) qui est souvent repris comme cas d'école est celui d'un distributeur automatique (de sodas, snacks …). En effet, le logiciel vérifie quelles conditions sont satisfaites et agit en conséquence. Avez-vous mis le montant nécessaire et le soda est-il en stock ? Une canette devrait tomber. Y a-t-il une rupture de stock ? La machine doit alors afficher un message d'erreur et rendre la monnaie. Le montant payé n'est pas suffisant ? Il faut alors afficher le montant restant à payer.
Ethereum prend ce concept et étend ses capacités à presque n'importe quelle opération financière. Un de ces avantages est sa nature décentralisée et le fait d'enlever le besoin d'avoir un lien de confiance avec une quelconque entité. Dans le cas d'Ethereum, le programme est souvent écrit dans un langage de programmation orienté-contrat appelé Solidity. Du fait que la machine virtuelle Ethereum (l'environnement d'exécution) soit Turing-complète, il n'y a pas moyen de déterminer si le code arrivera à une halte ou non. Pour régler ce problème, chaque transaction coûte du gaz, qui est exprimé en Gwei, une subdivision de l'Ether (1 Gwei = 10-9 ETH), ce qui explique pourquoi des frais sont associés aux interactions avec les smart contracts. Ces derniers sont assez élevés pour le moment mais le changement vers Eth 2.0 devrait les faire baisser.
3. Exemples d'applications
Lors de la résolution d'un problème, il faut d'abord chercher les outils appropriés à employer et s'y attaquer ensuite. Dans de nombreuses startups ou entreprises, cela n'est pas fait. Au lieu de ça, l'accent est mis sur des mots à la mode, des "buzz-words", tels que le machine learning, la blockchain, le chiffrement de niveau militaire et bien d'autres. C'est pourquoi je mets ici un petit avertissement: les exemples suivants seront plutôt de type "Comment utiliser les smart contracts d'Ethereum pour faire X" plutôt que "Quels outils sont-ils les plus appropriés pour faire X ?". Par contre, je vois tout de même beaucoup d'avantages à mettre ces applications sur la blockchain, le principal étant l'absence d'un tiers ou d'une autorité centrale, ce qui enlève le besoin d'inclure de la confiance dans le processus.
Mes exemples vous inspireront peut-être à coder votre proper programme ou à développer une application décentralisée (dapp). Si tel est le cas, vous pouvez me contacter pour des partenariats professionnels, faire un don si mon travail vous a été utile, mais surtout me donner du crédit et me le faire savoir. Je suis curieux de voir où cet article vous mènera. De plus, ces exemples ne sont pas prêts pour la production, alors assurez-vous de savoir ce que vous faites!
Dans tout contrat écrit en Solidity, certains éléments de code sont récurrents car requis (plus d'infos dans la documentation Solidity: Structure d'un fichier source Solidity). Par exemple, la première ligne est un commentaire ayant une structure spécifique qui indique la license. Cette structure lui permet d'être facilement lu par une machine. J'ai choisi la license MIT mais je n'ai pas fait assez de recherches pour avoir une opinion bien construite. La seconde ligne précise la version du compilateur à utiliser. Je dois encore comprendre les différences qui surviennent en utilisant diverses notations (Le caractère ^
s'assure de la compatibilité avec les versions 0.8.0 jusqu'à 0.9.0) mais je considère cela comme étant un détail car je débute encore. J'y regarderai plus en profondeur quand j'aurai l'opportunité d'écrire du code qui sera déployé en production. Pour commenecer un contrat, on ajoute une ligne qui spécifie son nom et qui ouvre des accollades. Nous avons donc de quoi commencer :
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract NomDuContrat {
}
Souvent, ils possèderont un constructeur (constructor() { }
) contenant du code qui ne sera exécuté qu'une seule fois; lors du déployement du contrat. Aussi, les types de variables doivent être définis explicitement, ce qui est la raison pour laquelle vous rencontrerez des mots-clés tels que uint
qui signifie unsigned integer, autrement dit un entier non signé (\(\in \mathbb{N}\) donc), string
dénommant une chaîne de caractères, une adresse Ethereum avec address
, et bien plus.
3.1 Copropriété
Lorsque je pense aux applications de la blockchain, j'essaie de trouver des cas d'usage liés au monde "réel" et qui requièrent peu de connaissances techniques, afin de rendre l'expérience la plus simple possible pour les utilisateurs. Ici, j'ai pensé à un groupe d'amis (supposons 4), propriétaires d'un bien immobilier et qui le louent à une personne. Il serait ennuyeux de faire 4 transactions chaque mois et, de plus, il est intéressant de solidifier cet accord dans le cas où il y aurait un différend dans ledit groupe. Les smart contracts peuvent apporter une résolution (ou, du moins, une amélioration) à ces problèmes. Au lieu que le locataire paie son loyer à un des amis, il le paierait dorénavant au smart contract. Celui-ci diviserait (en parts égales ou non, selon les termes de l'accord) et transférerait le montant à chaque copropriétaire. Dans sa forme la plus simple, cela ressemble à ça:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Copropriete {
address payable[] proprietaires;
constructor() {
proprietaires = [payable(0x704A1bFD15c629E08EC6824470c37a9aA81558c7),
payable(0xa62E10cD675A847E15399ea473AcC91f3BF3775a),
payable(0xD73d1B47cdc6fB4aAb59dCf8416e6Eec9bAD467f),
payable(0xC6C4187d9ca7585Df74E4df56F64A48bb3976aA4)];
}
receive() external payable {
uint loyer = msg.value;
for (uint i=0; i<proprietaires.length; i++) {
address payable coproprio = proprietaires[i];
coprop.transfer(loyer/proprietaires.length);
}
}
}
Ce contrat a été déployé sur le réseau ropsten et fonctionne parfaitement. Vous pouvez le tester, et assurez-vous d'avoir de l'Ether-test. N'envoyez pas de vrai Ether, je n'y aurai pas accès, faites un don plutôt! La première transaction de 0.4 ETH a été faite par moi-même et montre que le contrat fonctionne.
La ligne juste avant le constructeur définit, en utilisant []
, un array (que je vais appeler liste car on est en dimension 1) d'adresses payables, appelé proprietaires
. Cela nous parmet d'ajouter les adresses des quatre copropriétaires dans le constructeur. Notez qu'elles sont codées en dur (hardcoded) donc nous ne pouvons pas les modifier. Cependant, nous pouvons facilement trouver une fonction qui nous le permettrait. Par exemple, nous pourrions en créer une qui, si et seulement si le message est envoyé par un des coproprios, remplace son adresse avec celle passée en paramètre. Voilà comment faire cela en Solidity:
function changementAdresse(address _nouvelleAdresse) {
for (uint i=0; i<proprietaires.length; i++) {
if(msg.sender == proprietaires[i]) {
proprietaires[i] = _nouvelleAddress;
}
}
}
Et pour voir la liste des propriétaires
actuels:
function quiSontLesProprios() public view returns (address payable[] memory) {
return proprietaires;
}
Ce contrat amélioré a aussi été déployé sur le tesnet.
En écrivant cet article, je suis tombé sur une réponse sur StackExchange qui mentionnait que l'usage d'un mapping
au lieu d'une liste address[]
était une meilleure pratique car cela devrait être plus efficace et donc utiliser moins de gaz. Dans le cas de ce contrat, cela n'est pas conceivable car on doit de toutes façons parcourir tous les éléments de la liste afin de faire une transaction à chaque copropriétaire. Par contre, utiliser un mapping
peut être utile et meilleur dans de nombreux cas de figure. Aussi, notez qu'au moment où j'écris ces lignes, il n'y a pas moyen d'utiliser le mot-clé in
comme suit :
for (coproprio in proprietaires) {}
car il n'est pas utilisé dans Solidity. Il se pourrait qu'il le soit à l'avenir.
3.2 Jeux d'argent
Les jeux sont, de manière générale, basés sur un ensemble précis de règles, ce qui les rend facile à coder. Voilà pourquoi ils sont souvent utilisés dans des tutoriels ou cours de programmation; ils implémentent principalement des déclarations if-then-else
. La blockchain Ethereum, mélangeant valeur monétaire et programmation de manière autonome (dans le sens trustless), est une plateforme idéale sur laquelle développer des jeux d'argent. En outre, ils incorporent un problème que je souhaitais adresser: la génération de nombres aléatoires sur la blockchain.
3.2.1 Génération de nombres aléatoires sur la blockchain
Comme vous le savez peut-être, la génération de nombres aléatoires est un domaine spécifique de l'informatique. Les techniques principalement utilisées actuellement utilisent des générateurs de nombres pseudo-aléatoires (GNPA). Ces générateurs ne sont pas vraiment aléatoires, car ils emploient, par exemple, l'horloge de votre ordinateur (modulo le nombre adéquat, afin d'avoir un entier dans un intervalle défini), qui ne l'est pas. Les GNPAs iplémentent souvent un générateur congruentiel linéaire, un standard dans le milieu. On peut se permettre d'utiliser des GNPAs dans la majorité des applications (simulations, mélanges, échantillonage statistique…) car essayer de trouver un modèle ou de prévoir le nombre suivant ne vaut juste pas le coup. Par contre, pour des sujets délicats impliquant potentiellement de grosses sommes d'argent ou de grands enjeux de vie privée ou d'anonymat, il est nécessaire d'utiliser de vrais GNAs. Pour cela, nous pouvons utiliser des données du monde réel, non-aléatoires mais extrêmement difficiles -voir impossibles- à prédire (souvent issues de systèmes chaotiques) ou bien récolter des données depuis des phénomènes aléatoires par nature tels que les fluctuations quantiques ou des désintégrations radioactives.
Dans l'espace de la blockchain, la volonté d'avoir une source de hasard qui ne requiert pas de confiance et/ou qui est décentralisée ajoute de la complexité à la tâche. Nous ne pouvons pas se fier à un seul laboratoire qui nous enverrait des données issues de phénomènes quantiques car la source est centralisée. De même, nous ne pouvons pas non plus demander à un utilisateur de nous soumettre un NA généré matériellement car cette donnée peut être manipulée. Il n'est pas surprenant que "Comment puis-je générer en toute sécurité un nombre aléatoire dans mon smart contract ?" est actuellement la seconde question la mieux votée sur le stackexchange d'Ethereum.
Il semble que la fonction VRF de Chainlink fournisse une solution à cela. Je dois encore creuser le sujet et regarder les mathématiques sous-jacentes mais, tel que je le comprends maintenant, Chainlink est un oracle décentralisé et quand vous faites une requête de nombre aléatoire, vous devez fournir une graine (oui oui, ça se dit en français apparemment) pseudo-aléatoire qui, après avoir été mélangée avec d'autres données, est utilisée par le réseau chainlink afin de renvoyer un nombre aléatoire. Beaucoup de fonctions cryptographiques d'Ethereum sont utilisées, afin d'effectuer des actions telles que le hachage récursif. Pour l'utiliser, du LINK doit être envoyé au contrat, car la requête d'un nombre aléatoire nécessite qu'un frais soit payé.
En utilisant l'exemple de base fourni par Chainlink, nous pouvons ajouter une fonction qui prendra un nombre \(n\) comme paramètre et qui renverra un nombre aléatoire entre 0 et \(n-1\):
contract NombreAleatoireDansIntervalle is RandomNumberConsumer {
function getAleatoireDansIntervalle(uint userSeed, uint _intervalle) public returns (uint) {
getRandomNumber(userSeed);
uint nombre = resultatAleatoire % _intervalle;
return nombre;
}
}
Il est préférable de consulter l'exemple dont je viens de donner le lien afin de mieux comprendre cet extrait de code. Le mot-clé is
permet de créer un contrat qui hérite de celui fourni par Chainlink. Plusieurs autres fonctions peuvent être construites de cette manière, mais je ne les implémenterai pas dans cet article.
3.2.2 Choix de jeu
Au début, je souhaitais coder le Blackjack mais j'ai fait face à plusieurs soucis:
- Même si c'est l'un des jeux de casino les plus simples, les règles sont assez élaborées. Prendre ce jeu comme exemple n'aurait pas été un choix pertinent; non seulement le lecteur doit connaître les règles mais le code aurait aussi été déroutant.
- Beaucoup de jeux de hasard nécessitent que les cartes des joueurs soient cachées. Ce n'est pas tant pour l'anonymat mais plutôt pour éviter la triche et éviter de donner un avantage aux autres joueurs. La blockchain étant publique, garder en mémoire les mains des joueurs sans les révéler aux autres s'avère complexe. Chaque solution pour cacher ces données a un degré de tolérence qui lui est associé. Par exemple, rendre une variable privée avec le mot-clé
private
pourrait être suffisant pour certaines applications mais son nom est un peu trompeur. En effet, comme expliqué dans la documentation - Je ne suis pas -encore- assez qualifié que pour rapidement déployer une interface utilisateur pour une dapp, ce qui m'empêche d'ajouter les designs des cartes et de les lier avec leur ID (un nombre entre 0 et 51). J'aurais pu sauter cette étape et faire en sorte que les utilisateurs concluent eux-mêmes quelles cartes ils ont, basé sur l'ID (ID % 2 donne la couleur, ID % 4 l'enseigne et ID % 13 la valeur), mais en considérant les points précédents et le fait qu'une telle décision rendrait l'expérience utilisateur moins fluide, il était plus raisonnable de choisir un autre jeu.
Tout ce qui se trouve à l’intérieur d’un contrat est visible pour tous les observateurs extérieurs à la blockchain. Passer quelque chose en private
ne fait qu’empêcher les autres contrats d’accéder à l’information et de la modifier, mais elle sera toujours visible pour le monde entier à l’extérieur de la blockchain.
Nous pourrions aussi penser à construire une partie du jeu sur un site web, ce qui créerait une application partiellement décentralisée. Je ne suis cependant pas exactement sûr de comment cela pourrait être accompli, probablement en utilisant les signatures cryptographiques et en chiffrant une partie des données sur la machine de l'utilisateur.
3.2.3 Lotterie
Voici le jeu sur lequel j'ai décidé de me concentrer. Ici, nous allons faire un prix "collaboratif", c'est-à-dire que le prix à gagner commencera à 0 et sera augmenté à chaque achat de ticket. Après un laps de temps prédéterminé (stocké dans la variable dureeJeu
), la vente de tickets sera fermée et la fonction tirerGagnant()
(qui sélectionnera un gagnant et lui transfèrera les fonds) sera appelable (callable). J'avais d'abord pensé à restreindre l'accès à cette fonction à un administrateur (le créateur du contrat) mais un problème survient si cette fonction n'est pas appelée: les fonds sont gelés et le contrat est inutilisable. Il y a plusieurs manières d'éviter cela (ajouter une limite de temps, changer le propriétaire basé sur un consensus …) mais je souhaitais démontrer la puissance d'un smart contract bien conçu. C'est pourquoi je l'ai écrit de sorte que n'importe qui pourrait appeler la fonction qui choisit un gagnant ! Comme un verrouillage temporel a été mis et que -supposément- un bon GNA est utilisé, alea iacta est et que le gagnant soit choisi !
Nous définissons d'abord le prix d'un ticket. Pour cela, je choisis un prix fixe et en Ether, disons 0.01 ETH. Nous introduisons les variables suivantes:
uint public prixTicket = 0.01 ether;
uint public dureeJeu = 1 days;
address[] private joueurs;
uint public debutJeu;
Où, dans les deux premières lignes, nous avons utilisé les unités de Solidity de sorte que la valeur les précédant est automatiquement convertie au format approprié. Ici, mettre ether
après 0.01
multiplie la valeur par by 1018, exprimant le montant en wei, la plus petite dénomination de l'ether, et permettant ainsi de stocker la valeur en tant qu'entier non signé. Cela évite les problèmes liés aux nombres à virgule flottante (floating-point numbers). Similairement, days
est une unité qui convertit la valeur la précédant en secondes, qui est aussi stockée dans un uint
. La variable players
est une liste d'adresses dont les indices représentent les numéros des tickets. Afin d'appliquer un verouillage temporel, nous devons garder une trace du moment où le jeu a commencé. Nous utiliserons debutJeu
et l'écraserons (overwrite à chaque fois qu'un nouveau jeu sera lancé. Nous devons toutefois l'initialiser, c'est pourquoi nous ajoutons une ligne dans le constructeur:
constructor() {
debutJeu = block.timestamp;
}
De telle manière, debutJeu
aura pour valeur initiale l'heure (approximative) à laquelle le contrat sera déployé.
Nous devons maintenant définir les deux fonctions principales de notre contrat: acheterTicket()
et tirerGagnant()
. La première est payable, et la seconde ne peut être appelée que si la vente de tickets est fermée. Voici comment j'ai écrit la fonction d'achat:
function acheterTicket() public payable {
require(block.timestamp <= debutJeu + dureeJeu, "La vente de tickets est fermee");
require(msg.value == prixTicket, "Le montant envoye est different du prix d'un ticket.");
joueurs.push(msg.sender);
}
La seconde condition s'assure que le montant correct est payé. J'aurais pu faire en sorte que dans le cas où un montant trop élevé ait été envoyé, la somme excédentaire aurait été restituée mais cela aurait coûté (à l'utilisateur) du gaz supplémentaire. En plus, cela rend les choses plus claires si seul le montant du ticket peut être envoyé. La dernière ligne ajoute l'adresse de l'expéditeur du message à la liste des joueurs. Cela va donc attribuer un indice (qui peut être vu comme un numéro de ticket) à son acheteur.
La fonction qui suit choisit un gagnant et lui reverse le prix. Elle s'assure premièrement que la vente de tickets est bien clôturée, et ajoute ensuite une déclaration pour gérer les cas où personne n'aurait joué. Dans ce cas, un nouveau jeu est directement créé et du gaz est de nouveau économisé. Dans le cas où des personnes ont joué, un indice gagnant, dans l'intervalle des tickets vendus, est choisi au hasard et la personne ayant acheté ce ticket est déclarée gagnante. Après ça, les fonds sont transférés, les variables sont effacées (ou écrasées) et la variable debutJeu
est à redéfinie.
function tirerGagnant() public {
require(block.timestamp > debutJeu + dureeJeu, "La vente de tickets est toujours ouverte");
if (joueurs.length != 0) {
uint ticketsVendus = joueurs.length;
//obtention d'un nombre aléatoire en utilisant la fonction définie précédemment
uint indexGagnant = getAleatoireDansIntervalle(block.timestamp, ticketsVendus);
address gagnant = joueurs[indexGagnant];
payable(gagnant).transfer(address(this).balance);
delete joueurs;
delete indexGagnant;
delete gagnant;
}
debutJeu = block.timestamp;
}
Notez que la fonction VRF de Chainlink n'est pas encore très mature et qu'elle requiert le développement de plusieurs autres contrats, ce qui pourrait être désarçonnant. Si vous souhaitez plus de simplicité, vous pouvez déployer une version (moins sécurisée) sur le testnet, en utilisant:
uint indexGagnant = block.timestamp % ticketsVendus;
au lieu de
uint indexGagnant = getAleatoireDansIntervalle(block.timestamp, ticketsVendus);
Une amélioration de ce contrat est l'addition de deux fonctions appelables par le propriétaire (son créateur souvent) du contrat seulement.Pour cela, je peux ajouter un modifier
et l'ajouter à la fonction. Je laisse le soin au lecteur de comprendre comment elles fonctionnent.
Voici le contrat final:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Lottery {
address public proprio;
uint public prixTicket = 0.01 ether;
uint public dureeJeu = 1 days;
address[] private joueurs;
uint public debutJeu;
constructor() {
proprio = msg.sender;
debutJeu = block.timestamp;
}
modifier seulementProprio {
require(msg.sender == proprio);
_;
}
function acheterTicket() public payable {
require(block.timestamp <= debutJeu + dureeJeu, "La vente de tickets est fermee");
require(msg.value == prixTicket, "Le montant envoye est different du prix d'un ticket.");
joueurs.push(msg.sender);
}
function tirerGagnant() public {
require(block.timestamp > debutJeu + dureeJeu, "La vente de tickets est toujours ouverte");
if (joueurs.length != 0) {
uint ticketsVendus = joueurs.length;
//obtention d'un nombre aléatoire en utilisant la fonction définie précédemment
uint indexGagnant = getAleatoireDansIntervalle(block.timestamp, ticketsVendus);
address gagnant = joueurs[indexGagnant];
payable(gagnant).transfer(address(this).balance);
delete joueurs;
delete indexGagnant;
delete gagnant;
}
debutJeu = block.timestamp;
}
function changerPrixTicket(uint _nouveauPrix) public seulementProprio {
require(joueurs.length == 0 , "Afin de ne pas desavantager les joueurs, le prix ne peut etre changee si le jeu en cours possede deja des participants");
prixTicket = _nouveauPrix;
}
function changerDureeJeu(uint _nouvelleDureeJeu) public seulementProprio {
require(joueurs.length == 0, "Afin de ne pas desavantager les joueurs, la duree ne peut etre changee si le jeu en cours possede deja des participants");
dureeJeu = _nouvelleDureeJeu;
}
}
4. Réflexions
Je voulais initialement fournir 4 ou 5 exemples (je ne suis pas du tout à court d'idées) mais pendant la rédaction de cet article, j'ai remarqué que j'ai été plus explicite que je le pensais. Ce n'est pas un problème parce que ça aura peut-être aidé quelques lecteurs à mieux comprendre le potentiel d'Ethereum. Évidemment, il y a des améliorations qui peuvent êtres faites, notamment à l'égard de l'optimisation des frais en gaz, mais j'ai déjà donné quelques conseils ça et là. La rédaction de Smart Contracts est une chose que j'apprécie de plus en plus, leurs applications sont énormes et la finance décentralisée (DeFi pour decentralized finance) pourrait bien rivaliser avec la finance traditionnelle dans un futur proche. J'écrirai d'autres articles en lien avec Ethereum et je discuterai probablement d'autres blockchains qui supportent les smart contracts, mais pour l'instant j'ai une préférence pour Ethereum.