Aller au contenu

Résoudre le niveau 3 d'Ethernaut "Coin Flip"

Posted on:20 avril 2023 at 15:22

Dans ce guide, vous apprendrez à générer des nombres aléatoires dans la blockchain:

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 :

Lisez ce blog pour une liste plus complète.

Maintenant, on pourrait se poser les questions suivantes :

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 :

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 :

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:

Si vous êtes intéressé par le script, vous pouvez le trouver ici.

Sur un testnet public

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.

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 :

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 :

Analysons la nouvelle fonction flip :

D’autre part, la fonction de rappel fulfillRandomWords traite les mots aléatoires reçus :

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:

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 :