Разработка децентрализованных приложений на Spring Boot: инструменты Web3 Tech

Привет! Меня зовут Даниил, я разработчик в Web3 Tech. Недавно в JVM-инструментарии для нашей основной платформы «Конфидент» состоялся новый релиз, в который вошли библиотеки клиента для взаимодействия с нодой и Spring Boot стартеры. Далее в посте я расскажу об этих библиотеках, которые помогут вам комфортно и эффективно создавать на JVM-языках программирования полноценные приложения, взаимодействующие со смарт-контрактами нашей платформы.

99d97f7851376617b8d409c8bbc4dc6f.jpg

Какие JVM-библиотеки у нас вообще есть

Прежде чем рассказывать о конкретных решениях, перечислю, что входит в инструментарий для разработки на JVM-языках, который мы недавно обновили:

  • we-node-client — библиотека, предоставляющая клиент для взаимодействия с нодами;

  • we-sdk-contract — библиотека для создания смарт-контрактов; подробней о ней можно узнать в посте моего коллеги Степана;

  • we-sdk-spring — библиотека, содержащая стартеры с автоконфигурациями для клиентов ноды и контракта;

  • we-tx-observer — инструмент для обработки смайненных транзакций в блокчейне.

Далее я подробней расскажу о we-node-client и we-sdk-spring, а также немного о we-tx-observer.

Библиотека клиента ноды (we-node-client)

Основная задача we-node-client — взаимодействие с нодой через предоставление абстрактных сервисов конечному пользователю. Все наши библиотеки используют в своих реализациях we-node-client — через него организовано взаимодействие с нодами блокчейн-сети.

  • We-sdk-contract использует we-node-client для получения транзакций 103 и 104 (создание и вызов смарт-контракта соответственно) из UTX (пула неподтвержденных транзакций) — с целью их майнинга через исполнение смарт-контракта. Для отправки транзакций используется клиент для работы с контрактом (проводник для отправки транзакций контракта), который, в свою очередь, использует we-node-client.

  • We-tx-observer использует we-node-client для периодического опроса ноды (или подписки на события новых блоков, при использовании gRPC) с целью прослушивания актуальных транзакций и высоты смайненных блоков.

  • We-sdk-spring предоставляет Spring Boot автоконфигурации для удобного создания и конфигурирования клиентов, работающих с нодами и контрактами.

У we-node-client есть два типа подключения — через http и gRPC. Первый пока реализован через Feign (FeignNodeServiceFactory). Для gRPC-клиента используется GrpcNodeServiceFactory. Обе реализализации являются наследниками общего интерфейса NodeBlockingServiceFactory. Он предоставляет сервисы для взаимодействия с нодой, которые соответствуют эндпоинтам REST API со стороны ноды.

Список сервисов и их соответствие эндпоинтам в Swagger«е ноды:

  • TxService — /transactions

  • ContractService — /contracts

  • AddressService — /addresses

  • NodeInfoService — /node

  • PrivacyService — /privacy

  • BlocksService — /blocks

  • NodeUtilsService — /utils

  • PkiService — /pki

Список методов и их описание есть у нас в документации.

Обертки с дополнительной логикой

Библиотека клиента ноды включает обертки с дополнительной логикой для оптимизации работы с нодой (нодами).

RateLimitingServiceFactory ограничивает число запросов к нодам для предотвращения перегрузки.

Логика работы этой обертки опирается на количество транзакций в UTX-пуле ноды. Если достигнут некий установленный лимит, то запросы к этой ноде перестают поступать до уменьшения числа транзакций в пуле.

LoadBalancingServiceFactory предназначена для балансировки запросов между несколькими нодами. Она позволяет объединить клиенты нод в одной сети и балансировать нагрузку между ними. Обертка используется по умолчанию, если в настройках указано подключение более чем к одной ноде.

