К основному содержимому

Введите слово из вопроса или названия статьи.

3 · Под капотом

Пишем эскроу на Solidity: конечный автомат и разбор реального контракта

Как устроен эскроу-контракт в коде: статусы, переходы, проверки доступа, события и расчёт выплат. Минимальная версия с нуля и сравнение с боевым контрактом.

7 мин чтенияОбновлено:
В этой статье

Эскроу-контракт - отличный учебный пример: он маленький, но в нём есть всё важное - состояние, права доступа, деньги, события и защита от ошибок. Соберём минимальную версию с нуля, а затем посмотрим, чем отличается боевая.

Идея: конечный автомат

Сделка в любой момент находится в одном из состояний, и перейти между ними можно только по заранее известным правилам. Такая модель называется конечным автоматом (state machine). Для эскроу она выглядит так:

text
createOffer ──> Offered ──acceptOffer──> Active ──confirmDelivery──────────> Settled
                   │                       │
                   │                       ├─ setCancelIntent (оба) ────────> Settled
                   │                       ├─ claimExpired (после срока) ───> Settled
                   │                       └─ openDispute ──> Disputed
                   │                                            ├─ resolveDispute ──────> Settled
                   └─ cancelOffer ──> Settled                   └─ claimArbiterTimeout ─> Settled

Состояние Settled - единственное конечное: из него нет выхода. Выплата происходит ровно один раз, в момент перехода в Settled. Это важнейшее свойство для безопасности: если выплата привязана к необратимому переходу, её нельзя повторить.

Минимальная версия

Начнём с самой простой сделки: покупатель кладёт ETH, продавца определяет предложение, покупатель подтверждает - деньги уходят продавцу.

solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

contract MiniEscrow {
    enum Status { None, Funded, Settled }

    struct Deal {
        address buyer;
        uint96 amount; // 20 + 12 байт = один слот storage
        address seller;
        Status status;
    }

    mapping(uint256 => Deal) private _deals;
    uint256 public dealCount;

    event Created(uint256 indexed id, address indexed buyer, address indexed seller, uint256 amount);
    event Released(uint256 indexed id, uint256 amount);

    error ZeroAmount();
    error AmountTooLarge();
    error NotAuthorized();
    error WrongStatus(Status expected, Status actual);
    error TransferFailed();

    function create(address seller) external payable returns (uint256 id) {
        if (msg.value == 0) revert ZeroAmount();
        if (msg.value > type(uint96).max) revert AmountTooLarge();

        id = dealCount++;
        _deals[id] = Deal(msg.sender, uint96(msg.value), seller, Status.Funded);
        emit Created(id, msg.sender, seller, msg.value);
    }

    function release(uint256 id) external {
        Deal storage d = _deals[id];
        if (d.status != Status.Funded) revert WrongStatus(Status.Funded, d.status);
        if (msg.sender != d.buyer) revert NotAuthorized();

        d.status = Status.Settled;   // 1. сначала меняем состояние
        uint256 amount = d.amount;

        (bool ok, ) = d.seller.call{value: amount}("");   // 2. потом отправляем деньги
        if (!ok) revert TransferFailed();

        emit Released(id, amount);
    }
}

Разберём, что здесь важно.

Структура и enum. Статус - перечисление: None (по умолчанию, значит «сделки нет»), Funded, Settled. Нулевое значение намеренно «пустое»: обращение к несуществующей сделке вернёт статус None и будет отвергнуто проверкой.

Проверки в начале функции. Сначала статус, потом права: функция откатывается, если сделка не в нужном состоянии или вызывающий не покупатель. Именно такой порядок - «проверки, эффекты, взаимодействия» - мы разбираем в статье про реентрантность.

Пользовательские ошибки вместо строк require("..."): компактнее, дешевле по газу и однозначно кодируются (ABI).

Защита от переполнения. uint96(msg.value) молча обрезал бы большое число, поэтому перед приведением мы явно проверяем AmountTooLarge. С версии 0.8 арифметика проверяется сама, но явное приведение типов остаётся источником тихих ошибок.

Счётчик dealCount++ выдаёт номера сделок по порядку: 0, 1, 2… Первая сделка получает номер 0.

Чем отличается боевой контракт

Минимальная версия работает, но у неё много пробелов. Боевой контракт закрывает их:

