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

Безопасность Solidity: ограниченные циклы и продолжение

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

Построим отдельную неденежную очередь обработки курсов. Издатель добавляет задачи отключения, другой участник может выполнить ограниченный пакет. Это собственная модель DisableQueue, не изменение базового CourseCatalog. Исходники и проверки сейчас не запускаются.

Определяем предел одной операции

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

Выберем условный MAX_BATCH=20. Это учебный предел, а не измеренная безопасная величина газа. Реальное значение позднее выбирается по профилю операций и исполнению. В данной модели одна итерация выполняет только запись флага и не вызывает неизвестный внешний контракт.

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

Сохраняем устойчивый курсор

Очередь добавляется в конец и не переставляется во время обработки. Курсор указывает индекс следующего необработанного элемента. Если удалить старую запись методом swap-and-pop, индексы изменятся и продолжение потеряет смысл. Поэтому в этом варианте список сохраняет порядок.

pragma solidity 0.8.30;

contract DisableQueue {
    address public immutable publisher;
    uint256 public constant MAX_BATCH = 20;
    uint256[] public pending;
    uint256 public cursor;
    mapping(uint256 => bool) public disabled;
    error Unauthorized();
    error InvalidLimit();
    error InvalidCourse();

    constructor(address publisher_) {
        require(publisher_ != address(0), "publisher required");
        publisher = publisher_;
    }

    function enqueue(uint256 courseId) external {
        if (msg.sender != publisher) revert Unauthorized();
        if (courseId == 0) revert InvalidCourse();
        pending.push(courseId);
    }

    function process(uint256 limit) external returns (uint256 processed) {
        if (limit == 0 || limit > MAX_BATCH) revert InvalidLimit();
        while (processed < limit && cursor < pending.length) {
            uint256 courseId = pending[cursor];
            cursor += 1;
            disabled[courseId] = true;
            processed += 1;
        }
    }
}

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

Разбираем маленькую очередь

Пусть в список добавлены id 17, 18, 19, а курсор равен нулю. Вызов process(2) ожидаемо обработает первые два и установит курсор два. Следующий process(2) обработает оставшийся один и установит курсор три. В обоих случаях размер исполнения ограничен параметром и константой.

Если очередь пуста, допустимый вызов возвращает ноль и не меняет состояния. Если limit=0 или больше двадцати, получаем InvalidLimit. Эти исходы являются разбором листинга, а не результатом запущенного узла.

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

Конкуренция исполнителей

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

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

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

Продолжение не гарантирует завершение

Ограниченный вызов делает каждую операцию достижимее, но не заставляет кого-либо отправлять следующие транзакции. Для практической системы нужен исполнитель и политика оплаты его работы либо организационная обязанность. Без этого курсор может остановиться надолго.

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

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

Меняем условие: нужна отмена задачи

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

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

Отдельный путь чтения очереди

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

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

Предел двадцать в листинге сохраняется как параметр модели. Перед реальным применением его стоимость нужно изучить для выбранного байткода и среды. Здесь не выдаём число за измеренный gas budget: никаких исполнений не было. Содержательный результат главы — независимое ограничение и устойчивое продолжение. Фиксируем этот договор в README.

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

Цикл получил независимый предел, а продолжение — устойчивое значение в storage. Конкурирующие исполнители читают текущий курсор, пустая очередь определена, и рост общего списка не увеличивает число итераций одного вызова. Завершение и хранение истории остаются отдельными обязанностями.

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

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