Смарт-контракты в сети Ethereum по умолчанию неизменны. Однако для некоторых сценариев желательно иметь возможность их модифицировать.

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

Для чего это нужно?

Во-первых, в смарт-контракте могут обнаружиться ошибки или потенциальные уязвимости, которые необходимо будет исправить. Здесь нужно оговориться, если мы успеваем это сделать, то есть последствия от взлома еще не наступили, иначе это уже не имеет смысла.

Во-вторых, может быть потребоваться сценарий, когда нужно внести улучшения или новые функциональные возможности в смарт-контракты проект. Обычно это может потребоваться на старте, когда проект быстро и динамично меняется.

Важно ! В идеале, контроль над обновлениями должен быть децентрализован, чтобы избежать злонамеренных действий. То есть не находиться под контролем одного адреса. Для этого можно использовать мультисиг или строить полноценное DAO, которое будет управлять процессом обновления кода.

Изменить код смарт-контрактов можно несколькими способами:

  1. Создание нескольких версий смарт-контрактов и миграция состояния из старого контракта в новый контракт.

  2. Создание нескольких смарт-контрактов для раздельного хранения состояния и бизнес логики.

  3. Использование Proxy patterns для делегирования вызова функций из неизменяемого прокси-контракта в изменяемый логический контракт.

  4. Использование Strategy pattern. Создание неизменного основного контракта, который взаимодействует с гибкими вспомогательными контрактами и полагается на них для выполнения определенных функций.

  5. Использование Diamond pattern для делегирования вызовов функций из прокси-контракта логическим контрактам.

Ниже мы подробно поговорим про каждый из способов. Самый популярный - это proxy pattern. Если тебе нужен пример кода смарт-контрактов, то сразу переходи туда.

Версионирование

Основная идея заключается в создании нового контракта и переноса в него состояния из старого контракта. Изначально новый развернутый контракт должен иметь пустое хранилище.

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

  1. Создание нового экземпляра контракта.

  2. Перенос состояния или миграция данных. Это может быть реализовано двумя способами:

    • On-chain. Миграция при помощи смарт-контрактов.

    • Off-chain. Сбор данных со старого контракта происходит за пределами блокчейна. На последнем этапе собранные данные записываются по адресу нового контракта.

  3. Обновить адрес нового контракта для всех контрактов, сервисов и клиентских приложений. То есть заменить старый адрес, на новый.

  4. Убедить пользователей и другие проекты перейти на использование нового контракта.

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

On-chain миграция

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

  • За счет пользователя. Когда мы предлагаем пользователю заплатить за газ. Мы реализуем смарт-контракт миграции, который при вызове определяет пользователя и переносит функционал на новый контракт.

  • За счет протокола. Мы можем это делать за счет протокола. Мы реализуем смарт-контракт миграции, который будет принимать список адресов и заниматься переносом состояния на новый контракт. В этом случае затраты на газ покрываются проектом.

Off-chain миграция

Считываем все данные из блокчейна. Если был взлом или сбой, то читать необходимо до проблемного блока. При этом работу действующего смарт-контракта лучше приостановить (если это возможно).

Все публичные примитивы легко считываются. Для приватных переменных чуть сложнее, но можно полагаться на события (events) или использовать метод getStorageAt() для чтения таких переменных из хранилища.

Чтобы восстановить данные из событий необходимо понимать, как события хранятся, как индексируются и как фильтруются за пределами блокчейн. Про это хорошо описано тут.

Одним из вариантов сбора данных является использования сервиса Google BigQuery API. Я подготовил несколько примеров и небольшой гайд для Google BigQuery API.

После того, как сбор всех данных со старого контракта завершен, необходимо записать их на новый контракт.

Раздельное хранение данных и бизнес логики

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

Логический контракт содержит код, который выполняется, когда пользователи взаимодействуют с приложением. Он также содержит адрес контракта хранилища и взаимодействует с ним для получения и записи данных.

Контракт хранилище может хранить балансы, адреса пользователей и так далее.

_Важно !_ В контракт хранилище должен записывать данные только определенный контракт логики и никто другой. Иначе кто угодно сможет затереть данные.

Посмотрим на примере смарт-контракта TokenLogic. Это контракт токена, в котором отсутствуют переменные состояния.

Пример смарт-контракта TokenLogic.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

interface IBalanceStorage {
    function balanceOf(address _account) external view returns (uint256);
    function setBalance(address _account, uint256 _newBalance) external;
}

interface ITotalSupplyStorage {
    function getTotalSupply() external view returns (uint256);
    function setTotalSupply(uint256 _newTotalSupply) external;
}

