Всем привет! Меня зовут Степан, я руководитель группы разработки в Waves Enterprise, а конкретно — подразделения, которое создает децентрализованные приложения, реализующие процессы реального бизнеса на базе нашего блокчейна. В этом посте я хочу рассказать о нашем SDK для JVM-языков программирования, с помощью которого каждый Java/Kotlin-разработчик сможет попробовать себя в создании блокчейн-приложений.

Чтобы развернуть реальную бизнес-логику на блокчейне, необходимы смарт-контракты. Помимо реализации этой логики, они выступают гарантом прозрачного и честного выполнения условий всеми участниками системы. Это необходимое условие для перехода к Web3 с его гарантированно честным и безопасным прямым взаимодействием между участниками сети. О том, как работают смарт-контракты в сети Waves Enterprise, вы можете узнать из поста моего коллеги Руслана.
Более ранние проекты нашей компании использовали смарт-контракты, специально написанные под конкретную задачу, и зачастую содержали много шаблонного кода. Причина в том, что, вне зависимости от прикладной задачи, в коде всех docker смарт-контрактов есть одинаковая логика более низкого уровня:
подключение к ноде и получение данных транзакции,
передача значений для сохранения на стейте контракта,
обработка ошибок,
преобразование доменных объектов для хранения на стейте контракта.
После нескольких проектов с таким количеством шаблонного кода наш CTO Денис предложил выделить это в SDK смарт-контрактов. Заложенные в SDK подходы были основаны на опыте подобных решений в других блокчейн-сетях — Hyperledger, Ethereum. Впоследствии этот внутренний SDK хорошо показал себя во многих проектах.
В 2022 году в рамках развития opensource-направления мы решили выложить этот SDK в общий доступ вместе с кодом ноды. Чтобы не было стыдно перед сообществом, мы отрефакторили код SDK и практически переписали его, чтобы решить ряд проблем. Далее я расскажу подробней о том, как устроен SDK, и приведу пример написанного на нем смарт-контракта.
Основные принципы
Все действия на смарт-контракте мы представляем как «методы» смарт-контракта. Эти «методы» соответствуют методам класса, реализующего данный смарт-контракт — класс помечается аннотацией ContractHandler. Методы могут принимать на вход параметры как примитивных (string, long, boolean), так и составных типов (по умолчанию сериализация в JSON).
Тип и данные (блок params) транзакции смарт-контракта определяют, какой конкретно метод необходимо вызвать и какие конкретно параметры ему передать. Для транзакции 103 (создание смарт-контракта) вызываются методы, помеченные аннотацией ContractInit. Для транзакций 104 (вызов смарт-контракта на исполнение) — методы, помеченные аннотацией ContractAction.
Подобный подход используется во многих фреймворках для мэппинга метода класса на route HTTP REST запроса.
Прослойка ContractState
Блокчейн — key-value база данных, где каждый контракт имеет свое пространство имен, с которым он работает как с Redis или Hazelcast. SDK предоставляет для этого удобный и знакомый интерфейс. Для каждой транзакции (для вызова каждого метода) создается новый экземпляр ContractHandler, в который передается пустой виртуальный ContractState. Он отслеживает, для каких ключей были записаны значения текущим методом контракта, а также кеширует значения тех ключей, которые были непосредственно запрошены у ноды. Таким образом, мы имеем некий аналог незакомиченной транзакции БД:
повторные чтения одного ключа вернут один и тот же результат;
мы видим значения, которые только что записали;
новые значения попадут на стейт контракта в ноду только в случае успеха (аналог коммита).
Mapping
С наборами однотипных объектов на стейте контракта можно работать через абстракцию Mapping. Внутри key/value хранилища Mapping аллоцирует пространство имен, на ключах которого хранятся объекты одного типа. Поддерживается получение коллекции таких объектов для переданного списка ключей.

