Ошибки и проверки: require, revert, assert
- Зачем это нужно
- Транзакция либо проходит целиком, либо не проходит вовсе
- require: условие и сообщение
- require без сообщения оставляет пользователя с пустым экраном
- revert: отказ без условия
- Своя ошибка вместо строки: где на самом деле экономия
- assert — для того, чего не может быть
- Хорошее сообщение говорит, что сделать
- Порядок проверок: сначала дешёвые
- Перевёрнутое условие: ошибка, которую не ловит никто
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Контракт после публикации нельзя ни исправить, ни остановить, а вызвать его функцию может любой адрес в сети — не только тот, для кого она написана. Значит, единственная защита контракта — проверки внутри него самого.
В конкурсном задании это отдельный оцениваемый пункт. Модуль В требует «предложить текст сообщений об ошибке в существующих в контракте проверках» — в эталонном контракте они стоят заглушками "_________", и написать их правильно нужно самому. А в отчёте по логике ищут перевёрнутые условия: в том же контракте require(role != _role) вместо == — один знак, который открывает функцию всем, кроме того, кому она предназначена.
Эта глава — про три способа сказать «нет» и про то, что при этом происходит с деньгами и данными.
Транзакция либо проходит целиком, либо не проходит вовсе
Вызов функции, меняющей данные, — это транзакция. У неё ровно два исхода: статус 1 — всё записалось; статус 0 — откат (revert), и хранилище выглядит так, будто вызова не было. Промежуточного «половина записалась» не бывает.
Первая модель проверяет это буквально: счётчик попыток attempts += 1 стоит до проверки, и всё равно после отказа он остаётся нулём.
── до попытки ──
вклад Алисы: 1.0 ETH
attempts: 0
── withdraw(2 ETH) — проверка С текстом ──
VM Exception while processing transaction:
reverted with reason string 'Недостаточно средств на счёте'
── withdrawSilent(2 ETH) — проверка БЕЗ текста ──
Transaction reverted without a reason string
── состояние после двух отказов ──
вклад Алисы: 1.0 ETH
attempts: 0 (attempts += 1 стоял ДО require — и всё равно откатился)
── газ за отклонённую транзакцию ──
статус квитанции: 0 (0 = откат)
gasUsed: 46483
с Алисы списано: 0.000070385634774416 ETH — за газ, хотя ничего не произошло
── для сравнения: успешный withdraw(0.5 ETH) ──
статус квитанции: 1
gasUsed: 56385
вклад Алисы: 0.5 ETH
attempts: 1 — считается только успешная попытка
Два вывода. Откат стирает всё, а не «начиная с точки отказа»: запись, сделанная строкой выше проверки, тоже исчезает. Газ всё равно списывается — 0,00007 ETH за транзакцию, которая ничего не изменила. Работа машины оплачена, даже если результат отменён.
Отсюда практическое правило: проверки ставят в самом начале функции, до первой записи в хранилище. Чем раньше отказ, тем меньше сожжено газа.
require: условие и сообщение
Основная проверка выглядит так:
require(balance[msg.sender] >= amount, unicode"Недостаточно средств на счёте");
Условие формулируется как «то, что должно быть правдой», а не «что запрещено». Это частый источник путаницы: require(amount > 0) означает «сумма обязана быть больше нуля», и отказ произойдёт при нуле.
Сообщение уходит наружу целиком: Hardhat печатает reverted with reason string 'Недостаточно средств на счёте', кошелёк показывает тот же текст пользователю.
require без сообщения оставляет пользователя с пустым экраном
Написать проверку без второго аргумента можно:
require(balance[msg.sender] >= amount);
Это законно и компилируется. Но при отказе наружу не уходит ничего — даже служебного признака ошибки. Пользователь видит «транзакция не прошла» и не знает почему; разработчик в модели получил ровно это:
Transaction reverted without a reason string
Проверка без сообщения — прямая потеря баллов в модуле В, где текст сообщений оценивается отдельным отчётом.
Кириллица в обычных кавычках не компилируется: "Сумма должна быть больше нуля" даёт ParserError: Invalid character in string. If you are trying to use Unicode characters, use a unicode"..." string literal. Приставка unicode пишется слитно с кавычкой, без пробела — так во всех сообщениях эталонного контракта.
revert: отказ без условия
Когда логика уже разложена по веткам, последняя ветка часто означает «так быть не должно». Условия для require там нет — есть просто место, куда попадать нельзя:
if (tokenPrice == pubPrice) {
// публичная фаза
} else if (tokenPrice == privPrice) {
// приватная фаза
} else {
revert(unicode"Во время подготовительной фазы нельзя покупать CMON");
}
Так написано в эталонном контракте. revert — это «отказать прямо сейчас», без проверки условия. Скобки обязательны: запись revert unicode"..." без скобок даёт ParserError.
Разница простая: require — «продолжай, если правда», revert — «остановись здесь».
Своя ошибка вместо строки: где на самом деле экономия
С версии 0.8.4 отказ можно описать собственным типом ошибки:
error InsufficientBalance(uint256 available, uint256 required);
// в теле функции:
if (balance[msg.sender] < amount) {
revert InsufficientBalance(balance[msg.sender], amount);
}
Про такие ошибки часто говорят «они экономят газ». Вторая модель проверяет это на двух одинаковых контрактах — со строкой и со своей ошибкой:
── WalletString ──
газ на деплой: 332352
газ за откат: 24144
байт в данных отказа: 196 селектор: 0x08c379a0
сообщение Hardhat: reverted with reason string 'Недостаточно средств: …'
── WalletError ──
газ на деплой: 290471
газ за откат: 24105
байт в данных отказа: 68 селектор: 0xcf479181
сообщение Hardhat: reverted with custom error 'InsufficientBalance(5, 10)'
── разница (строка − ошибка) ──
деплой: 41881 газа
откат: 39 газа
данные: 128 байт
при этом строка НЕ несёт чисел, а ошибка несёт available=5 и required=10
Популярное утверждение оказалось наполовину мифом. Откат стоит одинаково — разница 39 газа. Экономия в другом: текст сообщения не зашивается в байткод, поэтому развёртывание дешевле на 41 881 газа.
А главное преимущество вообще не про газ: своя ошибка несёт числа. Пользователь видит InsufficientBalance(5, 10) — сколько есть и сколько запрошено. Строка так не умеет.
assert — для того, чего не может быть
Третья конструкция выглядит похоже, но означает другое:
assert(totalSupply >= balance[owner]);
assert проверяет инвариант — утверждение, которое обязано быть истинным всегда, если код написан правильно. При нарушении наружу уходит не текст, а panic code 0x1 — потому что текст читать некому: это сигнал программисту об ошибке в коде, а не пользователю об ошибке ввода.
Правило простое: require — для всего, что приходит снаружи (суммы, адреса, права, время). assert — для внутренних инвариантов, которые не должен нарушить даже самый странный ввод. Документация формулирует это так: правильно работающий код не должен порождать панику вообще, даже на неверных внешних данных.
Компилятор с версии 0.8 и сам вставляет паники: 0x11 при переполнении, 0x12 при делении на ноль, 0x32 при выходе за границу массива. Их сообщения бесполезны пользователю — ещё одна причина проверять входные данные заранее, своим require с понятным текстом.
Хорошее сообщение говорит, что сделать
Отчёт по проверкам в модуле В — это буквально «напишите тексты вместо заглушек». Плохие сообщения: "error", "fail", "_________". Хорошие называют предмет и границу:
require(_amount / dec <= 5_000, unicode"Максимальное кол-во - 5.000 CMON");
require(!userMap[msg.sender].whitelist, unicode"Вы уже в вайтлисте");
Три правила:
- Одно сообщение — одна причина. Если один текст стоит у трёх разных проверок, пользователь не поймёт, какая сработала.
- Без технических слов. Не «msg.sender не соответствует owner», а «Действие доступно только владельцу».
- Называть границу числом. Не «слишком много», а «Максимальное кол-во - 5.000 CMON».
Тексты видит эксперт при сверке с ключом — и пользователь в кошельке, если решение показывают на демонстрации.
Порядок проверок: сначала дешёвые
Каждое чтение из хранилища стоит около 2100 газа при первом обращении, а сравнение двух чисел из параметров — около трёх. Отсюда порядок: сначала то, что дёшево проверить.
function sendRequest() public {
require(getLifeTime() > seedPhase, unicode"Заявку можно подать только после старта"); // сравнение чисел
require(!userMap[msg.sender].whitelist, unicode"Вы уже в вайтлисте"); // одно чтение
for (uint256 i = 0; i < requests.length; i++) { // цикл — самое дорогое
require(requests[i].wallet != msg.sender, unicode"Вы уже подали заявку");
}
requests.push(Whitelist(userMap[msg.sender].login, msg.sender, false));
}
Ровно этот порядок стоит в эталонном контракте. Логика та же, что с ранним отказом: если транзакция всё равно будет отклонена, пусть это случится на третьем газе, а не после цикла.
Перевёрнутое условие: ошибка, которую не ловит никто
В эталонном контракте есть строка, которая компилируется без единого предупреждения и полностью ломает защиту:
require(userMap[msg.sender].role != _role, "_________");
Знак должен быть ==. Третья модель ставит рядом сломанный и исправленный варианты и вызывает функцию тремя разными адресами:
── RoleGateBroken: changePrice() только для Role.Provider ──
владелец (деплоил) → ПРОПУЩЕН (price = 200)
провайдер → ОТКАЗ reverted with reason string 'Недостаточно прав'
посторонний → ПРОПУЩЕН (price = 400)
── RoleGateFixed: changePrice() только для Role.Provider ──
владелец (деплоил) → ОТКАЗ reverted with reason string 'Недостаточно прав'
провайдер → ПРОПУЩЕН (price = 300)
посторонний → ОТКАЗ reverted with reason string 'Недостаточно прав'
Самое неприятное здесь — не сама ошибка, а то, почему её не находят. Проверка руками ничего не показывает: в редакторе по умолчанию функцию вызывает тот, кто разворачивал контракт, — владелец. На сломанном контракте владельца пропускает. Автор видит «работает» и идёт дальше. А единственного, кого пускать нельзя, — постороннего — никто не проверил.
Проверка прав тестируется минимум с двух адресов: того, кому можно, и того, кому нельзя.
Эта проверка стоит внутри модификатора — заготовки, вынесенной за скобки функции, чтобы не повторять один и тот же require в десяти местах. В эталонном контракте один такой модификатор защищает десяток функций, поэтому ошибка в нём ломает сразу все. Подробно модификаторы разберём в главе про функции.
Типичные ошибки
Кириллица без приставки. ParserError: Invalid character in string. — нужно unicode"…".
revert без скобок. revert unicode"текст"; → ParserError: Expected ';' but got 'UnicodeStringLiteral'. Скобки обязательны.
Опечатка reqiure. DeclarationError: Undeclared identifier. Did you mean "require"? — компилятор подсказывает сам.
Своя ошибка без revert. Объявленный error нельзя просто «вызвать»: TypeError: Errors can only be used with revert statements.
Проверка после записи. Компилируется и работает, но сжигает лишний газ на отклонённых вызовах и мешает читать код: сначала проверки, потом действия.
Одно сообщение на все проверки. Пользователь и эксперт не понимают, какая именно сработала.
assert вместо require для внешних данных. Пользователь получает panic code 0x1 без объяснения; в отчёте по логике это отмечают как ошибку.
Условие сформулировано наоборот. require(x != y) вместо == — компилятор молчит, ручная проверка не ловит. Тестировать с двух адресов.
Проверка без сообщения. Transaction reverted without a reason string — и потерянный пункт отчёта по проверкам.
Слова главы
require(условие, сообщение)— продолжить, если условие истинно; иначе откат с текстом.revert(сообщение)— отказать сразу, без условия; скобки обязательны.assert(условие)— проверка инварианта; при нарушенииpanic code 0x1без текста.error Имя(типы)— собственная ошибка; вызывается черезrevert Имя(значения).- Откат (revert) — отмена всей транзакции; изменения исчезают целиком.
- Статус квитанции — 1 (прошла) или 0 (откат).
gasUsed— сколько газа сожжено; списывается и при откате.unicode"…"— строковый литерал с кириллицей; приставка слитно с кавычкой.- Инвариант — утверждение, истинное всегда при правильном коде.
- Panic-код — служебный номер ошибки без текста:
0x1assert,0x11переполнение,0x12деление на ноль,0x32выход за границу. - Модификатор (modifier) — проверка, вынесенная за скобки функции; подробно впереди.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
require(msg.sender == owner, unicode"Только владелец");
На чемпионате
Модуль В, 14 баллов, шесть отчётов. Два из них — прямо про эту главу.
«Отчёт по проверкам: предложить текст сообщений об ошибке в существующих в контракте проверках». В эталонном контракте заглушки "_________" стоят в модификаторе доступа, во входе по паролю, в покупке токенов, в переводе и в получении разрешённой суммы. К каждой нужен текст: предмет, граница, что сделать.
«Отчёт по логике: указать на ошибки в логике работы контракта, внести предложения по их устранению». Перевёрнутые условия из этой главы — готовые пункты: require(role != _role) в модификаторе и require(passwordMap[_login] != _password) при входе. Оба должны быть ==.
Отчёты сверяются с ключами-ответами разработчиков задания: каждый ненайденный пункт — потерянный балл. И наоборот: за вредное предложение снимают, поэтому писать только то, в чём уверены.
Модуль А. Все проверки из требований задания — «сумма не менее 10 USDC», «сумма не превышает баланс», «выдан approve», «пользователь не должен быть ликвидирован после получения займа» — это require с понятным текстом в начале функции. Эксперт на демонстрации проверяет и отказы тоже.
Что дальше
Проверки — это ответ на вопрос «делать или отказать». Следующая глава — про выбор между несколькими вариантами: if и else, циклы и время. Там же выяснится, почему цикл по списку неизвестной длины — не просто медленно, а опасно: он может перестать помещаться в блок, и функция откажет навсегда.
Углубиться
- Полная таблица panic-кодов — от
0x00до0x51; в документации, в разделе про обработку ошибок. requireсо своей ошибкой — с версии 0.8.26 доступна записьrequire(условие, MyError(...)).try/catch— как поймать откат чужого контракта, а не упасть вместе с ним.- Возврат остатка газа — при откате неизрасходованный лимит возвращается отправителю; сгорает только потраченное.
- Как получить квитанцию отклонённой транзакции — инструмент обычно бросает ошибку заранее; чтобы увидеть статус 0 и
gasUsed, отключают автоматическую сборку блоков.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Обработка ошибок: assert, require, revert — docs.soliditylang.org — полная таблица panic-кодов и три формы
require. - Свои ошибки (custom errors) — объявление, параметры,
revert. - Блог Solidity: Custom Errors — зачем их ввели в 0.8.4 и что они дают.
- Изменения версии 0.8.0 — почему арифметика теперь порождает паники сама.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.