Skip to content

Latest commit

 

History

History
187 lines (120 loc) · 22.9 KB

File metadata and controls

187 lines (120 loc) · 22.9 KB

Proxy pattern

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

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

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

Концепт

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

  1. Пользователь взаимодействует с контрактом прокси. Например вызывает некий метод на контракте согласно бизнес-логике. Взаимодействие пользователя происходит только с контрактом прокси.
  2. Прокси контракт не имеет реализованной функции, которая вызывается и поэтому контракт вызывает встроенную функцию fallback(). Внутри этой функции происходит перенаправление вызова на логический контракт.
  3. Прокси-контракт хранит адрес логического контракта и делегирует все вызовы функций логическому контракту (который содержит бизнес-логику) с использованием delegatecall() функции.
  4. После перенаправления вызова на логический контракт возвращенные данные из логического контракта извлекаются и возвращаются пользователю или записываются на контракте прокси.

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

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

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

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

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

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

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

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

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

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

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

Простой пример контракта прокси можно посмотреть тут.

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

  1. Все вызовы проходят через контракт прокси, попадая в fallback() с последующим вызовом delegateсall().
  2. Контракт прокси хранит адрес контракта реализации в качестве переменной состояния. Так как переменные контракта логики будут затирать значения в нулевом слоте, все собственные переменные контракта прокси должны хранится по случайным и недоступным слотам для контракта логики. Суть этой проблемы и ее решение описаны в eip-1967.
  3. При обновление контракта до новой реализации нужно сохранять прошлую схему хранения переменных. Иначе старые данные будут перезаписаны.
  4. Так как constructor() не является частью байт кода и запускается лишь один раз во время деплоя, необходим другой способ установки значений инициализации. Общепринятым считается использование функции initialize(). Подробнее об этом можно прочитать у openzeppelin.

Поскольку прокси-контракт существует, ему потребуются собственные функции. Например upgradeTo(address newLogic) для смены адреса логического контракта. Но тогда прокси должен решить, следует ли проксировать/делегировать вызов к контракту логики. Что, если в контракте логики тоже есть функция с таким же именем, т.е. upgradeTo(address someAddr)?

Первыми решение для этой проблемы придумали в 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)

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(). Последняя позволяет обновлять имплементацию как с вызовом какой-либо функции так и без.

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 TransparentProxy {
    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 необходимо правильно реализовать в контракте логики методы для управления прокси. Иначе есть угроза никогда не обновить контракт. Для минимизации рисков можно посоветовать использовать возможности готового решения библиотеки OpenZeppelin

С примером использования TransparentProxy на базе библиотеки openzeppelin можно ознакомиться тут.

С примером использования UUPS на базе библиотеки openzeppelin можно ознакомиться тут.

Beacon Proxy

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

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

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

Minimal Clones

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

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

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

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

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

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

Плагин от openzeppelin

Для Hardhat у OpenZeppelin существуют плагин дял скриптов при помощи которого можно деплоить и управлять различными прокси. Ссылка на документацию. Использование вместе с hardhat.

Плагин поддерживают шаблоны прокси UUPS, Transparent и Beacon. В зависимости от шаблона варианты обновления отличаются.

OpenZeppelin utils

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

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

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

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

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

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

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
    _disableInitializers();
}

Рабочий пример кода контрактов можно посмотреть тут.

Links

  1. Пример delegateсall вызова
  2. DelegateCall: Calling Another Contract Function in Solidity
  3. Delegate call solidity doc
  4. Malicious backdoors in Ethereum Proxies
  5. Proxy patterns
  6. Proxy Patterns For Upgradeability Of Solidity Contracts: Transparent vs UUPS Proxies
  7. Upgrading your Smart Contracts | A Tutorial & Introduction
  8. ERC-1822: Universal Upgradeable Proxy Standard (UUPS)
  9. How to create a Beacon Proxy
  10. ERC-1167: Minimal Proxy Contract
  11. Proxies deep dive
  12. The initializable smart contract design pattern
  13. The Beacon Proxy Pattern Explained