CachingNodeBlockingServiceFactory предназначена для снижения нагрузки на сеть при взаимодействии с нодой. Она кеширует необходимую информацию по транзакциям сети:

  • BlocksService — при получении блока транзакций на определенной высоте blockAtHeight(height: Long) или последовательности блоков blockSequence(fromHeight: Long, toHeight: Long) транзакции из них кешируются.

  • PrivacyService — при получении метаданных для пакета конфиденциальных данных группы info(request: PolicyItemRequest) полученная информация кешируется; либо берется из кеша, если была ранее сохранена в нем.

  • TxService — при запросах информации по транзакциям txInfo(txId: TxId) транзакция кешируется; либо берется из кеша, если уже присутствует в нем.

AtomicAwareNodeBlockingServiceFactory используется при выполнении атомарных транзакций. С ее помощью в рамках одного метода можно собрать все транзакции, подписать их одновременно и отправить в виде атомарной транзакции. Обертка не имеет отдельных настроек: она просто оборачивает существующего клиента к ноде при добавлении зависимости we-starter-atomic из проекта we-sdk-spring. И переопределяет методы сервисов, которые отправляют транзакции:

  • TxService — отправка broadcast (tx: Tx) и подписание + отправка signAndBroadcast(request:SignRequest) транзакций;

  • PrivacyService — отправка 114 транзакции методом sendData(request: SendDataRequest) с параметром broadcast = false.

Далее у нас есть два способа объединения транзакций в одну атомарную. Можно либо пометить метод с кодом отправки транзакций аннотацией @Atomic, либо использовать метод atomicManager.doInAtomic.

Подробнее про настройку и использование оберток можно узнать в документации we-node-client и we-sdk-spring.

Библиотека для обработки и отслеживания транзакций (we-tx-observer) 

We-tx-observer предоставляет удобный способ обработки и отслеживания транзакций в блокчейн-сети. Библиотека использует постоянную очередь для хранения транзакций, которые далее передаются обработчикам, настроенным в пользовательском приложении. Есть возможность отбора и обработки транзакций по заданным фильтрам.

Инструмент полезен, когда бэкенд-приложение должно работать со смайненными транзакциями сети. Например, чтобы получать актуальную информацию со стейта смарт-контракта, необходимо реализовать всего два основных элемента:

  • сервис, который наследует TxEnqueuePredicate;

  • метод приложения, помеченный аннотацией @TxListener.

Я проиллюстрирую работу библиотеки в примере далее; более подробная информация есть в документации we-tx-observer.

Библиотека стартеров с автоконфигурациями (we-sdk-spring)

We-sdk-spring — это набор Spring Boot стартеров, позволяющий с минимальными дополнительными настройками сконфигурировать контекст приложения для работы с клиентами ноды (включая необходимые обертки) и с клиентами контракта.

Приведу простой пример на Java 17 с использованием стартеров для контракта, ноды и обсервера. Вот как выглядит схема взаимодействий с нодой (нодами) сети на примере приложения FabCar (fab-car-app):

daf3d2bd40c189d0d1d760524fbb3029.png

Напишем контракт с логикой «автофабрики», простой ролевой моделью, где любой желающий может заказать уже существующее свободное авто, но только создатель контракта может принудительно изменять владельца и создавать новые авто. Вот интерфейс контракта:

public interface FabCarContract {

   @ContractInit
   void initFab();

   @ContractAction
   void queryCar(@InvokeParam(name = "carNumber") String carNumber);

   @ContractAction
   void createCar(@InvokeParam(name = "car") Car car);

   @ContractAction
   void changeCarOwner(
           @InvokeParam(name = "carNumber") String carNumber,
           @InvokeParam(name = "carOwner")String carOwner
   );

   class Keys {
       public static final String CONTRACT_CREATOR = "CONTRACT_CREATOR";
       public static final String CARS_MAPPING_PREFIX = "CARS";
   }
}

Вот реализация:

@ContractHandler
public class FabCarContractImpl implements FabCarContract {

   private final ContractState contractState;
   private final ContractCall call;
   private final Mapping cars;

