Как обновлять код смарт-контрактов в Ethereum
Статья подразумевает, что у читателя есть базовое понимание того, как работают Ethereum, EVM (Ethereum Virtual Machine) и смарт-контракты на техническом уровне, а также понимание основ языка программирования смарт-контрактов — Solidity.
Нам материалом работала команда компании AXIOMA GROUP, во главе с Дмитрием Абросимовым.
В приложениях, где в приоритете прозрачность операций и доверие пользователей, часто, используют блокчейн и смарт-контракты. Преимущество такого архитектурного решения в том, что операции на блокчейне необратимы и при этом видны всем, то есть каждый может легко проверить, честно ли работает приложение.
Смарт-контракты — это специальные программы, выполняющие код, запрограммированный перед его публикацией в блокчейн. Изменить его после публикации уже нельзя. Это несомненное преимущество для многих приложений, но поддерживать и обслуживать код смарт-контракта довольно сложно. Представьте, что после продолжительной работы обнаружилась логическая ошибка, позволяющая мошенникам увести эфир из смарт-контракта. В такой ситуации все, что можно сделать, это наблюдать за тем, как эфир перечисляется на чужой кошелек. В качестве примера вспомним ошибку в одной из библиотек кошелька Parity, которая привела к заморозке эфира стоимостью $160 млн.
Следовательно, возможность обновлять код смарт-контракта нужна для исправления ошибок. Кроме того, это делает развитие приложения по мере его роста более удобным. В этой статье мы сгруппируем и классифицируем известные методы обновления кода смарт-контрактов в Ethereum и опишем достоинства и недостатки различных методов.
Основные способы обновления кода
В статье используются примеры кода из публичного open-source репозитория ZeppelinOS. У нас нет цели изобретать велосипед, поэтому мы используем готовые и протестированные решения. Названия смарт-контрактов могут отличаться.
Самое сложное во всех способах обновления кода — это сохранение перманентных данных в хранилище смарт-контракта. Есть разные способы обновления кода, позволяющие при этом сохранять данные, все их можно условно разделить на две группы:
Разбивка кода, реализующего логику и хранение данных, на разные смарт-контракты.
Проксирование кода из одного смарт-контракта в другой с использованием общего хранилища данных.
Рассмотрим каждую из групп подробнее.
Разбивка логики и хранения данных на разные смарт-контракты
Идея заключается в том, чтобы разбить код для хранения данных и код для реализации логики на разные смарт-контракты.
Абстрактно схема работы выглядит так:
Понятие “смарт-контракт” в схемах для удобства сокращаем до “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.
Рассмотрим пример:
Код обеих функций (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, то слоты хранилища можно выразить в виде таблицы:
Как видно из таблицы, 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)
По сути, все, что описано выше, использует хранилище наследуемого типа. Приведем код, описанный выше, в более общий вид, который можно будет использовать повторно в следующих разделах.
Схематично проксирование с наследуемым хранилищем выглядит так:
- 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)
Суть проксирования кода с использованием вечного хранилища состоит в том, чтобы избавиться от необходимости наследовать схему переменных хранилища из предыдущих версий смарт-контракта с логикой, и в принципе дает возможность не наследовать предыдущие версии.
Идея в том, чтобы подключить тот же вариант хранилища, который описан в “способе разбивки логики и хранения данных на разные смарт-контракты” с использованием вечного хранилища типа ключ-значение.
Схематично способ выглядит так:
Отличия от наследуемого хранилища:
- Вводится новый смарт-контракт — 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.
Схематично это выглядит так:
- LogicV1 не содержит больше ничего связанного с переменными состояния из ProxyStorage.
- LogicProxy хранит непосредственно в собственном смарт-контракте адрес текущей версии LogicV1.
- BaseProxy по-прежнему предоставляет стандартную функцию для проксирования.
- Адрес текущей версии смарт-контракта с логикой теперь обрабатывает иначе. LogicProxy нужно переписать так, как показано в данном примере.
Как видно, переменной состояния для хранения адреса текущей версии смарт-контракта с логикой вовсе нет. Вместо этого сделано следующее:
- Объявлена приватная константа implementationPosition, которая принимает в качестве значения результат хеш-функции keccak256, которая хеширует произвольную уникальную строку (уникальная в рамках ваших смарт-контрактов).
- Обновление текущей версии смарт-контракта с логикой происходит с помощью inline assembly кода: функция setImplementation устанавливает адрес смарт-контракта на позицию, которая задана в константе implementationPosition.
- Получение текущей версии смарт-контракта с логикой аналогично происходит с помощью inline assembly: функция implementation загружает из хранилища данные, которые хранятся на позиции implementationPosition (полученные данные и будут адресом текущей версии кода).
На первый взгляд, эта схема выглядит непонятной, но если вспомнить, как Solidity распределяет переменные в хранилище, то все становится ясно. Для распределения переменных используется та же хеш-функция — keccak256, которая принимает на вход номер позиции переменной (начиная с 0). В константе implementationPosition явно прописан адрес значения переменной для хранения адреса текущей версии смарт-контракта с логикой.
Согласно документации, константы не распределяются в хранилище, поэтому единственный риск данного подхода состоит в том, что есть маленькая вероятность коллизии с теми переменными, которые Solidity распределяет автоматически. Для того, чтобы этого избежать, в качестве значения keccak256 в implementationPosition необходимо указать уникальное в рамках ваших смарт-контрактах значение.
Обновление смарт-контракта с логикой происходит так же, как и в случае обновления с использованием наследуемого хранилища.
Таким образом, в смарт-контракте с логикой можно добавлять новые функции, а также новые переменные состояния, и при этом нет необходимости включать в код переменные, нужные для работы проксирования.
Инициализация смарт-контрактов с логикой
Версии смарт-контрактов с логикой публикуются в два этапа:
- публикация самого смарт-контракта с логикой,
- обновление адреса в проксирующем смарт-контракте.
С этим связана одна проблема со смарт-контрактами с логикой, которые обновляются, используя способ проксирования. На втором этапе проксирующий смарт-контракт не видит, что происходит в конструкторе смарт-контракта с логикой на первом шаге. И если в конструкторе задаются начальные значения переменных состояния, то когда произойдет обращение к смарт-контракту с логикой через проксирующий смарт-контракт, значения этих самых переменных будут иметь другие значения, потому что инициализация переменных происходила в контексте смарт-контракта с логикой.
Чтобы избежать этой проблемы, в смарт-контрактах с логикой нужно вынести инициализацию переменных состояния в отдельную функцию (например, initialize), а в код LogicProxy добавить функцию upgradeToAndCall, как это сделано в данном примере.
Функция upgradeToAndCall выполняет то же, что и updateCurrentVersionAddress, и вдобавок к этому делает низкоуровневый вызов к новой версии смарт-контракта с логикой, передавая все необходимые параметры для инициализации. Функция call может принимать signature вызываемой функции с передачей параметров. Соответственно, если новая версия смарт-контракта с логикой требует инициализации каких-либо переменных состояния, то вместо вызова updateCurrentVersionAddress, необходимо вызвать upgradeToAndCall, передавая signature функции initialize и аргументы для нее.
Подводные камни
Проксирование кода является несомненно более гибким способом, чем разбивка логики и хранения данных на разные смарт-контракты, однако следует относиться к нему с аккуратностью. На что стоит обратить внимание:
- Способ проксирования использует низкоуровневые конструкции средствами inline assembly, который является самым приближенным способом доступа к EVM, поэтому нужно хорошо понимать как работает Solidity Assembly.
- Нужно строго следить за схемой данных между всеми связанными смарт-контрактами, чтобы не нарушить организацию переменных в хранилище.
- Необходимо уделять большое внимание безопасности вашего кода с использованием assembly кода или другими низкоуровневыми конструкциями (call, delegatecall). В качестве примера, можно обратить внимание на кейс обнаружения уязвимости кода в The DAO, который также в основе имеет использование низкоуровневых функций, и который позволил “украсть” эфир стоимостью $150 млн.
Способ создания кратковременных автономных смарт-контрактов
Иногда возникает необходимость “фабричного” создания отдельных независимых смарт-контрактов общего типа, которые живут короткое время. Например, в проекте, который основан на каких-либо сделках, каждая сделка может быть представлена в виде отдельного смарт-контракта, с общим кодом, но принадлежащая разным пользователям.
Расскажем, как мы реализовали такую работу в одном из проектов.
Проект работает в сфере беттинга и в сердце системы лежит сущность — “событие”, которое является отдельным смарт-контрактом, позволяющий делать ставки на данное событие. Например, события “ЧМ по футболу 2018” и “Выборы президента 2024” — каждое выражено в виде отдельного смарт-контракта в блокчейне. Событий может быть создано бесконечное количество, и столько же раз будет публиковаться новый смарт-контракт в блокчейне.
Смарт-контракт события содержит достаточно большой объем кода (исходы события, ставки, определение правильно исхода события, выигрыш и так далее), что потребляет также достаточно много газа во время публикации смарт-контракта.
Чтобы значительно сэкономить на потреблении газа при публикации смарт-контракта события, мы применили следующий подход, основанный на том же механизме, что и проксирование кода.
Некоторые требования к смарт-контрактам события такие:
- Должна быть возможность обновления бизнес-логики.
- При этом при обновлении кода ранее созданные смарт-контракты никак не должны затрагиваться.
Схематично создание нового события выглядит так:
- 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 в качестве внутренней валюты на вашем проекте, то имеет смысл подумать о том, чтобы не делать его обновляемым.
Сравнение способов обновления смарт-контрактов
Мы составили сравнительную таблицу всех способов, которая, возможно, поможет выбрать правильный подход к вашим обновляемым смарт-контрактам:
Пост подготовила команда компании 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