Solidity Tutorial: Linear Vesting Contract

Cyrille
8 min readFeb 21, 2023

--

Hello! In this episode, we will be building a linear vesting contract in solidity.

Photo by Ludomił Sawicki on Unsplash

Linear vesting

Linear vesting is a way to gradually release or distribute tokens over a period of time instead of giving them all at once.

The benefit of linear vesting is that it incentivizes the recipient to stick around and continue to contribute to the project over the vesting period.

It can also help manage risk for the project by ensuring that the recipient doesn’t receive all of the benefits immediately and potentially leave without contributing to the long-term success.

It can also reduce the selling pressure on a token. Instead of making tokens available to recipients at a specific time, the tokens are gradually released, and recipients can claim their released tokens at different intervals.

Specifications

  • The deployer should give the contract the following inputs:
    - recipients
    - amounts
    - startTime
    - duration
  • The recipient can not claim any tokens before the start time is reached
  • Recipients can claim tokens with the following equation. released = amount * (startTime+duration-currentTime) / duration

Code

We will build the contract following a test-driven development approach. Meaning that we will write out test cases and build our contract step-by-step.

We are using hardhat, if you haven’t already, I suggest you follow their tutorial to get your environment set up. From now on, everything is happing in the project directory.

Let’s start by creating our test file, test/LinearVesting.ts

describe("LinearVesting", function () {

async function deploy() {
// deployment code will go here
}

// test cases go here

})

deployment / should have a token

describe("deployment", function () {
it('should have a token', async function () {
const { contract, token } = await loadFixture(deploy)
expect(await contract.token()).to.eq(token.address)
})
})

This test checks whether the deployer associated the contract with a token.

To make this test pass, we will need to do a couple of things:

  1. add the OpenZeppelin contracts library to our project
yarn add --dev @openzeppelin/contracts

2. create a token: contracts/Token.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract Token is ERC20 {
constructor(string memory name_, string memory symbol_, uint totalsuppy_) ERC20(name_, symbol_) {
_mint(msg.sender, totalsuppy_);
}
}

This token will take as input a name, symbol, and totalSupply. the totalSupply will be minted and sent to the deployer.

3. create our linear vesting contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract LinearVesting {
using SafeERC20 for IERC20;

IERC20 public token;

constructor(IERC20 token_) {
token = token_;
}
}

4. update our test file

// add the following imports
import { expect } from "chai";
import { ethers } from "hardhat";
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";

// update the deploy() function
async function deploy() {
// deploy the token
const Token = await ethers.getContractFactory("Token")
const token = await Token.deploy("OnMyChain", "OMC", ethers.utils.parseEther("10000"))
await token.deployed()

// deploy the contract
const Contract = await ethers.getContractFactory("LinearVesting")
const contract = await Contract.deploy(token.address)
await contract.deployed()

return { contract, token }
}

We can now run our first test using npx hardhat test

deployment / should have allocations

it("should have allocations", async function () {
const { contract, recipients, allocations } = await loadFixture(deploy)
for (let index = 0; index < recipients.length; index++) {
const recipient = recipients[index];
const allocation = allocations[index];
expect(await contract.allocation(recipient)).to.eq(allocation)
}
})

Here we are checking that every recipient has their expected allocation. We need to update the deploy code and the contract code.

in our test deploy function of the test file:

async function deploy() {
// deploy the token
const Token = await ethers.getContractFactory("Token")
const token = await Token.deploy("OnMyChain", "OMC", ethers.utils.parseEther("10000"))
await token.deployed()

// generate array of recipients and array of allocations
// recipients[0] will have allocations[0]
const recipients = (await ethers.getSigners()).map(s => s.address)
const allocations = recipients.map((r, idx) => ethers.utils.parseEther((idx * 10).toString()))

// deploy the contract
const Contract = await ethers.getContractFactory("LinearVesting")
const contract = await Contract.deploy(token.address, recipients, allocations) // add the args
await contract.deployed()

return { contract, token, recipients, allocations }
}

update the contract:

// in the contract, declare a mapping called allocation
mapping(address => uint) allocation;

// in the contractor function add recipients and allocations as input
constructor(IERC20 token_, address[] memory recipients_, uint[] memory allocations_) {
token = token_;
for (uint i = 0; i < recipients_.length; i++) {
allocation[recipients_[i]] = allocations_[i];
}
}

here, our constructor is taking the array of recipients and allocations. For each recipient, it is adding the corresponding allocation.

deployment / should have a start time

it("should have a start time", async function () {
const { contract, startTime } = await loadFixture(deploy)
expect(await contract.startTime()).to.eq(startTime)
})

