Статьи
Чек-лист безопасности для контрактов вестинга ERC-20
Математика вестинга, отзыв прав, ключи администратора, работа с токенами, инвариант-тесты Foundry и проверки при развёртывании для безопасных контрактов вестинга ERC-20.
Инженерная команда sigmacode.io9 мин чтения
На этой странице (14)
- 1. Математика вестинга
- 2. Бенефициары и отзыв прав
- 3. Контроль доступа и ключи администратора
- 4. Обработка переводов токенов
- 5. Реентерабельность и порядок вызовов
- 6. Метки времени
- 7. События и прозрачность
- 8. Компромиссы обновляемости
- 9. Аварийные механизмы
- 10. Тестирование
- 11. Статический анализ
- 12. Развёртывание и верификация
- 13. Эксплуатационные проверки
- Заключение
Контракты вестинга выглядят просто: заблокировать токены, постепенно их разблокировать — и готово. На практике они годами хранят значительную часть эмиссии проекта, с ними взаимодействуют основатели, инвесторы, сотрудники и мультисиги, а после развёртывания к ним почти никогда не возвращаются. Небольшая ошибка в расчётах или в модели прав остаётся в сети на весь период вестинга. Этот чек-лист собирает вопросы, которые мы задаём при проектировании или ревью контракта вестинга 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далеко в будущем может оказаться опечаткой (миллисекунды вместо секунд — классика). Добавьте проверки разумных границ в конструктор или фабрику и в скрипт развёртывания.
Компактная эталонная реализация графика:
// 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 или вредоносный токен могут реентерабельно вызвать контракт. - Сведите внешние вызовы к минимуму. У контракта вестинга нет причин вызывать произвольные адреса.
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. - Отозванный график впоследствии никогда не увеличивает разблокированную сумму.
// 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 при каждом изменении и рассматривайте его вывод как очередь на ревью, а не как сигнал «пройдено/не пройдено». Для контрактов вестинга обращайте внимание на находки по реентерабельности, непроверенные переводы, опасные строгие равенства по балансам и отсутствующие события.
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-летним опытом, и мы с удовольствием проверим вашу токеномику или дизайн вестинга: просто свяжитесь с нами.