Безопасность Solidity: проверка результата внешнего вызова
В возврате ETH мы проверяли bool низкоуровневого call. Но типизированная внешняя функция тоже может возвращать bool со своим предметным смыслом. Отсутствие revert не обязательно означает принятие операции. Разберём эту границу на собственной модели приёмника публикационного digest, без платежей и связи с реальным ProfessorWeb.
IReleaseSink принимает bytes32 и возвращает, принято ли значение. Это наш небольшой protocol, не CourseCatalog и не универсальный стандарт. Если приёмник возвращает false, вызывающий маршрут должен выполнить выбранную политику отказа. Забытый return способен создать локальную отметку «принято» при отказе внешнего компонента.
Полный проверяющий маршрут
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
interface IReleaseSink {
function accept(bytes32 digest) external returns (bool);
}
contract CheckedReleaseRoute is ReentrancyGuard {
error InvalidSetup();
error Unauthorized();
error Rejected();
address public immutable editor;
IReleaseSink public immutable sink;
bytes32 public lastAccepted;
constructor(address editor_, address sink_) {
if (editor_ == address(0) || sink_ == address(0)) revert InvalidSetup();
editor = editor_;
sink = IReleaseSink(sink_);
}
function send(bytes32 digest) external nonReentrant {
if (msg.sender != editor) revert Unauthorized();
if (digest == bytes32(0)) revert Rejected();
bool accepted = sink.accept(digest);
if (!accepted) revert Rejected();
lastAccepted = digest;
}
}
Это полный код вызывающей стороны и интерфейса, а не полный комплект с реализацией sink. Адрес sink выбирается как доверенная зависимость. Приведение address к IReleaseSink не проверяет наличие правильного кода и не подтверждает правдивость его return. Такой предел модели следует сохранить в отчёте.
lastAccepted записывается только после предметного true. Во время внешнего call getter показывает предыдущий завершённый результат. NonReentrant блокирует повторный вход в send, поэтому внешняя зависимость не может через тот же путь менять lastAccepted до возвращения. Это конкретное сочетание состояния и guard, не универсальное оправдание любой записи после взаимодействия.
Три различных ответа
Первый сценарий: sink успешно выполняет accept и возвращает true. Route записывает новый digest. Второй: sink успешно возвращает false. Route делает Rejected, и вся транзакция откатывается. Третий: sink делает revert. Ошибка типизированного внешнего вызова распространяется, поскольку здесь она не перехвачена.
Во втором случае sink мог успеть изменить собственное storage до return false. Но последующий revert route откатит и эти вложенные изменения в общей транзакции. Нельзя представить их как сохранённую отдельную половину операции только потому, что функция sink формально завершилась раньше проверки bool.
Если продукт хочет сохранять факт отклонения вместо полного revert, это другая политика. Например, он может записать failed-attempt и завершить вызов успешно. Тогда нужно отдельно определить, что происходит с уже сделанными внешними effects, и какие данные считаются доказанными. Не меняем такой договор незаметно ради удобного сообщения интерфейса.
Низкоуровневая форма
У call результат имеет два слоя: технический success и returned bytes. Для функции с bool внутри bytes может лежать false при success=true. Если проверить только success, получится ошибочное принятие предметного отказа. Поэтому ABI-форма и смысл возвращаемого значения анализируются отдельно.
call success=false → внешнее исполнение отклонено
call success=true, ABI bool=false → исполнение прошло, операция отказана
call success=true, ABI bool=true → заявлено принятие по нашему protocol
call success=true, пустые bytes → ожидаемый bool не подтверждён
Таблица описывает ожидаемую классификацию, а не выполненный лог. Особенность пустого ответа важна: call к адресу без нужного кода может технически завершиться без полезного действия. Наличие true у низкоуровневого success не доказывает, что заданный selector вообще обработан нужной реализацией.
Некоторые токенные библиотеки поддерживают отдельный договор необязательного return, но это специальная совместимость, не универсальное правило всех интерфейсов. Наш IReleaseSink требует bool. Следовательно, нельзя механически применить политику SafeERC20 к произвольной функции только потому, что оба используют внешний call.
Доверие к заявлению
True является утверждением sink по нашему выбранному protocol. Оно не доказывает, что digest соответствует манифесту, что файл доступен либо что другой реестр изменился. Если нужны такие свойства, интерфейс и независимое наблюдение должны описывать их отдельно.
Плохая зависимость может всегда возвращать true без записи. Route выполнит свою логику точно, но бизнес-цель не будет достигнута. Поэтому выбор sink, известный исходник и наблюдаемое состояние входят в модель доверия. Одной правильной проверки bool недостаточно для оценки неизвестной программы.
Ошибки в интерфейсе
Пользовательская страница различает неавторизованного editor, неправильный digest, предметный отказ и сбой внешнего исполнения. Если все случаи отображаются как «сеть занята», редактор не сможет понять, что нужно исправить. Сообщение опирается на доступные данные ошибки, а не на заранее удобное предположение.
Сетевой таймаут после отправки не сообщает, какой ответ sink вернул. Приложение сначала ищет receipt известного hash и читает lastAccepted. Повторная отправка является новым вызовом, который может получить другой ответ, если внешняя зависимость изменилась между попытками.
Подготовка локальных фикстур
Для будущего упражнения достаточно собственных sink, которые возвращают true, возвращают false и делают revert. Ещё один может возвращать true без изменения своего состояния, чтобы показать предел доверия. Они предназначены для локального тестового окружения; никакой чужой адрес не нужен.
В текущем задании такие программы не запускаются. Запишите ожидаемые lastAccepted и состояние sink после каждого случая, включая rollback. Затем объясните, почему success низкоуровневого вызова и предметный bool не равны. В следующем уроке применим это различие к токену, у которого успешный возврат ещё не гарантирует ожидаемое фактическое количество.
Разный ответ после повторной попытки
Предположим, первый send(H1) получил false, а вторая поздняя попытка получила true после изменения sink. В нашем варианте первый вызов не оставил lastAccepted=H1, поскольку завершился revert. Второй может оставить H1 как новый успешный результат. Для истории приложения это две попытки с разными outcomes, даже если конечный digest один.
Если интерфейс сохраняет только последний hash, он не восстановит все причины прежнего отказа. Для такого журнала нужны receipt и собственные события по осмысленной политике. Но событие внутри откатываемого вызова тоже не сохранится как успешный on-chain лог. Нельзя обещать журнал отказов простым emit перед последующим revert.
При будущей диагностике отмечайте, какая сторона сообщила ошибку: route, sink или transport. Так предметный отказ не будет ошибочно исправляться изменением газового лимита или подключением другого кошелька без основания.
Для самостоятельного уточнения: control-structures.html, SafeERC20.sol (5.4.0), ReentrancyGuard.sol (5.4.0).
Оглавление курса. Листинги, расчёты и наблюдения здесь разобраны как ожидаемое поведение; компиляция, учебные программы и проверки при написании не запускались.