[Из песочницы] Система вызова смарт-контрактов в блокчейне Ethereum

Задача

Централизованное решение


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

Разрабатывая контакты-шаблоны для платформы MyWish мы сразу столкнулись с этим ограничением. И мы задались целью, обойти это ограничение «красиво» — при помощи децентрализованного решения. Это система должна приводить контракты в действие, и поэтому решили назвать его Joule, в честь английского физика Джеймса Джоуля.

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

Перед описание решения необходимо рассказать базовые вещи про смарт-контракты блокчейна Ethereum.

Смарт контракты


Смарт контракты сети этериум — это программы выполняющиеся в среде EVM (ethereum virtual machine). Каждый такой контракт имеет уникальный адрес, баланс средств (эфиров), состояние (постоянная память) и набор функций, доступных для вызова (или без функций).

Код, баланс и состояние контракта хранится в блокчейне.

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

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

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

Система вызова


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

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

Для обхода данного недостатка нашей командой была придумана система Joule, которая реализована на базе смарт-контрактов.

Joule


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

Для мотивации участников сети делать вызов выплачивается вознаграждение и бонус, которые задаются при регистрации вызова. Расчет суммы, необходимой для вознаграждения, делает Joule. Сумма вознаграждения полностью покрывает стоимость газа, затраченного на вызов. В виде бонусной части за вызов выплачиваются WISH токены (в текущей версии не реализовано).

Система позволяет регистрировать множество вызовов контрактов на одинаковые отметки времени. Также один и тот же контракт может быть зарегистрирован на разное время. Единственное ограничение — нельзя делать регистрацию с полностью одинаковым набором параметров (адрес контракта, время, газ и стоимость газа).

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

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

Joule гарантирует, что контракт не будет вызван ранее заданного при регистрации времени, и не позволяет нарушать порядок вызовов.

Технические сложности реализации


Для разработки контракта такой сложности как Joule необходимо решить большое количество технических задач. Одной из таких задач является вопрос хранения множества адресов контрактов и времени для их вызова (и других параметров). Получение первого в очереди на вызов контракта должно производиться за постоянное время (не зависящее от числа контрактов зарегистрированных в системе).

Дополнительные сложности при разработке вызывает необходимость выбора алгоритмов, с минимальным потреблением газа. Например, нельзя применять решения, которые имеют алгоритмическую сложность большую чем $O(ln(n))$. Кроме стоимости газа еще существует ограничение на число газа. Например, если для поиска следующего элемента будет применен алгоритм со сложностью $O(n/2)$, и на перебор каждого элемента будет требоваться 50 000 газа, то после добавления 185 элемента контракт не сможет выполнить поиск (при текущем ограничении в 4 600 000 газа на транзакцию).

Решение


В этой главе будет описаны ключевые технические решения входящие в систему Joule.

Структура записи


Для хранения состояния (кроме баланса) у контракта есть доступ к хранилищу типа ключ-значение. Ключом в хранилище может быть любое 256-и битное число. Фактически, перед сохранением, EVM рассчитывает контрольную сумму от ключа по алгоритму keccak (sha-3). Значением тоже является 256-и битное число (компилятор позволяет положить в состояние и большего размера значения, добавляя к ключу параметр смещения). Таким образом контракт может хранить 2^256 уникальных значений по 256 бит каждое, что во много порядков превышает потребности.

Хранилище ключ-значение можно рассмотреть как область памяти с произвольным доступом размерностью 2^256. В таком случае ключ является ссылкой на ячейку памяти. Структура данных, необходимая для хранения регистрации вызова, состоит из следующих полей: адрес контракта, момент вызова контракта, число газа необходимое для вызова контракта и стоимость газа. Все эти данные можно уместить в 256 бит (32 байта):

Структура записи

Ограничения


Используя данную структуру хранения данных необходимо принять некоторые ограничения:

  • Address — 20 байт (адрес в сети Ethereum).
  • Timestamp — 4 байта, стандартное значение Unix Time, действующее от 1970–01–01 00:00:00 по 2106–02–07T09:28:15 (для беззнаковых величин). Простыми словами — максимальный срок регистрации — 2106 год. В будущем можно увеличить эту величину до 6812 года за счет использования лишнего байта от поля под величину газа.
  • Gas — 4 байта, но на самом деле сеть позволяет сделать одну транзакцию не более чем на 4 600 000 газа. С учетом затрат газа на работу системы Joule в момент вызова контракта сейчас прописано ограничение на 4 млн газа на 1 контракт. Хотя, конечно, для работы Joule не нужно 600 000 газа — в среднем достаточно 50 000 газа.
  • Gas Price — 4 байта. Предполагаемая при регистрации стоимость газа. Хранится данная величина в gwei (10^9 wei), поэтому минимальный размер это 0.000000001 ether, а максимальный — 4.294967296 ether.


Хранение цепочки записей


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

Для хранения списка в соотношении применяется следующая технология: значение записи (32 байта) рассматривается как ключ (или ссылка, если проводить аналогию с языком С++) на следующее значение.

Цепочка записей

