DEV Community

Sean Chen
Sean Chen

Posted on

Testing Leo Programs with DokoJS

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
Enter fullscreen mode Exit fullscreen mode

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 contains base-contract.ts, which defines a BaseContract 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'
};
Enter fullscreen mode Exit fullscreen mode

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,
    }

    ...
Enter fullscreen mode Exit fullscreen mode

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 u64s.

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,
    };
}
Enter fullscreen mode Exit fullscreen mode

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 receivers 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 as async: 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, the mint_pub transition is marked as async precisely because it calls another async function. finalize_increment_account must itself be marked as async because it is performing some on-chain state change.

Note as well that the calling async transition must explicitly return a Future: when the mint_pub transition is called, the returned Future 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 the async function on its own, since it is what is performing the actual logic? This is because only transitions can be exported and called outside of the defining program. Leo functions 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 a transition 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);
}
Enter fullscreen mode Exit fullscreen mode

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 by transfer_pub as opposed to having transfer_pub call our finalize_increment_account and finalize_decrement_account. This is done for a few reasons:

  1. We cannot call both finalize_increment_account and finalize_decrement_account in transfer_pub due to the fact that async transitions can only return at most one Future; we cannot return something like (Future, Future)currently, though we would need to if we wanted to call two async functions within an async transition.
  2. We cannot call both finalize_increment_account and finalize_decrement_account in finalize_transfer_pub either, due to the fact that Leo requires async functions to be called from async transitions. 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));
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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);
}

Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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', () => {
    ...
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 transitions); 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.