Checks-Effects-Interactions в Solidity: порядок действий
Базовый реестр не вызывает внешних получателей. Для изучения взаимодействий создадим отдельный CEIRefundLedger: лабораторный учёт возврата внесённого ETH. Он не подключается к CourseCatalog и не продаёт уроки. Средства и проверки не запускаются; код служит собственным примером состояния перед внешним call.
У каждого адреса есть credit в wei. deposit увеличивает обязательство перед непосредственным отправителем на внесённое msg.value. withdraw уменьшает его обязательство и пытается отправить соответствующую сумму выбранному получателю. Смысл credit важнее названия метода: это сумма, которую адрес ещё вправе потребовать по показанному учёту.
Полный учебный ledger
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
contract CEIRefundLedger {
error InvalidAmount();
error TransferFailed();
mapping(address => uint256) public credit;
event Deposited(address indexed account, uint256 amount);
event Withdrawn(address indexed account, address indexed to, uint256 amount);
function deposit() external payable {
if (msg.value == 0) revert InvalidAmount();
credit[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value);
}
function withdraw(uint256 amount, address payable to) external {
uint256 available = credit[msg.sender];
if (amount == 0 || amount > available || to == address(0))
revert InvalidAmount();
credit[msg.sender] = available - amount;
(bool ok,) = to.call{value: amount}("");
if (!ok) revert TransferFailed();
emit Withdrawn(msg.sender, to, amount);
}
}
Первая часть withdraw проверяет входы и доступное обязательство. Вторая записывает его уменьшение. Только потом передаёт управление получателю. Это конкретное применение Checks–Effects–Interactions. Не просто порядок комментариев, а момент, когда внешний код получает возможность читать и вызывать методы при уже обновлённом ресурсе.
Один возврат
Пусть R внёс 10 условных wei через deposit. Его credit равен 10. Он вызывает withdraw(4,Q). До внешнего call credit[R] становится 6. Если Q успешно принимает, состояние сохраняется, ETH перемещается и испускается Withdrawn с конкретными account, to и amount.
Величина в wei здесь специально мала и условна. Она не включает gas платежа вызывающего и не является балансом его кошелька после транзакции. Сумма возврата и стоимость исполнения — разные числовые эффекты. Нельзя сравнить их как будто ledger обещает компенсировать все сетевые расходы.
Если Q отклоняет приём, ok становится false, а TransferFailed откатывает withdraw. Credit R возвращается к 10; частичный успешный возврат не сохраняется. Поэтому списание перед call не означает окончательную потерю обязательства при ошибке получателя.
Повторный вызов внутри приёма
Q может быть контрактом и выполнить код во время call. Если Q также является непосредственным владельцем credit и вызывает withdraw повторно, он увидит уже уменьшенное значение. Повтор не может использовать прежнее большое available только потому, что внешний вызов ещё не вернулся.
Если Q отличается от R, повторный withdraw от Q относится к credit[Q], а не к credit[R]. Аргумент to задаёт получателя, но не выбирает произвольный аккаунт списания. Это ещё одна граница, которую легко пропустить при рассуждении только о «повторном выводе» без указания msg.sender.
Новый deposit во время callback тоже возможен по публичному интерфейсу. Он увеличивает credit непосредственного отправителя на реально переданное значение. Показанный withdraw после возврата не переписывает credit старым снимком, поэтому такое допустимое изменение не теряется из-за позднего повторного присваивания.
Инвариант обязательств
У этой модели полезное свойство: сумма доступных credit не должна превышать ETH, обеспечивающий их возврат. Но контракт не хранит список всех ключей mapping, поэтому он не вычисляет такую сумму общим циклом в каждом методе. Для будущего анализа нужен согласованный внешний перечень или отдельный агрегированный учёт.
Баланс адреса может включать ETH, полученный иными допустимыми способами без deposit, поэтому равенство balance=sumCredits не является универсальным требованием. Неучтённое поступление может создать излишек, не создавая новое пользовательское обязательство. Именно неравенство и определённые переходы важны для данного договора.
В показанном коде нет административного вывода излишка, процентов, продажи или платежа третьей стороне. Не следует изобретать их ожидаемые последствия в проверке. Если новый метод появится, его влияние на общий ресурс будет отдельной частью карты маршрутов.
Почему не заменить call случайным ограничением
Передача через call даёт получателю возможность исполнить свой код. Ограничение gas либо старый выбор transfer не заменяют согласованный учёт. Поведение gas и требований получателя зависит от сети и программы, поэтому безопасность не стоит строить только на предположении, что у другого адреса никогда не хватит ресурсов на действие.
CEI даёт понятный порядок собственных записей. Дополнительный ReentrancyGuard может ограничить определённые повторные входы, но он не исправит неправильную авторизацию или недостаточный баланс обязательств. Позже мы рассмотрим несколько методов одного ресурса и увидим, почему требуется анализ всех путей.
Ожидаемые границы
Нулевой amount отклоняется нашей проверкой. Amount больше credit тоже. Нулевой получатель отклоняется отдельно, хотя raw call к такому адресу имеет свой технический смысл. Мы выбрали предметный запрет нежелательного получателя вместо предположения, что тип address payable сам его исключает.
Повторный обычный withdraw после полного списания отклонится из-за нулевого доступного credit. Частичный повтор на оставшуюся допустимую сумму разрешён. В этом нет нарушения: пользователь расходует оставшееся обязательство. Правило «любой второй вызов запрещён» было бы другим договором.
Разбор перед исполнением
Для задания нарисуйте состояние credit[R] до проверки, после effects, внутри callback и после возврата. Первый вариант имеет успешного Q, второй отклоняющего Q, третий получателя с новым deposit. Укажите, какое значение увидит каждый внешний путь и что откатывается при ошибке.
Этот разбор показывает, зачем готовить ресурс до передачи управления. В следующей главе мы специально переставим запись в собственной ошибочной модели и проследим, какое устаревшее обязательство становится повторно доступным. Никакие выводы средств или учебные проверки при написании не выполняются.
При подготовке вывода приложение сохраняет и account, и to. Они могут различаться по нашему договору: R расходует собственный credit, выбирая Q получателем. Если экран покажет только Q как владельца обязательства, история станет неверной. Адрес списания определяется msg.sender, а адрес назначения параметром, поэтому обе величины нужны для осмысленного разбора результата.
Допустимый другой получатель полезен, если текущий адрес умеет отправлять вызовы, но отклоняет приём ETH. Однако это возможность выбранного владельца обязательства, а не публичное право X вывести credit R. Наш интерфейс не принимает произвольный account для списания.
Для самостоятельного уточнения: security-considerations.html, security-considerations.html.
Оглавление курса. Листинги, расчёты и наблюдения здесь разобраны как ожидаемое поведение; компиляция, учебные программы и проверки при написании не запускались.