A Shared Wallet Script

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.

Writing the validator

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)

...

Serialising a custom datum type

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 PubKeyHashes. 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 PubKeyHashes, 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.

Testing the validator

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

Last updated