Checks-Effects-Interactions и ReentrancyGuard

Простое правило порядка, которое закрывает целый класс атак.

Checks-Effects-Interactions (CEI) — паттерн, требующий выполнять операции строго в порядке: сначала проверки (checks), затем изменения собственного состояния (effects) и только в конце — внешние вызовы (interactions).

Идея паттерна

Реентранси возможна потому, что внешний вызов происходит, пока состояние ещё «врёт». Лечение очевидно: обновите состояние до того, как отдадите управление наружу. Тогда, даже если получатель повторно войдёт в функцию, проверки уже увидят актуальную картину (баланс обнулён) и отклонят повторный вывод.

// БЕЗОПАСНО: effect до interaction
function withdraw() external {
    uint256 amount = balances[msg.sender]; // checks
    require(amount > 0, "nothing");

    balances[msg.sender] = 0;              // effect: состояние обновлено ПЕРВЫМ

    (bool ok, ) = msg.sender.call{value: amount}(""); // interaction в конце
    require(ok, "transfer failed");
}

Теперь при повторном входе balances[msg.sender] уже равен нулю, и require(amount > 0) остановит атаку. Один лишь правильный порядок строк закрывает уязвимость.

Второй рубеж: ReentrancyGuard

CEI достаточно, но люди ошибаются, особенно в сложных функциях с несколькими внешними вызовами. Поэтому добавляют «замок» — модификатор nonReentrant из библиотеки OpenZeppelin. Он ставит флаг «вход» в начале функции и снимает в конце; повторный вход натыкается на флаг и откатывается.

// Эшелон 2: замок от повторного входа (OpenZeppelin)
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract Vault is ReentrancyGuard {
    function withdraw() external nonReentrant {
        // ... CEI внутри + защита замком снаружи
    }
}

Как работает под капотом

Модификатор хранит переменную состояния (например, _status). Вход в защищённую функцию проверяет, что статус «не занят», ставит «занят», выполняет тело и в конце возвращает «свободно». Любой повторный вход внутри той же транзакции видит «занят» и падает с ошибкой. Это дешёвый, но мощный страховочный механизм поверх CEI.

enum { NOT_ENTERED, ENTERED }
status = NOT_ENTERED

function guarded():
    require(status == NOT_ENTERED)   // повторный вход -> revert
    status = ENTERED
    ... тело ...
    status = NOT_ENTERED

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

  • Полагаться только на guard, нарушая CEI. Guard не защищает от кросс-функциональной реентранси (повторный вход в другую функцию). Делайте и то, и другое.
  • Забыть навесить nonReentrant на одну из функций, читающих/меняющих общий баланс.
  • Считать transfer() панацеей. Лимит газа 2300 у transfer — ненадёжная защита; ориентируйтесь на CEI + guard.

Итоги

  • CEI: проверки → изменение своего состояния → внешние вызовы. Это базовая защита.
  • Обновляйте баланс ДО внешнего вызова — повторный вход тогда упрётся в проверку.
  • ReentrancyGuard (nonReentrant) — страховочный «замок» поверх CEI.
  • Применяйте оба рубежа и не забывайте про кросс-функциональную реентранси.
Проверьте себя
1. Какой порядок предписывает паттерн CEI?
AInteractions → Effects → Checks
BChecks → Effects → Interactions
CEffects → Checks → Interactions
DInteractions → Checks → Effects
2. Почему обновление баланса ДО внешнего вызова защищает от реентранси?
AУскоряет транзакцию
BПри повторном входе проверки уже видят обновлённое состояние и откатывают атаку
CСнижает комиссию
DШифрует баланс
3. Что важно помнить про ReentrancyGuard?
AОн полностью заменяет CEI
BОн защищает даже от кросс-функциональной реентранси сам по себе
CЭто страховка поверх CEI, оба рубежа нужны вместе
DОн увеличивает скорость сети