Апгрейдируемые смарт-контракты: proxy, UUPS, плюсы и риски
Апгрейдируемый смарт-контракт — это связка из прокси, который хранит данные и адрес, и контракта реализации с логикой. Прокси перенаправляет каждый вызов в реализацию через delegatecall, поэтому логику можно заменить, а адрес и балансы останутся. Плюс — можно исправить ошибку. Минус — у кого-то появляется право переписать правила, и ошибка в самом механизме обновления может заблокировать или раскрыть все средства. Делать контракт обновляемым стоит только тогда, когда вы готовы защищать это право так же строго, как сами деньги.
Как устроен прокси
delegatecall выполняет код реализации в контексте прокси: читается и пишется storage прокси, msg.sender остаётся исходным пользователем. Адрес реализации хранится в специальной ячейке, описанной в стандарте ERC-1967, чтобы не пересекаться с переменными логики. Обновление — это запись нового адреса в эту ячейку.
Конструктор реализации при этом не выполняется в контексте прокси. Поэтому вместо конструктора пишут функцию initialize с модификатором initializer, которую можно вызвать только один раз. Документация OpenZeppelin подробно описывает это в разделе Upgrades.
Transparent, UUPS и Beacon
| Схема | Где логика обновления | Плюсы | Риски |
|---|---|---|---|
| Transparent | В прокси и отдельном ProxyAdmin | Обновление нельзя случайно удалить из логики | Дороже деплой и каждый вызов, отдельный контракт администратора |
| UUPS (ERC-1822) | В реализации, функция upgradeToAndCall | Дешевле, проще прокси, можно навсегда отключить обновления | Новая версия без функции обновления заблокирует апгрейды навсегда |
| Beacon | В контракте-маяке, общем для многих прокси | Обновить сотни одинаковых контрактов одной транзакцией | Один ключ меняет логику сразу везде |
В OpenZeppelin 5 при создании TransparentUpgradeableProxy контракт ProxyAdmin разворачивается автоматически, а начиная с версии 5.6 ERC1967Proxy и TransparentUpgradeableProxy требуют данные инициализации прямо при создании. Это закрывает целый класс ошибок, когда прокси оставался неинициализированным и его мог «захватить» первый желающий.
Минимальный UUPS-контракт: что в нём обязательно
Учебный пример на OpenZeppelin Contracts Upgradeable 5.x. Главное здесь не синтаксис, а четыре обязательных элемента.
contract BoxV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {/// @custom:oz-upgrades-unsafe-allow constructorиconstructor() { _disableInitializers(); }— реализацию нельзя инициализировать напрямую.function initialize(address initialOwner) public initializer { __Ownable_init(initialOwner); }— замена конструктора для прокси.function _authorizeUpgrade(address) internal override onlyOwner {}— кто имеет право обновлять.
Что ломается при обновлении
Раскладка storage
Прокси хранит данные по номерам ячеек. Если в новой версии поменять порядок переменных, удалить переменную или вставить новую в середину, старые данные окажутся «под другими именами»: баланс станет адресом владельца, а флаг паузы — суммой. В OpenZeppelin 5 upgradeable-контракты используют пространства имён по ERC-7201, чтобы данные родительских контрактов не пересекались. Ваши собственные переменные всё равно нужно добавлять только в конец или тоже выносить в namespace.
Инициализация
- Забыли вызвать
initializeпри создании прокси — права может забрать первый, кто вызовет его. - Не вызвали
_disableInitializers()в конструкторе реализации — реализацию можно инициализировать и получить над ней контроль. - Новая версия требует новых параметров — используйте
reinitializer(2), а не повторныйinitializer.
Несовместимые изменения
- Ключевое слово
immutableв реализации хранится в байт-коде, а не в storage прокси, и это легко упустить. selfdestructиdelegatecallк произвольным адресам в логике опасны для всей связки.- Изменение семантики функции при том же названии ломает интеграции, которые на неё полагались.
Инструменты, которые ловят ошибки
Плагины OpenZeppelin Upgrades для Foundry и Hardhat проверяют совместимость раскладки storage между версиями, наличие небезопасных конструкций и правильность инициализации. Запускайте проверку в CI до каждого деплоя новой версии. Тест на форке основной сети, где вы делаете апгрейд и проверяете, что старые балансы на месте, — обязательный минимум. Подробно о тестах — в статье о Foundry и Hardhat.
Кому доверить право обновления
| Вариант | Когда подходит | Чем рискуете |
|---|---|---|
| Один ключ разработчика | Только тестовая сеть | Потеря или кража ключа означает потерю контроля над всем |
| Мультиподпись 2 из 3 или 3 из 5 | Ранний этап проекта | Сговор или компрометация нескольких подписантов |
| Мультиподпись плюс таймлок | Продукт с деньгами пользователей | Задержка при срочных исправлениях |
| Голосование держателей (DAO) плюс таймлок | Зрелый протокол с сообществом | Сложность и захват голосованием |
| Отказ от обновлений | Контракт стабилен и проверен | Ошибку уже не исправить |
Таймлок — это задержка между решением об обновлении и его исполнением, например несколько дней. Пользователи видят запланированное обновление и могут вывести средства, если не согласны. OpenZeppelin предлагает для этого TimelockController. Как распределять роли — в статье о контроле доступа, а настройку мультиподписи компании мы делаем в рамках решения для казначейства.
Порядок безопасного обновления
- Опишите изменения и причину обновления, заранее сообщите о нём пользователям.
- Напишите новую версию, сохранив раскладку storage, и прогоните проверку плагином OpenZeppelin Upgrades.
- Запустите все старые тесты и добавьте новые на изменённую логику.
- Проведите апгрейд на форке основной сети и убедитесь, что балансы, роли и параметры на месте.
- Отдайте изменения на ревью, а при существенной логике — на внешний аудит.
- Разверните новую реализацию, верифицируйте её код и подготовьте транзакцию обновления в мультиподписи.
- Если есть таймлок, дождитесь окончания задержки, исполните обновление и проверьте результат ончейн.
- Включите усиленный мониторинг на первые дни после обновления.
Этот порядок кажется долгим, но каждое звено закрывает конкретный сценарий потери средств. В публичных разборах инцидентов с обновляемыми контрактами причиной нередко оказывается не новая логика, а пропущенная инициализация или сдвинутая переменная. Настройку оповещений о вызовах upgradeToAndCall и смене администратора мы делаем в рамках мониторинга контрактов.
Что видят пользователи
Каждое обновление оставляет след: событие Upgraded с адресом новой реализации записывается в лог прокси, а обозреватели показывают историю реализаций. Внимательные держатели и аналитики следят за этим. Поэтому лучше заранее опубликовать, кто владеет правом обновления, какая задержка у таймлока и где читать описание каждой версии. Прозрачность здесь работает на доверие сильнее любых обещаний в документации.
Когда обновляемость не нужна
Простой токен с фиксированной эмиссией, NFT-коллекция без сложной логики, контракт вестинга с заранее известным графиком — всё это лучше делать неизменяемым. Держатели доверяют такому контракту больше, а код проще проверить. Если ошибку всё же нашли, план Б — новый контракт и миграция, и его стоит продумать заранее.
Если вы унаследовали обновляемый контракт и не уверены, кто сейчас может его обновить, это первое, что нужно выяснить. С проверкой поможет аудит смарт-контракта, а с разработкой новой версии — команда разработки смарт-контрактов.
Вопросы и ответы
Что такое прокси-контракт простыми словами?
Это контракт-посредник с постоянным адресом, который хранит данные и пересылает вызовы в отдельный контракт с логикой. Логику можно заменить, а адрес и балансы останутся прежними.
Что выбрать: UUPS или Transparent?
UUPS дешевле и проще, его рекомендует OpenZeppelin для новых проектов, но требует аккуратности: каждая новая версия должна сохранять функцию обновления. Transparent надёжнее в этом смысле, но дороже по газу.
Можно ли сделать контракт обновляемым после деплоя?
Нет. Если контракт развёрнут без прокси, его логику не заменить. Можно развернуть новую версию за прокси и перенести в неё средства и данные.
Что такое _disableInitializers?
Это функция OpenZeppelin, которую вызывают в конструкторе реализации. Она запрещает инициализировать саму реализацию напрямую, чтобы посторонний не получил над ней права владельца.
Как пользователю проверить, обновляемый ли контракт?
Обозреватели вроде Etherscan показывают пометку прокси и адрес реализации. Стоит также посмотреть, кто владеет правом обновления: один адрес, мультиподпись или таймлок.
Читайте также
Смарт-контракт простыми словами: что это и когда бизнесу он не нужен
БлокчейнСколько стоит разработка смарт-контракта и из чего складывается цена
БлокчейнТестирование смарт-контрактов: Foundry, Hardhat, фаззинг, инварианты
БлокчейнЧек-лист перед аудитом смарт-контракта: что подготовить команде
БлокчейнДеплой и верификация смарт-контракта: ключи, скрипты, Etherscan
БлокчейнСколько стоит аудит смарт-контракта и что в него входит