Как обновлять код смарт-контрактов в Ethereum

в 15:43, , рубрики: blockchain, Ethereum, smart contracts, solidity, блокчейн, децентрализованные сети, обновление кода

Как обновлять код смарт-контрактов в Ethereum / Часть 1

Статья подразумевает, что у читателя есть базовое понимание того, как работают Ethereum, EVM (Ethereum Virtual Machine) и смарт-контракты на техническом уровне, а также понимание основ языка программирования смарт-контрактов — Solidity.

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

Смарт-контракты — это специальные программы, выполняющие код, запрограммированный перед его публикацией в блокчейн. Изменить его после публикации уже нельзя. Это несомненное преимущество для многих приложений, но поддерживать и обслуживать код смарт-контракта довольно сложно. Представьте, что после продолжительной работы обнаружилась логическая ошибка, позволяющая мошенникам увести эфир из смарт-контракта. В такой ситуации все, что можно сделать, это наблюдать за тем, как эфир перечисляется на чужой кошелек. В качестве примера вспомним ошибку в одной из библиотек кошелька Parity, которая привела к заморозке эфира стоимостью $160 млн.

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

Основные способы обновления кода

В статье используются примеры кода из публичного open-source репозитория ZeppelinOS. У нас нет цели изобретать велосипед, поэтому мы используем готовые и протестированные решения. Названия смарт-контрактов могут отличаться.

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

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

Рассмотрим каждую из групп подробнее.

Разбивка логики и хранения данных на разные смарт-контракты

Идея заключается в том, чтобы разбить код для хранения данных и код для реализации логики на разные смарт-контракты.

Абстрактно схема работы выглядит так:

Как обновлять код смарт-контрактов в Ethereum - 1

Понятие “смарт-контракт” в схемах для удобства сокращаем до “SM”.

  • Фронт-контроллер в данной схеме необходим для того, чтобы клиент обращался всегда к единой точке доступа, которая автоматически направляет его на актуальную версию смарт-контракта с логикой.
  • Фронт-контроллер реализует те же методы, что и смарт-контракт с логикой, через отдельный интерфейс, общий для обоих смарт-контрактов для целостности.
  • Фронт-контроллер хранит адрес текущей реализации, который может быть изменен через функцию setCurrentLogicAddress().
  • Смарт-контракт с логикой хранит адрес смарт-контракта с данными (data).
  • Если функция задействует работу с данными (чтение или запись), то происходит обращение к смарт-контракту с данными (через адрес data)
  • Вся бизнес-логика, валидация и возвраты значений реализуются в смарт-контракте с логикой, а фронт-контроллер всего лишь совершает вызов необходимых функций смарт-контракта с логикой, передавая нужные аргументы

Процесс обновления кода заключается в том, что вместо текущей версии смарт-контракта с логикой создается новая версия и во фронт-контроллере изменяется адрес новой версии кода.

Проблема этой схемы в том, что смарт-контракт с данными имеет фиксированную схему данных. Продукт в IT-сфере быстро меняется и должен адаптироваться под новые требования. Здесь возникает проблема: смарт-контракт с данными не обновляется.
Решить эту проблему можно, используя шаблона хранения данных “Вечное хранилище вида ключ-значение”.

Вечное хранилище вида ключ-значение (Eternal key-value storage)

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

  • Обратите внимание, что в качестве значений маппингов используются типы наибольшего размера (32 байта), чтобы быть максимально гибкими в хранении данных.
  • В качестве ключа используется тип bytes32, который должен содержать хеш, полученный, например, через keccak256(‘...’). Такой выбор обусловлен тем, что:

Это разрешает использование произвольной длины ключей.
Это делает возможным использование составных ключей, например keccak256(“users”, “user_id_123”).

Все типы данных объявлены как internal, чтобы использовать меньше газа для доступа к ним. Чтобы работать с данными, для каждого их типа нужно написать необходимые функции. Пример операций для типа данных uint можно посмотреть по ссылке.

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

Пример использования шаблона “вечное хранилище” из смарт-контракта с логикой можно посмотреть тут.

Подводные камни

Что следует учитывать при обновлении кода смарт-контрактов рассмотренным способом:

В качестве “защиты от дурака” в смарт-контракте логики нужно разрешить доступ к функциям, которые меняют состояние, только для фронт-контроллера.

