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

Безопасность Solidity: отказ обслуживания и локальный ресурс

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

Используем отдельную модель обязательств возврата, а не денежную функцию реестра курсов. Базовый CourseCatalog не хранит ETH и не приобретает платёжный API. Solidity 0.8.30 выбран для исходников; контракт, получатели и проверки сейчас не запускаются.

Общая операция создаёт общую зависимость

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

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

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

Предпочитаем индивидуальное получение

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

Приведём полную небольшую отдельную модель с депозитом в пользу адреса и индивидуальным выводом. Используем ReentrancyGuard закреплённого OpenZeppelin 5.4.0, а не предположительно совместимый импорт иной версии.

pragma solidity 0.8.30;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract PullCreditLedger is ReentrancyGuard {
    mapping(address => uint256) public credit;
    error NoCredit();
    error TransferFailed();
    error InvalidRecipient();

    function depositFor(address recipient) external payable {
        if (recipient == address(0) || msg.value == 0) revert InvalidRecipient();
        credit[recipient] += msg.value;
    }

    function withdrawTo(address payable recipient) external nonReentrant {
        if (recipient == address(0)) revert InvalidRecipient();
        uint256 amount = credit[msg.sender];
        if (amount == 0) revert NoCredit();
        credit[msg.sender] = 0;
        (bool success,) = recipient.call{value: amount}("");
        if (!success) revert TransferFailed();
    }
}

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

Разбираем отказ одного получателя

Адрес A имеет обязательство и направляет вывод контракту, который не принимает перевод. Вызов получает TransferFailed. Полный revert восстанавливает credit[A], несмотря на предварительное присваивание нуля. Так обязательство не теряется из-за отказа назначения.

Адрес B в отдельной транзакции выводит свою запись пригодному получателю. Отказ A не является общим флагом, который запретил бы B действовать. Это и есть локализация: каждый вызов имеет собственную границу исполнения и свои последствия.

Если A выберет другое назначение следующей операцией, прежнее обязательство можно использовать снова. Однако UI должен показать неизменившуюся сумму после отката, а не отметить «выведено» в момент отправления транзакции. Receipt и текущая проекция остаются важными этапами.

Почему guard не решает все отказы

nonReentrant ограничивает повторный вход в защищённый маршрут, но не заставляет внешний получатель принять перевод. CEI и guard помогают защитить согласованность ресурса, а pull-модель ограничивает область отказа. Это разные свойства одной операции.

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

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

Отказы бывают и без перевода

Публичная функция может читать внешний источник, который всегда откатывается, либо возвращает большой объём данных. Даже view-операция расходует ресурсы при on-chain вызове. Поэтому состав зависимостей и пределы ответа относятся к модели доступности, а не только к передаче ETH.

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

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

Меняем условие: тысячи обязательств

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

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

Повторная доставка операции вывода

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

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

Для будущего изменения функции отдельно анализируют связь частичного вывода с текущим credit. Наш полный вывод проще, но добавление параметра amount создаст новые границы: ноль, превышение и повтор при изменённом обязательстве. Существующий guard не заменяет проверку этих новых предметных условий.

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

Мы отделили отказ получателя от отказа всей системы. Индивидуальная операция с CEI и проверкой результата сохраняет обязательство при полном откате. Guard не обещает успешность внешнего действия, а новый маршрут к тому же ресурсу требует нового анализа.

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

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