Foundry: откаты и полезная работа генератора
В предыдущем уроке появился сильный инвариант ожидаемого выпуска. Теперь проверим, выполняет ли генератор полезные действия и как кампании следует относиться к отказам. Согласованная пустая модель ещё не означает, что публикации исследованы.
Добавим счётчики категорий в handler и явно зададим fail_on_revert. Числа будут получаться только при будущем исполнении; здесь нет фактической статистики и отчёта о найденных ошибках. Подготовка нужна, чтобы будущий результат можно было оценить по действиям, а не по одному статусу завершения.
Два уровня отказа
Читатель должен получать Unauthorized от реестра. Handler заранее ожидает эту ошибку, проверяет неизменность и завершает своё действие нормально. Это содержательный отрицательный сценарий. Он не должен считаться неожиданным откатом всей проверяющей программы.
Если разрешённая публикация неожиданно отклоняется либо чужое действие не получает нужной ошибки, handler не выполняет свой договор. Такой сбой должен остановить исследование как нарушение. Поэтому для нашей подготовленной поверхности выбираем строгую политику неожиданного отката.
[invariant]
runs = 128
depth = 64
fail_on_revert = true
Этот раздел добавляется в foundry.toml; он не заменяет ранее зафиксированный [profile.default]. Число историй и глубина являются выбранным будущим бюджетом, а не результатом состоявшегося прогона. В следующем уроке подробнее разберём, что именно ограничивают эти параметры.
Почему игнорирование может скрывать проблему
Если политика допускает неожиданные откаты handler, повреждённая реализация, отвергающая все разрешённые публикации, может оставлять реестр и модель пустыми. Инвариант согласованности продолжает выполняться, потому что после отказа состояние не меняется. Но нужная функция библиотеки не работает.
Строгая политика не делает отрицательные проверки невозможными. Ожидаемый отказ реестра обработан через expectRevert и не считается штатным откатом внешнего метода handler. Различаем ожидаемую прикладную ошибку внутри действия и провал проверяющей программы целиком.
Если handler намеренно посылает любые случайные недопустимые данные напрямую без ожидания, строгая политика будет шумной. В нашей модели допустимые действия подготовлены, запрещённые имеют точное ожидание, а неактивное разрешённое отключение явно пропускается. Политика соответствует именно этой поверхности.
Считаем категории отдельно
Добавим в CatalogHandler пять публичных счётчиков. Getter каждого используется для наблюдения и не включён в массив генерируемых селекторов. Значения начинаются с нуля вместе с новой моделью каждой истории.
uint256 public attempts;
uint256 public publications;
uint256 public deactivations;
uint256 public skipped;
uint256 public rejected;
attempts увеличивается в начале каждого из четырёх целевых методов. Разрешённая публикация увеличивает publications только после успешного вызова и обновления модели. Отключение увеличивает deactivations после соответствующего завершённого перехода. Ранний пропуск получает отдельную категорию skipped.
Для publishAsReader и deactivateAsReader увеличиваем rejected после проверки ожидаемой ошибки и двух утверждений неизменности. Таким образом, счётчик означает завершённый отрицательный сценарий, а не любую попытку, случайно приведшую к какой-либо ошибке.
Изменяем разрешённое отключение
Следующая функция заменяет deactivate из предыдущего урока. Обратите внимание на увеличение пропуска до раннего завершения и полезного перехода после изменения модели. Эти позиции определяют смысл будущей статистики.
function deactivate(uint256 rawId) external {
++attempts;
uint256 id = bound(rawId, 1, 20);
if (!expected[id].active) {
++skipped;
return;
}
vm.prank(publisher);
catalog.deactivate(id);
expected[id].active = false;
++deactivations;
}
В начале publish аналогично добавляется ++attempts, а после expected[id] = ExpectedEdition(digest, true) — ++publications. В обеих чужих функциях ставится ++attempts в начале и ++rejected после сравнений. Другие строки и условия этих методов не меняются.
Повторная публикация того же digest считается выполненным вызовом публикации, даже если поля численно остаются прежними. Поэтому publications не следует называть количеством уникальных выпусков или фактических изменений каждого байта storage. Имя категории должно соответствовать тому, что код действительно считает.
Сверяем внутренний учёт
В invariant-файле добавим ещё одно свойство. Для каждого нормально завершённого действия handler должна быть ровно одна категория результата. Если автор забудет увеличить счётчик на одной ветви или увеличит две категории, равенство обнаружит несогласованность учёта.
function invariantActionAccountingIsConsistent() public view {
uint256 completed = handler.publications()
+ handler.deactivations()
+ handler.skipped()
+ handler.rejected();
assertEq(handler.attempts(), completed);
}
Этот инвариант относится к проверяющей программе, а не к безопасности реестра. Его полезность состоит в достоверности интерпретации будущего отчёта. Если все счётчики равны нулю, равенство тоже выполняется, поэтому оно не заменяет анализ содержательной работы кампании.
Не вводим без объяснения утверждение «должно быть хотя бы десять отключений». Случайная история может выбрать иной набор действий и нарушить такой произвольный порог при правильном контракте. Для обязательного маршрута лучше сохранить детерминированный сценарий, а метрики генерации оценивать вместе с настройками и выбором целей.
Откат возвращает и вспомогательные изменения
Счётчики являются обычным состоянием handler. Если его внешний вызов неожиданно откатывается, сделанное в начале увеличение attempts тоже может откатиться вместе с остальными изменениями. Поэтому нельзя рассчитывать, что одно это поле надёжно сохранит число всех провалов, когда политика кампании их игнорирует. Строгий отказ и внешний отчёт инструмента остаются необходимыми.
С другой стороны, ожидаемый откат внутреннего вызова реестра не отменяет нормально завершившийся внешний метод handler. После проверки ошибки увеличивается rejected, и эта категория остаётся частью учёта истории. Разница определяется границей вызова, а не словом «ошибка» в человеческом описании.
Не следует объединять статистику разных историй обычным предположением, что storage счётчиков сохраняется между ними. Подготовка новой истории задаёт своё начальное состояние. Для итогового отчёта нужно использовать данные выбранной версии инструмента или специально описанное внешнее агрегирование. Наши поля прежде всего характеризуют наблюдаемую историю и согласованность её действий, а не обещают автоматически накопленный отчёт всего проекта.
Читаем будущие числа правильно
Если доля skipped велика, генератор часто пытается отключить ещё неактивные номера. Это повод улучшить выбор истории или добавить отдельный маршрут существующего курса, а не объявлять множество пропусков полноценными деактивациями. Однако изменение генератора должно сохранять разнообразие состояний и явную область.
Если rejected велико, это может означать множество проверенных чужих попыток, но не глубокое исследование замен выпуска. Нужны также разрешённые публикации и отключения. Категории позволяют увидеть перекос и не смешивать разные виды полезной работы в одном общем числе.
Поскольку всё пока локально и не исполнялось, счётчики остаются только определёнными полями исходника. В следующем уроке зададим воспроизводимый бюджет и порядок сохранения результатов. Политика откатов описана в руководстве Foundry по инвариантам. Оглавление курса.