Перейти к содержимому

Статьи

Чек-лист безопасности для контрактов вестинга ERC-20

Математика вестинга, отзыв прав, ключи администратора, работа с токенами, инвариант-тесты Foundry и проверки при развёртывании для безопасных контрактов вестинга ERC-20.

Инженерная команда sigmacode.io9 мин чтения

На этой странице (14)
  1. 1. Математика вестинга
  2. 2. Бенефициары и отзыв прав
  3. 3. Контроль доступа и ключи администратора
  4. 4. Обработка переводов токенов
  5. 5. Реентерабельность и порядок вызовов
  6. 6. Метки времени
  7. 7. События и прозрачность
  8. 8. Компромиссы обновляемости
  9. 9. Аварийные механизмы
  10. 10. Тестирование
  11. 11. Статический анализ
  12. 12. Развёртывание и верификация
  13. 13. Эксплуатационные проверки
  14. Заключение

Контракты вестинга выглядят просто: заблокировать токены, постепенно их разблокировать — и готово. На практике они годами хранят значительную часть эмиссии проекта, с ними взаимодействуют основатели, инвесторы, сотрудники и мультисиги, а после развёртывания к ним почти никогда не возвращаются. Небольшая ошибка в расчётах или в модели прав остаётся в сети на весь период вестинга. Этот чек-лист собирает вопросы, которые мы задаём при проектировании или ревью контракта вестинга ERC-20 — от арифметики до развёртывания и повседневной эксплуатации.

1. Математика вестинга#

Ядро любого контракта вестинга — одна функция, которая отвечает на вопрос «сколько токенов разблокировано к моменту t?». Почти все серьёзные ошибки вестинга живут именно здесь.

Линейный график и клифф#

  • Определяйте график явными параметрами: start, cliff, duration, totalAllocation. Избегайте неявных значений, выводимых из block.timestamp при развёртывании.
  • Определитесь, что означает клифф. Распространённые модели: «до клиффа — ничего, затем линейное догоняющее начисление от start» и «до клиффа — ничего, затем единовременная выплата, затем линейно». Зафиксируйте выбранную модель в NatSpec и в тестах.
  • Проверяйте при создании: duration больше нуля, cliff не позже start + duration, totalAllocation больше нуля, бенефициар — не нулевой адрес.
  • После start + duration разблокированная сумма должна равняться ровно totalAllocation, а не «примерно».

Округление#

  • Умножайте до деления. total * elapsed / duration — корректно; total / duration * elapsed незаметно теряет токены при каждой выплате.
  • Округление всегда должно быть в пользу контракта: сумму, которую может получить бенефициар, округляйте вниз, никогда — вверх. Финальная выплата в конце графика забирает оставшуюся «пыль».
  • Проверяйте переполнение при крупных аллокациях в токенах с 18 знаками после запятой. Solidity 0.8.x откатывает транзакцию при переполнении, но revert внутри vestedAmount может заблокировать любую выплату. Если произведение может стать большим, используйте Math.mulDiv из OpenZeppelin.

Начало в прошлом или в будущем#

  • start в прошлом — законный вариант (гранты сотрудникам задним числом), но означает, что крупная сумма доступна сразу. Это должно быть явным, согласованным решением, а не случайностью из-за неверного параметра.
  • start далеко в будущем может оказаться опечаткой (миллисекунды вместо секунд — классика). Добавьте проверки разумных границ в конструктор или фабрику и в скрипт развёртывания.

Компактная эталонная реализация графика:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";

function vestedAmount(
    uint256 total,
    uint64 start,
    uint64 cliff,
    uint64 duration,
    uint64 timestamp
) pure returns (uint256) {
    if (timestamp < cliff) return 0;
    if (timestamp >= start + duration) return total;
    // Multiply before divide; mulDiv avoids intermediate overflow.
    return Math.mulDiv(total, timestamp - start, duration);
}

2. Бенефициары и отзыв прав#

  • Кто может инициировать выплату? Только бенефициар или любой от его имени? Разрешить любому вызывать release() допустимо, пока токены всегда уходят бенефициару; это помогает при восстановлении после потери ключа и в автоматизации.
  • Может ли бенефициар меняться? Если да, требуйте, чтобы смену инициировал текущий бенефициар (в идеале — двухэтапная передача с принятием), и эмитьте событие. Бенефициар-контракт должен уметь принимать и использовать токены.
  • Модель отзыва. Решайте для каждого графика, отзываемый ли он. При отзыве уже разблокированная, но ещё не выплаченная сумма должна оставаться доступной бенефициару; в казну возвращается только неразблокированный остаток. Отзыв уже разблокированных токенов — проблема доверия, а не только кода.
  • Отзыв должен быть окончательным. Отозванный график нельзя отозвать дважды, он не должен продолжать вестинг и не должен позволять администратору вывести больше, чем неразблокированный остаток.
  • Несколько графиков на одного бенефициара. Индексируйте графики по идентификатору, а не только по адресу, чтобы второй грант не перезаписал первый.

