Пишем эскроу на Solidity: конечный автомат и разбор реального контракта
Как устроен эскроу-контракт в коде: статусы, переходы, проверки доступа, события и расчёт выплат. Минимальная версия с нуля и сравнение с боевым контрактом.
В этой статье
Эскроу-контракт - отличный учебный пример: он маленький, но в нём есть всё важное - состояние, права доступа, деньги, события и защита от ошибок. Соберём минимальную версию с нуля, а затем посмотрим, чем отличается боевая.
Идея: конечный автомат
Сделка в любой момент находится в одном из состояний, и перейти между ними можно только по заранее известным правилам. Такая модель называется конечным автоматом (state machine). Для эскроу она выглядит так:
createOffer ──> Offered ──acceptOffer──> Active ──confirmDelivery──────────> Settled
│ │
│ ├─ setCancelIntent (оба) ────────> Settled
│ ├─ claimExpired (после срока) ───> Settled
│ └─ openDispute ──> Disputed
│ ├─ resolveDispute ──────> Settled
└─ cancelOffer ──> Settled └─ claimArbiterTimeout ─> SettledСостояние Settled - единственное конечное: из него нет выхода. Выплата происходит ровно один раз, в момент перехода в Settled. Это важнейшее свойство для безопасности: если выплата привязана к необратимому переходу, её нельзя повторить.
Минимальная версия
Начнём с самой простой сделки: покупатель кладёт ETH, продавца определяет предложение, покупатель подтверждает - деньги уходят продавцу.
// 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% платформе, базовая и спорная ставки гаранта |
| Только ETH | ERC-20 и нативная валюта; проверка полученной суммы |
| Получатель может заблокировать выплату | «Отложенные платежи» (credits), см. pull-платежи |
| Нет защиты от повторного входа | ReentrancyGuard на каждой функции с деньгами |
| Нет остановки | Пауза: блокирует только новые сделки, но не выход из существующих |
| Невидимость условий | Хеш условий в сделке, который проверяется при принятии |
Расчёт выплат - самое осторожное место
Расчёт долей - чистая арифметика, поэтому вынесен в отдельную функцию, которую удобно тестировать:
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. Она сначала записывает итог, а потом отправляет деньги:
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);
}Преимущество единой точки: любую выплату можно проверить в одном месте, а каждый исход - подтверждение, отмена, срок, спор, тайм-аут - выбирает параметры и вызывает один и тот же код. Различие между ними - только в том, какую ставку гаранта и какую долю покупателя передать. Так «исход» превращается в чистые данные, а не в разветвлённую логику.
Права доступа
Контракт знает, кто есть кто, потому что роли записаны в сделке. Проверки выглядят так:
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-платежи.
Читайте также
- 3 · Под капотом
Реентрантность, checks-effects-interactions и pull-платежи
Как работает атака повторного входа, взломавшая The DAO, и как от неё защищаться: порядок операций, ReentrancyGuard и отложенные платежи на примере эскроу.
5 мин чтения - 3 · Под капотом
Шаблоны контроля доступа в смарт-контрактах: owner, роли и стороны сделки
Как контракты решают, кому что можно: Ownable и Ownable2Step, роли, проверка сторон сделки, паузы и минимальные права. Разбор на примере эскроу.
3 мин чтения
Упоминается в статьях
- Bitcoin и Ethereum: в чём разница и почему сделки строят на Ethereum
- Криптография с открытым ключом: как подпись доказывает, что это вы
- Что такое смарт-контракт и почему ему можно доверять деньги
- Жизненный цикл сделки: от оферты до выплаты шаг за шагом
- Как проверить смарт-контракт перед использованием: пошаговая инструкция
- Обзор языка Solidity: типы, функции, ошибки и события на примерах
- События и логи: eth_getLogs, bloom-фильтры и ограничения публичных RPC
- Шаблоны контроля доступа в смарт-контрактах: owner, роли и стороны сделки
- Каталог уязвимостей смарт-контрактов: 12 классических ошибок и защита от них
- Foundry для начинающих: компиляция, тесты, фаззинг и локальная сеть
Готовы попробовать?
Создайте первую сделку за пару минут. Деньги блокируются в смарт-контракте и выплачиваются по правилам, которые видны заранее.

