Перейти к основному содержимому
Блокчейн17ГЛАВА 17Прокси: обновление логики без смены адреса0xe7f1…0512V1V2данные осталисьадрес тот же

Прокси: обновление логики без смены адреса

Прочитано0%
Квизы0/4
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

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

А в списке шагов демонстрации стоит: «Провести обновление логики контракта. Изменённые параметры: … Показать изменение параметров и отсутствие изменений в адресе контракта».

Требование выглядит невозможным: код контракта в сети неизменен, мы это знаем с первой главы. Ключ — в одной операции, которая выполняет чужой код в своём хранилище. Из неё вырастает вся конструкция; из неё же берутся способы её сломать так, что адрес превратится в число.

delegatecall: чужой код в своей памяти​

Обычный вызов через интерфейс запускает чужой код в чужом хранилище. У особого вызова delegatecall меняются три вещи. Первая модель показывает их таблицей:

── таблица ──
что call delegatecall
чей код выполняется Logic Logic
чьё хранилище меняется Logic Caller
address(this) Logic Caller
msg.sender Caller человек
куда попал 1 ETH Logic Caller
ОДИН И ТОТ ЖЕ КОД, ДВА СПОСОБА ЕГО ПОЗВАТЬобычный вызовdelegatecallчей код выполняетсячужойчужойчьё хранилище меняетсячужоесвоёчему равен address(this)чужой адрессвой адрескем оказывается msg.senderвызывающий контрактчеловекdelegatecall выполняет чужой код в своём хранилище — и эфир при этом никуда не уходитна этом и стоит прокси: код меняется, память остаётся
Код чужой, а хранилище, адрес и отправитель — свои: на этом стоит вся конструкция прокси

Читается так: код берём чужой, а работаем со своей памятью. Внутри чужого кода 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
ОБНОВЛЕНИЕ — ЭТО ОДНА ЗАПИСЬ В СПРЯТАННЫЙ СЛОТпрокси0xe7f1…0512слот 0: владелецслот 1: ставка 10слот 2: вклады 1000слот 0x3608…2bbc: адрес кодабылосталореализация V1проценты, вкладыреализация V2то же плюс новая функция и другая ставкаадрес прокси до и после обновления один и тот же; ставка и вклады пережили подмену кодау самой реализации владелец нулевой, а ставка 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 ← новое поле, пока пустое
ПРОКСИ ХРАНИТ БАЙТЫ, А НЕ СМЫСЛбайты в слотах не меняются — меняется то, как их читаютслот 00x…70997970C518…79C8казна: адрес annaставка: 642829559307850963…слот 10x…0000000aставка: 10казна: 0x000…00Aслот 20x…000001f4долг: 500долг: 500у новой версии поля объявлены в другом порядке — адрес читается как число, число как адресновые поля добавляют только в конец: вставка в середину сдвигает всё, что за ней
Байты в слотах не изменились — изменилось то, как их читают: адрес стал числом, число адресом

Байты не сдвинулись ни на один — сырые слоты до и после совпадают. Изменилось только то, как их читают. Казна превратилась в число из сорока восьми цифр, ставка — в адрес.

Второй случай мягче на вид и опаснее по последствиям: вставка поля в середину сдвинула всё, что за ним. Ставка стала десяткой из старого поля, долг — пятисоткой, а новое поле осталось пустым. Ничего не откатилось, ошибок нет — протокол просто считает по чужим числам.

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

Отвечено вопросов: 0 из 2
Чем delegatecall отличается от обычного вызова?
У новой версии контракта поля объявлены в другом порядке. Что произойдёт при обновлении?
Отвечено верно: 0 из 2

Как проверить раскладку до обновления​

Карту слотов выдаёт сам компилятор — надо только попросить: в настройках проекта включается вывод раскладки хранилища, и после сборки в артефакте появляется таблица «поле → слот → тип».

Порядок работы перед обновлением: собрать старую версию, собрать новую, сравнить таблицы. Если у какого-то поля изменился номер слота или тип — обновлять нельзя.

Это делается за минуту и закрывает целый класс ошибок, которые иначе не находятся вообще.

Две формы переадресации​

Четвёртая модель сравнивает прокси, написанный целиком на обычном коде, с прокси, где переадресация сделана вставкой на языке виртуальной машины:

── обе формы 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 баллов. Готовые пункты отчётов: конструктор вместо функции настройки, отсутствие защиты от повторной настройки, обновление без проверки прав, непроверенный результат переадресации, изменённый порядок полей между версиями.

Отвечено вопросов: 0 из 2
Что нужно показать эксперту на шаге обновления логики?
Почему у реализации, развёрнутой рядом с прокси, владелец нулевой?
Отвечено верно: 0 из 2

Что дальше​

Блок стандартов закончен, а вместе с ним — и весь Solidity. Контракт умеет всё, что требует модуль А: токены по стандартам, хранилище с долями, обновление логики без смены адреса.

Дальше в треке — второй язык, TypeScript. Он нужен не для контрактов, а для всего вокруг них: скриптов развёртывания, тестов, интерфейса. Начинается он с нуля, как и Solidity, — но многое покажется знакомым.

А сейчас — экзамен по блоку стандартов.

Отвечено вопросов: 0 из 1
Почему готовые прокси пишут вставкой на языке виртуальной машины, а не обычным кодом?
Отвечено верно: 0 из 1

Углубиться​

  • Прокси-маяк — когда одним обновлением нужно переключить сразу несколько одинаковых контрактов: хранилище и три маркета.
  • Два вида прокси — где живёт функция обновления: в самом прокси или в реализации, и чем это отличается по цене и рискам.
  • Промежутки в раскладке — приём, когда в базовом контракте заранее резервируют пустые слоты под будущие поля.
  • Обновление с миграцией данных — что делать, если новая версия хранит те же данные иначе.
  • Заморозка обновлений — как отказаться от возможности менять код, когда протокол устоялся.

Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Экзамен по блоку «Стандарты и обновления»​

Четыре главы: токен своими руками, готовая библиотека, доли хранилища и обновляемые контракты. Проверка — по всему блоку.

Экзамен по блоку: Стандарты и обновления

Сводная проверка по 4 главам блока: 15 вопросов, 10:00 на всё, по одному вопросу, без возврата. Пересдавать можно сколько угодно раз.

Готовность: экзамены глав сданы 0 из 4. Блок можно сдавать и так, но шанс выше после всех глав.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоSolidity и смарт-контракты Ethereum, урок #28 | Паттерн Proxy/Upgradeable: TransIlya Krukowski
ВидеоSolidity и Ethereum, урок #59 | Как работают transparent upgradeable proxy и ERCIlya Krukowski
ВидеоSolidity и Ethereum, урок #78 | Beacon и proxy: один маячок для управления всемиIlya Krukowski
ВидеоDelegatecall | Solidity 0.8Smart Contract Programmer
ВидеоProxy Patterns and Storage Layouts — Forge CollegeForge College

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.