Reach Workshop — Trustless Wagering

·

10 min read

In this workshop, we'll be creating a trustless wagering application using smart contracts built in Reach. This is same implementation used in AlgoChess. Give it a visit to gain some insight on what this can be used for!

Screenshot 2022-09-23 at 6.15.37 PM.png

Getting Started

  1. Create a project directory:
    $ mkdir -p ~/reach/workshop-hash-lock && cd ~/reach/workshop-hash-lock
  2. Install Reach. Make sure that the Reach executable is in your working directory
  3. Try:
    $ ./reach version
    to see if the installation was successful.

Problem Analysis

Let us first define the scope of out project:
2 participants must be able to play an off-chain game and wager trustlessly on its outcome. The game in question must have a public API in order to reliably determine said outcome. Additionally, the contract must be reusable.

Now, before we write any code, we must ask ourselves the following questions:

  1. Who is involved in this application?
    One key limitation of blockchain technology is that any off-chain data fed into it is only as reliable as the source that does so. This means that if the only participants in out application were the 2 players, then there would be no way to agree on who won. One way to get around this would be to use a decentralized oracle. ChainLink, for one, allows for HTTPS GET requests, which would solve our problem. However, this is not yet a possibility on Algorand.
    As a result, we will opt for a centralized oracle, or a neutral third party who acts as a referee of sorts. This means that we have a total of 3 participants—the players (who we will name Alice and Bob) and the admin. The admin role can be played by an automated backend.
  2. What information do they know at the start of the program?
    The admin knows who Alice is—the person who has the authority to create games and decide on wagers. Alice and Bob both need not know of the existence of the admin. Unlike Alice, whose identity is fixed, "Bob" can refer to any opponent.
  3. What is the ideal program flow?
    • Alice decides on and pays a wager.
    • Bob either accepts or declines the wager.
    • The admin oversees the game and publishes the winner.
    • The winner gets paid the entire pot, and the contract repeats.
      However, there are several weaknesses in this approach, which we will now address.
  4. What if Alice pays the wager, but Bob never shows up?
    There would need to be a provision for Alice to cancel the game, have her funds returned, and reset the contract to its first step.
  5. What if both players pay the wager, but one never shows up to the off-chain game? Alternatively, what if the admin fails to name a winner?
    We could implement a time-out mechanism after which either player can cancel the game and return the funds to their respective owners.
  6. What if Alice publishes herself as the admin?
    If Alice were to deploy the contract herself, she could act maliciously and set herself as the admin, consequently declaring herself the winner in every game. Therefore, it is essential that the admin deploys the contract.

Alright, now that we got that out of the way, let's get coding!

Contract Code

First, let's run:
$ ./reach init
to create our index.mjs and index.rsh files.

In index.rsh let's create the following skeletal structure for our contract:

'reach 0.1';
'use strict';

export const main = Reach.App(() => {

  const Deployer = Participant('Admin', {
    hasDeployed: Fun([], Null),
    aliceAddress: Address,
    adminAddress: Address
  })

  const PlayerAPI = API('PlayerAPI', {
    alicePaysWager: Fun([UInt, UInt], Bool),
    bobPaysWager: Fun([UInt], Bool)
  })

  const Admin = API('AdminAPI', {
    sendOutcome: Fun([UInt, UInt], Bool)
  })

  const vMain = View({stage: UInt, outcome: UInt});

  init()
  //next section here

  commit()
  exit()

})

We have defined the users of our contract with the following methods:

  • hasDeployed: A convenience method that allows the deployer to disconnect after confirming that the contract has indeed deployed.
  • alicePaysWager: Takes 2 integers as arguments—the wager and the deadline (used later on)
  • bobPaysWager: Takes an integer (either 1 or 0) as an argument that indicates whether or not Bob accepted the wager
  • sendOutcome: Used by the admin to publish the outcome. The first argument is the outcome (0 - Alice wins, 1 - Bob wins, 2 - Tie) while the second is the current network time in blocks. Why the block time needs to be sent is explained later on.

Additionally we have a contract view that tells us what stage the contract is in. This can be used by the front-ends of the players to monitor the status of the game.

Deployer.only(() => {
    const aliceAddress = declassify(interact.aliceAddress)
    const adminAddress = declassify(interact.adminAddress)
})
Deployer.publish(aliceAddress, adminAddress)
Deployer.interact.hasDeployed()
//next section here

This block of code comes after the init() statement. The deployer publishes its own address as well as that of Alice for future reference. Then, it confirms that the contract is deployed.

Let's now add the main loop of our contract.

var prevOutcome = 0
invariant(balance() == 0)
while (true) {

  vMain.stage.set(0) // wager not yet sent or previous game outcome decided
  vMain.outcome.set(prevOutcome)
  commit()
  //next section here

}

As we can see, every time a new game begins, the outcome of the previous one is visible through the contract view. Let's add the first step of the wagering process.

const [[wager, deadline], k1] = call(PlayerAPI.alicePaysWager)
.check((wager_, _) => {check(this == aliceAddress)})
.pay((wager_, _) => wager_)
k1(true)
//next section here

Here, Alice decides on and pays her wager. We perform a check to see if the person making the call is actually Alice.

if (wager != 0) {
  vMain.stage.set(1) // alice paid
  commit()
  const [[isAbort2], k2] = call(PlayerAPI.bobPaysWager)
  .check((isAbort_) => {check(isAbort_ == 0 || isAbort_ == 1)})
  .pay((isAbort_) => wager * (1 - isAbort_))
  k2(true)
  const bobAddress = this
  //next section here
}
continue

This is the step where Bob pays the wager. As we can see, if Alice sets a wager of 0, the rest of the program is omitted. Additionally, Bob can reject the wager by sending a value of 1. If Bob fails to respond, Alice can perform this call herself to cancel the game. Bob's address is also saved for future use.

if (isAbort2 == 0) {
  vMain.stage.set(2) // bob paid
  commit()
  const endTime = lastConsensusTime() + deadline
  const [[outcome, _], k3] = call(Admin.sendOutcome)
  .check((outcome_, curTime) => {
    check(curTime < endTime + 2, "err1") // 2 block margin for safety. Use this check to see whether it is safe to abort
    check((this == adminAddress || this == aliceAddress || this == bobAddress) && (outcome_ == 0 || outcome_ == 1 || outcome_ == 2), "err2")
  })
  k3(true)
  //next section here
}
transfer(wager).to(aliceAddress)
prevOutcome = 4
continue

This step occurs nested within the previous if statement. Assuming the game was not aborted, the contract calculates the earliest block time in which the game can be canceled due to non-participation of either player. Interestingly, the contract allows for not only the admin, but for Alice and Bob to send the outcome as well. The reason behind this is explained in the next section.

const timeoutViolation = thisConsensusTime() < endTime && this != adminAddress
if (this == adminAddress) {
  transfer(wager * (2 - outcome)).to(aliceAddress)
  transfer(wager * outcome).to(bobAddress)
  prevOutcome = outcome
  continue
} else {
  if (timeoutViolation) {
    if (this == aliceAddress) {
      transfer(balance()).to(bobAddress)
      prevOutcome = 5
      continue
    } else {
      transfer(balance()).to(aliceAddress)
      prevOutcome = 6
      continue
    }
  } else {
    transfer(wager).to(aliceAddress)
    transfer(wager).to(bobAddress)
    prevOutcome = 3
    continue
  }
}

Assuming that either Alice and Bob decided to act maliciously publish themselves as the winner, the contract determines whether a "timeout violation" had occurred. This is true if the outcome was published before the deadline had passed, and false otherwise. If true, the publisher forfeits their side of their wager, which disincentivizes this form of cheating. To explain the previous 2 steps further, the following would happen with honest and dishonest frontends respectively.

Honest frontend:

  1. Assume either the opponent failed to join the game, or the admin failed to determine a winner.
  2. The frontend keeps track of the block time itself and repeatedly calls Admin.sendOutcome using what it believes to be the correct block time. However, if it is too soon to end the game, the "err1" check fails.
  3. Once it is safe to end the game, sendOutcome results in timeoutViolation being set to false, which returns the wager to their respective owners.

Dishonest frontend:

  1. A malicious player sends a false block time to get past the "err1" check in attempt to end the game early.
  2. timeoutViolation gets set to true, and they lose their funds.

However, if the game proceeds normally, the admin determines the winner, who subsequently gets paid the entire pot.

Now, here's the contract in its entirety.

'reach 0.1';
'use strict';

export const main = Reach.App(() => {

  const Deployer = Participant('Admin', {
    hasDeployed: Fun([], Null),
    aliceAddress: Address,
    adminAddress: Address
  })

  const PlayerAPI = API('PlayerAPI', {
    alicePaysWager: Fun([UInt, UInt], Bool),
    bobPaysWager: Fun([UInt], Bool),
  })

  const Admin = API('AdminAPI', {
    sendOutcome: Fun([UInt, UInt], Bool)
  })

  const vMain = View({stage: UInt, outcome: UInt});

  init()
  Deployer.only(() => {
    const aliceAddress = declassify(interact.aliceAddress)
    const adminAddress = declassify(interact.adminAddress)
  })
  Deployer.publish(aliceAddress, adminAddress)
  Deployer.interact.hasDeployed()


  var prevOutcome = 0
  invariant(balance() == 0)
  while (true) {

    vMain.stage.set(0) // wager not yet sent or previous game outcome decided
    vMain.outcome.set(prevOutcome)
    commit()


    const [[wager, deadline], k1] = call(PlayerAPI.alicePaysWager)
    .check((_, _) => {check(this == aliceAddress)})
    .pay((wager_, _) => wager_)
    k1(true)
    if (wager != 0) {
      vMain.stage.set(1) // alice paid
      commit()
      const [[isAbort2], k2] = call(PlayerAPI.bobPaysWager)
      .check((isAbort_) => {check(isAbort_ == 0 || isAbort_ == 1)})
      .pay((isAbort_) => wager * (1 - isAbort_))
      k2(true)
      const bobAddress = this
      if (isAbort2 == 0) {
        vMain.stage.set(2) // bob paid
        commit()
        const endTime = lastConsensusTime() + deadline
        const [[outcome, _], k3] = call(Admin.sendOutcome)
        .check((outcome_, curTime) => {
          check(curTime < endTime + 2, "err1") // 2 block margin for safety. Use this check to see whether it is safe to abort
          check((this == adminAddress || this == aliceAddress || this == bobAddress) && (outcome_ == 0 || outcome_ == 1 || outcome_ == 2), "err2")
        })
        k3(true)

        const timeoutViolation = thisConsensusTime() < endTime && this != adminAddress
        if (this == adminAddress) {
          transfer(wager * (2 - outcome)).to(aliceAddress)
          transfer(wager * outcome).to(bobAddress)
          prevOutcome = outcome
          continue
        } else {
          if (timeoutViolation) {
            if (this == aliceAddress) {
              transfer(balance()).to(bobAddress)
              prevOutcome = 5
              continue
            } else {
              transfer(balance()).to(aliceAddress)
              prevOutcome = 6
              continue
            }
          } else {
            transfer(wager).to(aliceAddress)
            transfer(wager).to(bobAddress)
            prevOutcome = 3
            continue
          }
        }
      }
      transfer(wager).to(aliceAddress)
      prevOutcome = 4
      continue
    }
    continue
  }
  commit()
  exit()
})

Let's whip up a quick program to test this contract. In your index.mjs file, paste the following:

import { loadStdlib, ask } from '@reach-sh/stdlib'
import * as backend from './build/index.main.mjs'
const stdlib = loadStdlib()

let who
const isAdmin = await ask.ask('Are you the admin?', ask.yesno)
if (isAdmin) {
    who = 'Admin'
} else {
    const isAlice = await ask.ask('Are you Alice?', ask.yesno)
    if (isAlice) {
        who = 'Alice'
    } else {
        who = 'Bob'
        console.log('Then you are Bob.')
    }
}

const acc = await stdlib.newTestAccount(stdlib.parseCurrency(1000))
console.log('Created and funded test newTestAccount.')
console.log('Your address is: ' + acc.getAddress())

const before = stdlib.formatCurrency(await stdlib.balanceOf(acc), 4)
console.log(`Your starting balance is ${before}`)

let ctc
if (who === 'Admin') {
    const aliceAddress = await ask.ask("What is Alice's address?", (x) => x)
    ctc = acc.contract(backend)
    ctc.getInfo().then((info) => {
        console.log(`The contract is deployed as: ${JSON.stringify(info)}`)
    })
    const interact = {
        hasDeployed: () => {stdlib.disconnect(null)},
        aliceAddress: aliceAddress,
        adminAddress: acc
    }
    await stdlib.withDisconnect(() => ctc.p.Admin(interact))
    await ask.ask("Press Enter once both players have paid their wager.", (x) => x)
    const winner = await ask.ask("Enter 0 to declare Alice as the winner, 1 to declare Bob as the winner, or 2 to declare a tie: ", (x) => Number(x))
    await ctc.a.AdminAPI.sendOutcome(winner, 0)
} else {
    const info = await ask.ask('Please paste the contract information:', JSON.parse)
    ctc = acc.contract(backend, info)
    if (who === 'Alice') {
        const howMuch = await ask.ask("Enter wager amount: ", (x) => parseInt(stdlib.parseCurrency(x)._hex))
        const deadline = await ask.ask("Enter deadline in blocks: ", (x) => Number(x))
        await ctc.a.PlayerAPI.alicePaysWager(howMuch, deadline)
        await ask.ask("Press Enter once the admin has determined the outcome of the game.", (x) => x)
    } else {
        const acceptStatus = await ask.ask("Enter 0 to accept the wager and 1 to decline: ", (x) => Number(x))
        await ctc.a.PlayerAPI.bobPaysWager(acceptStatus)
        await ask.ask("Press Enter once the admin has determined the outcome of the game.", (x) => x)
    }
}

const after = stdlib.formatCurrency(await stdlib.balanceOf(acc), 4)
console.log(`Your balance is now ${after}`)

ask.done()

Opening up 3 separate terminal instances (one for each participant), run the following command in each:
REACH_CONNECTOR_MODE=ETH ./reach run
(You could use REACH_CONNECTOR_MODE=ALGO as well!)

Here's some sample output:

Screenshot 2022-09-23 at 5.56.08 PM.png

Screenshot 2022-09-23 at 5.55.25 PM.png

Screenshot 2022-09-23 at 5.55.45 PM.png

Success! As we can see, Alice gained approximately 7 ETH from Bob after being declared the winner by the admin. Note that, for simplicity, this front-end example does not demonstrate other features of the contract, such as timeouts and aborting the game.

If you would like to see how this contract used to its full extend, do check out AlgoChess's GitHub!