Перейти к содержанию

Обновление Solidity-контракта: совместимость storage

Оглавление курса «Безопасность: разбор собственного контракта» · Урок 26 из 30.

Постоянное состояние имеет договор, который продолжается дольше отдельного вызова. Теперь рассмотрим замену кода, читающего это состояние. В уроке введём новую самостоятельную модель обновляемого реестра и покажем совместимое добавление поля. Главный результат — объяснить, почему одинаковые имена getters и успешная замена адреса реализации ещё не доказывают сохранность курса 17.

Это отдельный UpgradeableCourseCatalogV1, не базовый CourseCatalog с immutable publisher. В новой модели publisher является обычной переменной proxy storage, а права первоначально задаёт initialize. Профиль исходников: Solidity 0.8.30, Cancun и OpenZeppelin Contracts 5.4.0. Пакеты не устанавливались, компиляция, проверки и развёртывание не выполнялись.

Код и состояние находятся в разных ролях

При выбранной proxy-схеме пользователь обращается к адресу прокси, а реализация предоставляет исполняемый код. Договор делегированного выполнения сохраняет контекст состояния прокси. Поэтому новая версия обязана интерпретировать уже записанные значения согласованно. ERC-1967 выделяет специальную область для указателя реализации, но не исправляет произвольные изменения собственных полей приложения. Документация прокси OpenZeppelin.

У базового контракта publisher записан как immutable в коде его экземпляра. Такое назначение нельзя автоматически превратить в proxy-поле простым изменением способа вызова. Поэтому здесь создаём отдельный реестр с собственной жизнью и адресом. Старый CourseCatalog продолжает существовать; сайт меняет окружение лишь по явной процедуре, если позднее решит использовать новую модель.

Новый вариант сохраняет знакомую предметную семантику publish и deactivate: публикация заменяет хеш и включает запись, деактивация сохраняет хеш и выключает active. Дополнительно вводим releaseCount как счётчик успешных публикаций. Он не является revision курса и не реализует условие сравнения из GuardedCourseCatalog прежних глав.

Самостоятельная первая версия

В листинге используются утилиты proxy из стандартного пакета Contracts 5.4.0. Для этой минимальной модели они подходят по своему устройству; не добавляем обычные stateful базовые классы с конструкторами, ожидая, что они автоматически заполнят proxy storage. API UUPS сверено по исходнику версии 5.4.0.

pragma solidity 0.8.30;
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";

contract UpgradeableCourseCatalogV1 is Initializable, UUPSUpgradeable {
    struct Course { bytes32 contentHash; bool active; }
    address public publisher;
    mapping(uint256 => Course) public courses;
    uint256 public releaseCount;

    error Unauthorized();
    error InvalidCourse();
    error NotActive();
    event Published(uint256 indexed courseId, bytes32 contentHash);
    event Deactivated(uint256 indexed courseId);

    constructor() { _disableInitializers(); }

    modifier onlyPublisher() {
        if (msg.sender != publisher) revert Unauthorized();
        _;
    }

    function initialize(address publisher_) external initializer {
        if (publisher_ == address(0)) revert Unauthorized();
        publisher = publisher_;
    }

    function publish(uint256 courseId, bytes32 contentHash) external onlyPublisher {
        if (courseId == 0 || contentHash == bytes32(0)) revert InvalidCourse();
        courses[courseId] = Course(contentHash, true);
        releaseCount += 1;
        emit Published(courseId, contentHash);
    }

    function deactivate(uint256 courseId) external onlyPublisher {
        if (!courses[courseId].active) revert NotActive();
        courses[courseId].active = false;
        emit Deactivated(courseId);
    }

    function _authorizeUpgrade(address) internal override onlyPublisher {}
}

Право обновления в этой учебной модели имеет тот же publisher. Это сильное полномочие: новый код способен изменить любые правила. Оно не равнозначно обычной публикации материала. Реальный продукт может разделять роли, вводить задержку или другое управление, но такие гарантии нельзя приписывать маленькому листингу без их реализации.

Раскладка собственных полей

