Блокчейн
Паттерны смарт-контрактов
В марте 2023 года протокол Euler Finance потерял $197 миллионов за одну транзакцию. Причина - нарушение одного паттерна проектирования. Месяцем позже Compound едва избежал потери $80M благодаря другому паттерну - Timelock дал сообществу 48 часов на обнаружение вредоносного предложения. Смарт-контракты управляют миллиардами долларов без возможности отката, и единственное, что стоит между деньгами пользователей и катастрофой - это архитектурные паттерны, отточенные годами ошибок индустрии.
- **Uniswap V2 Factory** создала более 300,000 торговых пар через один Factory-контракт. CREATE2 позволяет вычислить адрес любой пары off-chain без обращения к блокчейну - это экономит gas в каждом свопе
- **USDC, Aave, Compound** - все используют Proxy-паттерн для обновления логики. Когда Circle обнаружила уязвимость в USDC, они обновили implementation через proxy, не прерывая работу стейблкоина с капитализацией в 30B+
- **The DAO (2016, $60M), Euler (2023, $197M), Curve (2023, 70M)** - все эти взломы произошли из-за нарушения паттерна Checks-Effects-Interactions. ReentrancyGuard из OpenZeppelin, который стоит 20,000 gas, мог бы предотвратить каждый из них
Предварительные знания
Factory: создание контрактов из контрактов
В традиционном программировании паттерн Factory создаёт объекты. В Solidity он делает нечто иное - **создаёт новые контракты прямо из кода другого контракта**. Каждый созданный контракт получает собственный адрес, storage и баланс. Factory-контракт выступает единой точкой входа: он хранит реестр всех порождённых контрактов и гарантирует единообразие их создания.
EVM предоставляет два opcode для создания контрактов: **CREATE** вычисляет адрес из `keccak256(sender, nonce)` - каждый вызов даёт новый адрес. **CREATE2** (EIP-1014) вычисляет адрес из `keccak256(0xff, sender, salt, bytecodeHash)` - адрес **детерминирован** и известен заранее, ещё до деплоя. Это позволяет отправлять средства на адрес контракта, который ещё не существует.
Полноценный деплой каждого контракта через Factory стоит дорого - от 200,000 до 1,000,000+ gas. Для ситуаций, когда нужно создавать сотни или тысячи однотипных контрактов, существует **Clone Pattern (EIP-1167)**: вместо копирования всего bytecode создаётся минимальный proxy (всего 45 байт), который перенаправляет все вызовы на один implementation-контракт через DELEGATECALL.
**Uniswap V2** использует CREATE2 в своей Factory, чтобы любой мог вычислить адрес пары off-chain: `address(uint160(uint256(keccak256(abi.encodePacked(hex"ff", factory, salt, initCodeHash)))))`. Роутер вычисляет адрес пары без единого вызова к блокчейну - это экономит gas при каждом свопе.
Какое главное преимущество Clone Pattern (EIP-1167) по сравнению с обычным Factory, который деплоит полный контракт?
Proxy: обновляемые контракты
Код смарт-контракта в Ethereum **неизменяем** (immutable): после деплоя bytecode нельзя изменить. Но что если вы нашли баг? Или нужно добавить новую функцию? **Proxy-паттерн** решает эту проблему: пользователи взаимодействуют с proxy-контрактом, который **делегирует все вызовы** в отдельный implementation-контракт. Чтобы обновить логику, достаточно указать proxy на новый implementation - адрес, баланс и данные пользователей остаются на месте.
Ключевой механизм - opcode **DELEGATECALL**: он исполняет код целевого контракта, но в контексте вызывающего (proxy). Это значит, что `msg.sender`, `msg.value` и весь storage принадлежат proxy, а implementation лишь определяет логику. Вот минимальная реализация:
**Storage collision** - главная опасность proxy-паттерна. Proxy и implementation делят одно storage-пространство. Если proxy хранит `address impl` в слоте 0, а implementation ожидает `uint256 totalSupply` в слоте 0 - они перезапишут данные друг друга. **EIP-1967** решает это, размещая служебные данные proxy в псевдослучайных слотах (`keccak256(...) - 1`), которые практически не могут совпасть с обычными переменными.
Proxy-паттерн - обширная тема с множеством вариантов (Transparent Proxy, UUPS, Beacon Proxy, Diamond). В этом уроке мы рассматриваем базовый принцип. Глубокий разбор обновляемых контрактов - в уроке **bc-37-upgradeable**.
Proxy-контракт использует DELEGATECALL для вызова implementation. Где хранятся данные (state variables), записанные implementation-кодом?
Pull Payment: безопасная отправка средств
Представьте аукцион: 100 участников сделали ставки, и когда победитель определён, контракт должен вернуть средства 99 проигравшим. Наивный подход - пройтись циклом и вызвать `transfer()` для каждого. Но что если один из получателей - контракт, чей `receive()` всегда делает `revert()`? Весь цикл откатывается, и **никто не получает средства**. Один злоумышленник блокирует выплаты для всех. Это называется **Denial of Service (DoS) через revert**.
**Pull Payment** ("получатель забирает сам") переворачивает модель: вместо отправки средств контракт записывает **причитающуюся сумму** во внутренний escrow, и каждый получатель вызывает `withdraw()` самостоятельно. Если один получатель не забирает средства - это его проблема, остальных это не затрагивает.
Pull Payment - не только защита от DoS. Он также снижает **поверхность атаки для reentrancy**: вместо отправки ETH внутри сложной бизнес-логики (аукцион, распределение прибыли), единственная точка отправки - функция `withdraw()`, которую проще аудировать и защитить.
Контракт аукциона использует Push-паттерн: в цикле вызывает transfer() для возврата ставок 50 участникам. Один из участников - контракт с receive() { revert(); }. Что произойдёт?
Access Control: управление правами
Смарт-контракт - это публичный код: **любой** может вызвать любую `external`/`public` функцию. Без Access Control злоумышленник может вывести средства, поставить контракт на паузу или сменить владельца. Паттерны управления доступом задают правила: *кто* может вызвать *какую* функцию и *при каких условиях*.
Самый простой вариант - **Ownable** (один владелец). Но реальные протоколы требуют гранулярных прав: один адрес может ставить на паузу, другой - управлять казной, третий - обновлять параметры. Для этого OpenZeppelin предоставляет **AccessControl** - ролевую модель.
**Ownable** без двухшаговой передачи (`transferOwnership`) - частая причина потери контрактов. Если owner передаёт права на неправильный адрес (опечатка), контракт потерян навсегда. **Ownable2Step** из OpenZeppelin требует, чтобы новый владелец явно подтвердил принятие прав - `acceptOwnership()`.
DeFi-протокол использует Ownable (один owner). Команда хочет, чтобы операции паузы выполнялись оперативно (security team), а вывод средств из treasury проходил через 48-часовую задержку. Какой паттерн подходит лучше всего?
Guard Check: защита инвариантов
Последняя линия обороны смарт-контракта - **guard-паттерны**: механизмы, которые проверяют предусловия, постусловия и инварианты на каждом шаге выполнения. Центральный принцип - **Checks-Effects-Interactions (CEI)**, порядок операций, нарушение которого стоило индустрии сотни миллионов долларов в reentrancy-атаках.
Solidity предоставляет три механизма для проверок: `require()`, `revert()` и `assert()`. С версии 0.8.26 рекомендуется использовать **custom errors** вместо строковых сообщений - они экономят до 50% gas на revert:
OpenZeppelin предоставляет готовые guard-модификаторы для типовых сценариев защиты:
**Circuit Breaker** (Pausable) - паттерн аварийного останова. При обнаружении аномалии (необычный объём выводов, подозрительные транзакции) авторизованный адрес ставит контракт на паузу. Все критические функции с модификатором `whenNotPaused` перестают работать. Это дало **Euler Finance** время заблокировать дальнейшую потерю средств во время эксплойта 2023 года.
Паттерн Checks-Effects-Interactions (CEI) полностью защищает от reentrancy, поэтому ReentrancyGuard не нужен
CEI - необходимая, но не всегда достаточная защита. В сложных контрактах с множеством функций возможна cross-function reentrancy: атакующий вызывает функцию A, которая триггерит callback, и из callback-а вызывает функцию B, использующую ещё не обновлённое состояние. ReentrancyGuard (mutex) защищает весь контракт целиком, блокируя любой повторный вход
Разработчики часто полагаются только на CEI, забывая о cross-function reentrancy. Например, функция withdraw() следует CEI, но функция getBalance() читает состояние, которое другая функция ещё не обновила. Комбинация CEI + ReentrancyGuard + Pausable - стандартный набор защиты в production-контрактах, используемый Aave, Compound и Uniswap.
Контракт вызывает payable(msg.sender).call{value: amount}("") ПЕРЕД обновлением баланса в storage (balances[msg.sender] -= amount). Какую атаку это делает возможной?
Итоги
- **Factory-паттерн** создаёт контракты из контрактов. CREATE2 даёт детерминированные адреса, а Clone Pattern (EIP-1167) снижает стоимость деплоя с 200,000+ до 37,000 gas - критично при массовом создании однотипных контрактов
- **Proxy-паттерн** разделяет данные и логику через DELEGATECALL: proxy хранит state, implementation - код. Обновление = смена указателя на implementation. Главная опасность - storage collision, которую решает EIP-1967
- **Pull Payment** переворачивает модель выплат: контракт не отправляет средства (Push), а позволяет получателям забирать самостоятельно (Pull). Один злоумышленный receive() не может заблокировать выплаты остальным
- **AccessControl** даёт гранулярные роли вместо единственного owner. PAUSER_ROLE для быстрого реагирования, TREASURY_ROLE через Timelock для прозрачности - каждый протокол нуждается в многоуровневом доступе
- **Checks-Effects-Interactions + ReentrancyGuard + Pausable** - тройная защита от reentrancy и аварийных ситуаций. Именно нарушение паттерна CEI стоило индустрии сотни миллионов долларов - от The DAO до Euler Finance
Связанные темы
Паттерны смарт-контрактов связывают фундамент Solidity с продвинутыми темами безопасности и обновляемости:
- Solidity: основы — Паттерны строятся на фундаменте Solidity - типах данных, функциях, модификаторах и наследовании
- Обновляемые контракты — Proxy-паттерн из этого урока - введение. Transparent Proxy, UUPS, Beacon и Diamond разбираются детально
- Стандарты ERC — ERC-20, ERC-721 и ERC-1155 активно используют все паттерны из этого урока: AccessControl для mint/burn, Pull Payment для распределения royalties
- Безопасность: reentrancy — Guard Check и CEI-паттерн - первая линия обороны. Глубокий разбор reentrancy-атак, включая cross-function и read-only reentrancy
Вопросы для размышления
- Factory + Clone (EIP-1167) резко снижает стоимость деплоя, но все клоны делегируют вызовы одному implementation. Какие риски это создаёт, если в implementation обнаружится критический баг?
- Proxy-паттерн делает контракт обновляемым, но обновляемость противоречит идее immutable code в блокчейне. Как найти баланс между безопасностью (возможность исправить баг) и доверием (пользователь знает, что код не изменится)?
- Pull Payment перекладывает ответственность на получателя: он должен сам вызвать withdraw(). Как это влияет на UX? Как DeFi-протоколы решают проблему "забытых" средств в escrow?