Разработка контракта вестинга токенов
Вестинг-контракт выглядит простым — линейное разблокирование токенов по времени. На практике именно в этих контрактах концентрируется значительный объём финансовых потерь: неправильная обработка 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 days)
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 часа delay
Функция revoke() — особенно чувствительная. Она позволяет забрать все незаработанные токены. Если revocable = true для investor allocation — это красный флаг для инвесторов. Revocable вестинг уместен только для команды.
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 сразу accessible
// линейный вестинг остатка
_createSchedule(beneficiary, vestingAmount, vestingStart, 0, vestingDuration);
}
Мультитокенный вестинг
Если протокол имеет несколько токенов (governance + utility) или вестинг нужен для LP-токенов — можно обобщить контракт, принимая адрес токена как параметр. Это усложняет логику учёта балансов и требует маппинга token → totalVested. Аудит становится сложнее. Оправдано только если действительно нужны разные токены — не стоит усложнять ради "гибкости".







