Vesting

Vesting

Armed with our recently acquired knowledge from the Hello, World! contract, let's increase the difficulty and write a slightly more challenging one.

A vesting contract is a common type of contract that allows to lock funds for a period of time, only to unlock them later -- once a specified time has passed. Usually, vesting contract defines a beneficiary who can be different from the original owner.

Covered in this tutorial

  • Writing non-trivial Aiken validators, with complex datums.
  • Using more advanced Aiken features (type-aliases, pattern-matches)
  • Writing unit tests using Aiken's built-in test framework
  • Managing time on-chain through transaction validity ranges
πŸ“˜

When encountering an unfamiliar syntax or concept, do not hesitate to refer to the language-tour for details and extra examples.

Setup

In a similar fashion to what we did for the Hello, World! contract, we'll need some credentials (and funds) to play around with. Here, we define an extra key for the beneficiary. Again, use the Cardano faucet (opens in a new tab) to receive test funds. Refer to Hello World :: Getting Funds in case you have any doubts on the procedure.

deno repl --allow-net --allow-write
import { Lucid } from "https://deno.land/x/[email protected]/mod.ts";
 
const lucid = await Lucid.new(undefined, "Preview");
 
const ownerPrivateKey = lucid.utils.generatePrivateKey();
await Deno.writeTextFile("owner.sk", ownerPrivateKey);
 
const ownerAddress = await lucid
  .selectWalletFromPrivateKey(ownerPrivateKey)
  .wallet.address();
await Deno.writeTextFile("owner.addr", ownerAddress);
 
const beneficiaryPrivateKey = lucid.utils.generatePrivateKey();
await Deno.writeTextFile("beneficiary.sk", beneficiaryPrivateKey);
 
const beneficiaryAddress = await lucid
  .selectWalletFromPrivateKey(beneficiaryPrivateKey)
  .wallet.address();
await Deno.writeTextFile("beneficiary.addr", beneficiaryAddress);

On-Chain code

Let's write our time lock validator as validators/vesting.ak, starting with the definition of its interface (i.e. its datum's shape).

validators/vesting.ak
use aiken/hash.{Blake2b_224, Hash}
use aiken/transaction/credential.{VerificationKey}
 
type Datum {
  /// POSIX time in second, e.g. 1672843961000
  lock_until: POSIXTime,
  /// Owner's credentials
  owner: VerificationKeyHash,
  /// Beneficiary's credentials
  beneficiary: VerificationKeyHash,
}
 
type VerificationKeyHash =
  Hash<Blake2b_224, VerificationKey>
 
type POSIXTime =
  Int

As we can see the script's datum serves as configuration and contains the different parameters of our vesting operation. Remember that these elements are set when locking funds in the contract; combined with the script they define the conditions by which the funds can be released.

From there, lets define the spend validator itself.

validators/vesting.ak
use aiken/interval.{Finite}
use aiken/list.{and, or}
use aiken/transaction.{Transaction, ScriptContext, Spend, ValidityRange}
 
validator {
  fn vesting(datum: Datum, _redeemer: Void, 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([
          must_be_signed_by(ctx.transaction, datum.owner),
          and([
            must_be_signed_by(ctx.transaction, datum.beneficiary),
            must_start_after(ctx.transaction.validity_range, datum.lock_until),
          ])
        ])
      _ ->
      False
    }
  }
}
 
fn must_be_signed_by(transaction: Transaction, vk: VerificationKeyHash) {
  list.has(transaction.extra_signatories, vk)
}
 
fn must_start_after(range: ValidityRange, lower_bound: POSIXTime) {
  when range.lower_bound.bound_type is {
    Finite(now) -> now >= lower_bound
    _ -> False
  }
}

The novelty here mostly lies in the check against a time period. In fact, transactions can have validity intervals that define from when and until when the transaction is considered valid. Validity bounds are checked by the ledger prior to executing a script and only does so if the bounds are legit.

This is meant to give scripts a notion of time, while preserving determinism from within the context of a script. For example, in this scenario, given a lower bound A on the transaction, we can deduce that the current time is at least A.

Note that because we don't control the upper-bound, it could very much be that this transaction is executed 30 years after the vesting delay. Yet, from the perspective of the vesting script, this is perfectly okay.

See also how we annotate the redeemer to Void to indicate that it isn't used. We could also leave it unannotated but it's generally good to signal your intent explicitly. Void captures that pretty well.

Testing

Okay, now before deploying our contract in the wild and risking collapsing the economy with some unforeseen bug, let's write a few tests. Aiken has builtin support for tests, which are very much like functions that takes no argument and must return a Bool.

Tests can use any function, constant or types defines in our module but beware, they cannot reference other tests!

validators/vesting.ak
use aiken/interval.{Finite, Interval, IntervalBound, PositiveInfinity}
 
test must_start_after_succeed_when_lower_bound_is_after() {
  let range: ValidityRange =
    Interval {
      lower_bound: IntervalBound { bound_type: Finite(2), is_inclusive: True },
      upper_bound: IntervalBound { bound_type: PositiveInfinity, is_inclusive: False },
    }
 
  must_start_after(range, 1)
}
 
