Checks-Effects-Interactions паттерн
Как писать функции в порядке Checks-Effects-Interactions: сначала проверки, затем изменения state, затем внешние вызовы.
CEI задаёт безопасный порядок кода
Checks-Effects-Interactions — это паттерн порядка операций в Solidity. Сначала функция проверяет входные данные, затем меняет state, и только в конце вызывает внешние адреса или контракты.
Этот порядок особенно важен для withdraw, refund, callback и любых функций, где контракт отправляет ETH или вызывает чужой контракт.
Пишем escrow с безопасным withdraw
Создайте файл contracts/SafeEscrow.sol:
// 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 не удалась, вся транзакция откатится, включая уменьшение баланса.
Неправильный порядок
Опасный вариант выглядит так:
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 частью Effects → emit не меняет state. Он пишет log для внешних приложений.
Думать, что CEI нужен только для ETH → любой внешний вызов может вернуть управление: токены с hooks, NFT callbacks, оракулы и произвольные контракты.
Что дальше
- Reentrancy — атака и защита — посмотрите, как нарушение CEI превращается в атаку
- Кастомные ошибки — Custom Errors — возвращайте точные причины отката в checks
- Events — объявление и emit — записывайте успешное действие после effects и interactions