$cat/blog/token-vesting-contracts.md1443 words | 8 min
//

Token Vesting Contracts: Complete Implementation Guide

#token vesting smart contract#Solidity vesting#token cliff
article.md

We've built vesting contracts for 20+ token launches. One had no cliff and the team dumped on day 1. Cost: $2M in market cap. Here's how to build vesting right and protect your project.

The No-Cliff Disaster

Project launched with team tokens unlocked immediately. Day 1: team member sold 5% of supply. Price crashed 60%. Investors panicked. More selling. Dead project in 2 weeks.

What happened:

DayEventPrice Impact
0Launch$0.10
1Team sell (5% supply)$0.04 (-60%)
2Panic selling$0.015 (-62%)
7Dead$0.002

Market cap went from $10M to $200K. All because of no cliff period.

The fix was simple:

// Add 6-month cliff
uint256 public constant CLIFF_DURATION = 180 days;

function release() external {
    require(
        block.timestamp >= startTime + CLIFF_DURATION,
        "Cliff not reached"
    );
    // ... rest of vesting logic
}

6 lines of code would have saved $10M in market cap.

Types of Vesting

Different vesting schedules for different purposes.

Linear Vesting

Tokens unlock gradually over time.

function vestedAmount(address beneficiary) public view returns (uint256) {
    VestingSchedule memory schedule = vestingSchedules[beneficiary];

    if (block.timestamp < schedule.startTime) {
        return 0;
    }

    if (block.timestamp >= schedule.startTime + schedule.duration) {
        return schedule.totalAmount;
    }

    return (schedule.totalAmount *
        (block.timestamp - schedule.startTime)) / schedule.duration;
}

Example: 1M tokens over 12 months = 83,333 tokens per month.

Cliff + Linear

Nothing for X months, then linear vesting.

function vestedAmount(address beneficiary) public view returns (uint256) {
    VestingSchedule memory schedule = vestingSchedules[beneficiary];

    // Before cliff: nothing
    if (block.timestamp < schedule.startTime + schedule.cliffDuration) {
        return 0;
    }

    // After full vesting: everything
    if (block.timestamp >= schedule.startTime + schedule.duration) {
        return schedule.totalAmount;
    }

    // During vesting: linear from cliff to end
    uint256 timeFromStart = block.timestamp - schedule.startTime;
    return (schedule.totalAmount * timeFromStart) / schedule.duration;
}

Example: 6-month cliff, then 24-month linear = nothing for 6 months, then ~4.17% per month.

Milestone-Based

Tokens unlock when conditions are met.

mapping(bytes32 => bool) public milestonesCompleted;
mapping(bytes32 => uint256) public milestoneAmounts;

function completeMilestone(bytes32 milestoneId) external onlyOwner {
    require(!milestonesCompleted[milestoneId], "Already completed");
    milestonesCompleted[milestoneId] = true;
}

function claimMilestone(bytes32 milestoneId) external {
    require(milestonesCompleted[milestoneId], "Milestone not complete");
    require(!claimed[msg.sender][milestoneId], "Already claimed");

    claimed[msg.sender][milestoneId] = true;
    token.transfer(msg.sender, milestoneAmounts[milestoneId]);
}

Example: 25% at mainnet launch, 25% at 10K users, 50% at profitability.

Complete Vesting Contract