contract TokenLogic {
    IBalanceStorage public balanceStorage;
    ITotalSupplyStorage public totalSupplyStorage;

    event Transfer(address from, address to, uint256 amount);
    error AddressZero();

    constructor(address _balanceStorage, address _totalSupplyStorage) {
        balanceStorage = IBalanceStorage(_balanceStorage);
        totalSupplyStorage = ITotalSupplyStorage(_totalSupplyStorage);
    }

    function totalSupply() public view returns (uint256) {
        // Возвращаем значение из контракта хранилища TotalSupply
        return totalSupplyStorage.getTotalSupply();
    }

    function _mint(address _account, uint256 _amount) internal virtual {
        if (_account == address(0)) {
            revert AddressZero();
        }

        // Записываем новое значение TotalSupply
        uint256 prevTotalSupply = totalSupplyStorage.getTotalSupply();
        totalSupplyStorage.setTotalSupply(prevTotalSupply + _amount);

        // Записываем новое значение balance
        uint256 prevBalance = balanceStorage.balanceOf(_account);
        balanceStorage.setBalance(_account, prevBalance + _amount);

        emit Transfer(address(0), _account, _amount);
    }

Все переменные состояния вынесены в контракты BalanceStorage и TotalSupplyStorage. Эти два контракта имеют публичные методы для управления состояниями. Эти публичные методы могут быть вызваны только контрактом логики.

Пример смарт-контракта BalanceStorage.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/access/Ownable.sol";

contract BalanceStorage is Ownable {
    address private _logic;
    mapping (address => uint256) private _balances;

    modifier onlyLogic() {
        if (_msgSender() != _logic) {
            revert OnlyLogic(_msgSender());
        }

        _;
    }

    event BalanceSet(address account, uint256 newBalance);
    event LogicSet(address newLogic);

    error OnlyLogic(address sender);

    constructor(address logic) {
        _logic = logic;
    }

    function balanceOf(address _account) external view returns (uint256) {
        return _balances[_account];
    }

    function setBalance(address _account, uint256 _newBalance) onlyLogic() external {
        _balances[_account] = _newBalance;

        emit BalanceSet(_account, _newBalance);
    }

    function setLogic(address _newLogic) external onlyOwner() {
        _logic = _newLogic;

        emit LogicSet(_newLogic);
    }
}

Пример смарт-контракта SupplyStorage.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/access/Ownable.sol";

contract SupplyStorage is Ownable {
    address private _logic;
    uint256 private _totalSupply;

    modifier onlyLogic() {
        if (_msgSender() != _logic) {
            revert OnlyLogic(_msgSender());
        }

        _;
    }

    event TotalSupplySet(uint256 newTotalSupply);
    event LogicSet(address newLogic);

    error OnlyLogic(address sender);

    constructor(address logic) {
        _logic = logic;
    }

    function getTotalSupply() external view returns (uint256) {
        return _totalSupply;
    }

    function setTotalSupply(uint256 _newTotalSupply) onlyLogic() external {
        _totalSupply = _newTotalSupply;

        emit TotalSupplySet(_newTotalSupply);
    }

    function setLogic(address _newLogic) external onlyOwner() {
        _logic = _newLogic;

        emit LogicSet(_newLogic);
    }
}

Proxy pattern

Шаблон прокси использует разделение данных для хранения бизнес-логики и состояния аналогично шаблону "разделение данных". Однако имеет отличие. Можно сказать, что это обратный метод разделения данных, когда контракт хранилище делает вызовы на логический контракт.

_Важно !_ Дальше контракт "хранилище состояний" будем называть просто "прокси".

Схематично взаимодействие пользователя с контрактом реализации и прокси выглядит так.

Концепт proxy

Прокси шаблон работает следующим образом:

  1. Пользователь взаимодействует с контрактом прокси. Например, вызывает некую функцию. Взаимодействие пользователя происходит только с контрактом прокси.

  2. Прокси контракт не имеет реализованной функции, которая вызывается и поэтому контракт вызывает встроенную функцию fallback().

  3. Прокси-контракт хранит адрес контракта реализации и делегирует вызов функции контракту реализации (который содержит бизнес-логику) с использованием низкоуровневой функцииdelegatecall().

  4. После перенаправления вызова, исполнение кода происходит на контракте реализации, но записываются данные на контракте прокси.

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

Смысл использования delegatecall() в шаблонах прокси заключается в том, что прокси-контракт читает и записывает в свое хранилище, а выполняет логику, хранящуюся в другом контракте.

Пример смарт-контракта с вызовом delegateсall().

Это может звучать сложно, на самом деле тут нет никакой магии, "снаружи" это выглядит так - вы берете ABI смарт-контракта реализации и вызываете любую функцию этой реализации на смарт-контракте прокси.

Подобное можно проделать даже в Remix:

Чтобы заставить шаблон прокси работать, необходимо написать пользовательскую резервную функцию fallback(), которая указывает, как контракт прокси должен обрабатывать вызовы функций, которые он не поддерживает. А уже внутри сделать вызов логического контракта через delegateсall().

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

contract Proxy {
    // Любые вызовы функций через прокси будут делегироваться 
    fallback() external {
        _delegate(_getImplementation());
    }
}

В контракте прокси в любой момент можно изменить адрес контракта логики. Это позволяет нам обновлять логику контракта, не заставляя пользователей переходить на использование нового контракта.

Конфликты селекторов функций

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

Для того, чтобы вызов с контракта прокси всегда делегировался, нужно чтобы вызов любой функции всегда попадал в fallback() и делегировался контракту логики. Поэтому контракт прокси не должен содержать одноименных функций с контрактом логики. Если это случится, то вызов не будет делегирован. Это значит, что всегда необходимо помнить о конфликте селекторов функций.

Ниже представлен недопустимый вариант, когда вызов функции setBalance() на контракте прокси не будет делегирован на контракт реализации.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

contract Implementation {
  function setBalance(uint256 balance) external {
    ...
  }
}

contract Proxy {
    function setBalance(uint256 balance) external {
      ...
    }
      
    // Любые вызовы функций через прокси будут делегироваться 
    fallback() external {
        _delegate(_getImplementation());
    }
}

Подробнее можно почитать об этом тут.

Простой прокси

Весь вышеописанный опыт был описан в стандарте eip-1967. Стандарт описывает механизм безопасного делегирования вызова и несколько нюансов, связанных с хранением данных.

Простой пример контракта прокси:

contract Proxy {
    struct AddressSlot {
        address value;
    }

    /**
     * @notice Внутренняя переменная для определения места записи информации об адресе контракта логики
     * @dev Согласно EIP-1967 слот можно рассчитать как bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1));
     * Выбираем псевдослучайный слот и записывает адрес контракта логики в этот слот. Эта позиция слота должна быть достаточно случайной,
     * чтобы переменная в контракте логики никогда не занимала этот слот.
     */
    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    constructor(address logic) {
        _setImplementation(logic);
    }

    /// @notice Возвращает адрес установленного контракта логики для контракта прокси
    function getImplementation() external view returns (address) {
        return _getImplementation();
    }

    /// @notice Устанавливает адрес контракта логики для контракта прокси
    function setImplementation(address _newLogic) external {
        _setImplementation(_newLogic);
    }

    function _delegate(address _implementation) internal {
        // Необходима assembly вставка, потому что невозможно получить доступ к слоту для возврата значения в обычном solidity
        assembly {
            // Копируем msg.data и получаем полный контроль над памятью для этого вызова.
            calldatacopy(0, 0, calldatasize())

            // Вызываем контракт реализации
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)

            // Копируем возвращаемые данные
            returndatacopy(0, 0, returndatasize())

            switch result
            // Делаем revert, если возвращенные данные равны нулю.
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }

    /**
     * @notice Возращает адрес установленного контракта логики для контракта прокси
     * @dev Адрес логики хранится в специально отведенном слоте, для того, чтобы невозможно было случайно затереть значение
     */
    function _getImplementation() internal view returns (address) {
        return getAddressSlot(_IMPLEMENTATION_SLOT).value;
    }

    /**
     * @notice Устанавливает адрес контракта логики для котракта прокси
     * @dev Адрес логики хранится в специально отведенном слоте, для того, чтобы невозможно было случайно затереть значение
     */
    function _setImplementation(address newImplementation) private {
        getAddressSlot(_IMPLEMENTATION_SLOT).value = newImplementation;
    }

    /**
     * @notice Возвращает произвольный слот памяти типа storage
     * @param slot Указатель на слот памяти storage
     */
    function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
        assembly {
            r.slot := slot
        }
    }

    /// @dev Любые вызовы функций контракта логики через прокси будут делегироваться благодаря обработке внутри fallback
    fallback() external {
        _delegate(_getImplementation());
    }
}

