A Game with an oracle

In this small tutorial, we will see how to write a chance game on the Dune blockchain with Liquidity and a small external oracle which provides random numbers.

Principle of the game

Rules of the game are handled by a smart contract on the Dune blockchain.

When a player decides to start a game, she must start by making a transaction (i.e. a call) to the game smart contract with a number parameter (let’s call it n) between 0 and 100 (inclusively). The amount that is sent with this transaction constitute her bet b.

A random number r is then chosen by the oracle and the outcome of the game is decided by the smart contract.

  • The player loses if her number n is greater than r. In this case, she forfeits her bet amount and the game smart contract is resets (the money stays on the game smart contract).
  • The player wins if her number n is smaller or equal to r. In this case, she gets back her initial bet b plus a reward which is proportional to her bet and her chosen number b * n / 100. This means that a higher number n, while being a riskier choice (the following random number must be greater), yields a greater reward. The edge cases being n = 0 is an always winning input but the reward is always null, and n = 100 wins only if the random number is also 100 but the player doubles her bet.

Architecture of the DApp

Everything that happens on the blockchain is deterministic and reproducible which means that smart contracts cannot generate random numbers securely [1] .

The following smart contract works in this manner. Once a user starts a game, the smart contract is put in a state where it awaits a random number from a trusted off-chain source. This trusted source is our random generator oracle. The oracle monitors the blockchain and generates and sends a random number to the smart contract once it detects that it is waiting for one.

../_images/game_arch.jpg

Because the oracle waits for a play transaction to be included in a block and sends the random number in a subsequent block, this means that a game round lasts at least two blocks [2] .

This technicality forces us to split our smart contract into two distinct entry points:

  1. A first entry point play is called by a player who wants to start a game (it cannot be called twice). The code of this entry point saves the game parameters in the smart contract storage and stops execution (awaiting a random number).
  2. A second entry point finish, which can only be called by the oracle, accepts random numbers as parameter. The code of this entry point computes the outcome of the current game based on the game parameters and the random number, and then proceeds accordingly. At the end of finish the contract is reset and a new game can be started.

The Game Smart Contract

The smart contract game manipulates a storage of the following type:

type game = {
  number : nat;
  bet : tez;
  player : key_hash;
}

type storage = {
  game : game option;
  oracle_id : address;
}

The storage contains the address of the oracle, oracle_id. It will only accept transactions coming from this address (i.e. that are signed by the corresponding private key). It also contains an optional value game that indicates if a game is being played or not.

A game consists in three values, stored in a record:

  1. number is the number chosen by the player.
  2. bet is the amount that was sent with the first transaction by the player. It constitute the bet amount.
  3. player is the key hash (tz1…) on which the player who made the bet wishes to be payed in the event of a win (we ask for a key_hash so that the payout operation cannot fail).

We also give an initializer function that can be used to deploy the contract with an initial value. It takes as argument the address of the oracle, which cannot be changed later on.

let%init storage (oracle_id : address) =
  { game = None; oracle_id }

The play entry point

The first entry point, play takes as argument a pair composed of:

  • a natural number, which is the number chosen by the player
  • and a key hash, which is the address on which a player wishes to be payed as well as the current storage of the smart contract.
let%entry play (number : nat) storage = ...

The first thing this contract does is validate the inputs:

  1. Ensure that the number is a valid choice, i.e. is between 0 and 100 (natural numbers are always greater or equal to 0).

    if number > 100p then failwith "number must be <= 100";
    
  2. Ensure that the contract has enough funds to pay the player in case she wins. The highest paying bet is to play 100 which means that the user gets payed twice its original bet amount. At this point of the execution, the balance of the contract is already credited with the bet amount, so this check comes to ensuring that the balance is greater than twice the bet.

    if 2p * Current.amount () > Current.balance () then
      failwith "I don't have enough money for this bet";
    
  3. Ensure that no other game is currently being played so that a previous game is not erased.

    match storage.game with
    | Some g ->
      failwith ("Game already started with", g)
    | None ->
      (* Actual code of entry point *)
    

The rest of the code for this entry point consist in simply creating a new game record { number; bet; player } and saving it to the smart contract’s storage. This entry point always returns an empty list of operations because it does not make any contract calls or transfers.

let bet = Current.amount () in
let storage = storage.game <- Some { number; bet; player } in
([], storage)

The new storage is returned and the execution stops at this point, waiting for someone (the oracle) to call the finish entry point.

The finish entry point

The second entry point, finish takes as argument a natural number parameter, which is the random number generated by the oracle, as well as the current storage of the smart contract.

let%entry finish (random_number : nat) storage = ...

The random number can be any natural number (these are mathematically unbounded natural numbers) so we must make sure it is between 0 and 100 before proceeding. Instead of rejecting too big random numbers, we simply (Euclidean) divide it by 101 and keep the remainder, which is between 0 and 100. The oracle already generates random numbers between 0 and 100 so this operation will do nothing but is interesting to keep if we want to replace the random generator one day.

let random_number = match random_number / 101p with
  | None -> failwith ()
  | Some (_, r) -> r in

