Creating smart contract transactions via CLI

Since Cardano’s smart contract support, there have been various tutorials and documentation resources about how to create smart contracts.

From recent experience, there are still a lot of gaps to fill and gotchas to be aware of. However, since the Plutus PAB backend is still WIP and there is a pre-release (date: 2021-11-17), we want to overview how it is possible to deploy and run the proof-of-burn contract against the testnet.

The critical part of understanding this approach is that this does not allow running actual Plutus PAB endpoints written in the smart contract. The only thing re-used from it will be the transaction validator (also called “Plutus script”), which is run when constructing and validating transactions on the chain.

Prerequisites

First, we’ll need:

  1. A running node to submit transactions
  2. A  running wallet to create transactions, do coin selection etc.
  3. A  chain indexer (either local or remote like blockfrost), so we can query script UTxOs etc.

Additional Cardano cli tools:

  1. cardano-cli: main tool to create and submit transactions
  2. cardano-address: some additional address functionality
  3. bech32: for decoding

A plethora of command line utility tools:

  1. jq for command line json processing
  2. xxd for dealing with hex strings
  3. bc to convert hex strings to binary representation
  4. openssl for some hashing functionality
  5. curl for making HTTP requests to our wallet and chain indexer

It can be difficult to figure out the correct combination of tools. A set that was used here:

  1. wallet, cardano-address and bech32 from wallet 2021-09-29 release (just unpack the release tarball)
  2. node and cardano-cli from commit 9dd31b3f8f17fba30882e98bb02810a7a504ba38 (has to be built from source)

It is possible that the latest wallet and node releases are fine too.

Extracting the “Plutus script”

Finally, we need the actual smart contract, from which we can extract the Plutus script. We will use the following example: proof-of-burn contract.

In the contract, we have our TypedValidator, which is the Plutus Script definition:

burnerTypedValidator :: Scripts.TypedValidator Burner
burnerTypedValidator = Scripts.mkTypedValidator @Burner
    $$(PlutusTx.compile [|| validateSpend ||])
    $$(PlutusTx.compile [|| wrap          ||])
  where
    wrap = Scripts.wrapValidator @MyDatum @MyRedeemer

We then turn this into a serialized script:

import Codec.Serialise          (serialise )
import Cardano.Api.Shelley      (PlutusScript (..), PlutusScriptV1)
import Plutus.V1.Ledger.Scripts (unValidatorScript)
import qualified Data.ByteString.Short as SBS
import qualified Data.ByteString.Lazy  as LBS

burnerSerialised :: PlutusScript PlutusScriptV1
burnerSerialised = PlutusScriptSerialised . SBS.toShort . LBS.toStrict . serialise . Plutus.unValidatorScript $ burnerValidator

In our main, we can write it to a file:

import Cardano.Api (writeFileTextEnvelope, Error(displayError))

main :: IO ()
main = do
  result <- writeFileTextEnvelope "result.plutus" Nothing burnerSerialised
  case result of
    Left err -> print $ displayError err
    Right () -> return ()

This file result.plutus will be used when submitting a transaction, getting the script address etc.

Starting wallet, node etc.

In this case, we do not cover how to start wallet, node, etc. These are explained in the following documents:

  1. https://github.com/input-output-hk/cardano-node/blob/master/README.rst
  2. https://input-output-hk.github.io/cardano-wallet/user-guide/cli

Make sure to use the testnet and use the correct configuration files.

Creating transactions

The biggest issue when creating transactions via the command line is getting the datum/redeemers right. These must be passed to cardano-cli in some JSON format, which is a bit ‘under documented’ (as can be seen here).

We assume that a wallet has already been created/restored and that it has some funds. If you have not added some, use the testnet faucet: https://testnets.cardano.org/en/testnets/cardano/tools/faucet/

Also, make sure that CARDANO_NODE_SOCKET_PATH is set, which is needed by cardano-cli.

Verifying JSON schema of datum/redeemer

In your contract repo, start a repl, e.g. via cabal repl --build-depends cardano-api --build-depends aeson, then construct an example datum, e.g. for our contract:

Prelude ProofOfBurn> :set -XOverloadedStrings
Prelude ProofOfBurn> import Cardano.Api
Prelude Cardano.Api ProofOfBurn> import Ledger.Scripts
Prelude Cardano.Api Ledger.Scripts ProofOfBurn> import qualified PlutusTx
Prelude Cardano.Api Ledger.Scripts PlutusTx ProofOfBurn> import qualified Data.Aeson as Aeson
Prelude Cardano.Api Ledger.Scripts PlutusTx Aeson ProofOfBurn> let datum = MyDatum "test"
Prelude Cardano.Api Ledger.Scripts PlutusTx Aeson ProofOfBurn> Aeson.encode . scriptDataToJson ScriptDataJsonDetailedSchema . toCardanoAPIData . PlutusTx.toBuiltinData $ datum
"{\"constructor\":0,\"fields\":[{\"bytes\":\"74657374\"}]}"

That’s it… our datum format is
"{\"constructor\":0,\"fields\":[{\"bytes\":\"74657374\"}]}", where 74657374 is our actual data, hex-encoded.

We do the same thing for the redeemer type:

Prelude Cardano.Api Ledger.Scripts PlutusTx Aeson ProofOfBurn> let redeemer = MyRedeemer ()
Prelude Cardano.Api Ledger.Scripts PlutusTx Aeson ProofOfBurn> Data.Aeson.encode . scriptDataToJson ScriptDataJsonDetailedSchema . toCardanoAPIData . PlutusTx.toBuiltinData $ redeemer
"{\"constructor\":0,\"fields\":[{\"constructor\":0,\"fields\":[]}]}"

Creating wallet keys

Since we cannot use the wallet’s REST API for all tasks yet, we need a lot of key/address files to be passed to cardano-cli.

First we create the root private key, for which we need the recovery phrase:

echo "<recovery-phrase>" |
    cardano-address key from-recovery-phrase Shelley > root.prv

Then the account private/public keys:

cat root.prv |
    cardano-address key child 1852H/1815H/0H |
    tee acct.prv |
    cardano-address key public --with-chain-code > acct.pub

Creating lock transaction

First we need to create the script address from the result.plutus script and save it to burn.addr:

cardano-cli address build \
    --payment-script-file "result-plutus" \
    --testnet-magic=1097911063 \
    --out-file "burn.addr"

Then we create files for the datum/redeemer and its hash:

echo "{\"constructor\":0,\"fields\":[{\"bytes\":\"74657374\"}]}" > datum.json
cardano-cli transaction hash-script-data --script-data-file "datum.json" > datum.hash

echo "{\"constructor\":0,\"fields\":[{\"constructor\":0,\"fields\":[]}]}" > redeemer.json
cardano-cli transaction hash-script-data --script-data-file "redeemer.json" > redeemer.hash

Now we need to perform a coin selection, which figures out which UTxOs can be used as inputs for the transaction. We also need the wallet-id, which you should have got when creating it. Otherwise check your wallets via curl -s -X GET "http://localhost:8090/v2/wallets".

cat <<EOF >/tmp/payload
{
  "payments": [
    {
      "address": "$(cat burn.addr)",
      "amount": {
        "quantity": 9000000,
        "unit": "lovelace"
      }
    }
  ]
}
EOF

curl -s -H "Content-Type: application/json" --data @/tmp/payload "http://localhost:8090/v2/wallets/<wallet-id>/coin-selections/random" > coin_selection.json

This will return a json of the following type:

{
  "inputs": [
    {
      "address": "addr1sjck9mdmfyhzvjhydcjllgj9vjvl522w0573ncustrrr2rg7h9azg4cyqd36yyd48t5ut72hgld0fg2xfvz82xgwh7wal6g2xt8n996s3xvu5g",
      "amount": {
        "quantity": 42000000,
        "unit": "lovelace"
      },
      "assets": [
        {
          "policy_id": "65ab82542b0ca20391caaf66a4d4d7897d281f9c136cd3513136945b",
          "asset_name": "",
          "quantity": 0
        }
      ],
      "id": "1423856bc91c49e928f6f30f4e8d665d53eb4ab6028bd0ac971809d514c92db1",
      "derivation_path": [
        "1852H",
        "1815H",
        "0H",
        "0",
        "0",
      ],
      "index": 0
    }
  ],
  "outputs": [
    {
      "address": "addr1sjck9mdmfyhzvjhydcjllgj9vjvl522w0573ncustrrr2rg7h9azg4cyqd36yyd48t5ut72hgld0fg2xfvz82xgwh7wal6g2xt8n996s3xvu5g",
      "amount": {
        "quantity": 42000000,
        "unit": "lovelace"
      },
      "assets": [
        {
          "policy_id": "65ab82542b0ca20391caaf66a4d4d7897d281f9c136cd3513136945b",
          "asset_name": "",
          "quantity": 0
        }
      ]
    }
  ],
  "change": [
    {
      "address": "addr1sjck9mdmfyhzvjhydcjllgj9vjvl522w0573ncustrrr2rg7h9azg4cyqd36yyd48t5ut72hgld0fg2xfvz82xgwh7wal6g2xt8n996s3xvu5g",
      "amount": {
        "quantity": 42000000,
        "unit": "lovelace"
      },
      "assets": [
        {
          "policy_id": "65ab82542b0ca20391caaf66a4d4d7897d281f9c136cd3513136945b",
          "asset_name": "",
          "quantity": 0
        }
      ],
      "derivation_path": [
        "1852H",
        "1815H",
        "0H",
        "0",
        "1",
      ],
    }
  ],
  "collateral": [
    {
      "address": "addr1sjck9mdmfyhzvjhydcjllgj9vjvl522w0573ncustrrr2rg7h9azg4cyqd36yyd48t5ut72hgld0fg2xfvz82xgwh7wal6g2xt8n996s3xvu5g",
      "amount": {
        "quantity": 42000000,
        "unit": "lovelace"
      },
      "id": "1423856bc91c49e928f6f30f4e8d665d53eb4ab6028bd0ac971809d514c92db1",
      "derivation_path": [
        "1852H",
        "1815H",
        "0H",
        "0",
        "0",
      ],
      "index": 0
    }
  ],
  "withdrawals": [],
  "certificates": [],
  "deposits": [],
  "metadata": "string"
}

To make it simple, we assume that there is only one input and one change address (cardano-cli of course allows to specify multiple inputs). We do not need the collateral in this step, but in the next one (redeeming).

We also assume there are two different derivation paths (e.g. for inputs and change) ["1852H","1815H","0H","0","0"] and ["1852H","1815H","0H","0","2"]. For each of those, we need to create signing and verification keys:

# if derivation path is ["1852H","1815H","0H","0","0"], pass "1852H/1815H/0H/0/0"
cat root.prv |
    cardano-address key child "1852H/1815H/0H/0/0" > addr0.prv

cardano-cli key convert-cardano-address-key --shelley-payment-key --signing-key-file "addr0.prv" --out-file key0.skey
cardano-cli key verification-key --signing-key-file key0.skey --verification-key-file key0.vkey

Now we should have the files key0.skey, key0.vkey, key1.skey and key1.vkey.

Finally, we can create the transaction:

# generate protocol parameters
cardano-cli query protocol-parameters \
    --testnet-magic=1097911063 \
    > pparams.json

cardano-cli transaction build \
    --alonzo-era \
    \ # format is "${tx_id}#${tx_index}", [0] stands for the first input, use [1] for the second etc.
    --tx-in "$(cat coin_selection.json | jq -e -r '.inputs | .[0].id')#$(cat coin_selection.json | jq -e -r '.inputs | .[0].index')" \
    \ # amount needs to match what we fed into coin selection
    --tx-out "$(cat burn.addr)+9000000" \
    --tx-out-datum-hash=$(cat datum.hash)" \
    --change-address "$(cat coin_selection.json | jq -e -r '.change | .[0].address')" \
    --testnet-magic=1097911063 \
    --protocol-params-file "pparams.json" \
    --witness-override 2 \
    --required-signer="key0.skey" \
    --required-signer="key1.skey" \
    --out-file "tx.raw"