test must_start_after_succeed_when_lower_bound_is_equal() {
  let range: ValidityRange =
    Interval {
      lower_bound: IntervalBound { bound_type: Finite(2), is_inclusive: True },
      upper_bound: IntervalBound { bound_type: PositiveInfinity, is_inclusive: False },
    }
 
  must_start_after(range, 2)
}
 
test must_start_after_fail_when_lower_bound_is_after() {
  let range: ValidityRange =
    Interval {
      lower_bound: IntervalBound { bound_type: Finite(2), is_inclusive: True },
      upper_bound: IntervalBound { bound_type: PositiveInfinity, is_inclusive: False },
    }
 
  !must_start_after(range, 3)
}
πŸ’‘

You can run tests with aiken check; Aiken will collect and run all tests found in your modules, and give you some statistics about the execution units (CPU and memory) required by the test.

Building

It's now time to build our on-chain contract! Simply do:

aiken build

This generate a CIP-0057 Plutus blueprint (opens in a new tab) as plutus.json at the root of your project. This blueprint describes your on-chain contract and its binary interface. In particular, it contains the generated on-chain code that will be executed by the ledger, and a hash of your validator(s) that can be used to construct addresses.

Let's see the validator in action!

Off-Chain code

As a starter, we need to lock funds in our newly created contract. We'll use Lucid (opens in a new tab) to construct and submit our transaction through Blockfrost.

This is only one example of possible setup using tools we love. For more tools, make sure to check out the Cardano Developer Portal (opens in a new tab)!

Setup

First, we setup Lucid with Blockfrost as a provider. You know the drill from the Hello, World! example already.

vesting_lock.ts
import {
  Blockfrost,
  C,
  Data,
  Lucid,
  SpendingValidator,
  TxHash,
  fromHex,
  toHex,
} from "https://deno.land/x/[email protected]/mod.ts";
import * as cbor from "https://deno.land/x/[email protected]/index.js";
 
const lucid = await Lucid.new(
  new Blockfrost(
    "https://cardano-preview.blockfrost.io/api/v0",
    Deno.env.get("BLOCKFROST_API_KEY"),
  ),
  "Preview",
);
 
lucid.selectWalletFromPrivateKey(await Deno.readTextFile("./owner.sk"));
 
const validator = await readValidator();
 
// --- Supporting functions
 
async function readValidator(): Promise<SpendingValidator> {
  const validator = JSON.parse(await Deno.readTextFile("plutus.json")).validators[0];
  return {
    type: "PlutusV2",
    script: toHex(cbor.encode(fromHex(validator.compiledCode))),
  };
}
πŸ’‘

If you've installed deno (opens in a new tab), you can run the except above by executing:

deno run --allow-net --allow-read vesting_lock.ts

It assumes that this file (vesting_lock.ts) is placed at the root of your vesting folder. At this stage, your folder should looks roughly like this:

./vesting
β”‚
β”œβ”€β”€ README.md
β”œβ”€β”€ aiken.toml
β”œβ”€β”€ plutus.json
β”œβ”€β”€ vesting_lock.ts
β”œβ”€β”€ owner.addr
β”œβ”€β”€ owner.sk
β”œβ”€β”€ beneficiary.addr
β”œβ”€β”€ beneficiary.sk
β”œβ”€β”€ lib
β”‚Β Β  └── ...
└── validators
 Β Β  └── vesting.ak

Locking funds into the contract

Here, we make our first transaction to lock funds into the contract. The datum must match the representation expected by the script, constructor is an object expecting 3 fields.

vesting_lock.ts
const ownerPublicKeyHash = lucid.utils.getAddressDetails(
  await lucid.wallet.address()
).paymentCredential.hash;
 
const beneficiaryPublicKeyHash =
  lucid.utils.getAddressDetails(await Deno.readTextFile("beneficiary.addr"))
    .paymentCredential.hash;
 
const Datum = Data.Object({
  lock_until: Data.BigInt, // this is POSIX time, you can check and set it here: https://www.unixtimestamp.com
  owner: Data.String, // we can pass owner's verification key hash as byte array but also as a string
  beneficiary: Data.String, // we can beneficiary's hash as byte array but also as a string
});
 
type Datum = Data.Static<typeof Datum>;
 
const datum = Data.to<Datum>(
  {
    lock_until: 1672843961000n, // Wed Jan 04 2023 14:52:41 GMT+0000
    owner: ownerPublicKeyHash, // our own wallet verification key hash
    beneficiary: beneficiaryPublicKeyHash,
  },
  Datum
);
 
const txLock = await lock(1000000, { into: validator, datum: datum });
 
await lucid.awaitTx(txLock);
 
console.log(`1 ADA locked into the contract
    Tx ID: ${txLock}
    Datum: ${datum}
`);
 
// --- Supporting functions
 
