К основному содержимому

Введите слово из вопроса или названия статьи.

3 · Под капотом

Прокси и обновляемые контракты: delegatecall, UUPS и цена гибкости

Как устроены прокси-контракты, что делает delegatecall, чем отличаются Transparent и UUPS, зачем слоты EIP-1967 и почему Coin-Trust выбрал неизменяемый контракт.

4 мин чтенияОбновлено:
В этой статье

Код развёрнутого контракта нельзя изменить. Это сила (правила не подменить) и слабость (найденную ошибку не исправить). Чтобы совместить стабильный адрес с возможностью обновления, придумали прокси. Но эта гибкость стоит дорого: она добавляет доверие к тому, кто может обновлять. Эта статья объясняет механику и компромисс. Основы контрактов - в статье что такое смарт-контракт.

Идея: адрес один, логика сменная

Вместо одного контракта их два:

  • Прокси - тонкий контракт по постоянному адресу. Он хранит данные и ничего не умеет.
  • Реализация (implementation) - контракт с кодом (логикой). Его можно заменить на новую версию.

Пользователь вызывает прокси. Тот перенаправляет вызов в текущую реализацию, но исполняет её код на своих данных. Для обновления владелец меняет адрес реализации в прокси: адрес и данные остаются прежними, а логика - новая.

delegatecall: чужой код на моих данных

Ключевая операция - delegatecall. Обычный вызов (call) исполняет код другого контракта в его контексте. А delegatecall исполняет чужой код в контексте вызывающего: тот же msg.sender, то же хранилище, тот же баланс.

solidity
// упрощённо
fallback() external payable {
    address impl = _implementation();
    assembly {
        calldatacopy(0, 0, calldatasize())
        let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
        returndatacopy(0, 0, returndatasize())
        switch ok case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) }
    }
}

Любой вызов, не найденный в самом прокси (fallback), пересылается в реализацию вместе с данными (формат вызова). Это работает на уровне EVM.

Где хранить адрес реализации

Адрес реализации тоже должен лежать в хранилище прокси. Если положить его в слот 0, он столкнётся с переменными реализации. Стандарт EIP-1967 определяет «случайный» слот:

text
slot = keccak256("eip1967.proxy.implementation") - 1
     = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

Слот выбран через хеш, поэтому не пересекается с обычными переменными. Обозреватели блоков знают этот слот и показывают «Read as Proxy».

Виды прокси

СхемаГде логика обновленияОсобенность
Transparentв проксиадмин не может вызывать логику, остальные не могут админить
UUPSв реализациидешевле в вызове, но новая версия должна содержать функцию обновления
Beaconу «маяка»один маяк обновляет сразу множество прокси
Minimal proxy (EIP-1167)нет обновлениядешёвые клоны одной реализации

Инициализаторы вместо конструктора

Конструктор исполняется при развёртывании реализации и записывает данные в её хранилище, а не в хранилище прокси. Поэтому в обновляемых контрактах вместо него используют обычную функцию initialize(), вызываемую через прокси один раз.

Совместимость раскладки хранилища

Новая реализация должна сохранять порядок и типы старых переменных. Если в версии 2 вставить новую переменную в середину, все последующие сместятся и будут читать чужие данные.

solidity
// v1
address owner;      // слот 0
uint256 total;      // слот 1

// v2 - ошибка: вставили поле в середину
address owner;      // слот 0
uint256 fee;        // слот 1 - теперь читает старое total!
uint256 total;      // слот 2

Правило: новые переменные - только в конец (или использовать резервные «пустые» слоты). Проверку раскладки автоматизируют инструменты.

Главный риск: доверие к тому, кто обновляет

Прокси превращают «код - закон» в «код - пока администратор не решил иначе». Тот, кто может сменить реализацию, может, в принципе, заменить логику на вредоносную и забрать средства. Поэтому доверяют не только коду, но и управлению. Меры:

  • администратор - мультиподпись, а не один ключ;
  • таймлок: обновление применяется с задержкой, и пользователи успевают выйти;
  • публичные аудиты каждой новой версии (аудиты);
  • со временем - «отказ от права обновления» (renounce) или передача управления сообществу.

Почему Coin-Trust неизменяем

Контракт эскроу развёрнут без прокси: его код нельзя изменить никем, включая разработчиков. Это сознательный выбор для сервиса, который держит чужие деньги:

  • пользователям не нужно доверять будущим решениям администратора;
  • исходный код верифицируется один раз и остаётся таким навсегда;
  • нет класса уязвимостей с инициализаторами и раскладкой хранилища.

Цена выбора: ошибку нельзя исправить обновлением. Поэтому контракт сделан максимально простым и покрыт тестами (включая фаззинг), а у владельца есть только ограниченные административные права (права владельца). Новая версия означала бы новый контракт по новому адресу, а сделки в старом доработали бы по прежним правилам (адреса через CREATE2).

Как распознать прокси

  1. Обозреватель показывает вкладку «Read as Proxy» или адрес реализации.
  2. В коде есть delegatecall, implementation, upgradeTo.
  3. Читаемые функции контракта предельно малы, а основная логика - по другому адресу.

Перед использованием чужого проекта выясните, кто владеет правом обновления и есть ли таймлок.

Что почитать дальше

Узнайте, как строятся уникальные детерминированные адреса: CREATE2. Или как проверяют большие множества данных: деревья Меркла.

Упоминается в статьях

Готовы попробовать?

Создайте первую сделку за пару минут. Деньги блокируются в смарт-контракте и выплачиваются по правилам, которые видны заранее.