Development of Token Vesting Contract
Vesting contract looks simple — linear token unlock over time. In practice, significant financial losses concentrate in these contracts: improper cliff handling, vulnerability to rug pull through admin backdoors, lack of revocation on dismissal and bugs with precision when working with large numbers. Before writing code, must clearly define vesting model requirements.
Vesting Models
Common schemes met in practice:
Linear vesting with cliff: most common for team and investors. Tokens fully locked until cliff date, then unlock equally until end date. Example: 1-year cliff, 4-year total vesting — standard for team allocation in most protocols.
Graded vesting: different percentages in different periods. Used for IDO/ICO: 10% TGE, then equally 6–12 months.
Milestone-based vesting: unlock tied to events (mainnet launch, TVL targets), not just time. Requires oracle or multisig for milestone verification.
Contract Architecture
// 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/AccessControl.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract TokenVesting is AccessControl, ReentrancyGuard {
using SafeERC20 for IERC20;
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
struct VestingSchedule {
address beneficiary;
uint256 totalAmount; // total to pay
uint256 releasedAmount; // already paid
uint64 startTime;
uint64 cliffDuration; // in seconds
uint64 duration; // full vesting length
uint64 slicePeriod; // minimum unlock period (e.g. 30 days)
bool revocable; // can be revoked by admin
bool revoked;
}
IERC20 public immutable token;
mapping(bytes32 => VestingSchedule) public vestingSchedules;
mapping(address => bytes32[]) public beneficiarySchedules;
uint256 public vestingSchedulesTotalAmount;
event ScheduleCreated(bytes32 indexed scheduleId, address indexed beneficiary);
event TokensReleased(bytes32 indexed scheduleId, uint256 amount);
event ScheduleRevoked(bytes32 indexed scheduleId);
constructor(address _token) {
token = IERC20(_token);
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(ADMIN_ROLE, msg.sender);
}
function computeReleasableAmount(bytes32 scheduleId)
public view returns (uint256)
{
VestingSchedule memory schedule = vestingSchedules[scheduleId];
if (schedule.revoked) return 0;
uint256 currentTime = block.timestamp;
uint256 cliffEnd = schedule.startTime + schedule.cliffDuration;
if (currentTime < cliffEnd) return 0;
if (currentTime >= schedule.startTime + schedule.duration) {
// vesting complete — all available
return schedule.totalAmount - schedule.releasedAmount;
}
// linear unlock accounting for slicePeriod
uint256 timeFromStart = currentTime - schedule.startTime;
uint256 vestedSlices = timeFromStart / schedule.slicePeriod;
uint256 vestedSeconds = vestedSlices * schedule.slicePeriod;
// important: division after multiplication to prevent overflow
uint256 vestedAmount = (schedule.totalAmount * vestedSeconds) / schedule.duration;
return vestedAmount - schedule.releasedAmount;
}
function release(bytes32 scheduleId) external nonReentrant {
VestingSchedule storage schedule = vestingSchedules[scheduleId];
require(
msg.sender == schedule.beneficiary || hasRole(ADMIN_ROLE, msg.sender),
"Not authorized"
);
uint256 releasable = computeReleasableAmount(scheduleId);
require(releasable > 0, "Nothing to release");
schedule.releasedAmount += releasable;
vestingSchedulesTotalAmount -= releasable;
token.safeTransfer(schedule.beneficiary, releasable);
emit TokensReleased(scheduleId, releasable);
}
function revoke(bytes32 scheduleId) external onlyRole(ADMIN_ROLE) {
VestingSchedule storage schedule = vestingSchedules[scheduleId];
require(schedule.revocable, "Schedule not revocable");
require(!schedule.revoked, "Already revoked");
uint256 releasable = computeReleasableAmount(scheduleId);
if (releasable > 0) {
// release earned before revoke
schedule.releasedAmount += releasable;
token.safeTransfer(schedule.beneficiary, releasable);
}
uint256 remainingAmount = schedule.totalAmount - schedule.releasedAmount;
schedule.revoked = true;
vestingSchedulesTotalAmount -= remainingAmount;
// return unearned tokens to admin
token.safeTransfer(msg.sender, remainingAmount);
emit ScheduleRevoked(scheduleId);
}
}
Common Mistakes
Precision loss: when calculating (totalAmount * elapsed) / duration operation order is critical. Multiplication must precede division. For 18 decimals tokens, intermediate value may not fit uint256 for very large amounts — use mulDiv from OpenZeppelin Math library.
block.timestamp manipulation: validators can shift timestamp within small limits (~15 seconds on Ethereum). For vesting with monthly periods insignificant, but for slicePeriod < 1 hour — potential problem.
No balance check: when creating schedule, contract must verify sufficient balance to cover new obligations: token.balanceOf(address(this)) >= vestingSchedulesTotalAmount + newAmount.
Security and Access Control
Schedule creation and revocation shouldn't be single key. Recommended scheme:
- ADMIN_ROLE: Gnosis Safe 3/5 multisig — schedule creation and revocation
- TIMELOCK: for critical functions (admin change, upgrade) — 48–72 hour delay
Function revoke() especially sensitive. Allows taking all unearned tokens. If revocable = true for investor allocation — red flag. Revocable vesting appropriate only for team.
TGE + Linear Vesting: Combined Scheme
Often need: X% at TGE (Token Generation Event), rest by linear schedule. Implemented as two separate schedules for one beneficiary:
function createTGESchedule(
address beneficiary,
uint256 totalAmount,
uint256 tgePercent, // in basis points (1000 = 10%)
uint64 vestingStart,
uint64 vestingDuration
) external onlyRole(ADMIN_ROLE) {
uint256 tgeAmount = (totalAmount * tgePercent) / 10000;
uint256 vestingAmount = totalAmount - tgeAmount;
// immediate TGE unlock
_createSchedule(beneficiary, tgeAmount, 0, 0, 1); // duration=1 immediately accessible
// linear vesting of remainder
_createSchedule(beneficiary, vestingAmount, vestingStart, 0, vestingDuration);
}
Multi-Token Vesting
If protocol has multiple tokens (governance + utility) or LP tokens — can generalize contract accepting token address as parameter. Complicates balance accounting logic and requires token → totalVested mapping. Audit becomes harder. Justified only if truly need different tokens — don't over-engineer for "flexibility".