Smart contracts are public objects on the Dune blockchain so anyone can decide to call them. This means that permissions must be handled by the logic of the smart contract itself. In particular, we don’t want finish to be callable by anyone, otherwise it would mean that the player could choose its own random number. Here we make sure that the call comes from the oracle.

if Current.sender () <> storage.oracle_id then
  failwith ("Random numbers cannot be generated");

We must also make sure that a game is currently being played otherwise this random number is quite useless.

match storage.game with
| None -> failwith "No game already started"
| Some game -> ...

The rest of the code in the entry point decides if the player won or lost, and generates the corresponding operations accordingly.

if random_number < game.number then
  (* Lose *)
  []

If the random number is smaller that the chosen number, the player lost. In this case no operation is generated and the money is kept by the smart contract.

else
  (* Win *)
  let gain = match (game.bet * game.number / 100p) with
    | None -> 0tz
    | Some (g, _) -> g in
  let reimbursed = game.bet + gain in
  [ Account.transfer ~dest:game.player ~amount:reimbursed ]

Otherwise, if the random number is greater or equal to the previously chosen number, then the player won. We compute her gain and the reimbursement value (which is her original bet + her gain) and generate a transfer operation with this amount.

let storage = storage.game <- None in
(ops, storage)

Finally, the storage of the smart contract is reset, meaning that the current game is erased. The list of generated operations and the reset storage is returned.

A safety entry point: default

At anytime we authorize anyone (most likely the manager of the contract) to add funds to the contract’s balance. This allows new players to participate in the game even if the contract has been depleted, by simply adding more funds to it.

let%entry fund () storage =
  [], storage

This code does nothing, excepted accepting transfers with amounts.

Full Liquidity Code of the Game Smart Contract

Try online
type game = {
  number : nat;
  bet : tez;
  player : key_hash;
}

type storage = {
  game : game option;
  oracle_id : address;
}

let%init storage (oracle_id : address) =
  { game = None; oracle_id }

(* Start a new game *)
let%entry play ((number : nat), (player : key_hash)) storage =
  if number > 100p then failwith "number must be <= 100";
  if Current.amount () = 0tz then failwith "bet cannot be 0tz";
  if 2p * Current.amount () > Current.balance () then
    failwith "I don't have enough money for this bet";
  match storage.game with
  | Some g ->
    failwith ("Game already started with", g)
  | None ->
    let bet = Current.amount () in
    let storage = storage.game <- Some { number; bet; player } in
    ([], storage)

(* Receive a random number from the oracle and compute outcome of the
   game *)
let%entry finish (random_number : nat) storage =
  let random_number = match random_number / 101p with
    | None -> failwith ()
    | Some (_, r) -> r in
  if Current.sender () <> storage.oracle_id then
    failwith ("Random numbers cannot be generated");
  match storage.game with
  | None -> failwith "No game already started"
  | Some game ->
    let ops =
      if random_number < game.number then
        (* Lose *)
        []
      else
        (* Win *)
        let gain = match (game.bet * game.number / 100p) with
          | None -> 0tz
          | Some (g, _) -> g in
        let reimbursed = game.bet + gain in
        [ Account.transfer ~dest:game.player ~amount:reimbursed ]
    in
    let storage = storage.game <- None in
    (ops, storage)

(* accept funds *)
let%entry default () storage =
  [], storage

The Oracle

The oracle can be implemented using Dune Network RPCs on a running Dune node. The principle of the oracle is the following:

  1. Monitor new blocks in the chain.
  2. For each new block, look if it includes successful transactions whose destination is the game smart contract.
  3. Look at the parameters of the transaction to see if it is a call to either play, finish or fund.
  4. If it is a successful call to play, then we know that the smart contract is awaiting a random number.
  5. Generate a random number between 0 and 100 and make a call to the game smart contract with the appropriate private key (the transaction can be signed by a Ledger plugged to the oracle server for instance).
  6. Wait a small amount of time depending on blocks intervals.
  7. Loop.

These can be implemented with the following RPCs:

An implementation of a random number Oracle in OCaml (which uses the liquidity client to make transactions) can be found in this repository: https://github.com/OCamlPro/liq_game/blob/master/src/crawler.ml.

Remarks

  1. In this game, the oracle must be trusted and so it can cheat. To mitigate this drawback, the oracle can be used as a random number generator for several games, if random values are stored in an intermediate contract.
  2. If the oracle looks for events in the last baked block (head), then it is possible that the current chain will be discarded and that the random number transaction appears in another chain. In this case, the player that sees this happen could potentially play another game with a chosen number if she sees the random number in the mempool. However, in Dune, operations include a block hash to designate a particular branch so the random number transaction would not be valid in this other branch.

Footnotes

[1]Some contracts on Ethereum use block hashes as sources of randomness but these are easily manipulated by miners so they are not safe to use. There are also ways to have participants contribute parts of a random number with enforceable commitments https://github.com/randao/randao.
[2]The random number could technically be sent in the same block by monitoring the mempool but it is not a good idea because the miner could reorder the transactions which will make both of them fail, or worse she could replace her bet accordingly once she sees a random number in her mempool.