Основное, что нужно усвоить:

  1. Все вызовы проходят через контракт прокси, попадая в fallback() с последующим вызовом delegateсall().

  2. Контракт прокси хранит адрес контракта реализации в качестве переменной состояния. Так как переменные контракта реализации будут затирать значения в нулевом слоте, все собственные переменные контракта прокси должны хранится по случайным и недоступным слотам для контракта логики. Суть этой проблемы и ее решение описаны в eip-1967.

  3. При обновление контракта нужно сохранять прошлую схему хранения переменных. Иначе старые данные будут перезаписаны.

  4. Так как constructor() не является частью байт кода и запускается лишь один раз во время деплоя, необходим другой способ установки значений инициализации. Общепринятым считается использование функции initialize(). Подробнее об этом можно прочитать у OpenZeppelin.

Для прокси требуются собственные функции. Например upgradeTo(address newLogic) для смены адреса контракта реализации. Как решить проблему конфликта селекторов функции?

Первыми решение для этой проблемы придумали в OpenZeppelin. Они добавили понятие администратора прокси. Тогда, если администратор (т.е. msg.sender == admin), прокси не будет делегировать вызов, а выполнит вызов, если она существует или сделает revert(). Это решение называется Transparent Proxy.

Важно! Для того чтобы адрес администратора мог быть обычным пользователем и его вызовы делегировались контракту реализации, OpenZeppelin предлагает использовать дополнительный контракт ProxyAdmin. Вызовы от имени ProxyAdmin не будут делегироваться.