При выбранных базовых утилитах обсуждаем ожидаемое размещение: publisher начинается в обычном slot 0, mapping имеет опорный slot 1, releaseCount — slot 2. Состояние Initializable находится в отдельном пространстве имён его утилиты; внутренний immutable UUPS не является нашим proxy publisher. Точную раскладку будущего скомпилированного артефакта необходимо отдельно получить и сравнить. Мы её не извлекали компилятором.

Значения mapping расположены по производным от его опорного места адресам. Поэтому перестановка объявления courses меняет способ поиска всех существующих записей. Данные могут оставаться в storage, но новый getter читает другую область и показывает нули. Это не означает, что курс 17 никогда не публиковался: новая реализация потеряла согласованную интерпретацию.

Версия Обычная последовательность собственных полей Ожидаемый смысл
V1 publisher, courses, releaseCount исходное размещение
V2 ошибочная releaseCount, publisher, courses прежние значения читаются иначе
V2 добавочная прежние поля, затем retentionEpoch прежняя область сохранена

Ошибочную строку не используем как пример безопасного обновления. Даже когда имена функций совпали, publisher может читаться из другой области, а courses(17) — по другому seed mapping. Простая проверка proxiableUUID подтверждает механизм определённого профиля, но не доказывает предметную совместимость всех полей.

Добавим отдельное поле

Для самостоятельной второй версии наследуем прежнюю модель и добавим значение в конец. Новая инициализация защищена версией и ролью. Имя retentionEpoch служит лишь меткой политики, а не автоматически удаляет storage или историю.

pragma solidity 0.8.30;
import "./UpgradeableCourseCatalogV1.sol";

contract UpgradeableCourseCatalogV2 is UpgradeableCourseCatalogV1 {
    uint256 public retentionEpoch;

    function initializeV2(uint256 epoch) external reinitializer(2) onlyPublisher {
        require(epoch != 0, "epoch required");
        retentionEpoch = epoch;
    }
}

После ожидаемого совместимого перехода publisher остаётся прежним, courses(17) хранит тот же хеш и active, releaseCount не меняется от самого обновления. Новый retentionEpoch задаётся согласованной процедурой. Изменение порядка наследования или структуры Course потребовало бы отдельного анализа: добавление поля в одном месте не разрешает любые изменения рядом.

Договор обновления

До действия фиксируем адрес прокси, старую реализацию, версию компилятора, профиль зависимостей и значения контрольного курса. Затем сравниваем размещение и предметные свойства. Обновление вместе с необходимым initializeV2 должно иметь явный атомарный маршрут, чтобы не оставить промежуточное состояние новой версии.

В этой главе ничего не обновлялось. Мы получили исходники, ожидаемые свойства и конкретный несовместимый вариант для будущей проверки. Следующий урок подробно разберёт первоначальную инициализацию: даже идеальная раскладка не защищает реестр, если право publisher первым назначил посторонний участник.

Совместимость данных и совместимость поведения

Допустим, размещение сохранено, но V2 меняет правило deactivate и стирает contentHash. Старое значение сначала читается правильно, однако следующая административная операция нарушает прежний предметный договор. Поэтому анализ обновления имеет две части: как код находит данные и что он с ними делает. Сравнение slots не заменяет проверку семантики.

Другой пример — новая версия увеличивает releaseCount при деактивации. Storage по-прежнему совместим, но значение счётчика больше не означает число публикаций. Если интерфейс использовал этот смысл, он начинает показывать неверную статистику без единой ошибки ABI. Изменение должно быть введено как новая версия API или иной явно названный договор.

Полезно сохранить перед переходом несколько различных состояний: отсутствующий курс, активный курс17 и деактивированную запись с ненулевым хешем. Проверка только нулевого mapping не обнаруживает потерю его прежнего размещения: оба getters могут случайно возвращать нули. Именно ненулевые контрольные записи делают ожидаемое свойство содержательным.

Также добавочное поле не является разрешением заменить основные версии всех зависимостей. Изменение базового класса или его модели storage требует отдельного сравнения. На будущем запуске фиксируются точные исходники, параметры компиляции и значения, а нынешняя таблица остаётся ожидаемым описанием выбранного профиля.

Оглавление серии