The very structure of a blockchain relies on determinism. In a blockchain ecosystem, each network’s state is public; there is complete transparency. If you know the state and the input, you can calculate the output. Determinism relates to consensus, which is what enables a blockchain’s progress to be verified. Without this determinism, independent verification of the blockchain’s progress would be impossible, as the blockchain would no longer be decentralized.
For most use cases, random numbers cannot be known until they are actually used. So this means that the very foundations of a blockchain, transparency and consensus, make the generation of random numbers quite difficult.
In this article, we’ll cover how to overcome the restrictions for generating random numbers for a blockchain. We’ll walk through how to build and test a Solidity contract for a casino betting game that utilizes random numbers. We’ll also discuss some strategies for preventing abuse in a blockchain betting game.
N.B., after The Merge there may be a source of randomness on the EVM itself; however, even if EIP 4499 is implemented, the randomness will still be far from perfect
Jump ahead:
- Use cases for randomness
- The commit/reveal protocol
- Betting game tutorial
- Preventing abuse in the betting game
Use cases for randomness
For some purposes, such as statistical sampling, it can be sufficient to use pseudorandom numbers — which appear to be random but have actually been generated by a deterministic process. However, there are some use cases where a seemingly random number that can be predicted is simply not good enough.
Let’s look at some examples.
NFTs
Many NFT projects, such as OptiPunks, Optimistic Bunnies, and Optimistic Loogies, randomly assign attributes to their NFTs when they are minted. As some attributes are more valuable than others, the result of the mint must remain unknown to the minter until after the mint.
Games
Lots of games rely on randomness, either for making decisions or for generating information that is supposed to be hidden from the player. Without randomness, blockchain games would be limited to those in which all the information is known to all players, such as chess or checkers.
The commit/reveal protocol
So, how do we generate random numbers on the blockchain, which is fully transparent? Remember, there are “no secrets on the blockchain”.
Well, the answer lies in those last three words, “on the blockchain”. To generate random numbers, we’ll use a secret number that one side of the interaction has and the other does not. However, we’ll make sure that the secret number is not on the blockchain.
The commit/reveal protocol allows two or more people to arrive at a mutually agreed upon random value using a cryptographic hash function. Let’s take a look at how it works:
- Side A generates a random number,
randomA
- Side A sends a message with the hash of that number,
hash(randomA)
. This
commits Side A to the valuerandomA
, because while no one can guess the value ofrandomA
, once side A provides it everyone can check that its value is correct - Side B sends a message with another random number,
randomB
- Side A reveals the value of
randomA
in a third message - Both sides accept that the random number is
randomA ^ randomB
, the exclusive or (XOR) of the two values
The advantage of XOR here is that it is determined equally by both sides, so neither can choose an advantageous “random” value.
Casino betting game tutorial
In order to see how a random number generator can be used in an actual blockchain game, we’ll review the code for Casino.sol, a casino betting game. Casino.sol is written in Solidity and uses the commit/reveal scheme; it may be accessed on GitHub.
Setting up the contract
Let’s walk through the code for the Casino.sol betting game; it’s in this GitHub file.
First, we specify the license and Solidity version:
//SPDX-License-Identifier: Unlicense pragma solidity ^0.8.0;
Next, we define a contract called Casino
. Solidity contracts are somewhat similar to objects in other programming languages.
contract Casino {
Now, we create a struct, ProposedBet
, where we’ll store information about proposed bets:
struct ProposedBet { address sideA; uint value; uint placedAt; bool accepted; } // struct ProposedBet
This struct does not include the commitment, the hash(randomA)
value, because that value is used as the key to locate the ProposedBet
. However, it does contain the following fields:
Field | Type | Purpose |
---|---|---|
sideA |
address | the address that proposes the bet |
value |
integer | the size of the bet in Wei, the smallest denomination of Ether |
placedAt |
integer | the timestamp of the proposal |
accepted |
Boolean | whether the proposal has been accepted |
N.B., the placedAt
field is not used in this example, but I’ll explain later in this article why it’s important to keep track of this information
Next, we create an AcceptedBet
struct to store the extra information after the bet is accepted.
An interesting difference here is that sideB
provides us with randomB
directly, rather than a hash.
struct AcceptedBet { address sideB; uint acceptedAt; uint randomB; } // struct AcceptedBet
Here are the mappings that store the proposed and accepted bets:
// Proposed bets, keyed by the commitment value mapping(uint => ProposedBet) public proposedBet; // Accepted bets, also keyed by commitment value mapping(uint => AcceptedBet) public acceptedBet;
Next, we set up an event, BetProposed
. Events are the standard mechanism used in Solidity smart contracts to send messages to the outside world. This event tells the world that a user (in this case, sideA
) is proposing a bet and for how much.
event BetProposed ( uint indexed _commitment, uint value );
Now, we set up another event, BetAccepted
. This event tells the world (and specifically sideA
, who proposed the bet), that it’s time to reveal randomA
. There’s no way to send a message from the blockchain only to a specific user.
event BetAccepted ( uint indexed _commitment, address indexed _sideA );
Next, we create an event, BetSettled
. This event is emitted when the bet is settled.
event BetSettled ( uint indexed _commitment, address winner, address loser, uint value );
Now, we create a proposeBet
function. The commitment is the sole parameter to this function.
Everything else (the value of the bet and the identity of sideA
) is available as part of the transaction.
Notice that this function is payable
. This means that it can accept Ether in payment.
// Called by sideA to start the process function proposeBet(uint _commitment) external payable {
Most externally called functions, like proposedBet
shown here, start with a bunch of require
statements.
require(proposedBet[_commitment].value == 0, "there is already a bet on that commitment"); require(msg.value > 0, "you need to actually bet something");
When we write a smart contract we must assume that an attempt will be made to call the function maliciously. This assumption will prompt us to put protections in place.
In the above code, we have two conditions:
- If there is already a bet on the commitment, reject this one. Otherwise, people might try to use it to overwrite existing bets, which would cause the amount
sideA
put in to get stuck in the contract forever - If the bet is for 0 Wei, reject it
If neither of these two conditions is met, we write the information to proposedBet
.
Because of the way Ethereum storage works, we don’t need to create a new struct, fill it, and then assign it to the mapping. Instead, there’s already a struct for every commitment value, filled with zeros — we just need to modify it.
proposedBet[_commitment].sideA = msg.sender; proposedBet[_commitment].value = msg.value; proposedBet[_commitment].placedAt = block.timestamp; // accepted is false by default
Now, we tell the world about the proposed bet and the amount:
emit BetProposed(_commitment, msg.value); } // function proposeBet
We need two parameters to know what the user is accepting: the commitment and the user’s random value.
// Called by sideB to continue function acceptBet(uint _commitment, uint _random) external payable {
In the below code, we check for three potential issues before accepting the bet:
- If the bet has already been accepted by someone, it cannot be accepted again
- If the
sideA
‘s address is zero, it means that no one actually made the bet sideB
needs to bet the same amount assideA
require(!proposedBet[_commitment].accepted, "Bet has already been accepted"); require(proposedBet[_commitment].sideA != address(0), "Nobody made that bet"); require(msg.value == proposedBet[_commitment].value, "Need to bet the same amount as sideA");
If all the requirements have been met, we create the new AcceptedBet
, mark in the proposedBet
that it had been accepted, and then emit a BetAccepted
message.
acceptedBet[_commitment].sideB = msg.sender; acceptedBet[_commitment].acceptedAt = block.timestamp; acceptedBet[_commitment].randomB = _random; proposedBet[_commitment].accepted = true; emit BetAccepted(_commitment, proposedBet[_commitment].sideA); } // function acceptBet
This next function is the great reveal
!
sideA
reveals randomA
, and we are able to see who won:
// Called by sideA to reveal their random value and conclude the bet function reveal(uint _random) external {
We don’t need the commitment itself as a parameter, because we can derive it from randomA
.
uint _commitment = uint256(keccak256(abi.encodePacked(_random)));
To reduce the risk of accidentally sending ETH to addresses where it will get stuck, Solidity only permits us to send it to addresses of the type address payable
.
address payable _sideA = payable(msg.sender); address payable _sideB = payable(acceptedBet[_commitment].sideB);
The agreed random value is an XOR of the two random values, as explained below:
uint _agreedRandom = _random ^ acceptedBet[_commitment].randomB;
We’re going to use the value of the bet in multiple places within the contract, so for brevity and readability, we’ll create another variable, _value
, to hold it.
uint _value = proposedBet[_commitment].value;
There are two cases in which that proposedBet[_commitment].sideA == msg.sender
does not equal to the commitment.
- The user did not place the bet
- The value provided as
_random
is incorrect. In this case,_commitment
will be a different value and therefore the proposed bet in that location won’t have the correct value forsideA
.
require(proposedBet[_commitment].sideA == msg.sender, "Not a bet you placed or wrong value");
require(proposedBet[_commitment].accepted, "Bet has not been accepted yet");
The above proposedBet[_commitment].accepted
function will only makes sense after the bet has been accepted.
Next, we use the least significant bit of the value to decide the winner:
// Pay and emit an event if (_agreedRandom % 2 == 0) {
Here, we give the winner the bet and emit a message to tell the world the bet has been settled.
// sideA wins _sideA.transfer(2*_value); emit BetSettled(_commitment, _sideA, _sideB, _value); } else { // sideB wins _sideB.transfer(2*_value); emit BetSettled(_commitment, _sideB, _sideA, _value); }
Now, we’ll delete the bet storage, which is no longer needed.
Anybody can look back in the blockchain and see what the commitment was and the revealed value of the bet. The purpose of deleting this data is to collect the gas refund for cleaning storage that is no longer needed.
// Cleanup delete proposedBet[_commitment]; delete acceptedBet[_commitment];
Finally, we have the end of the function and contract:
} // function reveal } // contract Casino
Transacting with a rollup
As of this writing, the most economical way to transact on Ethereum is to use a rollup.
Basically, a rollup is a blockchain that writes all transactions to Ethereum, but runs the processing somewhere else where it is cheaper. Remember, anyone can verify the blockchain state, because Ethereum is uncensorable.
The state root is then posted to Layer 1, and there are guarantees (either mathematical or economical) that it is the correct value. By using the state root, it’s possible to prove any part of the state — for example, to prove ownership of something.
This mechanism means that processing (which can be done on the rollup, or Layer 2) is very cheap, and the transaction data (which has to be stored on Ethereum, or Layer 1) is by comparison very expensive. For example, at the time of writing, Layer 1 gas is 20,000 times the cost of Layer 2 gas in the rollup I use. You can check here to see the current ratio of Layer 1 to Layer 2 gas prices.
For this reason, reveal
only takes randomA
.
I could have written the Casino.sol game to also get the value of the commitment, and then it could distinguish between incorrect values and bets that don’t exist. However, on a rollup, this would significantly increase the cost of the transaction.
Testing the contract
casino-test.js is the JavaScript code that tests the Casino.sol contract. It is repetitive, so I’ll only explain the interesting parts.
The hash function on the ethers package ethers.utils.keccak256
accepts a string that contains a hexadecimal number. This number is not converted to 256bits if it is smaller, so for example 0x01
, 0x0001
, and 0x000001
all hash to different values. To create a hash that would be identical to the one produced on Solidity, we would need a 64-character number, even if it is 0x00..00
. Using the hash function here is a simple way to make sure the value we generate is 32bytes.
const valA = ethers.utils.keccak256(0xBAD060A7)
We want to check both possible results: a sideA
win and a sideB
win.
If the value sideB
sends is the same as the one hashed by sideA
, the result is zero (any number xor itself is zero), and therefore sideB
loses.
const hashA = ethers.utils.keccak256(valA) const valBwin = ethers.utils.keccak256(0x600D60A7) const valBlose = ethers.utils.keccak256(0xBAD060A7)
When using the Hardhat EVM for local testing, the revert reason is provided as a Buffer
object inside the stack trace. When connecting to an actual blockchain, we get it in the reason
field.
This function lets us ignore this difference in the rest of the code.
// Chai's expect(<operation>).to.be.revertedWith behaves // strangely, so I'm implementing that functionality myself // with try/catch const interpretErr = err => { if (err.reason) return err.reason else return err.stackTrace[0].message.value.toString('ascii') }
Below is the standard way to use the Chai testing library. We describe
a piece of code with a number of it
statements to denote actions that should happen.
describe("Casino", async () => { it("Not allow you to propose a zero Wei bet", async () => {
Here’s the standard Ethers mechanism for creating a new instance of a contract:
f = await ethers.getContractFactory("Casino") c = await f.deploy()
By default, transactions have a value
(amount of attached Wei) of zero.
try { tx = await c.proposeBet(hashA)
The function call tx.wait()
returns a Promise
object. The expression await <Promise>
pauses until the promise is resolved, and then either continues (if the promise is resolved successfully) or throws an error (if the promise ends with an error).
rcpt = await tx.wait()
If there is no error, it means that a zero Wei bet was accepted. This means the code failed the test.
// If we get here, it's a fail expect("this").to.equal("fail")
Here we catch the error and verify that the error matches the one we’d expect from the Casino.sol contract.
If we run using the Hardhat EVM, the Buffer we get back includes some other characters, so it’s easiest to just match
to make sure we see the error string rather than check for equality.
} catch(err) { expect(interpretErr(err)).to .match(/you need to actually bet something/) } }) // it "Not allow you to bet zero Wei"
The other error conditions, such as this one, are pretty similar:
it("Not allow you to accept a bet that doesn't exist", async () => { . . . }) // it "Not allow you to accept a bet that doesn't exist"
To change the default behavior of contract interaction (for example, to attach a payment to the transaction), we add an override hash as an extra parameter. In this case, we send 10Wei to test if this kind of bet is accepted:
it("Allow you to propose and accept bets", async () => { f = await ethers.getContractFactory("Casino") c = await f.deploy() tx = await c.proposeBet(hashA, {value: 10})
If a transaction is successful, we get the receipt when the promise of tx.wait()
is resolved.
Among other things, that receipt has all the emitted events. In this case, we expect to have one event: BetProposed
.
Of course, in production-level code we’d also check that the parameters emitted are correct.
rcpt = await tx.wait() expect(rcpt.events[0].event).to.equal("BetProposed") tx = await c.acceptBet(hashA, valBwin, {value: 10}) rcpt = await tx.wait() expect(rcpt.events[0].event).to.equal("BetAccepted") }) // it "Allow you to accept a bet"
Sometimes we need to have a few successful operations to get to the failure we want to test, such as an attempt to accept a bet that has already been accepted:
it("Not allow you to accept an already accepted bet", async () => { f = await ethers.getContractFactory("Casino") c = await f.deploy() tx = await c.proposeBet(hashA, {value: 10}) rcpt = await tx.wait() expect(rcpt.events[0].event).to.equal("BetProposed") tx = await c.acceptBet(hashA, valBwin, {value: 10}) rcpt = await tx.wait() expect(rcpt.events[0].event).to.equal("BetAccepted")
In this example, if the bet had already been accepted, the transaction will revert, but it will still stay on the blockchain. This means that if sideA
reveals prematurely, anyone else can accept the bet with a winning value.
try { tx = await c.acceptBet(hashA, valBwin, {value: 10}) rcpt = await tx.wait() expect("this").to.equal("fail") } catch (err) { expect(interpretErr(err)).to .match(/Bet has already been accepted/) } }) // it "Not allow you to accept an already accepted bet"
it("Not allow you to accept with the wrong amount", async () => { . . . }) // it "Not allow you to accept with the wrong amount" it("Not allow you to reveal with wrong value", async () => { . . . }) // it "Not allow you to accept an already accepted bet" it("Not allow you to reveal before bet is accepted", async () => { . . . }) // it "Not allow you to reveal before bet is accepted"
So far, we’ve used a single address for everything. However, to check a bet between two users we need to have two user addresses.
We’ll use Hardhat’s ethers.getSigners()
to get an array of signers; all addresses are derived from the same mnemonic. Then, we’ll use the Contract.connect
method to get a contract object that goes through one of those signers.
it("Work all the way through (B wins)", async () => { signer = await ethers.getSigners() f = await ethers.getContractFactory("Casino") cA = await f.deploy() cB = cA.connect(signer[1])
In this system, Ether is used both as the asset being gambled and as the currency used to pay for transactions. As a result, the change in sideA
‘s balance is partially the result of paying for the reveal
transaction.
To see how the balance changed because of the bet, we look at sideB
.
We check the preBalanceB
:
. . . // A sends the transaction, so the change due to the // bet will only be clearly visible in B preBalanceB = await ethers.provider.getBalance(signer[1].address)
And compare it to the postBalanceB
:
tx = await cA.reveal(valA) rcpt = await tx.wait() expect(rcpt.events[0].event).to.equal("BetSettled") postBalanceB = await ethers.provider.getBalance(signer[1].address) deltaB = postBalanceB.sub(preBalanceB) expect(deltaB.toNumber()).to.equal(2e10) }) // it "Work all the way through (B wins)" it("Work all the way through (A wins)", async () => { . . . . expect(deltaB.toNumber()).to.equal(0) }) // it "Work all the way through (A wins)" }) // describe("Casino")
Preventing abuse in the betting game
When you write a smart contract you should consider how hostile users might try to abuse it and then implement strategies to prevent those actions.
Protecting from a never reveal
Since there is nothing in the contract that obligates sideA
to reveal the random number, a spiteful, losing sideA
could avoid issuing the reveal
transaction and prevent sideB
from collecting on the bet.
Fortunately, this problem has an easy solution: Keep a timestamp of when sideB
accepted the bet. If a predefined length of time has passed since the timestamp, and sideA
has not responded with a valid reveal
, let sideB
issue a forfeit
transaction to collect on the bet.
This is the reason for keeping track of the time at which a function is called, the placedAt
field created earlier.
Protecting from frontrunning
Ethereum transactions don’t get executed immediately. Instead, they are placed into an entity called the mempool, and miners (or block proposers after The Merge) get to choose which transactions to put in the block they submit.
Typically, the transactions chosen are the ones that agree to pay the most gas, and therefore provide the most profit.
As soon as the sideA
sees sideB
‘s acceptBet
transaction in the mempool, with a random value that would cause sideA
to lose, sideA
can issue a different acceptBet
transaction (possibly from a different address).
If sideA
‘s acceptBet
transaction gives the miner more gas, we can expect the miner to execute their transaction first. This way, sideA
could withdraw from the bet instead of losing it.
This strategy, called frontrunning, is made possible by the decentralized structure of Ethereum and the information asymmetry between sideA
and sideB
after sideB
submits the acceptBet
transaction.
We can’t address the decentralization; the mempool has to be available, at least for miners (and stakers after The Merge), for the network to be uncensorable.
However, we can prevent frontrunning by removing the asymmetry.
When sideB
submits the acceptBet
transaction, sideA
already knows randomA
and randomB
, and can therefore see who won. However, sideB
has no idea until the reveal
.
If sideB
‘s acceptBet
only discloses hash(randomB)
, then sideA
doesn’t know who won either, making it useless to front run the transaction. Then, once sideB
‘s acceptance of the bet is part of the blockchain, both sideA
and sideB
can issue reveal transactions.
Once one of the sides issues a reveal
the other side knows who won, but if we add forfeit
transactions there is no advantage to refusing to reveal beyond the small charge for the transaction itself.
One potential issue to be aware of is that sideB
could make the exact same commitment as sideA
. Then, when sideA
reveals, sideB
can reveal the same number. The XOR of a number with itself is always zero. However, because of the way this particular game is written, in this scenario sideB
would simply be ensuring that sideA
wins.
Conclusion
In this article, we reviewed a casino betting game Solidity contract line by line to demonstrate how to build a random number generator for the blockchain. Creating random numbers on a deterministic machine is not trivial, but by offloading the task to the users we managed to achieve a pretty good solution.
We also reviewed several strategies to prevent abuse or hostile actions in the blockchain betting game.
As long as both sides have an interest in the result being random, we can be assured of the outcome.
The post How to build a random number generator for the blockchain appeared first on LogRocket Blog.
from LogRocket Blog https://ift.tt/blRSwe7
via Read more