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.
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.
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.
Given this high stakes design, we can improve upon it for our blockchain casino. The improvements can be done in two ways:
Using those two improvements, a single game round is then reduced to
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.
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.
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");
}
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:
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.
function placeBet(bool hasUserBetOnRed, uint256 userValue,uint256 _betAmount) external {
require(gameRounds[msg.sender].bankHash != 0x0, "Bank hash not yet set");
require(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;
}
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;
}
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:
To determine if the color is red, we can use a storage bool[37] isNumberRed
array with the definitions.
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;
}
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.
Solidity Developer
The Openzeppelin v4 contracts are now available in Beta and most notably come with Solidity 0.8 support. For older compiler versions, you'll need to stick with the older contract versions. The beta tag means there still might be small breaking changes coming for the final v4 version, but you can...
As we've discussed last week, flash loans are a commonly used pattern for hacks. But what exactly are they and how are they implemented in the contracts? As of right now each protocol has its own way of implementing flash loans. With EIP-3156 we will get a standardized interface. The standard was...
With the recent Yearn vault v1 hack from just a few days ago, we can see a new pattern of hacks emerging: Get anonymous ETH via tornado.cash . Use the ETH to pay for the hack transaction(s). Use a flash loan to decrease capital requirements. Create some imbalances given the large capital and...