Как войти в блокчейн-разработку через Java и Kotlin: представляем JVM SDK смарт-контрактов

—

от автора

Всем привет! Меня зовут Степан, я руководитель группы разработки в 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, которую мы добавим с соответствующим релизом ноды. Кроме того, мы планируем добавить клиент для вызова со стороны бэкенда, упростить работу с данными стейтов других контрактов, добавить модуль для удобного юнит-тестирования контрактов.


ссылка на оригинал статьи https://habr.com/ru/articles/678062/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *