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
orfalse
. -
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