   public FabCarContractImpl(ContractState contractState, ContractCall call) {
       this.contractState = contractState;
       this.call = call;
       this.cars = contractState.getMapping(Car.class, CARS_MAPPING_PREFIX);
   }

   @Override
   public void initFab() {
       contractState.put(CONTRACT_CREATOR, call.getCaller());
       cars.put("1", new Car("beatle", null, "1"));
       cars.put("2", new Car("banana", null, "2"));
       cars.put("3", new Car("cat", null, "3"));
   }

   @Override
   public void queryCar(String carNumber) {
       Car car = getCarIfExist(carNumber);
       checkPossibilityQueryCar(car);
       car.setOwner(call.getCaller());
       cars.put(carNumber, car);
   }

   @Override
   public void createCar(Car car) {
       checkContractCreator();
       cars.put(car.getNumber(), car);
   }

   @Override
   public void changeCarOwner(String carNumber, String carOwner) {
       checkContractCreator();
       Car car = getCarIfExist(carNumber);
       car.setOwner(carOwner);
       cars.put(carNumber, car);
   }

   private void checkPossibilityQueryCar(Car car) {
       if (car.getOwner() != null) {
           throw new IllegalStateException("Car with number " + car.getNumber() + " has owner.");
       }
    }

    private void checkContractCreator() {
        String contractCreator = contractState.get(CONTRACT_CREATOR, String.class);
        if (!contractCreator.equals(call.getCaller())) {
            throw new IllegalStateException("Only contract creator can create cars or change car owner.");
        }
    }

    private Car getCarIfExist(String carNumber) {
        Optional optionalCar = cars.tryGet(carNumber);
        return optionalCar.orElseThrow(() -> new IllegalStateException("Car with number" + carNumber + "not exist"));
    }
}

Далее необходимо добавить образ контракта в репозиторий — подробней об это можно почитать в посте «Как войти в блокчейн-разработку через Java и Kotlin: представляем JVM SDK смарт-контрактов» и в документации к we-contract-sdk.

После добавления образа контракта в репозиторий, на который смотрит нода, необходимо реализовать бэкенд для взаимодействия с контрактом через клиента. Основные компоненты бэкенда:

  • Spring-конфигурация;

  • сервис работы с клиентом контракта;

  • предикат и листенер для фильтрации и обработки смайненных транзакций;

Вот Spring-конфигурация контракта FabCarContract и компонента клиента к ноде SenderAddressProvider:

@Configuration
@EnableContracts(
       contracts = {
               @Contract(
                       api = FabCarContract.class,
                       impl = FabCarContractImpl.class,
                       name = "fabCarContract"
               )
       }
)
public class FabCarAppConfiguration {


   @Bean
   SenderAddressProvider senderAddressProvider() {
       return new HttpSenderAddressProvider();
   }
}

С помощью аннотации @EnableContracts конфигурация определяет контракты, для которых необходимо создать клиентов. Аннотация @Contract определяет интерфейс, реализацию и названия контракта для связывания через настройки контракта в yml-файле. Также здесь присутствует бин пользовательской реализации SenderAddressProvider«а — он необходим для определения отправителя при отправке транзакций контракт-клиентом. Реализация использует переданный в хедер X-Tx-Sender адрес для реализации ролевой модели контракта.

Вот как выглядит конфигурация контракт-клиента в application.yml. По имени контракта определяются образ, хеш образа, версия транзакции, комиссия за вызовы контракта:

contracts:
 config:
   fabCarContract:
     image: registry.hub.docker.com/contracts/fab-car-contract:0.0.1
     imageHash: 00bd8c05a9a01a0add73dbe67baac679eb312d0dd692f3a0b331f5f42a0f1439
     version: 3
     fee: 0
     auto-update:
       enabled: false
       contractCreatorAddress: null

Вот конфигурация для клиента ноды:  

node:
 config:
   node-0:
     url: 'DO_NOT_USE'
   node-1:
     url: 'DO_NOT_USE'
   fns-node-0:
     http:
       url: http://158.160.97.253:6862
       loggerLevel: FULL
       read-timeout: 30000
     grpc:
       address: node-0
       port: 6865
 credentials-provider:
   addresses:
     3QPTyemLrun9iwE54XHZ1r7P3h1RCkFh5ty: wrFF1JmpAk1Ft8ug