Transparent vs UUPS

Transparent и UUPS (Universal Upgradeable Proxy Standard) — это разные реализации шаблона прокси для механизма обновления смарт-контрактов от OpenZeppelin. На самом деле между этими двумя реализациями нет большой разницы в том смысле, что они используют один и тот же интерфейс для обновлений и делегирования вызовов с прокси на реализацию.

Разница заключается в том, где находится логика обновления, в прокси контракте или контракте реализации.

В Transparent proxy логика обновления находится в контракте прокси. Это означает, что контракт прокси имеет метод upgradeToAndCall(address newLogic, bytes memory data)

Пример TransparentProxy.sol
contract Logic {
    uint256 private _value;

    function store(uint256 value) public { /*..*/ }
    function retrieve() public view returns (uint256) { /*..*/ }
}

contract TransparentProxy {
    function _delegate(address implementation) internal virtual { /*..*/ }
    function getImplementationAddress() public view returns (address) { /*..*/ }

    /// @notice Обновить адрес контракта логики для прокси
    upgradeToAndCall(address newlogic, bytes memory data) external {
        // Меняем адрес логики в специальном слоте памяти прокси контракта
    }

    fallback() external { /*..*/ }
}

В UUPS логика обновления обрабатывается самим контрактом реализации. Это означает, что функцияupgradeToAndCall(address newLogic, bytes memory data) находится не в прокси, а в реализации.

Важно! До 5й версии в библиотеке OpenZeppelin была также функция upgradeTo(), в 5-й осталась только функция upgradeToAndCall(). Последняя позволяет обновлять имплементацию как с вызовом какой-либо функции так и без.

Пример UUPSProxy.sol
contract Logic {
    uint256 private _value;

    function store(uint256 value) public { /*..*/ }
    function retrieve() public view returns (uint256) { /*..*/ }

    /// @notice Обновить адрес контракта логики для прокси
    upgradeToAndCall(address newlogic, bytes memory data) external {
        // Меняем адрес логики в специальном слоте памяти прокси контракта
    }
}

contract UUPSProxy {
    function _delegate(address implementation) internal virtual { /*..*/ }
    function getImplementationAddress() public view returns (address) { /*..*/ }
    fallback() external { /*..*/ }
}

Обновление через UUPS может быть дешевле по газу и проще, чем обновление через Transparent Proxy, т.к. не нужно задействовать дополнительный смарт-контракт ProxyAdmin. С другой стороны ProxyAdmin дает больший уровень безопасности и позволяет отделить логику обновления от основной бизнес-логики.

Ещe одним важным моментом является то, что TransparentProxy при каждом вызове проверяет, от кого идет вызов — от смарт-контракта ProxyAdmin или от обычного пользователя. Это необходимо для определения, нужно ли делегировать выполнение или выполнять собственные методы администрирования прокси. Из-за дополнительного кода такой проверки все вызовы функций TransparentProxy незначительно дороже UUPS.

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

Код для песочницы TransparentProxy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol";
import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";

