Dans ce guide, vous apprendrez à générer des nombres aléatoires dans la blockchain:
- Tout d’abord, nous présenterons l’aléatoire et pourquoi il est difficile d’en générer dans la blockchain (voir Blockchain et aléatoire).
- Ensuite, vous résoudrez le défi Ethernaut Coin Flip pour montrer comment pirater un contrat intelligent qui utilise des nombres aléatoires non sécurisés (voir Problème ).
- Enfin, vous corrigerez le contrat intelligent Ethernault Coin Flip en utilisant une source d’aléatoire sécurisée : Chainlink VRF v2 (voir Solution ).
Toc
Blockchain et aléatoire
L’aléatoire fait référence à l’absence de prévisibilité. Par exemple, le résultat d’un lancer de dés est imprévisible.
Les blockchains sont des systèmes déterministes où les mêmes entrées produisent toujours les mêmes sorties. Cet attribut déterministe permet aux nœuds validateurs de la blockchain (dans le mécanisme de consensus preuve d’enjeu) ou aux nœuds de minage de la blockchain (dans le mécanisme de consensus preuve de travail) d’atteindre un consensus. Ils doivent tous parvenir au même résultat lors de l’exécution d’une transaction. Ainsi, les systèmes de blockchain ne fournissent aucune solution native pour générer de l’aléatoire, car fournir une solution pour générer de l’aléatoire contredirait leur attribut déterministe.
Cela signifie-t-il que l’aléatoire n’est pas utilisé dans la blockchain ? Pas si vite… L’aléatoire est déjà utilisé (liste non exhaustive) dans les cas suivants :
- Preuve d’enjeu : sélection aléatoire des responsabilités des validateurs.
- NFT : attribuer des attributs aléatoires lors de la génération de NFT.
- Jeux : matchmaking, coups critiques (combats)… Etc.
Lisez ce blog pour une liste plus complète.
Maintenant, on pourrait se poser les questions suivantes :
- Étant donné que les blockchains sont déterministes, comment ces applications blockchain obtiennent-elles de l’aléatoire ?
- Et, plus important encore, comment garantir que l’aléatoire est équitable et que personne ne peut biaiser le système ? Imaginez un jeu avec gains utilisant une source d’aléatoire biaisée…
Pour répondre à ces questions, vous pourriez utiliser (mais s’il vous plaît, ne le faites pas 🙂) des solutions naïves et, surtout, non sécurisées :
- Opérateur Oracle opaque : Demander de l’aléatoire à un opérateur Oracle qui ne fournit pas de preuves/garanties cryptographiques de génération équitable. Un oracle malveillant ou compromis pourrait fournir des données biaisées à vos contrats intelligents et nuire à vos utilisateurs. Par exemple, vous avez créé une loterie sur et demandé de l’aléatoire à un oracle opaque pour déterminer les numéros gagnants. L’oracle pourrait participer à la loterie et générer des nombres “aléatoires” qui arrangeraient son jeu.
- Solution de contournement: S’appuyer sur les horodatages ou les hachages des blocs. Cependant, les mineurs ayant un enjeu dans le jeu pourraient décider quand “miner” une transaction. Ainsi, influençant les valeurs d’horodatage et de hachage.
La solution consiste à utiliser un générateur de nombres aléatoires (RNG) équitable et prouvable, tel que Chainlink VRF, dans lequel chaque résultat aléatoire est impartial et vérifié cryptographiquement sur la chaîne. Pour en savoir plus sur les fonctions aléatoires vérifiables, vous pouvez lire cet article.
Problème avec l’aléatoire non sécurisé : le défi Coin Flip
Pour illustrer les risques importants de s’appuyer sur des solutions de contournement, nous allons pirater le défi Coin Flip Ethernaut.
Objectif
Dans ce défi, vous devez deviner le résultat d’un lancer de pièce. Devinez le résultat dix fois de suite, et vous remportez le défi.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract CoinFlip {
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR =
57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor() {
consecutiveWins = 0;
}
function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(blockhash(block.number - 1));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}
Analyse du contrat
Pour deviner le résultat d’un lancer de pièce, il faut appeler la fonction flip
et fournir un booléen (vrai/faux) en entrée. Passons en revue la fonction flip
:
-
La première ligne calcule une valeur “aléatoire” basée sur le hachage (
blockhash
) du bloc précédent (block.number - 1
). Commeblockhash
renvoie une valeur bytes32, le résultat est converti de bytes32 en uint256 pour obtenir un entier non signé de 256 bits.uint256 blockValue = uint256(blockhash(block.number - 1))
-
Ensuite, la fonction vérifie qu’elle est appelée une seule fois dans un bloc donné : si elle a déjà été appelée dans le même bloc, la fonction renvoie une erreur.
if (lastHash == blockValue) { revert(); } lastHash = blockValue;
-
coinFlip
est calculé en divisant blockValue parFACTOR
.FACTOR
est une variable d’état uint256 variable d’état. Si la valeur decoinFlip
est1
, alorsside
seratrue
; sinon, il serafalse
. N’oubliez pas qu’en Solidity, la division d’entiers donne un entier.uint256 coinFlip = blockValue / FACTOR; bool side = coinFlip == 1 ? true : false;
-
Le contrat a un compteur (
consecutiveWins
).side
est comparé à la valeur devinée_guess
. Si la valeur devinée est correcte, alors le compteur est incrémenté. Sinon, le compteur est remis à zéro.if (side == _guess) { consecutiveWins++; return true; } else { consecutiveWins = 0; return false; }
Piratage du contrat
Démontrons qu’utiliser le blockhash
pour générer de l’aléatoire est une mauvaise idée et que nous pouvons deviner le résultat du lancer de pièce.
La solution la plus simple consiste à déployer un contrat avec une fonction qui calcule le lancer de pièce attendu en utilisant le même algorithme que la fonction flip
, puis appelle le contrat CoinFlip
avec le résultat attendu. Plusieurs solutions documentées en ligne existent:
En guise de défi, j’ai voulu pirater le contrat hors chaîne sans déployer un autre contrat. La solution fonctionne bien dans un environnement local. Cependant, il n’est pas toujours facile à utiliser sur un testnet public (par exemple, Sepolia), car on n’est pas sûr à 100% que les mineurs incluent les transactions dans le bloc attendu. Note : si vous pouvez affiner le code pour le rendre toujours fonctionnel sur Sepolia, veuillez ouvrir une PR 😉. Le répo peut être trouvé ici.
Sur Blockchain locale
Afin de tester:
-
Veuillez ouvrir un terminal, cloner le répo, et installer toutes les dépendances :
git clone git@github.com:web3station/CoinFlip.git && cd CoinFlip yarn
-
Compilez les contrats:
yarn compile
-
Dans un nouveau terminal, démarrez un environnement local :
yarn start-node
-
Deployez CoinFlip:
yarn deploy-coinflip
-
Notez l’addresse du contrat déployé:
-
Piratez le contrat:
yarn flip <your-address>
Si vous êtes intéressé par le script, vous pouvez le trouver ici.
Sur un testnet public
-
Veuillez ouvrir un terminal, cloner le répo, et installer toutes les dépendances :
git clone git@github.com:web3station/CoinFlip.git && cd CoinFlip yarn
-
Compilez les contrats:
yarn compile
-
Copiez
.env.example
vers.env
afin de générer un nouveau fichier de variables d’environnement:cp .env.example .env
-
Ouvrez
.env
and completez les variables obligatoires. -
Deployez CoinFlip. Example on Sepolia:
yarn deploy-coinflip --network sepolia
-
Notez l’addresse du contrat déployé.
-
Piratez le contrat. Note : Le principal défi consiste à s’assurer que les validateurs/mineurs incluent la transaction dans le bon bloc. En fonction de la congestion du réseau, vous pourriez peut-être remarquer que les victoires consécutives redémarrent, ce qui signifie que les validateurs n’ont pas inclus la transaction dans le bloc attendu.
yarn flip <your-address> --network sepolia
Solution : Correction de CoinFlip - Chainlink VRF v2
Maintenant que nous avons vu comment nous pourrions deviner avec précision le résultat de la fonction coinFlip
, utilisons Chainlink VRF pour obtenir une sécurité aléatoire et corriger le contrat CoinFlip
.
Introduction à Chainlink VRF v2
Chainlink Verifiable Random Function (VRF) est une solution RNG standard, permettant aux contrats intelligents et aux systèmes hors chaîne d’accéder à une source de hasard vérifiable en utilisant un calcul hors chaîne. Vous pouvez en apprendre davantage sur Chainlink VRF ici.
Au moment de la rédaction, il existait deux versions: v1 et v2. Nous allons utiliser v2 car il inclut plusieurs améliorations. Notez que Chainlink VRF v2 offre deux méthodes pour demander des nombres aléatoires. Comme la documentation indique:
Abonnement: Créez un compte d’abonnement et approvisionnez son solde avec des jetons LINK. Les utilisateurs peuvent ensuite connecter plusieurs contrats consommateurs au compte d’abonnement. Lorsque les contrats consommateurs demandent des nombres aléatoires, les frais de transaction sont calculés à la fin de celle-ci, et le solde d’abonnement est déduit en conséquence. Cette méthode vous permet de financer les demandes de plusieurs contrats consommateurs à partir d’un seul abonnement.
Financement direct: Les contrats consommateurs paient directement avec LINK lorsqu’ils demandent des valeurs aléatoires. Vous devez financer directement vos contrats consommateurs et vous assurer qu’il y a suffisamment de jetons LINK pour payer les requêtes.
Comme nous allons déployer un contrat consommateur et l’utiliser pour une demande unique, la méthode Financement direct semble plus appropriée à notre cas d’utilisation.
Prérequis
Pour exécuter le prochain tutoriel, vous aurez besoin de :
- Portefeuille Metamask.
- Environnement de développement Remix. Si vous n’avez jamais déployé de contrat en utilisant Remix IDE, suivez ce tutoriel pour débutants.
- Le test sera effectué sur le réseau de test Sepolia. Par conséquent, vous aurez besoin de suffisamment d’ETH Sepolia pour déployer et interagir avec votre contrat. Vous aurez également besoin de suffisamment de jetons LINK Sepolia pour payer le réseau Chainlink afin d’obtenir des nombres aléatoires. Vous pouvez obtenir des LINKs de test auprès du Chainlink faucet.
Correction CoinFlip
Ci-dessous se trouve le contrat CoinFlipFix
. Veuillez noter que certaines variables sont codées en dur et définies en tant que variables d’état à des fins éducatives, ce qui rend ce contrat inadapté au déploiement en production.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@chainlink/contracts/src/v0.8/ConfirmedOwner.sol";
import "@chainlink/contracts/src/v0.8/VRFV2WrapperConsumerBase.sol";
/**
@title CoinFlipFix
@dev A smart contract that allows users to participate in a coin flip game using Chainlink VRF v2 Direct Funding https://docs.chain.link/vrf/v2/direct-funding.
The contract is hardcoded for the Sepolia network.
Users can submit their guesses as an array of booleans and will get the results of their game after
a random number is generated from Chainlink VRF v2 Direct Funding https://docs.chain.link/vrf/v2/direct-funding.
*/
contract CoinFlipFix is VRFV2WrapperConsumerBase, ConfirmedOwner {
/**
* @dev Custom error for when the input length does not match the required length.
* @param desiredLength The expected length of the input array.
* @param providedInputLength The actual length of the provided input array.
*/
error WrongInputLength(uint256 desiredLength, uint256 providedInputLength);
/**
* @dev Custom error for when a request with the given requestId is not found in the requests mapping.
*/
error RequestNotFound();
/**
* @dev Custom error for when the transfer of LINK tokens fails.
*/
error UnableTransferLink();
/**
* @dev Event emitted when a new request is sent.
*/
event RequestSent(uint256 requestId, bool[] guesses);
/**
* @dev Event emitted when a request is fulfilled with random words.
*/
event RequestFulfilled(
uint256 requestId,
uint256[] randomWords,
uint256 payment
);
/**
* @dev Event emitted when the game results are available.
*/
event GameResult(
uint256 requestId,
bool[] sides,
bool[] guesses,
uint8 correctResults,
bool isWinner
);
/**
* @dev Struct to store request information.
*/
struct RequestStatus {
uint256 paid; // amount paid in link
bool fulfilled; // whether the request has been successfully fulfilled
uint256[] randomWords;
bool[] sides;
bool[] guesses;
}
/**
* @dev Mapping to store request status for each requestId.
*/
mapping(uint256 => RequestStatus)
public requests; /* requestId --> requestStatus */
// Array to store past requestIds.
uint256[] public requestIds;
uint256 public lastRequestId;
uint256 FACTOR =
57896044618658097711785492504343953926634992332820282019728792003956564819968;
// Configuration for your Network can be found on https://docs.chain.link/vrf/v2/direct-funding/supported-networks
// Address LINK - hardcoded for Sepolia
address linkAddress = 0x779877A7B0D9E8603169DdbD7836e478b4624789;
// address WRAPPER - hardcoded for Sepolia
address wrapperAddress = 0xab18414CD93297B0d12ac29E63Ca20f515b3DB46;
uint32 callbackGasLimit = 400_000;
// Cannot exceed VRFV2Wrapper.getConfig().maxNumWords.
uint32 numWords = 10;
// The default is 3, but you can set this higher.
uint16 requestConfirmations = 3;
/**
* @dev Constructor that sets the contract owner and initializes the VRFV2WrapperConsumerBase.
*/
constructor()
ConfirmedOwner(msg.sender)
VRFV2WrapperConsumerBase(linkAddress, wrapperAddress)
{}
/**
* @notice Initiates a coin flip game by requesting randomness and storing the user's guesses.
* @param _guesses An array of booleans representing the user's guesses for the coin flip results.
* @return requestId The generated requestId for this game.
*/
function flip(bool[] memory _guesses) external returns (uint256 requestId) {
if (_guesses.length != numWords)
revert WrongInputLength(numWords, _guesses.length);
requestId = requestRandomness(
callbackGasLimit,
requestConfirmations,
numWords
);
requests[requestId] = RequestStatus({
paid: VRF_V2_WRAPPER.calculateRequestPrice(callbackGasLimit),
randomWords: new uint256[](0),
fulfilled: false,
sides: new bool[](0),
guesses: _guesses
});
requestIds.push(requestId);
lastRequestId = requestId;
emit RequestSent(requestId, _guesses);
return requestId;
}
/**
* @notice Fulfill the random words from a Chainlink VRF request
* @dev This function is called by Chainlink VRF to fulfill a randomness request
* @param _requestId The unique identifier of the randomness request
* @param _randomWords An array containing the random words generated by the Chainlink VRF system
*/
function fulfillRandomWords(
uint256 _requestId,
uint256[] memory _randomWords
) internal override {
if (requests[_requestId].paid == 0) revert RequestNotFound();
requests[_requestId].fulfilled = true;
requests[_requestId].randomWords = _randomWords;
bool[] memory sides = new bool[](10);
uint256 coinFlip;
for (uint8 i = 0; i < _randomWords.length; i++) {
coinFlip = _randomWords[i] / FACTOR;
sides[i] = coinFlip == 1 ? true : false;
}
requests[_requestId].sides = sides;
emit RequestFulfilled(
_requestId,
_randomWords,
requests[_requestId].paid
);
bool[] memory guesses = requests[_requestId].guesses;
(uint8 correctResults, bool isWinner) = getGameResults(sides, guesses);
emit GameResult(_requestId, sides, guesses, correctResults, isWinner);
}
/**
* @notice Fetches the status of a specific coin flip game request.
* @param _requestId The ID of the request to be fetched.
* @return paid The amount paid in LINK for the request.
* @return fulfilled Indicates if the request has been successfully fulfilled.
* @return randomWords The random words generated by the Chainlink VRF.
* @return sides The determined sides of the coin flips (true for heads, false for tails).
* @return guesses The user's submitted guesses for the coin flips.
* @return correctResults The number of correct guesses made by the user.
* @return isWinner Indicates if the user has won the game (all guesses are correct).
*/
function getRequestStatus(
uint256 _requestId
)
external
view
returns (
uint256 paid,
bool fulfilled,
uint256[] memory randomWords,
bool[] memory sides,
bool[] memory guesses,
uint8 correctResults,
bool isWinner
)
{
if (requests[_requestId].paid == 0) revert RequestNotFound();
RequestStatus memory request = requests[_requestId];
if (request.fulfilled)
(correctResults, isWinner) = getGameResults(
request.sides,
request.guesses
);
paid = request.paid;
fulfilled = request.fulfilled;
randomWords = request.randomWords;
sides = request.sides;
guesses = request.guesses;
}
/**
* @notice Calculate the game results based on the provided sides and guesses.
* @dev Compares the sides array with the user's guesses array and counts the correct results.
* If all guesses are correct, the user is considered a winner.
* @param sides An array of booleans representing the actual sides of the coin flips.
* @param guesses An array of booleans representing the user's guesses for the coin flips.
* @return correctResults The number of correct guesses made by the user.
* @return isWinner A boolean indicating whether the user has won the game (all guesses are correct).
*/
function getGameResults(
bool[] memory sides,
bool[] memory guesses
) private pure returns (uint8 correctResults, bool isWinner) {
for (uint8 i = 0; i < sides.length; i++) {
if (sides[i] == guesses[i]) correctResults++;
}
if (correctResults == sides.length) isWinner = true;
}
/**
* @notice Withdraws the LINK tokens from the contract to the owner's address.
* @dev This function can only be called by the contract owner.
* Reverts if the transfer of LINK tokens fails.
*/
function withdrawLink() public onlyOwner {
LinkTokenInterface link = LinkTokenInterface(linkAddress);
bool success = link.transfer(msg.sender, link.balanceOf(address(this)));
if (!success) revert UnableTransferLink();
}
}
La meilleure façon de comprendre la méthode Direct Funding de Chainlink VRF v2 est d’essayer le tutoriel Get a Random Number de la documentation officielle.
Un ou plusieurs nombres aléatoires sont demandés à un oracle, qui génère un tableau de nombres aléatoires et une preuve cryptographique. Ensuite, l’oracle renvoie les résultats dans un rappel. Cette séquence est connue sous le nom de Request and Receive cycle. Pour cette raison, il existe deux fonctions :
-
flip
: utilisée pour demander un nombre aléatoire. *Notez: Vous pourriez nommer votre fonction comme vous le souhaitez, tant que la fonction appellerequestRandomness
. -
fulfillRandomWords
: c’est la fonction de rappel où vous pouvez traiter les mots aléatoires reçus. La signature de la fonction ne peut pas être modifiée. En fait, remarquez que votre contrat hérite deVRFV2WrapperConsumerBase
et que le rappel est défini ici.
Analysons la nouvelle fonction flip
:
-
Elle reçoit un tableau booléen de
_guesses
en entrée, dont la longueur doit être de 10 (nous demandons aux joueurs de deviner les dix flips de la pièce à l’avance. Ils gagnent s’ils ont tout juste). -
Ensuite, elle appelle Chainlink VRF (
requestRandomness
) pour demander dix mots aléatoires. La fonction retourne un identifiant unique :requestId
. Notez que requestRandomness prend soin de payer l’oracle en jetons LINK dans la même transaction. Le montant est basé surcallbackGasLimit
, qui est la limite de la quantité de gaz à utiliser pour appeler la fonction de rappelfulfillRandomWords
plus une prime. Le modèle des coûts est détaillé dans la documentation officielle. -
L’identifiant de la demande, le montant payé et les valeurs devinées par le joueur
_guesses
sont stockés dans la table de hachagerequests
.requests[requestId] = RequestStatus({ paid: VRF_V2_WRAPPER.calculateRequestPrice(callbackGasLimit), randomWords: new uint256[](0), fulfilled: false, sides: new bool[](0), guesses: _guesses });
D’autre part, la fonction de rappel fulfillRandomWords
traite les mots aléatoires reçus :
-
Marque la demande comme étant remplie et stocke les mots aléatoires reçus.
requests[_requestId].fulfilled = true; requests[_requestId].randomWords = _randomWords;
-
Ensuite, la fonction calcule
side
pour chaque mot aléatoire reçu en le divisant parFACTOR
(logique similaire au défi Ethernaut CoinFlip).bool[] memory sides = new bool[](10); uint256 coinFlip; for (uint8 i = 0; i < _randomWords.length; i++) { coinFlip = _randomWords[i] / FACTOR; sides[i] = coinFlip == 1 ? true : false; } requests[_requestId].sides = sides;
-
Ensuite, la fonction appelle une fonction privée
getGameResults
qui compare les valeurs devinées par les joueurs avec les nombres aléatoires. La fonction renvoie le nombre de résultats correctscorrectResults
et un booléenisWinner
défini à true si le joueur a tout deviné correctement. -
Enfin, il émet un événement
GameResult
avec les résultats du jeu.
Notez qu’à tout moment, un joueur peut appeler getRequestStatus
pour obtenir les résultats d’un jeu spécifique (identifié de manière unique par _requestId
).
Test
Maintenant, testons le contrat CoinFlipFix
:
-
Ouvrez Remix IDE. Créez un nouveau fichier Solidity
CoinFlipFix.sol
et copiez/collez le code ci-dessus. -
Compilez le contrat, puis déployez-le sur le testnet Sepolia.
-
Alimentez votre contrat en tokens LINK (~3 tokens LINK par appel de
flip
). Vous pouvez suivre ce tutoriel pour apprendre à financer un contrat. -
Maintenant, essayez d’obtenir dix résultats de lancer de pièce. Par exemple :
[false,false,true,true,true,true,false,true,true,false]
. Ensuite, cliquez surtransact
. -
Metamask s’ouvre et vous demande de confirmer la transaction. Note importante: Remix IDE ne définit pas la bonne limite de gaz. Pour que cet exemple fonctionne, définissez une limite de gaz de 400 000, comme expliqué ici.
-
Une fois confirmé, cliquez sur
lastRequestId
pour obtenir l’ID de la requête. -
Attendez quelques minutes, puis cliquez sur
getRequestStatus
avec votre ID de requête. Dans mon test, j’ai obtenu six résultats corrects. -
Jouez plusieurs fois et voyez si vous pouvez gagner le jeu 🙂.
Réflexions finales
Comme discuté au début, l’aléatoire est essentiel pour de nombreux projets : NFTs, jeux, loteries, etc. Lors du développement d’un contrat intelligent, vous devez prêter une grande attention à l’expérience utilisateur ainsi qu’à leur sécurité : s’appuyer sur des solutions hors chaîne non sécurisées (par exemple, des oracles sans aucune vérification cryptographique) ou des solutions de contournement sur chaîne (par exemple, blockhashes) doit être exclu, et vous devriez vous appuyer sur des oracles qui fournissent une randomisation qui peut être vérifiée cryptographiquement sur chaîne. Pour en savoir plus sur Chainlink VRF :