китаев.tech

ReentrancyGuard

Как работает ReentrancyGuard в Solidity: modifier nonReentrant, lock-флаг и защита функций с внешними вызовами.

ReentrancyGuard блокирует повторный вход

ReentrancyGuard — это защитный паттерн, который не даёт повторно войти в функцию, пока первый вызов ещё выполняется.

Его используют для функций с внешними вызовами: withdraw, refund, claim, callback, отправка ETH или вызов чужого контракта. Guard не заменяет Checks-Effects-Interactions, а добавляет второй слой защиты.


Пишем vault с nonReentrant

Создайте файл contracts/LockedVault.sol:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

abstract contract ReentrancyGuard {
    bool private locked;

    error Reentrancy();

    modifier nonReentrant() {
        if (locked) {
            revert Reentrancy();
        }

        locked = true;
        _;
        locked = false;
    }
}

contract LockedVault is ReentrancyGuard {
    mapping(address => uint256) public balances;
    mapping(address => bool) public bonusClaimed;

    uint256 public constant BONUS = 0.01 ether;

    event Deposited(address indexed user, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount);
    event BonusClaimed(address indexed user, uint256 amount);

    error ZeroAmount();
    error NotEnoughBalance(uint256 available, uint256 required);
    error BonusAlreadyClaimed();
    error EthTransferFailed();

    receive() external payable {}

    function deposit() external payable {
        if (msg.value == 0) {
            revert ZeroAmount();
        }

        balances[msg.sender] += msg.value;

        emit Deposited(msg.sender, msg.value);
    }

    function withdraw(uint256 amount) external nonReentrant {
        if (amount == 0) {
            revert ZeroAmount();
        }

        uint256 balance = balances[msg.sender];

        if (balance < amount) {
            revert NotEnoughBalance(balance, amount);
        }

        balances[msg.sender] = balance - amount;

        (bool success, ) = msg.sender.call{value: amount}("");

        if (!success) {
            revert EthTransferFailed();
        }

        emit Withdrawn(msg.sender, amount);
    }

    function claimBonus() external nonReentrant {
        if (bonusClaimed[msg.sender]) {
            revert BonusAlreadyClaimed();
        }

        bonusClaimed[msg.sender] = true;

        (bool success, ) = msg.sender.call{value: BONUS}("");

        if (!success) {
            revert EthTransferFailed();
        }

        emit BonusClaimed(msg.sender, BONUS);
    }
}

Этот контракт хранит депозиты и даёт один бонус на адрес. withdraw и claimBonus отправляют ETH наружу, поэтому обе функции защищены nonReentrant.


Как это работает

bool private locked — lock-флаг. Пока он true, повторный вход запрещён.

modifier nonReentrant() — проверяет lock, включает его перед телом функции и выключает после выполнения.

locked = true — effect до внешнего вызова. Если получатель ETH попробует повторно вызвать защищённую функцию, проверка увидит locked == true.

_ — место, где выполняется тело функции withdraw или claimBonus.

locked = false — освобождает lock после завершения функции. Если тело функции откатится, вся транзакция откатится, и state вернётся в исходное состояние.

function withdraw(...) external nonReentrant — защищает вывод ETH от повторного входа через receive() или fallback() вызывающего контракта.


CEI всё равно нужен

ReentrancyGuard не даёт повторно войти в функцию, но порядок кода всё равно должен быть правильным.

В withdraw сначала идут checks: сумма не ноль, баланса достаточно. Затем effects: balances[msg.sender] = balance - amount. Только потом interaction: call{value: amount}.

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


Ограничение одного lock

Если две функции помечены nonReentrant, одна не может вызвать другую напрямую:

solidity
function first() external nonReentrant {
    second(); // так не скомпилируется, если second external
}

function second() external nonReentrant {
    // ...
}

Обычный паттерн: внешняя функция с nonReentrant вызывает приватную функцию без modifier.

solidity
function withdrawAll() external nonReentrant {
    _withdraw(balances[msg.sender]);
}

function _withdraw(uint256 amount) private {
    // общая логика вывода
}

Так lock остаётся на внешней границе, а внутренняя логика переиспользуется без повторного входа в modifier.


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

Ставить nonReentrant вместо CEI → guard дополняет безопасный порядок, но не отменяет его.

Защитить только одну функцию с общим state → если withdraw защищён, а claim использует тот же баланс и не защищён, reentrancy может пройти через claim.

Делать внешние вызовы в modifiernonReentrant должен быть простым lock. Внешние вызовы внутри modifier усложняют анализ безопасности.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.