Розробка контракту вестинга токенів
Вестинг-контракт виглядає простим — лінійне розблокування токенів по часу. На практиці саме в цих контрактах концентрується значний обсяг фінансових втрат: неправильна обробка cliff, вразливість до rug pull через backdoor в admin-функціях, відсутність ревокації при звільненні та баги з точністю обчислень при роботі з великими числами. Перш ніж писати код, потрібно чітко визначити вимоги до моделі вестинга.
Моделі вестинга
Типові схеми, які зустрічаються на практиці:
Linear vesting with cliff: найпоширеніша для команди та інвесторів. Токени повністю заблоковані до cliff date, потім розблокуються рівномірно до end date. Приклад: 1-year cliff, 4-year total vesting — стандарт для team allocation в більшості протоколів.
Graded vesting: різні відсотки в різні періоди. Використовується для IDO/ICO: 10% TGE, потім рівномірно 6–12 місяців.
Milestone-based vesting: розблокування привʼязано до подій (mainnet launch, TVL цілі), а не тільки часу. Вимагає oracle або мультисиг для верифікації milestones.
Архітектура контракту
// 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; // загальна сума до виплати
uint256 releasedAmount; // вже виплачено
uint64 startTime;
uint64 cliffDuration; // в секундах
uint64 duration; // повна довжина вестинга
uint64 slicePeriod; // мінімальний період розблокування (напр. 30 днів)
bool revocable; // може ли бути відозвано адміністратором
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) {
// вестинг завершений — все доступно
return schedule.totalAmount - schedule.releasedAmount;
}
// лінійне розблокування з урахуванням slicePeriod
uint256 timeFromStart = currentTime - schedule.startTime;
uint256 vestedSlices = timeFromStart / schedule.slicePeriod;
uint256 vestedSeconds = vestedSlices * schedule.slicePeriod;
// важливо: деление перед множенням для запобігання 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) {
// виплачуємо заробленого перед відзивом
schedule.releasedAmount += releasable;
token.safeTransfer(schedule.beneficiary, releasable);
}
uint256 remainingAmount = schedule.totalAmount - schedule.releasedAmount;
schedule.revoked = true;
vestingSchedulesTotalAmount -= remainingAmount;
// повертаємо незаробленні токени адміністратору
token.safeTransfer(msg.sender, remainingAmount);
emit ScheduleRevoked(scheduleId);
}
}
Типичні помилки
Precision loss: при розрахунку (totalAmount * elapsed) / duration порядок операцій критичний. Множення повинно передувати діленню. Для токенів з 18 decimals проміжне значення може не поміститися в uint256 при дуже великих сумах — використовуйте mulDiv з OpenZeppelin Math library.
block.timestamp маніпуляція: валідатори можуть зсунути timestamp в невеликих межах (~15 секунд на Ethereum). Для вестинга з періодом у місяці це несущественно, але для slicePeriod < 1 години — потенційна проблема.
Відсутність перевірки баланса: при створенні schedule контракт повинен убедиться що на його балансі достатньо токенів для покриття нових зобов'язань: token.balanceOf(address(this)) >= vestingSchedulesTotalAmount + newAmount.
Безпека та access control
Функції створення та відзиву schedule не повинні бути у одного ключа. Рекомендована схема:
- ADMIN_ROLE: Gnosis Safe 3/5 мультисиг — створення та відзив вестинг-розписань
- TIMELOCK: для критичних функцій (зміна admin, upgrade) — 48–72 години затримки
Функція revoke() — особливо чутлива. Вона дозволяє забрати всі незаробленні токени. Якщо revocable = true для investor allocation — це червоний флаг. Revocable vesting доцільно тільки для команди.
TGE + лінійний вестинг: комбінована схема
Часто потрібна схема: X% при TGE (Token Generation Event), решта по лінійному розписанню. Реалізується як два окремі schedule на одного beneficiary:
function createTGESchedule(
address beneficiary,
uint256 totalAmount,
uint256 tgePercent, // у basis points (1000 = 10%)
uint64 vestingStart,
uint64 vestingDuration
) external onlyRole(ADMIN_ROLE) {
uint256 tgeAmount = (totalAmount * tgePercent) / 10000;
uint256 vestingAmount = totalAmount - tgeAmount;
// немедленное разблокирование TGE части
_createSchedule(beneficiary, tgeAmount, 0, 0, 1); // duration=1 сразу доступно
// лінійний вестинг решти
_createSchedule(beneficiary, vestingAmount, vestingStart, 0, vestingDuration);
}
Мультитокенний вестинг
Якщо протокол має кілька токенів (governance + utility) або вестинг потрібен для LP-токенів — можна узагальнити контракт, приймаючи адресу токена як параметр. Це ускладнює логіку обліку балансів та вимагає маппинга token → totalVested. Аудит стає складніше. Виправдано тільки якщо дійсно потрібні різні токени — не ускладнюйте заради "гнучкості".







