[Из песочницы] Как обновлять код смарт-контрактов в Ethereum16.10.2018 19:04
Как обновлять код смарт-контрактов в Ethereum / Часть 1
Статья подразумевает, что у читателя есть базовое понимание того, как работают Ethereum, EVM (Ethereum Virtual Machine) и смарт-контракты на техническом уровне, а также понимание основ языка программирования смарт-контрактов — Solidity.
В приложениях, где в приоритете прозрачность операций и доверие пользователей, часто, используют блокчейн и смарт-контракты. Преимущество такого архитектурного решения в том, что операции на блокчейне необратимы и при этом видны всем, то есть каждый может легко проверить, честно ли работает приложение.
Смарт-контракты — это специальные программы, выполняющие код, запрограммированный перед его публикацией в блокчейн. Изменить его после публикации уже нельзя. Это несомненное преимущество для многих приложений, но поддерживать и обслуживать код смарт-контракта довольно сложно. Представьте, что после продолжительной работы обнаружилась логическая ошибка, позволяющая мошенникам увести эфир из смарт-контракта. В такой ситуации все, что можно сделать, это наблюдать за тем, как эфир перечисляется на чужой кошелек. В качестве примера вспомним ошибку в одной из библиотек кошелька 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 {
Создаем указатель на адрес 0×40 (в диапазоне 0×40 — 0×5f находится область «свободной памяти») с помощью операции mload:
let ptr:= mload (0×40)
Копируем весь 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 байта).
На этом 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 {
bool public data1;
address public data2;
// other state variables
function handleSomething() {
// ...
}
}
Новая версия смарт-контракта с логикой создается на основе предыдущей версии:
contract LogicV2 is LogicV1 {
bool public data3;
// ... other code
}
Основные минусы данного варианта в том, что для новых версий смарт-контракта с логикой необходимо тянуть код всех предыдущих версий и нельзя исключить какую-либо переменную состояния из предыдущей версии.
Вариант 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 в качестве внутренней валюты на вашем проекте, то имеет смысл подумать о том, чтобы не делать его обновляемым.