Безопасность Solidity: защита редакционного намерения
В предыдущем уроке два разрешённых действия дали разные результаты в зависимости от порядка. Теперь выразим редакционное намерение точнее: издатель хочет изменить именно ту ревизию, которую видел при подготовке. Если состояние уже изменилось, операция должна отклониться и предложить повторный просмотр, а не применяться к неизвестному выпуску.
Создадим отдельный GuardedCourseCatalog. Это новый API, а базовый CourseCatalog всех предыдущих курсов сохраняет прежнюю семантику. Solidity 0.8.30, локальный пример курса 17, никаких транзакций или проверок сейчас не выполняется.
Почему проверяем ревизию
Можно сравнить только ожидаемый contentHash, но это не различит путь H1→H2→H1. Итоговый отпечаток снова равен исходному, хотя между ними произошли изменения. Такой случай известен как возвращение к прежнему значению, и для точного намерения нужна отдельная версия состояния.
Введём монотонную ревизию для каждого курса. Публикация и деактивация увеличивают её; начальное значение равно нулю. Издатель передаёт expectedRevision, прочитанный из текущего состояния. Ревизия относится к id курса, поэтому изменение другого id не создаёт ненужного конфликта.
Не сбрасываем ревизию при деактивации и не используем delete для полной очистки записи. Иначе прежнее разрешение могло бы снова совпасть с начальным значением. Рост счётчика в checked arithmetic задаёт явную границу; практически достижимые данные не оправдывают скрытый переход через максимум.
Записываем новый договор в исходник
Контракт проверяет издателя, срок, поля и ревизию. Новая функция деактивации тоже принимает ожидаемую версию. Нельзя защитить только публикацию, оставив другой путь изменения той же записи без условия.
pragma solidity 0.8.30;
contract GuardedCourseCatalog {
struct Course { bytes32 contentHash; bool active; uint256 revision; }
mapping(uint256 => Course) public courses;
address public immutable publisher;
error Unauthorized();
error InvalidCourse();
error StaleRevision();
error Expired();
error NotActive();
event Changed(uint256 indexed id, bytes32 hash, bool active, uint256 revision);
constructor(address publisher_) {
require(publisher_ != address(0), "publisher required");
publisher = publisher_;
}
function publish(uint256 id, bytes32 hash,
uint256 expectedRevision, uint256 deadline) external
{
if (msg.sender != publisher) revert Unauthorized();
if (block.timestamp > deadline) revert Expired();
if (id == 0 || hash == bytes32(0)) revert InvalidCourse();
Course storage course = courses[id];
if (course.revision != expectedRevision) revert StaleRevision();
course.contentHash = hash;
course.active = true;
course.revision += 1;
emit Changed(id, hash, true, course.revision);
}
function deactivate(uint256 id, uint256 expectedRevision,
uint256 deadline) external
{
if (msg.sender != publisher) revert Unauthorized();
if (block.timestamp > deadline) revert Expired();
Course storage course = courses[id];
if (course.revision != expectedRevision) revert StaleRevision();
if (!course.active) revert NotActive();
course.active = false;
course.revision += 1;
emit Changed(id, course.contentHash, false, course.revision);
}
}
В новой модели getter возвращает три поля. Фронтенд обязан использовать её отдельный ABI и адрес, а не читать ответ как прежнюю пару полей. Событие тоже другое и явно сообщает ревизию. Изменение интерфейса не скрывается внутри похожего названия файла.
Разбираем конкурентное изменение
Условное исходное состояние: H1, активен, ревизия четыре. Две операции подготовлены с expectedRevision=4. Первая публикация H2 проходит и создаёт ревизию пять. Вторая деактивация получает StaleRevision, независимо от того, была ли она подписана раньше первой.
В обратном порядке первой выполнится деактивация и создаст ревизию пять. Публикация с ожиданием четыре отклонится. Так обе последовательности имеют один принятый переход и один объяснимый отказ, вместо молчаливого изменения уже другого выпуска.
Отклонение не является автоматическим разрешением серверу заменить ожидаемую ревизию на пять. Это изменило бы исходное намерение. Издатель должен перечитать выпуск, понять новый контекст и подготовить отдельное действие по новой версии состояния.
Что защищает срок
Дедлайн ограничивает продолжительность действительности намерения. Он не устанавливает очередь и не гарантирует своевременное включение. Если операция исполнилась после конца окна, получаем Expired до изменения записи. Равенство timestamp и deadline допускается, как в шестнадцатом уроке.
Ревизия и срок решают разные задачи. Ревизия связывает действие с состоянием, срок — с допустимым временем. Долгое ожидание не делает ревизию устаревшей само по себе, а новая ревизия не продлевает срок. В интерфейсе эти причины отказа показываются отдельно.
Курс 17 может быть неактивным, но иметь ненулевую ревизию и сохранённый отпечаток. Новая публикация допускается с текущей ревизией. Это осознанная семантика модели, а не возвращение к состоянию «никогда не существовал».
Не обещаем скрытие транзакции
Проверка ожидаемого состояния не скрывает calldata и не гарантирует исключение любого влияния на порядок. Она предотвращает применение к неподходящей ревизии. Если действие публично, другие участники могут видеть его поля и последствия в рамках выбранной сети.
При разрешённой роли только издатель может изменить реестр. Если его полномочия скомпрометированы, ревизия не мешает новому правильно сформированному злонамеренному действию. Защита ключей, процедура редакционного согласования и реагирование остаются отдельными слоями.
Таким образом, название главы про защиту намерения не следует читать как универсальную защиту от всех видов MEV. Здесь решён конкретный конфликт нашего API: старое редакционное действие не применяется молча к новой записи.
Меняем условие: отпечаток вернулся
Сначала H1 при ревизии четыре заменён на H2 при пяти, затем H1 опубликован снова при шести. Старое действие с ожиданием четыре всё ещё отклоняется, хотя байты отпечатка совпали. Это главное преимущество отдельной ревизии перед одним сравнением хеша.
Сам contentHash по-прежнему является SHA-256 точных байтов манифеста в общей библиотеке. Новый счётчик не делает текст проверенным и не меняет алгоритм. Для отображения содержимого браузер отдельно получает файл и сопоставляет его отпечаток с объявленным выпуском.
Повтор после неизвестного результата
Предположим, интерфейс потерял сетевой ответ публикации и не знает, исполнилась ли она. Нельзя просто перечитать ревизию и автоматически сформировать новое действие. Сначала восстанавливаем судьбу прежней транзакции по её сохранённому идентификатору, затем сопоставляем текущий выпуск с исходным намерением.
Если первое действие уже прошло, повтор с прежней ревизией отклонится. Это полезная граница, но интерфейс должен объяснить, что исходная операция могла завершиться успешно. Если первое действие не выполнялось, нужно учесть оставшийся срок и состояние, а не утверждать, что любой повтор допустим.
Наблюдаем версию отдельно от содержимого
Ревизия описывает переходы реестра, а отпечаток описывает байты файла. Один и тот же файл может быть объявлен несколько раз и получить разные ревизии. И наоборот, новый файл обязательно меняет отпечаток, но сам по себе не создаёт запись реестра. В карточке сохраняем оба значения и не используем счётчик как доказательство качества материала.
Разбор результата
Мы создали точный договор конкурентного изменения: ожидаемая ревизия, срок и общий контроль всех изменяющих функций. Базовый реестр не менялся, а новый экземпляр имеет отдельный ABI. Отказ по старой версии требует нового осознанного намерения.
В следующем уроке перенесём поля намерения в разрешение по подписи и определим его домен. Полное оглавление связывает остальные механизмы защиты.