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

Безопасность Solidity: зависимость от порядка транзакций

Базовый CourseCatalog проверяет издателя и допустимые поля, но разрешённые действия всё ещё зависят от порядка исполнения. Издатель отправил публикацию нового выпуска, затем деактивацию старого состояния. Если порядок отличается от ожидаемого интерфейсом, итог может оказаться другим без нарушения проверки роли.

В этом уроке рассмотрим только конкурирующие изменения собственной учебной библиотеки. Не ищем чужие контракты и не отправляем транзакции. Course id равен 17, а обозначения H1 и H2 являются условными отпечатками выпуска, не вычисленными хешами файлов.

Восстанавливаем исходную семантику

Базовый реестр хранит contentHash и active. publish записывает новый ненулевой отпечаток и устанавливает active=true. deactivate требует активную запись и меняет только флаг, сохраняя отпечаток. Проверка издателя действует в обоих маршрутах.

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

Пусть исходное состояние курса — H1, активен. Имеются два разрешённых действия: P публикует H2, D деактивирует запись. Поскольку у D нет условия «только если текущий выпуск H1», оно относится к состоянию курса в момент исполнения.

Сравниваем два порядка

В порядке P→D сначала записывается H2 с активным флагом, затем D снимает флаг. Итог — H2, неактивен. В порядке D→P сначала H1 становится неактивным, затем P записывает H2 и включает активность. Итог — H2, активен.

Порядок Итоговый отпечаток Активность
P затем D H2 false
D затем P H2 true

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

События помогают восстановить порядок, но не заменяют текущий getter. Список событий, прочитанный из неполного диапазона или другой ветки, может привести к неверной картине. Индексатор обязан хранить блок и порядок логов, а интерфейс — показывать происхождение снимка.

Кто может создать конкуренцию

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

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

Главный вопрос здесь не «кто обязательно переставит транзакцию», а «какой смысл имеет действие на текущем состоянии». Если деактивация должна относиться к конкретному выпуску, исходный API этого намерения не выражает. Проверка роли не восполняет отсутствие поля ожидаемого состояния.

Отправление и подтверждение показываем отдельно

После получения хеша интерфейс знает только отправленную операцию. После успешного исполнения знает, что операция выполнилась при конкретном состоянии. Чтобы показать новый выпуск, он обновляет getter и проверяет сеть, адрес экземпляра и блок наблюдения.

prepare intent against H1
→ submit operation
→ observe receipt
→ read current course on chosen chain snapshot
→ compare intended and actual state

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

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

Меняем условие: две публикации

Пусть P1 записывает H2, а P2 записывает H3. При разрешённом порядке P1→P2 итог H3, при обратном — H2. Исходный publish работает как замена текущей записи и не хранит историю версий в mapping. Это полезно знать до построения редакционного процесса.

Если интерфейс хотел только заменить H1 на H2, он должен передать условие ожидаемого состояния в новом API. Если хотел просто объявить самый поздний согласованный выпуск независимо от промежуточного состояния, другой договор может быть подходящим. Нельзя выбрать оба смысла одновременно одним названием кнопки.

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

Не блокируем всю работу одной очередью по привычке

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

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

Устаревшее редакторское состояние

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

Оптимистичное изменение карточки тоже не является подтверждённым реестром. Если интерфейс сразу нарисовал новый выпуск, он должен обозначить ожидание и затем сопоставить receipt с текущим чтением. Иначе локальная картинка станет ложным источником уверенности, хотя проблема находилась в конкурирующих допустимых действиях.

Разбор результата

Порядок транзакций стал частью предметного договора. Успешные разрешённые вызовы могут дать разные итоги, а receipt не заменяет чтение актуального состояния. Мы сохранили базовый API и выявили поле, которого ему не хватает для точного редакционного намерения.

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

Оглавление серии