3. Контроль доступа и ключи администратора#

  • Перечислите все привилегированные функции: создание графиков, отзыв, пауза, вывод излишков, обновление. Каждая из них — поверхность атаки при компрометации ключа.
  • Используйте Ownable2Step или AccessControl с отдельными ролями вместо одного всемогущего владельца. Роль, создающая графики, не обязана быть ролью, которая может выводить средства.
  • Храните административные роли в мультисиге и рассмотрите таймлок для всего, что выводит токены из контракта.
  • Администратор никогда не должен иметь возможности вывести токены, зарезервированные под графики. Отслеживайте totalCommitted и разрешайте выводить излишек только в пределах balance - totalCommitted.
  • Планируйте конечное состояние: можно ли отказаться от прав администратора после создания всех графиков? Меньше активных ключей — меньше сценариев отказа.

4. Обработка переводов токенов#

  • Используйте SafeERC20 из OpenZeppelin для каждого перевода. Некоторые токены не возвращают boolean, другие возвращают false вместо revert.
  • Токены с комиссией на перевод. Если контракт пополняется токеном, взимающим комиссию, он получает меньше номинальной суммы. Измеряйте баланс до и после пополнения и фиксируйте фактически поступившую сумму — или явно отклоняйте такие токены.
  • Ребейзинговые токены. Балансы, меняющиеся сами по себе, ломают допущение balance == committed + surplus. Либо задокументируйте, что ребейзинговые токены не поддерживаются, либо проектируйте учёт в долях.
  • Зафиксируйте адрес токена как immutable, если контракт обслуживает один токен. Приём произвольных адресов токенов для каждого графика значительно расширяет поверхность атаки.
  • Никогда не позволяйте «спасти» вестинговый токен через общую функцию recoverERC20 без вычета зарезервированных сумм.

5. Реентерабельность и порядок вызовов#

  • Следуйте схеме checks-effects-interactions: обновляйте released до вызова safeTransfer.
  • Добавьте nonReentrant к release, revoke и любой функции вывода. Хуки в стиле ERC-777 или вредоносный токен могут реентерабельно вызвать контракт.
  • Сведите внешние вызовы к минимуму. У контракта вестинга нет причин вызывать произвольные адреса.
solidity
function release(uint256 scheduleId) external nonReentrant {
    Schedule storage s = schedules[scheduleId];
    uint256 amount = _releasable(s);
    require(amount > 0, "Vesting: nothing to release");

    s.released += amount;          // effects first
    totalCommitted -= amount;

    token.safeTransfer(s.beneficiary, amount); // interaction last
    emit TokensReleased(scheduleId, s.beneficiary, amount);
}

6. Метки времени#

  • Используйте block.timestamp, а не номера блоков. Время блока различается между сетями и меняется со временем; тот же контракт позже может быть развёрнут на L2.
  • Влияние валидаторов на метки времени ограничено секундами. Для графиков, измеряемых месяцами, это несущественно, но не стройте логику, зависящую от точности до секунды.
  • Храните метки времени как uint64. Этого достаточно для любого реалистичного графика, и они хорошо упаковываются в хранилище.
  • Явно тестируйте границы: за секунду до клиффа, ровно на клиффе, ровно в конце и значительно позже конца.

7. События и прозрачность#

Каждое изменение состояния должно эмитить событие: ScheduleCreated, TokensReleased, ScheduleRevoked, BeneficiaryChanged, изменения ролей и паузы. События — то, на что опираются индексаторы, дашборды и ваша собственная служба поддержки. Включайте идентификатор графика и суммы, а не только адреса. Инвесторы и сотрудники будут спрашивать, сколько разблокировано, и ончейн-события — самый убедительный ответ.

8. Компромиссы обновляемости#

ВариантПреимуществоРиск
Неизменяемый контрактНаиболее сильная гарантия для бенефициаров, более простой аудитОшибки нельзя исправить; миграция требует нового контракта и средств
Обновляемый проксиОшибки можно исправлятьКлюч обновления может изменить любое правило, ошибки лейаута хранилища, больший объём аудита
Неизменяемый контракт плюс фабрикаКаждый набор графиков изолирован, новые версии — для новых грантовСтарые экземпляры сохраняют старые ошибки

Для вестинга неизменяемость часто — лучший вариант по умолчанию: весь смысл контракта в том, что никто не сможет позже изменить условия сделки. Если вы выбираете прокси, поместите роль обновления за мультисиг и таймлок, используйте storage gaps или namespaced-хранилище и прогоняйте проверки безопасности обновлений OpenZeppelin в CI.