ПробелКак закрыт в боевом контракте
Нет третьей стороныГарант, спор, решение долей от 0 до 100%
Продавец должен принятьПредложение живёт 7 дней, принять может только вторая сторона
Нет срокаСрок 1/7/30/90 дней, возврат после истечения
Нет комиссий1% платформе, базовая и спорная ставки гаранта
Только ETHERC-20 и нативная валюта; проверка полученной суммы
Получатель может заблокировать выплату«Отложенные платежи» (credits), см. pull-платежи
Нет защиты от повторного входаReentrancyGuard на каждой функции с деньгами
Нет остановкиПауза: блокирует только новые сделки, но не выход из существующих
Невидимость условийХеш условий в сделке, который проверяется при принятии

Расчёт выплат - самое осторожное место

Расчёт долей - чистая арифметика, поэтому вынесен в отдельную функцию, которую удобно тестировать:

solidity
function _split(uint256 amount, uint16 arbiterFeeBps, uint16 buyerShareBps)
    private pure
    returns (uint256 buyerAmount, uint256 sellerAmount, uint256 arbiterFee, uint256 platformFee)
{
    platformFee = (amount * PLATFORM_FEE_BPS) / BPS;      // 1% от суммы
    uint256 net = amount - platformFee;

    arbiterFee = (net * arbiterFeeBps) / BPS;             // ставка гаранта от остатка
    uint256 pot = net - arbiterFee;

    buyerAmount = (pot * buyerShareBps) / BPS;
    sellerAmount = pot - buyerAmount;                      // остаток - вычитанием
}

Деньги измеряются в базисных пунктах (bps): 1% = 100 bps, 100% = 10 000. Целочисленное деление округляет вниз, поэтому «копейки» неизбежно теряются при делении. Ключевая деталь - последняя строка: доля продавца считается вычитанием, а не умножением. Если бы обе доли считались через деление, сумма частей могла бы оказаться меньше целого (или, наоборот, больше), и контракт остался бы должен или отдал бы лишнее. Вычитание гарантирует, что части всегда в сумме дают целое.

Единая точка выплаты

В боевом контракте все исходы проходят через одну функцию _settle. Она сначала записывает итог, а потом отправляет деньги:

solidity
function _settle(uint256 dealId, Deal storage d, uint16 buyerShareBps,
                 uint16 arbiterFeeBps, Outcome outcome) private {
    // ... вычисляем доли через _split ...

    // Эффекты
    d.status = Status.Settled;
    d.outcome = outcome;
    platformFees[token] += platformFee;
    emit DealSettled(dealId, outcome, buyerAmount, sellerAmount, arbiterFee, platformFee);

    // Взаимодействия
    if (arbiterFee != 0)  _payout(token, arbiter, arbiterFee);
    if (buyerAmount != 0) _payout(token, buyer,   buyerAmount);
    if (sellerAmount != 0) _payout(token, seller, sellerAmount);
}

Преимущество единой точки: любую выплату можно проверить в одном месте, а каждый исход - подтверждение, отмена, срок, спор, тайм-аут - выбирает параметры и вызывает один и тот же код. Различие между ними - только в том, какую ставку гаранта и какую долю покупателя передать. Так «исход» превращается в чистые данные, а не в разветвлённую логику.

Права доступа

Контракт знает, кто есть кто, потому что роли записаны в сделке. Проверки выглядят так:

solidity
if (msg.sender != d.buyer) revert NotAuthorized();            // только покупатель
if (msg.sender != d.arbiter) revert NotAuthorized();          // только гарант
if (msg.sender != d.buyer && msg.sender != d.seller) revert NotAuthorized(); // одна из сторон

msg.sender - тот, кто подписал вызов. Подделать его нельзя: это адрес, определённый подписью транзакции.

События - летопись сделки

Каждый переход записывает событие: OfferCreated, OfferAccepted, DisputeOpened, DealSettled. Именно из них интерфейс строит историю сделки, а наблюдатели узнают о случившемся, не читая состояние.

Что почитать дальше

Как контракт считает доли без потерь - в статье целые числа и округление, а как упакованы данные сделки - в разборе хранилища. Затем - о том, кто и что может вызывать: шаблоны контроля доступа. И самое опасное место любого контракта с деньгами - передача управления внешнему адресу: реентрантность и pull-платежи.

Упоминается в статьях

Готовы попробовать?

Создайте первую сделку за пару минут. Деньги блокируются в смарт-контракте и выплачиваются по правилам, которые видны заранее.