Как стать автором
Обновить
0
Web3 Tech
Блокчейн-платформа для бизнеса и государства

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

Время на прочтение10 мин
Количество просмотров8.3K

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

Теги:
Хабы:
Всего голосов 12: ↑11 и ↓1+10
Комментарии1

Публикации

Информация

Сайт
web3tech.ru
Дата регистрации
Дата основания
2016
Численность
101–200 человек
Местоположение
Россия
Представитель
klauss_z

Истории