Блокчейн
Upgradeable контракты
В сентябре 2021 года протокол Compound по ошибке раздал пользователям 80 миллионов сверх нормы. Баг нашли быстро, но исправить его мгновенно было невозможно: контракт использовал Transparent Proxy с Timelock, и обновление требовало 48-часовой задержки. Два дня сообщество наблюдало, как средства утекают. Это не провал обновляемости, а её цена: механизм, спасающий от хакеров, замедляет исправление собственных ошибок. Каждый протокол выбирает свой баланс между безопасностью и скоростью реакции, и за каждым вариантом Proxy, UUPS, Diamond стоят конкретные компромиссы, стоившие индустрии миллиарды долларов.
- **USDC (Circle)** использует Transparent Proxy для управления стейблкоином с капитализацией 30B+. Когда обнаружена уязвимость, Circle обновляет implementation через ProxyAdmin, не прерывая работу миллионов транзакций в день - адрес контракта и все балансы остаются на месте
- **Aave V3** - крупнейший lending-протокол (10B+ TVL) - использует UUPS proxy для всех core-контрактов. Каждое обновление проходит через governance и Timelock. ERC-7201 namespaced storage защищает от storage corruption при добавлении новых функций
- **Aavegotchi** (NFT-игра) использует Diamond Standard (EIP-2535) для своего core-контракта с 50+ функциями. Когда Ethereum ввёл лимит 24KB, Diamond позволил разбить логику на facets и обновлять отдельные модули (marketplace, gameplay, governance) независимо друг от друга
Предварительные знания
Proxy Pattern: DELEGATECALL и архитектура обновляемости
В уроке про паттерны мы познакомились с Proxy-паттерном на уровне идеи: proxy хранит данные, implementation - логику, а DELEGATECALL связывает их. Теперь погрузимся в детали. Почему адрес implementation хранится в специальном слоте? Почему нельзя использовать конструктор? Как именно proxy перехватывает все вызовы? Ответы на эти вопросы - фундамент для понимания всех вариантов обновляемых контрактов: UUPS, Transparent Proxy, Diamond.
Ключевая проблема proxy - **storage collision**. Proxy и implementation разделяют одно storage-пространство. Если proxy объявит `address implementation` как обычную переменную в слоте 0, а implementation ожидает `address owner` в слоте 0 - они перезапишут данные друг друга. **EIP-1967** решает это, размещая служебные данные proxy в псевдослучайном слоте: `bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)`. Вычитание 1 исключает known preimage. Вторая проблема - **конструкторы не работают через proxy**: конструктор исполняется при деплое и модифицирует storage implementation, а не proxy. Решение - паттерн **Initializable** с функцией `initialize()`, защищённой от повторного вызова:
**Незащищённый `initialize()`** - частая уязвимость. Если implementation задеплоен без вызова `_disableInitializers()` в конструкторе, атакующий может вызвать `initialize()` напрямую на implementation-контракте, стать owner-ом и через `selfdestruct` (до Dencun) уничтожить его. Всегда используйте `/// @custom:oz-upgrades-unsafe-allow constructor` + `_disableInitializers()`.
Почему upgradeable-контракты используют функцию initialize() вместо constructor()?
Diamond Standard (EIP-2535): мульти-facet proxy
Обычный proxy указывает на **один** implementation-контракт. Но что если ваш протокол вырос до 50 функций и implementation превышает лимит в **24,576 байт** (24KB) - максимальный размер контракта по EIP-170? Вы не можете задеплоить такой контракт. **Diamond Standard (EIP-2535)** решает эту проблему: один proxy маршрутизирует вызовы к **множеству implementation-контрактов** (facets), каждый из которых отвечает за свою группу функций.
Diamond хранит **реестр**: mapping от 4-байтного selector функции к адресу facet. Когда пользователь вызывает Diamond, fallback извлекает selector из calldata, находит нужный facet в реестре и делает DELEGATECALL. Функция `diamondCut()` позволяет владельцу добавлять, заменять и удалять function selectors - это единственная функция, которая изменяет сам Diamond.
**DiamondLoupe** (EIP-2535) - обязательный facet, реализующий интроспекцию: `facets()` возвращает все facets и их selectors, `facetFunctionSelectors(address)` - selectors конкретного facet, `facetAddress(bytes4)` - по selector находит facet. Инструмент **Louper** (louper.dev) визуализирует структуру любого Diamond на mainnet. Это критично для аудита - пользователи и аудиторы могут проверить, какой код за какую функцию отвечает.
Diamond подходит не всем. Используйте его, когда: **(1)** контракт превышает 24KB, **(2)** нужно обновлять отдельные модули независимо, **(3)** нужен общий storage между модулями. Для простых контрактов UUPS или Transparent Proxy - проще и дешевле.
Какую основную проблему решает Diamond Standard (EIP-2535), которую не решает обычный proxy?
UUPS: логика обновления в implementation
**UUPS (Universal Upgradeable Proxy Standard, EIP-1822)** переносит логику обновления из proxy в implementation. В отличие от Transparent Proxy, где proxy сам содержит функцию `upgradeTo()`, в UUPS proxy - максимально тонкий: только fallback с DELEGATECALL. Функция `upgradeTo()` определена в implementation и исполняется через DELEGATECALL, изменяя слот implementation в storage proxy.
**Главный риск UUPS - bricked contract.** Если implementation v2 не содержит функцию `_authorizeUpgrade()` (или `upgradeTo()`), контракт навсегда теряет возможность обновления. Proxy не имеет собственной логики обновления - он полностью полагается на implementation. OpenZeppelin Upgrades Plugin проверяет это автоматически, но при ручном деплое ошибка фатальна: средства навсегда заблокированы в необновляемом контракте.
**Преимущества UUPS:** proxy компактнее (~200 байт vs ~2,000 для Transparent), деплой дешевле (~100K gas vs ~500K), нет overhead на admin-проверку при каждом вызове. **Недостатки:** ответственность за обновляемость на разработчике implementation, забытый `_authorizeUpgrade()` = необратимая потеря обновляемости. OpenZeppelin рекомендует UUPS как стандарт по умолчанию с версии 4.x.
Разработчик задеплоил UUPS proxy с VaultV1. Затем создал VaultV2, но забыл наследовать UUPSUpgradeable и не включил функцию _authorizeUpgrade(). Что произойдёт после обновления на V2?
Transparent Proxy: разделение admin и user вызовов
UUPS элегантен, но возлагает ответственность на implementation. **Transparent Proxy** (OpenZeppelin) выбирает противоположный подход: логика обновления живёт в самом proxy, а маршрутизация вызовов зависит от **кто вызывает**. Если вызывает admin - proxy исполняет свои admin-функции (upgrade, changeAdmin). Если вызывает любой другой адрес - proxy делает DELEGATECALL к implementation. Это решает проблему **selector clash**: когда функция proxy и implementation имеют одинаковый 4-байтный selector.
В OpenZeppelin 5.x Transparent Proxy вынесли admin-логику в отдельный контракт **ProxyAdmin**. Вместо того чтобы admin-адрес вызывал proxy напрямую, ProxyAdmin вызывает proxy, а admin взаимодействует с ProxyAdmin. Это позволяет admin-у быть EOA или multisig, который также может использовать implementation как обычный user (через другой адрес).
**Gas overhead:** Transparent Proxy проверяет `msg.sender == admin` при **каждом** вызове, даже от обычных пользователей. Это дополнительные ~2,600 gas (SLOAD admin-слота + сравнение). В UUPS этой проверки нет - proxy просто делает DELEGATECALL. Для контрактов с миллионами вызовов в день (DEX, lending) разница ощутима. Поэтому OpenZeppelin рекомендует UUPS для новых проектов.
**Когда выбрать Transparent Proxy:** если implementation разрабатывается сторонними командами и вы не можете гарантировать, что они включат UUPSUpgradeable. Transparent Proxy не может быть "окирпичен" забытой `_authorizeUpgrade()`, потому что логика обновления живёт в proxy и не зависит от implementation.
Admin Transparent Proxy вызывает функцию deposit(), которая существует в implementation. Что произойдёт?
Storage Layout: слоты, gaps и namespaces
Все паттерны обновляемых контрактов упираются в одну фундаментальную проблему: **storage layout**. EVM хранит state variables контракта в 256-битных слотах, нумерованных с 0. Когда вы обновляете implementation с V1 на V2, storage proxy остаётся неизменным - а новый код должен "видеть" переменные в тех же слотах, что и старый. Нарушение этого правила приводит к **storage corruption** - самой опасной категории багов в upgradeable-контрактах.
При обновлении implementation критично соблюдать правила совместимости storage layout: **нельзя менять порядок переменных**, нельзя удалять или вставлять переменные в середину, нельзя менять тип переменной. Новые переменные можно добавлять **только в конец**. Для наследования OpenZeppelin использует паттерн **storage gaps** - зарезервированные слоты:
**Самые частые ошибки storage layout при обновлении:** 1. Изменение порядка переменных - данные "сдвигаются" и читаются неправильно. 2. Удаление переменной из середины - все последующие переменные сдвигаются. 3. Изменение типа (uint128 → uint256) - меняет packing и сдвигает слоты. 4. Добавление переменной в базовый контракт без __gap - сдвигает переменные дочерних контрактов. Всегда используйте `@openzeppelin/upgrades-core` для автоматической проверки storage compatibility при обновлении. ERC-7201 (namespaced storage) снимает большинство этих проблем: каждый модуль хранит данные в изолированном namespace по адресу `keccak256(строка) - 1`, что исключает коллизии между модулями.
Storage gaps (__gap) полностью решают проблему storage layout в upgradeable-контрактах, поэтому можно свободно добавлять и переставлять переменные
Storage gaps - это ручной workaround с ограничениями. Gaps резервируют пустые слоты для будущих переменных в базовых контрактах, но разработчик должен вручную уменьшать размер __gap при каждом добавлении. Переставлять переменные или вставлять их в середину по-прежнему нельзя. ERC-7201 (namespaced storage) - более надёжная альтернатива, где каждый модуль хранит данные в изолированном namespace по хеш-адресу, что исключает коллизии между модулями
Разработчики часто добавляют переменную в базовый контракт и забывают уменьшить __gap, или считают __gap[50] достаточным, не задумываясь о наследовании в нескольких уровнях. Реальные протоколы (Aave V3, Compound V3) перешли на ERC-7201 namespaced storage именно потому, что gaps оказались хрупким решением: один забытый декремент __gap - и весь storage дочерних контрактов повреждён. OpenZeppelin 5.x использует ERC-7201 по умолчанию во всех upgradeable-контрактах.
Контракт BaseV1 имеет переменные: address owner (slot 0), uint256 supply (slot 1), без __gap. Дочерний TokenV1 добавляет mapping balances (slot 2). В BaseV2 между owner и supply вставляют bool paused. Что произойдёт с балансами после обновления?
Итоги
- **Proxy Pattern** строится на DELEGATECALL: proxy хранит данные, implementation - логику. EIP-1967 определяет стандартные слоты для адреса implementation (`keccak256('eip1967.proxy.implementation') - 1`), предотвращая storage collision. Конструктор не работает через proxy - используется Initializable-паттерн
- **Diamond (EIP-2535)** маршрутизирует вызовы к нескольким facets по selector, преодолевая лимит 24KB. `diamondCut()` добавляет, заменяет и удаляет facets атомарно. DiamondLoupe обеспечивает интроспекцию для аудита
- **UUPS (EIP-1822)** размещает логику обновления в implementation, а не в proxy. Proxy минимален (~200 байт), деплой дешёвый (~100K gas), нет admin-проверки при каждом вызове. Но забытая `_authorizeUpgrade()` навсегда блокирует обновление - контракт "окирпичен"
- **Transparent Proxy** решает проблему selector clash через маршрутизацию по msg.sender: admin обращается к admin-функциям proxy, все остальные - через DELEGATECALL к implementation. ProxyAdmin выносит admin-логику в отдельный контракт. Overhead: ~2,600 gas на каждый вызов
- **Storage layout** - фундамент обновляемости. Переменные нельзя переставлять, удалять или менять тип. Storage gaps (__gap) резервируют слоты для базовых контрактов, но хрупки. ERC-7201 namespaced storage изолирует данные каждого модуля по хеш-адресу - как те 80 миллионов Compound показали, даже корректный proxy бесполезен без правильного управления storage
Связанные темы
Upgradeable-контракты объединяют низкоуровневые механизмы EVM, паттерны проектирования и вопросы безопасности:
- Паттерны смарт-контрактов — Proxy-паттерн и Initializable были представлены как базовые концепции. Этот урок углубляется в варианты proxy и проблемы storage layout
- Solidity: основы — Понимание типов данных, наследования и модификаторов - необходимый фундамент для работы с storage layout и initializer-паттерном
- Безопасность: reentrancy — DELEGATECALL создаёт дополнительные векторы атак. Reentrancy через proxy, незащищённый initialize() и storage corruption - темы на стыке обновляемости и безопасности
- EVM: виртуальная машина — DELEGATECALL, SLOAD, SSTORE, storage slots - все механизмы proxy работают на уровне EVM opcodes, разобранных в уроке про виртуальную машину
Вопросы для размышления
- UUPS переносит ответственность за обновляемость на implementation, а Transparent Proxy оставляет её в proxy. Какой подход безопаснее для протокола, где implementation пишут внешние разработчики? А для протокола, где всё контролирует одна команда?
- Diamond Standard позволяет обновлять отдельные facets независимо. Но все facets разделяют один storage через DELEGATECALL. Как обеспечить, чтобы новый facet не повредил данные существующих? Как ERC-7201 помогает решить эту проблему?
- Compound потерял 80M из-за Timelock-задержки при обновлении. Если убрать Timelock, обновления станут мгновенными, но admin сможет обновить контракт без предупреждения сообщества. Как DAO-протоколы решают эту дилемму между скоростью реакции и прозрачностью?