Then we sign it:

cardano-cli transaction sign \
        --tx-body-file tx.raw \
        --signing-key-file="key0.skey" \
        --signing-key-file="key1.skey" \
        --out-file "tx.signed"

Submit the transaction:

cardano-cli transaction submit \
    --testnet-magic=1097911063 \
    --tx-file "tx.signed"

Now, using blockfrost, we can query the UTxO of the script address (you need so sign up for a free account to get a token or use some other indexer/service):

curl -s -X GET -H "project_id: ${BLOCKFROST_API_TOKEN}" \
    "https://cardano-testnet.blockfrost.io/api/v0/addresses/$(cat burn.addr)/utxos"

This might take a while, so retry at will.

Creating redeem transaction

This follows pretty much the same procedure as above except that we need the datum and the redeemer, which should already be in the files datum.json and redeemer.json.

The coin selection should be run as above, create signing keys and then create the raw transaction. The differences here are:

  1. We additionally need to pass a collateral (in case redeeming fails)… again, for simplicity we assume coin selection returned only one
  2. We also pass the Plutus script itself for validation
  3. We pass in the mandatory redeemer (which is a dummy value in proof-of-burn though)
  4. We do not have --tx-out, because we are not submitting a value like when we locked
cardano-cli transaction build \
    --alonzo-era \
    \ # format is "${tx_id}#${tx_index}", [0] stands for the first input, use [1] for the second etc.
    --tx-in "$(cat coin_selection.json | jq -e -r '.inputs | .[0].id')#$(cat coin_selection.json | jq -e -r '.inputs | .[0].index')" \
    \ # same format
    --tx-in-collateral "$(cat coin_selection.json | jq -e -r '.collateral | .[0].id')#$(cat coin_selection.json | jq -e -r '.collateral | .[0].index')" \
    --tx-in-script-file result.plutus \
    --tx-in-datum-file datum.json \
    --tx-in-redeemer-file redeemer.json \
    --change-address "$(cat coin_selection.json | jq -e -r '.change | .[0].address')" \
    --testnet-magic=1097911063 \
    --protocol-params-file "pparams.json" \
    --witness-override 2 \
    --required-signer="key0.skey" \
    --required-signer="key1.skey" \
    --out-file "tx.raw"

Signing and submitting follows the instructions from locking.

Check your balance before and after redeeming, via:

curl -s -X GET "http://localhost:8090/v2/wallets/<wallet-id>" | jq -r '.balance.available.quantity'

Whether redeeming works, obviously, depends on the contract, the datum and the redeemer. For the proof-of-burn contract, we have a dummy redeemer (which is irrelevant), but the datum needs to be your own wallet pubkey sha3_256 encoded for the redeeming to work. The Plutus script makes it obvious:

validateSpend :: ValidatorType Burner
validateSpend (MyDatum addrHash) _myRedeemerValue ScriptContext { scriptContextTxInfo = txinfo } =
   addrHash `elem` allPubkeyHashes
 where
  requiredSigs :: [PubKeyHash]
  requiredSigs = txInfoSignatories txinfo
  allPubkeyHashes :: [BuiltinByteString]
  allPubkeyHashes = fmap (sha3_256 . getPubKeyHash) requiredSigs

If you want to get your wallet pubkey sha3_256 encoded, run:

curl -s -X GET "http://localhost:8090/v2/wallets/<wallet-id>/keys/utxo_external/0?hash=true" | jq -r . | bech32 | xxd -r -p | openssl dgst -r -sha3-256 | awk '{ print $1 }'

Making things more ergonomic

Since this is a lot of manual work, it might make sense to:

  1. create a docker-compose.yml to orchestrate the services like node, wallet etc.
  2. install all the required command line tools in a single docker image
  3. abstract over the transaction creation in a shell script

These steps were done for the proof-of-burn.

Conclusion

It can be quite challenging to manually create a transaction. Again, it is important to understand that only the Plutus script that does transaction validation is used in this cli based approach, which limits the use cases somewhat.

By Michał J. Gajda