High Stakes Roulette on Ethereum

Learn by Example: Building a secure High Stakes Roulette

It's always best to learn with examples. So let's build a little online casino on the blockchain. We'll also make it secure enough to allow playing in really high stakes by adding a secure randomness generator.

Let's discuss the overall design first.

Designing the contract

Before we program anything as developers, we properly plan and design our system. In our Roulette we need to figure out how we can create a random number, how we manage funds and how to handle timeouts.

Securely generating a random number

At the core of the contract will be a commitment scheme. If you want to learn more about randomness on the blockchain, check out my previous tutorial here. But in summary, there is no good direct source for randomness in the blockchain, since all code has to run deterministically.

Solution for low stakes: Using a future block hash is a possible solution, but miners have some influence on this value. They can choose to not publish a new block, foregoing the block reward. But if they are meanwhile playing a very high stakes game of Roulette, withholding a block might be the better strategy for them.

Solution for high stakes: So for a high stakes setup we need a better randomness generator. Luckily in a setup with two participants (bank and player), we can use the commitment scheme. Each player commits to secret random number by first sending the keccak256 commitment hash of that number. Once both hashes are in the contract, players can securely reveal the actual random number. The contract verifies that keccak256(randomNumber) == commitment hash, ensuring both parties cannot change the random number anymore. The final random number will be randomNumberBank XOR randomNumberPlayer. More details about this are explained in the linked tutorial above.

Random Number

Improving the high stakes design

Given this high stakes design, we can improve upon it for our blockchain casino. The improvements can be done in two ways:

  1. Having the bank pre-commit to a secret number sending the hash is actually sufficient. When the bank is committed, a player can directly reveal his value. This reduces a game round by one step, avoiding the player to also send a commitment hash.
  2. Assuming a player will not just play a single round, we can build a chain of commitment hashes. This chain is computed as keccak256(keccak256(keccak256( ... (randomNumber) ... ))). You basically calculate the hash of the hash of the hash, and so on, for millions of times. The last hash is later sent as the first commitment, while all other intermediate hashes are stored for later usage. Any secret number reveal from the bank will then automatically be the commitment for the next round.


Using those two improvements, a single game round is then reduced to

  1. Player sending a secret number along with his bet. For simplicity we will only allow betting on red or black, but it should be easy to extend the functionality.
  2. The bank sending the reveal which will automatically trigger the payout.

Funds Management

We don't want to send out funds for every single game round. So just like in the real world, our casino will have its own funds management. Players and the bank deposit funds in the contract and receive in-game funds. They can withdraw any unlocked funds or deposit more funds whenever they want.

Handling timeouts

In the commitment scheme has one way to manipulate a result: not revealing and thus preventing a round from finishing. To handle this case, we need an additional function for players that checks if the bank has not sent a reveal within a defined time. In that case the player wins automatically.

Managing funds in the contract

We can declare a simple mapping in the storage:

mapping (address => uint256) public registeredFunds;

In here we can add the deposited amount for an address upon sending ETH, or likewise remove the withdrawn amount upon taking ETH out. You could similarly just use an ERC-20 token instead of ETH.

We use the .call method instead of .transfer, as transfer is not a recommended way anymore for sending ETH.

function depositFunds() external payable {
  require(msg.value > 0, "Must send ETH");
  registeredFunds[msg.sender] += msg.value;
}

function withdrawFunds() external {
  require(registeredFunds[msg.sender] > 0);

  uint256 funds = registeredFunds[msg.sender];
  registeredFunds[msg.sender] = 0;

  (bool success, ) = msg.sender.call{value: funds}("");
  require(success, "ETH transfer failed");
}

Placing a bet

Next up let's create the actual game. A single round will be defined by our GameRound struct. We'll have the values for generating the random number:

  • bankHash
  • bankSecretValue
  • userValue

As well as the choice by the user if he bet on red or on black (better coding style may be to use an enum here with the two values RED and BLACK). And the size potential money to win as lockedFunds. For red/black bets this will be double the bet size. And we'll also need to store the time of placing the bet for the timeout function.

struct GameRound {
    bytes32 bankHash;
    uint256 bankSecretValue;
    uint256 userValue;
    bool hasUserBetOnRed;
    uint256 timeWhenSecretUserValueSubmitted;
    uint256 lockedFunds;
}

Now with that we can create a placeBet function. We'll make sure that the game is in the correct state and that enough funds exist for the bank and the player. We'll store the bet, lock the funds and store the time for the timeout.

Why do we store one bank hash per player? You may wonder why we don't just use one bank hash across all games. This seems tempting as it reduces the complexity for the bank. Unfortunately it would allow full manipulation of the randomness. Imagine multiple players betting at the same time. Now the bank could decide for which player to send the current reveal for. To prevent this we would need to enforce a strict order for each reveal according to the time of the bet. That would ultimately end up being more complex than having one hash per player.