/**
 * Чтобы понять контракты. Лучше всего задеплоить их при помощи Remix.
 * Порядок деплоя:
 *      1. Задеплоить контракт Logic
 *      2. Задеплоить контракт LogicProxy(address Logic, address InitialOwner, 0x)
 *      3. Связать ABI контракта Logic с LogicProxy при помощи встроенного в Remix функционала "Deploy at address".
 *         Чтобы сделать это необходимо выбрать в поле CONTRACT - Logic, а в "At Address" установить адрес LogicProxy. Нажать на кнопку "At address"
 *          Это позволит вызывать методы контракта Logic для контракта LogicProxy
 *      4. Задеплоить контракт Logic2. Этот контракт обновит логику контракта Logic. Будет добавлена новая функция increment()
        5. Вызвать на контракте LogicProxy функцию "getAdmin()" чтобы получить адрес контракта администратора, затем связать ABI ProxyAdmin
            с этим адресом, как это было проделано в пункте 3
 *      6. На контракте ProxyAdmin вызвать upgradeAndCall(address LogicProxy, address Logic2, 0x) и передать туда адреса LogicProxy, Logic2 и data (можно нулевую 0x)
 *      7. Повторить пункт 3 но уже для контракта Logic2. Теперь у нас появился дополнительный метод increment().
 *         При этом состояние прокси не изменилось, там хранятся те же значения что были до обновления имплементации.
 */

/// Контракт логики
contract Logic {
    uint256 private _value;

    function store(uint256 _newValue) public {
        _value = _newValue;
    }

    function retrieve() public view returns (uint256) {
        return _value;
    }
}

/// Контракт логики для обновления
contract Logic2 {
    uint256 private _value;

    function store(uint256 _newValue) public {
        _value = _newValue;
    }

    function increment() public {
        _value += 1;
    }

    function retrieve() public view returns (uint256) {
        return _value;
    }
}

/// Контракт прокси
contract LogicProxy is TransparentUpgradeableProxy {
    constructor(address _logic, address _initialOwner, bytes memory _data)
        TransparentUpgradeableProxy(_logic, _initialOwner, _data)
    {}

    function getAdmin() external view returns (address) {
        return ERC1967Utils.getAdmin();
    }

    function getImplementation() external view returns (address) {
        return ERC1967Utils.getImplementation();
    }

    receive() external payable {}
}

Код для песочницы UUPSProxy.sol

Beacon Proxy

Это шаблон прокси, в котором несколько прокси контрактов ссылаются на один смарт-контракт Beacon. Этот смарт-контракт предоставляет всем прокси адрес контракта реализации.

Важно! Этот подход оправдывает себя, когда у вас несколько прокси, а контракт реализации один. В случае с TransparentProxy и UUPS будет необходимо обновлять каждый прокси. Beacon proxy обновит реализацию сразу для всех прокси.

Пример простой реализации Beacon proxy тут.

Код для песочницы BeaconProxy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";
import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";

/**
 * Чтобы понять контракты. Лучше всего задеплоить их при помощи Remix.
 * Порядок деплоя для тестирования в remix:
 *      1. Деплой контракта Logic
 *      2. Деплой контракта Beacon(address Logic, address Owner)
 *      3. Деплой контракта LogicProxy(address Beacon, 0x)
 *      4. Деплой контракта LogicProxy2(address Beacon, 0x)
 *      5. Деплой нового контракта Logic2
 *      6. Вызов upgradeTo(address Logic2) на контракте Beacon
 *      7. Вызов функции getImplementation() на каждом контракте LogicProxy для проверки смены контракта логики
 */

/// Контракт логики
contract Logic {
    uint256 private _value;

    function store(uint256 _newValue) public {
        _value = _newValue;
    }

    function retrieve() public view returns (uint256) {
        return _value;
    }
}

/// Контракт логики для обновления
contract Logic2 {
    uint256 private _value;

    function store(uint256 _newValue) public {
        _value = _newValue;
    }

    function increment() public {
        _value += 1;
    }

    function retrieve() public view returns (uint256) {
        return _value;
    }
}

// Контракт Beacon
contract Beacon is UpgradeableBeacon {
    // Для обновления логики для всех контрактов прокси нужно вызывать функцию upgradeTo() на контракте Beacon
    constructor(address _implementation, address _owner) UpgradeableBeacon(_implementation, _owner) {}
}

/// Контракт First прокси
contract LogicProxy is BeaconProxy {
    constructor(address _beacon, bytes memory _data) BeaconProxy(_beacon, _data) {}

    /// @notice Возвращает адрес Beacon контракта
    function getBeacon() public view returns (address) {
        return _getBeacon();
    }

    /// @notice Возвращает адрес установленного контракта логики для прокси
    function getImplementation() public view returns (address) {
        return _implementation();
    }

    /// @notice Возвращает описание прокси
    function getProxyDescription() external pure returns (string memory) {
        return "First proxy";
    }

    receive() external payable {}
}

