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

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