Mesh LogoMesh

Build a Vesting Smart Contract

Implement a time-locked vesting contract using Aiken and Mesh SDK. Lock funds with scheduled withdrawal.

Overview

A vesting contract locks funds until a specified time, then allows a beneficiary to withdraw. This is commonly used for employee compensation, token distributions, and escrow arrangements. This contract runs on Cardano's extended UTXO (eUTXO) model, which enables deterministic script execution and predictable transaction fees.

What you will build

  • An Aiken validator with time-based release
  • Deposit transactions that lock funds
  • Withdrawal transactions after the lock period

What you will learn

  • Cardano's time handling with validity intervals
  • Owner vs beneficiary access patterns
  • Building time-constrained transactions

Prerequisites

  • Aiken CLI installed (see Aiken guide)
  • Next.js application with Mesh SDK
  • Understanding of datums and redeemers

Time to complete

60 minutes

Quick Start

Clone the complete example:

git clone https://github.com/MeshJS/examples
cd examples/aiken-vesting

Step-by-Step Guide

Step 1: Define the vesting datum

Create the datum type that configures vesting parameters:

// lib/vesting/types.ak
pub type VestingDatum {
  /// POSIX time in milliseconds when funds unlock
  lock_until: Int,
  /// Owner's public key hash (can withdraw anytime)
  owner: ByteArray,
  /// Beneficiary's public key hash (can withdraw after lock_until)
  beneficiary: ByteArray,
}

The datum contains:

  • lock_until - POSIX timestamp (ms) when funds become available
  • owner - Can withdraw at any time (emergency access)
  • beneficiary - Can withdraw only after the lock expires

What to expect: A type definition for vesting configuration.

Step 2: Write the validator

Create validators/vesting.ak:

use aiken/transaction.{ScriptContext, Spend}
use vesting/types.{VestingDatum}

// Helper to check if a key signed the transaction
fn key_signed(signatories: List<ByteArray>, key: ByteArray) -> Bool {
  list.has(signatories, key)
}

// Helper to check if transaction is valid after a certain time
fn valid_after(validity_range, lock_until: Int) -> Bool {
  when validity_range.lower_bound.bound_type is {
    Finite(tx_earliest_time) -> tx_earliest_time > lock_until
    _ -> False
  }
}

validator {
  pub fn vesting(datum: VestingDatum, _redeemer: Data, ctx: ScriptContext) {
    when ctx.purpose is {
      Spend(_) -> or {
        // Owner can always withdraw
        key_signed(ctx.transaction.extra_signatories, datum.owner),
        // Beneficiary can withdraw after lock period
        and {
          key_signed(ctx.transaction.extra_signatories, datum.beneficiary),
          valid_after(ctx.transaction.validity_range, datum.lock_until),
        },
      }
      _ -> False
    }
  }
}

The validator allows withdrawal when:

  1. The owner signs (can withdraw anytime), OR
  2. The beneficiary signs AND current time is after lock_until

What to expect: A validator file with time-based access control.

Step 3: Write tests

Create validators/vesting_test.ak:

use vesting

test owner_can_withdraw_early() {
  // Owner should be able to withdraw before lock_until
  True
}

test beneficiary_cannot_withdraw_early() {
  // Beneficiary should NOT be able to withdraw before lock_until
  True
}

test beneficiary_can_withdraw_after_lock() {
  // Beneficiary should be able to withdraw after lock_until
  True
}

Run the tests:

aiken check

What to expect: All tests pass (implement actual test logic for production).

Step 4: Compile the contract

Build the Aiken project:

aiken build

What to expect: A plutus.json blueprint file is generated.

Step 5: Set up the frontend

Create a contract helper file src/lib/vesting.ts:

import {
  resolvePlutusScriptAddress,
  resolvePaymentKeyHash,
  deserializeAddress,
} from "@meshsdk/core";
import type { PlutusScript, Data } from "@meshsdk/core";
import cbor from "cbor";
import plutusBlueprint from "@/data/plutus.json";

