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.Internal.Node (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.spendUtxosFromTheScript 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.spendUtxosFromTheScript
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: TxnValidation d0f5b08cc20688becb8eceba9770a18ea49a49d5df159715b899736bd1d1121d
Validation success
[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 7733b05c8a3d6eb7ade1182beea8b1c1ad7440e7400ef238f7ffeab21e94cd9c:
inputs:
reference inputs:
collateral inputs:
outputs:
- 10000000 lovelace addressed to
ScriptCredential: 3e4f54085c2eb253b81fb958f3c3369ab6139c12964ee894ae57a908 (no staking credential)
with datum in tx ScriptDataConstructor 0 [ScriptDataConstructor 0 [ScriptDataConstructor 0 [ScriptDataBytes "\162\194\fw\136z\206\FS\217\134\EM>Nu\186\189\137\147\207\213i\149\205\\\252\230\t\194"],ScriptDataConstructor 1 []],ScriptDataConstructor 0 [ScriptDataConstructor 0 [ScriptDataBytes "\128\164\244[V\184\141\DC19\218#\188L<u\236m2\148<\b\DEL%\v\134\EM<\167"],ScriptDataConstructor 1 []],ScriptDataNumber 10000000]
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:
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 in tx ScriptDataConstructor 0 [ScriptDataConstructor 0 [ScriptDataConstructor 0 [ScriptDataBytes "\162\194\fw\136z\206\FS\217\134\EM>Nu\186\189\137\147\207\213i\149\205\\\252\230\t\194"],ScriptDataConstructor 1 []],ScriptDataConstructor 0 [ScriptDataConstructor 0 [ScriptDataBytes "\128\164\244[V\184\141\DC19\218#\188L<u\236m2\148<\b\DEL%\v\134\EM<\167"],ScriptDataConstructor 1 []],ScriptDataNumber 10000000]
- 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: TxnValidation 3aed0c9c37edee742d00559de3471f4ad6b791522ba224c17fe188a0efcdcda5
Validation success
[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 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"
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: TxnValidation 6156586126d719203a5e22e67360550c8dd3d1565c2afeee576349b7ea84bc09
Validation success
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¶
Extract the function that assigns funds to each recipient from
unlockFunds
andvalidateSplit
to reduce redundancy in the codeExtend the contract to deal with a list of recipients instead of a fixed number of 2.