Разработка контрактов вестинга токенов

Проектируем и разрабатываем блокчейн-решения полного цикла: от архитектуры смарт-контрактов до запуска DeFi-протоколов, NFT-маркетплейсов и криптобирж. Аудит безопасности, токеномика, интеграция с существующей инфраструктурой.
Показано 1 из 1 услугВсе 1306 услуг
Разработка контрактов вестинга токенов
Средняя
~2-3 рабочих дня
Часто задаваемые вопросы
Направления блокчейн-разработки
Этапы блокчейн-разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1221
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1163
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    855
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1056
  • image_logo-advance_0.png
    Разработка логотипа компании B2B Advance
    561
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    828

Разработка контракта вестинга токенов

Вестинг-контракт выглядит простым — линейное разблокирование токенов по времени. На практике именно в этих контрактах концентрируется значительный объём финансовых потерь: неправильная обработка 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. Аудит становится сложнее. Оправдано только если действительно нужны разные токены — не стоит усложнять ради "гибкости".