// Load compiled contract
const scriptCbor = cbor
  .encode(Buffer.from(plutusBlueprint.validators[0].compiledCode, "hex"))
  .toString("hex");

export const script: PlutusScript = {
  code: scriptCbor,
  version: "V2",
};

export const scriptAddress = resolvePlutusScriptAddress(script, 0);

// Create vesting datum
export function createVestingDatum(
  lockUntilMs: number,
  ownerAddress: string,
  beneficiaryAddress: string
): Data {
  const { pubKeyHash: ownerHash } = deserializeAddress(ownerAddress);
  const { pubKeyHash: beneficiaryHash } = deserializeAddress(beneficiaryAddress);

  return {
    alternative: 0,
    fields: [lockUntilMs, ownerHash, beneficiaryHash],
  };
}

What to expect: Helper functions for working with the vesting contract.

Step 6: Deposit funds

Create the deposit transaction:

import { MeshTxBuilder, KoiosProvider } from "@meshsdk/core";
import { useWallet } from "@meshsdk/react";
import { script, scriptAddress, createVestingDatum } from "@/lib/vesting";

async function depositFunds(
  amountLovelace: string,
  lockDurationMinutes: number,
  beneficiaryAddress: string
) {
  const provider = new KoiosProvider("preprod");
  const { wallet } = useWallet();

  const utxos = await wallet.getUtxos();
  const changeAddress = await wallet.getChangeAddress();
  const ownerAddress = (await wallet.getUsedAddresses())[0];

  // Calculate lock time
  const lockUntilMs = Date.now() + lockDurationMinutes * 60 * 1000;

  // Create datum
  const datum = createVestingDatum(lockUntilMs, ownerAddress, beneficiaryAddress);

  const txBuilder = new MeshTxBuilder({ fetcher: provider });

  const unsignedTx = await txBuilder
    // Lock funds at script with inline datum
    .txOut(scriptAddress, [{ unit: "lovelace", quantity: amountLovelace }])
    .txOutInlineDatumValue(datum)
    .changeAddress(changeAddress)
    .selectUtxosFrom(utxos)
    .complete();

  const signedTx = await wallet.signTx(unsignedTx);
  const txHash = await wallet.submitTx(signedTx);

  console.log("Deposited funds:", txHash);
  console.log("Unlocks at:", new Date(lockUntilMs).toISOString());

  return { txHash, lockUntilMs };
}

What to expect: Funds are locked at the script address with vesting parameters.

Step 7: Withdraw funds

Create the withdrawal transaction:

import {
  MeshTxBuilder,
  KoiosProvider,
  deserializeAddress,
  unixTimeToEnclosingSlot,
  SLOT_CONFIG_NETWORK,
} from "@meshsdk/core";
import { useWallet } from "@meshsdk/react";
import { script, scriptAddress, createVestingDatum } from "@/lib/vesting";

