Smart Contract Interactions

It's important to understand that on the Cardano blockchain, you don't directly interact with "smart contracts", atleast not in the traditional sense.

  • Instead, you work with validators. These validators are responsible for verifying the actions taken in a given transaction, rather than executing or calling any actions themselves.

  • A validator checks whether the transaction meets its requirements, and if it does, the transaction is processed successfully. If the requirements are not met, the transaction fails (is not allowed to execute).


Instantiate validator

Lucid Evolution consumes compiled validators. On-chain scripts can be written in PlutusTx, Aiken, Plutarch, and many other languages available in the Cardano ecosystem. We then derive the address from the compiled script with Lucid Evolution.

const spend_val: SpendingValidator = {
  type: "PlutusV2",
  script: "59099a590997010000...", // from plutus.json of the compiled contract code
};

Working with Datums and Redeemers

Datums and redeemers are crucial for interacting with validators

const datum = Data.to(new Constr(0, [publicKeyHash]));
const redeemer = Data.to(new Constr(0, [fromText("Hello, World!")]));

You can also define datum schemas for type safety

const DatumSchema = Data.Object({
  owner: Data.Bytes(),
});
type DatumType = Data.Static<typeof DatumSchema>;
const DatumType = DatumSchema as unknown as DatumType;

Lock funds at a plutus script address

const tx = await lucid
  .newTx()
  .pay.ToAddressWithData(
    contractAddress,
    { kind: "inline", value: datum },
    { lovelace: 10_000_000n }
  )
  .complete();

Redeem from a plutus script address

const allUTxOs = await lucid.utxosAt(contractAddress);
const ownerUTxO = allUtxos.find((utxo) => {
  if (utxo.datum) {
    const datum = Data.from(utxo.datum, DatumType);
    return datum.owner === publicKeyHash;
  }
});
 
const tx = await lucid
  .newTx()
  .collectFrom([ownerUTxO], redeemer)
  .attach.SpendingValidator(spend_val) // spend_val was defined earlier
  .addSigner(address)
  .complete();

Redeemer Builder

The Redeemer Indexing design pattern (opens in a new tab) leverages the deterministic script evaluation property of the Cardano ledger to achieve substantial performance gains in onchain code.

In scenarios where the protocol necessitates spending from the script back to a specific output—such as returning funds from the script to the same script, directing them to another script, or transferring assets to a wallet, it is imperative to ensure that each script input is uniquely associated with an output.

This preventive measure is essential for mitigating the risk of Double Satisfaction Attack (opens in a new tab).

You can use a redeemer containing one-to-one correlation between script input UTxOs and output UTxOs. This is provided via ordered lists of input/output indices of inputs/ouputs present in the Script Context.

For e.g.

Inputs     :  [scriptInputA, scriptInputB, randomInput1, scriptInputC, randomInput2, randomInput3]          // random inputs are not the concerned script inputs
Outputs    :  [outputA, outputB, outputC, randomOuput1, randomOutput2, randomOutput3]
InputIdxs  :  [0, 1, 3]
OutputIdxs :  [0, 1, 2]

where type Redeemer = List<(inputIdx, outputIdx)>

Luckily lucid-evolution (opens in a new tab) provides a high-level interface that abstracts all the complexity away and makes writing offchain code for this design pattern extremely simple by using the RedeemerBuilder to help building your redeemer.

const scriptInputs: UTxO[] = [
    { txHash: "a", outputIndex: 1, address: "scriptAddress", assets: { lovelace: 10_000000n } },
    { txHash: "b", outputIndex: 2, address: "scriptAddress", assets: { lovelace: 20_000000n } },
    { txHash: "d", outputIndex: 4, address: "scriptAddress", assets: { lovelace: 40_000000n } },
];
const randomInputs: UTxO[] = [
    { txHash: "c", outputIndex: 3, address: "randomAddress", assets: { lovelace: 30_000000n } },
    { txHash: "e", outputIndex: 5, address: "randomAddress", assets: { lovelace: 50_000000n } },
    { txHash: "f", outputIndex: 6, address: "randomAddress", assets: { lovelace: 60_000000n } },
];
 
const redeemer: RedeemerBuilder = {
    kind: "selected",
    makeRedeemer: (inputIdxs: bigint[]) => {
        const tupleList = inputIdxs.map((inputIdx, i) => [inputIdx, BigInt(i)]); // List<(inputIdx, outputIdx)>
        return Data.to(tupleList);
    },
    inputs: scriptInputs,
};
 
