Writing a basic Plutus app in an emulated environment

Plutus apps are programs that run off-chain and manage active contract instances. They monitor the blockchain, ask for user input, and submit transactions to the blockchain. If you are a contract author, building a Plutus app is the easiest way to create and spend Plutus script outputs. In this tutorial you are going to write a Plutus app that locks some ada in a script output and splits them evenly between two recipients.

import Cardano.Node.Emulator.Params (pNetworkId)
import Control.Monad (forever, void)
import Control.Monad.Freer.Extras.Log (LogLevel (Debug, Info))
import Data.Aeson (FromJSON, ToJSON)
import Data.Default (def)
import Data.Text qualified as T
import Data.Text qualified as Text
import GHC.Generics (Generic)
import Ledger (CardanoAddress, PaymentPubKeyHash (unPaymentPubKeyHash), toPlutusAddress)
import Ledger.Tx.Constraints qualified as Constraints
import Ledger.Typed.Scripts qualified as Scripts
import Plutus.Contract (Contract, Endpoint, Promise, endpoint, getParams, logInfo, selectList, submitTxConstraints,
                        submitTxConstraintsSpending, type (.\/), utxosAt)
import Plutus.Contract.Test (w1, w2)
import Plutus.Script.Utils.Ada qualified as Ada
import Plutus.Trace.Emulator qualified as Trace
import Plutus.V1.Ledger.Api (Address, ScriptContext (ScriptContext, scriptContextTxInfo), TxInfo (txInfoOutputs),
                             TxOut (TxOut, txOutAddress, txOutValue), Value)
import PlutusTx qualified
import PlutusTx.Prelude (Bool, Maybe (Just, Nothing), Semigroup ((<>)), mapMaybe, mconcat, ($), (&&), (-), (.), (==),
                         (>=))
import Prelude (IO, (<$>), (>>))
import Prelude qualified as Haskell
import Wallet.Emulator.Stream (filterLogLevel)
import Wallet.Emulator.Wallet (Wallet, mockWalletAddress)

Defining the types

You start by defining some data types that you’re going to need for the Split app.

data SplitData =
    SplitData
        { recipient1 :: Address -- ^ First recipient of the funds
        , recipient2 :: Address -- ^ Second recipient of the funds
        , amount     :: Ada.Ada -- ^ How much Ada we want to lock
        }
    deriving stock (Haskell.Show, Generic)

-- For a 'real' application use 'makeIsDataIndexed' to ensure the output is stable over time
PlutusTx.unstableMakeIsData ''SplitData
PlutusTx.makeLift ''SplitData

SplitData describes the two recipients of the funds, and the total amount of the funds denoted in ada.

You are using the Plutus.V1.Ledger.Api.Address type to identify the recipients. When making the payment you can use the hashes to create two public key outputs.

Instances for data types

The SplitData type has instances for a number of typeclasses. These instances enable the serialisation of SplitData to different formats. ToJSON and FromJSON are needed for JSON serialization. JSON objects are passed between the frontend (for example, the Plutus apps emulator) and the app instance. PlutusTx.FromData and PlutusTx.ToData are used for values that are attached to transactions, for example as the <redeemer> of a script output. This class is used by the Plutus app at runtime to construct Data values. Finally, PlutusTx.makeLift is a Template Haskell statement that generates an instance of the PlutusTx.Lift.Class.Lift class for SplitData. This class is used by the Plutus compiler at compile-time to construct Plutus core programs.

Defining the validator script

The validator script is the on-chain part of our Plutus app. The job of the validator is to look at individual transactions in isolation and decide whether they are valid. Plutus validators have the following type signature:

d -> r -> ScriptContext -> Bool

where d is the type of the <datum> and r is the type of the redeemer.

You are going to use the validator script to lock a script output that contains the amount specified in the SplitData.

Note

There is an n-to-n relationship between Plutus apps and validator scripts. Apps can deal with multiple validators, and validators can be used by different apps.

In this tutorial you only need a single validator. Its datum type is SplitData and its redeemer type is () (the unit type). The validator looks at the Plutus.V1.Ledger.Api.ScriptContext value to see if the conditions for making the payment are met:

validateSplit :: SplitData -> () -> ScriptContext -> Bool
validateSplit SplitData{recipient1, recipient2, amount} _ ScriptContext{scriptContextTxInfo} =
    let half = Ada.divide amount 2
        outputs = txInfoOutputs scriptContextTxInfo
     in
     Ada.fromValue (valuePaidToAddr outputs recipient1) >= half &&
     Ada.fromValue (valuePaidToAddr outputs recipient2) >= (amount - half)
 where
     valuePaidToAddr :: [TxOut] -> Address -> Value
     valuePaidToAddr outs addr =
         let flt TxOut{txOutAddress, txOutValue} | txOutAddress == addr = Just txOutValue
             flt _ = Nothing
         in mconcat $ mapMaybe flt outs