/// Контракт Second прокси
contract LogicProxy2 is BeaconProxy {
    constructor(address _beacon, bytes memory _data) BeaconProxy(_beacon, _data) {}

    /// @notice Возвращает адрес Beacon контракта
    function getBeacon() public view returns (address) {
        return _getBeacon();
    }

    /// @notice Возвращает адрес установленного контракта логики для прокси
    function getImplementation() public view returns (address) {
        return _implementation();
    }

    /// @notice Возвращает описание прокси
    function getProxyDescription() external pure returns (string memory) {
        return "Second proxy";
    }

    receive() external payable {}
}

Minimal Clones

Это стандарт на основе eip-1167 для развертывания минимальных прокси контрактов, которые называют клонами. OpenZeppelin предлагает собственную библиотеку реализации стандарта.

Использовать этот подход следует, когда нужно on-chain создавать новый экземпляр контракта и это действие повторяющиеся с течением времени. Своего рода - фабрика контрактов. За счет низкоуровневых вызовов и свертки кода библиотеки в байткод такое клонирование относительно недорогое.

Важно! Библиотека поддерживает функции для создания контрактов create() и create2(). Также поддерживает функции для предсказания адресов склонированных контрактов.

Очень важно! Этот подход не служит для обновления логики контрактов задеплоенных при помощи eip-1167. Это просто дешевый, контролируемый способ создать клона существующего контракта.

Ниже, пример показывает создание контракта пары внутри контракта фабрики. Вдохновлялся концептом Uniswap.

Пример использования Minimal Clones

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

import "@openzeppelin/contracts/proxy/Clones.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/**
 * Чтобы понять контракты. Лучше всего задеплоить их при помощи Remix.
 * Порядок деплоя:
 * 1. Деплой контракта Pair
 * 2. Деплой контракта Factory(address Pair)
 * 3. Вызываем метод createPair на контракте Factory. Адреса токенов можно отправить любые
 * 4. Убедиться, что новый инстанс(клон) контракта Pair успешно создан
 */

interface IPair {
    function initialize(address _tokenA, address _tokenB) external;
}

contract Pair {
    address public factory;
    IERC20 public token0;
    IERC20 public token1;

    function initialize(address _tokenA, address _tokenB) external {
        require(factory == address(0), "UniswapV2: FORBIDDEN");

        factory = msg.sender;
        token0 = IERC20(_tokenA);
        token1 = IERC20(_tokenB);
    }

    function getReserves() public view returns (uint112 reserve0, uint112 reserve1) {/** */}
    function mint(address to) external returns (uint256 liquidity) {/** */}
    function burn(address to) external returns (uint256 amount0, uint256 amount1) {/** */}
    function swap(uint256 amount0Out, uint256 amount1Out, address to) external {/** */}
}

contract Factory {
    address public pairImplementation;
    mapping(address => mapping(address => address)) private _pairs;

    event PairCreated(address tokenA, address tokenB, address pair);

    constructor(address _pairImplementation) {
        pairImplementation = _pairImplementation;
    }

    function createPair(address _tokenA, address _tokenB) external returns (address pair) {
        require(getPair(_tokenA, _tokenB) == address(0), "Pair has been created already");

        // При помощи библиотеки clones развертываем контракт pair на основе задеплоенного контракта Pair
        bytes32 salt = keccak256(abi.encodePacked(_tokenA, _tokenB));
        pair = Clones.cloneDeterministic(pairImplementation, salt);

        // Инициализируем контракт пары. Передаем токены и дополнительно установится адрес factory для Pair
        IPair(pair).initialize(_tokenA, _tokenB);

        _pairs[_tokenA][_tokenB] = pair;

        emit PairCreated(_tokenA, _tokenB, pair);
    }

    function getPair(address tokenA, address tokenB) public view returns (address) {
        return _pairs[tokenA][tokenB] != address(0) ? _pairs[tokenA][tokenB] : _pairs[tokenB][tokenA];
    }
}

Больше примеров использования тут.

OpenZeppelin utils

Как мы говорили выше upgradeable контракты не имеют constructor(). Вместо этого используется общепринятая функция initialize(). Она используется для первичной инициализации данных при деплое обновляемого смарт-контракта.

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

Важно! Чтобы не оставлять proxy контракт неинициализированным, нужно вызывать функцию initialize()как можно раньше. Обычно это делается при помощи аргумента data в момент деплоя proxy.

Важно! Помимо того, что нельзя оставлять proxy контракт неинициализированным, также не рекомендуется оставлять возможность вызвать функцию initialize() на контракте логики.

