BTC: 5552.368 $ XRP: 0.491328 $ ETH: 174.0011 $ BCH: 387.6117 $ LTC: 41.91300 $ XMR: 87.99122 $ DASH: 132.6294 $ ZEC: 111.3632 $
17.10.2018
Как обновлять код смарт-контрактов в Ethereum / Часть 1

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Существует и третья низкоуровневая функция — 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, то слоты хранилища можно выразить в виде таблицы:

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

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

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

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 функции по порядку.

address addr = _sm2;

assembly {

let ptr := mload(0x40)

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)

let size := returndatasize

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)

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

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

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

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

Код 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)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

EventBase public base

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

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

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

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

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

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

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

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

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

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

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

Пост подготовила команда компании 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


Оригинал статьи

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Существует и третья низкоуровневая функция — 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, то слоты хранилища можно выразить в виде таблицы:

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

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

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

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 функции по порядку.

address addr = _sm2;

assembly {

let ptr := mload(0x40)

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)

let size := returndatasize

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)

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

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

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

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

Код 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)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

EventBase public base

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

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

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

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

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

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

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

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

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

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

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

Пост подготовила команда компании 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


Оригинал статьи