Деньги: приём, вывод и возврат
Самая ответственная часть контракта - та, где двигаются деньги. Разберём, как принимать эфир, как безопасно его отправлять и почему порядок двух строк здесь важнее всего остального кода.
- функции receive и fallback - как контракт получает «просто перевод»;
- три способа отправить эфир: transfer, send, call - и почему сегодня выбирают call;
- паттерн «вывод по запросу» (pull) и правило «проверки - эффекты - взаимодействие».
5.1Как контракт принимает эфир
Эфир попадает в контракт тремя путями: через payable-функцию (как наш contribute), при развёртывании и при «голом» переводе без данных. Для последнего случая есть две особые функции:
| Функция | Когда срабатывает |
|---|---|
receive() external payable |
Пришёл эфир без данных (обычный перевод). Главный приёмник «пустых» платежей. |
fallback() external payable |
Вызвали несуществующую функцию или прислали данные, не совпавшие ни с одной функцией. |
// позволяет вносить вклад обычным переводом на адрес контракта receive() external payable { contribute(); }
Если у контракта нет ни одной payable-функции и нет receive, любой обычный перевод эфира на него откатится. Это защита: деньги не застрянут случайно. Но она же означает, что приём средств надо предусмотреть осознанно. Оговорка: эфир всё же можно зачислить принудительно, через selfdestruct другого контракта, и тогда никакой ваш код не выполнится. Поэтому не стройте логику на address(this).balance, а ведите собственный счётчик собранного. В нашем краудфандинге это будет переменная totalRaised.
5.2Как контракт отправляет эфир
Отправить эфир можно тремя способами. Появлялись они по очереди: каждый следующий чинил проблемы предыдущего и, как водится, приносил свои.
| Способ | Поведение |
|---|---|
addr.transfer(x) |
Шлёт x вей, при неудаче сразу откатывает. Жёстко ограничивает получателя 2300 газа. |
addr.send(x) |
То же ограничение 2300 газа, но при неудаче возвращает false, а не откатывает. Легко забыть проверить результат. |
addr.call{value: x}("") |
Пересылает почти весь доступный газ (63/64 остатка), возвращает (bool success, bytes data). Сегодня рекомендуемый способ. |
// рекомендуемый способ перевода эфира сегодня (bool ok, ) = recipient.call{value: amount}(""); require(ok, "transfer failed");
Лимит в 2300 газа задумывался как защита, но он жёстко привязан к стоимости операций EVM. После изменений в стоимости газа контракты-получатели с чуть более сложной логикой приёма (например, кошельки-контракты) стали не вписываться в 2300 - и переводы к ним начали срываться. Поэтому современная практика - call с явной проверкой результата. Но у call есть своя цена: он передаёт управление получателю, и тут возникает риск повторного входа - об этом урок 5.4. С версии 0.8.31 компилятор прямо помечает transfer и send как устаревшие, а в версии 0.9 их уберут из языка.
5.3Push против pull: кто инициирует выплату
Есть два подхода к раздаче денег, и от выбора между ними зависит, переживёт ли наш возврат вкладов одного вредного вкладчика.
| Подход | Как устроен | Проблема / плюс |
|---|---|---|
| Push | Контракт сам рассылает деньги всем в цикле. | Один «плохой» получатель (чей приём откатывает) ломает выплату всем. Плюс дорогой цикл. |
| Pull | Контракт лишь помечает, кому сколько должен; каждый сам забирает своё отдельным вызовом. | Надёжно: сбой у одного не мешает остальным. Предпочтительный паттерн. |
- Но рассылать деньги самому удобнее: пользователю не нужно ничего нажимать.
- Удобнее, пока все получатели ведут себя прилично. Хватит одного контракта, который откатывает любой входящий перевод, - и цикл рассылки падает целиком, а деньги не получает никто. При pull каждый забирает своё сам, и чужая вредность ему не мешает.
Поэтому возврат вкладов мы делаем именно по модели pull: вкладчик сам зовёт refund() и забирает свои деньги.
function refund() external { require(block.timestamp >= deadline, "too early"); require(totalRaised < goal, "goal reached"); // провал сбора uint256 amount = contributions[msg.sender]; require(amount > 0, "nothing to refund"); contributions[msg.sender] = 0; // СНАЧАЛА обнуляем долг (bool ok, ) = msg.sender.call{value: amount}(""); // ПОТОМ отправляем require(ok, "refund failed"); emit Refunded(msg.sender, amount); }
5.4Порядок решает всё: проверки - эффекты - взаимодействие
Обратите внимание на порядок в refund: сначала мы обнулили запись о вкладе и только потом отправили деньги. Вкусовщиной такой порядок не назовёшь - это защита от самой знаменитой уязвимости в истории смарт-контрактов, повторного входа (reentrancy).
Когда вы шлёте эфир через call, управление на миг переходит к получателю. А если получатель - вредоносный контракт? Прямо в этот момент он снова вызывает ваш refund(). Вклад ещё не обнулён, проверка amount > 0 снова проходит - и он вытягивает деньги ещё раз. И ещё. И ещё, пока контракт не опустеет. Именно так в 2016 году был взломан The DAO на 60 млн долларов.
Защита - дисциплина, которую называют Checks-Effects-Interactions (проверки → эффекты → взаимодействие):
Когда дело доходит до внешнего вызова, вклад уже равен нулю, и повторный заход упрётся в require(amount > 0): вытягивать больше нечего. В продвинутом курсе мы добавим ещё и «замок» nonReentrant, а в курсе по аудиту разберём этот класс атак во всех деталях. А пока повторяйте как мантру: сначала меняем своё состояние, чужих зовём в последнюю очередь.
5.5Вывод средств создателем
Если же сбор удался, собранное забирает создатель кампании. Порядок действий тот же, плюс флаг, чтобы вывести деньги можно было ровно один раз, а не сколько душе угодно.
bool public withdrawn; function withdraw() external onlyOwner { require(block.timestamp >= deadline, "too early"); require(totalRaised >= goal, "goal not reached"); require(!withdrawn, "already withdrawn"); withdrawn = true; // эффект до взаимодействия uint256 amount = address(this).balance; (bool ok, ) = owner.call{value: amount}(""); require(ok, "withdraw failed"); }
Эфир принимают payable-функции и receive; отправляют сегодня через call с проверкой результата. Деньги раздают по модели pull - каждый забирает своё сам. И главное правило безопасности: Checks-Effects-Interactions - сначала проверки, потом изменение своего состояния, и лишь в самом конце внешний вызов. Теперь у нас есть все части краудфандинга - в следующем модуле собираем их в единый контракт.
Все детали собраны по отдельности. Дальше соединим их в один контракт, пройдём его строка за строкой и развернём в Remix с настоящими кнопками и тестовым эфиром.