The validator checks that the transaction, represented by Plutus.V1.Ledger.Api.scriptContextTxInfo, pays half the specified amount to each recipient.

You then need some boilerplate to compile the validator to a Plutus script (see Writing basic validator scripts in the Plutus Core and Plutus Tx User Guide).

data Split
instance Scripts.ValidatorTypes Split where
    type instance RedeemerType Split = ()
    type instance DatumType Split = SplitData

splitValidator :: Scripts.TypedValidator Split
splitValidator = Scripts.mkTypedValidator @Split
    $$(PlutusTx.compile [|| validateSplit ||])
    $$(PlutusTx.compile [|| wrap ||]) where
        wrap = Scripts.mkUntypedValidator @ScriptContext @SplitData @()

The Plutus.Script.Utils.V1.Typed.Scripts.Validators.ValidatorTypes class defines the types of the validator, and splitValidator contains the compiled Plutus core code of validateSplit.

Asking for input

When you start the app, you want to ask the sender for a SplitData object. In Plutus apps, the mechanism for requesting inputs is called endpoints.

All endpoints that an app wants to use must be declared as part of the type of the app. The set of all endpoints of an app is called the schema of the app. The schema is defined as a Haskell type. You can build a schema using the Plutus.Contract.Endpoint type family to construct individual endpoint types, and the .\/ operator to combine them.

data LockArgs =
        LockArgs
            { recipient1Address :: CardanoAddress
            , recipient2Address :: CardanoAddress
            , totalAda          :: Ada.Ada
            }
    deriving stock (Haskell.Show, Generic)
    deriving anyclass (ToJSON, FromJSON)

type SplitSchema =
        Endpoint "lock" LockArgs
        .\/ Endpoint "unlock" LockArgs

The SplitSchema defines two endpoints, lock and unlock. Each endpoint declaration contains the endpoint’s name and its type.

To use the lock endpoint in our app, you call the Plutus.Contract.Request.endpoint function:

lock :: Promise () SplitSchema T.Text ()
lock = endpoint @"lock" (lockFunds . mkSplitData)

unlock :: Promise () SplitSchema T.Text ()
unlock = endpoint @"unlock" (unlockFunds . mkSplitData)

endpoint has a single argument, the name of the endpoint. The name of the endpoint is a Haskell type, not a value, and you have to supply this argument using the type application operator @. This operator is provided by the TypeApplications GHC extension.

Next you need to turn the endpoint parameter datatype LockArgs into the SplitData datatype used by the Plutus script.

mkSplitData :: LockArgs -> SplitData
mkSplitData LockArgs{recipient1Address, recipient2Address, totalAda} =
    SplitData
        { recipient1 = toPlutusAddress recipient1Address
        , recipient2 = toPlutusAddress recipient2Address
        , amount = totalAda
        }

Locking the funds

With the SplitData that you got from the user you can now write a transaction that locks the requested amount of ada in a script output.

lockFunds :: SplitData -> Contract () SplitSchema T.Text ()
lockFunds s@SplitData{amount} = do
    logInfo $ "Locking " <> Haskell.show amount
    let tx = Constraints.mustPayToTheScriptWithDatumInTx s (Ada.toValue amount)
    void $ submitTxConstraints splitValidator tx

Using the constraints library that comes with the Plutus SDK you specify a transaction tx in a single line.

After calling Plutus.Contract.submitTxConstraints in the next line, the Plutus app runtime examines the transaction constraints tx and builds a transaction that fulfills them. The runtime then sends the transaction to the wallet, which adds enough to cover the required funds (in this case, the ada amount specified in amount).

Unlocking the funds

All that’s missing now is the code for retrieving the funds, and some glue to put it all together.

unlockFunds :: SplitData -> Contract () SplitSchema T.Text ()
unlockFunds SplitData{recipient1, recipient2, amount} = do
    networkId <- pNetworkId <$> getParams
    let contractAddress = Scripts.validatorCardanoAddress networkId splitValidator
    utxos <- utxosAt contractAddress
    let half = Ada.divide amount 2
        tx =
            Constraints.collectFromTheScript utxos ()
            <> Constraints.mustPayToAddress recipient1 (Ada.toValue half)
            <> Constraints.mustPayToAddress recipient2 (Ada.toValue $ amount - half)
    void $ submitTxConstraintsSpending splitValidator utxos tx