Для запрета вызова функции initialize() на контракте логики утилита реализует функцию _disableInitializers();.

Пример использования:

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
    _disableInitializers();
}
Пример использования Initializable.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol";
import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";

/**
 * Чтобы понять контракты. Лучше всего задеплоить их при помощи Remix.
 * Порядок деплоя:
 *      1. Задеплоить контракт Logic. Попробовать вызвать initialize() на задеплоенном контракте.
 *         Наша защита не позволит этого сделать
 *      2. Задеплоить контракт LogicProxy(address Logic, address InitialOwner, 0x)
 *      3. Связать ABI контракта Logic с LogicProxy при помощи встроенного в Remix функционала "Deploy at address".
 *         Чтобы сделать это необходимо выбрать в поле CONTRACT - Logic, а в "At Address" установить адрес LogicProxy. Нажать на кнопку "At address"
 *          Это позволит вызывать методы контракта Logic для контракта LogicProxy
 *      4. Вызвать функцию initialize() на контракте Logic (из пункта 3, этот контракт позволяет прокси вызывать методы Logic)
 *         Убедиться, что транзакция прошла успешно. Вызвать функцию initialize() повторно. Убедиться что транзакция вернулась с ошибкой
 */

/// Контракт логики
contract Logic is Initializable {
    uint256 private _defaultValue;
    uint256 private _value;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        /// Это не позволит инициализировать контракт логики миную прокси
        _disableInitializers();
    }

    /**
     * @notice Функция инициализации.
     * @param defaultValue Дефолтное значение
     * @dev Используется модификатор из контракта Initializable.sol от OpenZeppelin
     */
    function initialize(uint256 defaultValue) external initializer {
        _defaultValue = defaultValue;
    }

    function store(uint256 _newValue) public {
        _value = _newValue;
    }

    function retrieve() public view returns (uint256) {
        if (_value != 0) {
            return _value;
        }

        return _defaultValue;
    }
}

/// Контракт прокси
contract LogicProxy is TransparentUpgradeableProxy {
    constructor(address _logic, address _initialOwner, bytes memory _data)
        TransparentUpgradeableProxy(_logic, _initialOwner, _data)
    {}

    function getAdmin() external view returns (address) {
        return ERC1967Utils.getAdmin();
    }

    function getImplementation() external view returns (address) {
        return ERC1967Utils.getImplementation();
    }

    receive() external payable {}
}

Strategy pattern

На этот подход напрямую влияет классический паттерн стратегии. Основная идея которого заключается в выборе поведения или алгоритма действий в зависимости от условий во время выполнения.

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

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

Всегда можно создать новый вспомогательный контракт и настроить основной контракт на новый адрес. Это позволяет менять стратегии (внедрять новую логику или другими словами обновлять код) для смарт-контракта.

Важно! Главный недостаток заключается в том, что этот шаблон в основном полезен для развертывания незначительных обновлений. Кроме того, если основной контракт скомпрометирован (был взлом), то этот метод обновления уже не подойдет.

Примеры strategy pattern

  1. Хорошим примером простого паттерна стратегии является Compound, который имеет разные реализации RateModel для расчета процентной ставки, и его контракт CToken может переключаться между ними.

  2. Чуть более сложной реализацией паттерна стратегия является "Pluggable Modules" или подключаемые модули. В этом подходе основной контракт предоставляет набор основных неизменяемых функций и позволяет регистрировать новые модули. Эти модули добавляют новые функции для вызова в основной контракт. Этот паттерн встречается в кошельке Gnosis Safe. Пользователи могут добавить новые модули в свои собственные кошельки, а затем каждый вызов контракта кошелька будет запрашивать выполнение определенной функции из определенного модуля.

Важно ! Нужно иметь в виду, что Pluggable Modules также требует, чтобы основной контракт не содержал ошибок. Любые ошибки в самом управлении модулями не могут быть исправлены путем добавления новых модулей в эту схему.

Diamond pattern

Этот подход можно считать улучшением прокси шаблона. Главное отличие заключается в том, что diamond proxy может делегировать вызовы более чем одному логическому контракту.

Diamond шаблон обновления имеет некоторые преимущества по сравнению с обычными шаблонами прокси:

  1. Можно обновить только небольшую часть контракта без изменения всего кода.

  2. Diamond шаблон позволяет легко разделить функции на несколько логических контрактов. Таким образом можно легко обойти ограничение размера контракта в 24 КБ.

  3. Diamond шаблон обеспечивает модульный подход к управлению разрешениями на обновление, можно ограничить обновление определенных функций в рамках смарт-контракта.

