Розробка прокси-контрактів (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.







