Разработка прокси-контрактов (UUPS, Transparent Proxy)
Контракт задеплоен на mainnet, в нём нашли критическую уязвимость. Без прокси — всё, миграция вручную, уговаривать пользователей переходить на новый адрес, хоронить старый TVL. Мы понимаем: с правильно настроенным прокси — апгрейд через мультисиг за 15 минут, адрес контракта не меняется. Но «правильно настроенным» — ключевое словосочетание. Прокси-паттерны добавляют целый класс уязвимостей, которых нет в immutable-контрактах: storage collision, неинициализированные имплементации, потеря права апгрейда. Наша команда за 7 лет реализовала десятки прокси-систем для DeFi-протоколов с TVL до $500M. Выбор паттерна — не техническая формальность, а архитектурное решение, влияющее на gas, безопасность и управление. Ниже разберём два основных паттерна — Transparent Proxy и UUPS, их компромиссы и типовые ошибки, а также как мы проектируем и тестируем такие системы.
Проблемы, которые решаем
Главная задача — дать возможность исправлять ошибки и добавлять фичи без миграции пользователей. Но за это приходится платить:
- Storage collision: при апгрейде переменные могут перезаписаться — молчаливая порча данных.
- Неинициализированная имплементация: если забыть вызвать
initialize(), злоумышленник может захватить контракт. - Потеря права апгрейда: имплементация без
upgradeToнавсегда блокирует обновление. - Gas overhead: Transparent Proxy добавляет SLOAD на каждый вызов, что критично для высоконагруженных протоколов. Например, при 10 000 транзакций в день дополнительный расход газа составляет
1 000 000 gas ($20 по текущим ценам).
Два паттерна и их реальные отличия
Transparent Proxy
Классическая реализация из OpenZeppelin EIP-1967. Proxy-контракт содержит логику маршрутизации: если вызывает admin — он управляет прокси напрямую (upgrade, changeAdmin). Если вызывает любой другой — вызов делегируется в имплементацию.
Проблема: каждый вызов контракта требует дополнительного SLOAD для чтения адреса admin (100 gas по EIP-2929) и сравнения с msg.sender. На горячих путях — это постоянный overhead. Для протокола с миллионами вызовов в день — ощутимо. Второй момент: ProxyAdmin — отдельный контракт, который владеет правами апгрейда. Это добавляет ещё один контракт в систему, ещё одну точку ответственности за ключи.
Почему UUPS сегодня — стандарт индустрии?
Логика апгрейда перенесена из proxy в имплементацию. Proxy сам по себе — тупой делегатор без какой-либо логики маршрутизации по caller. Нет дополнительного SLOAD на каждый вызов — дешевле в эксплуатации. OpenZeppelin начиная с версии 4.x рекомендует UUPS для новых проектов, что подтверждено в официальном репозитории.
Но есть критический риск: если задеплоить новую имплементацию без функции upgradeTo (забыть унаследовать UUPSUpgradeable, или намеренно убрать в целях экономии газа) — прокси навсегда потеряет возможность апгрейда. Контракт заморожен на текущей версии без возможности исправления.
Реальный кейс: несколько протоколов на UUPS столкнулись с проблемой неинициализированной имплементации. Имплементация деплоилась без вызова initialize(), и атакующий вызывал initialize() первым, становясь owner, после чего upgradeTo() позволял подменить имплементацию на самоуничтожающийся контракт. Все прокси, указывавшие на эту имплементацию, превращались в нерабочие. Решение: _disableInitializers() в конструкторе имплементации — обязательный паттерн в OpenZeppelin 4.3+.
Как избежать storage collision на этапе проектирования?
Суть проблемы: proxy и имплементация используют один storage. Если в proxy есть переменная в slot 0, а в имплементации тоже есть переменная в slot 0 — они перезаписывают друг друга.
EIP-1967 решает это для служебных переменных proxy (адрес имплементации, адрес admin) — они хранятся в псевдослучайных слотах на основе keccak256 хэша строки, практически исключая коллизию с пользовательским storage.
Но storage имплементации при апгрейдах — ответственность разработчика. Если в V1 была структура:
uint256 public totalSupply; // slot 0
address public owner; // slot 1
А в V2 добавили переменную перед существующими:
bool public paused; // slot 0 — КОЛЛИЗИЯ с totalSupply
uint256 public totalSupply; // slot 1 — КОЛЛИЗИЯ с owner
address public owner; // slot 2
totalSupply теперь читает то, что раньше было owner (адрес, интерпретированный как число). Молчаливая порча данных, без reverting транзакций, без ошибок компилятора.
ERC-7201 (Namespaced Storage Layout) решает это радикально. Все переменные имплементации собираются в одну struct, которая хранится в заранее вычисленном слоте:
bytes32 private constant STORAGE_LOCATION =
keccak256(abi.encode(uint256(keccak256("myprotocol.storage.v1")) - 1)) & ~bytes32(uint256(0xff));
Новые переменные добавляются в конец struct. Никаких коллизий со служебными слотами proxy, никаких проблем при апгрейдах. Это текущий best practice для production UUPS-контрактов.
Как инициализировать прокси-контракт?
В прокси-архитектуре конструктор имплементации не выполняется в контексте proxy — он выполняется только при деплое самой имплементации. Поэтому все инициализирующие действия (установка owner, начальные параметры) переносятся в функцию initialize(), защищённую initializer модификатором.
Распространённый баг: initialize() забыли вызвать после деплоя proxy. Контракт работает, но owner не установлен — первый, кто вызовет initialize(), станет владельцем. В результате инцидента с контрактом WalletLibrary было потеряно 587 ETH (на момент атаки ~$1.5M) из-за захвата неинициализированного контракта. Решение: деплой-скрипт должен атомарно деплоить proxy и вызывать initialize() в рамках одного скрипта. Никогда не деплоить прокси без немедленной инициализации.
Как мы реализуем прокси-контракты
Базовая библиотека — OpenZeppelin Upgrades (Hardhat plugin или Foundry-совместимый вариант). Плагин автоматически проверяет storage layout совместимость между версиями при каждом апгрейде — это обязательный инструмент, не опциональный.
Для UUPS выбираем UUPSUpgradeable из OpenZeppelin 5.x. Для систем, где апгрейд должен управляться DAO или мультисигом — AccessControlUpgradeable с ролью UPGRADER_ROLE, выданной Gnosis Safe адресу.
Тестируем апгрейды через Foundry fork-тесты: форкаем mainnet, симулируем апгрейд, проверяем, что все storage-переменные сохранили значения, функции работают корректно, новые переменные инициализированы правильно.
| Критерий выбора | Transparent Proxy | UUPS |
|---|---|---|
| Gas на вызов | +100-200 gas (SLOAD admin) | Без overhead |
| Риск потери upgrade | Нет | Есть (забытый upgradeTo) |
| Сложность кода | Ниже | Чуть выше |
| Рекомендация OZ 5.x | Устарел для новых | Предпочтительный |
| Отдельный ProxyAdmin | Да | Нет |
Когда прокси не нужен
Immutable-контракт проще, дешевле в аудите, вызывает больше доверия у пользователей (нет риска rug через апгрейд). Если логика стабильна и риск критической ошибки минимален — прокси добавляет сложность без необходимости. Для DeFi-протоколов с большим TVL обычно лучше immutable + timelock на параметрах, чем upgradeability без формального governance. Однако, если вы предвидите необходимость обновлений — прокси незаменим.
Процесс работы и сроки
- Аналитика требований — 1-2 дня. Выбор паттерна, определение ролей и таймлоков.
- Проектирование storage layout — 1 день. Схема с ERC-7201, проверка на коллизии.
- Разработка имплементаций — 2-3 дня. Solidity код с тестами на Foundry (fork).
- Развёртывание и инициализация — 1 день. Атомарный деплой через скрипт.
- Тестирование апгрейда — 1 день. Проверка совместимости хранения.
- Внутренний аудит — 2-4 дня. Включает Slither, Mythril и ручной review.
Общие сроки: от 5 рабочих дней для простого прокси до 2-3 недель для сложных систем с governance и несколькими имплементациями. Свяжитесь с нами для точной оценки вашего проекта — мы рассчитаем сроки под ключ.
Чек-лист для безопасного деплоя прокси
- [ ] Выбран паттерн (UUPS / Transparent) с обоснованием.
- [ ] Storage layout реализован через ERC-7201.
- [ ] В конструкторе имплементации вызван
_disableInitializers(). - [ ] Деплой-скрипт вызывает
initialize()атомарно. - [ ] ProxyAdmin (если Transparent) развёрнут и настроен.
- [ ] Роль
UPGRADER_ROLEназначена мультисигу. - [ ] Storage совместимость проверена плагином OpenZeppelin.
- [ ] Форк-тесты симулируют апгрейд с сохранением данных.
- [ ] Аудит проведён (внутренний или внешний).
- [ ] Документация процедуры апгрейда подготовлена.
Гарантия и опыт
Мы разработали более 50 прокси-систем для DeFi, NFT и инфраструктурных проектов. 7+ лет практического опыта в Solidity и Ethereum. Каждый проект сопровождаем аудитом и предоставляем гарантию корректности апгрейда на тестнете. Получите консультацию по выбору паттерна и архитектуры прокси для вашего проекта — это бесплатно.