const tx = await lucid
    .newTx()
    .collectFrom([...scriptInputs, ...randomInputs], redeemer)
    .attach.SpendingValidator(script)
    .pay.ToContract("scriptAddress", datum, { lovelace: 10_000000n })
    .pay.ToContract("scriptAddress", datum, { lovelace: 20_000000n })
    .pay.ToContract("scriptAddress", datum, { lovelace: 40_000000n })
    .pay.ToAddress("randomAddress", { lovelace: 30_000000n })
    .pay.ToAddress("randomAddress", { lovelace: 50_000000n })
    .pay.ToAddress("randomAddress", { lovelace: 60_000000n })
    .addSigner("randomAddress")
    .complete();

Apply parameters

Some validators are parameterized, you can apply parameters dynamically. Below example is for your validator when it expects 1 integer argument, eg. validator minting_policy(first_param: Int) { .. }

const mintingPolicy = {
  type: "PlutusV3",
  script: applyParamsToScript(
    applyDoubleCborEncoding("5907945907910100..."),
    [10n] // Parameters
  ),
};

Next example is for your validator when it expects 2 arguments, eg. a ByteArray and a Boolean. For example, validator spending_validator(pkh: VerificationKeyHash, yes_no: Bool) { .. }

const pkh = paymentCredentialOf(address).hash;
const yes = new Constr(1, []);
 
const spendingValidator = {
  type: "PlutusV3",
  script: applyParamsToScript(
    applyDoubleCborEncoding("5907945907910101..."),
    [pkh, yes] // Parameters
  ),
};

The following example is for your validator when it expects a more complex type, for example:

type OutputReference {
  transaction_id: ByteArray,
  output_index: Int,
}

validator your_validator(o_ref: OutputReference) { .. }
const oRef = new Constr(0, [String(utxo.txHash), BigInt(utxo.outputIndex)]);
 
const yourValidator = {
  type: "PlutusV3",
  script: applyParamsToScript(
    applyDoubleCborEncoding("5907945907910102..."),
    [oRef] // Parameters
  ),
};

Note that this OutputReference format is Aiken's, for Plutus format,

data TxOutRef = TxOutRef
  { txOutRefId  :: TxId
  , txOutRefIdx :: Integer
  }

where TxId itself is another type like newtype TxId = TxId { getTxId :: PlutusTx.BuiltinByteString }, this would translate to the following Aiken code:

type TransactionId {
    tx_id: ByteArray
}

type OutputReference {
  transaction_id: TransactionId,
  output_index: Int,
}

validator your_validator(o_ref: OutputReference) { .. }

In this case, you can provide the parameter for your validator like:

const txID = new Constr(0, [String(utxo.txHash)]);
const txIdx = BigInt(utxo.outputIndex)
const oRef = new Constr(0, [txID, txIdx]);
 
const yourValidator = {
  type: "PlutusV3",
  script: applyParamsToScript(
    applyDoubleCborEncoding("5907945907910103..."),
    [oRef] // Parameters
  ),
};

Plutus script purposes

Like native scripts, Plutus scripts can be used not only for checking the spending conditions of UTxOs but also for verifying conditions related to minting, delegations, and withdrawals

.collectFrom(utxos, redeemer)
.mintAssets(assets, redeemer)
.delegateTo(stakeAddress, poolId, redeemer)
.deRegisterStake(stakeAddress, redeemer)
.withdraw(stakeAddress, rewardAmount, redeemer)

Multi validator interactions

You can run and execute multiple validators in a single transaction with Lucid Evolution. The only limitation is the execution units limit

const tx = await lucid
  .newTx()
  .collectFrom([scriptUtxoA, scriptUtxoB])
  .collectFrom([scriptUtxoC])
  .collectFrom([scriptUtxoD])
  .mintAssets({ [plutusPolicyId]: 10n })
  .attach.SpendingValidator(spendingScript1)
  .attach.SpendingValidator(spendingScript2)
  .attach.MintingPolicy(mintingPolicy)
  .complete();

Read UTxOs and plutus scripts

Lucid Evolution allows you to conveniently read/reference UTxOs. If a Plutus script is already stored in the UTxO, there's no need to attach the same script explicitly in the transaction, resulting in cost savings.

const tx = await lucid
  .newTx()
  .readFrom([scriptUtxo])
  .complete();
💡

Remember that when interacting with Plutus scripts, you need to provide appropriate datums and redeemers. Always ensure your transaction meets the validator's requirements to avoid failures.