Skip to content

Solve Ethernaut Level3 Coin Flip

Posted on:April 20, 2023 at 03:22 PM

In this guide, you will learn about generating randomness on blockchains:

Toc

Blockchain and Randomness

Randomness refers to a lack of predictability. For instance, the outcome of a dice roll is unpredictable.

Blockchains are deterministic systems whereby the same inputs always create the same outputs. This deterministic attribute allows blockchain validator nodes (in proof of stake consensus mechanism) or blockchain mining nodes (in proof of work consensus mechanism) to reach consensus. They must all reach the same outcome when executing a transaction. Thus, blockchain systems do not provide any native solution for generating randomness, which contradicts their deterministic attribute.

Does it mean that randomness is not used in blockchain? Not so fast… Randomness is already used (non-exhaustive list) in the following:

Read this blog for a more comprehensive list.

Now, one might ask the following questions:

To answer these questions, you could use (but please don’t 🙂) naive and, more importantly, unsecure solutions :

The solution is to use a provably fair RNG (Random Number Generator) such as Chainlink VRF, in which each random result is unbiased and cryptographically verified on-chain. To learn more about Verifiable Random Functions, you can read this article.

Problem with unsecured randomness: Coin Flip Challenge

To illustrate the severe risks of relying on on-chain solutions, let’s hack the Coin Flip Ethernaut challenge.

Objective

In this challenge, you must guess the outcome of flipping a coin. Guess the outcome ten times a row, and you win the challenge.

// 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;
        }
    }
}

Analyzing the contract

To guess the outcome of flipping a coin, one has to call the function flip and provide a boolean (true/false) as input. Let’s go through the flip function:

Hacking the contract

Let’s demonstrate that relying on blockhash for randomness is a bad idea and that we can trick the contract and always guess the outcome of flipping a coin.

The simplest solution is to deploy a contract with a function that calculates the expected coin flip using the same algorithm as the flip function and then calls CoinFlip contract with the expected result. There are several documented solutions online:

As a challenge, I wanted to hack the contract off-chain without deploying another contract. The solution works fine in a local blockchain environment. However, it is not always easy to use on a public testnet (e.g., Sepolia) as you are not 100% certain that the miners will include the transactions in the correct block. Note: If you can finetune the code to make it always work on Sepolia, please open a PR 😉 . The repo can be found here.

On a local Blockchain

If you want to experiment:

If you are interested in the flip script, you can find it here.

On a public testnet

Now that we have seen how we could accurately guess the outcome of the coinFlip function, let’s use Chainlink VRF to get secure randomness and fix the CoinFlip contract.

Chainlink Verifiable Random Function (VRF) is the industry-standard RNG solution, enabling smart contracts and off-chain systems to access a source of verifiable randomness using off-chain computation. You can learn more about Chainlink VRF here.

At the time of writing, there were two versions: v1 and v2. We are going to use v2 as it includes several improvements. Note that Chainlink VRF v2 offers two methods for requesting randomness. As the Chainlink developer documentation states:

Subscription: Create a subscription account and fund its balance with LINK tokens. Users can then connect multiple consuming contracts to the subscription account. When the consuming contracts request randomness, the transaction costs are calculated after the randomness requests are fulfilled, and the subscription balance is deducted accordingly. This method allows you to fund requests for multiple consumer contracts from a single subscription.

Direct funding: Consuming contracts directly pay with LINK when they request random values. You must directly fund your consumer contracts and ensure there are enough LINK tokens to pay for randomness requests.

Because we will deploy one consumer contract and use it for a “one-off” request, the Direct Funding method seems more suitable to our use case.

Prerequisites

To run the next tutorial, you will need:

CoinFlip Fix

Here below is a CoinFlipFix contract. Please be aware that some variables are hardcoded and defined as state variables for educational purposes, making this contract unsuitable for production deployment.

// 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();
    }
}

The best way to understand the Chainlink VRF v2 Direct Funding method is to try the Get a Random Number from the official docs. It is a quick tutorial that will teach you the required imports and configuration to get randomness.

Randomness is requested from an oracle service, which generates an array of random numbers and a cryptographic proof. Then, the oracle returns the results in a callback. This sequence is known as the Request and Receive cycle. For that reason, there are two functions:

Let’s analyze the new flip function:

On the other hand, the callback function fulfillRandomWords processes the received random words:

Note that at any time, a player can call getRequestStatus to get the results of a specific game (uniquely identified by _requestId).

Test

Now let’s test the CoinFlipFix contract:

Closing thoughts

As discussed in the beginning, Randomness is essential for many projects: NFTs, gaming, lotteries…ETc. When developing a smart contract, you have to pay great attention to the user experience and security of the users: Relying on unsecure off-chain solutions (e.g., oracles without any cryptographic verification) or on-chain workarounds (e.g., blockhashes) must be a no-go, and you should rely on oracles that provide tamper-proof randomness that can be cryptographically verified on-chain. To learn more about Chainlink VRF: