Smart Contract

Smart Contract

Let's now start by writing a smart contract that we will use to convey framework's important features.

⚠️

This contract is for illustrative purposes only. We do not recommend using it in a production environment1.

💡

Here we'll be writing our smart contract in Haskell but do note that we are not limited to it. You for instance could write your smart contract in any language of your choice and read the compiled CBOR using scriptFromCBOR function defined here (opens in a new tab) (Operations over Contract chapter explains about types such as GYScript, PlutusVersion which are used in this function). Similarly, there is readScript defined in the same (opens in a new tab) file to read from the compiled text envelope (opens in a new tab) file.

Contract Description

Setting here is that we have a sport match happening and a group of friends want to bet on the number of goals scored by their favorite team in it.

Winner is the one whose guess is closest (and in case of tie - the one who takes it fastest!).

The smart contract code is available here (opens in a new tab). This example was inspired by MLabs (opens in a new tab).

Contract Parameters

  • brpOraclePkh :: PubKeyHash: We'll be using a reference input, and its datum will give us the actual result (the number of goals). Since the reference input UTxO must belong to Oracle, we check it using Oracle's payment public key hash.
  • brpBetUntil :: POSIXTime: Time until which bets can be placed.
  • brpBetReveal :: POSIXTime: Time that the Oracle will reveal the match results.
  • brpBetStep :: Value: Minimum value that bets must increase by.

Thus, the parameters of our contract are given by:

BetRef.hs
-- | Our contract is parameterized with this.
data BetRefParams = BetRefParams
  { brpOraclePkh :: PubKeyHash  -- ^ Oracle's payment public key hash. This is needed to assert that UTxO being looked at indeed belongs to the Oracle.
  , brpBetUntil  :: POSIXTime   -- ^ Time until which bets can be placed.
  , brpBetReveal :: POSIXTime   -- ^ Time at which Oracle will reveal the correct match result.
  , brpBetStep   :: Value       -- ^ Each newly placed bet must be more than previous bet by `brpBetStep` amount.
  }
PlutusTx.makeLift ''BetRefParams

Reference Input Datum

The Oracle tells us the number of goals scored by the concerned team:

BetRef.hs
-- | Goals made my the concerned team.
type TeamGoals = Integer
 
-- | Match result given by the Oracle.
newtype OracleAnswerDatum = OracleAnswerDatum TeamGoals deriving newtype (Eq, Show)
PlutusTx.unstableMakeIsData ''OracleAnswerDatum

Contract Datum

It consists of two fields:

  1. List containing each person's guess along with their payment public key hash. Key hash is used to tie guess with the guesser. Every time a new guess is made, we prepend it to this list. This key hash is obtained from transaction signatories - we insist on key hash being present in signatories as otherwise anyone may override bet of someone else.

  2. Amount denoting the previously placed bet. Note that the total value in the UTxO belonging to contract is the culmination of all the previously placed bets and thus it isn't in general equal to last placed bet. We use this to assert that the newly placed bet is more than the previous one by brpBetStep amount.

BetRef.hs
-- | List of guesses by users along with the maximum bet placed yet. A new guess gets /prepended/ to this list. Note that since we are always meant to increment previously placed bet with `brpBetStep`, the newly placed bet would necessarily be maximum (it would be foolish to initialize `brpBetStep` with some negative amounts).
data BetRefDatum = BetRefDatum
  { brdBets        :: [(PubKeyHash, OracleAnswerDatum)]
  , brdPreviousBet :: Value
  }
PlutusTx.unstableMakeIsData ''BetRefDatum

Contract Redeemer

There are two actions available to user:

  1. To place a bet - in which case they give their guess.
  2. To take the bets in the pot after the result is out.

This is therefore codified as:

BetRef.hs
-- | Redeemer representing choices available to the user.
data BetRefAction = Bet !OracleAnswerDatum  -- ^ User makes a guess.
                  | Take                    -- ^ User takes the pot.
PlutusTx.unstableMakeIsData ''BetRefAction

Contract Logic

Placing a bet

Initial bet gets placed as it is (in Cardano, validator script is executed only when spending an UTxO belonging to it but not for creating at it).

For subsequent bets, we require three conditions:

  1. The bet must be before (inclusive) the brpBetUntil time.
  2. There must be exactly one continuing output at the script address whose datum shall have the current guess prepended to it along with the current bet amount.
  3. The current bet must be more than the previous bet by at least brpBetStep amount.

