Skip to main content

Lock and Spend

Every smart contract interaction has two halves: you lock funds at a script address (sending value with a datum attached), and later you spend them (consuming the UTXO by providing a redeemer the validator accepts). This is the off-chain work. Your validator just says yes or no; this page is how you build the transactions it judges.

Pick your tool below. The SDK tabs use the same Evolution and Mesh setup as Your first transaction.

Before you start

Lock funds

Locking means sending ADA (and optionally native tokens) to the script address with a datum attached. The datum is the state your validator will check when someone later tries to spend the UTXO.

import { Address, Assets, Data, InlineDatum, preprod, Client } from "@evolution-sdk/evolution"

const client = Client.make(preprod)
.withBlockfrost({
baseUrl: "https://cardano-preprod.blockfrost.io/api/v0",
projectId: process.env.BLOCKFROST_API_KEY!
})
.withSeed({ mnemonic: process.env.WALLET_MNEMONIC!, accountIndex: 0 })

const scriptAddress = Address.fromBech32("addr_test1...") // your script's address

const tx = await client
.newTx()
.payToAddress({
address: scriptAddress,
assets: Assets.fromLovelace(10_000_000n), // 10 ADA
datum: new InlineDatum.InlineDatum({ data: Data.constr(0n, []) })
})
.build()

const signed = await tx.sign()
const txHash = await signed.submit()
console.log("Locked funds at:", txHash)

For real contracts, define the datum with TSchema for type safety instead of a raw Data.constr. See Datum, redeemer & context.

Spend funds

Spending means consuming a UTXO locked at the script address by providing a redeemer: the data your validator checks to authorize the spend. Because a Plutus script runs, the transaction also needs collateral (see Collateral below). The SDKs select it for you.

import { Data, preprod, type UTxO, Client } from "@evolution-sdk/evolution"

// reuse the client from the lock step
declare const scriptUtxos: UTxO.UTxO[] // the UTxO(s) you locked, queried back
declare const validatorScript: any // your compiled validator

const tx = await client
.newTx()
.collectFrom({
inputs: scriptUtxos,
redeemer: Data.constr(0n, []) // the action your validator expects
})
.attachScript({ script: validatorScript })
.addSigner({ keyHash: myKeyHash }) // if the validator checks a signature
.build()

const signed = await tx.sign()
const txHash = await signed.submit()

Evolution handles script evaluation, redeemer indexing, and collateral automatically. For time-locked validators, add .setValidity({ from, to }) so the script can check the current time. See redeemer indexing for the static, self, and batch redeemer modes.

Collateral

A transaction that runs a Plutus script is validated in two phases: phase 1 checks structure (inputs exist, signatures, balancing), and phase 2 runs the scripts. Collateral is a set of ADA-only UTXOs the node consumes only if a script fails phase 2. A transaction that succeeds never loses its collateral, so honest users are safe, while flooding the network with failing scripts becomes expensive.

The SDKs pick collateral automatically from your wallet's ADA-only UTXOs; with cardano-cli you mark it explicitly with --tx-in-collateral. Keep a few pure-ADA UTXOs around for this. With CIP-40 any excess is returned to a collateral-change address.

Reference scripts

Including a multi-kilobyte validator in every spend transaction is wasteful. A reference script (Plutus V2+) stores the script once in a UTXO; later transactions point at that UTXO with readFrom instead of attaching the script: much smaller transactions and lower fees.

import { Assets, Data, type UTxO } from "@evolution-sdk/evolution"

// 1. Deploy: park the script in a UTXO (the `script` field makes it a reference script)
const deploy = await client
.newTx()
.payToAddress({ address: await client.address(), assets: Assets.fromLovelace(10_000_000n), script: validatorScript })
.build()
await (await deploy.sign()).submit()

// 2. Spend by referencing it: no attachScript, the node reads the script from the referenced UTXO
declare const scriptUtxos: UTxO.UTxO[]
declare const referenceScriptUtxo: UTxO.UTxO
const spend = await client
.newTx()
.collectFrom({ inputs: scriptUtxos, redeemer: Data.constr(0n, []) })
.readFrom({ referenceInputs: [referenceScriptUtxo] })
.build()

