Привет! Меня зовут Даниил, я разработчик в Web3 Tech. Недавно в JVM-инструментарии для нашей основной платформы «Конфидент» состоялся новый релиз, в который вошли библиотеки клиента для взаимодействия с нодой и Spring Boot стартеры. Далее в посте я расскажу об этих библиотеках, которые помогут вам комфортно и эффективно создавать на JVM-языках программирования полноценные приложения, взаимодействующие со смарт-контрактами нашей платформы.
Какие 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<T>)
транзакций;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 стартеров, позволяющий с минимальными дополнительными настройками сконфигурировать контекст приложения для работы с клиентами ноды (включая необходимые обертки) и с клиентами контракта.
В сообществе Hyperledger Fabric есть известный пример разработки — приложение rent-car-app. По сути, это простая ролевая модель, где любой желающий может заказать уже существующее свободное авто, но только создатель контракта может принудительно изменять владельца и создавать новые авто. Вот как будет выглядеть схема взаимодействия с нодой (нодами), если мы сделаем это приложение на нашей платформе:
Теперь воспроизведем нужную логику на Java 17. Вот интерфейс контракта:
public interface RentCarContract {
@ContractInit
void initRent();
@ContractAction
void rentCar(@InvokeParam(name = "carNumber") String carNumber);
@ContractAction
void createCar(@InvokeParam(name = "car") Car car);
@ContractAction
void changeCarRenter(
@InvokeParam(name = "carNumber") String carNumber,
@InvokeParam(name = "carRenter") String carRenter
);
class Keys {
public static final String CONTRACT_CREATOR = "CONTRACT_CREATOR";
public static final String CARS_MAPPING_PREFIX = "CARS";
}
}
Вот реализация:
@ContractHandler
public class RentCarContractImpl implements RentCarContract {
private final ContractState contractState;
private final ContractCall call;
private final Mapping<Car> cars;
public RentCarContractImpl(ContractState contractState, ContractCall call) {
this.contractState = contractState;
this.call = call;
this.cars = contractState.getMapping(Car.class, CARS_MAPPING_PREFIX);
}
@Override
public void initRent() {
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 rentCar(String carNumber) {
Car car = getCarIfExist(carNumber);
checkPossibilityRentCar(car);
car.setRenter(call.getCaller());
cars.put(carNumber, car);
}
@Override
public void createCar(Car car) {
checkContractCreator();
cars.put(car.getNumber(), car);
}
@Override
public void changeCarRenter(String carNumber, String carRenter) {
checkContractCreator();
Car car = getCarIfExist(carNumber);
car.setRenter(carRenter);
cars.put(carNumber, car);
}
private void checkPossibilityRentCar(Car car) {
if (car.getRenter() != 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<Car> optionalCar = cars.tryGet(carNumber);
return optionalCar.orElseThrow(() -> new IllegalStateException(
"Car with number " + carNumber + " not exist."
));
}
}
Далее необходимо добавить образ контракта в репозиторий — подробней об это можно почитать в посте «Как войти в блокчейн-разработку через Java и Kotlin: представляем JVM SDK смарт-контрактов» и в документации к we-contract-sdk.
После добавления образа контракта в репозиторий, на который смотрит нода, необходимо реализовать бэкенд для взаимодействия с контрактом через клиента. Основные компоненты бэкенда:
Spring-конфигурация;
сервис работы с клиентом контракта;
предикат и листенер для фильтрации и обработки смайненных транзакций;
Вот Spring-конфигурация контракта RentCarContract и компонента клиента к ноде SenderAddressProvider:
@Configuration
@EnableContracts(
contracts = {
@Contract(
api = RentCarContract.class,
impl = RentCarContractImpl.class,
name = "rentCarContract"
)
}
)
public class RentCarAppConfiguration {
@Bean
SenderAddressProvider senderAddressProvider() {
return new HttpSenderAddressProvider();
}
}
С помощью аннотации @EnableContracts конфигурация определяет контракты, для которых необходимо создать клиентов. Аннотация @Contract определяет интерфейс, реализацию и названия контракта для связывания через настройки контракта в yml-файле. Также здесь присутствует бин пользовательской реализации SenderAddressProvider’а — он необходим для определения отправителя при отправке транзакций контракт-клиентом. Реализация использует переданный в хедер X-Tx-Sender
адрес для реализации ролевой модели контракта.
Вот как выглядит конфигурация контракт-клиента в application.yml. По имени контракта определяются образ, хеш образа, версия транзакции, комиссия за вызовы контракта:
contracts:
config:
rentCarContract:
image: registry.hub.docker.com/donyfutura/contract:0.0.1
imageHash: e95dfce7fb4ca9ef7f5e6a829467a4c65284689d9dcfe00daed1f96d3a2999e4
version: 3
fee: 0
auto-update:
enabled: false
contractCreatorAddress: null
Вот конфигурация для клиента ноды:
node:
config:
node-0:
http:
url: http://158.160.97.253:6862
loggerLevel: FULL
read-timeout: 30000
grpc:
address: node-0
port: 6865
В ней настроен клиент к ноде без оберток для http и gRPC; gRPC используется для прослушивания транзакций из блокчейна через we-tx-observer. Также настройки включают в себя значения credentials-provider — они нужны для определения пароля отправителя по адресу для подписания транзакций (если нода настроена на работу с транзакциями с паролем).
Сервис работы с клиентом контракта
Для взаимодействия с контрактом мы реализуем сервис RentCarService, который инжектит в себя бин необходимого контракт-клиента (ContractBlockingClientFactory<RentCarContract>). Согласно коду, возможен вызов двух типов транзакций — 103 (создание контракта на ноде) и 104 (вызов контракта):
@Service
public class RentCarService {
private ContractBlockingClientFactory<RentCarContract> contractClient;
private CarRepository carRepository;
@Autowired
public void setContractClient(
ContractBlockingClientFactory<RentCarContract> contractClient,
CarRepository carRepository
) {
this.contractClient = contractClient;
this.carRepository = carRepository;
}
public String initRent() {
ExecutionContext executionContext = contractClient.executeContract(
null, (RentCarContract rentCarContract) -> {
rentCarContract.initRent();
return null;
});
return executionContext.getTx().getId().asBase58String();
}
public String rentCar(String carNumber, String contractId) {
ExecutionContext executionContext = contractClient.executeContract(
ContractId.fromBase58(contractId), (RentCarContract rentCarContract) -> {
rentCarContract.rentCar(carNumber);
return null;
});
return executionContext.getTx().getId().asBase58String();
}
...
}
После всех настроек и реализации взаимодействия с контрактом нужно написать предикат и листенер транзакций для получения актуальной информации на стейте контракта. Код предиката:
@Service
public class RentCarPredicate implements TxEnqueuePredicate {
private ContractService contractService;
@Value("${contracts.config.rentCarContract.image}")
private String rentCarContractImage;
@Autowired
public RentCarPredicate(ContractService contractService) {
this.contractService = contractService;
}
@Override
public boolean isEnqueued(Tx tx) {
return switch (tx) {
case ExecutedContractTx executedContractTx -> rentCarContractImage.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 RentCarListener {
private CarRepository carRepository;
@Autowired
public RentCarListener(CarRepository carRepository) {
this.carRepository = carRepository;
}
Logger log = LoggerFactory.getLogger(RentCarListener.class);
@TxListener
public void onCallRentCar(
@KeyFilter(keyPrefix = "CARS_") KeyEvent<Car> 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.getRenter());
}
}
В примере мы определяем метод onCallRentCar(KeyEvent<Car> keyEvent)
, куда попадают ивенты по префиксу ключа, измененного на стейте.
Транзакция, которую получит на обработку слушатель в объекте KeyEvent<Car>
, будет соответствовать названию образа, а также содержать в параметрах ключ с префиксом CARS_
и объект Car
в значении. Например, если будет вызван метод контракта rentCar(String carNumber)
, то после майнинга 105-й транзакции, содержащей в себе results нашего вызова, метод, аннотированный @TxListener, получит в обработку ожидаемый KeyEvent<Car>
и сохранит в БД новую информацию.
Пример транзакции, которая будет прослушана реализованным нами слушателем:
{
"senderPublicKey": "3BDjUQy6umvupLY2YR13VXnrjb5QAzMbRsFCFEQEXKVcQ8AHMGXiBC2YUJBXRUDimqnpuUFYA7QEMTi9c6cebipa",
"tx": {
"senderPublicKey": "4L4XEpNpesX9r6rVJ8hW1TrMiNCZ6SMvRuWPKB7T47wKfnp4D84XBUv7xsa36CGwoyK3fzfojivwonHNrsX2fLBL",
"fee": 0,
"type": 104,
"params": [
{
"type": "string",
"value": "rentCar",
"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.RentCarContract\"],\"impls\":[\"com.wavesenterprise.contract.app.RentCarContractImpl\"]}",
"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 — инструменте для обработки смайненных транзакций. А еще о постквантовой криптографии… но это уже совсем другая история.