update the deploy function:

async function deploy() {
// deploy the token
const Token = await ethers.getContractFactory("Token")
const token = await Token.deploy("OnMyChain", "OMC", ethers.utils.parseEther("10000"))
await token.deployed()

// generate array of recipients and array of allocations
// recipients[0] will have allocations[0]
const recipients = (await ethers.getSigners()).map(s => s.address)
const allocations = recipients.map((r, idx) => ethers.utils.parseEther((idx * 10).toString()))

const startTime = (await time.latest()) + 60 // starts 60 seconds after deployment

// deploy the contract
const Contract = await ethers.getContractFactory("LinearVesting")
const contract = await Contract.deploy(token.address, recipients, allocations, startTime) // add the args
await contract.deployed()

return { contract, token, recipients, allocations, startTime }
}

update the contract:

// ...
contract LinearVesting {
// ...
uint public startTime;

constructor(IERC20 token_, address[] memory recipients_, uint[] memory allocations_, uint startTime_) {
// ...
startTime = startTime_;
}
}

deployment / should have a duration

it("should have a duration", async function () {
const { contract, duration } = await loadFixture(deploy)
expect(await contract.duration()).to.eq(duration)
})

update the deploy function:

async function deploy() {
// deploy the token
const Token = await ethers.getContractFactory("Token")
const token = await Token.deploy("OnMyChain", "OMC", ethers.utils.parseEther("10000"))
await token.deployed()

// generate array of recipients and array of allocations
// recipients[0] will have allocations[0]
const recipients = (await ethers.getSigners()).map(s => s.address)
const allocations = recipients.map((r, idx) => ethers.utils.parseEther((idx * 10).toString()))

const startTime = (await time.latest()) + 60 // starts 60 seconds after deployment
const duration = 60 * 60 // 1 hour duration

// deploy the contract
const Contract = await ethers.getContractFactory("LinearVesting")
const contract = await Contract.deploy(token.address, recipients, allocations, startTime, duration) // add the args
await contract.deployed()

return { contract, token, recipients, allocations, startTime, duration }
}

update the contract:

// ...
contract LinearVesting {
// ...
uint public duration;

constructor(IERC20 token_, address[] memory recipients_, uint[] memory allocations_, uint startTime_, uint duration_) {
// ...
duration = duration_;
}
}

Ok, we have all the basic test cases for our deployment covered. If you want to see how we can add additional validations and events to this, make sure to check out the bonus section at the end.

Logic

Now we will be jumping into the code logic, there are a couple of variables we want to keep track of.

  1. claimed — how much the recipient has already claimed
  2. released — how much has been released
  3. available — how much is available to claim (released — claimed)
  4. outstanding — how much has not been released (allocation — released)

for each of these, we want to test their value in three different states:

  1. prior to the start time
  2. throughout the vesting period (start time … start time + duration)
  3. after the vesting period (start time + duration)

Lastly, we need to define a function that the recipient can call to claim their available tokens.

claim / should revert before the start time

describe("claim", function () {
it("should revert before start time", async function () {
const { contract, recipients } = await loadFixture(deploy)
for await (const recipient of recipients) {
await expect(contract.connect(recipient).claim()).to.be.revertedWith("LinearVesting: has not started")
}
})
})

I refactored some code in deploy to make it sender recipients as signers instead of strings.

// ...
const recipients = await ethers.getSigners()
// ..
// added recipients.map
const contract = await Contract.deploy(token.address, recipients.map(s => s.address), allocations, startTime, duration)

Updated the contract code:

function claim() external {
require(block.timestamp > startTime, "LinearVesting: has not started");
}

this code reverts if the block.timestamp is less than or equal to startTime

claim / should transfer available tokens

it("should transfer available tokens", async function () {
const { contract, token, recipients, allocations, startTime, duration } = await loadFixture(deploy)
await time.increaseTo(startTime)
await time.increase((duration / 2) - 1) // increase to 50% of tokens being available

const allocation = allocations[0];
const amount = allocation.div(2) // 50%
await expect(contract.connect(recipients[0]).claim()).to.changeTokenBalances(token,
[contract, recipients[0]],
[amount.mul(-1), amount]
)
})

let’s start by updating the deploy function. We need to transfer the sum of the allocated amount from the deployer to the contract:

// ...

// transfer tokens to the contract
const amount = allocations.reduce((acc, cur) => acc.add(cur), ethers.utils.parseEther("0"))
await token.transfer(contract.address, amount)

now let's update our contract code. we will need to declare some of the variables previously mentioned:

contract LinearVesting {
// ...
mapping(address => uint) public claimed;

function claim() external {
require(block.timestamp >= startTime, "LinearVesting: has not started");
uint amount = _available(msg.sender);
token.safeTransfer(msg.sender, amount);
}

function _available(address address_) internal view returns (uint) {
return _released(address_) - claimed[address_];
}

function _released(address address_) internal view returns (uint) {
return (allocation[address_] * (block.timestamp - startTime)) / duration;
}
}

The tokens are transferred.

claim / should update claimed

it("should update claimed", async function () {
const { contract, token, recipients, allocations, startTime, duration } = await loadFixture(deploy)
await time.increaseTo(startTime)
await time.increase((duration / 2) - 1) // increase to 50% of tokens being available

const allocation = allocations[0];
const amount = allocation.div(2) // 50%
expect(await contract.claimed(recipients[0].address)).to.eq(0)
await contract.connect(recipients[0]).claim()
expect(await contract.claimed(recipients[0].address)).to.eq(amount)
})

contract code:

function claim() external {
// ...
claimed[msg.sender] += amount;
}

That is it for the contract variables, the other variables will actually be functions that we will make external.

helpers / before

describe("helpers", function () {
describe("before start time", function () {

let contract: Contract
let recipient: SignerWithAddress
let allocation: BigNumber

beforeEach(async function() {
const fixture = await loadFixture(deploy)
contract = fixture.contract
recipient = fixture.recipients[0]
allocation = fixture.allocations[0]
await time.increaseTo(fixture.startTime)
})

it("should have 0 released", async function () {
expect(await contract.released(recipient.address)).to.eq(0)
})
it("should have 0 available", async function () {
expect(await contract.available(recipient.address)).to.eq(0)
})
it("should have all outstanding", async function () {
expect(await contract.outstanding(recipient.address)).to.eq(allocation)
})
})
})

code:

//...

function available(address address_) external view returns (uint) {
return _available(address_);
}

function released(address address_) external view returns (uint) {
return _released(address_);
}

function outstanding(address address_) external view returns (uint) {
return allocation[address_] - _released(address_);
}

helpers / during

describe("during", function () {
let contract: Contract;
let recipient: SignerWithAddress;
let allocation: BigNumber;

beforeEach(async function () {
const fixture = await loadFixture(deploy);
contract = fixture.contract;
recipient = fixture.recipients[0];
allocation = fixture.allocations[0];
await time.increaseTo(fixture.startTime);
await time.increase(fixture.duration / 2 - 1);
await contract.connect(recipient).claim();
});

it("should have 50% released", async function () {
expect(await contract.released(recipient.address)).to.eq(allocation.div(2));
});
it("should have 0% available", async function () {
expect(await contract.available(recipient.address)).to.eq(0);
});
it("should have 50% outstanding", async function () {
expect(await contract.outstanding(recipient.address)).to.eq(
allocation.div(2)
);
});
});

helpers / after

describe("after", function () {
let contract: Contract;
let recipient: SignerWithAddress;
let allocation: BigNumber;

beforeEach(async function () {
const fixture = await loadFixture(deploy);
contract = fixture.contract;
recipient = fixture.recipients[0];
allocation = fixture.allocations[0];
await time.increaseTo(fixture.startTime);
await time.increase(fixture.duration);
await contract.connect(recipient).claim();
});

it("should have 100% released", async function () {
expect(await contract.released(recipient.address)).to.eq(allocation);
});
it("should have 0% available", async function () {
expect(await contract.available(recipient.address)).to.eq(0);
});
it("should have 0% outstanding", async function () {
expect(await contract.outstanding(recipient.address)).to.eq(0);
});
});

update the contract code:

function _released(address address_) internal view returns (uint) {
if (block.timestamp < startTime) {
return 0;
} else {
if (block.timestamp > startTime + duration) {
return allocation[address_];
} else {
return (allocation[address_] * (block.timestamp - startTime)) / duration;
}
}
}

That’s it! we have a basic linear vesting contract.

Bonus

Here are some bonus improvements you can implement:

  • verify that recipients and allocations have the same lengths
  • verify that there are no duplicate recipients
  • emit an event after creating an allocation
  • verify that the start time is greater than or equal to the latest block timestamp
  • add a function for the end time
  • emit an event after claiming tokens
  • how can you make this a more general vesting contract?
    - multiple tokens
    - different vesting schedules by address

I look forward to seeing what you create! Hit me up.

The code can be found in this GitHub repo.

--

--

Cyrille

I build web2 and web3 products and teams. Depending on the project, I operate as a CTO, product manager, developer, advisor, or investor.