Перейти к содержанию

Тестирование смарт-контрактов: Foundry, fuzzing и безопасность

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

Мы разберём структуру будущей лаборатории на Foundry, разные виды проверок и порядок анализа безопасности. Примеры ниже подготовлены для локального проекта; тесты и контракт при написании материала не запускались. Результаты сформулированы как ожидания, а не как отчёт об успешном исполнении.

Определяем объекты и полномочия

Для нашего реестра ценность представляет достоверная связь номера курса с объявленным выпуском. Неизвестный посетитель может читать сведения, но не должен подменять отпечаток или выключать курс. Издатель может обновлять публикацию, однако действие должно оставлять наблюдаемое событие. Другой контракт, который вызывает реестр, также не получает полномочий автоматически.

В такой модели есть три набора данных: состояние реестра, адрес издателя и история событий. Проверять только возвращаемое значение courses(17) недостаточно. Нужно сопоставить результат с разрешённым отправителем, предыдущим состоянием и событием, которое увидит индексатор сайта.

Требование Почему оно важно Какой результат ожидаем
Запись меняет только издатель Защита от подмены публикации Чужой адрес получает откат
Номер и отпечаток ненулевые Нет двусмысленной пустой записи Неверные аргументы отклоняются
Деактивация сохраняет отпечаток Можно объяснить последний выпуск Меняется только флаг
Повторная публикация разрешена Исправление материала Новый отпечаток становится текущим
Событие соответствует записи Индексатор строит понятную историю Номер и отпечаток совпадают

Таблица показывает и продуктовые решения. Например, если читателю нужна неизменяемая запись каждого выпуска, текущий контракт не удовлетворяет требованию: он хранит один последний отпечаток. Это не обязательно ошибка Solidity. Возможно, нужно изменить модель данных, добавить номер версии и только после этого писать проверки новой модели.

Remix, Foundry и Hardhat решают разные задачи

Remix удобен для первого ручного знакомства с вызовами. Foundry позволяет держать контракт, проверки и настройки в файловом проекте и исследовать поведение на Solidity. Hardhat полезен в проектах, где контракт тесно связан с TypeScript-интеграцией. Выбор инструмента не меняет требований к реестру: чужой отправитель всё равно не должен менять запись.

В Hardhat 3 есть поддержка Solidity-проверок в стиле Foundry и TypeScript-проверок. Однако конфигурация плагинов и используемый клиент отличаются от многих курсов Hardhat 2. Нужно выбрать профиль, runner и библиотеку — например viem — и согласовать их версии. Нельзя переносить старый набор плагинов и утверждений ethers в другой проект только из-за совпадения слова Hardhat. Руководство Hardhat по viem, изменения Hardhat 3.

В нашей первой линии основным инструментом остаётся Foundry: это сохраняет связь с существующей лабораторией сертификатов. Отдельный урок сравнения сможет реализовать одни требования двумя способами и показать различия окружения. Ни один из этих инструментов при подготовке материала не запускался.

Что дают unit-проверки

Unit-проверка выбирает определённый сценарий. Она создаёт новый экземпляр, задаёт отправителя, выполняет действие и сравнивает результат с ожиданием. Начнём с трёх сценариев: публикация издателем, попытка публикации читателем и деактивация. Для каждого указываем исходное состояние; порядок выполнения других проверок не должен быть скрытым условием.

Foundry позволяет писать такие проверки на Solidity. Библиотека forge-std предоставляет базовый класс Test, утверждения и специальные средства управления учебной EVM. Например, vm.prank задаёт отправителя следующего вызова, а vm.expectRevert описывает ожидаемый откат. Их действие относится к среде проверки, а не к функциям, которые появляются у посетителя сайта.

Ниже — самостоятельный файл проверки для контракта из предыдущего материала. Он предполагает, что CourseCatalog.sol расположен в src, а совместимая зафиксированная версия forge-std уже добавлена в проект.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

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

contract CourseCatalogTest is Test {
    CourseCatalog internal catalog;
    address internal publisher = address(0xA11CE);
    address internal reader = address(0xB0B);

    function setUp() public {
        catalog = new CourseCatalog(publisher);
    }

    function testReaderCannotPublish() public {
        bytes32 digest = keccak256("edition-one");
        vm.expectRevert(CourseCatalog.Unauthorized.selector);
        vm.prank(reader);
        catalog.publish(17, digest);
    }

    function testDeactivateKeepsDigest() public {
        bytes32 digest = keccak256("edition-one");
        vm.prank(publisher);
        catalog.publish(17, digest);

        vm.prank(publisher);
        catalog.deactivate(17);

        (bytes32 storedDigest, bool active) = catalog.courses(17);
        assertEq(storedDigest, digest);
        assertFalse(active);
    }
}

Адреса здесь задают учебные роли; они не являются ключами реальных пользователей. Перед вторым вызовом мы снова задаём издателя: первый prank уже использован. Если забыть об этом, проверка может завершиться откатом из-за отправителя, а автор ошибочно начнёт исправлять деактивацию.