This is coded as:

BetRef.hs
{-# INLINABLE mkBetRefValidator' #-}
-- | Core smart contract logic. Read its description from Atlas guide.
mkBetRefValidator' :: BetRefParams -> BetRefDatum -> BetRefAction -> ScriptContext -> Bool
mkBetRefValidator' (BetRefParams oraclePkh betUntil betReveal betStep) (BetRefDatum previousGuesses previousBet) brAction ctx =
 
  case brAction of
 
    Bet guess  ->
      let
        sOut = case getContinuingOutputs ctx of
          [sOut']        -> sOut'
          _anyOtherMatch -> traceError "Expected only one continuing output."
        outValue = txOutValue sOut
        sIn = maybe (traceError "Could not find own input") txInInfoResolved (findOwnInput ctx)
        inValue = txOutValue sIn
        (guessesOut, betOut) = case outputToDatum sOut of
          Nothing                                -> traceError "Could not resolve for script output datum"
          Just (BetRefDatum guessesOut' betOut') -> (guessesOut', betOut')
      in
        traceIfFalse
          "Must be before `BetUntil` time"
            (to betUntil `contains` validRange) &&
        traceIfFalse
          "Guesses update is wrong"
            ((signerPkh, guess) : previousGuesses == guessesOut) &&
        traceIfFalse
          "The current bet must be more than the previous bet by atleast `brpBetStep` amount"
            (outValue `geq` (inValue <> previousBet <> betStep)) &&
        traceIfFalse
          "Out bet is wrong"
            (betOut == outValue - inValue)

Where we have the following common helpers for both the redemeer actions:

BetRef.hs
  where
 
    info :: TxInfo
    info = scriptContextTxInfo ctx
 
    validRange :: POSIXTimeRange
    validRange = txInfoValidRange info
 
    signerPkh :: PubKeyHash
    signerPkh = case txInfoSignatories info of
      [signerPkh']   -> signerPkh'
      []             -> traceError "No signatory"
      _anyOtherMatch -> traceError "Expected only one signatory"
 
    outputToDatum :: FromData b => TxOut -> Maybe b
    outputToDatum o = case txOutDatum o of
      NoOutputDatum      -> Nothing
      OutputDatum d      -> processDatum d
      OutputDatumHash dh -> processDatum =<< findDatum dh info
      where processDatum = fromBuiltinData . getDatum

Taking the bet pot

In this case we require the following four conditions:

  1. This operation must occur after (inclusive) brpBetReveal time.
  2. The script must get fully spend, i.e., there shouldn't be any continuing outputs to this script address.
  3. The reference input whose datum is used to see actual answer should belong to concerned Oracle.
  4. Guess should be closest among all.

This is therefore coded as:

BetRef.hs
    Take ->
      let
        Just guess = find ((== signerPkh) . fst) previousGuesses  -- Note that `find` returns the first match. Since we were always prepending, this is valid.
        oracleIn = case filter (isNothing . txOutReferenceScript) (txInInfoResolved <$> txInfoReferenceInputs info) of
          [oracleIn']    -> oracleIn'
          []             -> traceError "No reference input provided"
          _anyOtherMatch -> traceError "Expected only one reference input"
        oracleAnswer = case outputToDatum oracleIn of
          Nothing                                  -> traceError "Could not resolve for datum"
          (Just (OracleAnswerDatum oracleAnswer')) -> oracleAnswer'
        guessDiff = getGuessDiff $ snd guess
        getGuessDiff (OracleAnswerDatum g) = abs (oracleAnswer - g)
        oracleInPkh = case toPubKeyHash (txOutAddress oracleIn) of
          Nothing  -> traceError "Not PKH for oracle address"
          Just pkh -> pkh
      in
        traceIfFalse
          "Must be after `RevealTime`"
            (from betReveal `contains` validRange) &&
        traceIfFalse
          "Must fully spend Script"
            (null (getContinuingOutputs ctx)) &&
        traceIfFalse
          "Reference input must be from Oracle address (wrt Payment part)"
            (oracleInPkh == oraclePkh) &&
        traceIfFalse
          "Guess is not closest"
            (all (\pg -> getGuessDiff (snd pg) >= guessDiff) previousGuesses)

And lo behold! This is our contract.

Footnotes

  1. For instance, here we assert that UTxO being used as reference input must belong to Oracle's address but do note that anyone can create an UTxO at Oracle's address.