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 Plutus-tx (aka Plinth) but do note that we are not limited to it. If you are using Aiken (opens in a new tab), check out our Blueprint section to see how easily Aiken validators can be plugged into Atlas, also supporting creation of high level Haskell types corresponding to blueprint schema.
And in general, one can read the compiled validator's 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.
Note that if your validator is written in Plutarch, we recommend Ply (opens in a new tab) to read Plutarch compiled scripts. In particular, check out this (opens in a new tab) example where GeniusYield reads it's plutarch validators into Atlas.
Besides Plutus scripts, Atlas also supports Simple scripts. Check out Simple Scripts section to learn more!
Contract Description
A 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).
Since the underlying version of plutus
library we are using defaults to version 1.1.0 of plutus core, we need to explicitly set target-version
(opens in a new tab) to 1.0.0 (as this example uses Plutus V2 ledger version, but Atlas also supports Plutus V3), and that is why there is ghc-options: -fplugin-opt PlutusTx.Plugin:target-version=1.0.0
in our cabal file (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:
-- | 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.unstableMakeIsData ''BetRefParams
Reference Input Datum
The Oracle tells us the number of goals scored by the concerned team:
-- | 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:
-
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.
-
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.
-- | 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:
- To place a bet - in which case they give their guess.
- To take the bets in the pot after the result is out.
This is therefore codified as:
-- | 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, spending 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:
- The bet must be before (inclusive) the
brpBetUntil
time. - 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.
- The current bet must be more than the previous bet by at least
brpBetStep
amount.
This is coded as:
{-# 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:
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:
- This operation must occur after (inclusive)
brpBetReveal
time. - The script must get fully spend, i.e., there shouldn't be any continuing outputs to this script address.
- The reference input whose datum is used to see actual answer should belong to concerned Oracle.
- Guess should be closest among all.
This is therefore coded as:
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
-
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. ↩