In unlockFunds you use the constraints library to build the spending transaction. Here, tx combines three different constraints. Ledger.Tx.Constraints.collectFromTheScript takes the script outputs in unspentOutputs and adds them as input to the transaction, using the unit () as the redeemer. The other two constraints use Ledger.Tx.Constraints.mustPayToAddress to add payments for the recipients.

Running the app on the Plutus apps emulator

You have all the functions you need for the on-chain and off-chain parts of the app. Every contract in the Plutus apps emulator must define its public interface like this:

splitPlutusApp :: Contract () SplitSchema T.Text ()

splitPlutusApp is the high-level definition of our app:

splitPlutusApp = forever $ selectList [lock, unlock]

The Plutus.Contract.selectList function acts like a choice between two branches. The left branch starts with lock and the right branch starts with unlock. The app exposes both endpoints and proceeds with the branch that receives an answer first. So, if you call the lock endpoint in one of the simulated wallets, it will call lockFunds and ignore the unlock side of the contract. The forever call, which runs the application in an infinite loop, is necessary for the Plutus.Trace.Emulator.EmulatorTrace. If you omit it, you will only be able to trigger a single endpoint after activating the contract, from which the contract instance will close.

You can now compile the contract and create a simulation. The following action sequence results in two transactions that lock the funds and then distribute them to the two recipients.

runSplitDataEmulatorTrace :: IO ()
runSplitDataEmulatorTrace = do
    Trace.runEmulatorTraceIO mkSplitDataEmulatorTrace

mkSplitDataEmulatorTrace :: Trace.EmulatorTrace ()
mkSplitDataEmulatorTrace = do
    -- w1, w2, w3, ... are predefined mock wallets used for testing
    let w1Addr = mockWalletAddress w1
    let w2Addr = mockWalletAddress w2

    h <- Trace.activateContractWallet w1 splitPlutusApp
    Trace.callEndpoint @"lock" h $ LockArgs w1Addr w2Addr 10_000_000
    void Trace.nextSlot
    Trace.callEndpoint @"unlock" h $ LockArgs w1Addr w2Addr 10_000_000

You should see an output similar to what follows:

[INFO] Slot 0: TxnValidate d0f5b08cc20688becb8eceba9770a18ea49a49d5df159715b899736bd1d1121d [  ]
[INFO] Slot 1: 00000000-0000-4000-8000-000000000000 {Wallet W[1]}:
                 Contract instance started
[INFO] Slot 1: 00000000-0000-4000-8000-000000000000 {Wallet W[1]}:
                 Receive endpoint call on 'lock' for Object (fromList [("contents",Array [Object (fromList [("getEndpointDescription",String "lock")]),Object (fromList [("unEndpointValue",Object (fromList [("recipient1Address",String "addr_test1vz3vyrrh3pavu8xescvnunn4h27cny70645etn2ulnnqnssrz8utc"),("recipient2Address",String "addr_test1vzq2fazm26ug6yfemg3mcnpuwhkx6v558sy87fgtscvnefckqs3wk"),("totalAda",Object (fromList [("getLovelace",Number 1.0e7)]))]))])]),("tag",String "ExposeEndpointResp")])
[INFO] Slot 1: 00000000-0000-4000-8000-000000000000 {Wallet W[1]}:
                 Contract log: String "Locking Lovelace {getLovelace = 10000000}"
[INFO] Slot 1: W[1]: Balancing an unbalanced transaction:
                       Tx:
                         Tx 7733b05c8a3d6eb7ade1182beea8b1c1ad7440e7400ef238f7ffeab21e94cd9c:
                           {inputs:
                           reference inputs:
                           collateral inputs:
                           outputs:
                             - 10000000 lovelace addressed to
                               ScriptCredential: 3e4f54085c2eb253b81fb958f3c3369ab6139c12964ee894ae57a908 (no staking credential)
                               with datum hash 43492163ee71f886ebc65c85f3dfa8db313f00d701b433b539811464d4355873
                           mint: 
                           fee: 0 lovelace
                           validity range: Interval {ivFrom = LowerBound NegInf True, ivTo = UpperBound PosInf True}
                           data:
                             ( 43492163ee71f886ebc65c85f3dfa8db313f00d701b433b539811464d4355873
                             , <<<"\162\194\fw\136z\206\FS\217\134\EM>Nu\186\189\137\147\207\213i\149\205\\\252\230\t\194">,
                             <>>,
                             <<"\128\164\244[V\184\141\DC19\218#\188L<u\236m2\148<\b\DEL%\v\134\EM<\167">,
                             <>>,
                             10000000> )
                           redeemers:}
                       Requires signatures:
                       Utxo index:
[INFO] Slot 1: W[1]: Finished balancing:
                       Tx 3aed0c9c37edee742d00559de3471f4ad6b791522ba224c17fe188a0efcdcda5:
                         {inputs:
                            - d0f5b08cc20688becb8eceba9770a18ea49a49d5df159715b899736bd1d1121d!50

                            - d0f5b08cc20688becb8eceba9770a18ea49a49d5df159715b899736bd1d1121d!51

                         reference inputs:
                         collateral inputs:
                         outputs:
                           - 10000000 lovelace addressed to
                             ScriptCredential: 3e4f54085c2eb253b81fb958f3c3369ab6139c12964ee894ae57a908 (no staking credential)
                             with datum hash 43492163ee71f886ebc65c85f3dfa8db313f00d701b433b539811464d4355873
                           - 9821079 lovelace addressed to
                             PubKeyCredential: a2c20c77887ace1cd986193e4e75babd8993cfd56995cd5cfce609c2 (no staking credential)
                         mint: 
                         fee: 178921 lovelace
                         validity range: Interval {ivFrom = LowerBound NegInf True, ivTo = UpperBound PosInf True}
                         data:
                           ( 43492163ee71f886ebc65c85f3dfa8db313f00d701b433b539811464d4355873
                           , <<<"\162\194\fw\136z\206\FS\217\134\EM>Nu\186\189\137\147\207\213i\149\205\\\252\230\t\194">,
                           <>>,
                           <<"\128\164\244[V\184\141\DC19\218#\188L<u\236m2\148<\b\DEL%\v\134\EM<\167">,
                           <>>,
                           10000000> )
                         redeemers:}
[INFO] Slot 1: W[1]: Signing tx: 3aed0c9c37edee742d00559de3471f4ad6b791522ba224c17fe188a0efcdcda5
[INFO] Slot 1: W[1]: Submitting tx: 3aed0c9c37edee742d00559de3471f4ad6b791522ba224c17fe188a0efcdcda5
[INFO] Slot 1: W[1]: TxSubmit: 3aed0c9c37edee742d00559de3471f4ad6b791522ba224c17fe188a0efcdcda5
[INFO] Slot 1: TxnValidate 3aed0c9c37edee742d00559de3471f4ad6b791522ba224c17fe188a0efcdcda5 [  ]
[INFO] Slot 2: 00000000-0000-4000-8000-000000000000 {Wallet W[1]}:
                 Receive endpoint call on 'unlock' for Object (fromList [("contents",Array [Object (fromList [("getEndpointDescription",String "unlock")]),Object (fromList [("unEndpointValue",Object (fromList [("recipient1Address",String "addr_test1vz3vyrrh3pavu8xescvnunn4h27cny70645etn2ulnnqnssrz8utc"),("recipient2Address",String "addr_test1vzq2fazm26ug6yfemg3mcnpuwhkx6v558sy87fgtscvnefckqs3wk"),("totalAda",Object (fromList [("getLovelace",Number 1.0e7)]))]))])]),("tag",String "ExposeEndpointResp")])
[INFO] Slot 2: W[1]: Balancing an unbalanced transaction:
                       Tx:
                         Tx 91ed39867cbcc307d0beb619215e1c138e726105024dbb6668e5ffbfdd2fd754:
                           {inputs:
                              - 3aed0c9c37edee742d00559de3471f4ad6b791522ba224c17fe188a0efcdcda5!0

                           reference inputs:
                           collateral inputs:
                           outputs:
                             - 5000000 lovelace addressed to
                               PubKeyCredential: a2c20c77887ace1cd986193e4e75babd8993cfd56995cd5cfce609c2 (no staking credential)
                             - 5000000 lovelace addressed to
                               PubKeyCredential: 80a4f45b56b88d1139da23bc4c3c75ec6d32943c087f250b86193ca7 (no staking credential)
                           mint: 
                           fee: 0 lovelace
                           validity range: Interval {ivFrom = LowerBound NegInf True, ivTo = UpperBound PosInf True}
                           data:
                             ( 43492163ee71f886ebc65c85f3dfa8db313f00d701b433b539811464d4355873
                             , <<<"\162\194\fw\136z\206\FS\217\134\EM>Nu\186\189\137\147\207\213i\149\205\\\252\230\t\194">,
                             <>>,
                             <<"\128\164\244[V\184\141\DC19\218#\188L<u\236m2\148<\b\DEL%\v\134\EM<\167">,
                             <>>,
                             10000000> )
                           redeemers:
                             RedeemerPtr Spend 0 : Constr 0 []
                           attached scripts:
                             PlutusScript PlutusV1 ScriptHash "3e4f54085c2eb253b81fb958f3c3369ab6139c12964ee894ae57a908"}
                       Requires signatures:
                       Utxo index:
                         ( 3aed0c9c37edee742d00559de3471f4ad6b791522ba224c17fe188a0efcdcda5!0
                         , - 10000000 lovelace addressed to
                             ScriptCredential: 3e4f54085c2eb253b81fb958f3c3369ab6139c12964ee894ae57a908 (no staking credential)
                             with datum hash 43492163ee71f886ebc65c85f3dfa8db313f00d701b433b539811464d4355873 )
