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 typedataSharedDatum= SharedDatum { wallet1 :: Plutus.PubKeyHash, wallet2 :: Plutus.PubKeyHash }PlutusTx.unstableMakeIsData ''SharedDatum-- create validator typesdataSharedWalletValidatorinstance Scripts.ValidatorTypesSharedWalletValidatorwheretypeinstanceDatumTypeSharedWalletValidator=SharedDatumtypeinstanceRedeemerTypeSharedWalletValidator=()
Make sure that you import the Scripts package as:
...importqualified 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 txSignedByfunction 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:
Our mkValidator type signature will be mkValidator :: SharedDatum -> () -> Plutus.ScriptContext -> Bool, and the main goal is to check for either of the two signatures:
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:
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:
In order for this to work, we also need to enable the DataKinds GHC extension.
{-# LANGUAGEDataKinds #-}
Make sure that the plutus-script-utils packages are imported:
...importqualified Plutus.Script.Utils.V2.Typed.Scripts as PSU.V2importqualified 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 validatorvalidator = PSU.V2.validatorScript typedValidatorscript:: Plutus.Script-- gets the hashscript = Plutus.unValidatorScript validator
Lastly, we just need to add serialise/write file functions for this module.
For reference, here is the full list of imports for this module:
...importqualified PlutusTximport PlutusTx.Preludeimportqualified Plutus.V2.Ledger.Api as Plutusimport Cardano.Api.Shelley (PlutusScript (PlutusScriptSerialised),PlutusScriptV2,writeFileTextEnvelope)import Cardano.Api (FileError)importqualified Data.ByteString.Lazy as LBSimportqualified Data.ByteString.Short as SBSimport Codec.Serialiseimportqualified Ledger.Typed.Scripts as Scriptsimportqualified Plutus.Script.Utils.V2.Typed.Scripts as PSU.V2importqualified Plutus.Script.Utils.Typed as PSUimport 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:
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.
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:
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.
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.
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.
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.
Interestingly, the transaction still fails at the submit stage. But notice that the transaction did successfully pass the validator simulation before failing.
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.