Основные минусы данного подхода в целом:

  • Потребление газа увеличивается за счет того, что работа с единственным смарт-контрактом меняется на цепочку из трех. Также стоимость публикации таких смарт-контрактов в блокчейн будет выше.
  • В смарт-контракте с логикой можно изменять любые внутренние функции или изменять работу любой публичной функции, сохраняя ее signature. Если же сохранить signature по какой-то причине нельзя, придется обновить и общий интерфейс между фронт-контроллером и смарт-контрактом с логикой и реализовать эти же изменения во фронт-контроллере. Как видно из описания подхода, это невозможно. Получается, что единую точку входа тоже необходимо делать обновляемой, применяя такой же подход (для которого эта же проблема, однако, останется актуальной).
  • Проблему можно решить через низкоуровневую функцию call, которая принимает на вход signature функции с произвольным количеством аргументов. Если добавить к этому использование solidity assembly кода, то общий интерфейс между фронт-контроллером и кодом с логикой можно убрать, а во фронт-контроллере оставить единственную функцию для обработки запросов клиентов для перенаправления прямо в смарт-контракт с логикой. Мы не будем рассматривать решение проблемы таким образом, потому что похожая схема используется в проксировании, за исключением того, что там используется функция delegatecall, которая не переключает контекст.

Минусы шаблона “вечное хранение типа ключ-значение”:

  • Абстрагирование от предметной области приложения. Это усложняет работу и восприятие данных, а также может стать препятствием для реализации требований (например, отсутствуют структуры).
  • Потребление газа, скорее всего, будет выше, чем если бы схема данных проектировалась с учетом предметной области приложения. Во-первых, какие бы типы ключей в маппингах не использовались, они будут занимать 32 байта (потому что ключи маппингов реализуются с помощью keccak256). Во-вторых, маппингитрудно оптимизировать, потому что каждое значение маппинга занимает целый слот (32 байта) в хранилище, независимо от используемого типа данных.

Проксирование кода с использованием общего хранилища

Чтобы разобраться в этом способе обновления кода, нужно понимать как работает EVM на уровне коммуникации двух (или более) смарт-контрактов и какие возможности предоставляет Solidity для этого.

Извне, функции смарт-контрактов могут вызываться двумя способами:

  • Call — это локальный вызов функции, который не отправляет ничего в сеть блокчейна и не меняет состояние, то есть выполняет функцию в режиме чтения.
  • Transaction — это вызов функции, который меняет состояние блокчейна отправляет транзакцию в сеть для обработки майнерами.

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

Если смарт-контракт вызывает функцию другого смарт-контракта, то вызываемая функция работает в контексте своего смарт-контракта, что обеспечивает безопасную и независимую работу с хранилищем одного и второго смарт-контракта.

Хотя вызов функции другого смарт-контракта похож на тип вызова “transaction”, работает он несколько иначе в плане доступности результата другой функции, и официальное название такого вызова функции — message call.

Рассмотрим пример:

Как обновлять код смарт-контрактов в Ethereum - 2

Код обеих функций (handle, handle2) можно посмотреть по ссылке.

Функция SM1.handle() меняет значение переменной data равное true, работая только в контексте смарт-контракта SM1, а функция SM2.handle2() меняет значение переменной data2 равное false, работая только в контексте смарт-контракта SM2. Если SM1.handle() попробует изменить значение SM2.data2, то EVM завершит данную операцию с ошибкой.

Низкоуровневые функции Solidity для вызова кода другого смарт-контракта

То, что продемонстрировано выше, использует вызов функции другого смарт-контракта с известным ABI (тип переменной sm2 — SM2).

Существуют способы вызвать код другого смарт-контракта без наличия ABI, т.е. в нашем случае не импортируя смарт-контракт SM2 или его интерфейс.

Solidity предоставляет две встроенных функции низкого уровня, благодаря которым можно вызвать код другого смарт-контракта:

  • call — вызов функции другого смарт-контракта с переключением контекста (как в примере выше с ABI).
  • delegatecall — вызов функции другого смарт-контракта в рамках текущего контекста.

Существует и третья низкоуровневая функция — callcode, но ее не рекомендуют использовать и она будет убрана в будущих версиях Solidity.

Call работает по уже знакомому нам сценарию, но delegatecall привносит что-то новое — контекст при выполнении функции из другого смарт-контракта не переключается. Здесь мы впервые сталкиваемся с понятием “общее хранилище”. Вызываемая функция способна менять значение переменных в вызывающем смарт-контракте — он делегирует выполнение кода функции из другого смарт-контракта, но в рамках своего контекста.