Random 42
function placeBet(bool hasUserBetOnRed, uint256 userValue, uint256 betAmount) external {
    require(gameRounds[msg.sender].bankHash != 0x0, "Bank hash not yet set");
    require(gameRounds[msg.sender].userValue == 0, "Already placed bet");
    require(registeredFunds[bankAddress] >= betAmount, "Not enough bank funds");
    require(registeredFunds[msg.sender] >= betAmount, "Not enough user funds");

    gameRounds[msg.sender].userValue = userValue;
    gameRounds[msg.sender].hasUserBetOnRed = hasUserBetOnRed;
    gameRounds[msg.sender].lockedFunds = betAmount * 2;
    gameRounds[userAddress].timeWhenSecretUserValueSubmitted = block.timestamp;

    registeredFunds[msg.sender] -= _betAmount;
    registeredFunds[bankAddress] -= _betAmount;
}

You may have noticed that the bank hash would be empty before the first round. So we need two extra functions which are only ever used once at the beginning by a player. With initializeGame a player can request the bank to call setInitialBankHash.

function initializeGame() external {
    require(!hasRequestedGame[msg.sender],"Already requested");

    hasRequestedGame[msg.sender] = true;
    emit NewGameRequest(msg.sender);
}

The bank would have a server running that listens to the NewGameRequest event. Upon receipt of the event, it will set its initial bank hash.

function setInitialBankHash(
    bytes32 bankHash,
    address user
) external onlyOwner {
    require(
        gameRounds[user].bankHash == 0x0,
        "Bank hash already set"
    );
    gameRounds[user].bankHash = bankHash;
}

Sending the bank reveal

Now for the actual game, the bank needs to reveal the value. We require that the game round is indeed in the state for the bank to send the value. Also we ensure that the hashedReveal equals gameRounds[userAddress].bankHash, therefore enforcing that the bank cannot manipulate the randomness.

function sendBankSecretValue(uint256 bankSecretValue, address user) external {
    require(gameRounds[userAddress].userValue != 0, "User has no value set");
    require(gameRounds[userAddress].bankSecretValue == 0, "Already revealed");
    bytes32 hashedReveal = keccak256(abi.encodePacked(bankSecretValue));
    require(hashedReveal == gameRounds[userAddress].bankHash, "Bank reveal not matching commitment");

    gameRounds[userAddress].bankSecretValue = bankSecretValue;

    _evaluateBet(user);
    _resetContractFor(user);

    gameRounds[userAddress].bankHash = bytes32(bankSecretValue);
}

Then we evaluate the bet to find out who won. Lastly we reset the data for the next round which includes automatically setting the bank hash to the current secret number (according to our design described at the start).

function _resetContractFor(address user) private {
    gameRounds[user] = GameRound(0x0, 0, 0, false, 0, 0);
}

function _evaluateBet(address user) private {
    uint256 random = gameRounds[user].bankSecretValue
        ^ gameRounds[user].userValue;
    uint256 number = random % ROULETTE_NUMBER_COUNT;
    uint256 winningAmount = gameRounds[user].lockedFunds;

    bool isNeitherRedNorBlack = number == 0;
    bool isRed = isNumberRed[number];
    bool hasUserBetOnRed = gameRounds[user].hasUserBetOnRed;

    address winner;

    if (isNeitherRedNorBlack) winner = bankAddress;
    else if (isRed == hasUserBetOnRed) winner = userAddress;
    else winner = bankAddress;

    registeredFunds[winner] += winningAmount;
}

Evaluating a bet

As for the actual bet evaluation, we now have two randomly chosen numbers by player and bank. Using bitwise OR we can compute a final random number.

Using random % ROULETTE_NUMBER_COUNT, or in other words computing the random number modulo 37, we will get a random number between 0 and 36 with any number having the same chance to be chosen.

Now for evaluating the winner we have three cases:

  1. Number is 0: Bank wins (the bank profit)
  2. Color was guessed correctly by player
  3. Color was not guessed correctly by player

To determine if the color is red, we can use a storage bool[37] isNumberRed array with the definitions.

Handling the timeout

Now given the pre-defined timeout time TIMEOUT_FOR_BANK_REVEAL (for example 2 days), we can check for any timeouts. If the game is indeed waiting for the bank to send the reveal and is waiting for more than the timeout time, a player can call checkBankSecretValueTimeout and will automatically win the game round.

function checkBankSecretValueTimeout() external {
   require(gameRounds[msg.sender].bankHash != 0, "Bank hash not set");
   require(gameRounds[msg.sender].bankSecretValue == 0, "Bank secret is set");
   require(gameRounds[msg.sender].userValue != 0, "User value not set");

    uint256 timeout = (gameRounds[msg.sender].timeWhenSecretUserValueSubmitted + TIMEOUT_FOR_BANK_REVEAL);
    require(block.timestamp > timeout, "Timeout not yet reached");

    registeredFunds[msg.sender] += gameRounds[msg.sender].lockedFunds;
    _resetContractFor(msg.sender);
    hasRequestedGame[msg.sender] = false;
}

Fully working example + missing components

You can find a fully working example here. You should know though that this is only the contract side. As a bank provider you would need a backend server running that handles the logic for listening to new bets and sending the commitment hashes. Further gas improvements are possible by allowing the bank to submit multiple hashes for multiple players at the same time.

Also a nice frontend interface for the players is always welcome.


Markus Waas

Solidity Developer

More great blog posts from Markus Waas

© 2024 Solidity Dev Studio. All rights reserved.

This website is powered by Scrivito, the next generation React CMS.