Розробка проксі-контрактів (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. Кожен проект супроводжуємо аудитом та надаємо гарантію коректності апгрейда на тестнеті. Отримайте консультацію з вибору патерну та архітектури проксі для вашого проекту — це безкоштовно.







