Vesting Script End-to-End
A vesting contract locks funds and allows beneficiary withdrawal after a lockup period.
Organizations use vesting contracts to incentivize retention. Funds deposited are accessible to employees after a predetermined period.
On-Chain Code
Define the datum shape to configure vesting parameters.
pub type VestingDatum {
/// POSIX time in milliseconds, e.g. 1672843961000
lock_until: Int,
/// Owner's credentials
owner: ByteArray,
/// Beneficiary's credentials
beneficiary: ByteArray,
}The VestingDatum contains:
lock_until: POSIX timestamp (ms) for lock expiration.owner: Owner credentials (public key hash).beneficiary: Beneficiary credentials (public key hash).
Source: aiken-vesting/aiken-workspace/lib/vesting/types.ak.
Define the spend validator:
use aiken/transaction.{ScriptContext, Spend}
use vesting/types.{VestingDatum}
use vodka_extra_signatories.{key_signed}
use vodka_validity_range.{valid_after}
validator {
pub fn vesting(datum: VestingDatum, _redeemer: Data, ctx: ScriptContext) {
// In principle, scripts can be used for different purpose (e.g. minting
// assets). Here we make sure it's only used when 'spending' from a eUTxO
when ctx.purpose is {
Spend(_) -> or {
key_signed(ctx.transaction.extra_signatories, datum.owner),
and {
key_signed(ctx.transaction.extra_signatories, datum.beneficiary),
valid_after(ctx.transaction.validity_range, datum.lock_until),
},
}
_ -> False
}
}
}The vesting validator ensures:
- The transaction is signed by the owner.
- OR:
- The transaction is signed by the beneficiary AND valid after the lockup period.
Source: aiken-vesting/aiken-workspace/validators/vesting.ak.
How It Works
The owner deposits funds, locked until the period expires.
Transactions include validity intervals. The ledger verifies these bounds before script execution. This incorporates time while maintaining determinism.
Since the upper bound is uncontrolled, execution can occur long after the delay. This is acceptable.
The beneficiary (potentially different from the owner) withdraws funds after expiration.
Testing
Run comprehensive tests with aiken check.
Test cases include:
- Success unlocking.
- Success unlocking with only owner signature.
- Success unlocking with beneficiary signature and time passed.
- Fail unlocking with only beneficiary signature.
- Fail unlocking with only time passed.
See aiken-vesting/aiken-workspace/validators/tests/vesting.ak.
Compile and Build Script
Compile the script:
aiken buildGenerates a CIP-0057 Plutus blueprint in aiken-vesting/aiken-workspace/plutus.json.
Off-Chain Code
Deposit Funds
The owner deposits funds, specifying the lockup period and beneficiary.
const assets: Asset[] = [
{
unit: "lovelace",
quantity: "10000000",
},
];
const lockUntilTimeStamp = new Date();
lockUntilTimeStamp.setMinutes(lockUntilTimeStamp.getMinutes() + 1);
const beneficiary =
"addr_test1qpvx0sacufuypa2k4sngk7q40zc5c4npl337uusdh64kv0uafhxhu32dys6pvn6wlw8dav6cmp4pmtv7cc3yel9uu0nq93swx9";Deposit 10 ADA, locked for 1 minute.
Prepare transaction variables: wallet address, UTXOs, script address, and public key hashes.
const { utxos, walletAddress } = await getWalletInfoForTx();
const { scriptAddr } = getScript();
const { pubKeyHash: ownerPubKeyHash } = deserializeAddress(walletAddress);
const { pubKeyHash: beneficiaryPubKeyHash } = deserializeAddress(beneficiary);Construct the deposit transaction. Specify script address, amount, lockup period, owner, and beneficiary.
const txBuilder = new MeshTxBuilder({
fetcher: provider,
submitter: provider,
});
await txBuilder
.txOut(scriptAddr, amount)
.txOutInlineDatumValue(
mConStr0([lockUntilTimeStampMs, ownerPubKeyHash, beneficiaryPubKeyHash])
)
.changeAddress(walletAddress)
.selectUtxosFrom(utxos)
.complete();
const unsignedTx = txBuilder.txHex;Sign and submit.
const signedTx = await wallet.signTx(unsignedTx);
const txHash = await wallet.submitTx(signedTx);Ensure the Blockfrost key is in .env and mnemonic in aiken-vesting/src/configs.ts.
Run the deposit code:
npm run depositSave the returned transaction hash for withdrawal.
See successful deposit transaction.
Withdraw Funds
Beneficiaries (or owners) withdraw funds after expiration.
Fetch UTxOs containing locked funds using the deposit transaction hash.
const txHashFromDesposit =
"ede9f8176fe41f0c84cfc9802b693dedb5500c0cbe4377b7bb0d57cf0435200b";
const utxos = await provider.fetchUTxOs(txHash);
const vestingUtxo = utxos[0];Prepare transaction variables.
const { utxos, walletAddress, collateral } = await getWalletInfoForTx();
const { input: collateralInput, output: collateralOutput } = collateral;
const { scriptAddr, scriptCbor } = getScript();
const { pubKeyHash } = deserializeAddress(walletAddress);Prepare the datum and validity interval slot. Set the valid interval to start after the lockup period.
const datum = deserializeDatum<VestingDatum>(vestingUtxo.output.plutusData!);
const invalidBefore =
unixTimeToEnclosingSlot(
Math.min(datum.fields[0].int as number, Date.now() - 15000),
SLOT_CONFIG_NETWORK.preprod
) + 1;Construct the withdrawal transaction. Specify the UTxO, script address, recipient address, and validity interval.
const txBuilder = new MeshTxBuilder({
fetcher: provider,
submitter: provider,
});
await txBuilder
.spendingPlutusScriptV2()
.txIn(
vestingUtxo.input.txHash,
vestingUtxo.input.outputIndex,
vestingUtxo.output.amount,
scriptAddr
)
.spendingReferenceTxInInlineDatumPresent()
.spendingReferenceTxInRedeemerValue("")
.txInScript(scriptCbor)
.txOut(walletAddress, [])
.txInCollateral(
collateralInput.txHash,
collateralInput.outputIndex,
collateralOutput.amount,
collateralOutput.address
)
.invalidBefore(invalidBefore)
.requiredSignerHash(pubKeyHash)
.changeAddress(walletAddress)
.selectUtxosFrom(utxos)
.complete();
const unsignedTx = txBuilder.txHex;Sign and submit. Enable partial signing (true) for validator unlocking.
const signedTx = await wallet.signTx(unsignedTx, true);
const txHash = await wallet.submitTx(signedTx);Update aiken-vesting/src/withdraw-fund.ts with the deposit transaction hash.
Run:
npm run withdraw