Безопасность Solidity: рост постоянного состояния
Оглавление курса «Безопасность: разбор собственного контракта» · Урок 25 из 30.
В предыдущем уроке DisableQueue получил курсор и ограничение MAX_BATCH равное 20. Одна операция обрабатывает конечный пакет без внешних вызовов. Но массив pending после обработки не уменьшается: новые задачи продолжают добавляться, а старые занимают постоянное состояние. Теперь определим договор хранения и покажем, почему ограниченный цикл не решает рост всей коллекции.
Кодовые фрагменты не запускались и не являются изменением действующего сайта или базового CourseCatalog. Мы продолжаем отдельную неденежную модель очереди. Очистка создаётся как новая версия исходника, а не как возможность незаметно заменить уже развёрнутый контракт. Профиль примеров остаётся Solidity 0.8.30 и EVM Cancun.
Три величины очереди
В модели есть длина pending, курсор следующей задачи и число оставшихся элементов. После добавления пяти задач и обработки трёх значения равны пяти, трём и двум. Размер исполненной работы уменьшает остаток, но не общую длину массива. Поэтому процент готовности и объём постоянного хранения нельзя считать одной метрикой.
Рост может иметь разные причины: необходимая история, повторные назначения одного курса или поток новых задач быстрее обработки. В enqueue только издатель назначает работу, но это не задаёт верхнюю границу числа назначений за всё время. Авторизованный субъект тоже способен создать состояние, неудобное для дальнейшей эксплуатации.
Выбираем политику до очистки. Нужны ли старые номера заданий непосредственно контракту? Достаточно ли результата disabled? Требуется ли прикладному индексатору объяснение решения? Если вопросы не определены, случайное удаление удобного массива может разрушить пользовательскую историю или восстановление, даже когда основной флаг выглядит правильным.
Удаление элемента не сдвигает курсор
В Solidity delete для элемента массива сбрасывает его значение, но не превращает массив в короткую коллекцию. Для mapping нет автоматического перечисления всех ключей. Правила хранения и ограничения очистки раскрыты в документации Solidity 0.8.30 и разделе об очистке mapping.
Если просто сделать swap-and-pop для обработанных задач, индексы оставшихся изменятся. Старый cursor перестанет указывать на правильную следующую запись. В нашей append-only модели поэтому нельзя использовать такую операцию как косметическую уборку. Требуется новый договор идентификаторов и продолжения, а не одна короткая строка удаления.
Для учебного расширения разрешим сбрасывать только уже обработанные значения, сохраняя длину и индексы. Это уменьшает число ненулевых записей, но не обещает ограниченную общую вместимость или универсальную экономию газа. Стоимость записи и возвраты газа зависят от профиля исполнения; мы их не измеряли.
// Новый исходник рядом с DisableQueue из урока 24.
pragma solidity 0.8.30;
import "./DisableQueue.sol";
contract DisableQueueWithCleanup is DisableQueue {
uint256 public cleanedCursor;
constructor(address publisher_) DisableQueue(publisher_) {}
function cleanup(uint256 limit) external returns (uint256 cleaned) {
if (limit == 0 || limit > MAX_BATCH) revert InvalidLimit();
while (cleaned < limit && cleanedCursor < cursor) {
delete pending[cleanedCursor];
cleanedCursor += 1;
cleaned += 1;
}
}
}
Функция не вызывает внешних адресов и не меняет disabled. Любой исполнитель может очистить уже завершённую область, потому что в выбранной политике эти номера больше не нужны on-chain обработчику. Если приложение требует сохранить их для контрактного чтения, такую политику применять нельзя. Право очистки связано с назначением данных, а не автоматически следует из слова cleanup.
Инвариант границ
Для этой версии ожидаем cleanedCursor не больше cursor, а cursor не больше pending.length. Очистка никогда не касается ещё не обработанного элемента. process продолжает читать от своего cursor, поэтому нулевые значения в прошлой области не становятся новыми задачами с courseId равным нулю.
Пусть массив хранит 17, 18, 19, а cursor равен двум. cleanup(1) сбрасывает первый элемент и устанавливает cleanedCursor в единицу. Следующий cleanup(20) сбрасывает второй и останавливается на cursor. Третий остаётся доступным для process. Длина всё ещё три. Эти результаты получены рассуждением по листингу, а не запуском транзакций.
Если издатель добавил задачу между очистками, общий конец меняется, но завершённая граница остаётся определяемой cursor. Конкурирующие вызовы cleanup читают текущее состояние при исполнении и продвигают собственный курсор. Нет сохранённого клиентского индекса, который разрешал бы повторное удаление чужой области.
Mapping не становится пустым от удаления списка
disabled хранит результат по номеру курса. Сброс pending не перечисляет ключи disabled и не делает их false. Если контракт должен поддержать отмену результатов, нужны отдельные явно разрешённые методы и предметная политика. Смешивать очистку следов задания с включением курса обратно опасно: это разные полномочия.
Также не рассчитываем на удаление структуры, которая содержит mapping, как на универсальный сброс всех вложенных значений. Для больших пространств ключей требуется заранее выбранная модель: отдельный список известных ключей с ограниченной очисткой, поколения данных или другой способ. Поколение логически перестаёт использовать старую область, но само по себе не стирает всю историческую информацию сети.
Если нужна действительно ограниченная активная очередь, можно спроектировать кольцевой буфер или mapping задач с устойчивыми head и tail и лимитом разницы. Это новая модель, где важно определить переполнение, повторное использование идентификаторов и незавершённые записи. Нельзя назвать такой переход исправлением одной строки в существующем append-only массиве.
История не исчезает вместе с текущим значением
Удаление storage не является обещанием конфиденциальности. Данные могли попасть в calldata, события, архивы и сторонние индексаторы. В исходной DisableQueue события назначения не введены, поэтому приложение не должно выдумывать их наличие. Если нужна удобная история, добавляем события в новой версии и определяем покрытие отдельно.
Для нашей библиотеки личные сведения читателя вообще не помещаются в эту очередь. Хранятся номера курсов и технические назначения. Удаление номера 17 после обработки не отзывает уже скачанный файл и не отменяет факт, что задача ранее существовала. On-chain состояние и распространённый материал живут в разных системах.
Мы получили ограниченную очистку без нарушения продолжения и честное описание того, чего она не делает. Следующая глава переключится на другую самостоятельную модель — обновляемый реестр, где договор хранения особенно важен при замене исполняемого кода.
Если очищенные номера нужны старому интерфейсу, его ответ обязан обозначить недоступность этой области. Нулевое значение не следует выдавать за исходное назначение курса с номером ноль.