async function withdrawFunds(depositTxHash: string) {
  const provider = new KoiosProvider("preprod");
  const { wallet } = useWallet();

  // Find the vesting UTxO
  const scriptUtxos = await provider.fetchUTxOs(depositTxHash);
  const vestingUtxo = scriptUtxos[0];

  if (!vestingUtxo) {
    throw new Error("Vesting UTxO not found");
  }

  // Parse the inline datum
  const plutusData = vestingUtxo.output.plutusData;
  // Extract lock_until from datum
  const lockUntilMs = plutusData.fields[0].int;

  // Check if we can withdraw
  const now = Date.now();
  if (now < lockUntilMs) {
    throw new Error(`Cannot withdraw yet. Unlocks in ${Math.ceil((lockUntilMs - now) / 60000)} minutes`);
  }

  const utxos = await wallet.getUtxos();
  const changeAddress = await wallet.getChangeAddress();
  const collateral = await wallet.getCollateral();
  const { pubKeyHash } = deserializeAddress(changeAddress);

  // Calculate validity interval (must be after lock_until)
  const invalidBefore = unixTimeToEnclosingSlot(
    Math.max(lockUntilMs, now - 15000),
    SLOT_CONFIG_NETWORK.preprod
  ) + 1;

  const txBuilder = new MeshTxBuilder({ fetcher: provider });

  const unsignedTx = await txBuilder
    .spendingPlutusScriptV2()
    .txIn(
      vestingUtxo.input.txHash,
      vestingUtxo.input.outputIndex,
      vestingUtxo.output.amount,
      scriptAddress
    )
    .spendingReferenceTxInInlineDatumPresent()
    .spendingReferenceTxInRedeemerValue("") // Empty redeemer
    .txInScript(script.code)
    .txOut(changeAddress, [])
    .txInCollateral(
      collateral[0].input.txHash,
      collateral[0].input.outputIndex,
      collateral[0].output.amount,
      collateral[0].output.address
    )
    .invalidBefore(invalidBefore)
    .requiredSignerHash(pubKeyHash)
    .changeAddress(changeAddress)
    .selectUtxosFrom(utxos)
    .complete();

  const signedTx = await wallet.signTx(unsignedTx, true);
  const txHash = await wallet.submitTx(signedTx);

  console.log("Withdrawn funds:", txHash);
  return txHash;
}

What to expect: Funds are transferred to the beneficiary's wallet.

Complete Example

Here is a complete React component:

import { useState } from "react";
import { CardanoWallet, useWallet } from "@meshsdk/react";

export default function VestingApp() {
  const { wallet, connected } = useWallet();
  const [depositTxHash, setDepositTxHash] = useState("");
  const [loading, setLoading] = useState(false);

  async function handleDeposit() {
    setLoading(true);
    try {
      const beneficiary = "addr_test1..."; // Beneficiary address
      const result = await depositFunds("10000000", 5, beneficiary); // 10 ADA, 5 min lock
      setDepositTxHash(result.txHash);
    } finally {
      setLoading(false);
    }
  }

  async function handleWithdraw() {
    setLoading(true);
    try {
      await withdrawFunds(depositTxHash);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold mb-4">Vesting Contract</h1>
      <CardanoWallet />

      {connected && (
        <div className="mt-4 space-y-4">
          <button
            onClick={handleDeposit}
            disabled={loading}
            className="px-4 py-2 bg-blue-500 text-white rounded"
          >
            Deposit 10 ADA (5 min lock)
          </button>

          {depositTxHash && (
            <>
              <p>Deposit TX: {depositTxHash}</p>
              <button
                onClick={handleWithdraw}
                disabled={loading}
                className="px-4 py-2 bg-green-500 text-white rounded"
              >
                Withdraw
              </button>
            </>
          )}
        </div>
      )}
    </div>
  );
}

Next Steps

Troubleshooting

Cannot withdraw yet

Cause: Current time is before lock_until.

Solution: Wait for the lock period to expire. The invalidBefore constraint must be after lock_until for the transaction to be valid.

Validity interval error

Cause: The invalidBefore slot is in the past or incorrectly calculated.

Solution: Ensure you use the correct slot config for your network:

import { SLOT_CONFIG_NETWORK } from "@meshsdk/core";

// For preprod testnet
const slotConfig = SLOT_CONFIG_NETWORK.preprod;

// For mainnet
const slotConfig = SLOT_CONFIG_NETWORK.mainnet;

Owner vs beneficiary withdrawal

Cause: Wrong signer for the withdrawal type.

Solution: The owner can withdraw anytime by signing. The beneficiary must:

  1. Sign the transaction
  2. Set invalidBefore to a slot after lock_until

Inline datum not found

Cause: The UTxO was created with a datum hash instead of inline datum.

Solution: Use txOutInlineDatumValue for deposits:

.txOut(scriptAddress, amount)
.txOutInlineDatumValue(datum)  // Use inline datum
// NOT .txOutDatumHashValue(datum)

References

On this page