К основному содержимому

Введите слово из вопроса или названия статьи.

3 · Под капотом

Тестирование смарт-контрактов: unit, fuzz, инварианты и статический анализ

Как проверяют контракты с деньгами: юнит-тесты, фаззинг, инварианты платёжеспособности, «злые» моки токенов, Slither и Mythril на примере эскроу с Foundry.

5 мин чтенияОбновлено:
В этой статье

Контракт с деньгами нельзя «выпустить и починить потом»: после развёртывания код нельзя исправить, а ошибка стоит реальных средств. Поэтому на тестирование уходит больше времени, чем на написание кода. Разберём слои проверки на примере нашего эскроу - это инструментарий Foundry, статические анализаторы и методика мышления.

Слой 1. Юнит-тесты

Самый базовый слой - тест на каждый сценарий и каждую ошибку. В Foundry тесты пишутся на Solidity. Ниже - упрощённые примеры в духе наших тестов:

solidity
function test_ConfirmDelivery_PaysSellerMinusFees() public {
    uint256 id = _createAndAccept(1_000e6);   // сделка на 1000 USDT

    vm.prank(buyer);
    escrow.confirmDelivery(id);

    // платформа 1% = 10, гарант 1% от 990 = 9.9, продавцу 980.1
    assertEq(usdt.balanceOf(seller), 980_100_000);
    assertEq(usdt.balanceOf(arbiter), 9_900_000);
}

function test_ConfirmDelivery_RevertsForNonBuyer() public {
    uint256 id = _createAndAccept(1_000e6);

    vm.prank(seller);
    vm.expectRevert(Escrow.NotAuthorized.selector);
    escrow.confirmDelivery(id);
}

Читайте их как спецификацию: что должно произойти и что не должно. Чем больше «отрицательных» тестов - «вызывающий не тот», «статус не тот», «сумма нулевая», - тем надёжнее контракт.

Слой 2. Фаззинг (fuzz-тесты)

Человек придумывает ограниченное число случаев. Фаззер сам генерирует сотни случайных входных значений и ищет такое, на котором тест упадёт. В Foundry достаточно объявить аргументы у тестовой функции:

solidity
function testFuzz_SplitPartsSumToWhole(
    uint96 amount, uint16 arbiterBps, uint16 buyerBps
) public view {
    arbiterBps = uint16(bound(arbiterBps, 0, 3000));   // допустимые ставки
    buyerBps   = uint16(bound(buyerBps, 0, 10_000));

    (uint256 b, uint256 s, uint256 a, uint256 p) = harness.split(amount, arbiterBps, buyerBps);

    assertEq(b + s + a + p, amount);   // части всегда равны целому
}

Именно фаззинг ловит краевые случаи: суммы в 1 wei, максимальные значения, округления. В нашем проекте каждый такой тест выполняется 512 раз на случайных данных при каждом запуске.

Слой 3. Инварианты

Фаззинг проверяет одну функцию. Но реальные ошибки живут во взаимодействии функций: «создали, приняли, оспорили, отменили, вывели…». Инвариантный тест задаёт фаззеру набор допустимых действий и случайно вызывает их в случайном порядке и с разными участниками, после каждого шага проверяя неизменные свойства системы.

Главный инвариант эскроу - платёжеспособность:

solidity
function invariant_ContractIsSolvent() public view {
    assertGe(token.balanceOf(address(escrow)), escrow.reserved(address(token)));
}

«Баланс контракта никогда не меньше суммы всех его обязательств». Что бы ни делали участники, в каком бы порядке ни вызывали функции, это должно оставаться правдой. Наши инвариантные тесты прогоняют по 256 серий глубиной 128 вызовов - десятки тысяч случайных действий.

Слой 4. «Злые» окружения

Чтобы проверить защиту от нестандартных токенов и получателей, пишутся моки - подставные контракты с враждебным поведением:

  • токен без возвращаемого bool (как USDT);
  • токен с комиссией при переводе;
  • токен с чёрным списком адресов;
  • получатель с receive(), который откатывает приём или сжигает газ;
  • получатель, повторно входящий в контракт (проверка реентрантности).

Если эскроу пережил все эти моки, значит защита из статьи про нестандартные токены работает не на бумаге.

Слой 5. Регрессии и эксплойты

Когда нашлась ошибка, её сначала воспроизводят тестом, который падает, затем исправляют код, и тест остаётся навсегда. Так ошибка не вернётся. В нашем репозитории есть и тесты на уязвимости первой версии контракта, выполняемые прямо на EVM: они наглядно показывают, что именно было сломано и как это исправлено.

Слой 6. Статический анализ

Анализаторы читают код, не запуская его, и ищут известные шаблоны ошибок.

Slither - быстрый анализатор: находит реентрантность, неинициализированные переменные, опасные вызовы, нарушения порядка действий. Выдаёт много находок, из которых часть - ложные срабатывания. Каждую находку нужно разобрать и обосновать: исправить или объяснить, почему она безопасна.

Mythril - символьное исполнение: вместо конкретных чисел он исследует пути выполнения с «абстрактными» значениями и ищет входы, ломающие код. Медленнее, но находит то, что пропускают другие.

Результаты разбора записаны в репозитории - это документ, по которому видно ход мысли, а не просто чек «прошло».

Слой 7. Газ и проверка байткода

Отдельные тесты измеряют газ ключевых операций, чтобы случайная правка не сделала сервис дороже. А скрипт проверки опкодов гарантирует, что в байткоде нет инструкций новее выбранной версии EVM: иначе контракт не заработает в старых сетях (подробнее в статье про CREATE2).

Что тесты не доказывают

Честно о границах.

  • Тесты не доказывают отсутствие ошибок. Они показывают, что проверенные сценарии работают. Непредусмотренные могут скрываться.
  • Инструменты ограничены. Статический анализ не понимает бизнес-логику: он не заметит, что гарант получает не ту сумму, если код корректен технически.
  • Спецификация может быть ошибочной. Тесты проверяют, что код делает то, что в них записано. Если требования неверны, неверными окажутся и тесты.

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

Как мыслить при проектировании

Хороший контракт начинается с вопроса не «как это написать», а «что должно быть всегда истинно». Из таких утверждений получаются инварианты, из инвариантов - тесты, из тестов - уверенность.

Примеры утверждений для эскроу:

  • выплата по сделке происходит ровно один раз;
  • сумма выплат всегда равна сумме сделки;
  • платформа не может забрать больше накопленных комиссий;
  • гарант не может быть участником сделки;
  • из состояния «Завершена» нельзя выйти.

Что дальше

Тесты не заменяют независимой проверки: аудиты и баг-баунти. Для практики - руководство по Foundry. Вы прошли всю траекторию: от «что такое блокчейн» до устройства виртуальной машины, кода эскроу и методов проверки. Теперь самое время применить знания на практике: прочитайте инструкции по ролям и создайте свою первую сделку.

Упоминается в статьях

Готовы попробовать?

Создайте первую сделку за пару минут. Деньги блокируются в смарт-контракте и выплачиваются по правилам, которые видны заранее.