Если вернуться к примеру кода выше и заменить call на delegatecall, то SM2.handle2(), устанавливая переменной data2 в качестве значения false, на самом деле будет менять значение переменной SM1.data, а SM2.data2 останется неизменным, потому что функция SM2.handle2() работала в контексте смарт-контракта SM1.

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

keccak256(variablePosition) // variablePosition начинается с 0


Переменные состояния динамической величины размещаются несколько иначе. Например, позиция элементов маппинга вычисляется так:

keccak256(elementKey . mappingPosition)


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

Если вернуться к коду двух контрактов SM1 и SM2, то слоты хранилища можно выразить в виде таблицы:

Как обновлять код смарт-контрактов в Ethereum - 3

Как видно из таблицы, SM2.data2 и SM1.data занимают один и тот же слот в хранилище, поэтому при использовании delegatecall для выполнения функции SM2.handle2, которая изменяет значение переменной data2, внутри EVM изменяется значение переменной data смарт-контракта SM1.

Функции call и delegatecall полезны, если нужно вызвать функцию другого смарт-контракта, ABI которого не известен, и присутствует только его адрес.

Недостатки этих функций:

  • Они не возвращают результат выполнения вызванной функции, а только “успешность” или “неуспешность” работы функции (true/false).
  • Они не вызывают исключения в случае ошибок на стороне вызванной функции, поэтому, как следствие первого недостатка, вызов функции должен быть обрамлен в выражение require():

require(sm2.call("handle2"));

Solidity предоставляет возможность возвращать результат выполнения функции через call/delegatecall с помощью низкоуровневого языка программирования — Solidity Assembly.

Solidity Assembly

Solidity assembly — это низкоуровневый язык программирования, который можно использовать без самого Solidity. Мы рассмотрим inline assembly — это assembly код, который встраивают прямо в код смарт-контрактов Solidity.

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

Рассмотрим пример вызова функции SM2.handle2 из SM1 с помощью delegatecall на уровне assembly. Перепишем код SM1 и SM2 следующим образом и разберемся в нем:

  • Реализована так называемая fallback функция (без названия и аргументов) в SM1, которая срабатывает тогда, когда происходит вызов функции смарт-контракта, которого нет в смарт-контракте.
  • Чтобы смарт-контракт мог принимать эфир, fallback функция обозначена ключевым словом — payable.
  • Внутри fallback функции объявлен inline assembly код, который с помощью assembly выражений вызывает функцию другого смарт-контракта через delegatecall.
  • Код SM2 переписан таким образом, чтобы переменные состояния были объявлены в том же порядке и с теми же типами, что и в смарт-контракте SM1. Для консистентности данных мы записываем в sm2 собственный адрес SM2.

Рассмотрим более детально код fallback функции по порядку.

  • Объявляем локальную переменную addr, которая принимает значение _sm2, вне блока кода assembly, потому что внутри блока assembly отсылка к внешним переменным (переменным состояния) происходит не так, как обычно:

address addr = _sm2;

  • Начинаем встраивать assembly код в код функции смарт-контракта:

assembly {

  • Создаем указатель на адрес 0x40 (в диапазоне 0x40 — 0x5f находится область “свободной памяти”) с помощью операции mload:

let ptr := mload(0x40)

  • Копируем весь calldata (данные, которые переданы при вызове функции) на начало указателя, который мы создали ранее.

calldatacopy(ptr, 0, calldatasize)

Ниже происходит вызов функции другого смарт-контракта с помощью delegatecall, результат выполнения которой (true/false) сохраняем в переменную success. Аргументы вызова означают следующее:

gas — остаток газа, доступного для выполнения работы
addr — адрес другого смарт-контракта
ptr — указываем позицию начала области памяти calldata, которую мы передаем вызываемой функции
calldatasize — указываем позицию конца области памяти calldata, которую мы передаем вызываемой функции
последние два аргумента (0, 0) — указывают на позицию начала и конца области памяти возвращаемых данных вызываемой функции. На момент вызова функции размер возвращаемых данных неизвестен, поэтому оба аргумента указываются нулями, а ниже идет реальное вычисление размера возвращенных данных.

let success := delegatecall(gas, addr, ptr, calldatasize, 0, 0)

  • Записываем в переменную size размер данных, которые возвратила вызываемая функция (returndata):

let size := returndatasize

  • Копируем все данные, которые возвратила вызываемая функция (returndata), на начало указателя, который мы создали ранее:

returndatacopy(ptr, 0, size)

  • Проверяем успешность вызова функции:

Если success = 0, то отменяем все изменения состояния и возвращаем returndata (длиной 32 байта).

В обратном случае (success = 1), просто возвращаем returndata (длиной 32 байта).

switch success
case 0 { revert(ptr, 32) }
default { return(ptr, 32) }

На этом fallback функция завершает свою работу. Таким образом, при вызове функцию SM1.handle() (которой на самом деле нет в SM1) происходит вызов функции SM2.handle(), которая будет менять значение переменной состояние SM1.data.

Подход, который описан выше, с помощью inline assembly и delegatecall является основой для способа обновления кода смарт-контракта — “проксирование кода с использованием общего хранилища”. Все варианты, которые будут описаны ниже, отличаются только в плане организации схемы данных.

Рассмотрим известные варианты проксирования кода: наследуемое хранилище (inherited storage), вечное хранилище (eternal storage) и неструктурированное хранилище (unstructured storage).

Вариант 1: Наследуемое хранилище (inherited storage)

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

Схематично проксирование с наследуемым хранилищем выглядит так:

Как обновлять код смарт-контрактов в Ethereum - 4

  • Proxy Storage — это смарт-контракт, который хранит необходимые переменные для корректной работы проксирующего смарт-контракта (в нем хранится адрес текущей версии смарт-контракта с логикой).
  • Base Proxy SM — это базовый смарт-контракт, содержащий код для проксирования, который можно наследовать другим смарт-контрактам (в нашем случае Logic Proxy SM наследует Base Proxy SM).
  • Logic Proxy SM — входная точка для вызовов функций, которая делегирует их выполнение определенной версии смарт-контракта с логикой (в нашем случае — Logic SM v1). Logic Proxy SM наследует Proxy Storage для хранения адреса текущей версии смарт-контракта с логикой.
  • Logic SM v1 — это реализация конкретной версии смарт-контракта с логикой, которая наследует Proxy Storage для того, чтобы согласовать общие переменные состояния с проксирующим смарт-контрактом. Смарт-контракт с логикой может представлять новые переменные состояния.

Обновление смарт-контракта с логикой происходит так:

  • Создается новый смарт-контракт с логикой — Logic SM v2 (v3, v4, v5, …).
  • Важно: так как проксирование основано на использовании общего хранилища, все новые версии смарт-контрактов с логикой необходимо наследовать от предыдущей версии, чтобы порядок и тип переменных состояния сохранялся.
  • В Logic Proxy SM обновляется адрес новой версии кода.

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

Код BaseProxy содержит fallback функцию для проксирования и интерфейсную функцию implementation, которая отдает адрес актуальной версии смарт-контракта с логикой.

Код ProxyStorage содержит переменные состояния, необходимые для функционирования проксирования кода (в нашем примере присутствие переменной registry можно исключить), а также реализует функцию implementation.

LogicProxy только наследует BaseProxy, а также содержит функцию для обновления адреса актуальной версии смарт-контракта с логикой.

Сам смарт-контракт с логикой (Logic SM v1) реализует логику приложения и содержит собственные переменные состояния:

contract LogicV1 is ProxyStorage {</p>
<source>bool public data1;
address public data2;

// other state variables

function handleSomething() {
    // ...
}

}


Новая версия смарт-контракта с логикой создается на основе предыдущей версии:

contract LogicV2 is LogicV1 {</p>
<p>bool public data3;
// ... other code</p>
<p>}


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

Вариант 2: Вечное хранилище (eternal storage)

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

Идея в том, чтобы подключить тот же вариант хранилища, который описан в “способе разбивки логики и хранения данных на разные смарт-контракты” с использованием вечного хранилища типа ключ-значение.

Схематично способ выглядит так:

Как обновлять код смарт-контрактов в Ethereum - 5

Отличия от наследуемого хранилища:

  • Вводится новый смарт-контракт — EternalStorage, упомянутый в «способе разбивки логики и хранения данных на разные смарт-контракты”. Так как в способе проксирования используется общее хранилище, в EternalStorage нет необходимости реализовывать функции для манипуляции данных (setUint, getUint, deleteUint, …) — все маппинги будут доступны прямо в смарт-контракте с логикой.
  • EternalStorage наследует ProxyStorage, чтобы требуемые для функциональности проксирования данные были согласованы.
  • LogicProxy и LogicV1 наследует оба EternalStorage — таким образом, схема данных согласована между смарт-контрактами.

Процесс обновления смарт-контракта с логикой:

  • Создается новый смарт-контракт с логикой — Logic SM v2 (v3, v4, v5, …).
  • Важно: новые версии смарт-контрактов с логикой должны наследовать EternalStorage и не вводить новые переменные состояния.
  • В Logic Proxy SM обновляется адрес новой версии кода.

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

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

Вариант 3: Неструктурированное хранилище (unstructured storage)

Этот вариант похож на наследуемое хранилище (inherited storage), но смарт-контракты с логикой не должны наследовать ProxyStorage, который содержал необходимые переменные состояния для работоспособности проксирования.И сам ProxyStorage в этом варианте как отдельный смарт-контракт отсутствует. Переменные состояния с адресом текущей версии смарт-контракта с логикой перенесены прямо в LogicProxy.

Схематично это выглядит так:

Как обновлять код смарт-контрактов в Ethereum - 6

  • LogicV1 не содержит больше ничего связанного с переменными состояния из ProxyStorage.
  • LogicProxy хранит непосредственно в собственном смарт-контракте адрес текущей версии LogicV1.
  • BaseProxy по-прежнему предоставляет стандартную функцию для проксирования.
  • Адрес текущей версии смарт-контракта с логикой теперь обрабатывает иначе. LogicProxy нужно переписать так, как показано в данном примере.

Как видно, переменной состояния для хранения адреса текущей версии смарт-контракта с логикой вовсе нет. Вместо этого сделано следующее:

  • Объявлена приватная константа implementationPosition, которая принимает в качестве значения результат хеш-функции keccak256, которая хеширует произвольную уникальную строку (уникальная в рамках ваших смарт-контрактов).
  • Обновление текущей версии смарт-контракта с логикой происходит с помощью inline assembly кода: функция setImplementation устанавливает адрес смарт-контракта на позицию, которая задана в константе implementationPosition.
  • Получение текущей версии смарт-контракта с логикой аналогично происходит с помощью inline assembly: функция implementation загружает из хранилища данные, которые хранятся на позиции implementationPosition (полученные данные и будут адресом текущей версии кода).

На первый взгляд, эта схема выглядит непонятной, но если вспомнить, как Solidity распределяет переменные в хранилище, то все становится ясно. Для распределения переменных используется та же хеш-функция — keccak256, которая принимает на вход номер позиции переменной (начиная с 0). В константе implementationPosition явно прописан адрес значения переменной для хранения адреса текущей версии смарт-контракта с логикой.

Согласно документации, константы не распределяются в хранилище, поэтому единственный риск данного подхода состоит в том, что есть маленькая вероятность коллизии с теми переменными, которые Solidity распределяет автоматически. Для того, чтобы этого избежать, в качестве значения keccak256 в implementationPosition необходимо указать уникальное в рамках ваших смарт-контрактах значение.

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

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

Инициализация смарт-контрактов с логикой

Версии смарт-контрактов с логикой публикуются в два этапа:

  1. публикация самого смарт-контракта с логикой,
  2. обновление адреса в проксирующем смарт-контракте.

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

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

Функция upgradeToAndCall выполняет то же, что и updateCurrentVersionAddress, и вдобавок к этому делает низкоуровневый вызов к новой версии смарт-контракта с логикой, передавая все необходимые параметры для инициализации. Функция call может принимать signature вызываемой функции с передачей параметров. Соответственно, если новая версия смарт-контракта с логикой требует инициализации каких-либо переменных состояния, то вместо вызова updateCurrentVersionAddress, необходимо вызвать upgradeToAndCall, передавая signature функции initialize и аргументы для нее.

Подводные камни

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

  • Способ проксирования использует низкоуровневые конструкции средствами inline assembly, который является самым приближенным способом доступа к EVM, поэтому нужно хорошо понимать как работает Solidity Assembly.
  • Нужно строго следить за схемой данных между всеми связанными смарт-контрактами, чтобы не нарушить организацию переменных в хранилище.
  • Необходимо уделять большое внимание безопасности вашего кода с использованием assembly кода или другими низкоуровневыми конструкциями (call, delegatecall). В качестве примера, можно обратить внимание на кейс обнаружения уязвимости кода в The DAO, который также в основе имеет использование низкоуровневых функций, и который позволил “украсть” эфир стоимостью $150 млн.

Способ создания кратковременных автономных смарт-контрактов

Иногда возникает необходимость “фабричного” создания отдельных независимых смарт-контрактов общего типа, которые живут короткое время. Например, в проекте, который основан на каких-либо сделках, каждая сделка может быть представлена в виде отдельного смарт-контракта, с общим кодом, но принадлежащая разным пользователям.

Расскажем, как мы реализовали такую работу в одном из проектов.

Проект работает в сфере беттинга и в сердце системы лежит сущность — “событие”, которое является отдельным смарт-контрактом, позволяющий делать ставки на данное событие. Например, события “ЧМ по футболу 2018” и “Выборы президента 2024” — каждое выражено в виде отдельного смарт-контракта в блокчейне. Событий может быть создано бесконечное количество, и столько же раз будет публиковаться новый смарт-контракт в блокчейне.

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

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

Некоторые требования к смарт-контрактам события такие:

  • Должна быть возможность обновления бизнес-логики.
  • При этом при обновлении кода ранее созданные смарт-контракты никак не должны затрагиваться.

Схематично создание нового события выглядит так:

Как обновлять код смарт-контрактов в Ethereum - 7

  • BaseEvent — является “прототипом” события, которое содержит весь необходимый код для реализации логики самых событий (Event). BaseEvent публикуется один раз, пока не нужно его обновить — в этом случае публикуется его новая версия.
  • Event — это непосредственно смарт-контракт конкретного события — его создает EventFactory.
  • EventFactory — это фабрика, к которой обращается пользователь, чтобы создать новое событие (Event). Фабрика хранит адрес текущей версии BaseEvent и позволяет обновлять его на новые версии.

Решение требований состоит в том, что:

Смарт-контракт события не содержит ничего, кроме делегирования вызова функции смарт-контракту EventBase (то есть работает в собственном контексте с общим хранилищем).
Фабрика при создании события:

  • Создает новый смарт-контракт Event, указывая в его конструкторе адрес прототипа — EventBase (чтобы Event делегировал выполнение EventBase’у).
  • “Оборачивает” новый созданный Event в EventBase, чтобы инициализировать новое событие с множеством параметров (в нашем случае через массив байтов единым аргументом) функции EventBase.init.

Код смарт-контракта Event содержит одну переменную состояния — адрес прототипа событий — EventBase. При создании нового события в конструктор должен быть передан его адрес. Так реализуется второе требование — ранее созданные смарт-контракты события никак не затрагиваются при обновлении прототипа EventBase.

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

Само создание кроется в строчке:

EventBase _lastEvent = EventBase(address(new Event(address(eventBase))));

Необходимо помнить, что EventBase также должен иметь в своей схеме данных на первой позиции переменную состояния:

EventBase public base

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

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

Как сохранить доверие пользователей

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

Чтобы сохранить доверие пользователей, можно применить разные техники обновления и дополнения к ним, например:

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

Таким образом, пользователи смогут заранее ознакомиться с деталями обновления.

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

Следует упомянуть об еще одном способе, который может быть основан на любом из вышеперечисленных: частичное обновление кода. Необязательно выносить весь код в отдельные обновляемые смарт-контракты с логикой. Наиболее чувствительные для пользователя функции можно оставить непосредственно в проксирующем смарт-контракте (или в случае разбивки логики и хранения данных на разные смарт-контракты в смарт-контракте “единая точка входа”). Это позволит пользователю чувствовать себя более спокойно.

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

Сравнение способов обновления смарт-контрактов

Мы составили сравнительную таблицу всех способов, которая, возможно, поможет выбрать правильный подход к вашим обновляемым смарт-контрактам:

Как обновлять код смарт-контрактов в Ethereum - 8

Пост подготовила команда компании AXIOMA GROUP, во главе с Дмитрием Абросимовым.
Надеемся, было полезно!

Источники

https://solidity.readthedocs.io
https://github.com/comaeio/porosity/wiki/Ethereum-Internals
https://blog.zeppelinos.org/proxy-patterns/
https://blog.zeppelinos.org/smart-contract-upgradeability-using-eternal-storage/
https://blog.zeppelinos.org/upgradeability-using-unstructured-storage/
https://medium.com/@novablitz/storing-structs-is-costing-you-gas-774da988895e
https://blog.gnosis.pm/solidity-delegateproxy-contracts-e09957d0f201
https://github.com/zeppelinos/labs

Автор: lapstory

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js