Production-ready implementation.

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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract TokenVesting is Ownable, ReentrancyGuard {
    using SafeERC20 for IERC20;

    IERC20 public immutable token;

    struct VestingSchedule {
        uint256 totalAmount;
        uint256 startTime;
        uint256 cliffDuration;
        uint256 vestingDuration;
        uint256 released;
        bool revocable;
        bool revoked;
    }

    mapping(address => VestingSchedule) public vestingSchedules;

    uint256 public totalAllocated;

    event VestingCreated(
        address indexed beneficiary,
        uint256 amount,
        uint256 startTime,
        uint256 cliffDuration,
        uint256 vestingDuration
    );

    event TokensReleased(address indexed beneficiary, uint256 amount);
    event VestingRevoked(address indexed beneficiary, uint256 amountReturned);

    constructor(address _token) Ownable(msg.sender) {
        token = IERC20(_token);
    }

    function createVesting(
        address beneficiary,
        uint256 amount,
        uint256 startTime,
        uint256 cliffDuration,
        uint256 vestingDuration,
        bool revocable
    ) external onlyOwner {
        require(beneficiary != address(0), "Zero address");
        require(amount > 0, "Zero amount");
        require(vestingDuration > 0, "Zero duration");
        require(vestingDuration >= cliffDuration, "Cliff > duration");
        require(
            vestingSchedules[beneficiary].totalAmount == 0,
            "Schedule exists"
        );

        // Ensure contract has enough tokens
        uint256 available = token.balanceOf(address(this)) - totalAllocated;
        require(available >= amount, "Insufficient tokens");

        vestingSchedules[beneficiary] = VestingSchedule({
            totalAmount: amount,
            startTime: startTime,
            cliffDuration: cliffDuration,
            vestingDuration: vestingDuration,
            released: 0,
            revocable: revocable,
            revoked: false
        });

        totalAllocated += amount;

        emit VestingCreated(
            beneficiary,
            amount,
            startTime,
            cliffDuration,
            vestingDuration
        );
    }

    function vestedAmount(address beneficiary) public view returns (uint256) {
        VestingSchedule memory schedule = vestingSchedules[beneficiary];

        if (schedule.revoked) {
            return schedule.released;
        }

        if (block.timestamp < schedule.startTime + schedule.cliffDuration) {
            return 0;
        }

        if (block.timestamp >= schedule.startTime + schedule.vestingDuration) {
            return schedule.totalAmount;
        }

        uint256 timeFromStart = block.timestamp - schedule.startTime;
        return (schedule.totalAmount * timeFromStart) / schedule.vestingDuration;
    }

    function releasable(address beneficiary) public view returns (uint256) {
        return vestedAmount(beneficiary) - vestingSchedules[beneficiary].released;
    }

    function release() external nonReentrant {
        VestingSchedule storage schedule = vestingSchedules[msg.sender];
        require(schedule.totalAmount > 0, "No vesting schedule");
        require(!schedule.revoked, "Vesting revoked");

        uint256 amount = releasable(msg.sender);
        require(amount > 0, "Nothing to release");

        schedule.released += amount;
        totalAllocated -= amount;

        token.safeTransfer(msg.sender, amount);

        emit TokensReleased(msg.sender, amount);
    }

    function revoke(address beneficiary) external onlyOwner {
        VestingSchedule storage schedule = vestingSchedules[beneficiary];
        require(schedule.revocable, "Not revocable");
        require(!schedule.revoked, "Already revoked");

        uint256 vested = vestedAmount(beneficiary);
        uint256 unreleased = vested - schedule.released;
        uint256 refund = schedule.totalAmount - vested;

        schedule.revoked = true;
        schedule.released = vested;
        totalAllocated -= refund;

        if (refund > 0) {
            token.safeTransfer(owner(), refund);
        }

        emit VestingRevoked(beneficiary, refund);
    }

    function emergencyWithdraw(address _token) external onlyOwner {
        // For recovering accidentally sent tokens
        // Cannot withdraw vesting token beyond allocated
        if (_token == address(token)) {
            uint256 excess = token.balanceOf(address(this)) - totalAllocated;
            require(excess > 0, "No excess");
            token.safeTransfer(owner(), excess);
        } else {
            IERC20(_token).safeTransfer(
                owner(),
                IERC20(_token).balanceOf(address(this))
            );
        }
    }
}

Testing Vesting Logic

Vesting is time-dependent. Test thoroughly.

const { expect } = require("chai");
const { ethers } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-network-helpers");