keccak256("edition-one") создаёт удобные данные проверки. Это не установленный формат хеширования нашего будущего выпуска статьи. В прикладном контракте алгоритм и каноническое представление документа определяются отдельно. Такая разница помогает не смешивать фикстуру проверки с бизнес-протоколом.

Fuzzing меняет входы

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

function testFuzzPublish(uint256 courseId, bytes32 digest) public {
    vm.assume(courseId != 0);
    vm.assume(digest != bytes32(0));

    vm.prank(publisher);
    catalog.publish(courseId, digest);

    (bytes32 storedDigest, bool active) = catalog.courses(courseId);
    assertEq(storedDigest, digest);
    assertTrue(active);
}

Этот фрагмент добавляется внутрь CourseCatalogTest. Предположения исключают ровно два недопустимых значения. Если же отбросить почти весь диапазон, генератор будет тратить время на неподходящие входы. Для ограниченных чисел часто удобнее преобразовать вход в нужные границы, но преобразование нужно понимать: оно задаёт исследуемую область.

Отдельные проверки должны покрывать отклонённые значения. Иначе assume превратится в способ спрятать важное поведение. Также полезно явно задавать граничные случаи: минимальный допустимый номер, максимальное число и повторный ключ. Генерация не заменяет продуманного выбора примеров.

Успешный fuzz-прогон означает, что нарушение не найдено в использованной выборке. Он не перебирает практически все значения uint256 и не является доказательством корректности. Если ошибка проявляется только после нескольких связанных действий, проверка одного вызова может вообще не создавать необходимое состояние.

Инварианты меняют последовательность

Stateful-проверка исследует последовательности действий. Для нашего реестра это публикация, обновление, деактивация и ещё одна публикация. Инвариант формулируется как свойство состояния, которое должно выполняться после разрешённых переходов. Например: активная запись имеет ненулевой отпечаток; чужой отправитель не может изменить опубликованные сведения.

Простого имени invariant_... недостаточно для хорошей проверки. Нужно решить, какие контракты и функции доступны генератору, какие адреса он использует и как отслеживается множество созданных курсов. Если проверяющая функция смотрит только на номер 17, а генератор записывает другие номера, большая часть поведения останется невидимой.

Для более сложной модели создают handler — учебный посредник, который превращает сгенерированные входы в осмысленные действия. Он знает роли, выбирает существующие номера для деактивации и сохраняет собственную модель ожидаемого состояния. В Foundry задают цели через механизмы вроде targetContract; иначе можно случайно генерировать вызовы к объектам, не относящимся к исследуемому маршруту.

Handler тоже способен скрыть ошибку. Если он никогда не вызывает действие читателем, проверка полномочий отсутствует. Если он запрещает повторную публикацию, важная ветка контракта не исследуется. Следует записать, какое пространство действий разрешено генератору, и посмотреть статистику вызовов и откатов. Очень много откатов при отсутствии полезных изменений может объяснить красивый, но малоинформативный результат.

Параметры числа запусков, глубины последовательности и отношения к откатам относятся к конфигурации эксперимента. Значение fail_on_revert выбирают осознанно: иногда отклонённый вызов ожидаем, иногда он сигнализирует ошибку модели. Эти параметры не являются универсальной оценкой безопасности. Актуальный порядок настройки описан в руководстве Foundry по инвариантам.

Добавляем анализ безопасности

Когда появляются платежи, токены и другие контракты, расширяется модель угроз. Для каждого внешнего вызова нужно выяснить, может ли получатель вернуть управление, изменить ожидаемый порядок действий или вернуть нестандартный результат. Разделение проверок условий, изменений состояния и внешнего взаимодействия помогает рассуждать о повторном входе, но не отменяет анализа всех связанных функций.

Особенно внимательно рассматриваем следующие места:

Список нужен для разбора конкретного кода, а не для механического проставления галочек. Например, в нашем первом реестре нет внешнего вызова, который выплачивает деньги. Добавлять защиту от повторного входа ради самого названия приёма не объясняет текущую угрозу. Зато неизменяемый издатель требует ясно описать последствия потери его ключа.

Уязвимые учебные задачи удобно решать на локальной копии или специально предназначенной лаборатории. Переносить действия на чужой действующий контракт нельзя считать продолжением упражнения. Нам нужен воспроизводимый разбор собственного примера: исходное требование, минимальная последовательность нарушения, исправление и проверка, которая отличает исправленный вариант от ошибочного.

Что фиксируем перед выпуском

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

В будущий отчёт включаем выполненные сценарии, найденные ошибки и границы модели. Отдельно отмечаем, чего не проверяли: экономическую устойчивость, сторонний мост, права реального администратора или интерфейс кошелька. Это позволяет следующему разработчику продолжить исследование и не принять неполный результат за гарантию.

Для нашего материала пока определены требования и примеры проверок, а результаты исполнения отсутствуют. Следующий этап — браузерный маршрут публикации: он должен отличать отказ пользователя, откат исполнения и задержку подтверждения. Дополнительные объяснения доступны в документации Foundry и рекомендациях Solidity по безопасности. Общая программа находится в оглавлении Web3.