async function lock(lovelace, { into, datum }): Promise<TxHash> {
  const contractAddress = lucid.utils.validatorToAddress(into);
 
  const tx = await lucid
    .newTx()
    .payToContract(contractAddress, { inline: datum }, { lovelace })
    .complete();
 
  const signedTx = await tx.sign().complete();
 
  return signedTx.submit();
}

Unlocking funds from the contract

Now we can use another wallet (beneficiary.sk). This wallet will be beneficiary wallet had been added into datum in the previous step (locking) as well.

Finally, as a last step: we now want to spend the UTxO that is locked by our vesting contract.

To be valid, our transaction must meet one of two conditions:

  • it must be signed by the owner referenced as "owner" in the datum; or
  • it must be signed by the beneficiary referenced as "beneficiary" in the datum AND time has to pass beyond the threshold we fixed -- that is it needs to be later than 'Wed Jan 04 2023 14:52:41 GMT+0000'.

Like for the Hello, World! example, we need to explicitly add a signer using .addSigner so that it gets added to the extra_signatories of our transaction and becomes accessible for our script.

In addition we need to specify .validFrom as a POSIX timestamp from where transaction is considered valid (should be by the time we submit it). We can optionally defined an upper validity bound using .validTo as a TTL (Time-To-Live).

Let's make a new file vesting_unlock.ts and copy over some of the boilerplate from the first one.

vesting_unlock.ts
import {
  Blockfrost,
  C,
  Data,
  Lucid,
  SpendingValidator,
  TxHash,
  fromHex,
  toHex,
} from "https://deno.land/x/[email protected]/mod.ts";
import * as cbor from "https://deno.land/x/[email protected]/index.js";
 
const lucid = await Lucid.new(
  new Blockfrost(
    "https://cardano-preview.blockfrost.io/api/v0",
    Deno.env.get("BLOCKFROST_API_KEY"),
  ),
  "Preview",
);
 
lucid.selectWalletFromPrivateKey(await Deno.readTextFile("./beneficiary.sk"));
 
const validator = await readValidator();
 
// --- Supporting functions
 
async function readValidator(): Promise<SpendingValidator> {
  const validator = JSON.parse(await Deno.readTextFile("plutus.json")).validators[0];
  return {
    type: "PlutusV2",
    script: toHex(cbor.encode(fromHex(validator.compiledCode))),
  };
}

Now, let's add the bits to unlock the funds in the contract. We'll need the transaction identifier obtained when you ran the previous script (vesting_lock.ts)

That transaction identifier, and the corresponding output index (here, 0) uniquely identifies the UTxO (Unspent Transaction Output) in which the funds are currently locked. And that's the one we're about to unlock.

As we stated above, we need to make sure to only submit our transaction after the vesting delay has passed without what the node will simply reject the transaction (without charging any fee) and kindly ask us to re-submit the transaction at a later time.

vesting_unlock.ts
// ^^^ Code above is unchanged. ^^^
 
const scriptAddress = lucid.utils.validatorToAddress(validator);
 
// we get all the UTXOs sitting at the script address
const scriptUtxos = await lucid.utxosAt(scriptAddress);
 
const Datum = Data.Object({
  lock_until: Data.BigInt, // this is POSIX time, you can check and set it here: https://www.unixtimestamp.com
  owner: Data.String, // we can pass owner's verification key hash as byte array but also as a string
  beneficiary: Data.String, // we can beneficiary's hash as byte array but also as a string
});
 
type Datum = Data.Static<typeof Datum>;
 
const currentTime = new Date().getTime();
 
// we filter out all the UTXOs by beneficiary and lock_until
const utxos = scriptUtxos.filter((utxo) => {
    let datum = Data.from<Datum>(
      utxo.datum,
      Datum,
    );
 
    return datum.beneficiary === beneficiaryPublicKeyHash &&
      datum.lock_until <= currentTime;
});
 
if (utxos.length === 0) {
  console.log("No redeemable utxo found. You need to wait a little longer...");
  Deno.exit(1);
}
 
// we don't have any redeemer in our contract but it needs to be empty
const redeemer = Data.empty();
 
const txUnlock = await unlock(utxos, currentTime, { from: validator, using: redeemer });
 
await lucid.awaitTx(txUnlock);
 
console.log(`1 ADA recovered from the contract
    Tx ID: ${txUnlock}
    Redeemer: ${redeemer}
`);
 
// --- Supporting functions
 
async function unlock(utxos, currentTime, { from, redeemer }): Promise<TxHash> {
  const laterTime = new Date(currentTime + 2 * 60 * 60 * 1000).getTime(); // add two hours (TTL: time to live)
 
  const tx = await lucid
    .newTx()
    .collectFrom(utxos, redeemer)
    .addSigner(await lucid.wallet.address()) // this should be beneficiary address
    .validFrom(currentTime)
    .validTo(laterTime)
    .attachSpendingValidator(from)
    .complete();
 
  const signedTx = await tx
    .sign()
    .complete();
 
  return signedTx.submit();
}
πŸ’‘

As you imagine, we can run this script with the following incantation:

deno run --allow-net --allow-read vesting_unlock.ts

Assuming everything went well... congratulations πŸŽ‰!