Foundry Handler: генерация действий публикации
В предыдущем уроке мы выбрали целевые методы, но случайные большие ключи плохо складывались в жизненный цикл одного курса. Теперь поставим между генератором и реестром handler: небольшую проверяющую программу, которая превращает входы в понятные прикладные действия.
Создадим test/handlers/CatalogHandler.sol и изменим подготовку invariant-файла. Handler будет работать с номерами 1..20, включая наш курс 17, и вызывать реестр от издателя. Код не исполнялся; количество реально достигнутых переходов пока не измерено.
Два уровня действий
Генератор выбирает метод handler и его параметры. Затем handler подготавливает допустимый номер, задаёт роль и обращается к настоящему CourseCatalog. Прикладное состояние хранится в реестре; посредник не становится новой реализацией каталога и не заменяет его проверки доступа.
Такой слой полезен, когда случайный ABI-вход редко достигает интересующего состояния. Но он также может скрывать ошибки, если слишком многое исправляет или молча пропускает. Поэтому каждое преобразование и условие должны иметь явную причину, а исключённые отрицательные случаи остаются в обычных сценариях.
Начнём с публикации допустимого выпуска и отключения активной записи. Нулевой отпечаток преобразуем в маленькую ненулевую фикстуру, поскольку здесь задача — построить историю, а не исследовать валидатор. Общая fuzz-проверка нулевого входа не удаляется и не заменяется этим преобразованием.
Сохраняем полный начальный handler
Импорт прикладного контракта содержит два перехода вверх, потому что файл расположен внутри test/handlers. Поля конфигурации задаются конструктором. Передаваемый издатель должен совпадать с catalog.publisher(); эту согласованность мы обеспечиваем подготовкой invariant-файла.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {Test} from "forge-std/Test.sol";
import {CourseCatalog} from "../../src/CourseCatalog.sol";
contract CatalogHandler is Test {
CourseCatalog public immutable catalog;
address public immutable publisher;
constructor(CourseCatalog catalog_, address publisher_) {
catalog = catalog_;
publisher = publisher_;
}
function publish(uint256 rawId, bytes32 rawDigest) external {
uint256 id = bound(rawId, 1, 20);
bytes32 digest = rawDigest == bytes32(0)
? bytes32(uint256(1))
: rawDigest;
vm.prank(publisher);
catalog.publish(id, digest);
}
function deactivate(uint256 rawId) external {
uint256 id = bound(rawId, 1, 20);
(, bool active) = catalog.courses(id);
if (!active) return;
vm.prank(publisher);
catalog.deactivate(id);
}
}
Прямая публикация всегда получает допустимые данные в рамках текущего договора. Если она неожиданно отклоняется, handler не перехватывает отказ и не объявляет его нормальным результатом. Политика обработки таких отказов кампанией будет отдельно обсуждаться в двадцать втором уроке.
Отключение пока читает активность реестра и пропускает неактивный номер. Это начальный способ не создавать заведомо запрещённые действия. В двадцать первом уроке заменим основание выбора независимой моделью, чтобы ошибочный флаг реализации не мог определять всю ожидаемую историю.
Перенаправляем генератор
В CourseCatalogInvariantTest добавим импорт handler и поле CatalogHandler internal handler. Затем заменим setUp целиком. Старый targetSender(publisher) здесь не нужен: роль прикладного вызова явно задаёт сам handler через prank.
function setUp() public {
catalog = new CourseCatalog(publisher);
handler = new CatalogHandler(catalog, publisher);
bytes4[] memory selectors = new bytes4[](2);
selectors[0] = CatalogHandler.publish.selector;
selectors[1] = CatalogHandler.deactivate.selector;
targetContract(address(handler));
targetSelector(FuzzSelector({
addr: address(handler),
selectors: selectors
}));
}
Импорт из invariant-файла имеет путь ./handlers/CatalogHandler.sol. Адрес target теперь относится к посреднику, а функции инвариантов продолжают читать настоящий реестр. Это разделение уровней важно: цель действий и объект наблюдения больше не совпадают автоматически.
Список селекторов ограничен двумя прикладными методами. Handler наследует вспомогательные средства и предоставляет getter полей, но они не являются действиями истории. Явное ограничение сохраняет малую поверхность генерации и делает её понятной при чтении исходника.
Расширяем наблюдение на все выбранные номера
Проверку только 17 заменим циклом по ограниченной области. Внутри файла остаётся отдельный инвариант издателя из семнадцатого урока. Старую функцию с одиночным номером можно удалить, чтобы не создавать впечатление двух разных областей исследования.
function invariantActiveCoursesHaveDigests() public view {
for (uint256 id = 1; id <= 20; ++id) {
(bytes32 digest, bool active) = catalog.courses(id);
if (active) assertTrue(digest != bytes32(0));
}
}
Этот цикл охватывает все номера, которые создаёт handler. Он не охватывает весь uint256, и контракт не получил соответствующего ограничения. Проверка больших ключей остаётся в отдельных unit- и fuzz-сценариях. Мы выбрали малую область ради связных последовательностей, а не изменили программу под инструмент.
Разбираем будущую историю
Представим выбранные действия: публикация 17 с первым хешем, публикация 18, отключение 17, повторная публикация 17 со вторым хешем. В отличие от прямых случайных огромных ключей, ограниченная область позволяет повторно попадать в уже существующие записи и строить их жизненный цикл.
Если генератор первым выберет отключение пустого 17, метод handler завершится без прикладного вызова. Это осознанно пропущенное действие, которое нельзя считать успешно выполненной деактивацией. Позднее счётчики помогут отличить такие попытки от полезной работы и оценить фактическую кампанию.
При повторной публикации того же хеша действие всё равно допустимо: контракт заново записывает активную публикацию и испускает событие. Мы не вводим условие отличия digest, которого нет в требованиях. Состояние может остаться численно тем же, но прикладной вызов отличается от раннего return.
Конфигурация посредника тоже требует внимания
Handler получает экземпляр реестра и адрес издателя отдельно. Если при подготовке передать другой адрес, все разрешённые действия начнут отклоняться. Это будет ошибка лаборатории, а не найденное нарушение прикладной программы. Поэтому в итоговом комплекте конфигурацию связываем с getter издателя и сохраняем рядом с целевыми селекторами.
Нельзя также создать два handler для одного реестра, если каждый позднее хранит независимую модель выпуска. Изменения первого будут неизвестны второму, и сравнение покажет рассогласование наблюдателей. Для данной кампании используется один посредник, через который проходят все рассматриваемые действия. Отдельные внешние изменения требуют другой модели и не включаются незаметно.
Преобразованный ключ нужно использовать согласованно и в действии, и в наблюдении. Возврат к rawId после bound может создать ложное ощущение повреждения mapping. Поэтому имена различают случайную величину и прикладной номер. При будущем воспроизведении истории сохраняем метод, исходные аргументы, полученный ключ и ожидаемое состояние до вызова. Такой контекст намного полезнее, чем один большой случайный номер без указания преобразования.
Handler не подменяет угрозы
Текущий посредник действует только издателем. Он не исследует чужую запись, нулевые данные и повторное отключение неактивной записи. Все эти случаи уже имеют отдельные сценарии. В следующем уроке добавим в историю запрещённые попытки, сохраняя различие между ожидаемой ошибкой и неожиданным сбоем разрешённого действия.
Также пока не сравнивается точный хеш последнего выпуска: инвариант проверяет только форму активной записи. Независимая модель станет следующим усилением. Handler полезен как механизм построения истории, однако сам по себе не делает слабое наблюдаемое свойство сильным.
В следующем уроке добавим чужого отправителя. Подход к handler описан в руководстве Foundry, функция ограничения диапазона — в StdUtils 1.9.7. Оглавление курса.