If you plan to launch a token on Sui, then you might consider implementing a vesting strategy to strengthen the long-term outlook of your token. A vesting strategy typically releases your tokens to team members, investors, or other early stakeholders over time, rather than releasing them all at once.
Implementing and publishing the details of your vesting strategy helps to:
- Ensure long-term commitment of your token.
- Prevent market dumps.
- Allay fears of rug pulls (immediate withdraw of a large amount of early tokens that hurts token value).
- Align stakeholder incentives with the project's success.
Vesting options
There are different vesting strategies available for your token launch. The best option for your project depends on a number of factors unique to your project and its goals.
The following sections highlight some available options to consider when launching a token on the Sui network.
Cliff vesting
Cliff vesting refers to a situation where the entire amount of tokens or assets becomes available after a specific period (the “cliff”). Until the cliff period is met, no tokens are released.
Each of the ten employees of a project are granted 1,000 tokens with a one-year cliff. After one year, they receive the full 1,000 tokens. Before the year is up, they have no access to the tokens.
The following smart contract implements a cliff vesting schedule for token releases. The module includes a new_wallet
function that you pass the total sum of coins to vest and the cliff date as a timestamp. You can then call the claim
function to retrieve the tokens from the wallet if the cliff date is in the past.
Considering the example scenario, you would call new_wallet
ten times so that a separate wallet existed for each employee. You would include 1,000 tokens in each call to load the wallet with the necessary funds. Subsequent calls to claim
using the relevant wallet would compare the cliff time (cliff_time
in the Wallet
object) against the current time, returning tokens from the wallet if the cliff time is later than the current time.
Click to opencliff.move
#[allow(unused_const)]
use sui::balance::Balance;
use sui::clock::Clock;
use sui::coin::{Self, Coin};
#[error]
const EInvalidCliffTime: vector<u8> = b"Cliff time must be in the future.";
public struct Wallet<phantom T> has key, store {
id: UID,
balance: Balance<T>,
cliff_time: u64,
claimed: u64,
}
public fun new_wallet<T>(
coins: Coin<T>,
clock: &Clock,
cliff_time: u64,
ctx: &mut TxContext,
): Wallet<T> {
assert!(cliff_time > clock.timestamp_ms(), EInvalidCliffTime);
Wallet {
id: object::new(ctx),
balance: coins.into_balance(),
cliff_time,
claimed: 0,
}
}
public fun claim<T>(self: &mut Wallet<T>, clock: &Clock, ctx: &mut TxContext): Coin<T> {
let claimable_amount = self.claimable(clock);
self.claimed = self.claimed + claimable_amount;
coin::from_balance(self.balance.split(claimable_amount), ctx)
}
public fun claimable<T>(self: &Wallet<T>, clock: &Clock): u64 {
let timestamp = clock.timestamp_ms();
if (timestamp < self.cliff_time) return 0;
self.balance.value()
}
public fun delete_wallet<T>(self: Wallet<T>) {
let Wallet { id, balance, cliff_time: _, claimed: _ } = self;
id.delete();
balance.destroy_zero();
}
public fun balance<T>(self: &Wallet<T>): u64 {
self.balance.value()
}
public fun cliff_time<T>(self: &Wallet<T>): u64 {
self.cliff_time
}
Graded vesting
Graded vesting allows tokens to be gradually released over time, often in equal portions, during the vesting period.
An employee receives 1,200 tokens, with 300 tokens vesting every year over four years. At the end of each year, 300 tokens become available.
The following Hybrid vesting section includes a smart contract that demonstrates how to perform graded vesting.
Hybrid vesting
Hybrid vesting combines different vesting models, such as cliff and graded vesting. This allows flexibility in how tokens are released over time.
50% of tokens are released after a one-year cliff, and the rest are distributed linearly over the next three years.
The following smart contract creates a hybrid vesting model. Like the cliff vesting smart contract, the hybrid model defines a Wallet
struct to hold all the tokens for each stakeholder. This wallet, however, actually contains two different wallets that each follow a different set of vesting rules. When you call the new_wallet
method for this contract, you provide the cliff cutoff timestamp, the timestamp for when the linear schedule begins, and a timestamp for when the linear vesting should end. Calls to claim
then return the sum of tokens that fall within those parameters.
Click to openhybrid.move
#[allow(unused_const)]
use sui::clock::Clock;
use sui::coin::{Self, Coin};
use vesting::cliff;
use vesting::linear;
public struct Wallet<phantom T> has key, store {
id: UID,
cliff_vested: cliff::Wallet<T>,
linear_vested: linear::Wallet<T>,
}
public fun new_wallet<T>(
coins: Coin<T>,
clock: &Clock,
start_cliff: u64,
start_linear: u64,
duration_linear: u64,
ctx: &mut TxContext,
): Wallet<T> {
let mut balance = coins.into_balance();
let balance_cliff = balance.value() * 50 / 100;
Wallet {
id: object::new(ctx),
cliff_vested: cliff::new_wallet(
coin::from_balance(balance.split(balance_cliff), ctx),
clock,
start_cliff,
ctx,
),
linear_vested: linear::new_wallet(
coin::from_balance(balance, ctx),
clock,
start_linear,
duration_linear,
ctx,
),
}
}
public fun claim<T>(self: &mut Wallet<T>, clock: &Clock, ctx: &mut TxContext): Coin<T> {
let mut coin_cliff = self.cliff_vested.claim(clock, ctx);
let coin_linear = self.linear_vested.claim(clock, ctx);
coin_cliff.join(coin_linear);
coin_cliff
}
public fun claimable<T>(self: &Wallet<T>, clock: &Clock): u64 {
self.cliff_vested.claimable(clock) + self.linear_vested.claimable(clock)
}
public fun delete_wallet<T>(self: Wallet<T>) {
let Wallet {
id,
cliff_vested,
linear_vested,
} = self;
cliff_vested.delete_wallet();
linear_vested.delete_wallet();
id.delete();
}
public fun balance<T>(self: &Wallet<T>): u64 {
self.cliff_vested.balance() + self.linear_vested.balance()
}
Backloaded vesting
Backloaded vesting distributes the majority of tokens near the end of the vesting period, rather than evenly over time. This approach can help an ecosystem become more mature before large amounts of tokens become unlocked. Team members and stakeholders can be rewarded early but save the biggest rewards for those that remain with the project for a greater length of time.
An employee's tokens release under the following schedule:
- 10% in the first three years
- 90% in the fourth year
The smart contract for backloaded vesting creates two Wallet
objects inside a parent wallet, which contains all the tokens to be vested. Each of the child wallets is responsible for its own vesting schedule. You call new_wallet
with the coins to vest and start_front
, start_back
, duration
, and back_percentage
values. Based on the values you provide, the contract determines how many tokens to return when the wallet owner calls the claim
function.
For the example scenario, you could pass the start timestamp for the frontload and the start timestamp for the backload (three years after the frontload start). You would also pass the duration of four years (126230400000
) and 90
for the back_percentage
value.
Click to openbackloaded.move
#[allow(unused_const)]
use sui::clock::Clock;
use sui::coin::{Self, Coin};
use vesting::linear;
#[error]
const EInvalidBackStartTime: vector<u8> =
b"Start time of back portion must be after front portion.";
#[error]
const EInvalidPercentageRange: vector<u8> = b"Percentage range must be between 50 to 100.";
#[error]
const EInsufficientBalance: vector<u8> = b"Not enough balance for vesting.";
#[error]
const EInvalidDuration: vector<u8> = b"Duration must be long enough to complete back portion.";
public struct Wallet<phantom T> has key, store {
id: UID,
front: linear::Wallet<T>,
back: linear::Wallet<T>,
start_front: u64,
start_back: u64,
duration: u64,
back_percentage: u8,
}
public fun new_wallet<T>(
coins: Coin<T>,
clock: &Clock,
start_front: u64,
start_back: u64,
duration: u64,
back_percentage: u8,
ctx: &mut TxContext,
): Wallet<T> {
assert!(start_back > start_front, EInvalidBackStartTime);
assert!(back_percentage > 50 && back_percentage <= 100, EInvalidPercentageRange);
assert!(duration > start_back - start_front, EInvalidDuration);
let mut balance = coins.into_balance();
let balance_back = balance.value() * (back_percentage as u64) / 100;
let balance_front = balance.value() - balance_back;
assert!(balance_front > 0 && balance_back > 0, EInsufficientBalance);
Wallet {
id: object::new(ctx),
front: linear::new_wallet(
coin::from_balance(balance.split(balance_front), ctx),
clock,
start_front,
start_back - start_front,
ctx,
),
back: linear::new_wallet(
coin::from_balance(balance, ctx),
clock,
start_back,
duration - (start_back - start_front),
ctx,
),
start_front,
start_back,
duration,
back_percentage,
}
}
public fun claim<T>(self: &mut Wallet<T>, clock: &Clock, ctx: &mut TxContext): Coin<T> {
let mut coin_front = self.front.claim(clock, ctx);
let coin_back = self.back.claim(clock, ctx);
coin_front.join(coin_back);
coin_front
}
public fun claimable<T>(self: &Wallet<T>, clock: &Clock): u64 {
self.front.claimable(clock) + self.back.claimable(clock)
}
public fun delete_wallet<T>(self: Wallet<T>) {
let Wallet {
id,
front,
back,
start_front: _,
start_back: _,
duration: _,
back_percentage: _,
} = self;
front.delete_wallet();
back.delete_wallet();
id.delete();
}
public fun balance<T>(self: &Wallet<T>): u64 {
self.front.balance() + self.back.balance()
}
public fun start<T>(self: &Wallet<T>): u64 {
self.start_front
}
public fun duration<T>(self: &Wallet<T>): u64 {
self.duration
}
Milestone- or performance-based vesting
With performance-based vesting, achieving specific goals or metrics trigger vest events, such as hitting revenue targets or progressing through project stages.
A team's tokens vest in relation to the number of monthly active users (MAUs). All tokens become vested after the platform reaches its goal of 10 million MAUs.
Similarly, milestone-based vesting creates vest events when specific project or personal milestones are achieved, rather than being tied to time-based conditions.
Tokens unlock when the mainnet of a blockchain project launches.
Like the other examples, the following smart contract creates a wallet to hold the coins to be distributed. Unlike the others, however, this Wallet
object includes a milestone_controller
field that you set to the address of the account that has the authority to update the milestone progress. The call to the new_wallet
function aborts with an error if the wallet has the same address as the entity with milestone update privileges as an integrity check.
The milestone update authority can call update_milestone_percentage
to update the percentage-to-complete value. The owner of the vested token wallet can call claim
to retrieve the tokens that are unlocked based on the current percentage-to-complete value. Considering the first example scenario, you could update the milestone value by ten percent for every million MAUs the project achieves. You could use the same contract for the second scenario, updating the percentage one time to 100 only after mainnet launches.
Click to openmilestone.move
use sui::balance::Balance;
use sui::coin::{Self, Coin};
#[error]
const EOwnerIsController: vector<u8> = b"Owner cannot be the milestone controller.";
#[error]
const EUnauthorizedOwner: vector<u8> = b"Unauthorized owner.";
#[error]
const EUnauthorizedMilestoneController: vector<u8> = b"Unauthorized milestone controller.";
#[error]
const EMilestonePercentageRange: vector<u8> = b"Invalid milestone percentage.";
#[error]
const EInvalidNewMilestone: vector<u8> =
b"New milestone must be greater than the current milestone.";
public struct Wallet<phantom T> has key, store {
id: UID,
balance: Balance<T>,
claimed: u64,
milestone_percentage: u8,
owner: address,
milestone_controller: address,
}
public fun new_wallet<T>(
coins: Coin<T>,
owner: address,
milestone_controller: address,
ctx: &mut TxContext,
) {
assert!(owner != milestone_controller, EOwnerIsController);
let wallet = Wallet {
id: object::new(ctx),
balance: coins.into_balance(),
claimed: 0,
milestone_percentage: 0,
owner,
milestone_controller,
};
transfer::share_object(wallet);
}
public fun claim<T>(self: &mut Wallet<T>, ctx: &mut TxContext): Coin<T> {
assert!(self.owner == ctx.sender(), EUnauthorizedOwner);
let claimable_amount = self.claimable();
self.claimed = self.claimed + claimable_amount;
coin::from_balance(self.balance.split(claimable_amount), ctx)
}
public fun claimable<T>(self: &Wallet<T>): u64 {
let claimable: u128 =
(self.balance.value() + self.claimed as u128) * (self.milestone_percentage as u128) / 100;
(claimable as u64) - self.claimed
}
public fun update_milestone_percentage<T>(
self: &mut Wallet<T>,
percentage: u8,
ctx: &mut TxContext,
) {
assert!(self.milestone_controller == ctx.sender(), EUnauthorizedMilestoneController);
assert!(percentage > 0 && percentage <= 100, EMilestonePercentageRange);
assert!(percentage > self.milestone_percentage, EInvalidNewMilestone);
self.milestone_percentage = percentage;
}
public fun delete_wallet<T>(self: Wallet<T>) {
let Wallet {
id,
balance,
claimed: _,
milestone_percentage: _,
owner: _,
milestone_controller: _,
} = self;
id.delete();
balance.destroy_zero();
}
public fun balance<T>(self: &Wallet<T>): u64 {
self.balance.value()
}
public fun milestone<T>(self: &Wallet<T>): u8 {
self.milestone_percentage
}
public fun get_owner<T>(self: &Wallet<T>): address {
self.owner
}
public fun get_milestone_controller<T>(self: &Wallet<T>): address {
self.milestone_controller
}
Linear vesting
With linear vesting, tokens are released gradually over a set time period.
An employee is granted 1,000 tokens to be gradually released over a one-year period.
The linear vesting smart contract creates a Wallet
object with start
and duration
fields. The contract uses those values along with the current time to determine the number of tokens that are vested. The current time, in this case, is the time at which the wallet owner calls the claim
function.
For the example scenario, you create the wallet (call new_wallet
) with 1,000 tokens, the timestamp for the employee start date, and one year (31557600000
) as the duration.
Click to openlinear.move
#[allow(unused_const)]
use sui::balance::Balance;
use sui::clock::Clock;
use sui::coin::{Self, Coin};
#[error]
const EInvalidStartTime: vector<u8> = b"Start time must be in the future.";
public struct Wallet<phantom T> has key, store {
id: UID,
balance: Balance<T>,
start: u64,
claimed: u64,
duration: u64,
}
public fun new_wallet<T>(
coins: Coin<T>,
clock: &Clock,
start: u64,
duration: u64,
ctx: &mut TxContext,
): Wallet<T> {
assert!(start > clock.timestamp_ms(), EInvalidStartTime);
Wallet {
id: object::new(ctx),
balance: coins.into_balance(),
start,
claimed: 0,
duration,
}
}
public fun claim<T>(self: &mut Wallet<T>, clock: &Clock, ctx: &mut TxContext): Coin<T> {
let claimable_amount = self.claimable(clock);
self.claimed = self.claimed + claimable_amount;
coin::from_balance(self.balance.split(claimable_amount), ctx)
}
public fun claimable<T>(self: &Wallet<T>, clock: &Clock): u64 {
let timestamp = clock.timestamp_ms();
if (timestamp < self.start) return 0;
if (timestamp >= self.start + self.duration) return self.balance.value();
let elapsed = timestamp - self.start;
let claimable: u128 =
(self.balance.value() + self.claimed as u128) * (elapsed as u128) / (self.duration as u128);
(claimable as u64) - self.claimed
}
public fun delete_wallet<T>(self: Wallet<T>) {
let Wallet { id, start: _, balance, claimed: _, duration: _ } = self;
id.delete();
balance.destroy_zero();
}
public fun balance<T>(self: &Wallet<T>): u64 {
self.balance.value()
}
public fun start<T>(self: &Wallet<T>): u64 {
self.start
}
public fun duration<T>(self: &Wallet<T>): u64 {
self.duration
}
All tokens vest immediately, meaning they are fully available as soon as they are allocated.
An early investor receives their full allocation of tokens at the time of purchase.
With immediate investing, you could always just transfer tokens to an address. Opting for a smart contract approach provides several advantages over a manual transfer, however.
- Enhanced transparency and accountability because the transaction is stored on chain for any interested parties to verify. The smart contract logic identifies the exact purpose of the transaction.
- Possible to enforce specific conditions. For example, you could create a milestone-based vesting contract that you update to 100% complete only after some conditions are met, like accepting terms of an agreement.
- Provides an auditable record for compliance and reporting.
- Allows flexibility to perform other actions as the terms of an agreement change, like conversion to another token before claiming.
The following test leverages the linear vesting smart contract example to demonstrate how to use one of the other vesting strategy smart contracts to support immediate vesting. The test uses the Wallet
object and new_wallet
function from vesting::linear
to perform (or test) an immediate vest scenario. The test accomplishes this by setting the duration
value to 0.
Click to openimmediate_tests.move
#[test_only]
use sui::clock;
use sui::coin;
use sui::sui::SUI;
use sui::test_scenario as ts;
use vesting::linear::{new_wallet, Wallet};
public struct Token has key, store { id: UID }
const OWNER_ADDR: address = @0xAAAA;
const CONTROLLER_ADDR: address = @0xBBBB;
const FULLY_VESTED_AMOUNT: u64 = 10_000;
const VESTING_DURATION: u64 = 0;
const START_TIME: u64 = 1;
fun test_setup(): ts::Scenario {
let mut ts = ts::begin(CONTROLLER_ADDR);
let coins = coin::mint_for_testing<SUI>(FULLY_VESTED_AMOUNT, ts.ctx());
let now = clock::create_for_testing(ts.ctx());
let wallet = new_wallet(coins, &now, START_TIME, VESTING_DURATION, ts.ctx());
transfer::public_transfer(wallet, OWNER_ADDR);
now.destroy_for_testing();
ts
}
#[test]
fun test_immediate_vesting() {
let mut ts = test_setup();
ts.next_tx(OWNER_ADDR);
let mut now = clock::create_for_testing(ts.ctx());
let mut wallet = ts.take_from_sender<Wallet<SUI>>();
now.set_for_testing(START_TIME);
assert!(wallet.claimable(&now) == FULLY_VESTED_AMOUNT);
assert!(wallet.balance() == FULLY_VESTED_AMOUNT);
let coins = wallet.claim(&now, ts.ctx());
transfer::public_transfer(coins, OWNER_ADDR);
assert!(wallet.claimable(&now) == 0);
assert!(wallet.balance() == 0);
ts.return_to_sender(wallet);
now.destroy_for_testing();
let _end = ts::end(ts);
}