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. So, validators are pure functions that return true or false.

  • 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, this is the compiled script in CBOR format
};
 
const scriptAddress = lucid.utils.validatorToAddress(validator);

Locking funds at Script Address

// Lock assets to the script address with inline datum
const tx = await lucid
  .newTx()
  .pay.ToAddressWithData(
    scriptAddress,
    { inline: datum },
    { lovelace: 10_000_000n }
  )
  .complete();

or with datum hash

const tx = await lucid
  .newTx()
  .pay.ToAddressWithData(
    scriptAddress,
    { hash: datumHash },
    { lovelace: 10_000_000n }
  )
  .complete();
// Publish reference script
const tx = await lucid
  .newTx()
  .pay.ToAddressWithData(
    scriptAddress,
    datum,
    { lovelace: 10_000_000n },
    referencedScript,
  )
  .complete();

Reference scripts allow reuse of validator code and can be used systemically to reduce fees

Spending (Redeeming) from Script Address

const allUTxOs = await lucid.utxosAt(scriptAddress);
const ownerUTxO = allUTxOs.find((utxo) => {
  if (utxo.datum) {
    const datum = Data.from(utxo.datum, DatumType);
    return datum.owner === publicKeyHash;
  }
});
 
// Spend script UTxO
const tx = await lucid
  .newTx()
  .collectFrom(
    [ownerUTxO], // UTxO at script address
    redeemer, // Arguments we provide
  )
  .attachSpendingValidator(spend_val) // Attach validator if not using reference script
  .complete();

or using a reference script

const tx = await lucid
  .newTx()
  .collectFrom(
    [ownerUTxO],
    redeemer,
    referenceScriptUtxo  // UTxO containing validator code
  )
  .complete();

Script Execution Units

  • Each script execution consumes CPU and memory units
  • Reference scripts reduce fees by avoiding script storage in each transaction

Dive deeper

Validator Types

Lucid supports different validator purposes:

// Attach different validator types
.attach.SpendingValidator(validator)    // spending UTxOs
.attach.MintingPolicy(mintingPolicy)    // minting tokens
.attach.CertificateValidator(validator) // stake operations
.attach.WithdrawalValidator(validator)  // reward withdrawals
.attach.VoteValidator(validator)        // governance votes
.attach.ProposeValidator(validator)     // governance proposals

Datums and Redeemers

Datums are data attached to script UTxOs and the redeemers are the arguments you have to provide when spending them:

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;

Redeemer Builder

Redeemer indexing is a design pattern (opens in a new tab) that leverages Cardano's deterministic script evaluation to improve on-chain performance.

When a protocol needs to spend from a script and send outputs back (whether to the same script, another script, or a wallet), it's imperative to maintain a one-to-one relationship between script inputs and outputs. This prevents Double Satisfaction (opens in a new tab) attacks and the redeemer maintains this relationship using ordered lists of input/output indices from the Script Context.

💡
Inputs     :  [scriptInputA, scriptInputB, randomInput1, scriptInputC, randomInput2, randomInput3]
Outputs    :  [outputA, outputB, outputC, randomOutput1, randomOutput2, randomOutput3]
InputIdxs  :  [0, 1, 3]        // Index of each script input
OutputIdxs :  [0, 1, 2]        // Corresponding output indices

type Redeemer = List<(inputIdx, outputIdx)>  // Pairs of corresponding input/output indices

Evolution library provides a high-level interface that abstracts all the complexity away by using the RedeemerBuilder:

// UTxOs we want to spend from the script (script inputs)
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 } },
];
// Other UTxOs (random inputs)
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[]) => {
        // Map each input index to its corresponding output index
        const tupleList = inputIdxs.map((inputIdx, i) => [inputIdx, BigInt(i)]);
        return Data.to(tupleList);
    },
    inputs: scriptInputs,
};
 
const tx = await lucid
    .newTx()
    .collectFrom([...scriptInputs, ...randomInputs], redeemer)
    .attach.SpendingValidator(script)
    // Outputs maintain the same order as script inputs
    .pay.ToContract("scriptAddress", datum, { lovelace: 10_000000n })
    .pay.ToContract("scriptAddress", datum, { lovelace: 20_000000n })
    .pay.ToContract("scriptAddress", datum, { lovelace: 40_000000n })
    // Other outputs
    .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.

Single argument

When your validator expects 1 integer argument

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

Multiple arguments

Next example is for your validator when it expects 2 arguments, eg. a ByteArray and a Boolean.

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
  ),
};

Complex type

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

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 the previous OutputReference type is for Aiken, the equivalent Plutus type is:

data TxOutRef = TxOutRef
  { txOutRefId  :: TxId
  , txOutRefIdx :: Integer
  }
 
-- where TxId itself is another type like
newtype TxId = TxId { getTxId :: PlutusTx.BuiltinByteString } 

which 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. Ensure your transaction meets the validator's requirements to avoid validation logic failures.


⚠️

Troubleshooting

Some stuff to keep in mind:

  • Provide correct datum/redeemer format (matching validator expectations)
  • Provide collateral when spending from script addresses
  • Include required signatories (if validator checks them)
  • Handle minimum ADA requirements