$ sudo teach IT
Модуль 5 из 7

Деньги: приём, вывод и возврат

Самая ответственная часть контракта - та, где двигаются деньги. Разберём, как принимать эфир, как безопасно его отправлять и почему порядок двух строк здесь важнее всего остального кода.

Вы узнаете
  • функции receive и fallback - как контракт получает «просто перевод»;
  • три способа отправить эфир: transfer, send, call - и почему сегодня выбирают call;
  • паттерн «вывод по запросу» (pull) и правило «проверки - эффекты - взаимодействие».

5.1Как контракт принимает эфир

Эфир попадает в контракт тремя путями: через payable-функцию (как наш contribute), при развёртывании и при «голом» переводе без данных. Для последнего случая есть две особые функции:

Функция Когда срабатывает
receive() external payable Пришёл эфир без данных (обычный перевод). Главный приёмник «пустых» платежей.
fallback() external payable Вызвали несуществующую функцию или прислали данные, не совпавшие ни с одной функцией.
solidity
// позволяет вносить вклад обычным переводом на адрес контракта
receive() external payable {
    contribute();
}
Нет payable - нет денег

Если у контракта нет ни одной 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). Сегодня рекомендуемый способ.
solidity
// рекомендуемый способ перевода эфира сегодня
(bool ok, ) = recipient.call{value: amount}("");
require(ok, "transfer failed");
Почему transfer больше не советуют

Лимит в 2300 газа задумывался как защита, но он жёстко привязан к стоимости операций EVM. После изменений в стоимости газа контракты-получатели с чуть более сложной логикой приёма (например, кошельки-контракты) стали не вписываться в 2300 - и переводы к ним начали срываться. Поэтому современная практика - call с явной проверкой результата. Но у call есть своя цена: он передаёт управление получателю, и тут возникает риск повторного входа - об этом урок 5.4. С версии 0.8.31 компилятор прямо помечает transfer и send как устаревшие, а в версии 0.9 их уберут из языка.

5.3Push против pull: кто инициирует выплату

Есть два подхода к раздаче денег, и от выбора между ними зависит, переживёт ли наш возврат вкладов одного вредного вкладчика.

Подход Как устроен Проблема / плюс
Push Контракт сам рассылает деньги всем в цикле. Один «плохой» получатель (чей приём откатывает) ломает выплату всем. Плюс дорогой цикл.
Pull Контракт лишь помечает, кому сколько должен; каждый сам забирает своё отдельным вызовом. Надёжно: сбой у одного не мешает остальным. Предпочтительный паттерн.

- Но рассылать деньги самому удобнее: пользователю не нужно ничего нажимать.

- Удобнее, пока все получатели ведут себя прилично. Хватит одного контракта, который откатывает любой входящий перевод, - и цикл рассылки падает целиком, а деньги не получает никто. При pull каждый забирает своё сам, и чужая вредность ему не мешает.

Поэтому возврат вкладов мы делаем именно по модели pull: вкладчик сам зовёт refund() и забирает свои деньги.

solidity
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 (проверки → эффекты → взаимодействие):

Схема
1. Checks
проверить условия и права (require)
2. Effects
изменить своё состояние (обнулить вклад)
3. Interactions
вызвать внешний мир (отправить эфир)

Когда дело доходит до внешнего вызова, вклад уже равен нулю, и повторный заход упрётся в require(amount > 0): вытягивать больше нечего. В продвинутом курсе мы добавим ещё и «замок» nonReentrant, а в курсе по аудиту разберём этот класс атак во всех деталях. А пока повторяйте как мантру: сначала меняем своё состояние, чужих зовём в последнюю очередь.

5.5Вывод средств создателем

Если же сбор удался, собранное забирает создатель кампании. Порядок действий тот же, плюс флаг, чтобы вывести деньги можно было ровно один раз, а не сколько душе угодно.

solidity
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 - сначала проверки, потом изменение своего состояния, и лишь в самом конце внешний вызов. Теперь у нас есть все части краудфандинга - в следующем модуле собираем их в единый контракт.

Дальше · модуль 6

Все детали собраны по отдельности. Дальше соединим их в один контракт, пройдём его строка за строкой и развернём в Remix с настоящими кнопками и тестовым эфиром.