Прокси: обновление логики без смены адреса
- Зачем это нужно
- delegatecall: чужой код в своей памяти
- Хранилище — это пронумерованные слоты
- Прокси: fallback и один спрятанный слот
- Обновление: адрес тот же, данные те же
- Съехавшая раскладка: адрес читается как число
- Как проверить раскладку до обновления
- Две формы переадресации
- Кто имеет право обновлять
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Экзамен по блоку «Стандарты и обновления»
- Куда дальше: проверенные ресурсы
Зачем это нужно
Конкурсное задание требует этого прямым текстом: «При реализации конкурсного задания должны быть использованы проксирующие контракты для обновления бизнес-логики без изменения адреса основного контракта».
А в списке шагов демонстрации стоит: «Провести обновление логики контракта. Изменённые параметры: … Показать изменение параметров и отсутствие изменений в адресе контракта».
Требование выглядит невозможным: код контракта в сети неизменен, мы это знаем с первой главы. Ключ — в одной операции, которая выполняет чужой код в своём хранилище. Из неё вырастает вся конструкция; из неё же берутся способы её сломать так, что адрес превратится в число.
delegatecall: чужой код в своей памяти
Обычный вызов через интерфейс запускает чужой код в чужом хранилище. У особого вызова delegatecall меняются три вещи. Первая модель показывает их таблицей:
── таблица ──
что call delegatecall
чей код выполняется Logic Logic
чьё хранилище меняется Logic Caller
address(this) Logic Caller
msg.sender Caller человек
куда попал 1 ETH Logic Caller
Читается так: код берём чужой, а работаем со своей памятью. Внутри чужого кода address(this) — это мы; msg.sender — не мы, а тот человек, который позвал нас; и эфир никуда не уходит, потому что перехода в другой контракт с деньгами не было.
Отсюда вся идея: если один контракт хранит данные и умеет исполнять код другого в своей памяти, то код можно заменить, а данные останутся.
Хранилище — это пронумерованные слоты
У контракта нет «переменных с именами». Есть ячейки по 32 байта с номерами 0, 1, 2, и компилятор раздаёт их полям по порядку объявления. Имена в блокчейн не попадают вовсе — они существуют только в исходнике.
contract V1 {
address public treasury; // слот 0
uint256 public rate; // слот 1
uint256 public totalDebt; // слот 2
}
Это и есть раскладка хранилища (storage layout). Запомните её как факт: она понадобится через две секции, когда мы будем ломать обновление.
Прокси: fallback и один спрятанный слот
Прокси — контракт, который сам ничего не считает. Он хранит адрес реализации и переадресует туда всё, что не совпало с его собственными функциями:
fallback() external payable {
address impl = _implementation();
(bool ok, bytes memory out) = impl.delegatecall(msg.data);
require(ok, unicode"delegatecall не прошёл");
assembly { return(add(out, 32), mload(out)) }
}
Адрес реализации нельзя положить в обычное поле: оно займёт слот 0, и первое же поле реализации затрёт его при первой записи. Поэтому адрес прячут в «дальний» слот, номер которого — хеш строки:
keccak256("eip1967.proxy.implementation") - 1
= 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
Обычная нумерация до такого номера не дойдёт никогда — значит, столкновения не будет. Это отдельный стандарт, и все библиотеки пользуются одним и тем же номером: поэтому внешние программы умеют читать адрес реализации у любого прокси.
Обновление: адрес тот же, данные те же
Вторая модель проходит шаг демонстрации целиком:
── откуда взялся номер слота ──
keccak256("eip1967.proxy.implementation") - 1
= 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
── развернули: прокси + реализация V1 ──
адрес прокси: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
адрес реализации: 0x5FbDB2315678afecb367f032d93F642f64180aa3
версия логики: V1
ставка rate: 10 %
totalDeposits: 1000
── что лежит в хранилище ПРОКСИ (сырые слоты) ──
слот 0: 0x…f39fd6e51aad88f6f4ce6ab8827279cfffb92266 (владелец)
слот 1: 0x…000000000a (ставка 10)
слот 2: 0x…0000003e8 (вклады 1000)
слот EIP-1967: 0x…5fbdb2315678afecb367f032d93f642f64180aa3
── а что в хранилище самой РЕАЛИЗАЦИИ ──
v1.owner(): 0x0000000000000000000000000000000000000000
v1.initialized(): false
v1.rate(): 0
Последний блок — самое неожиданное. У самой реализации владелец нулевой, а ставка ноль. Её конструктор выполнялся при её собственном развёртывании и писал в её хранилище; прокси этого не видел никогда.
Отсюда правило: вместо конструктора — функция начальной настройки, которую вызывают через прокси один раз:
function initialize(address _owner, uint256 _rate) public {
require(!initialized, unicode"Уже инициализирован");
initialized = true;
owner = _owner;
rate = _rate;
}
Защита от повторного вызова обязательна: без неё любой сможет переназначить владельца.
Реализацию можно проинициализировать напрямую — в модели чужой вызов прошёл и записал ставку 777 в память самой реализации. На прокси это не повлияло, но безопасной такую реализацию считать нельзя: настраивать её надо и напрямую тоже, чтобы место было занято.
Съехавшая раскладка: адрес читается как число
Прокси хранит байты, а не смысл. Третья модель обновляет один и тот же прокси на версию, где поля переставлены местами:
── исходное состояние: treasury = anna, rate = 10, totalDebt = 500 ──
слот 0: 0x…70997970c51812dc3a010c7d01b50e0d17dc79c8
слот 1: 0x…000000000a
слот 2: 0x…0000001f4
── ТОТ ЖЕ прокси обновили на V2, где поля переставлены местами ──
rate: 642829559307850963015472508762062935916233390536
← это адрес anna, прочитанный как число
treasury: 0x000000000000000000000000000000000000000A
← это число 10, прочитанное как адрес
totalDebt: 500 ← слот 2 не трогали, значение цело
сырые слоты после обновления совпадают с теми же слотами до: true
── V2, где новое поле вставлено в СЕРЕДИНУ ──
treasury: 0x7099…79C8 ← слот 0 совпал
lltv: 10 ← сюда съехал старый rate
rate: 500 ← сюда съехал старый totalDebt
totalDebt: 0 ← слот 3 никогда не заполняли
── V2, где новое поле добавлено В КОНЕЦ ──
treasury: 0x7099…79C8
rate: 10
totalDebt: 500
lltv: 0 ← новое поле, пока пустое
Байты не сдвинулись ни на один — сырые слоты до и после совпадают. Изменилось только то, как их читают. Казна превратилась в число из сорока восьми цифр, ставка — в адрес.
Второй случай мягче на вид и опаснее по последствиям: вставка поля в середину сдвинула всё, что за ним. Ставка стала десяткой из старого поля, долг — пятисоткой, а новое поле осталось пустым. Ничего не откатилось, ошибок нет — протокол просто считает по чужим числам.
Правило одно: новые поля добавляют только в конец. Порядок существующих не меняют, поля не удаляют, тип не сужают.
Как проверить раскладку до обновления
Карту слотов выдаёт сам компилятор — надо только попросить: в настройках проекта включается вывод раскладки хранилища, и после сборки в артефакте появляется таблица «поле → слот → тип».
Порядок работы перед обновлением: собрать старую версию, собрать новую, сравнить таблицы. Если у какого-то поля изменился номер слота или тип — обновлять нельзя.
Это делается за минуту и закрывает целый класс ошибок, которые иначе не находятся вообще.
Две формы переадресации
Четвёртая модель сравнивает прокси, написанный целиком на обычном коде, с прокси, где переадресация сделана вставкой на языке виртуальной машины:
── обе формы fallback переадресуют вызов одинаково ──
через Solidity-fallback: rate = 10
через ассемблер: rate = 10
── разница первая: цена одного и того же setRate(10) ──
Solidity-fallback: 48908 газа
ассемблер: 48541 газа
переплата за Solidity: 367 газа
── разница вторая: что доходит до вызывающего при откате ──
Solidity-fallback: reverted with reason string 'delegatecall не прошёл'
ассемблер : reverted with reason string 'Нет столько денег'
── и то же самое со своей ошибкой ──
Solidity-fallback: reverted with reason string 'delegatecall не прошёл'
ассемблер : reverted with custom error 'NotEnough(500, 0)'
Обе формы работают. Но обычная стирает причину отката: до пользователя доходит бесполезное «переадресация не прошла» вместо настоящего сообщения реализации. Разница в газе при этом небольшая — 367 единиц.
Именно поэтому все готовые прокси написаны вставкой на языке машины: она возвращает наружу ровно те байты, что вернула реализация. Писать её с нуля на площадке не нужно — достаточно понимать, почему обычная форма плоха, и что при отладке «delegatecall не прошёл» означает «смотри в реализацию».
Кто имеет право обновлять
Обновление — самая опасная функция протокола: она меняет весь код разом. Минимальный набор защит:
function upgradeTo(address newImpl) public {
require(msg.sender == admin, unicode"Только админ");
require(newImpl.code.length > 0, unicode"По адресу нет контракта");
_setImplementation(newImpl);
emit Upgraded(newImpl);
}
Проверка «по адресу есть код» спасает от опечатки: обновление на пустой адрес превращает прокси в контракт, который ничего не умеет и уже не может быть обновлён обратно.
У прокси есть и собственные функции — узнать адрес реализации, обновить. Они перехватывают вызов раньше, чем он дойдёт до реализации: если у реализации есть функция с тем же именем, вызвать её через прокси не получится. Это называется конфликтом селекторов, и решают его по-разному: либо прокси отвечает своими функциями только администратору, либо функцию обновления переносят в саму реализацию.
Типичные ошибки
Конструктор вместо функции настройки. Данные окажутся в памяти реализации, а прокси останется пустым.
Функция настройки без защиты от повторного вызова. Любой переназначит владельца.
Адрес реализации в обычном поле. Первое же поле реализации его затрёт.
Порядок полей изменён при обновлении. Байты читаются иначе; ошибок нет, числа чужие.
Новое поле вставлено в середину. Всё, что за ним, сдвигается.
Обновление доступно кому угодно. Смена всего кода протокола одной транзакцией.
Обновление на адрес без кода. Прокси перестаёт работать и не может быть обновлён обратно.
Результат delegatecall не проверен. Транзакция «успешна», хотя вызов провалился.
Обычная форма переадресации. Причина отката теряется — отладка вслепую.
fallback не помечен payable. Вызовы с эфиром до реализации не дойдут.
Слова главы
delegatecall— выполнить чужой код в своём хранилище.- Прокси (proxy) — контракт, который хранит данные и переадресует вызовы.
- Реализация (implementation) — контракт с кодом, у которого своя, обычно пустая, память.
- Раскладка хранилища — соответствие полей номерам слотов; задаётся порядком объявления.
- Слот EIP-1967 — «дальний» номер для адреса реализации; общий для всех библиотек.
fallback— функция «всё остальное»; у прокси именно она делает переадресацию.- Функция начальной настройки — заменяет конструктор; вызывается через прокси один раз.
- Конфликт селекторов — функция прокси перехватывает одноимённую функцию реализации.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
fallback() external payable {
На чемпионате
Модуль А, 37 баллов. Прокси записан требованием, а не рекомендацией, и шаг демонстрации засчитывается только полностью: показать и новые параметры, и неизменившийся адрес.
Что подготовить заранее:
- две версии реализации, отличающиеся ровно теми параметрами, что перечислены в задании: курсы валют, ставка, пороги ликвидации;
- скрипт или последовательность действий, которая печатает адрес прокси до и после — чтобы эксперт увидел совпадение;
- порядок полей во второй версии совпадает с первой, новые поля — только в конец.
Отдельно стоит проверить, что после обновления старые данные читаются правильно: вклады, долги, доли. Это и есть содержание шага «показать изменение параметров».
Модуль В, 14 баллов. Готовые пункты отчётов: конструктор вместо функции настройки, отсутствие защиты от повторной настройки, обновление без проверки прав, непроверенный результат переадресации, изменённый порядок полей между версиями.
Что дальше
Блок стандартов закончен, а вместе с ним — и весь Solidity. Контракт умеет всё, что требует модуль А: токены по стандартам, хранилище с долями, обновление логики без смены адреса.
Дальше в треке — второй язык, TypeScript. Он нужен не для контрактов, а для всего вокруг них: скриптов развёртывания, тестов, интерфейса. Начинается он с нуля, как и Solidity, — но многое покажется знакомым.
А сейчас — экзамен по блоку стандартов.
Углубиться
- Прокси-маяк — когда одним обновлением нужно переключить сразу несколько одинаковых контрактов: хранилище и три маркета.
- Два вида прокси — где живёт функция обновления: в самом прокси или в реализации, и чем это отличается по цене и рискам.
- Промежутки в раскладке — приём, когда в базовом контракте заранее резервируют пустые слоты под будущие поля.
- Обновление с миграцией данных — что делать, если новая версия хранит те же данные иначе.
- Заморозка обновлений — как отказаться от возможности менять код, когда протокол устоялся.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Экзамен по блоку «Стандарты и обновления»
Четыре главы: токен своими руками, готовая библиотека, доли хранилища и обновляемые контракты. Проверка — по всему блоку.
Сводная проверка по 4 главам блока: 15 вопросов, 10:00 на всё, по одному вопросу, без возврата. Пересдавать можно сколько угодно раз.
Готовность: экзамены глав сданы 0 из 4. Блок можно сдавать и так, но шанс выше после всех глав.
Куда дальше: проверенные ресурсы
- EIP-1967: стандартные слоты прокси — откуда берётся номер слота для адреса реализации.
- Обновляемые контракты — OpenZeppelin — виды прокси и правила раскладки.
- Что можно и нельзя менять при обновлении — порядок полей, конструкторы, промежутки.
- Раскладка хранилища — docs.soliditylang.org — как поля ложатся в слоты.
- delegatecall и другие низкоуровневые вызовы — формальное описание.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.