Разработка прокси-контрактов (UUPS, Transparent Proxy)
Контракт задеплоен на mainnet, в нём нашли критическую уязвимость. Без прокси — всё, миграция вручную, уговаривать пользователей переходить на новый адрес, хоронить старый TVL. С правильно настроенным прокси — апгрейд через мультисиг, 15 минут, адрес контракта не меняется. Но «правильно настроенным» — ключевое словосочетание. Прокси-паттерны добавляют целый класс уязвимостей, которых нет в immutable-контрактах.
Два паттерна и их реальные отличия
Transparent Proxy
Классическая реализация из OpenZeppelin (EIP-1967). Proxy-контракт содержит логику маршрутизации: если вызывает admin — он управляет прокси напрямую (upgrade, changeAdmin). Если вызывает любой другой — вызов делегируется в имплементацию.
Проблема: каждый вызов контракта требует дополнительного SLOAD для чтения адреса admin (100 gas по EIP-2929) и сравнения с msg.sender. На горячих путях — это постоянный overhead. Для протокола с миллионами вызовов в день — ощутимо.
Второй момент: ProxyAdmin — отдельный контракт, который владеет правами апгрейда. Это добавляет ещё один контракт в систему, ещё одну точку ответственности за ключи.
UUPS (EIP-1822 / ERC-1967)
Логика апгрейда перенесена из proxy в имплементацию. Proxy сам по себе — «тупой» делегатор без какой-либо логики маршрутизации по caller. Нет дополнительного SLOAD на каждый вызов — дешевле в эксплуатации.
Но есть критический риск: если задеплоить новую имплементацию без функции upgradeTo (забыть унаследовать UUPSUpgradeable, или намеренно убрать в целях экономии газа) — прокси навсегда потеряет возможность апгрейда. Контракт заморожен на текущей версии без возможности исправления.
Реальный кейс: в 2022 году несколько протоколов на 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(), станет владельцем. В 2021 году именно так был атакован контракт Parity — неинициализированный WalletLibrary был захвачен, после чего атакующий вызвал kill(), заморозив 587 ETH навсегда.
Решение: деплой-скрипт должен атомарно деплоить 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.
Процесс и сроки
Разработка прокси-системы: 2-3 дня на базовую имплементацию, ещё 1-2 дня на тесты апгрейда и storage layout validation. Сложные системы с несколькими прокси и custom governance — от недели.
Стоимость зависит от количества контрактов и требований к governance.







