Awesome
Paddle for Waves
Paddle is a Java library to write tests for your dApps and other smart contracts on Waves blockchain.
Эта документация доступна на русском языке.
Table of Contents
- Getting started
- Test lifecycle
- Paddle configuration
- Node instance
- Account
- Assertions
- Other features
- What next?
Getting started
Requirements
- Java 11 or higher;
- Docker 17.03.1 or newer if you want to use Waves Node in Docker. On Windows, install the latest "Docker for Windows".
Installation
The easiest way to get started is to create a new project from the paddle-example GitHub template.
Or add Paddle as dependency to your existing project.
Maven
<dependency>
<groupId>im.mak</groupId>
<artifactId>paddle</artifactId>
<version>1.0.0-rc9</version>
</dependency>
Gradle
Groovy DSL:
implementation 'im.mak:paddle:1.0.0-rc9'
Kotlin DSL:
compile("im.mak:paddle:1.0.0-rc9")
Simple usage
import im.mak.paddle.Account;
public class Main {
public static void main(String[] args) {
// Create two accounts.
// At the time of creation first account,
// Paddle automatically downloaded and started Waves Node in Docker container!
Account alice = new Account(10_00000000); // account with 10 Waves
Account bob = new Account(); // account with no Waves
// Alice sends 3 Waves to Bob
// Paddle waits until the Transfer transaction appears in blockchain
alice.transfers(t -> t.to(bob).amount(3_00000000));
System.out.println( bob.balance() ); // 300000000
// When the program ends, Paddle will automatically turn off the Docker container
}
}
Example with JUnit 5
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));
}
}
Test lifecycle
Paddle is framework agnostic, i.e. Paddle can be used independently, or with JUnit, TestNG and any other test framework familiar to you.
But in general, any test consists of the following steps:
- run or connect to node;
- create test accounts;
- send some transactions;
- perform assertions;
- shutdown docker node, if it's been used in step 1.
Steps 1 and 5 are performed automatically by Paddle.
Paddle configuration
Paddle needs some Waves node to execute test scenarios. By default, Paddle starts a node in a Docker container, but it's possible to connect to any existing node.
The behaviour is set by parameters of selected profile, which can be declared in paddle.conf
file.
The file paddle.conf
can be created either in the project root or in the resources
folder.
Pre-configured profiles
Paddle has predefined profiles, that are enough for most purposes:
docker
- default profile. Paddle starts local node on port6869
and turns it down when tests are complete.
It uses the official image of private Waves node with clean blockchain and most frequent blocks generation. It allows to run your tests faster than in the Testnet or Mainnet.local
- Paddle connects to existing local node. For example, if docker container with a node is already started manually.stagenet
,testnet
,mainnet
- Paddle connects to a node in the specified Waves network.
Additionally, you must specify the seed of account which Waves will be taken to send transactions. This can be done through the launch command:
mvn test -Dpaddle.profile=testnet "-Dpaddle.testnet.faucet-seed=some seed text"
or set the seed in the filepaddle.conf
:
paddle.profile = testnet
paddle.testnet.faucet-seed = some seed text
Custom profiles
In the paddle.conf
you can create your own profiles:
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
}
In this example, when using my-profile
, Paddle will connect to an already running local node.
And with my-docker-profile
Paddle will launch a node container, because the docker-image
field is specified.
${paddle.my-profile}
means that my-docker-profile
is inherited from the my-profile
profile with additional parameters.
Note! Your custom docker image must expose port 6869
for REST API.
Run node in Docker
Default official image
If the docker
profile is selected, then to start the node, just create an account or refer to the node instance:
import im.mak.paddle.Account;
import static im.mak.paddle.Node.node;
Account alice = new Account(100_00000000); // account with initial balance
// OR
node().height();
Paddle will start the node automatically!
Paddle requires a faucet
account - this account is available as node().faucet()
field of the Node instance.
It's used as a bank for initial balances of other test accounts.
When creating any new account, Waves tokens for its initial balance are transferred from the faucet
account.
Paddle turns off the node container itself when all tests are finished. But if tests are interrupted urgently, then the container will most likely remain working, and you will have to turn it off yourself.
Node instance
node().chainId()
- chain id of used blockchain;node().height()
- current height of blockchain;node().compileScript()
- compile RIDE script;node().isSmart(assetOrAddress)
- returns true if asset or account is scripted;node().send(...)
- send transaction;node().api.assetDetails(assetId)
- information about issued asset;node().api.nft(address)
- list of NFT for specified account;node().api.stateChanges(invokeTxId)
- result of specified InvokeScript transaction.
Account
In Paddle, Account is an actor of your test. It has all the Waves account information and can send transactions.
To create new account:
Account alice = new Account(10_00000000L);
Optionally, you can specify seed phrase, otherwise it will be randomly generated.
Also optionally, you can set initial Waves balance, otherwise account will not have Waves tokens at start.
Technically, when you specify initial balance, "rich" account sends Transfer transaction to the created account.
Retrieving information about account
Account can provide seed phrase, private and public keys, address. Account can check if it's scripted:
alice.seed();
alice.privateKey();
alice.publicKey();
alice.address();
alice.isSmart();
Account can get Waves or asset balance and read entries from its data storage:
alice.balance(); // balance in Waves
alice.balance(assetId); // balance in some asset
alice.nft(); // list of non-fungible tokens on this account
alice.data(); // collection of all entries in account data storage
alice.dataByKey(key); // entry of unknown type by specified key
alice.dataBin(key); // binary entry by specified key
alice.dataBool(key); // boolean entry by specified key
alice.dataInt(key); // integer entry by specified key
alice.dataStr(key); // string entry by specified key
Signing data and sending transactions
Account can sign any bytes and send transactions:
alice.sign(tx.getBodyBytes());
alice.issues(...);
alice.setsScript(...);
alice.invokes(...);
// and etc...
Sending a transaction, you can specify only the fields important for your scenario - in most cases all other fields will be set by default or calculated automatically.
For example, you don't have to specify asset name and description for Issue transaction:
alice.issues(a -> a.quantity(1000).decimals(0));
// only the number of tokens and decimals are specified here
Also, transaction fee will be calculated automatically too! Exceptions at now: sponsored fee in tokens and InvokeScript transactions with smart assets in ScriptTransfers of dApp contract.
You don't need sign transactions explicitly - Paddle does it automatically.
You don't need wait when transactions will be added to blockchain - Paddle does it automatically.
Creating dApp and smart account
To create dApp or smart asset, you can provide script source from file:
alice.setsScript(s -> s.script(Script.fromFile("wallet.ride")));
alice.issues(a -> a.script(Script.fromFile("fixed-price.ride")));
Or set script code directly as string:
alice.setsScript(s -> s.script("sigVerify(tx.bodyBytes, tx.proofs[0], tx.senderPublicKey)"));
alice.issues(a -> a.script("true"));
In both cases Paddle will compile your script automatically.
Assertions
Transaction
Each account action returns an instance of the resulting transaction from blockchain.
String assetId = alice.issues(a -> a.quantity(1000)).getId().toString();
assertEquals(1000, alice.balance(assetId));
State changes of InvokeScript transaction
If connected node stores state changes of InvokeScript transactions (depends of the node config), you can check it:
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())
);
Transactions rejection
If you expect that some transaction will return error and will not be in blockchain, you can check it by catching the NodeError exception.
For example, how it can look with 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!"));
Other features
Waitings
Height
Paddle allows to wait for the growth of the height of N blocks
node().waitNBlocks(2);
or until the height of the blockchain reaches the specified
node().waitForHeight(100);
The both methods have "soft" timeouts. This means that they continue to wait until the height rises with the expected frequency. The expected frequency is a triple value of the block-interval
field in Paddle config file, but can be redefined by optional argument:
node().waitNBlocks(2, waitingInSeconds);
node().waitForHeight(100, waitingInSeconds);
Transaction
Also Paddle can wait until transaction with specified id is in blockchain:
node().waitForTransaction(txId);
The timeout value is specified in block-interval
field in Paddle config file, but can be redefined by optional argument.
Asynchronous actions
To save execution time (or for other reasons in specific cases) it would be great to be able to perform some actions asynchronously.
For example, we want create some test account and each of them will issue asset:
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"));
These 6 transactions will be sent consecutively. But with Async.async()
their asynchronous execution will be three times faster:
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"));
}
);
At now, all operations will be executed in three threads, and only dependent transactions will be sent consecutively.
Async
waits for all operations to be completed.
RSA Encryption
Function rsaVerify()
in Ride checks that the RSA signature is valid, i.e. it was created by the owner of the public key.
With Paddle you can create a signature like this:
Rsa rsa = new Rsa(); // generated private and public keys pair
byte[] prKey = rsa.privateKey();
byte[] pubKey = rsa.publicKey();
// signature created by the private key with SHA256 algorithm
byte[] signature = rsa.sign(HashAlg.SHA256, "Hello!".getBytes());
HashAlg
contains all hashing algorithms supported in Ride:
- NOALG
- MD5
- SHA1
- SHA224
- SHA256
- SHA384
- SHA512
- SHA3_224
- SHA3_256
- SHA3_384
- SHA3_512
Merkle Tree
Function checkMerkleProof()
in Ride checks that the data is part of the Merkle tree.
With Paddle you can create such a Merkle tree and get data proofs:
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();
What next?
See tests in the repository for examples how the Paddle can be used.
paddle-example - project boilerplate with example of Waves dApp and tests with Paddle.