Leo is a programming language designed for writing scalable, verifiable, and auditable programs for the private-by-default Aleo network: testing is a crucial component in achieving these aims. The Leo environment, however, presents some hurdles that make testing Leo code less straightforward than in more mainstream programming language environments.
Enter DokoJS, a lightweight testing framework that sands down the rough edges around writing unit tests against Leo code: it makes the testing workflow as simple as testing JavaScript. The rest of this post will walk through the process of testing a Leo program using DokoJS.
This post assumes familiarity with Leo syntax: you should ideally have perused the Leo language reference beforehand.
This post also assumes familiarity with JavaScript and common unit testing conventions and patterns.
Challenges Around Testing Leo Code
Before diving in, it’s important to understand what problems DokoJS addresses. The thing is, Leo is not a general-purpose programming language. It is not Turing-complete, and its syntax must maintain semantic equivalence with the underlying zkSNARK output of the compiler. Indeed, it is a language designed specifically for writing decentralized applications that live and run on the Aleo network. Building testing capabilities directly into the Leo language and its compiler would run counter to these purposes, i.e., we cannot write tests for Leo code in Leo itself.
DokoJS addresses this limitation by generating and exposing interfaces that allow developers to programmatically interact with Leo code using JavaScript. At that point, developers are able to write tests against Leo code just like how they would write tests against JavaScript, with all of the streamlined developer experience niceties that come with such a mature testing ecosystem. With that said, let’s take a look at DokoJS in action!
Getting Started with DokoJS
Install Prerequisites
First off, make sure that Leo is already installed on your machine. If it isn’t, refer to these instructions for installing it and its dependencies. You’ll also need npm
installed for the purposes of this walkthrough; you can refer here if you need to grab it. For testing purposes, you’ll also need a local testnet setup. For this, we recommend Amareleo, which is a light, fast, and easy to use Aleo development node: refer here for installation instructions.
Install DokoJS Itself
As for DokoJS itself, you can opt to either install it via npm
by running npm install -g @doko-js/cli@latest
, or building it from source. Note that building from source will also require pnpm
, which you can install following this guide if you need to do so.
# Clone the git repository
git clone https://github.com/venture23-aleo/doko-js
cd doko-js
# Install the dependencies
pnpm install
# Build the project
npm run build
# Install the DokoJS CLI
npm run install:cli
Initializing a New Project
We can now initialize a new project called token
by running dokojs init token
.
Let’s highlight a few of the directories and files contained in the project we just initialized:
-
contract/
: This directory containsbase-contract.ts
, which defines aBaseContract
class that exposes methods that we’ll need in order to test our Leo programs. -
programs/
: This directory holds the Leo programs we’ll be testing. -
test/
: This directory holds the JavaScript/TypeScript tests written against our Leo programs.
For this walkthrough, we’ll be testing a modified version of the Token program that can be found on the Leo playground. It will require us to get familiar with how to test both public and private transactions and operations. Navigate to the playground link, and then copy and paste the playground code into the token.leo
file in the programs/
directory.
Configuring DokoJS
Before continuing on, we should familiarize ourselves with how DokoJS is configured through the aleo-config.js
file at the root of the project. The default config file looks like this:
import dotenv from 'dotenv';
dotenv.config();
export default {
accounts: [process.env.ALEO_PRIVATE_KEY],
mode: 'execute',
mainnet: {},
networks: {
testnet: {
endpoint: 'http://localhost:3030',
accounts: [process.env.ALEO_PRIVATE_KEY_TESTNET3,
process.env.ALEO_DEVNET_PRIVATE_KEY2]
priorityFee: 0.01
},
mainnet: {
endpoint: 'https://api.explorer.aleo.org/v1',
accounts: [process.env.ALEO_PRIVATE_KEY_MAINNET],
priorityFee: 0.001
}
},
defaultNetwork: 'testnet'
};
This config sets up Aleo accounts associated with private keys exposed through a .env
file that comes with your newly-initialized DokoJS project, so no need to go set up your own .env
file. It sets up testnet and mainnet networks (we’ll be making exclusive use of the testnet) and also declares the testing mode to be one of two possible types: execute
for if we want to generate and broadcast proofs on-chain, and evaluate
for if we want to skip proof generation during testing. For this walkthrough, we won’t be changing anything in this file, and will be making use of the accounts that are already configured.
Generating Leo-to-JS Bindings
With our token.leo
program located in the programs/
directory, we now need to generate the bindings that will allow us to interact with our Leo program using JavaScript code. To do that, run dokojs compile
. This command creates and populates an artifacts/
directory with the generated Leo-to-JS, as well as JS-to-Leo, type conversions. Note that whenever we make changes to our Leo program over the course of testing, we’ll need to re-compile these bindings.
Walking Through Our Leo Program
Type Definitions
At this point, let’s go through our token.leo
program in preparation for writing tests against it. At the top of the file we have the following:
program token.aleo {
mapping account: address => u64;
record token {
owner: adddress,
balance: u64,
}
...
We start off by declaring the program ID, token.aleo
, as well as defining a mapping and a record. Mappings in Leo define on-chain state in the form of key-value pairs: we’ll be using this account mapping to associate Aleo account addresses with token amounts in the form of u64
s.
Next up, we have the token
record, which will be used as a receipt for confirming private transactions. Each record keeps track of the token owner’s address, as well as their token balance. Because we did not declare the fields of the token
record to be public
, they are private by default. Think of it like this: the mapping type keeps track of all public state changes and transactions while the record type confirms all private transactions.
Minting Functions
Next up we have functions and transitions for performing public and private mint transactions:
async transition mint_pub(public receiver: address, public amount: u64) -> Future {
return finalize_increment_account(receiver, amount);
}
async function finalize_increment_account(public addr: address, public amount: u64) {
let addr_amount: u64 = Mapping::get_or_use(account, addr, 0u64);
Mapping::set(account, addr, addr_amount + amount);
}
transition mint_priv(receiver: address, amount: u64) -> token {
return token {
owner: receiver,
balance: amount,
};
}
The mint_priv
transition function simply returns a new token record indicating that the receiver
's balance
has been set to the input amount
.
The mint_pub
transition calls finalize_increment_account
, which fetches the account
mapping associated with the addr
address if it already exists, or initializes it with a balance of 0 if it doesn’t. This avoids the possibility of this transaction failing due to a non-existent account. The receiver
s account
mapping is then incremented by the specified amount
.
Leo’s Async Programming Model
Let’s make note of some of the distinctive differences between how the public logic is written compared to how the private logic is written. First off, the
mint_priv
transition is not marked asasync
: due to the private nature of this transaction, it is not making any on-chain state changes, only returning an off-chain record that will not be visible on-chain. Conversely, themint_pub
transition is marked asasync
precisely because it calls anotherasync
function.finalize_increment_account
must itself be marked asasync
because it is performing some on-chain state change.Note as well that the calling
async transition
must explicitly return aFuture
: when themint_pub
transition is called, the returnedFuture
must be awaited to be resolved, similar to how promises in JavaScript are awaited and resolved when executing asynchronous code.But wait a minute, why do we need to define an
async transition
in the first place? Why don’t we just have theasync function
on its own, since it is what is performing the actual logic? This is because onlytransition
s can be exported and called outside of the defining program. Leofunction
s cannot be exported or called externally, they exist purely for encapsulating and abstracting logic. So we’ll need this functionality to be exposed by defining it within atransition
that we’ll then be able to call externally in order to test its behavior.Rest assured, we’ll be seeing much more of this pattern later on in our program.
Transfer Functions
Now that we have the ability to publicly and privately mint tokens, let’s take a look at the logic for transferring them. transfer_pub
accesses self.caller
's account
mapping and decrements their token balance by amount
. Simultaneously, it accesses receiver
's account mapping and increments their token balance by that same amount
.
In a similar vein,transfer_priv
produces and returns two token
records, one for the sender
, whose balance has been decremented by amount
, and one for the receiver
, whose balance has been set to that amount
:
async transition transfer_pub(public receiver: address, public amount: u64) -> Future {
return finalize_transfer_pub(self.caller, receiver, amount);
}
async function finalize_transfer_pub(public sender: address, public receiver: address, public amount: u64) {
let sender_amount: u64 = Mapping::get_or_use(account, sender, 0u64);
Mapping::set(account, sender, sender_amount - amount);
let receiver_amount: u64 = Mapping::get_or_use(account, receiver, 0u64);
Mapping::set(account, receiver, receiver_amount + amount);
}
transition transfer_priv(sender: token, receiver: address, amount: u64) -> (token, token) {
let difference: u64 = sender.balance - amount;
let remaining: token = token {
owner: sender.owner,
balance: difference,
};
let transferred: token = token {
owner: receiver,
balance: amount,
};
return (remaining, transferred);
}
Indeed, the transfer functions adhere to the same async pattern that we saw with the mint functions: transfer_pub
is marked as async
, it calls an async function finalize_transfer_pub
which performs the on-chain state changes, and it returns a Future
for awaiting and resolving the asynchronous execution.
transfer_priv
does none of this, eschewing asynchronous behavior and instead opting to perform all of its logic off-chain in order to preserve privacy.
The observant reader will notice that we defined a separate
finalize_transfer_pub
function that is called bytransfer_pub
as opposed to havingtransfer_pub
call ourfinalize_increment_account
andfinalize_decrement_account
. This is done for a few reasons:
- We cannot call both
finalize_increment_account
andfinalize_decrement_account
intransfer_pub
due to the fact thatasync transition
s can only return at most oneFuture
; we cannot return something like(Future, Future)
currently, though we would need to if we wanted to call twoasync function
s within anasync transition
.- We cannot call both
finalize_increment_account
andfinalize_decrement_account
infinalize_transfer_pub
either, due to the fact that Leo requiresasync function
s to be called fromasync transition
s. Doing it this way does result in some code duplication, but given Leo’s rules around its async programming model, this is how our code must be organized.
Public-to-Private and Private-to-Public Transfers
Lastly, we have logic for performing a public-to-private transfer, as well as the inverse, a private-to-public transfer. Let’s take a look at the former first:
async transition transfer_pub_to_priv(public sender: address, public receiver: address, public amount: u64) -> (token, Future) {
let transferred: token = token {
owner: receiver,
balance: amount,
};
return (transferred, finalize_decrement_account(sender, amount));
}
We can see that this functionality is an amalgamation of the public and private logic and patterns we’ve been seeing thus far. A private token record is created representing the transfer from the receiver
’s perspective. Additionally, the sender
's account mapping is fetched and their balance is decremented by the sent amount
. On-chain, we would see that the sender performed a transfer operation, along with the amount that was transferred out of their account, but we would not see anything about the receiving account on the other side.
Finally, the inverse transfer_priv_to_pub
function does the opposite:
async transition transfer_priv_to_pub(sender: token, public receiver: address, public amount: u64) -> (token, Future) {
assert_eq(self.caller, sender.owner);
let difference: u64 = sender.balance - amount;
let remaining: token = token {
owner: sender.owner,
balance: difference,
};
return (remaining, finalize_increment_account(receiver, amount));
}
It creates a token
record for the sender
, reflecting their decremented account balance. It also updates the receiver
's account
mapping with their incremented balance. On-chain, we’d see that the receiver received an injection of tokens, but not the source of those tokens.
Test Helpers
We’ll also need to add a transition function for testing purposes:
async transition reset_account(public addr: address) -> Future {
return finalize_reset_account(addr);
}
async function finalize_reset_account(public addr: address) {
Mapping::set(account, addr, 0u64);
}
At this point, this code should be pretty self-explanatory. We’ll need these functions in order to reset the state of our account
mappings back to 0 after every test, so that every test run starts with a clean slate.
Our Leo program is now complete! The next step is to call dokojs compile
to generate the necessary bindings and types that we’ll need in order to call our Leo functions from JavaScript.
Writing Our Tests
Phew! With all that preamble out of the way, we are now finally ready to write tests for our token program with DokoJS.
We’ll start with a blank token.test.ts
file in the test/
directory.
Setting Up Dependencies, Constants, and Helpers
We’ll add the following at the top of token.test.ts
:
import { ExecutionMode } from '@doko-js/core';
import { TokenContract } from '../artifacts/js/token';
import { decrypttoken } from '../artifacts/js/leo2js/token';
const TIMEOUT = 200_000;
const mode = ExecutionMode.SnarkExecute;
const contract = new TokenContract({ mode });
const [admin, recipient] = contract.getAccounts();
We define a TIMEOUT
constant as an upper bound for how long our tests can run. We also instantiate a TokenContract
with our choice of mode, ExecutionMode
in this case, as per what we specified in our aleo-config.js
file. The TokenContract
exposes the transition
functions defined in our Leo program (and only the transition
functions), as well as many other helpful functions we’ll need throughout the course of our testing; indeed, this type is the lynchpin upon which our entire testing strategy relies on. The decrypttoken
function will be used to decrypt encrypted token records, which we’ll need in order to test our private transition
functions. We also initialize two accounts, admin
and recipient
, that are associated with two of the private keys specified in aleo-config.js
: these will act as our sender and receiver accounts throughout our testing flows.
Next, we’ll define beforeAll
and beforeEach
functions:
beforeAll(async () => {
const tx = await contract.deploy();
await tx.wait();
}, TIMEOUT);
beforeEach(async () => {
const resetAdmin = await contract.reset_account(admin);
const resetRecipient = await contract.reset_account(recipient);
await resetAdmin.wait();
await resetRecipient.wait();
}, TIMEOUT);
describe('tests', () => {
...
If you’re familiar with JavaScript testing practices, you’ll know that beforeAll
runs once before the test suite is run, so it’s the perfect place for adding any one-time initialization logic that the tests may need. Here, we’re deploying our Leo program, via contract.deploy
, to our testnet that will be accessible on localhost:3030. Like other asynchronous code that we’ll be executing, contract.deploy
returns a Future
that we must await
on for it to resolve.
We also define a beforeEach
helper, which runs before every test, ensuring that our admin
and recipient
accounts always start with a balance of 0; this is so that we won’t have to deal with inconsistent account state due to a previous test manipulating said state.
Testing Public Minting
When it comes to testing public minting functionality, we’ll simply mint some amount to an account, and then assert that that account’s balance equals the amount we minted:
test('public mint', async () => {
const actualAmount = BigInt(300000);
const mintTx = await contract.mint_pub(admin, actualAmount);
await mintTx.wait();
const expected = await contract.account(admin);
expect(expected).toBe(actualAmount);
}, TIMEOUT);
We initialize an actualAmount
value, passing it to contract.mint_pub
along with the admin
account address. Our mint_pub
code should then increment admin
's account mapping by actualAmount
. We then read the value of admin
's account balance via a call to contract.account
, passing in the address of the mapping we want to fetch. We then assert that the balance we got back matches the amount we minted, actualAmount
.
Now that we have our first official test, let’s make sure it passes.
Running Our Tests
The first thing we’ll need to do before we can run our tests is start up our amareleo
node in a separate terminal instance. Without performing this step, if you try to run the tests, you should see that you get a DokoJSError: DOKOJS105: Deployment check failed
error due to localhost:3030 not actively listening for our requests. Starting the amareleo
node with amareleo-chain start
will alleviate this problem.
Once the amareleo
node is running and localhost:3030 is listening for our requests, run the test suite with npm run test
. You should see our single test asserting that our public minting functionality works as intended passes. If it doesn’t, make sure that you don’t have any typos or inconsistencies in your code before trying again.
Before re-running the test suite again, you’ll need to cancel and restart the
amareleo
node process as well.
Testing Private Minting
Testing our private functions requires a little bit more effort than testing our public functions. Chiefly, we can’t just inspect public state by fetching account mappings. Instead, we’ll have to decrypt token records before we can inspect and assert their state. Our private mint test looks like this:
test('private mint', async () => {
const actualAmount = BigInt(100000);
const tx = await contract.mint_priv(recipient, actualAmount);
const [record1] = await tx.wait();
const recipientKey = contract.getPrivateKey(recipient);
const decryptedRecord = decrypttoken(record1, recipientKey);
expect(decryptedRecord.owner).toBe(recipient);
expect(decryptedRecord.balance).toBe(actualAmount);
}, TIMEOUT);
Our first deviation from our public mint test can be found right after calling contract.mint_priv
: we're getting back an encrypted token record from this call. In order to check that this token record is as we expect, we’ll need to decrypt it by fetching the recipient
account’s view key by calling contract.getPrivateKey
. We then pass this view key, along with the encrypted record, to the decrypttoken
function, which decrypts it, allowing us to now assert that the owner
and balance
are what we expect them to be.
Testing Public Transfer
Now that we’re confident our minting functionality works, we can test our transfer functionality, starting with the public version. The idea of our transfer tests is to mint some tokens to an account, and then transfer some of those tokens to another account. At that point, we’ll assert that the sender’s account balance is the amount we minted for them less the amount that they transferred, and that the receiver’s account balance is the amount that was transferred to them:
test('public transfer', async () => {
const amount1 = BigInt(100000);
const amount2 = BigInt(30000);
const mintTx = await contract.mint_pub(admin, amount1);
await mintTx.wait();
let adminAmount = await contract.account(admin);
expect(adminAmount).toBe(amount1);
const transferTx = await contract.transfer_pub(recipient, amount2);
await transferTx.wait();
adminAmount = await contract.account(admin);
const recipientAmount = await contract.account(recipient);
expect(adminAmount).toBe(amount1 - amount2);
expect(recipientAmount).toBe(amount2);
}, TIMEOUT);
Hopefully you’re starting to get the hang of the patterns and concepts that we’re seeing in the tests; there shouldn’t be anything new here that we haven’t seen already.
Testing Private Transfer
The test for our private transfer functionality follows the same general idea as our public transfer test. Again, the only additional wrinkle is working with encrypted token records that we’ll need to decrypt, just like how we did it in our private mint test:
test('private transfer', async () => {
const adminKey = contract.getPrivateKey(admin);
const recipientKey = contract.getPrivateKey(recipient);
const amount1 = BigInt(1000000000);
const amount2 = BigInt(100000000);
const mintTx = await contract.mint_priv(admin, amount1);
const [encryptedToken] = await mintTx.wait();
const decryptedRecord = decrypttoken(encryptedToken, adminKey);
const transferTx = await contract.transfer_priv(decryptedRecord, recipient, amount2);
const [encryptedAdminRecord, encryptedRecipientRecord] = await transferTx.wait();
const adminRecord = decrypttoken(encryptedAdminRecord, adminKey);
const recipientRecord = decrypttoken(encryptedRecipientRecord, recipientKey);
expect(adminRecord.owner).toBe(admin);
expect(adminRecord.balance).toBe(amount1 - amount2);
expect(recipientRecord.owner).toBe(recipient);
expect(recipientRecord.balance).toBe(amount2);
}, TIMEOUT);
Note that we need to decrypt the token record that contract.mint_priv
returns before we’re able to pass it as an input to transfer_priv
. From there, we get back two more encrypted token records, one representing the sender’s view of the transaction, the other representing the receiver’s view of the same transaction
Testing Public-to-Private Transfer
Now that we’ve seen how to test both public and private transfers, testing public-to-private and private-to-public transfers will simply be an amalgamation of the same techniques we’ve been using:
test('public to private transfer', async () => {
const recipientKey = contract.getPrivateKey(recipient);
const amount1 = BigInt(500000);
const amount2 = BigInt(100000);
const mintTx = await contract.mint_pub(admin, amount1);
await mintTx.wait();
let adminAmount = await contract.account(admin);
expect(adminAmount).toBe(amount1);
const transferTx = await contract.transfer_pub_to_priv(admin, recipient, amount2);
const [record] = await transferTx.wait();
adminAmount = await contract.account(admin);
const decryptedRecord = decrypttoken(record, recipientKey);
expect(adminAmount).toBe(amount1 - amount2);
expect(decryptedRecord.owner).toBe(recipient);
expect(decryptedRecord.balance).toBe(amount2);
}, TIMEOUT);
With the public-to-private transfer, the sender’s view of the transaction is public, while the recipient’s view of it is private. So we’ll only need to get the recipient’s view key, as only their parts of the transaction will need to be decrypted.
Testing Private-to-Public Transfer
And finally, the test for the private-to-public transfer will be the inverse of the public-to-private transfer test:
test('private to public transfer', async () => {
const adminKey = contract.getPrivateKey(admin);
const amount1 = BigInt(600000);
const amount2 = BigInt(500000);
const mintTx = await contract.mint_priv(admin, amount1);
const [encryptedToken] = await mintTx.wait();
const decryptedToken = decrypttoken(encryptedToken, adminKey)
expect(decryptedToken.balance).toBe(amount1);
const transferTx = await contract.transfer_priv_to_pub(decryptedToken, recipient, amount2);
const [record] = await transferTx.wait();
const recipientAmount = await contract.account(recipient);
const decryptedRecord = decrypttoken(record, adminKey);
expect(recipientAmount).toBe(amount2);
expect(decryptedRecord.owner).toBe(admin);
expect(decryptedRecord.balance).toBe(amount1 - amount2);
}, TIMEOUT);
The sender’s view of the transfer is private, so we’ll need their view key to be able to decrypt their part of the transaction. The receiver’s view of the transfer is public, and so can be asserted as such.
In Summary
And there we have it! We’ve written a pretty comprehensive set of tests for our token program. If you made it this far, you should have a solid grasp of the basics when it comes to testing Leo programs with DokoJS. Even for such a simple token program as this one, there’s a lot more edge cases that we could test. For example, we could write an additional test asserting that transfer_priv_to_pub
throws an error when it is called by an account other than the sender of the transaction. But we’ll leave that as an exercise for the reader!
While writing programmatic unit and integration tests using a testing framework like DokoJS is an extremely powerful technique for building confidence in our code’s correctness, it has its limitations (like being able to only directly test Leo transition
s); it should not be the only testing tool in a developer’s toolbox. The Leo ecosystem makes available many other useful tools when it comes to testing, such as the Leo debugger for stepping through code execution, as well as the Aleoscan block explorer, which allows you to get a much more macro-level view of what happened when a transaction failed. It behooves any developer aiming to develop private applications atop the Aleo network to get familiar and comfortable with utilizing all of these complementary testing tools in appropriate situations.
If you’re interested in taking a look at the accompanying project code, you can find it at this GitHub repo.
To bring it all back, DokoJS, by exposing Leo programs as JavaScript-consumable types and interfaces, makes testing accessible to any developer who is familiar with JS. At the end of the day, it offers a developer-friendly testing experience for those keen on building atop the Aleo network.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.