В ней настроен клиент к ноде без оберток для http и gRPC; gRPC используется для прослушивания транзакций из блокчейна через we-tx-observer. Также настройки включают в себя значения credentials-provider — они нужны для определения пароля отправителя по адресу для подписания транзакций (если нода настроена на работу с транзакциями с паролем).

Сервис работы с клиентом контракта

Для взаимодействия с контрактом мы реализуем сервис FabCarService, который инжектит в себя бин необходимого контракт-клиента (ContractBlockingClientFactory). Согласно коду, возможен вызов двух типов транзакций — 103 (создание контракта на ноде) и 104 (вызов контракта):

@Service
public class FabCarService {
   private ContractBlockingClientFactory contractClient;

   @Autowired
   public void setContractClient(
           ContractBlockingClientFactory contractClient
   ) {
       this.contractClient = contractClient;
   }

   public String initFab() {
       ExecutionContext executionContext = contractClient.executeContract(
               null, (FabCarContract fabCarContract) -> {
                   fabCarContract.initFab();
                   return null;
               });
       return executionContext.getTx().getId().asBase58String();
   }

   public String queryCar(String carNumber, String contractId) {
       ExecutionContext executionContext = contractClient.executeContract(
               ContractId.fromBase58(contractId), (FabCarContract fabCarContract) -> {
                   fabCarContract.queryCar(carNumber);
                   return null;
               });
       return executionContext.getTx().getId().asBase58String();
   }
...
}

После всех настроек и реализации взаимодействия с контрактом нужно написать предикат и листенер транзакций для получения актуальной информации на стейте контракта. Код предиката:

@Service
public class FabCarPredicate implements TxEnqueuePredicate {

   private ContractService contractService;

   @Value("${contracts.config.fabCarContract.image}")
   private String fabCarContractImage;

   @Autowired
   public FabCarPredicate(ContractService contractService) {
       this.contractService = contractService;
   }


   @Override
   public boolean isEnqueued(Tx tx) {
       return switch (tx) {
           case ExecutedContractTx executedContractTx -> fabCarContractImage.equals(getImage(executedContractTx));
           default -> false;
       };
   }

   private String getImage(ExecutedContractTx executedContractTx) {
       return switch (executedContractTx.getTx()) {
           case CallContractTx callContractTx ->
                   contractService.getContractInfo(callContractTx.getContractId()).get().getImage().getValue();
           case CreateContractTx createContractTx -> createContractTx.getImage().getValue();
           case UpdateContractTx updateContractTx -> updateContractTx.getImage().getValue();
           default -> null;
       };
   }
}

Что здесь происходит? Простыми словами, каждая полученная транзакция, связанная с вызовом контракта, проверяется на соответствие «нужно ли нам, чтобы она попала в слушатель, или нет?». В этом примере идет проверка по названию образа из полученной транзакции: если образ совпадает с ожидаемым, то транзакция попадет в слушатель. Реализация определяет Spring-бин наследника TxEnqueuePredicate (базовый интерфейс предикатов), который используется для фильтрации библиотекой we-tx-observer при получении новых транзакций из сети блокчейн.

Перейдем к листенеру (слушателю) — это обычный Spring-сервис, который определяет методы, аннотированные @TxListener:

@Service
public class FabCarListener {

   private CarRepository carRepository;

