This is a premium alert message you can set from Layout! Get Now!

How to build a random number generator for the blockchain

0

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

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:

  1. Side A generates a random number, randomA
  2. Side A sends a message with the hash of that number, hash(randomA). This
    commits Side A to the value randomA, because while no one can guess the value of randomA, once side A provides it everyone can check that its value is correct
  3. Side B sends a message with another random number, randomB
  4. Side A reveals the value of randomA in a third message
  5. 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:

  1. 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
  2. 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:

  1. If the bet has already been accepted by someone, it cannot be accepted again
  2. If the sideA‘s address is zero, it means that no one actually made the bet
  3. sideB needs to bet the same amount as sideA
   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.

  1. The user did not place the bet
  2. 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 for sideA.
   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

Post a Comment

0 Comments
* Please Don't Spam Here. All the Comments are Reviewed by Admin.
Post a Comment

Search This Blog

To Top