На изображении слева представлены записи регистрации R и функции получения ключа $K( R) = R$ в виде цепочки значений. Справа представлено хранилище ключ-значение в виде таблицы. Видно, что значение $К_0$ теряется, из-за того что оно лишь является ключем к следующему значениею. В контракте нет возможности прочитать ключ из соотношения. Для этого в таблице присутствует нулевая запись (Head), которая всегда указывает на значение начала цепочки.

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

Индекс


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

Дерево

При таком подходе поиск элементов по дереву дает константное значение сложности, не зависящей от числа элементов в девере $O(1)$. Есть худший сценарий, который в текущей реализации составляет 168 переборов. Баланс дерева можно подобрать, меняя число уровней и множители.

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

Размещение и компоненты


Код контрактов в блокчейне не изменяем. И в случае внесения изменений необходимо размещать новую версию контракта. При этом из старой версии необходимо переносить данные (состояние). Это не всегда может быть легко сделать, так как состояние контакта не доступно снаружи. И если контракт изначально не имеет функций для доступа к состоянию, то перенос будет практически невозможным. Аналогично состоянию, средства с контракта невозможно перевести без заранее подготовленных для этого функций.

Для решения данных проблем, контракт Joule разделен на несколько отдельных контрактов:

Структура

Экосистема Joule состоит из следующих контрактов:

  • Joule — контракт, реализующий ключевую логику регистрации и вызова контрактов
  • Proxy — контракт с функцией фронт-контроллера, для доступа к функциям Joule. Proxy принимает регистрации, вызовы, и с его адреса происходят вызовы зарегистрированных контрактов. Любое взаимодействие с Joule со стороны пользователей (не зависимо от роли пользователя) происходит через Proxy.
  • Vault — хранилище средств, для компенсации вызовов.
  • Register — контракт, хранящий цепочку записей с регистрациями.
  • Index — контракт, для быстрого поиска места в регистре для вставки нового контракта.
  • State — контракт, реализующий базовые функции хранения. Используется контрактами Index и Register.


Такая архитектура позволяет изменять логику Joule, не теряя данных. Не нужно данные переносить или копировать. Не нужно делать механизмы для перевода средств (которые могут быть уязвимы к хакерским атакам).

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

Использование


В данной части опишем то, как можно пользоваться получившейся системой.

Майнинг


Любой пользователь сети, имеющий адрес и достаточное количество средств для произведения вызова может приумножить свои средства при помощи Joule.

Для этого достаточно в нужный момент произвести транзакцию с вызовом к Joule (метод invoke или invokeOnce). В процессе выполнения транзакции Joule переведет вызывающему вознаграждение, которое было зарезервировано при регистрации контракта.

Момент, когда следует делать вызов можно легко определить при помощи метода getTop. Этот метод возвращает актуальное состояние очереди в Joule: время ближайшего вызова, минимальный газ, сумму вознаграждения по каждому контракту и другие значения.

Размер газа на транзакцию, который указывает майнер должен быть не меньше величины указанной в поле invokeGas для ближайшего контракта. Газ можно назначать с запасом — неиспользуемый газ вернется обратно (средства на него не будут списаны). Бывают случаи, что в очереди на выполнение находится несколько контрактов готовых к вызову. В таком случае, лучше выставлять газ равный сумме газа всех контрактов. Тогда (в случае метода invoke) будут вызваны несколько контрактов одной транзакцией, и майнер получит вознаграждение за каждый.

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

В случае успешного вызова Joule публикует событие Invoked, по которому можно найти все свои успешные вызовы и узнать точные суммы вознаграждения.

Регистрация своих контрактов


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

Для регистрации вызова контракта на определенное время необходимо вызвать метод register со следующими параметрами:

  • Address — адрес контракта, которой необходимо вызвать.
  • Timestamp — момент времени в формате unix timestamp в который вызов должен быть произведен. Важно понимать, что Joule гарантирует только то, что вызов не будет произведен ранее данного момента.
  • GasLimit — максимальное значение газа которое будет предоставлено на вызов. Лучше указать значение с запасом, чтоб не возникло ситуации, что вызов контракта завершится ошибкой из-за нехватки газа.
  • GasPrice — предполагаемая стоимость газа для вызова контракта.


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

Контракт должен реализовывать метод check. Если преждевременные вызовы могут нарушить логику работы контракта или создать уязвимость, то следует добавить проверку, что вызов был именно от Joule. Если контракт уже в сети, и нет возможности добавить в него метод check, то можно воспользоваться контрактом-посредником, реализующим нужный метод и вызывающий целевой контракт. Тогда при регистрации в Joule следует указать адрес контракта-посредника.

В случае успешной регистрации Joule публикует событие Registered, при помощи которого можно увидеть все свои регистрации.

Оба вида взаимодействия можно реализовать напрямую, работая с контрактом через клиент сети Ethereum, например Parity или Geth (Mist).

Также для удобства работы Joule имеет веб-интерфейс, позволяющий сделать вызов или зарегистрировать свой контракт использую лишь браузер с установленным в нем расширением, таким как Metamask или вообще без расширения, например, через MEW.

Вывод


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

Код Joule доступен на github.

© Habrahabr.ru