9. Аварийные механизмы#

  • Пауза может защитить от неизвестной ошибки, но пауза, блокирующая release навсегда, — это ещё и способ заморозить бенефициаров. Рассмотрите ограничение длительности паузы или разрешение выплат, пока создание новых графиков приостановлено.
  • Задокументируйте, кто может ставить на паузу, при каких условиях и как будет информироваться сообщество.
  • Избегайте функций «аварийного вывода всего». Если без такой не обойтись, она должна находиться за таймлоком и быть видна в документации, которую читают инвесторы.

10. Тестирование#

Юнит-тесты — необходимый минимум. Для контрактов вестинга тесты на основе свойств дают большую ценность, потому что математика должна выполняться для любого момента времени и любой суммы.

  • Юнит-тесты: каждый путь revert, каждая граничная метка времени, отзыв до клиффа, отзыв после полного вестинга, несколько графиков для одного бенефициара.
  • Фаззинг-тесты: случайные total, duration и timestamp; проверяйте, что vestedAmount монотонна и никогда не превышает total.
  • Инвариант-тесты: позвольте Foundry вызывать release, revoke, createSchedule и vm.warp в случайном порядке, затем проверяйте глобальные свойства.

Полезные инварианты:

  • Сумма выплаченных по графику средств никогда не превышает его аллокацию.
  • Баланс токенов контракта всегда не меньше totalCommitted.
  • Отозванный график впоследствии никогда не увеличивает разблокированную сумму.
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";

contract VestingInvariants is Test {
    VestingHandler handler;

    function setUp() public {
        handler = new VestingHandler(); // deploys token + vesting, exposes bounded actions
        targetContract(address(handler));
    }

    function invariant_releasedNeverExceedsAllocation() public view {
        uint256 n = handler.vesting().scheduleCount();
        for (uint256 i; i < n; i++) {
            (, uint256 total, uint256 released) = handler.vesting().scheduleInfo(i);
            assertLe(released, total);
        }
    }

    function invariant_balanceCoversCommitments() public view {
        assertGe(
            handler.token().balanceOf(address(handler.vesting())),
            handler.vesting().totalCommitted()
        );
    }
}

11. Статический анализ#

Запускайте Slither при каждом изменении и рассматривайте его вывод как очередь на ревью, а не как сигнал «пройдено/не пройдено». Для контрактов вестинга обращайте внимание на находки по реентерабельности, непроверенные переводы, опасные строгие равенства по балансам и отсутствующие события.

bash
slither . --filter-paths "lib|test" --exclude-dependencies
forge test --fuzz-runs 10000
forge coverage --report summary

Предварительное ревью с помощью ИИ, например наш Smart Contract AI Reviewer, — ещё один быстрый первый проход, выявляющий подозрительные паттерны до того, как код увидит человек.

12. Развёртывание и верификация#

  • Описывайте развёртывание скриптами Foundry, а не ручными транзакциями. Параметры хранятся в конфигурационных файлах под версионным контролем и проходят ревью как код.
  • Сначала разверните в тестнете тем же скриптом и с теми же параметрами, затем выполните пробный прогон на форке мейннета.
  • Верифицируйте исходный код в блок-эксплорере сразу после развёртывания — с той же версией компилятора и настройками оптимизатора.
  • Дважды проверьте десятичные знаки: аллокация в 1 000 000 токенов с 18 знаками — это 1_000_000e18, а не 1_000_000.
  • Передавайте владение мультисигу в том же скрипте и убедитесь, что у ключа деплоера не осталось ролей.

13. Эксплуатационные проверки#

  • Регулярно сверяйте: сумма аллокаций по графикам минус выплаты должна совпадать с зарезервированной суммой и балансом контракта.
  • Мониторьте события и настраивайте оповещения о неожиданных отзывах, изменениях ролей или паузах.
  • Ведите публичный или ориентированный на инвесторов обзор графиков, чтобы на вопросы можно было отвечать ончейн-данными.
  • Репетируйте ключевые процедуры: ротацию подписантов мультисига, действия при потере бенефициаром доступа к кошельку и то, как будет сообщаться о паузе.

Заключение#

Чек-лист и автоматизированное ревью выявляют многие проблемы на раннем этапе, но не заменяют независимый аудит безопасности. Прежде чем контракт вестинга будет хранить реальную ценность, пусть его проверят люди, которые его не писали.

Если хотите увидеть, как мы подходим к этому на практике, посмотрите наш кейс token suite, который включает контракт вестинга с тестами, или почитайте подробнее о наших блокчейн-услугах. Нашей командой руководит техлид с более чем 20-летним опытом, и мы с удовольствием проверим вашу токеномику или дизайн вестинга: просто свяжитесь с нами.

Есть проект в голове?

Расскажите, что строите. В рабочие дни senior-инженер отвечает в течение часа — затем вы получаете честную оценку, понятный объём и предложение с фиксированной ценой или по вехам, обычно за несколько рабочих дней.

Сначала написать? Напишите нам

Вы будете говорить напрямую с Ing. Ismet Mesic, Tech lead.