Массивы, циклы и проблема газа

Массивы в Solidity есть, но цикл по неограниченному массиву — мина: он может упереться в лимит газа и навсегда заблокировать функцию.
«Раздай награды всем участникам в одном цикле» — звучит логично, а на деле это распространённый способ заморозить контракт. Газ не бесконечен.

Есть два вида массивов: фиксированные (uint256[5]) и динамические (uint256[]). У динамических есть push (добавить) и pop (убрать с конца), у обоих — length и индексация. Массивы можно держать в storage (постоянно) или в memory (временно, фиксированной длины).

Опасность цикла по массиву

Каждая итерация цикла тратит газ, а у блока есть жёсткий лимит. Если массив может расти без границ (например, список всех вкладчиков), однажды цикл по нему потребует больше газа, чем влезает в блок — и функция перестанет исполняться вообще. Это классический unbounded loop / DoS by gas.

   ЦИКЛ ПО РАСТУЩЕМУ МАССИВУ
   ========================
   users = [u1, u2, u3, ... uN]
   for u in users:  reward(u)   <- газ растёт линейно с N
                                   |
   при больших N -----------------+--> OUT OF GAS -> функция мертва

   РЕШЕНИЕ: pull-модель (каждый забирает сам) ИЛИ пагинация по диапазону
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Rewards {
    mapping(address => uint256) public owed; // pull-модель

    // ПЛОХО: цикл по неограниченному массиву (для иллюстрации)
    // function payAll(address[] memory users) external { ... for ... }

    // ХОРОШО: каждый забирает свою награду сам, без цикла
    function claim() external {
        uint256 amount = owed[msg.sender];
        require(amount > 0, "nothing");
        owed[msg.sender] = 0;            // эффект до взаимодействия
        payable(msg.sender).transfer(amount);
    }
}

Как работает под капотом (EVM/газ)

Лимит газа блока сейчас порядка 30 млн. Каждая итерация, особенно с записью в storage, легко стоит десятки тысяч газа. Несложно посчитать предел: если одна итерация — 25000 газа, то больше ~1000 элементов вы в одну транзакцию не обработаете. Поэтому массовые операции в блокчейне переводят на pull-модель: контракт записывает, сколько кому причитается, а каждый пользователь сам вызывает claim() — газ платит он, и за себя одного.

# Та же логика на Python: оценка газа цикла vs pull-модель
GAS_PER_ITER = 25000
BLOCK_GAS_LIMIT = 30_000_000

def can_pay_all(n_users):
    need = n_users * GAS_PER_ITER
    return need, need <= BLOCK_GAS_LIMIT

for n in (100, 1000, 5000):
    need, ok = can_pay_all(n)
    print(f"{n} юзеров -> {need} газа, влезает в блок: {ok}")

# pull-модель: каждый claim — константа, не зависит от числа юзеров
print("claim() одного юзера:", GAS_PER_ITER, "газа, независимо от N")

«Та же логика на Python ▶». При росте числа пользователей цикл перестаёт влезать в блок, а pull-модель остаётся константной — поэтому её и предпочитают.

Частые ошибки

  • Раздавать средства/награды циклом по массиву, который растёт от действий пользователей — рано или поздно DoS по газу.
  • Удалять элемент из середины массива через сдвиг — это O(n) записей в storage, очень дорого.
  • Хранить большие массивы в storage, когда хватило бы маппинга или событий.

Best practices

  • Предпочитайте pull-модель push-модели: пусть пользователь сам забирает причитающееся.
  • Если цикл неизбежен — ограничивайте его диапазоном (пагинация) и контролируйте максимальную длину.
  • Для удаления из массива без порядка используйте «swap-and-pop»: переставить последний элемент на место удаляемого и сделать pop — это O(1).

storage-массивы против memory-массивов

Ещё одно различие, на котором спотыкаются новички: массив в storage и массив в memory ведут себя по-разному. Storage-массив динамический, у него есть push/pop, он сохраняется между транзакциями. Memory-массив создаётся на время вызова и имеет фиксированную длину, заданную при создании: uint256[] memory tmp = new uint256[](n) — добавить элемент в него уже нельзя, только перезаписать по индексу. Это сделано из-за стоимости: динамический рост в памяти потребовал бы перевыделения, а EVM-память растёт только линейно и за неё тоже платят газ (квадратично при сильном расширении). Поэтому типичный приём — собрать результат во временный memory-массив фиксированного размера и вернуть его наружу, не трогая дорогой storage. А ещё помните про calldata: для внешних функций массивы-аргументы лучше принимать как calldata — это самый дешёвый, доступный только для чтения вид данных.

Итоги

Массивы удобны, но циклы по неограниченным массивам в блокчейне опасны из-за лимита газа. Pull-модель и пагинация спасают от DoS. Мы прошли все базовые структуры; дальше — токены ERC-20 и ERC-721.

Проверьте себя
1. Почему цикл по неограниченно растущему массиву опасен в смарт-контракте?
AОн всегда вызывает переполнение
BПри большом размере он превысит лимит газа блока и функция перестанет исполняться
CМассивы вообще запрещены
DОн копирует storage в memory
2. Какой паттерн избавляет от массового цикла раздачи средств?
Apush-модель — контракт сам всем платит в цикле
Bpull-модель — каждый пользователь сам вызывает claim за себя
Cхранение всех в одном слоте
Dиспользование tx.origin