Разработка контрактов эскроу
Эскроу-контракт выглядит просто: положить деньги, выполнить условие, забрать деньги. На практике это один из наиболее аудируемых типов контрактов, потому что любая ошибка в логике условий или правах вывода ведёт к прямой потере средств. Не к неправильному отображению баланса — к потере ETH или токенов.
Две главные точки отказа
Недостаточно жёсткие условия разблокировки
Самый частый баг в эскроу-контрактах — неполная проверка условий перед release(). Пример: маркетплейс NFT с escrow для P2P-сделок. Покупатель депонирует ETH, продавец должен передать NFT. Контракт проверяет ownerOf(tokenId) == address(this) перед release — то есть что NFT находится на контракте. Но не проверяет, что это именно тот NFT, который был заявлен в момент deposit().
Атака: продавец депонирует дешёвый NFT из той же коллекции (или вовсе другой контракт с совпадающим tokenId), контракт видит NFT на своём адресе и отдаёт ETH. Потеря для покупателя — разница между заявленным и реальным NFT.
Правильная реализация хранит в маппинге не просто флаг депозита, а полный слепок сделки: адрес NFT-контракта, tokenId, ожидаемый продавец, сумму, дедлайн.
struct Deal {
address buyer;
address seller;
address nftContract;
uint256 tokenId;
uint256 amount;
uint256 deadline;
bool released;
bool disputed;
}
Проблема арбитража и dispute-механизма
Простой двусторонний эскроу (покупатель и продавец соглашаются на release) имеет очевидную проблему: если стороны не согласны, средства заморожены навсегда. Либо нужен третий арбитр, либо таймаут с логикой возврата.
Арбитр — отдельная точка централизации и риска. Если арбитр — EOA, это single point of failure (потеря ключа). Если арбитр — контракт, нужна governance. Если арбитр — multisig (Gnosis Safe), это приемлемый компромисс для большинства случаев.
Важный момент: арбитр не должен иметь возможность принудительно вывести средства на произвольный адрес. Права арбитра должны быть ограничены: либо approve release к покупателю, либо approve возврат к продавцу. Не более.
Как мы строим эскроу-контракт
Базовая структура
Три состояния сделки: PENDING (средства задепонированы, условие не выполнено), COMPLETED (release выполнен), CANCELLED (возврат). Переходы между состояниями — строго через функции с проверками. Никаких прямых обращений к state извне.
Checks-effects-interactions везде без исключений. Это особенно важно для ETH-эскроу: обновляем state (marked as released) до отправки ETH, не после.
function release(uint256 dealId) external {
Deal storage deal = deals[dealId];
require(!deal.released, "Already released");
require(msg.sender == deal.buyer || msg.sender == arbiter, "Unauthorized");
deal.released = true; // Effects first
// Interactions last
(bool success, ) = deal.seller.call{value: deal.amount}("");
require(success, "Transfer failed");
emit Released(dealId, deal.seller, deal.amount);
}
Работа с ERC-20 токенами
ETH-эскроу проще: ETH нельзя отозвать после deposit с помощью approve(). С ERC-20 — другая история. Если контракт держит токены через transferFrom() в момент deposit — он физически владеет ими, и approve-отзыв после депозита ничего не меняет. Это правильный паттерн.
Неправильный паттерн: контракт только записывает allowance и делает transferFrom() в момент release. Между deposit и release покупатель может отозвать approve(), и release() завершится revert. Продавец выполнил условие, а средства не получил.
Для fee-on-transfer токенов (USDT на некоторых чейнах) считаем реально полученную сумму: balanceBefore - balanceAfter, не доверяем параметру amount в transferFrom().
Таймауты и дедлайны
Каждая сделка должна иметь дедлайн. Без него: продавец не выполняет условие, покупатель не может забрать деньги, средства заморожены. После истечения дедлайна — автоматический возврат к покупателю без необходимости согласия продавца.
Дедлайн проверяем через block.timestamp. Известный грабль: block.timestamp может быть сдвинут майнером на ±15 секунд на Ethereum. Для дедлайнов в днях это несущественно, для дедлайнов в секундах — нет.
Reentrancy в эскроу
ETH-эскроу особенно уязвим к reentrancy через receive(). Используем ReentrancyGuard из OpenZeppelin на функциях release() и refund(). Альтернатива — pull-паттерн: не отправляем ETH напрямую, а записываем в маппинг withdrawable[seller] += amount, продавец сам вызывает withdraw(). Это полностью устраняет reentrancy в release().
| Подход | Reentrancy риск | UX |
|---|---|---|
| Push (прямая отправка) | Есть, нужен ReentrancyGuard | Автоматически |
| Pull (withdrawable маппинг) | Отсутствует | Требует отдельной транзакции |
| Pull + permit | Отсутствует | Gasless через подпись |
Апгрейдность и многоцелевой эскроу
Для маркетплейсов с большим объёмом сделок имеет смысл фабричный паттерн: один EscrowFactory, который деплоит минимальные прокси (EIP-1167) под каждую сделку. Это изолирует средства разных сделок в отдельных контрактах и упрощает аудит.
Апгрейдность через Transparent Proxy или UUPS добавляет риск: администратор может изменить логику release() уже после депозита. Для честного эскроу апгрейдность должна быть ограничена или исключена. Если апгрейдность нужна для исправления багов — используем timelock (минимум 48 часов) и multisig.
Сроки
Базовый ETH/ERC-20 эскроу с арбитром и дедлайном: 2-3 рабочих дня включая тесты. NFT-эскроу с dispute-механизмом и фабричным паттерном: 4-6 рабочих дней. Стоимость рассчитывается индивидуально после уточнения требований.







