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-vestingStep-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 availableowner- 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:
- The owner signs (can withdraw anytime), OR
- 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 checkWhat to expect: All tests pass (implement actual test logic for production).
Step 4: Compile the contract
Build the Aiken project:
aiken buildWhat 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:
- Sign the transaction
- Set
invalidBeforeto a slot afterlock_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
- Cardano eUTXO Model - How Cardano's transaction model enables deterministic smart contracts
- Aiken Smart Contract Language - Official documentation for the Aiken language used in this guide
- Cardano Developer Portal - Official Cardano development resources
- CIP-0057: Plutus Blueprint - The standard for compiled Plutus contract blueprints
Related Links
Run Standalone Cardano Scripts with TypeScript
Execute TypeScript scripts directly to interact with Cardano using Mesh SDK. Build, sign, and submit transactions without a framework.
Fix Node.js Polyfill Errors in Browser Projects
Resolve 'Buffer is not defined' and Node.js polyfill errors in React, Vue, Svelte, and Angular with Mesh SDK.