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

Revert в Solidity: отмена операции и атомарность

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

Будем работать с курсом 17 и отпечатком H1. Издатель должен передавать ненулевой номер и ненулевой отпечаток. Требование довольно простое, но именно на нём видно, какие проверки относятся к данным, какие — к роли и какой результат получает читатель после ошибки.

Проверка до изменения

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

function publish(uint256 courseId, bytes32 contentHash)
    external
    onlyPublisher
{
    if (courseId == 0 || contentHash == bytes32(0)) {
        revert InvalidCourse();
    }
    courses[courseId] = Course(contentHash, true);
    emit Published(courseId, contentHash);
}

Перед нами точный фрагмент базового контракта. onlyPublisher, Course, поле courses и событие объявлены в этом же файле. Мы не добавляем новую модель прав. При вызове publish(17, 0) ожидается InvalidCourse, а прежняя запись курса 17 остаётся прежней.

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

Что отменяется

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

Исходно: course[17] = H1, true
Действие: попытка опубликовать недопустимый выпуск
Ошибка: InvalidCourse
После: course[17] = H1, true
Лог новой публикации из этого действия отсутствует

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

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

Откат, отмена и отсутствие квитанции

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

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

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

require, revert и assert

В Solidity существуют способы остановить недопустимое выполнение. revert удобно использует собственную ошибку, которая определена в интерфейсе контракта. require часто выражает предусловие. assert относится к внутреннему предположению, нарушение которого требует поиска ошибки программы, а не только исправления пользовательского ввода.

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

Не стоит полагаться на произвольную строку сообщения как единственный способ поведения интерфейса. Собственная ошибка имеет сигнатуру и при необходимости параметры. Клиент может декодировать её по подходящему ABI. При этом некорректный ABI или неизвестные байты требуют общего сообщения без выдуманного объяснения.

Изменим исходное условие

В таблице рассмотрим один вызов при разных входах:

Отправитель и параметры Ожидаемый результат Изменение курса
Издатель, 17, H2 Публикация H2 и active=true
Читатель, 17, H2 Unauthorized Нет
Издатель, 0, H2 InvalidCourse Нет
Издатель, 17, нули InvalidCourse Нет

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

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

Ответ сервера не отменяет транзакцию

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

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

Сформулируйте ожидаемый отчёт для случая «RPC ожидал слишком долго». В нём нет утверждения, что курс изменён, и нет утверждения, что он откатился. Есть известный вход, источник наблюдения и необходимость восстановить результат. Такая осторожность конкретна: она следует из этапа действия, а не из общего недоверия к технологии.

Оглавление курса. Листинги, расчёты и наблюдения здесь разобраны как ожидаемое поведение; компиляция, учебные программы и проверки при написании не запускались.

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