Now, we will write our first typed validator. It will act as a shared wallet between two trusted parties, where unlocking funds is allowed if either of their signatures is present in the spending transaction. For this example, we will define the public key hashes of the two parties in the datum, and the validator will check that either of those hashes signed the transaction. We do not need to use the redeemer in this example, only the datum
and context
.
We can start off by creating our datum type, SharedDatum
, and the corresponding ValidatorTypes
, Shared
. SharedDatum
will have two fields, each corresponding to a PubKeyHash
(from the Plutus.V2.Ledger.Api
module) of one of the parties.
-- create a new datum type
data SharedDatum = SharedDatum {
wallet1 :: Plutus.PubKeyHash,
wallet2 :: Plutus.PubKeyHash
}
PlutusTx.unstableMakeIsData ''SharedDatum
-- create validator types
data SharedWalletValidator
instance Scripts.ValidatorTypes SharedWalletValidator where
type instance DatumType SharedWalletValidator = SharedDatum
type instance RedeemerType SharedWalletValidator = ()
Make sure that you import the Scripts
package as:
...
import qualified Ledger.Typed.Scripts as Scripts
...
Next, we need to write the mkValidator
logic. As before, we need to destructure the transaction context to get TxInfo
, and use the txSignedBy
function from Plutus.V2.Ledger.Contexts
to check for the signature. txSignedBy
accepts two arguments, one of type TxInfo
and the other PubKeyHash
, returning True
if the signature is present:
txSignedBy :: TxInfo -> Plutus.V1.Ledger.Crypto.PubKeyHash -> Bool
Our mkValidator
type signature will be mkValidator :: SharedDatum -> () -> Plutus.ScriptContext -> Bool
, and the main goal is to check for either of the two signatures:
mkValidator sdat _ ctx = checkSignature1 || checkSignature2
We then write the helper functions as before, signature1
and signature2
for deconstructing our datum type to get each of the corresponding signatures and the checkSignature1
and checkSignature2
functions to check each of them with txSignedBy
. We can also add some error logging with traceIfFalse
. The full function looks like this:
mkValidator :: SharedDatum -> () -> Plutus.ScriptContext -> Bool
mkValidator sdat _ ctx = checkSignature1 || checkSignature2
where
info :: Plutus.TxInfo
info = Plutus.scriptContextTxInfo ctx
signature1 :: PubKeyHash
signature1 = wallet1 sdat
signature2 :: PubKeyHash
signature2 = wallet2 sdat
checkSignature1 :: Bool
checkSignature1 = traceIfFalse "signature 1 missing" $ txSignedBy info signature1
checkSignature2 :: Bool
checkSignature2 = traceIfFalse "signature 2 missing" $ txSignedBy info signature2
Make sure to import the txSignedBy
function:
...
import Plutus.V2.Ledger.Contexts (txSignedBy)
...
Now we have a function of type SharedDatum -> () -> Plutus.ScriptContext -> Bool
, but remember that we always have to get down to the Untyped Plutus Core version of a validator: BuiltinData -> BuiltinData -> BuiltinData -> ()
. So with typed validators, we have to do some extra steps to get there. Instead of the simple Plutus.mkValidatorScript
, we need to use PSU.V2.mkTypedValidator
(from Plutus.Script.Utils.V2.Typed.Scripts
which we also need to import). The type signature of PSU.V2.mkTypedValidator
is:
-- | Make a 'TypedValidator' from the 'CompiledCode' of a validator script and its wrapper.
mkTypedValidator ::
CompiledCode (ValidatorType a)
-- ^ Validator script (compiled)
-> CompiledCode (ValidatorType a -> WrappedValidatorType)
-- ^ A wrapper for the compiled validator
-> TypedValidator a
It accepts a compiled code of a typed validator (with some ValidatorType a
) and a wrapper that is a function of compiled code ValidatorType a -> WrappedValidatorType
. The WrappedValidatorType
is simply a synonym for the basic validator function BuiltinData -> BuiltinData -> BuiltinData -> ()
. That wrapper function for us is simply PSU.mkUntypedValidator
which has the type signature:
mkUntypedValidator
:: forall d r
. (PV1.UnsafeFromData d, PV1.UnsafeFromData r)
=> (d -> r -> sc -> Bool)
-> UntypedValidator
What this means is simply that instead of compiling just the validator function to Plutus core with $$(PlutusTx.compile [|| mkValidator ||])
, we also need to compile this wrapper. So PSU.V2.mkTypedValidator
ends up being applied to both of the compiled code instances:
typedValidator :: PSU.TypedValidator SharedWalletValidator
typedValidator = PSU.V2.mkTypedValidator @SharedWalletValidator
$$(PlutusTx.compile [|| mkValidator ||])
$$(PlutusTx.compile [|| wrap ||])
where
wrap = PSU.mkUntypedValidator
In order for this to work, we also need to enable the DataKinds
GHC extension.
{-# LANGUAGE DataKinds #-}
Make sure that the plutus-script-utils
packages are imported:
...
import qualified Plutus.Script.Utils.V2.Typed.Scripts as PSU.V2
import qualified Plutus.Script.Utils.Typed as PSU
...
From there, the rest is the same as before with only a minor difference in getting the actual validator and its hash. When compiling untyped validators, we got a type of Plutus.Validator
as the compilation result. However, with TypedValidator
we get a type that has multiple fields so we need to destructure it first to get to Plutus.Script
type that we can use to serialise the script:
validator :: Plutus.Validator -- uses tvValidator field to get the validator
validator = PSU.V2.validatorScript typedValidator
script :: Plutus.Script -- gets the hash
script = Plutus.unValidatorScript validator
Lastly, we just need to add serialise/write file functions for this module.
scriptShortBs :: SBS.ShortByteString
scriptShortBs = SBS.toShort . LBS.toStrict $ serialise script
scriptSerialised :: PlutusScript PlutusScriptV2
scriptSerialised = PlutusScriptSerialised scriptShortBs
writeSerialisedScript :: IO (Either (FileError ()) ())
writeSerialisedScript = writeFileTextEnvelope "compiled/SharedWallet.plutus" Nothing scriptSerialised
For reference, here is the full list of imports for this module:
...
import qualified PlutusTx
import PlutusTx.Prelude
import qualified Plutus.V2.Ledger.Api as Plutus
import Cardano.Api.Shelley (PlutusScript (PlutusScriptSerialised), PlutusScriptV2, writeFileTextEnvelope)
import Cardano.Api (FileError)
import qualified Data.ByteString.Lazy as LBS
import qualified Data.ByteString.Short as SBS
import Codec.Serialise
import qualified Ledger.Typed.Scripts as Scripts
import qualified Plutus.Script.Utils.V2.Typed.Scripts as PSU.V2
import qualified Plutus.Script.Utils.Typed as PSU
import Plutus.V2.Ledger.Contexts (txSignedBy)
import Prelude (IO)
...
That's it for the script. We now need to create the correct datum and learn how to construct valid transactions for this use case. First of all, how can we get a PubKeyHash
of an address? We can use the cardano-cli address key-hash
command:
cardano-cli address key-hash \
--payment-verification-key-file ../address/01.vkey
a5d318dadfb52eeffb260ae097f846aea0ca78e6cc4fe406d4ceedc0
cardano-cli address key-hash \
--payment-verification-key-file ../address/02.vkey
1b1e5895b03302b248e8c459817bab49471c4013a0806ac52cb73f9b
That gets us the PubKeyHash
es. How can we write them to a valid datum file in JSON format? We can create and import the SharedDatum
type (we have to export it first) from the TypedValidator.hs
module and create an instance of it, then serialise and write it to a file. We already have our Utils
module to write Plutus data to JSON so we can use that. We just need to use some REPL wizardry to do it right. First off, we want to export the SharedDatum
data type from our SharedWallet.hs
in order to be able to import it somewhere else. We use the SharedDatum (..)
syntax to export the type constructor and not just the type.
module SharedWallet
(
scriptSerialised,
writeSerialisedScript,
SharedDatum (..)
)
where
Next, update the exposed-modules
in the .cabal
file of the project for SharedWallet
:
...
exposed-modules: SimplestSuccess
, GuessingGame
, ExploringScriptContext
, SharedWallet -- add SharedWallet here
, Helpers.Utils
...
We are now ready to load up our cabal repl
and start importing:
Prelude SimplestSuccess> import SharedWallet
Prelude SharedWallet SimplestSuccess> import Helpers.Utils
Prelude SharedWallet Helpers.Utils SimplestSuccess> :set -XOverloadedStrings
Prelude SharedWallet Helpers.Utils SimplestSuccess> myDatum = SharedDatum "a5d318dadfb52eeffb260ae097f846aea0ca78e6cc4fe406d4ceedc0" "1b1e5895b03302b248e8c459817bab49471c4013a0806ac52cb73f9b"
Prelude SharedWallet Helpers.Utils SimplestSuccess> writeJSONData "compiled/assets/SharedDatum.json"
We used :set -XOverloadedStrings
to enable the overloaded strings extension inside the REPL in order for it to be able to interpret our strings as PubKeyHash
es, which is the type our datum requires. We end up with the following in compiled/assets/SharedDatum.json
:
{"constructor":0,"fields":[{"bytes":"a5d318dadfb52eeffb260ae097f846aea0ca78e6cc4fe406d4ceedc0"},{"bytes":"1b1e5895b03302b248e8c459817bab49471c4013a0806ac52cb73f9b"}]}
We now have everything we need to start testing the validator on the testnet.
Let's create a script address for this validator in create-script-address.sh
.
#!/usr/bin/env bash
NWMAGIC=2 # preview testnet
# Build script address
cardano-cli address build \
--payment-script-file ../../compiled/SharedWallet.plutus \
--testnet-magic $NWMAGIC \
--out-file SharedWallet.addr
echo "Script address: $(cat SharedWallet.addr)"
We need to update the check-utxos.sh
for this validator as well.
#!/usr/bin/env bash
NWMAGIC=2 # preview testnet
export CARDANO_NODE_SOCKET_PATH=$CNODE_HOME/sockets/node0.socket
funds_normal=$(cardano-cli query utxo \
--address $(cat ../address/01.addr) \
--testnet-magic $NWMAGIC)
funds_script=$(cardano-cli query utxo \
--address $(cat SharedWallet.addr) \
--testnet-magic $NWMAGIC)
echo "Normal address:"
echo "${funds_normal}"
echo "Script address:"
echo "${funds_script}"
After running these two scripts, we see the UTxO status. The script address does not have any UTxOs on it.
./create-script-address.sh
Script address: addr_test1wqtul3uvnfqvk7a52fa7r5wcrn6alna6t6pd684jj8mmdvgdxn9r9
./check-utxos.sh
Normal address:
TxHash TxIx Amount
--------------------------------------------------------------------------------------
5dc5111e257f8e68b0978c9619e57bbb12d365c0ec45d879115866bb674156ae 0 1826915 lovelace + TxOutDatumNone
e4a68d9cb4e58d47085c74426441445eaa30c2a60a9d102217d27ec0b0664db8 1 9985492189 lovelace + TxOutDatumNone
ede24e9e40ca82830c75d827b5c3b090132c1afaebd3a4256655fb5d2382474a 0 9649776 lovelace + TxOutDatumNone
ee346be463426509daec07aba24a8905c5f55965daebb39f842a49191d83f9e1 0 1829006 lovelace + TxOutDatumNone
Script address:
TxHash TxIx Amount
--------------------------------------------------------------------------------------
We need to supply the script with some funds in order to test that we can spend it with a valid signature. We already serialised our datum that specifies the two public key hashes that are allowed to spend the funds associated with the UTxO. Let's create a send-funds-to-script.sh
that will do that for us by sending 20 tADA to the script along with the datum.
#!/usr/bin/env bash
NWMAGIC=2 # preview testnet
export CARDANO_NODE_SOCKET_PATH=$CNODE_HOME/sockets/node0.socket
cardano-cli transaction build \
--testnet-magic $NWMAGIC \
--change-address $(cat ../address/01.addr) \
--tx-in e4a68d9cb4e58d47085c74426441445eaa30c2a60a9d102217d27ec0b0664db8#1 \
--tx-out $(cat SharedWallet.addr)+20000000 \
--tx-out-datum-embed-file ../../compiled/assets/SharedDatum.json \
--out-file tx.body
cardano-cli transaction sign \
--tx-body-file tx.body \
--signing-key-file ../address/01.skey \
--testnet-magic $NWMAGIC \
--out-file tx.signed
cardano-cli transaction submit \
--testnet-magic $NWMAGIC \
--tx-file tx.signed
Checking the UTxOs on the script shows us the new output with the hash of our datum:
Script address:
TxHash TxIx Amount
--------------------------------------------------------------------------------------
b980f55aade4508803dcac1c48c28554ee9cf942e40ac1cb5e40e48a621263a5 0 20000000 lovelace + TxOutDatumHash ScriptDataInBabbageEra "7857b572c6e6f6fdacffef2bdec1ecc87e32b805c11c75abd59ad9d18e7f438f"
We now want to spend this UTxO. Let's try it first without signing the transaction with anything. Create a spend-script-funds-no-signature.sh
.
#!/usr/bin/env bash
NWMAGIC=2 # preview testnet
export CARDANO_NODE_SOCKET_PATH=$CNODE_HOME/sockets/node0.socket
cardano-cli transaction build \
--testnet-magic $NWMAGIC \
--change-address $(cat ../address/02.addr) \
--tx-in b980f55aade4508803dcac1c48c28554ee9cf942e40ac1cb5e40e48a621263a5#0 \
--tx-in-script-file ../../compiled/SharedWallet.plutus \
--tx-in-datum-file ../../compiled/assets/SharedDatum.json \
--tx-in-redeemer-file ../../compiled/assets/unit.json \
--tx-in-collateral ee346be463426509daec07aba24a8905c5f55965daebb39f842a49191d83f9e1#0 \
--out-file tx.body
cardano-cli transaction submit \
--testnet-magic $NWMAGIC \
--tx-file tx.signed
Running this will give us an error:
./spend-script-funds-no-signature.sh
Command failed: transaction build Error: The following scripts have execution failures:
...
Script debugging logs: signature 1 missing
signature 2 missing
We see that the transaction build
command failed before we even get to the point where we sign the transaction with cardano-cli transaction sign
. This is because the transaction build
command must already run the validator in question to determine whether the transaction is valid. As our validator checks the transaction signatures with txSignedBy
, it will certainly fail validation since the transaction is not signed by anything at this build stage. So we need a way to tell the transaction-building command that the transaction needs to be signed by some private key corresponding to a public key hash.
This is where the --required-signer-hash
option comes in.
cardano-cli transaction build --help
...
--required-signer-hash HASH
Hash of the verification key (zero or more) whose
signature is required.
...
The --required-signer-hash
will run the validator simulation as if the transaction was signed with the private key matching the specified public key hash. Besides creating the correct simulation for the validator, this option will also make the transaction invalid for submitting unless it really is signed by the correct key. We can check this ourselves by specifying the --required-signer-hash
to pass validator simulation, but then try submitting the transaction without a signature. Let's update the cardano-cli transaction build
command in our spend-script-funds-no-signature.sh
to include the --required-signer-hash
option.
cardano-cli transaction build \
--testnet-magic $NWMAGIC \
--change-address $(cat ../address/02.addr) \
--tx-in b980f55aade4508803dcac1c48c28554ee9cf942e40ac1cb5e40e48a621263a5#0 \
--tx-in-script-file ../../compiled/SharedWallet.plutus \
--tx-in-datum-file ../../compiled/assets/SharedDatum.json \
--tx-in-redeemer-file ../../compiled/assets/unit.json \
--tx-in-collateral ee346be463426509daec07aba24a8905c5f55965daebb39f842a49191d83f9e1#0 \
--required-signer-hash a5d318dadfb52eeffb260ae097f846aea0ca78e6cc4fe406d4ceedc0 \
--out-file tx.body
We will get through the validator run this time, but the transaction will still fail to be submitted as expected.
./spend-script-funds-no-signature.sh
Estimated transaction fee: Lovelace 317891
Command failed: transaction submit Error: Error while submitting tx: ShelleyTxValidationError ShelleyBasedEraBabbage (ApplyTxError [UtxowFailure (UtxoFailure (AlonzoInBabbageUtxoPredFailure (ValueNotConservedUTxO (MaryValue 0 (MultiAsset (fromList []))) (MaryValue 9985492189 (MultiAsset (fromList [])))))),UtxowFailure (UtxoFailure (AlonzoInBabbageUtxoPredFailure (BadInputsUTxO (fromList [TxIn (TxId {unTxId = SafeHash "e4a68d9cb4e58d47085c74426441445eaa30c2a60a9d102217d27ec0b0664db8"}) (TxIx 1)]))))])
That's enough testing invalid transactions for this validator. Finally, let's create a valid transaction that will spend the funds by signing the transaction with our 02.skey
(even though the funds were sent from 01.addr
). Create a spend-script-funds.sh
script with an updated --required-signer-hash
, and sign and submit the transaction. The change address will be 02.addr
.
#!/usr/bin/env bash
NWMAGIC=2 # preview testnet
export CARDANO_NODE_SOCKET_PATH=$CNODE_HOME/sockets/node0.socket
cardano-cli transaction build \
--testnet-magic $NWMAGIC \
--change-address $(cat ../address/02.addr) \
--tx-in b980f55aade4508803dcac1c48c28554ee9cf942e40ac1cb5e40e48a621263a5#0 \
--tx-in-script-file ../../compiled/SharedWallet.plutus \
--tx-in-datum-file ../../compiled/assets/SharedDatum.json \
--tx-in-redeemer-file ../../compiled/assets/unit.json \
--tx-in-collateral ee346be463426509daec07aba24a8905c5f55965daebb39f842a49191d83f9e1#0 \
--required-signer-hash 1b1e5895b03302b248e8c459817bab49471c4013a0806ac52cb73f9b \
--out-file tx.body
cardano-cli transaction sign \
--tx-body-file tx.body \
--signing-key-file ../address/02.skey \
--testnet-magic $NWMAGIC \
--out-file tx.signed
cardano-cli transaction submit \
--testnet-magic $NWMAGIC \
--tx-file tx.signed
Interestingly, the transaction still fails at the submit stage. But notice that the transaction did successfully pass the validator simulation before failing.
./spend-script-funds.sh
Estimated transaction fee: Lovelace 317891
Command failed: transaction submit Error: Error while submitting tx: ShelleyTxValidationError ShelleyBasedEraBabbage (ApplyTxError [UtxowFailure (AlonzoInBabbageUtxowPredFailure (ShelleyInAlonzoUtxowPredFailure (MissingVKeyWitnessesUTXOW (fromList [KeyHash "a5d318dadfb52eeffb260ae097f846aea0ca78e6cc4fe406d4ceedc0"]))))])
It says we have a MissingVKeyWitnessesUTXOW
corresponding to a5d318dadfb52eeffb260ae097f846aea0ca78e6cc4fe406d4ceedc0
. But that is our 01.addr
public key hash! Why is it complaining about it? Well, remember what we said about collateral inputs, they must always be present with script transactions to cover potential failures. Our collateral input for this transaction is still ee346be463426509daec07aba24a8905c5f55965daebb39f842a49191d83f9e1#0
, which belongs to 01.addr
so clearly the transaction must be signed by 01.skey
as well in order to allow the usage of this UTxO. Let's add that signature to the cardano-cli transaction sign
command to allow the transaction to use that collateral input.
...
cardano-cli transaction sign \
--tx-body-file tx.body \
--signing-key-file ../address/01.skey \
--signing-key-file ../address/02.skey \
--testnet-magic $NWMAGIC \
--out-file tx.signed
...
Submitting the transaction again works as expected now.
./spend-script-funds.sh
Estimated transaction fee: Lovelace 317891
Transaction successfully submitted.
If we check our 02.addr
UTxOs, we will see the funds we just spent from the script.
cardano-cli query utxo \
--address $(cat ../address/02.addr) \
--testnet-magic 2 \
--socket-path $CNODE_HOME/sockets/node0.socket
TxHash TxIx Amount
--------------------------------------------------------------------------------------
59590fab00fb430d205151c59ca7e00af38e9945d778abdae6897f368aa39591 0 19682109 lovelace + TxOutDatumNone