Evolution SDK
Smart Contracts

Tutorial: Token Vesting

Build a complete token vesting contract — lock funds with a deadline, then release to a beneficiary after time passes

Tutorial: Token Vesting

Build a complete vesting flow: lock funds to a script with a deadline datum, then release them to a beneficiary after the deadline passes. This tutorial ties together TSchema, inline datums, validity ranges, and script spending.

What You'll Build

A time-locked vesting contract where:

  • An owner locks ADA to a script address with a deadline
  • A beneficiary can withdraw the funds after the deadline passes
  • The Plutus validator checks: (a) the deadline has passed and (b) the beneficiary signed the transaction

Prerequisites

Step 1: Define the Vesting Datum

The datum carries the state your validator needs — who the beneficiary is and when the lock expires:

import { , ,  } from "@evolution-sdk/evolution"

// Define the vesting datum schema
const  = .({
  : ., // beneficiary's key hash (28 bytes)
  : .,      // POSIX time in milliseconds
})

type  = typeof .
// { beneficiary: Uint8Array; deadline: bigint }

// Create a codec for encoding/decoding
const  = .()

// Create a datum
const  = .({
  : .("abc123def456abc123def456abc123def456abc123def456abc123de"),
  : (new ("2025-12-31T23:59:59Z").()), // Dec 31, 2025
})
// datum → PlutusData (Constr 0 with 2 fields: ByteArray + Integer)

Step 2: Lock Funds to the Vesting Script

Send ADA to the script address with the vesting datum attached:

// Create the datum with beneficiary and deadline
const  = .({
  : .("abc123def456abc123def456abc123def456abc123def456abc123de"),
  : (new ("2025-12-31T23:59:59Z").()),
})

// Lock 50 ADA to the vesting script address
const  = .(
  "addr_test1wrm9x2dgvdau8vckj4duc89m638t8djmluqw5pdrFollw8qnmqsyu"
)

const  = await 
  .()
  .({
    : ,
    : .(50_000_000n), // 50 ADA
    : new .({ :  }),
  })
  .()

const  = await .()
const  = await .()
// lockTxHash → transaction hash of the locking transaction

The locked funds are now at the script address with your datum attached. Nobody can spend them without satisfying the validator — which requires the deadline to pass and the beneficiary to sign.

Step 3: Spend After the Deadline

Once the deadline has passed, the beneficiary can claim the funds. The key is setting the validity interval to prove to the validator that the current time is past the deadline:

const  = (.())

const  = await 
  .()
  .({
    : ,
    : .(0n, []), // "Claim" action
    : "claim-vesting",
  })
  .({ :  })
  .({ :  }) // prove beneficiary signed
  .({
    : , // valid from now — proves to validator that deadline has passed
    :  + 300_000n, // expires in 5 minutes
  })
  .()

const  = await .()
const  = await .()
// claimTxHash → funds released to beneficiary's wallet

Why setValidity Matters

Plutus validators can't read the current time directly. Instead, they inspect the transaction's validity interval — the range [from, to] that the ledger guarantees the transaction was submitted within.

By setting from: now where now > deadline, you're telling the validator: "this transaction is only valid after the deadline, therefore the deadline has passed." The ledger enforces this — if someone tries to submit before the deadline, the transaction is rejected.

Step 4: Full Working Example

Putting it all together — lock, wait, claim:

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

// --- Schema ---
const VestingDatum = TSchema.Struct({
  beneficiary: TSchema.ByteArray,
  deadline: TSchema.Integer,
})
const VestingCodec = Data.withSchema(VestingDatum)

// --- Client ---
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 })

// --- Config ---
const scriptAddress = Address.fromBech32("addr_test1wrm9x2dgvdau8vckj4duc89m638t8djmluqw5pdrFollw8qnmqsyu")
const beneficiaryHash = Bytes.fromHex(
  "abc123def456abc123def456abc123def456abc123def456abc123de" // 28 bytes
)
const deadline = BigInt(new Date("2025-12-31T23:59:59Z").getTime())

declare const vestingScript: any // compiled Plutus validator

// === LOCK ===
async function lockFunds(amount: bigint) {
  const datum = VestingCodec.toData({
    beneficiary: beneficiaryHash,
    deadline,
  })

  const tx = await client
    .newTx()
    .payToAddress({
      address: scriptAddress,
      assets: Assets.fromLovelace(amount),
      datum: new InlineDatum.InlineDatum({ data: datum }),
    })
    .build()

  const signed = await tx.sign()
  return signed.submit()
}

// === CLAIM (after deadline) ===
async function claimFunds() {
  const utxos = await client.getUtxos(scriptAddress)
  const now = BigInt(Date.now())

  if (now < deadline) {
    throw new Error(`Deadline not reached. Wait until ${new Date(Number(deadline))}`)
  }

  const tx = await client
    .newTx()
    .collectFrom({
      inputs: utxos,
      redeemer: Data.constr(0n, []),
      label: "claim-vesting",
    })
    .attachScript({ script: vestingScript })
    .addSigner({ keyHash: new KeyHash.KeyHash({ hash: beneficiaryHash }) })
    .setValidity({ from: now, to: now + 300_000n })
    .build()

  const signed = await tx.sign()
  return signed.submit()
}

Common Pitfalls

Validity interval too early — If from is before the deadline, the validator will reject. Always set from to a time after the deadline.

ProblemCauseFix
"Script evaluation failed"Validity from is before deadlineSet from to current time (must be > deadline)
"Missing required signer"Beneficiary didn't signAdd .addSigner({ keyHash }) matching the datum's beneficiary
"Datum mismatch"Datum schema doesn't match validatorVerify TSchema field order matches your Aiken/Plutarch type
"UTxO already spent"Funds already claimedQuery UTxOs first to check if they're still there
"Outside validity interval"Transaction submitted too lateIncrease the to value or submit faster

Next Steps