[INFO] Slot 2: W[1]: Finished balancing:
                       Tx 6156586126d719203a5e22e67360550c8dd3d1565c2afeee576349b7ea84bc09:
                         {inputs:
                            - 3aed0c9c37edee742d00559de3471f4ad6b791522ba224c17fe188a0efcdcda5!0

                            - d0f5b08cc20688becb8eceba9770a18ea49a49d5df159715b899736bd1d1121d!52

                         reference inputs:
                         collateral inputs:
                           - d0f5b08cc20688becb8eceba9770a18ea49a49d5df159715b899736bd1d1121d!52

                         outputs:
                           - 5000000 lovelace addressed to
                             PubKeyCredential: a2c20c77887ace1cd986193e4e75babd8993cfd56995cd5cfce609c2 (no staking credential)
                           - 5000000 lovelace addressed to
                             PubKeyCredential: 80a4f45b56b88d1139da23bc4c3c75ec6d32943c087f250b86193ca7 (no staking credential)
                           - 9595609 lovelace addressed to
                             PubKeyCredential: a2c20c77887ace1cd986193e4e75babd8993cfd56995cd5cfce609c2 (no staking credential)
                         return collateral:
                           - 9393413 lovelace addressed to
                             PubKeyCredential: a2c20c77887ace1cd986193e4e75babd8993cfd56995cd5cfce609c2 (no staking credential)
                         total collateral: 606587 lovelace
                         mint: 
                         fee: 404391 lovelace
                         validity range: Interval {ivFrom = LowerBound NegInf True, ivTo = UpperBound PosInf True}
                         data:
                           ( 43492163ee71f886ebc65c85f3dfa8db313f00d701b433b539811464d4355873
                           , <<<"\162\194\fw\136z\206\FS\217\134\EM>Nu\186\189\137\147\207\213i\149\205\\\252\230\t\194">,
                           <>>,
                           <<"\128\164\244[V\184\141\DC19\218#\188L<u\236m2\148<\b\DEL%\v\134\EM<\167">,
                           <>>,
                           10000000> )
                         redeemers:
                           RedeemerPtr Spend 0 : Constr 0 []
                         attached scripts:
                           PlutusScript PlutusV1 ScriptHash "3e4f54085c2eb253b81fb958f3c3369ab6139c12964ee894ae57a908"}
[INFO] Slot 2: W[1]: Signing tx: 6156586126d719203a5e22e67360550c8dd3d1565c2afeee576349b7ea84bc09
[INFO] Slot 2: W[1]: Submitting tx: 6156586126d719203a5e22e67360550c8dd3d1565c2afeee576349b7ea84bc09
[INFO] Slot 2: W[1]: TxSubmit: 6156586126d719203a5e22e67360550c8dd3d1565c2afeee576349b7ea84bc09
[INFO] Slot 2: TxnValidate 6156586126d719203a5e22e67360550c8dd3d1565c2afeee576349b7ea84bc09 [ Data decoded successfully
                                                                                            , Redeemer decoded successfully
                                                                                            , Script context decoded successfully ]
Final balances
Wallet 7: 100000000 lovelace
Wallet 8: 100000000 lovelace
Wallet 6: 100000000 lovelace
Wallet 4: 100000000 lovelace
Wallet 2: 105000000 lovelace
Wallet 1: 94416688 lovelace
Wallet 10: 100000000 lovelace
Wallet 9: 100000000 lovelace
Wallet 3: 100000000 lovelace
Wallet 5: 100000000 lovelace

Exercise

  1. Extract the function that assigns funds to each recipient from unlockFunds and validateSplit to reduce redundancy in the code

  2. Extend the contract to deal with a list of recipients instead of a fixed number of 2.