Апгрейдабельность: proxy, storage collision и контроль
Возможность чинить контракт после деплоя — это и спасение, и новый бэкдор.
Upgradeable-контракт — схема, в которой адрес («proxy») неизменно хранит данные, а исполняемую логику можно заменить, переключив proxy на новый «логический» контракт.
Зачем апгрейды и чем они опасны
Неизменяемость кода — палка о двух концах: нашли баг — не исправить. Чтобы можно было чинить и развивать протокол, придумали upgradeable-паттерны. Но сама возможность заменить логику — это привилегия колоссальной силы: тот, кто контролирует апгрейд, может в любой момент подменить код на вредоносный и забрать все средства. Апгрейдабельность не уменьшает поверхность атаки, а добавляет новую — механизм самого апгрейда.
Как работает proxy
Proxy при каждом вызове через delegatecall исполняет код логического контракта в собственном хранилище (вспомните урок про delegatecall). Данные живут в proxy и переживают апгрейд; меняется лишь адрес логики. Распространённые виды — Transparent Proxy и UUPS.
Proxy (адрес неизменен, хранит данные)
| delegatecall
v
Logic v1 --апгрейд--> Logic v2 (данные в proxy сохраняются)Storage collision при апгрейде
Поскольку логика пишет в storage proxy по номерам слотов, новая версия логики обязана сохранять прежнюю раскладку переменных. Если в v2 переставить местами поля или вставить переменную в середину, слоты «съедут», и старые данные будут прочитаны/перезаписаны неверно — тихая порча состояния. Поэтому новые переменные добавляют только в конец, а служебные поля прячут в EIP-1967-слоты, чтобы не пересечься с пользовательскими.
// ПРАВИЛО апгрейда: не менять порядок, добавлять в конец
// v1: slot0 owner; slot1 balance
// v2: slot0 owner; slot1 balance; slot2 newField <-- ОК
// (вставить newField между owner и balance -> коллизия!)Как работает под капотом: инициализаторы
У upgradeable-контрактов нет обычного конструктора (его эффект не попал бы в storage proxy). Вместо него — функция initialize(), которую нужно вызвать ровно один раз и защитить от повторного вызова (модификатор initializer). Незащищённый или неинициализированный initialize() — известный путь к перехвату владельца.
Кто контролирует апгрейд — главный вопрос
Технические детали важны, но организационный вопрос важнее: у кого ключ от апгрейда? Если это один EOA — протокол ровно настолько безопасен, насколько защищён этот ключ (и насколько честен его владелец). Поэтому право апгрейда отдают мультиподписи + таймлоку: изменения вступают в силу с задержкой, и пользователи успевают заметить и выйти, если апгрейд злонамеренный.
Частые ошибки
- Менять раскладку storage между версиями — коллизия и порча данных.
- Незащищённый
initialize()— перехват владельца. - Апгрейд под одним ключом, без таймлока — единая точка катастрофы.
Итоги
- Upgradeable-паттерн: данные в proxy, логику можно заменить через delegatecall.
- Новые версии обязаны сохранять раскладку storage; поля добавлять в конец.
- Вместо конструктора — защищённый одноразовый
initialize(). - Контроль апгрейда — мультисиг + таймлок; это самое важное решение.