describe("TokenVesting", function () {
    let vesting, token, owner, beneficiary;
    const AMOUNT = ethers.parseEther("1000000");
    const CLIFF = 180 * 24 * 60 * 60; // 6 months
    const DURATION = 365 * 24 * 60 * 60; // 1 year

    beforeEach(async function () {
        [owner, beneficiary] = await ethers.getSigners();

        const Token = await ethers.getContractFactory("MockERC20");
        token = await Token.deploy();

        const Vesting = await ethers.getContractFactory("TokenVesting");
        vesting = await Vesting.deploy(token.target);

        await token.mint(vesting.target, AMOUNT);
    });

    it("should return 0 before cliff", async function () {
        const startTime = await time.latest();
        await vesting.createVesting(
            beneficiary.address,
            AMOUNT,
            startTime,
            CLIFF,
            DURATION,
            false
        );

        // Move to just before cliff
        await time.increase(CLIFF - 1);

        expect(await vesting.vestedAmount(beneficiary.address)).to.equal(0);
    });

    it("should vest linearly after cliff", async function () {
        const startTime = await time.latest();
        await vesting.createVesting(
            beneficiary.address,
            AMOUNT,
            startTime,
            CLIFF,
            DURATION,
            false
        );

        // Move to halfway through vesting
        await time.increase(DURATION / 2);

        const vested = await vesting.vestedAmount(beneficiary.address);
        const expected = AMOUNT / 2n;

        // Allow 1% tolerance for timing
        expect(vested).to.be.closeTo(expected, expected / 100n);
    });

    it("should release vested tokens", async function () {
        const startTime = await time.latest();
        await vesting.createVesting(
            beneficiary.address,
            AMOUNT,
            startTime,
            CLIFF,
            DURATION,
            false
        );

        // Move past cliff
        await time.increase(CLIFF + DURATION / 4);

        const balanceBefore = await token.balanceOf(beneficiary.address);
        await vesting.connect(beneficiary).release();
        const balanceAfter = await token.balanceOf(beneficiary.address);

        expect(balanceAfter).to.be.gt(balanceBefore);
    });

    it("should fully vest after duration", async function () {
        const startTime = await time.latest();
        await vesting.createVesting(
            beneficiary.address,
            AMOUNT,
            startTime,
            CLIFF,
            DURATION,
            false
        );

        // Move past full vesting
        await time.increase(DURATION + 1);

        expect(await vesting.vestedAmount(beneficiary.address)).to.equal(AMOUNT);
    });
});

Security Considerations

Vesting contracts hold significant value. Security is critical.

1. Reentrancy protection

Use OpenZeppelin's ReentrancyGuard on all state-changing functions.

2. Overflow protection

Calculate vesting carefully:

// WRONG: Can overflow
uint256 vested = totalAmount * timeElapsed / duration;

// CORRECT: Use safe ordering
uint256 vested = (totalAmount * timeElapsed) / duration;
// Or use mulDiv for even safer calculation
uint256 vested = Math.mulDiv(totalAmount, timeElapsed, duration);

3. Rounding exploitation

Users might claim tiny amounts repeatedly to accumulate rounding benefits:

// Add minimum claim amount
require(amount >= MIN_CLAIM, "Amount too small");

4. Revocation edge cases

Handle partially vested + partially released:

function revoke(address beneficiary) external onlyOwner {
    uint256 vested = vestedAmount(beneficiary);
    uint256 alreadyReleased = schedule.released;

    // Can still claim vested but unreleased
    uint256 stillClaimable = vested - alreadyReleased;

    // Only refund unvested portion
    uint256 refund = schedule.totalAmount - vested;
}

Gas-Efficient Vesting

For many beneficiaries, use Merkle-based claiming.

bytes32 public merkleRoot;

function claimVested(
    uint256 totalAmount,
    uint256 startTime,
    uint256 cliff,
    uint256 duration,
    bytes32[] calldata proof
) external {
    bytes32 leaf = keccak256(abi.encodePacked(
        msg.sender, totalAmount, startTime, cliff, duration
    ));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");

    // Calculate and release vested amount
    // No storage needed per beneficiary until they claim
}

Gas comparison for 1000 beneficiaries:

ApproachDeploymentPer Claim
Individual storage20M gas30K gas
Merkle-based500K gas50K gas

Merkle saves 97.5% on deployment for large airdrops.

Typical Vesting Schedules

What we've seen work in production.

Team tokens:

  • Cliff: 12 months
  • Vesting: 36-48 months total
  • Revocable: Yes (for departures)

Investor tokens:

  • Cliff: 6 months
  • Vesting: 18-24 months total
  • Revocable: No

Advisor tokens:

  • Cliff: 3-6 months
  • Vesting: 12-24 months total
  • Revocable: Yes

Community/Airdrop:

  • Cliff: 0-1 months
  • Vesting: 6-12 months total
  • Revocable: No

Resources

Libraries:

Development:

Security:

Documentation:

continue_learning.sh

# Keep building your smart contract knowledge

$ More guides from 100+ contract deployments generating $250M+ volume