   @Autowired
   public FabCarListener(CarRepository carRepository) {
       this.carRepository = carRepository;
   

   Logger log = LoggerFactory.getLogger(FabCarListener.class);

   @TxListener
   public void onCallFabCar(
           @KeyFilter(keyPrefix = "CARS_") KeyEvent keyEvent
   ) {
       Car car = keyEvent.getPayload();
       log.info("Received 104 tx [ id = {} ]", keyEvent.getTx().getId().asBase58String());
       carRepository.save(mapToEntity(car));
   }

   private CarEntity mapToEntity(Car car) {
       return new CarEntity(car.getNumber(), car.getName(), car.getOwner());
   }
}

В примере мы определяем метод onCallFabCar(KeyEvent keyEvent), куда попадают ивенты по префиксу ключа, измененного на стейте.

Транзакция, которую получит на обработку слушатель в объекте KeyEvent, будет соответствовать названию образа, а также содержать в параметрах ключ с префиксом CARS_ и объект Car в значении. Например, если будет вызван метод контракта queryCar(String carNumber), то после майнинга 105-й транзакции, содержащей в себе results нашего вызова, метод, аннотированный @TxListener, получит в обработку ожидаемый KeyEvent и сохранит в БД новую информацию.

Пример транзакции, которая будет прослушана реализованным нами слушателем:

{
  "senderPublicKey": "3BDjUQy6umvupLY2YR13VXnrjb5QAzMbRsFCFEQEXKVcQ8AHMGXiBC2YUJBXRUDimqnpuUFYA7QEMTi9c6cebipa",
  "tx": {
    "senderPublicKey": "4L4XEpNpesX9r6rVJ8hW1TrMiNCZ6SMvRuWPKB7T47wKfnp4D84XBUv7xsa36CGwoyK3fzfojivwonHNrsX2fLBL",
    "fee": 0,
    "type": 104,
    "params": [
      {
        "type": "string",
        "value": "queryCar",
        "key": "action"
      },
      {
        "type": "string",
        "value": "2",
        "key": "carNumber"
      }
    ],
    "version": 4,
    "contractVersion": 1,
    "atomicBadge": null,
    "sender": "3M3ybNZvLG7o7rnM4F7ViRPnDTfVggdfmRX",
    "feeAssetId": null,
    "proofs": [
      "DqtSCN72ZgW6RLG2AfRYsSy4jbnsthFMkyVTHCnwRD6ichxTUSuC6emVHETjCFnEvvgsb7GojLnNa4arcRi4wCn"
    ],
    "contractId": "6Qmz51WdR1iaDvjbvuMrduLE6xbnBpd21Yw3F7gVfLjG",
    "id": "Ge1bYCWNQtyfzdW8b2QYrznmzcMFkYUQuZC8z1AWV7v7",
    "timestamp": 1695740795467
  },
  "resultsHash": "HDRCWodoLvr6q3vmkPejPiwqVxhpf1NyQQvcvcLiXoGV",
  "fee": 0,
  "validationProofs": [],
  "type": 105,
  "version": 3,
  "sender": "3M7EEnszPAT2yr72SgWVDLxfYCa4AYvVRwv",
  "assetOperations": [],
  "proofs": [
    "aLNwwsbi4jx9AtCeQmWwD3Gf4pje4rVzb4KadvNE6LzmUDxCSd3bxihpx74hSvdxNE9ooeRGBxS1gESFvnvnfqE"
  ],
  "id": "Ekfec5VEGgdnfjfVDadBvWuBqU1hWit9BEodN3tv2M8U",
  "results": [
    {
      "type": "string",
      "value": "{\"lang\":\"java\",\"interfaces\":[\"com.wavesenterprise.contract.api.FabCarContract\"],\"impls\":[\"com.wavesenterprise.contract.app.FabCarContractImpl\"]}",
      "key": "__WRC12_CONTRACT_META"
    },
    {
      "type": "string",
      "value": "{\"name\":\"banana\",\"owner\":\"3M3ybNZvLG7o7rnM4F7ViRPnDTfVggdfmRX\",\"number\":\"2\"}",
      "key": "CARS_2"
    }
  ],
  "timestamp": 1695740799662,
  "height": 11924439
}

Полный код примера можно найти у нас гитхабе. В ближайших постах в блоге Web3 Tech мы подробней расскажем о we-tx-observer — инструменте для обработки смайненных транзакций. А еще о постквантовой криптографии…, но это уже совсем другая история :)

© Habrahabr.ru