Paddle - Java библиотека для тестирования смарт контрактов на блокчейне Waves.
Read this documentation in English.
Самый простой способ начать новый проект — создать его из GitHub-шаблона paddle-example.
Или, если уже есть существующий проект, добавьте Paddle как зависимость.
<dependency>
<groupId>im.mak</groupId>
<artifactId>paddle</artifactId>
<version>1.0.0-rc9</version>
</dependency>
Groovy DSL:
implementation 'im.mak:paddle:1.0.0-rc9'
Kotlin DSL:
compile("im.mak:paddle:1.0.0-rc9")
import im.mak.paddle.Account;
public class Main {
public static void main(String[] args) {
// Создать два аккаунта
// При создании первого аккаунта Paddle автоматически скачал и запустил ноду Waves в Docker контейнере!
Account alice = new Account(10_00000000); // аккаунт с балансом 10 Waves
Account bob = new Account(); // аккаунт с пустым балансом
// Алиса отправляет 3 Waves Бобу
// Paddle ждет, пока Transfer транзакция попадет в блокчейн
alice.transfers(t -> t.to(bob).amount(3_00000000));
System.out.println( bob.balance() ); // 300000000
// При завершении программы Paddle автоматически выключит Docker контейнер с нодой
}
}
import im.mak.paddle.Account;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
class FirstTest {
private Account alice, bob;
private String assetId;
@Test
void canSendSmartAsset() {
alice = new Account(10_00000000L);
bob = new Account();
assetId = alice.issues(a -> a
.name("My Asset")
.quantity(100)
.script("2 * 2 == 4")
).getId().toString();
alice.transfers(t -> t
.to(bob)
.amount(42)
.asset(assetId)
);
assertEquals(42, bob.balance(assetId));
}
}
Paddle не зависит от тестового фреймворка. Вы можете использовать Paddle как самостоятельно, так и совместно с JUnit, TestNG или любым другим фреймворком.
В любом случае, тест будет состоять из следующих шагов:
Шаги 1 и 5 Paddle выполнит автоматически.
Для выполнения тестовых сценариев необходима нода Waves.
По умолчанию Paddle автоматически запускает ноду в Docker, но вместо этого можно подключиться к какой-то уже работающей ноде.\
Поведение Paddle определяется параметрами выбранного профиля, которые можно объявить в файле paddle.conf
.
Файл paddle.conf
можно создать или в корне проекта, или в папке resources
.
У Paddle есть преднастроенные профили, которых хватает для большинства задач:
docker
- профиль по умолчанию. Paddle запустит локальную ноду на порту 6869
и выключит её по завершению тестов.local
- Paddle подключится к локальной ноде. Например, если контейнер с нодой уже запущен вручную.stagenet
, testnet
, mainnet
- Paddle подключится к ноде соответствующей сети Waves.mvn test -Dpaddle.profile=testnet "-Dpaddle.testnet.faucet-seed=some seed text"
paddle.conf
:
paddle.profile = testnet
paddle.testnet.faucet-seed = some seed text
В paddle.conf
можно создавать и собственные профили:
paddle.profile = my-profile
paddle.my-profile {
api-url = "http://localhost:8080/"
chain-id = D
faucet-seed = some seed text
}
paddle.my-docker-profile = ${paddle.my-profile} {
docker-image = "my-custom-image:latest"
faucet-seed = another seed text
auto-shutdown = true
}
В этом примере при использовании my-profile
Paddle подключится к уже работающей локальной ноде.
А при my-docker-profile
Paddle запустит ноду в контейнере, т.к. указано поле docker-image
.
${paddle.my-profile}
означает, что профиль my-docker-profile
отнаследован от профиля my-profile
с дополнительными параметрами.
Важно! Если вы используете другой Docker образ, то убедитесь, что у него открыт порт 6869
для доступа к REST API ноды.
Если выбран профиль docker
, то для запуска контейнера с нодой достаточно создать аккаунт или обратиться к инстансу ноды:
import im.mak.paddle.Account;
import static im.mak.paddle.Node.node;
Account alice = new Account(100_00000000); // аккаунт с начальным балансом
// ИЛИ
node().height();
При первом же обращении к ноде она будет запущена автоматически.
Для Paddle необходим аккаунт faucet
- к нему можно обратиться через поле node().faucet()
объекта Node
.
Аккаунт faucet
используется как “банк” для других тестовых аккаунтов.
Когда в тесте создается новый аккаунт, его стартовый баланс пополняется переводом токенов Waves именно с faucet
аккаунта.
Paddle старается самостоятельно выключать контейнер ноды по окончанию программы. Но если ваша программа была экстренно прервана, то контейнер скорее всего останется работать, и его придется выключить самостоятельно.
node().chainId()
- буква сети ноды;node().height()
- текущая высота блокчейна;node().compileScript()
- скомпилировать скрипт RIDE;node().isSmart(assetOrAddress)
- определить, является ли токен или аккаунт скриптованным;node().send(...)
- отправить транзакцию;node().api.assetDetails(assetId)
- информация о выпущенном токене;node().api.nft(address)
- список NFT на счету у аккаунта;node().api.stateChanges(invokeTxId)
- результат выполнения указанной InvokeScript транзакции.В Paddle объект Account
это действующее лицо в тестовом сценарии. В нем есть вся информация о Waves аккаунте и он может отправлять транзакции.
Чтобы создать аккаунт:
Account alice = new Account(10_00000000L);
Опционально можно задать seed фразу для аккаунта, иначе она будет сгенерирована автоматически.
Также опционально можно задать начальный баланс Waves, иначе аккаунт начнет работу с пустым кошельком.
Технически, чтобы у аккаунта появился начальный баланс, faucet
аккаунт делает перевод токенов на этот аккаунт Transfer транзакцией.
У объекта Account
есть методы для получения его сид фразы, приватного и публичного ключей, адреса. Аккаунт может подсказать, является ли он скриптованным:
alice.seed();
alice.privateKey();
alice.publicKey();
alice.address();
alice.isSmart();
Account
может сообщить свой баланс Waves или в любом токене, а также извлекать записи из своего хранилища данных:
alice.balance(); // баланс Waves
alice.balance(assetId); // баланс в указанном токене
alice.nft(); // список non-fungible токенов на аккаунте
alice.data(); // все записи из хранилища данных этого аккаунта
alice.dataByKey(key); // запись неопределенного типа по указанному ключу
alice.dataBin(key); // запись типа byte[] по указанному ключу
alice.dataBool(key); // запис типа boolean по указанному ключу
alice.dataInt(key); // запись типа long по указанному ключу
alice.dataStr(key); // запись типа String по указанному ключу
Аккаунт может подписывать данные и отправлять транзакции:
alice.sign(tx.getBodyBytes());
alice.issues(...);
alice.setsScript(...);
alice.invokes(...);
// и все остальные типы транзакций...
Для создания транзакции можно указывать только те поля, которые важны для текущего сценария - в большинстве случаев остальные поля примут значения по умолчанию или будут рассчитаны автоматически.
Например, для выпуска токена необязательно заполнять его имя и описание:
alice.issues(a -> a.quantity(1000).decimals(0));
// здесь явно указаны только количество выпускаемого токена и количество знаков после запятой
Также комиссия транзакции тоже определяется автоматически! Единственное, на данный момент не рассчитываются комиссии в спонсорских токенах или если в результате InvokeScript транзакции передаются смарт токены.
Вам не нужно подписывать транзакции в коде - Paddle делает это автоматически.
Вам не нужно явно ждать, пока транзакция попадет в блокчейн - Paddle делает это автоматически.
Чтобы создать dApp или смарт токен, скрипт для них можно хранить в отдельном файле, а в тесте просто указывать путь до него:
alice.setsScript(s -> s.script(Script.fromFile("wallet.ride")));
alice.issues(a -> a.script(Script.fromFile("fixed-price.ride")));
Или, если необходимо, код контракта можно задать и напрямую в коде теста:
alice.setsScript(s -> s.script("sigVerify(tx.bodyBytes, tx.proofs[0], tx.senderPublicKey)"));
alice.issues(a -> a.script("true"));
В обоих случаях не нужно компилировать скрипт - Paddle делает это автоматически.
Любое действие по отправке транзакции возвращает объект этой транзакции из блокчейна. Таким образом, можно использовать свойства транзакции для проверок:
String assetId = alice.issues(a -> a.quantity(1000)).getId().toString();
assertEquals(1000, alice.balance(assetId));
Если используемая нода хранит результаты InvokeScript транзакций (зависит от конфигурации ноды), то их можно извлекать для проверок:
String txId = bob.invokes(i -> i.dApp(alice)).getId().toString();
StateChanges changes = node().api.stateChanges(txId);
assertAll(
() -> assertEquals(1, changes.data.size()),
() -> assertEquals(bob.address(), changes.data.get(0).key),
() -> assertEquals(100500, changes.data.get(0).asInteger())
);
Если по сценарию нода должна отклонить конкретную транзакцию и вернуть ошибку, т.е. транзакция не должна попасть в блокчейн, то это можно проверить, перехватив исключение NodeError
.
Например, как это может выглядеть с JUnit 5:
NodeError error = assertThrows(NodeError.class, () ->
bob.invokes(i -> i.dApp(alice).function("deposit").payment(500, assetId))
);
assertTrue(error.getMessage().contains("can accept payment in waves tokens only!"));
Paddle позволяет ждать, пока высота блокчейна вырастет на заданное количество блоков:
node().waitNBlocks(2);
или пока блокчейн достигнет конкретной высоты:
node().waitForHeight(100);
Оба метода используют “мягкие” ожидания. Это означает, что они продолжают ждать, пока высота растет с ожидаемой частотой. Ожидаемая частота равна утроенному значению параметра block-interval
в конфиге Paddle, но может быть изменена или переопределена вторым аргументом:
node().waitNBlocks(2, waitingInSeconds);
node().waitForHeight(100, waitingInSeconds);
Также Paddle позволяет ждать, пока транзакция с заданным id не попадет в блокчейн:
node().waitForTransaction(txId);
По умолчанию время ожидания транзакции равно значению параметра block-interval
в конфиге Paddle, но может быть изменено или переопределено вторым аргументом:
node.waitForTransaction(txId, waitingInSeconds);
Чтобы сократить время выполнения теста (или из других соображений для специфических кейсов) иногда необходимо выполнять какие-то действия асинхронно.
Например, по сценарию нужно создать несколько тестовых аккаунтов, и каждый из них должен выпустить свой токен:
Account alice = new Account(1_00000000);
Account bob = new Account(1_00000000);
Account carol = new Account(1_00000000);
alice.issues(a -> a.name("Asset 1"));
bob.issues(a -> a.name("Asset 2"));
carol.issues(a -> a.name("Asset 3"));
Эти 6 транзакций мало зависят друг от друга, но всё равно будут выполнены последовательно и с ожиданием попадания очередной транзакции в блокчейн. Но с методом Async.async()
это можно выполнить асинхронно и в три раза быстрее:
async(
() -> {
Account alice = new Account(1_00000000);
alice.issues(a -> a.name("Asset 1"));
}, () -> {
Account bob = new Account(1_00000000);
bob.issues(a -> a.name("Asset 2"));
}, () -> {
Account carol = new Account(1_00000000);
carol.issues(a -> a.name("Asset 3"));
}
);
Теперь операции будут выполняться в три потока, а последовательно будут отправляться только зависимые транзакции.
Async
метод завершает работу, только когда все потоки завершены.
Функция rsaVerify()
в Ride проверяет, что RSA подпись валидна, т.е. была создана владельцем публичного ключа.
Paddle позволяет создавать такую подпись:
Rsa rsa = new Rsa(); // сгенерированная пара приватного и публичного ключей
byte[] prKey = rsa.privateKey();
byte[] pubKey = rsa.publicKey();
// подпись, созданная этим приватным ключом. Данные хешируются по алгоритму SHA256
byte[] signature = rsa.sign(HashAlg.SHA256, "Hello!".getBytes());
HashAlg
содержит все алгоритмы хеширования, поддерживаемые в Ride:
Функция checkMerkleProof()
в Ride проверяет, что данные являются частью дерева Меркла.
Paddle позволяет создать дерево Меркла по алгоритму, поддерживаемому в Ride, и получить пруфы для данных в этом дереве:
List<byte[]> leafs = asList("one".getBytes(), "two".getBytes(), "three".getBytes());
MerkleTree tree = new MerkleTree(leafs);
byte[] rootHash = tree.rootHash();
byte[] proof = tree.proofByLeaf("two".getBytes()).get();
Ознакомьтесь с тестами в этом репозитории. В них есть примеры, как можно использовать Paddle.
А также в GitHub-шаблоне paddle-example есть пример dApp, покрытый тестами на Paddle.