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:
| Day | Event | Price Impact |
|---|---|---|
| 0 | Launch | $0.10 |
| 1 | Team sell (5% supply) | $0.04 (-60%) |
| 2 | Panic selling | $0.015 (-62%) |
| 7 | Dead | $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:
| Approach | Deployment | Per Claim |
|---|---|---|
| Individual storage | 20M gas | 30K gas |
| Merkle-based | 500K gas | 50K 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:
- OpenZeppelin Vesting - Reference implementation
- Sablier - Streaming payments protocol
Development:
- Token Cost Calculator - Estimate costs
- Hardhat - Development environment
- Foundry - Testing framework
Security:
- OpenZeppelin - Audited contracts
- Slither - Static analysis
Documentation:
- Solidity Docs - Language reference
- Ethereum.org - Developer resources
- AllThingsWeb3 - Smart contract development