Снаружи Diamond паттерн кажется единым смарт-контрактом и имеет один адрес. Внутри использует набор смарт-контрактов, которые называются facets.

Когда снаружи на контракте Diamond прокси вызывается функция, прокси проверяет, есть ли у него facet с этой функцией, и вызывает ее, если она существует. При этом все состояния хранятся на основном контракте Diamond.

Важно! Diamond также, как и обычные прокси имеет резервную функцию fallback() внутри которой реализовано делегирование вызова к facets.

Первоначально EIP-2535 Diamonds был создан для устранения ограничения контракта в 24 КБ, но оказалось, что он полезен и помимо этого. Он обеспечивает основу для создания более крупных систем смарт-контрактов, которые могут расширяться в процессе разработки. Одним из примеров такой системы являются смарт-контракты блокчейна zkSync era, которые развернуты в Ethereum. Подробнее о них можно почитать в документации протокола.

Inherited storage

Так как много facets используют одно и тоже адресное пространство хранения в рамках контракта Diamond proxy необходимо правильно реализовать процесс создания и обновления state контракта.

Самая простая стратегия заключается в создание отдельного контракта Storage. Тут вспоминаем наш второй способ обновления смарт-контрактов (разделение данных). Важно строгое определение любых state переменных только в этом контракте. Эта стратегия работает и успешно используется в реализации паттерна.

Однако с большим количеством facets достаточно легко начать путать переменные объявленные в Storage и локально. Поэтому существует еще один подход к организации storage.

Diamond Storage

Для каждого facet можно указать разные места для начала хранения данных, тем самым предотвращая конфликты разных facets с разными переменными состояния в местах хранения.

Мы можем хэшировать уникальную строку, чтобы получить случайную позицию хранения и сохранить там структуру. Структура может содержать все переменные состояния, которые нам нужны. Уникальная строка может действовать как пространство имен для определенных функций.

App storage

Еще один вариант это завести одну структуру AppStorage для всех facets сразу. И в этой структуре хранить все переменные. Это может быть намного удобнее, потому что не нужно будет думать о разграничение state переменных.

Примеры diamond patterns

  1. Simple implementation

  2. Gas-optimized

  3. Simple loupe functions

Плюсы и минусы обновления смарт-контрактов

Плюсы

  1. Дает возможность исправить уязвимость после деплоя. Можно даже сказать (но это спорно), что это повышает безопасность, так как можно исправить уязвимость.

  2. Дает возможность добавлять функциональность к логике контракта после деплоя.

  3. Открывает новые возможности проектирования и построения децентрализованной системы с изолированием отдельных частей приложения и разграничением доступа и управления.

Минусы

  1. Отменяет идею блокчейна о неизменности кода. А значит с точки зрения безопасности, это плохо. Пользователи должны доверять разработчикам, чтобы они не изменяли смарт-контракты произвольно.

  2. Чтобы завоевать доверие пользователей, нужны дополнительные слои защиты, например DAO, которое будет защищать от несанкционированных изменений.

  3. Закладывание возможности обновления контракта может сильно увеличить его сложность.

  4. Небезопасный контроль доступа или централизация в смарт-контрактах может упростить злоумышленникам выполнение несанкционированных обновлений.

Вывод

Концепт "обновляемых" смарт-контрактов противоречит свойству иммутабельности смарт-контрактов. Однако, это может упростить и ускорить разработку проекта на старте или помочь исправить допущенные уязвимости.

Потенциально, доверие к проекту, который использует upgradeable contracts в своих проектах, (за исключением версионирования) может быть ниже со стороны пользователей. Более того, часто, аудиторы отмечают в своих отчетах возможность изменить логику работы приложения.

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

Links

  1. Upgrading smart contracts

  2. Upgradable Smart Contracts: What They Are and How To Deploy Your Own

  3. Upgrading smart contracts от OpenZeppelin

  4. yAcademy Proxies Research

  5. How contract migration works

  6. Proxy patterns

  7. Proxy Patterns For Upgradeability Of Solidity Contracts: Transparent vs UUPS Proxies

  8. ERC-1822: Universal Upgradeable Proxy Standard (UUPS)

  9. ERC-1167: Minimal Proxy Contract

  10. Proxies deep dive

  11. Strategy pattern

  12. Introduction to EIP-2535 Diamonds

  13. ERC-2535: Diamonds, Multi-Facet Proxy

  14. Smart Contract Security Audits for EIP-2535 Diamonds Implementations

Комментарии (1)