3 полезных решения для смарт контрактов на EOSIO
На примере реальных кейсов компании "Genesix".
Работа с блокчейном включает в себя множество нюансов, подводных камней и ограничений, одни из которых диктуются самим блокчейном, а другие - бизнесом и заказчиком. Эта статья о том, как мы в нашей компании преодолевали такие сложности на разных EOSIO проектах для достижения необходимого результата. В основном здесь будет рассказано об ончейн решениях, но также будет затрагиваться и окружение самого приложения.
При разработке приложений на EOSIO мы придерживаемся следующих принципов:
- Работа с приложением должна затрачивать как можно меньше ресурсов CPU/RAM у пользователя и самого приложения;
- Для пользователя работа с приложением должна быть максимально простой;
- Приложение должно как можно меньше зависеть от оффчейн инструментов;
- В целях безопасности, приватные ключи пользователей и приложения не должны храниться на сервере или каким-либо образом быть доступны для третьих лиц.
В статье мы будем не раз возвращаться к этим принципам для оценки того или иного решения.
Кейс №1 - Автоматическое удаление данных
На одном из наших проектов была необходимость в данных с "ограниченным сроком годности”. Нужно было реализовать механизм, позволяющий удалять эти данные, когда они становились неактуальными. Для выполнения этой задачи существовало 3 варианта решения.
Вариант №1 - Удалять данные вручную
Обязанность за удаление просроченных данных возлагается на пользователей или администраторов. В первом случае приходится надеяться на то, что пользователь сам удалит "просроченные" данные. Во втором администратору придется мониторить базы данных приложения и периодически удалять их вручную.
Плюсы и минусы:
+ Решение в лоб, самое простое;
+ Не надо разрабатывать новые программные решения. Action deletedata то, что нам необходимо;
- Ненадежный способ. Пользователь или администратор может забыть удалить данные;
- Много ручной и низкоэффективной работы;
- Потеря децентрализации.
Вариант №2 - Удалять данные автоматически через сервер
Вариант заключается в том, чтобы слушать через Demux события добавления данных в приложении. Дублировать их на сервере и по истечению срока действия вызывать с него action deletedata.
Плюсы и минусы:
+ Частичная автоматизация процесса, можно объединить с вариантом №1;
+ Часть нагрузки падает на сервер;
- Ключи от приложения придется хранить на сервере, что небезопасно;
- Потеря децентрализации, так как контракт зависит от сервера.
Вариант №3 - Удалять данные через смарт-контракт
Написать смарт-контракт, который при добавлении данных пользователем, будет создавать отложенную транзакцию, автоматически удаляющую их по истечению срока хранения.
Плюсы и минусы:
+ Полная автоматизация процесса;
+ Сохранение децентрализации;
- Максимальный срок задержки транзакции - 45 дней (3 888 000 секунд). Следовательно, максимальное ограничение на срок годности ордера тоже 45 дней;
- Сам контракт платит RAM и CPU за отложенную транзакцию.
В итоге мы остановились на варианте №3, данное решение удовлетворяет всем четырем принципам нашей работы. Также в отличие от варианта №1 и №2, с минусами в варианте №3 можно смириться: RAM вернется после отработки/отмены отложенной транзакции, а CPU восстанавливается каждые 24 часа. Так что главное всегда иметь ресурсы про запас. Возможность ручной отмены мы оставили для досрочного удаления данных. В таком случае важно помнить о том, что отложенную транзакцию нужно отменить.
Кейс №2 - Умный трансфер
Очень часто при разработке приложений возникает необходимость в создании функций, за выполнение которых будет взиматься плата в токенах. Например, выставление и трейд ордеров в бирже или ставка в лотерее или дайсах. При этом токены в EOSIO это ничего более, чем смарт-контракт, у которого есть свои функции и данные.
Контракты, основанные на eosio.token, имеют функцию transfer, которая и отвечает за перевод средств. Проблема заключается в том, что необходимо заставить работать вместе два независимых контракта. Такой функционал можно сделать как ончейн, так и оффчейн. Ниже я расскажу, как я реализовал полностью ончейн решение.
Первым делом, необходимо, чтобы приложение отслеживало все перечисления средств. Для этого была написана функция apply, которая отлавливает все transfer действия, связанные с аккаунтом приложения. После чего она вызывает внутреннюю функцию transfer, которая заполняет баланс или выполняет прочие действия.
Следует иметь в виду, что “dapp::transfer” будет вызываться не только, когда трансфер делается НА приложение, но и тогда, когда трансфер делается ОТ приложения. Например, при начислении средств на аккаунты пользователей после выигрыша. Исходящие ��рансферы также можно игнорировать. Делается это примерно так:
После того, как на аккаунт приложения поступили средства, мы можем выполнить действия, связанные с оплатой. Для наглядности рассмотрим пример с биржей. На нашей бирже будут два платных действия neworder (создание ордера) и trade (обмен). Оплата этих действий происходит через transfer. Чтобы усложнить задачу, добавим условие: пользователь не может хранить средства на бирже. Средства должны либо сразу попасть в ордер, либо быть обменены с уже существующим ордером.
Для этог�� мы можем написать функции neworder и trade на фронте и упаковать transfer + neworder/trade в одну транзакцию с помощью eos.js. Все, легко, просто и элегантно, но неправильно. При этом подходе мы не можем гарантировать то, что пользователь действительно внес средства, которыми он будет торговать. Можно с помощью Demux слушать все входящие трансферы, а потом с сервера вызвать neworder/trade. Но тогда необходимо хранить ключи на сервере, что мы считаем неприемлемым. В таком случае, мы можем объединить нужные нам action не на фронте, а сразу в контракте.
Есть инлайн action, которые могут вызвать action как собственного, так и чужого контракта. Но мы сделаем neworder и trade обычными приватными функциями и тем самым не дадим никому вызывать их напрямую. Мы ограничим взаимодействие с нашим контрактом одной лишь функцией transfer из любого стороннего контракта.
Чтобы с помощью transfer передать информацию о необходимых действиях, мы можем использовать мемо, передав туда json объект с описанием действия и параметрами.
Для neworder мемо будет выглядеть примерно так:
{\"id\":0,\"action\":\"neworder\",\"get\":{\"quantity\":\"2.0000 FOO\",\"contract\":\"eosio.token\"}}
Для trade мемо будет выглядеть примерно так:
{\"action\":\"trade\",\"id\":0}
Часть параметров берется непосредственно с трансфера, например, имя пользователя и сумма начисления. Осталось обработать мемо и извлечь из него нужные данные. Для этого можно использовать boost::property_tree или использовать стороннюю библиотеку. Чтобы не делать это вручную, используем все же стороннюю библиотеку, так будет проще разрабатывать и поддерживать проект. В качестве библиотеки я выбрал https://github.com/nlohmann/json. Подробнее почитать о её производительности, сравнить с другими библиотеками можно тут https://github.com/miloyip/nativejson-benchmark #parsing-time.
Для многоразового использования я добавил библиотеку в eosio.cdt. Для этого достаточно перенести папку json/include/ в папку, где установлены библиотеки eosio.cdt, в моем случае /usr/local/eosio.cdt/include/. Для одноразового использования можно добавить параметр -I при вызове eosio-cpp компиляции. Теперь eosio-cpp без проблем компилит наш контракт. Единственный минус - контракт стал тяжелее на 1 MB.
Осталась одна проблема: парсинг extended_asset объекта отличается от asset тем, что он помимо самого asset хранит имя контракта, которому принадлежит токен с данным символом. Для парсинга была написана такая функция:
Asset имеет функцию from_string(), но она системная и ее не стоит включать в контракт. Вот issue на github по этому поводу https://github.com/EOSIO/eos/issues/4995. Поэтому я самостоятельно реализовал парсинг asset.
Прелесть такого подхода также заключается в том, что помимо обязательных параметров мемо, для информативности можно добавить любые другие. Приложением они просто проигнорируются, а обязательные параметры можно перечислять в любом порядке.
Кейс №3 - Рандом фаталиста
Однажды мне довелось провести аудит кода чужого приложения. Суть его заключалась в том, что пользователь делал ставку и выбирал диапазон значений. Программа генерировала псевдослучайное число (случайностей не существует) и, если оно попадало в выбранный диапазон, награждала пользователя.
Проблема заключалась в том, что контракт взломали, пользователь нашел способ каждый раз предугадывать число и делать беспроигрышную ставку. Исходный код приложения лежал в открытом доступе как доказательство честности. И скорее всего злоумышленник смог предварительно подсчитать выигрышные числа на локальной ноде и использовать это. Моей задачей было разобраться и найти способ предотвратить подобное в будущем.
Action bet (ставка) выглядело так:
Функция random (выбор рандомного значения) выглядела так:
Переменная seed имеет тип capi_checksum256, значение которой генерируется с помощью хэша текущего блока и хэша транзакции. Полный алгоритм гораздо больше, но для статьи не имеет смысла указывать его полностью.
Первая трудность заключается в том, что создание случайных чисел смарт-контракта не соответствует консенсусу EOS, который гласит, что извлечение смарт-контрактов должно быть детерминировано. Это означает, что во всех нодах при одинаковых данных контракты должны привести к одинаковому результату, иначе ноды не смогут проверить одну и ту же транзакцию на правильность.
Тем не менее эту задачу можно решить. Для этого сначала давайте разберемся с тем, что такое случайность. Случайность - это проявление внешних, независимых от текущих процессов связей, событий или процессов в действительности. Т.е. чем их больше, тем более случайным будет значение. Таким образом, нам надо увеличить количество внешних (оффчейн) факторов, которые повлияют на генерацию чисел. Этим фактором может быть допо��нительный параметр salt (соль), который передается в bet.
Если bet вызывается от пользователя, то такой подход не имеет смысла. Пользователь все также сможет подсчитать результат, зная всю соль алгоритма. Но в данном примере нам повезло, что bet вызывается только от имени контракта и с сервера, поэтому пользователь никак не сможет на него повлиять. Но как насчет детерминированности? Каждая нода будет знать эту соль и все они получат одинаковый результат. Также эту salt можно создавать "случайным" образом, но на сервере это сделать гораздо проще. Как вариант, можно взять адрес какой-нибудь переменной с коротким циклом жизни и на основе его адреса генерировать соль.
Заключение
Конечно, многим описанные выше решения покажутся слишком короткими и простыми. В принципе так оно и есть, но EOSIO не платформа для громоздких и сложных приложений. Это платформа для легких, функциональных, надежных и в то же время простых приложений, которые выполняют свои функции. И именно такими я и наша компания стараемся их делать. В следующей статье я поделюсь решениями уже для обменников на EOSIO.
Автор: Александр Молина,
Редактор: Юлия Прокопенко,
компания Genesix