A Stake Validator Script

In this StakeValidator example, we will create a simple script that controls staking actions via secret codes that the script is parameterised with.

Writing the validator

First, we will use the same GHC extensions as with the parameterised shared wallet script and the same module imports (except we don't need txSignedBy here).

{-# LANGUAGE NoImplicitPrelude     #-}
{-# LANGUAGE TemplateHaskell       #-}
{-# LANGUAGE OverloadedStrings     #-}
{-# LANGUAGE TypeApplications      #-}
{-# LANGUAGE TypeFamilies          #-}
{-# LANGUAGE ScopedTypeVariables   #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE DataKinds             #-}

module StakingValidator
  (
    scriptSerialised,
    writeSerialisedScript,
    CodeParam (..)
  )
where

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 Prelude (IO)

...

Then, we need to create a parameter for the script as before, calling it CodeParam, and making it an instance of Data and Lift classes.

data CodeParam = CodeParam {
  cert :: Integer,
  reward :: Integer
}
PlutusTx.unstableMakeIsData ''CodeParam
PlutusTx.makeLift ''CodeParam

The cert field will contain an Integer code that must be specified in the redeemer for script certificates to be validated, and the reward code will have to be specified in order to withdraw rewards. We will be writing this as a typed validator so we need a ValidatorTypes instance as well:

data CodeValidator
instance PSU.ValidatorTypes CodeValidator where
  type instance RedeemerType CodeValidator = Integer
  -- We only care about the redeemer type for the stake validator

We now have everything to create the mkStakingValidator function which will represent our stake validator logic:

mkStakingValidator :: CodeParam -> Integer -> Plutus.ScriptContext -> Bool
mkStakingValidator cp redeemer ctx = 
  case Plutus.scriptContextPurpose ctx of
    Plutus.Certifying _   -> redeemer == cert cp
    Plutus.Rewarding  _   -> redeemer == reward cp
    _                     -> False

We simply need to check that the redeemer matches the corresponding action. Any script purpose other than Certifying or Rewarding will always be False.

Next, we need to compile that function into a StakeValidator type with the end goal of the UntypedStakeValidator that we looked at earlier. Since there is still no interface TypedStakeValidator, we have to use somewhat explicit code with mkStakeValidatorScript and mkUntypedStakeValidator. This function is defined as:

mkUntypedStakeValidator
    :: PV1.UnsafeFromData r
    => (r -> sc -> Bool)
    -> UntypedStakeValidator
mkUntypedStakeValidator f r p =
    check $ f (tracedUnsafeFrom "Redeemer decoded successfully" r)
              (tracedUnsafeFrom "Script context decoded successfully" p)

It receives a function (r -> sc -> Bool) and returns the UntypedStakeValidator which is the end result we want here. Since this is a parameterised contract, we have to apply our CodeParam to the mkStakingValidator function first in order to get just the (Integer -> ScriptContext -> Bool) function that is required here.

That is why we have to compose the two functions together and apply the CodeParam. However, since this is all being compiled to Plutus IR (intermediate Plutus Core), we also have to first lift the CodeParam value to Plutus IR for it to be applied.

We do this with PlutusTx.liftCode cp, and we are able to do it because we made CodeParam an instance of the Lift class. In the previous examples, this was abstracted for us via the mkTypedValidatorParam function, but since one is not available for stake validators (yet), we have to do it manually here:

validator :: CodeParam -> PSU.V2.StakeValidator
validator cp = Plutus.mkStakeValidatorScript $
  $$(PlutusTx.compile [|| PSU.mkUntypedStakeValidator . mkStakingValidator ||])
  `PlutusTx.applyCode`
  PlutusTx.liftCode cp

The rest is the same as before, the only difference being that instead of unValidatorScript, we use unStakeValidatorScript to get the Script type of the validator:

script :: CodeParam -> Plutus.Script
script = Plutus.unStakeValidatorScript . validator

scripShortBs :: CodeParam -> SBS.ShortByteString
scripShortBs cp = SBS.toShort . LBS.toStrict $ serialise $ script cp

scriptSerialised :: CodeParam -> PlutusScript PlutusScriptV2
scriptSerialised cp = PlutusScriptSerialised $ scripShortBs cp

writeSerialisedScript :: CodeParam -> IO (Either (FileError ()) ())
writeSerialisedScript cp = writeFileTextEnvelope "compiled/StakingValidator.plutus" Nothing $ scriptSerialised cp

All that is left to do is come up with a concrete instance of the CodeParam and pass it to the writeSerialisedScript function to compile the validator. For this example, we will just use the integers 1 and 2 as our secret codes. In the cabal repl of our nix-shell, we can do that with:

Prelude> :l src/StakingValidator.hs  
[1 of 1] Compiling StakingValidator ( src/StakingValidator.hs, /home/plutus/hpm-plutus/hpm-validators/dist-newstyle/build/x86_64-linux/ghc-8.10.7/hpm-validators-0.1.0.0/build/StakingValidator.o )
Ok, one module loaded.
Prelude StakingValidator> myCodeParam = CodeParam 1 2
Prelude StakingValidator> writeSerialisedScript myCodeParam 
Right ()

Testing the validator

To test a staking validator, we will need to slightly change our usual way of creating a script address with create-script-address.sh. So far we have only been using the payment credentials part to create an address, either a verification key or a validator script hash. Here, we want to add the optional staking credential part and use our stake validator script to provide the validation logic.

We can first use the cardano-cli stake-address build to get the stake address for this validator. When we do that, this address will be just a "Reward account address" (terminology from Cardano docs), unable to receive any UTxO, but could be used as a rewards address for delegation.

As mentioned above, we usually want to combine payment and staking credentials into one address, so let's do that next. We will specify the 01.vkey for the payment credential, and use our stake validator script to provide the staking credential. The full Bash script looks like this:

#!/usr/bin/env bash

NWMAGIC=2 # preview testnet


# Build script stake address
cardano-cli stake-address build \
  --testnet-magic ${NWMAGIC} \
  --stake-script-file ../../compiled/StakingValidator.plutus \
  --out-file StakingValidator.stake

echo "Script stake address: $(cat StakingValidator.stake)"

# Build address with 01.vkey for payment part and script stake part
cardano-cli address build \
  --payment-verification-key-file ../address/01.vkey \
  --stake-script-file ../../compiled/StakingValidator.plutus \
  --testnet-magic ${NWMAGIC} \
  --out-file StakingValidator.addr

echo "Full address: $(cat StakingValidator.addr)"

Running the script provides us with the two addresses, the first one being just the stake address and the second a fully combined address:

./create-script-address.sh
Script stake address: stake_test17peya46y0tymw8cq6hgdlzdlrys58acwsww4luzk0yur9vgy0xqrc
Full address: addr_test1yzjaxxx6m76jamlmyc9wp9lcg6h2pjncumxyleqx6n8wmsrjfmt5g7kfku0sp4wsm7ym7xfpg0msaquatlc9v7fcx2cs65ntf3

As usual, we need a way of checking the UTxO on our addresses, so we update the check-utxos.sh script for this StakingValidator.addr we just created.

#!/usr/bin/env bash

NWMAGIC=2 # preview testnet
export CARDANO_NODE_SOCKET_PATH=$CNODE_HOME/sockets/node0.socket

funds_normal1=$(cardano-cli query utxo \
--address $(cat ../normal_address/01.addr) \
--testnet-magic $NWMAGIC)


funds_script=$(cardano-cli query utxo \
--address $(cat codeParam.addr) \
--testnet-magic $NWMAGIC)

echo "Normal address1:"
echo "${funds_normal1}"
echo ""

echo "Script address:"
echo "${funds_script}"

We can now send some funds to the script with send-funds-to-script.sh that we will delegate to a pool.

#!/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 abe233c49d4162d886a0e38e2ed03739cc9feb0b5f38eb54d8417eb9821f039b#1 \
  --tx-out $(cat StakingValidator.addr)+200000000 \
  --tx-out-datum-embed-file ../../compiled/assets/unit.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
./send-funds-to-script.sh 
Estimated transaction fee: Lovelace 170341
Transaction successfully submitted.

./check-utxos.sh 
Normal address1:
                           TxHash                                 TxIx        Amount
--------------------------------------------------------------------------------------
5dc5111e257f8e68b0978c9619e57bbb12d365c0ec45d879115866bb674156ae     0        1826915 lovelace + TxOutDatumNone
9f24a56e249b86b3216cd337d9ada7fe3a030e339c97ab00191bc496b03132ed     1        9624465215 lovelace + TxOutDatumNone
ede24e9e40ca82830c75d827b5c3b090132c1afaebd3a4256655fb5d2382474a     0        9649776 lovelace + TxOutDatumNone
ee346be463426509daec07aba24a8905c5f55965daebb39f842a49191d83f9e1     0        1829006 lovelace + TxOutDatumNone

Script address:
                           TxHash                                 TxIx        Amount
--------------------------------------------------------------------------------------
9f24a56e249b86b3216cd337d9ada7fe3a030e339c97ab00191bc496b03132ed     0        200000000 lovelace + TxOutDatumHash ScriptDataInBabbageEra "923918e403bf43c34b4ef6b48eb2ee04babed17320d8d1b9ff9ad086e86f44ec"

It's time to finally register and delegate this address to a pool. Besides submitting the registration certificate, we also have to find a pool to delegate to. We can get a list of stake pools from cardano-cli with:

cardano-cli query stake-pools --testnet-magic 2
pool1q95luz38nhsw6h7mxud8tptc6mxvnsczhanw4j5htk8h2ltlf3k
pool1qxcz3zxnye8g9ejsqslhl0ljevdx895uc80lr89ulf92gcv40ng
pool1qncwwllw9nwtu7sl7zqw3fpyh4t3q6nhludryfwv0jyqjygd46d
pool1qal80uhlj949mgv0ecvdkmgqjdn5q27wmpaj4crnr5e9v6qmsv7
pool1p9xu88dzmpp5l8wmjd6f5xfs9z89mky6up86ty2wz4aavmm8f3m
pool1p835jxsj8py5n34lrgk6fvpgpxxvh585qm8dzvp7ups37vdet5a
...

We can use preview.cardanoscan.io to check pool information. Let's pick the second pool from the list pool1qxcz3zxnye8g9ejsqslhl0ljevdx895uc80lr89ulf92gcv40ng because it is regularly creating blocks so we know we will get rewards. We can submit both the address registration and delegation certificate in the same transaction. Our validator defined two secret integer codes (we used 1 and 2) that need to be specified as the redeemer for transactions, the first one required to validate certificates being submitted (registration, delegation, deregistration), and the second one for validating rewards withdrawals.

Let's create a register-and-delegate-script.sh script that will do that for us. We create the registration and delegation certificates and attach them to the transaction build command. Then we have to specify the --certificate-script-file argument since our certificates are validated by our script rather than regular keys. The script must receive a redeemer and since our redeemer is very simple (just the integer value 1), we can use --certificate-redeemer-value 1. Besides that, we can specify the --change-address $(cat StakingValidator.addr) to send any remaining funds after the transaction fees are substracted to the staking address as well to be delegated.

#!/usr/bin/env bash

NWMAGIC=2 # preview testnet
export CARDANO_NODE_SOCKET_PATH=$CNODE_HOME/sockets/node0.socket

cardano-cli stake-address registration-certificate \
  --stake-script-file ../../compiled/StakingValidator.plutus \
  --out-file reg.cert

cardano-cli stake-address delegation-certificate \
  --stake-script-file ../../compiled/StakingValidator.plutus \
  --stake-pool-id pool1qxcz3zxnye8g9ejsqslhl0ljevdx895uc80lr89ulf92gcv40ng \
  --out-file deleg.cert

cardano-cli transaction build \
  --testnet-magic $NWMAGIC \
  --change-address $(cat StakingValidator.addr) \
  --tx-in 9f24a56e249b86b3216cd337d9ada7fe3a030e339c97ab00191bc496b03132ed#01 \
  --tx-in-collateral ee346be463426509daec07aba24a8905c5f55965daebb39f842a49191d83f9e1#0 \
  --certificate-file reg.cert \
  --certificate-file deleg.cert \
  --certificate-script-file ../../compiled/StakingValidator.plutus \
  --certificate-redeemer-value 1 \
  --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
./register-and-delegate.sh 
Estimated transaction fee: Lovelace 313896
Transaction successfully submitted.

We can check Cardanoscan again to look for our script address and see that the transaction did what was expected. It is now registered and delegated to the pool we specified.

You are free to try redelegating now by creating a new delegation-certificate with the wrong redeemer or an absent redeemer but our staking validator will invalidate the transaction.

We can create a check-script-rewards.sh next, but it will be empty for a few days until the first rewards start coming in (epochs are 1 day long on the Preview testnet).

#!/usr/bin/env bash

NWMAGIC=2 # preview testnet
export CARDANO_NODE_SOCKET_PATH=$CNODE_HOME/sockets/node0.socket

rewards_script=$(cardano-cli query stake-address-info \
--address $(cat StakingValidator.stake) \
--testnet-magic $NWMAGIC)

echo "Script address:"
echo "${rewards_script}"
./check-script-rewards.sh 
Script address:
[
    {
        "address": "stake_test17peya46y0tymw8cq6hgdlzdlrys58acwsww4luzk0yur9vgy0xqrc",
        "delegation": "pool1qxcz3zxnye8g9ejsqslhl0ljevdx895uc80lr89ulf92gcv40ng",
        "rewardAccountBalance": 0
    }
]

Once a few days have passed and we have some rewards, we can withdraw them with the correct redeemer code in our withdraw-rewards.sh script.

#!/usr/bin/env bash

NWMAGIC=2 # preview testnet
export CARDANO_NODE_SOCKET_PATH=$CNODE_HOME/sockets/node0.socket


rewards=$(cardano-cli query stake-address-info \
    --address $(cat StakingValidator.stake) \
    --testnet-magic $NWMAGIC | jq -r ".[0].rewardAccountBalance")

cardano-cli transaction build \
    --testnet-magic $NWMAGIC \
    --change-address $(cat StakingValidator.addr) \
    --tx-in cf72524e68d5884b2b1fb402494cf81de60aec6fbefd610af606adcddc7e4837#0 \
    --tx-in-collateral ee346be463426509daec07aba24a8905c5f55965daebb39f842a49191d83f9e1#0 \
    --withdrawal $(cat StakingValidator.stake)+${rewards} \
    --withdrawal-script-file ../../compiled/StakingValidator.plutus \
    --withdrawal-redeemer-value 2 \
    --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

Actually, the pool we initially delegated pool1qxcz3zxnye8g9ejsqslhl0ljevdx895uc80lr89ulf92gcv40ng to is not producing blocks anymore so we need to redelegate to another pool. We can write a new redelegate.sh script for that.

#!/usr/bin/env bash

NWMAGIC=2 # preview testnet
export CARDANO_NODE_SOCKET_PATH=$CNODE_HOME/sockets/node0.socket

cardano-cli stake-address delegation-certificate \
  --stake-script-file ../../compiled/StakingValidator.plutus \
  --stake-pool-id pool1x9xkvkrfw6htmnflpad0z2aqsxx50f5mwkyzpylw0tlsk9z5uff \
  --out-file deleg.cert

cardano-cli transaction build \
  --testnet-magic $NWMAGIC \
  --change-address $(cat StakingValidator.addr) \
  --tx-in fbbf7a532ff30176087966c129f8fe44aa9e3462e4224e4fbfe3e162b1569ded#2 \
  --tx-in-collateral ee346be463426509daec07aba24a8905c5f55965daebb39f842a49191d83f9e1#0 \
  --certificate-file deleg.cert \
  --certificate-script-file ../../compiled/StakingValidator.plutus \
  --certificate-redeemer-value 1 \
  --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
./redelegate.sh 
Estimated transaction fee: Lovelace 309296
Transaction successfully submitted.

After a couple of days and pool blocks produced, rewards will be available at the stake address we created with the validator.

./check-script-rewards.sh
Script address:
[
    {
        "address": "stake_test17peya46y0tymw8cq6hgdlzdlrys58acwsww4luzk0yur9vgy0xqrc",
        "delegation": "pool1x9xkvkrfw6htmnflpad0z2aqsxx50f5mwkyzpylw0tlsk9z5uff",
        "rewardAccountBalance": 49443941
    }
]

Last updated