Fix Ethernaut Lvl 3 Coin Flip

Amine El
14 min readJan 3, 2023

--

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

  • First, we’ll introduce Randomness and why it is challenging in Blockchains. (see Blockchain and Randomness)
  • Then, you will hack the Ethernaut Coin Flip challenge to demonstrate how to hack a smart contract that uses unbiased data. (see Problem with unsecured randomness: Coin Flip Challenge)
  • Finally, you will fix the Ethernault Coin Flip smart contract using a secure randomness source: Chainlink VRF. (see Solution: Coin Flip fix — Chainlink VRF)

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:

  • Proof of Stake: randomly select validator responsibilities.
  • NFTs: assign random attributes when generating NFTs.
  • Gaming: matchmaking, critical hits (battles)...ETc.

Read this blog for a more comprehensive list:

Now, one might ask the following questions:

  • Because blockchains are deterministic, how do these blockchain applications get randomness?
  • And, more importantly, how to ensure randomness is fair and no one can bias the system? Image a play-to-earn game using a biased randomness source…

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

  • Opaque Oracle operator: Request randomness from an Oracle operator who does not provide cryptographic guarantees for tamper resistance and fair generation. A malicious or compromised oracle could deliver biased data to your smart contracts and expose your users. For instance, you built an on-chain lottery and requested randomness from an opaque oracle to determine the winning numbers. If malicious, the oracle could participate in the lottery and generate “random” numbers suiting his game.
  • On-chain workaround: Rely on blocks’ timestamps or hashes. However, miners with skin in the game could decide when to “mine” a transaction. Thus, influencing the timestamp and hash values.

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:

  • The first line computes a “random” value based on the hash (blockhash )of the previous block (block.number — 1). Because blockhash returns a bytes32 value, the result is converted from bytes32 to uint256 to get an unsigned 256 bits integer.
uint256 blockValue = uint256(blockhash(block.number - 1));
  • Then the function enforces it is only called once within a given block: If it has already been called in the same block, the function reverts.
if (lastHash == blockValue) {
revert();
}

lastHash = blockValue;
  • coinFlip is calculated by dividing blockValue by Factor. Factor is a uint256 state variable. If the value of coinFlip is 1, then the side will be set to true; otherwise, it will be set to false. Remember that in Solidity, the division of integers results in an integer.
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
  • The contract has a counter (consecutiveWins). The calculated side is compared to the guessed value _guess. If the guessed value is correct, then the counter is incremented. If not, the counter resets.
 if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}

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:

  • Please open a terminal, clone the repo, and install all the dependencies
git@github.com:aelmanaa/CoinFlip.git && cd CoinFlip
yarn
  • Compile the contracts
yarn compile
  • In a new terminal, start a local blockchain environment
yarn start-node
  • Deploy CoinFlip
yarn deploy-coinflip
  • Note the address of the deployed contract.
  • Hack the contract
yarn flip <your-address>

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

On a Public testnet

  • Please open a terminal, clone the repo, and install all the dependencies
git@github.com:aelmanaa/CoinFlip.git && cd CoinFlip
yarn
  • Compile the contracts
yarn compile
  • Copy .env.example into .env to generate your environment file
cp .env.example .env
  • Open .env and fill in the required variables.
  • Deploy CoinFlip on the correct network. Example on Sepolia:
yarn deploy-coinflip --network sepolia
  • Note the address of the deployed contract.
  • Hack the contract. Note: The main challenge is ensuring the validators/miners include the transaction in the right block. Depending on the network congestion, you might have to wait until the challenge is solved. You might notice that the consecutive wins restart, which means that the validators didn’t include the transaction in the expected block.
yarn flip <your-address> --network sepolia

Solution: Coin Flip fix — Chainlink VRF v2

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.

Introduction to Chainlink VRF v2

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:

  • Metamask wallet.
  • Remix Development Environment. If you have never deployed a contract using Remix IDE, follow this beginner tutorial.
  • The test will be made on the Sepolia testnet. Therefore, you will need enough Sepolia ETH to deploy and interact with your contract. You will also need enough Sepolia LINK tokens to pay the Chainlink network to get secure randomness. You can get testnet LINKs from the Chainlink faucet.

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:

  • flip: used to request randomness. It could be any name you can think of as long as the function calls requestRandomness.
  • fulfillRandomWords: this is the callback function where you can process the received random words. The function signature cannot be changed. In fact, notice that your contract inherits from VRFV2WrapperConsumerBase and that the callback is defined here.

Let’s analyze the newflip function:

  • It receives a boolean array of _guesses as input, which length is expected to be 10 (We ask the players to guess the ten coin flips in advance. They win if they get everything right).
  • Then It calls Chainlink VRF (requestRandomness ) to request ten random words. The function returns a unique id: requestId. Note that requestRandomness takes care of paying the oracle in LINK tokens along with requesting randomness. The amount is based on callbackGasLimit, which is the limit for how much gas to use for calling the callback function fulfillRandomWords plus a premium. The cost is detailed in the developer documentation.
  • The requestId, the paid amount, and player-guessed values _guesses are stored in the requests mapping.
requests[requestId] = RequestStatus({
paid: VRF_V2_WRAPPER.calculateRequestPrice(callbackGasLimit),
randomWords: new uint256[](0),
fulfilled: false,
sides: new bool[](0),
guesses: _guesses
});

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

  • Marks the request as fulfilled and stores the received random words.
requests[_requestId].fulfilled = true;
requests[_requestId].randomWords = _randomWords;
  • It then calculates the side for each received random word by dividing it by FACTOR (similar logic to the Ethernaut CoinFlip challenge).
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;
  • Then it calls a private function getGameResults that compares player-guessed values with random sides. The function returns the number of correct results correctResults and a boolean isWinner set to true if the player got all the guesses right.
  • Finally, it emits an event GameResult with the game results.

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:

  • Open Remix IDE. Create a new solidity file CoinFlipFix.sol and copy/paste the code above into it.
  • Compile the contract, then deploy it on Sepolia testnet.
  • Fund your contract with LINK tokens (~3 LINK tokens per flip call). You can follow this tutorial to learn how to fund a contract.
  • Now try to get ten coin flips. For instance : [false,false,true,true,true,true,false,true,true,false]. Then click on transact.
  • Metamask opens and asks you to confirm the transaction. Important note: Remix IDE doesn’t set the right gas limit. For this example to work, set a gas limit of 400,000, as explained here.
  • Once confirmed, click on lastRequestId to fetch the request id.
  • Wait for a few minutes, then click on getRequestStatus with your request id. In my test, I got six correct results.
  • Play several times and see if you can win the game 🙂 .

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:

--

--

Amine El

Cloud architect , new into Blockchain. Passionate about Defi, Oracles & NFTs. Views are my own