китаев.tech

Checks-Effects-Interactions паттерн

Как писать функции в порядке Checks-Effects-Interactions: сначала проверки, затем изменения state, затем внешние вызовы.

CEI задаёт безопасный порядок кода

Checks-Effects-Interactions — это паттерн порядка операций в Solidity. Сначала функция проверяет входные данные, затем меняет state, и только в конце вызывает внешние адреса или контракты.

Этот порядок особенно важен для withdraw, refund, callback и любых функций, где контракт отправляет ETH или вызывает чужой контракт.


Пишем escrow с безопасным withdraw

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

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

contract SafeEscrow {
    mapping(address => uint256) public balances;

    event Deposited(address indexed user, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount);
    event Refunded(address indexed from, address indexed to, uint256 amount);

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

    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 {
        // 1. Checks: проверяем входные данные и текущий state.
        if (amount == 0) {
            revert ZeroAmount();
        }

        uint256 balance = balances[msg.sender];

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

        // 2. Effects: меняем state до внешнего вызова.
        balances[msg.sender] = balance - amount;

        // 3. Interactions: только теперь отправляем ETH наружу.
        (bool success, ) = msg.sender.call{value: amount}("");

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

        emit Withdrawn(msg.sender, amount);
    }

    function refund(address payable to, uint256 amount) external {
        // 1. Checks
        if (to == address(0)) {
            revert ZeroAddress();
        }

        if (amount == 0) {
            revert ZeroAmount();
        }

        uint256 balance = balances[msg.sender];

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

        // 2. Effects
        balances[msg.sender] = balance - amount;

        // 3. Interactions
        (bool success, ) = to.call{value: amount}("");

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

        emit Refunded(msg.sender, to, amount);
    }
}

Этот контракт хранит ETH-балансы пользователей. В withdraw и refund state уменьшается до отправки ETH, поэтому повторный внешний вызов уже увидит обновлённый баланс.


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

Checks — проверки до любых изменений: сумма не равна нулю, адрес не нулевой, баланса достаточно.

Effects — изменения state внутри вашего контракта: уменьшить balances[msg.sender], обновить счётчик, пометить заявку выполненной.

Interactions — внешние действия: call, вызов другого контракта, отправка ETH, ERC-721 safeTransferFrom, callback.

balances[msg.sender] = balance - amount — effect происходит до call. Если получатель окажется контрактом и попробует вызвать withdraw повторно, старого баланса уже не будет.

msg.sender.call{value: amount}("") — interaction. Этот вызов может передать управление внешнему контракту, поэтому он стоит в конце функции.

revert EthTransferFailed() после call — если отправка ETH не удалась, вся транзакция откатится, включая уменьшение баланса.


Неправильный порядок

Опасный вариант выглядит так:

solidity
function withdrawUnsafe(uint256 amount) external {
    uint256 balance = balances[msg.sender];

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

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

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

    balances[msg.sender] = balance - amount;
}

Здесь interaction происходит до effects. Если msg.sender — контракт, он может получить ETH и повторно вызвать withdrawUnsafe, пока balances[msg.sender] ещё не уменьшен.


Где применять CEI

Используйте CEI в функциях, которые отправляют ETH, вызывают внешние контракты или работают с callback-механиками.

Паттерн полезен не только для withdraw. Он нужен в аукционах, escrow, claim-функциях, refund-логике, NFT mint с callback и DeFi-интеграциях.

CEI не заменяет все защиты. Для сложных контрактов его часто комбинируют с nonReentrant, лимитами, allowlist и отдельными pause-механизмами.


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

Отправлять ETH до обновления баланса → сначала уменьшите или обнулите storage, потом делайте call.

Считать event частью Effectsemit не меняет state. Он пишет log для внешних приложений.

Думать, что CEI нужен только для ETH → любой внешний вызов может вернуть управление: токены с hooks, NFT callbacks, оракулы и произвольные контракты.


Что дальше

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