Апгрейдабельность: 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().
  • Контроль апгрейда — мультисиг + таймлок; это самое важное решение.
Проверьте себя
1. Где хранятся данные upgradeable-контракта на proxy?
AВ логическом контракте
BВ самом proxy (они переживают апгрейд)
CВ мемпуле
DУ оракула
2. Как безопасно менять storage между версиями?
AПереставлять поля как удобно
BДобавлять новые переменные только в конец, не меняя порядок существующих
CУдалять старые поля из середины
DЭто не важно
3. Кому безопаснее всего доверить право апгрейда?
AОдному EOA
BМультиподписи с таймлоком
CЛюбому пользователю
DОракулу