Разработка прокси-контрактов UUPS и Transparent Proxy

Разработка прокси-контрактов (UUPS, Transparent Proxy) Контракт задеплоен на mainnet, в нём нашли критическую уязвимость. Без прокси — всё, миграция вручную, уговаривать пользователей переходить на новый адрес, хоронить старый TVL. Мы понимаем: с правильно настроенным прокси — апгрейд через мульт

Направления блокчейн-разработки

Часто задаваемые вопросы

Последние работы

  • image_website-b2b-advance_0.webp
    Разработка сайта компании B2B ADVANCE
    1441
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1301
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    998
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1267
  • image_logo-advance_0.webp
    Разработка логотипа компании B2B Advance
    713
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    1003

Разработка прокси-контрактов (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. Аналитика требований — 1-2 дня. Выбор паттерна, определение ролей и таймлоков.
  2. Проектирование storage layout — 1 день. Схема с ERC-7201, проверка на коллизии.
  3. Разработка имплементаций — 2-3 дня. Solidity код с тестами на Foundry (fork).
  4. Развёртывание и инициализация — 1 день. Атомарный деплой через скрипт.
  5. Тестирование апгрейда — 1 день. Проверка совместимости хранения.
  6. Внутренний аудит — 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. Каждый проект сопровождаем аудитом и предоставляем гарантию корректности апгрейда на тестнете. Получите консультацию по выбору паттерна и архитектуры прокси для вашего проекта — это бесплатно.