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