readFrom also reads a UTXO without consuming it, the same mechanism oracles use to expose price data and contracts use to read shared configuration (a reference input can carry a datum, not just a script). Reach for a reference script once a script is used across more than a few transactions; the one-time deploy cost pays for itself quickly.

Parameterized scripts

A parameterized validator leaves values like an owner key or a deadline as compile-time holes, so one validator serves many deployments: each set of parameters produces a distinct script (and address). Apply the parameters off-chain before use:

import { Bytes, Data, TSchema, UPLC } from "@evolution-sdk/evolution"

declare const compiledScript: string // the parameterized script from `aiken build`

// Raw data params, applied in the order of the script's lambda bindings
const applied = UPLC.applyParamsToScript(compiledScript, [
Data.bytearray("abc123def456abc123def456abc123def456abc123def456abc123de"), // owner
Data.int(1735689600000n), // deadline
])

// Or type-safe via a schema
const Params = Data.withSchema(TSchema.Struct({ owner: TSchema.ByteArray, deadline: TSchema.Integer }))
const appliedTyped = UPLC.applyParamsToScriptWithSchema(
compiledScript,
[Params.toData({ owner: Bytes.fromHex("abc1...23de"), deadline: 1735689600000n })],
(v) => v,
)

The applied script is what you attach (or deploy as a reference script). Use parameters for per-deployment config (owner, deadline, token policy, oracle address); use datum fields instead for state that changes per transaction. applyParamsToScript defaults to Aiken-compatible CBOR: pass CBOR.CML_DATA_DEFAULT_OPTIONS for CML-compiled scripts.

A complete example: vesting

The lock-then-spend shape above becomes a real contract when the datum carries meaningful state and the validator enforces a rule. A vesting contract is the canonical first example: lock funds with a { beneficiary, deadline } datum, and the validator allows the spend only when the transaction is signed by the beneficiary and its validity interval starts after the deadline. The on-chain validator (logic and tests) is walked through in Datum, redeemer & context; here is the off-chain flow end to end.

It is two transactions: lock the funds with the datum, then claim them after the deadline. The claim is the interesting half: a validator cannot read the wall clock, so you set the transaction's validity interval to start after the deadline, and the ledger's guarantee that the transaction really was in that window is what proves to the validator that the deadline has passed.

import { Address, Assets, Bytes, Data, InlineDatum, KeyHash, TSchema, type UTxO } from "@evolution-sdk/evolution"

// reuse the client from the lock step; vestingScript is your compiled validator
declare const vestingScript: any
const VestingDatum = TSchema.Struct({ beneficiary: TSchema.ByteArray, deadline: TSchema.Integer })
const Codec = Data.withSchema(VestingDatum)

const scriptAddress = Address.fromBech32("addr_test1w...") // the vesting script's address
const beneficiary = Bytes.fromHex("abc123def456abc123def456abc123def456abc123def456abc123de") // key hash, 28 bytes
const deadline = BigInt(new Date("2025-12-31T23:59:59Z").getTime()) // POSIX time, ms

// 1. LOCK: send 50 ADA with the { beneficiary, deadline } datum
const lock = await client
.newTx()
.payToAddress({
address: scriptAddress,
assets: Assets.fromLovelace(50_000_000n),
datum: new InlineDatum.InlineDatum({ data: Codec.toData({ beneficiary, deadline }) }),
})
.build()
await (await lock.sign()).submit()

// 2. CLAIM (after the deadline): beneficiary signs, validity starts past the deadline
declare const vestingUtxos: UTxO.UTxO[] // from client.getUtxos(scriptAddress)
const now = BigInt(Date.now()) // must be > deadline
const claim = await client
.newTx()
.collectFrom({ inputs: vestingUtxos, redeemer: Data.constr(0n, []) }) // Claim
.attachScript({ script: vestingScript })
.addSigner({ keyHash: new KeyHash.KeyHash({ hash: beneficiary }) })
.setValidity({ from: now, to: now + 300_000n }) // proves the deadline has passed
.build()
await (await claim.sign()).submit()

Submit the claim before the deadline and the ledger rejects it up front, so the funds stay locked until the time genuinely passes. Once a vesting validator is used more than a few times, deploy it once as a reference script so each claim transaction stays small.

Next steps