3 полезных решения для смарт контрактов на EOSIO

На примере реальных кейсов компании "Genesix".

3 полезных решения для смарт контрактов на EOSIO

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

При разработке приложений на EOSIO мы придерживаемся следующих принципов:

  1. Работа с приложением должна затрачивать как можно меньше ресурсов CPU/RAM у пользователя и самого приложения;
  2. Для пользователя работа с приложением должна быть максимально простой;
  3. Приложение должно как можно меньше зависеть от оффчейн инструментов;
  4. В целях безопасности, приватные ключи пользователей и приложения не должны храниться на сервере или каким-либо образом быть доступны для третьих лиц.

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

Кейс №1 - Автоматическое удаление данных

3 полезных решения для смарт контрактов на EOSIO

На одном из наших проектов была необходимость в данных с "ограниченным сроком годности”. Нужно было реализовать механизм, позволяющий удалять эти данные, когда они становились неактуальными. Для выполнения этой задачи существовало 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 - Умный трансфер

3 полезных решения для смарт контрактов на EOSIO

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

Контракты, основанные на eosio.token, имеют функцию transfer, которая и отвечает за перевод средств. Проблема заключается в том, что необходимо заставить работать вместе два независимых контракта. Такой функционал можно сделать как ончейн, так и оффчейн. Ниже я расскажу, как я реализовал полностью ончейн решение.

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

extern "C" { void apply(uint64_t receiver, uint64_t code, uint64_t action) { if (action == "transfer"_n.value) { execute_action(name(receiver), name(code), &dapp::transfer); }; if (code == receiver) { switch (action) { EOSIO_DISPATCH_HELPER(dapp, (some_action1)(some_action2)...); }; }; } }

Следует иметь в виду, что “dapp::transfer” будет вызываться не только, когда трансфер делается НА приложение, но и тогда, когда трансфер делается ОТ приложения. Например, при начислении средств на аккаунты пользователей после выигрыша. Исходящие трансферы также можно игнорировать. Делается это примерно так:

void dapp::transfer(name from, name to, asset quantity, string memo) { if (_self != to ) return; if ( _self == from) return; ... }

После того, как на аккаунт приложения поступили средства, мы можем выполнить действия, связанные с оплатой. Для наглядности рассмотрим пример с биржей. На нашей бирже будут два платных действия 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 хранит имя контракта, которому принадлежит токен с данным символом. Для парсинга была написана такая функция:

extended_asset parseamount(const json &j) { eosio_assert(j.find("quantity") != j.end(), "quantity not found"); eosio_assert(j.find("contract") != j.end(), "contract not found"); // extracting asset’s value and symbol string param = j["quantity"].get<string>(); eosio_assert(param.length() > 0, "quantity field is empty"); size_t space = param.find(' '); eosio_assert(space != string::npos, "asset's amount and symbol should be separated with space"); size_t dot = param.find('.'); eosio_assert(dot < param.length(), "missing decimal fraction after decimal point"); string left = param.substr(0, dot); // .4234 EOS = 0.4234 EOS if (left.length() == 0) left = "0"; string right = param.substr(dot + 1, space - dot - 1); // extracting symbol param = param.substr(space + 1); eosio_assert(param.length() != 0, "missing asset symbol"); asset result_asset; result_asset.amount = atoi(string(left + right).c_str()); result_asset.symbol = symbol(param.c_str(), right.length()); // extracting contract param = j["contract"].get<string>(); eosio_assert(param.length() > 0, "contract field is empty"); return extended_asset(result_asset, name(param.c_str())); }

Asset имеет функцию from_string(), но она системная и ее не стоит включать в контракт. Вот issue на github по этому поводу https://github.com/EOSIO/eos/issues/4995. Поэтому я самостоятельно реализовал парсинг asset.

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

Кейс №3 - Рандом фаталиста

3 полезных решения для смарт контрактов на EOSIO

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

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

Action bet (ставка) выглядело так:

void Dice::makeBet(eosio::name player, eosio::name inviter, eosio::asset quantity, uint8_t roll_type, uint16_t roll_border) { log("makeBet(%,%,%,%,%)\n", player, inviter, quantity, roll_type, roll_border); require_auth(_self); if(_stateConfig.enabled_betting) { eosio::transaction deferred; deferred.actions.emplace_back( permission_level{_self, "active"_n}, _self, "resolved"_n, std::make_tuple( player, inviter, quantity, roll_type, roll_border ) ); deferred.delay_sec = 1; uint128_t deferred_id = _stateConfig.next_deferred_id(TransactionNumber::RESOLVED); deferred.send(deferred_id, _self); } }

Функция random (выбор рандомного значения) выглядела так:

uint64_t random::gen(ChecksumType &seed, uint64_t max) const { if (max <= 0) { return 0; } const uint64_t *p64 = reinterpret_cast<const uint64_t *>(&seed); uint64_t aSeed = p64[1]; aSeed <<= 32; aSeed |= p64[0]; return (uint32_t)(aSeed % max); // uint64_t r = p64[0] % max; // return r; }

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

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

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

Если bet вызывается от пользователя, то такой подход не имеет смысла. Пользователь все также сможет подсчитать результат, зная всю соль алгоритма. Но в данном примере нам повезло, что bet вызывается только от имени контракта и с сервера, поэтому пользователь никак не сможет на него повлиять. Но как насчет детерминированности? Каждая нода будет знать эту соль и все они получат одинаковый результат. Также эту salt можно создавать "случайным" образом, но на сервере это сделать гораздо проще. Как вариант, можно взять адрес какой-нибудь переменной с коротким циклом жизни и на основе его адреса генерировать соль.

Заключение

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

Автор: Александр Молина,

Редактор: Юлия Прокопенко,

компания Genesix

22
Начать дискуссию