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