Пишем смарт-контракт
Перейдем к практике на нашем SDK. Напишем контракт, реализующий игру «камень-ножницы-бумага». Суть его следующая:
при создании контракта передаем адреса игроков;
каждый игрок генерирует случайную соль и отправляет хеш (ответ+соль) на контракт;
как только все игроки сделали предыдущее действие, игра стартует;
теперь каждый игрок должен раскрыть свой ответ, отправив значение соли на контракт;
как только все игроки раскрыли свой ответ, вычисляется победитель (если все ответы одинаковые, то победителя нет).
Полная версия кода есть на Github, здесь я приведу лишь самые важные фрагменты.
Реализация и сборка
Опишем необходимое взаимодействие с контрактом в его интерфейсе:
public interface RockPaperScissorsContract { @ContractInit void createGame(CreateGameRequest createGameRequest); @ContractAction void play(PlayRequest registerPlayerRequest); @ContractAction void reveal(RevealRequest revealRequest); }
Пометим методы контракта соответствующими аннотациями и перейдем непосредственно к реализации.
Создадим класс, пометив его аннотацией ContractHandler, и конструктор, в который наш framework передаст необходимые объекты — виртуальный state и текущую транзакцию.
@ContractHandler public class RockPaperScissorsContractImpl implements RockPaperScissorsContract { private final ContractState contractState; private final ContractTransaction tx; private final Mapping<Player> players; public RockPaperScissorsContractImpl(ContractState contractState, ContractTransaction tx) { this.tx = tx; this.contractState = contractState; this.players = contractState.getMapping(Player.class, PLAYERS_MAPPING_PREFIX); } }
Опишем метод, обрабатывающий создание контракта (транзакция 103). Он будет осуществлять необходимые проверки и писать адреса игроков в соответствующий Mapping.
public void createGame(CreateGameRequest createGameRequest) { if (createGameRequest.getPlayers().size() > 2) { throw new IllegalArgumentException("Currently only two players are supported"); } contractState.put(CREATED_DATE_KEY, txTimestamp().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); contractState.put( PLAYER_ADDRESSES_KEY, createGameRequest.getPlayers().stream().map(CreateGameRequest.Player::getAddress).collect(Collectors.toSet()) ); }
Теперь реализуем метод, с помощью которого игроки смогут передать хеш от своего ответа:
public void play(PlayRequest registerPlayerRequest) { if (contractState.tryGet(GAME_KEY, Game.class).isPresent()) { throw new IllegalStateException("Game has already started"); } String senderAddress = senderAddress(); Set<String> addresses = getPlayerAddresses(); String txSenderAddress = txSender(); if (!addresses.contains(txSenderAddress)) { throw new IllegalAccessError("Address " + txSenderAddress + " is not present in the players of this game"); } players.put(senderAddress(), new Player(senderAddress, registerPlayerRequest.getHashedAnswer())); if (players.hasAll(addresses)) { startGame(addresses); } } private void startGame(Set<String> addresses) { Game game = new Game(new ArrayList<>(players.getAll(addresses).values())); contractState.put(GAME_KEY, game); contractState.put(GAME_STATUS_KEY, game.getStatus()); } }
Для удобства выделим класс Game — он будет инкапсулировать часть логики контракта и хранить состояние. Как только все адреса прислали свои хеши, запускаем игру и кладем этот экземпляр Game на стейт.
Теперь игроки должны прислать соль, которая была использована при хешировании. Для этого метод контракта play, проведя необходимые проверки, делегирует обработку классу Game в одноименный метод.
public class Game { private Map<String, Player> players; private Player winner; private GameStatus status = GameStatus.ACTIVE; public Game(List<Player> players) { this.players = players.stream().collect(Collectors.toMap(Player::getAddress, Function.identity())); } public void reveal(String address, String salt) { Player player = players.get(address); if (player == null) { throw new IllegalArgumentException("Address " + address + " isn't among players of the game"); } Map<String, AnswerType> hashedAnswers = getHashedAnswers(salt); AnswerType answer = hashedAnswers.get(player.getHashedAnswer()); if (answer == null) { throw new IllegalArgumentException("Not found matching answer for salt"); } player.setAnswer(answer); if (allPlayersAnswered()) { finish(); } } private void finish() { status = GameStatus.FINISHED; if (allPlayersHaveTheSameAnswers()) { return; } this.winner = players.values().stream().max( (player1, player2) -> new AnswerComparator().compare(player1.getAnswer(), player2.getAnswer()) ).orElseThrow(() -> new IllegalStateException("Winner could not be determined")); } private boolean allPlayersHaveTheSameAnswers() { Set<AnswerType> differentAnswers = players.values().stream().map(Player::getAnswer).collect(Collectors.toSet()); return differentAnswers.size() == 1; } private boolean allPlayersAnswered() { return players.values().stream().allMatch(player -> player.getAnswer() != null); } public Map<String, AnswerType> getHashedAnswers(String salt) { return Arrays.stream(AnswerType.values()).collect( Collectors.toMap(answerType -> Util.hash(answerType + "_" + salt), Function.identity()) ); }
В main-методе нашего контракта опишем настройку и запуск обработчика контракта из SDK:
public class MainDispatch { public static void main(String[] args) { ContractDispatcher contractDispatcher = GrpcJacksonContractDispatcherBuilder.builder() .contractHandlerType(RockPaperScissorsContractImpl.class) .objectMapper(getObjectMapper()) .build(); contractDispatcher.dispatch(); } private static ObjectMapper getObjectMapper() { return JsonMapper.builder().addModule(new JavaTimeModule()).build(); } }
Остается собрать запускающий это docker-образ и опубликовать в registry, с которым работает нода Waves Enterprise.
FROM fabric8/java-alpine-openjdk11-jre MAINTAINER Waves Enterprise <> ENV JAVA_MEM="-Xmx256M" ENV JAVA_OPTS="" ADD build/libs/*-all.jar app.jar RUN chmod +x app.jar RUN eval $SET_ENV_CMD CMD ["/bin/sh", "-c", "eval ${SET_ENV_CMD} ; java $JAVA_MEM $JAVA_OPTS -jar app.jar"]
Для удобства можно использовать скрипт build_and_push_to_docker.sh из репозитория SDK.
./build_and_push_to_docker.sh my-registry.com/smart-contrats/rock-paper-scissors-contract:1.0.0
Проверяем смарт-контракт
После того как контракт опубликован, отправляем транзакцию 103 для его создания. Здесь можно использовать REST API ноды.
Передаем список игроков с адресами 3M3xGmJGmxBv2aZ4UFmn93rHxVXTJDKSAnh и 3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv.
curl --location --request POST 'https://node-0/transactions/signAndBroadcast' \ --header 'Content-Type: application/json' \ --data-raw '{ "image": "my-registry.com/smart-contrats/rock-paper-scissors-contract:1.0.0", "fee": 0, "imageHash": "2fad12f4de059e29f92e7d31b91b6cf8388bc0cadb2fdfd194b25e21193bd7cc", "type": 103, "params": [ { "type": "string", "value": "createGame", "key": "action" }, { "type": "string", "value": "{\"players\":[{\"address\":\"3M3xGmJGmxBv2aZ4UFmn93rHxVXTJDKSAnh\"},{\"address\":\"3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv\"}]}", "key": "createContract" } ], "version": 2, "sender": "{{address}}", "feeAssetId": null, "contractName": "sample-rock-paper-scissors-contract" }'
Получаем ID транзакции — она будет идентификатором контракта. Проверим, что контракт успешно смайнился:
curl --location --request GET 'https://node-0/contracts/GQNK5zxqt1a6cYxnYfG4Svk6HvEjwBdeq61vP2FnNSSp [ { "type": "string", "value": "2022-07-18T19:22:14.258", "key": "CREATED_DATE" }, { "type": "string", "value": "[\"3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv\",\"3M3xGmJGmxBv2aZ4UFmn93rHxVXTJDKSAnh\"]", "key": "PLAYER_ADDRESSES" } ]
Контракт с игрой создан для пользователей с адресами 3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv и 3M3xGmJGmxBv2aZ4UFmn93rHxVXTJDKSAnh.
Сгенерируем соль для пользователя с адресом 3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv. Допустим, это будет значение "abcd1234".
Возьмем SHA256-хеш от ответа + соли: Util.hash(AnswerType.SCISSORS.toString() + "_" + "abcd1234"). Отправим захешированный ответ для пользователя с адресом 3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv.
curl --location --request POST 'https://node-0/transactions/signAndBroadcast' \ --header 'Content-Type: application/json' \ --data-raw '{ "contractId": "GQNK5zxqt1a6cYxnYfG4Svk6HvEjwBdeq61vP2FnNSSp", "fee": 0, "type": 104, "params": [ { "type": "string", "value": "play", "key": "action" }, { "type": "string", "value": "{\"hashedAnswer\": \"3be42e249759cee0704157b135d2eb192d1cbd32e8a64d3b048d9de499514694\"}", "key": "arg" } ], "version": 2, "sender": "3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv", "feeAssetId": null, "contractVersion": 1 } '
Видим, что он появился на стейте контракта:
[ { "type": "string", "value": "2022-07-18T19:22:14.258", "key": "CREATED_DATE" }, { "type": "string", "value": "{\"address\":\"3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv\",\"hashedAnswer\":\"3be42e249759cee0704157b135d2eb192d1cbd32e8a64d3b048d9de499514694\",\"answer\":null}", "key": "PLAYERS_3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv" }, { "type": "string", "value": "[\"3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv\",\"3M3xGmJGmxBv2aZ4UFmn93rHxVXTJDKSAnh\"]", "key": "PLAYER_ADDRESSES" } ]
Проделаем аналогичные действия для адреса 3M3xGmJGmxBv2aZ4UFmn93rHxVXTJDKSAnh. Его ответом будет «камень» (ROCK).
Запуск игры
Мы видим на стейте новый объект GAME: игра запустилась.
{ "type": "string", "value": "{\"players\":{\"3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv\":{\"address\":\"3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv\",\"hashedAnswer\":\"3be42e249759cee0704157b135d2eb192d1cbd32e8a64d3b048d9de499514694\",\"answer\":null},\"3M3xGmJGmxBv2aZ4UFmn93rHxVXTJDKSAnh\":{\"address\":\"3M3xGmJGmxBv2aZ4UFmn93rHxVXTJDKSAnh\",\"hashedAnswer\":\"8ab8580983b8aa12284773fabb2f07caab18164266a92a3332599beb24629868\",\"answer\":null}},\"winner\":null,\"status\":\"ACTIVE\"}", "key": "GAME" }
Теперь игроки могут раскрыть значения своих солей. Например, так это сделает пользователь с адресом 3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv.
curl --location --request POST 'https://node-0/transactions/signAndBroadcast' \ --header 'Content-Type: application/json' \ --data-raw '{ "contractId": "GQNK5zxqt1a6cYxnYfG4Svk6HvEjwBdeq61vP2FnNSSp", "fee": 0, "type": 104, "params": [ { "type": "string", "value": "reveal", "key": "action" }, { "type": "string", "value": "{\"salt\": \"abcd1234\"}", "key": "arg" } ], "version": 2, "sender": "3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv", "feeAssetId": null, "contractVersion": 1 } '
После раскрытия всех игроков вычисляется победитель и игра переходит в статус FINISHED. На стейте появляется соответствующая информация, в том числе и адрес победителя — пользователя с адресом 3M3xGmJGmxBv2aZ4UFmn93rHxVXTJDKSAnh, показавшего камень.
{ "type": "string", "value": "3M3xGmJGmxBv2aZ4UFmn93rHxVXTJDKSAnh", "key": "GAME_WINNER_ADDRESS" }, { "type": "string", "value": "{\"players\":{\"3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv\":{\"address\":\"3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv\",\"hashedAnswer\":\"3be42e249759cee0704157b135d2eb192d1cbd32e8a64d3b048d9de499514694\",\"answer\":\"SCISSORS\"},\"3M3xGmJGmxBv2aZ4UFmn93rHxVXTJDKSAnh\":{\"address\":\"3M3xGmJGmxBv2aZ4UFmn93rHxVXTJDKSAnh\",\"hashedAnswer\":\"8ab8580983b8aa12284773fabb2f07caab18164266a92a3332599beb24629868\",\"answer\":\"ROCK\"}},\"winner\":{\"address\":\"3M3xGmJGmxBv2aZ4UFmn93rHxVXTJDKSAnh\",\"hashedAnswer\":\"8ab8580983b8aa12284773fabb2f07caab18164266a92a3332599beb24629868\",\"answer\":\"ROCK\"},\"status\":\"FINISHED\"}", "key": "GAME" }
Данный пример можно доработать, добавив логику раундов. Так можно будет обрабатывать ситуацию, когда победителя нет, или поддержать игру для более чем двух игроков.
Планы по развитию Java/Kotlin SDK
В ближайшее время мы продолжим дорабатывать SDK. В приоритете — интеграция с нативными ассетами Waves Enterprise, которую мы добавим с соответствующим релизом ноды. Кроме того, мы планируем добавить клиент для вызова со стороны бэкенда, упростить работу с данными стейтов других контрактов, добавить модуль для удобного юнит-тестирования контрактов.
