В этой статье мы изучим фундаментальные принципы проектирования, лежащие в основе создания обновляемых смарт-контрактов. К концу вы должны понять, почему мы обновляем смарт-контракты, как обновлять смарт-контракты и какие аспекты следует учитывать при этом.
Чтобы получить максимальную пользу от этой статьи, вы должны иметь начальные знания о смарт-контрактах на базе Ethereum и EVM. В статье приводится краткое описание кода, так что опыт программирования не менее трех месяцев будет полезен, как и базовое понимание Solidity и способов его компиляции, что такое смарт-контракты и как они развертываются, а также как использовать такие инструменты, как Metamask и Hardhat.
Что такое обновляемые смарт-контракты?
Блокчейн должен быть неизменяемым - это один из главных принципов технологии блокчейн. Данные, хранящиеся на блокчейне Ethereum, включая смарт-контракты, развернутые на нем, также неизменяемы.
Прежде чем мы погрузимся в детали того, как обновлять смарт-контракты, давайте рассмотрим, зачем вообще нужно обновлять смарт-контракты.
Основными причинами являются:
Исправление ошибок
Улучшение функциональности
Изменение функциональности, которая больше не требуется или считается полезной
Оптимизация кода для более эффективного использования газа Ethereum
Чтобы реагировать на изменения в технологии, рынках или обществе
Чтобы избавиться от необходимости переводить целое сообщество пользователей на новую версию приложения.
Большинство вещей всегда требует некоторого обновления. Но тогда данные, хранящиеся в блокчейн, неизменяемы. Так как же тогда смарт-контракты могут быть обновляемыми?
Короткий ответ заключается в том, что смарт-контракты сами по себе не могут изменяться - они постоянны и неизменяемы после развертывания на блокчейне. Но dApp может быть разработан таким образом, чтобы один или несколько смарт-контрактов работали вместе, обеспечивая его "бэкенд". Это означает, что мы можем обновить схему взаимодействия между этими смарт-контрактами. Модернизация смарт-контракта не означает, что мы изменяем код развернутого смарт-контракта, а означает, что мы меняем один смарт-контракт на другой. Мы делаем это таким образом, что (в большинстве случаев) конечному пользователю не придется менять способ взаимодействия с dApp.
Таким образом, обновление смарт-контрактов - это процесс замены нового смарт-контракта на старый. По сути, используются новые смарт-контракты, а старые "бросаются" в блокчейн, поскольку они неизменяемы.
Как работает обновление?
Смарт-контракты обычно обновляются с помощью шаблона архитектуры программного обеспечения, называемого "прокси-паттерн". Но что означает слово "прокси" в проектировании программного обеспечения? В двух словах можно сказать, что прокси - это часть программного обеспечения в более крупной программной системе, которая действует от имени другой части системы. В традиционных вычислениях Web2 прокси находится между клиентским приложением и серверным приложением. Прямой прокси действует от имени клиентского приложения, а обратный прокси действует от имени серверного приложения.
В мире смарт-контрактов прокси является скорее обратным прокси, действующим от имени другого смарт-контракта. Это своего рода промежуточное программное обеспечение, которое перенаправляет входящий трафик с фронтенда на нужный смарт-контракт на бэкенде системы. Будучи смарт-контрактом, прокси имеет свой собственный "стабильный" (т.е. неизменный) адрес контракта Ethereum. Поэтому вы можете поменять местами другие смарт-контракты в системе и просто обновить смарт-контракт прокси с правильным адресом нового развернутого смарт-контракта. Конечные пользователи dApp взаимодействуют с прокси напрямую, а с другими смарт-контрактами - только косвенно, через прокси.
Таким образом, при разработке смарт-контрактов паттерн прокси достигается с помощью следующих двух частей:
Прокси-смарт-контракт
Контракт исполнения, также называемый логическим контрактом или контрактом реализации.
В этой статье мы будем называть эти элементы прокси-контрактом и логическим контрактом соответственно.
Существует три распространенных варианта паттерна прокси, которые мы рассмотрим ниже.
Простой шаблон прокси-сервера
Простой прокси имеет архитектуру, показанную ниже.
Давайте поподробнее разберемся, как это работает.
В EVM есть нечто, называемое "контекстом выполнения". Считайте, что это пространство, в котором выполняется код.
Так вот, у прокси-контракта есть свой контекст исполнения, как и у всех других смарт-контрактов. У прокси-контракта также есть собственное хранилище, где данные постоянно хранятся на блокчейне, а также собственный баланс эфира. Вместе данные и баланс, которые хранит смарт-контракт, называются его "состоянием", а состояние является частью контекста исполнения.
Прокси-контракт использует переменные хранения для отслеживания адресов других смарт-контрактов, входящих в dApp. Именно так он может перенаправлять транзакции и вызывать соответствующий смарт-контракт.
Но существует хитрость, которая используется для передачи вызовов сообщений нужному контракту. Прокси-контракт не просто выполняет обычный вызов функции для логического контракта; он использует нечто, называемое delegatecall. Вызов делегата похож на обычный вызов функции, за исключением того, что код по целевому адресу выполняется в контексте вызывающего контракта. Если код логического контракта изменяет переменные хранения, эти изменения отражаются в переменных хранения прокси-контракта - т.е. в состоянии прокси-контракта.
Так где же в прокси-контракте находится логика вызова делегата? Ответ кроется в функции возврата прокси-контракта. Когда прокси-контракт получает вызов функции, которую он не поддерживает, для обработки этой функции будет вызвана резервная функция прокси-контракта. Прокси-контракт использует пользовательскую логику внутри своей функции fallback для перенаправления вызовов к логическим контрактам.
Применяя этот принцип к прокси и логическому контракту, delegatecall будет вызывать код логического контракта, но этот код будет выполняться в контексте выполнения прокси-контракта. Это означает, что код в логическом контракте имеет право изменять состояние в прокси-контракте - он может изменять переменные состояния и другие данные, хранящиеся в прокси-контракте. Это эффективно отделяет состояние приложения от выполняемого кода. Прокси-контракт фактически хранит все состояние dApp, что означает, что логика может быть изменена без потери этого состояния.
Теперь, когда состояние приложения и логика приложения разделены в EVM, мы можем обновить приложение, изменив логические контракты и передав новые адреса прокси. Но это обновление не повлияет на состояние приложения.
Есть две общие проблемы, которых следует остерегаться при использовании прокси.
Одна из проблем - коллизии хранения; другая - другой тип коллизии, называемый столкновением селекторов прокси. Вы можете прочитать статью по ссылке о коллизиях хранения, чтобы узнать больше, но сейчас мы сосредоточимся на столкновениях селекторов, поскольку именно они лежат в основе паттерна прокси, который мы будем рассматривать.
Как мы видели ранее, прокси делегируют все вызовы функций логическому контракту. Однако у прокси-контрактов есть и собственные функции, которые являются внутренними для них и необходимы для их работы. Например, прокси-контракту нужна функция upgradeTo(address newAdd) для обновления до нового адреса логического контракта. Что же произойдет, если прокси-контракт и логический контракт имеют функцию с одинаковым именем и подписью (параметры и типы)? Как прокси-контракт узнает, вызывать ли свою собственную функцию или делегировать вызов логическому контракту? Это известно как "столкновение селекторов прокси" и является уязвимостью безопасности, которую можно использовать, или, по крайней мере, источником раздражающих ошибок.
Технически, такое столкновение может произойти и между функциями, даже если они имеют разные имена. Это происходит потому, что каждая публично вызываемая функция (функция, которая может быть определена в ABI) идентифицируется на уровне байткода идентификатором длиной в четыре байта. Поскольку это всего четыре байта, технически возможно, что первые четыре байта двух совершенно разных сигнатур функций окажутся одинаковыми, что приведет к появлению идентичных идентификаторов для разных сигнатур функций и, как следствие, к конфликтам.
К счастью, компилятор Solidity может обнаружить этот подтип конфликтов селекторов, когда конфликт вызван подписями функций в рамках одного контракта, но не когда такой конфликт происходит в разных контрактах. Например, если конфликт произойдет между прокси-контрактом и логическим контрактом, компилятор не сможет его обнаружить, но внутри того же прокси-контракта компилятор обнаружит конфликт.
Решением этой проблемы является паттерн "прозрачного" прокси, который был распространён в Open Zeppelin.
Модель прозрачного прокси
Прозрачная схема прокси - это когда вызовы функций, инициированные конечным пользователем (вызывающей стороной), всегда направляются на логический контракт, а не на контракт прокси. Однако, если вызывающий является администратором прокси, прокси будет знать, что нужно вызвать свою собственную административную функцию. Это имеет интуитивный смысл, потому что вызов административных функций в контракте прокси для управления обновляемостью и другими административными задачами должен выполняться только администратором, и если произойдет столкновение, то можно сделать справедливое предположение, что администратор хотел вызвать функцию контракта прокси, а не функцию логического контракта. Но если вызывающая сторона - это любой другой неадминистративный адрес, прокси всегда будет делегировать вызов соответствующему логическому контракту. Мы можем определить вызывающую сторону, изучив значение message.sender.
В этом случае прокси-контракт будет иметь логику в своей функции обратного вызова для анализа message.sender и селектора вызываемой функции, и, соответственно, вызывать одну из своих собственных функций или делегировать вызов логическому контракту.
Контракты OpenZeppelin, как мы увидим в нашем прохождении кода, добавляют еще один уровень абстракции, при этом функциональность обновления принадлежит контракту ProxyAdmin - смарт-контракту, который является администратором для одного или нескольких прокси-контрактов. Этот контракт администратора прокси должен быть вызывающим для функциональности, связанной с обновлением. Таким образом, конечные пользователи будут взаимодействовать непосредственно с прокси, который будет делегировать вызов логическому контракту, но запросы на обновление и администрирование будут передаваться через контракт ProxyAdmin, который затем направит запрос на обновление прокси.
Прозрачные модели прокси имеют некоторые недостатки. Они подвержены коллизиям селектора функций, если их не обрабатывать тщательно, они могут стоить больше газа для работы (поскольку EVM требуется дополнительный газ для загрузки адреса логического контракта для каждого вызова делегата), и развертывание контракта прокси в этом шаблоне также может стоить больше газа.
Шаблон UUPS
Универсальный обновляемый стандарт прокси (UUPS) был предложен в EIP1822 как способ создания стандарта для прокси-контрактов, обладающего универсальной совместимостью со всеми контрактами. Он решает проблему столкновения селекторов прокси-функций. Этот шаблон также использует операцию вызова делегата Solidity, но если в шаблоне Simple/Transparent proxy все обновления управляются прокси-контрактом, то в UUPS обновления обрабатываются логическим контрактом, а именно "проксируемым" смарт-контрактом, от которого наследуется логический контракт.
Логический контракт по-прежнему будет выполняться в контексте прокси-контракта, таким образом, используя хранилище, баланс и адрес прокси-контракта, но логический контракт наследуется от родительского контракта Proxiable, который содержит функциональность обновления. Логика обновления, содержащаяся в проксируемом смарт-контракте, используется для обновления адреса логического контракта, который хранится в прокси-контракте.
Поскольку компилятор Solidity способен обнаружить столкновения селекторов функций, если они возникают внутри одного контракта, наличие логики обновления в родительском проксируемом контракте помогает компилятору выявить такие столкновения, что снижает их вероятность.
Прокси-шаблон UUPS также имеет недостатки. Хотя развертывание по этой схеме дешевле (меньше газа), обслуживание смарт-контрактов dApp по этой схеме может быть немного сложнее.
Важной проблемой является то, что поскольку логика обновления находится не в прокси-контракте, а в проксируемом родительском контракте логического контракта, если обновленный логический контракт не наследует proxiable, то функциональность обновления не наследуется, и обновить смарт-контракт в будущем будет невозможно.
Но у этой проблемы есть и обратная сторона: Шаблон UUPS позволяет удалить возможность обновления, просто перестав наследоваться от proxiable контракта, что является опцией, не присущей шаблону Transparent Proxy. Именно поэтому OpenZeppelin и другие рекомендуют использовать UUPS вместо прозрачных прокси, хотя на момент написания статьи прозрачные остаются более популярными.
В следующей статье мы подробно покажем как разворачивать обновляемые смарт-контракты в Ethereum.
Телеграм канал про web3 разработку, смарт-контракты и оракулы.