Розробка контрактів еськроу
Еськроу-контракт виглядає просто: покласти гроші, виконати умову, забрати гроші. На практиці це один з найбільш аудируємих типів контрактів, тому що будь-яка помилка в логіці умов або правах виводу веде до прямої втрати коштів. Не до неправильного відображення балансу — до втрати 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), це прийнятний компроміс для більшості випадків.
Важна деталь: арбітр не повинен мати можливість примусово вивести кошти на довільну адресу. Права арбітра мають бути обмежені: або схвалити release покупцеві, або схвалити повернення продавцеві. Не більше.
Як ми будуємо еськроу-контракт
Базова структура
Три стани угоди: PENDING (кошти задепоновані, умова не виконана), COMPLETED (release виконаний), CANCELLED (повернення). Переходи між станами — строго через функції з перевірками. Ніякого прямого доступу до state ззовні.
Checks-effects-interactions скрізь без виключень. Це особливо важливо для ETH-еськроу: оновлюємо state (позначено як 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 першим
// Interactions останніми
(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, який розгортає мінімальні proxies (EIP-1167) під кожну угоду. Це ізолює кошти різних угод у окремих контрактах та спрощує аудит.
Апгрейдність через Transparent Proxy або UUPS додає ризик: адміністратор може змінити логіку release() вже після депозиту. Для чесного еськроу апгрейдність має бути обмежена або виключена. Якщо апгрейдність потрібна для виправлення багів — використовуємо timelock (мінімум 48 годин) та multisig.
Строки
Базовий ETH/ERC-20 еськроу з арбітром та дедлайном: 2-3 робочих дні включаючи тести. NFT-еськроу з dispute-механізмом та фабричним паттерном: 4-6 робочих днів. Вартість розраховується індивідуально після уточнення вимог.







