Skip to main content

Testing Aztec.nr contracts with TypeScript

In this guide we will cover how to interact with your Aztec.nr smart contracts in a testing environment to write automated tests for your apps.

Prerequisites

Create TS file and install libraries

Pick where you'd like your tests to live and create a Typescript project.

You will need to install Aztec.js:

yarn add @aztec/aztecjs

You can use aztec.js to write assertions about transaction statuses, about chain state both public and private, and about logs.

Import relevant libraries

Import aztecjs. This is an example of some functions and types you might need in your test:

imports
import { createAccount, getDeployedTestAccountsWallets } from '@aztec/accounts/testing';
import { type AccountWallet, CheatCodes, Fr, type PXE, TxStatus, createPXEClient, waitForPXE } from '@aztec/aztec.js';
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L1-L4

You should also import the Typescript class you generated:

import_contract
import { TestContract } from '@aztec/noir-contracts.js/Test';
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L5-L7

Create a PXE client

Currently, testing Aztec.nr smart contracts means testing them against the PXE that runs in the local sandbox. Create a PXE client:

create_pxe_client
const pxe = createPXEClient(PXE_URL);
await waitForPXE(pxe);
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L18-L21

and use the accounts that are initialized with it:

use-existing-wallets
pxe = createPXEClient(PXE_URL);
[owner, recipient] = await getDeployedTestAccountsWallets(pxe);
token = await TokenContract.deploy(owner, owner.getCompleteAddress(), 'TokenName', 'TokenSymbol', 18)
.send()
.deployed();
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L57-L63

Alternatively, you can create a new account..

Write tests

Calling and sending transactions

You can send transactions within your tests with Aztec.js. Read how to do that in these guides:

Using debug options

You can use the debug option in the wait method to get more information about the effects of the transaction. This includes information about new note hashes added to the note hash tree, new nullifiers, public data writes, new L2 to L1 messages, new contract information, and newly visible notes.

This debug information will be populated in the transaction receipt. You can log it to the console or use it to make assertions about the transaction.

debug
const tx = await asset.methods.transfer(accounts[1].address, totalBalance).send().wait({ debug: true });
Source code: yarn-project/end-to-end/src/e2e_token_contract/private_transfer_recursion.test.ts#L39-L41

You can also log directly from Aztec contracts. Read this guide for some more information.

Examples

A private call fails

We can check that a call to a private function would fail by simulating it locally and expecting a rejection. Remember that all private function calls are only executed locally in order to preserve privacy. As an example, we can try transferring more tokens than we have, which will fail an assertion with the Balance too low error message.

local-tx-fails
const call = token.methods.transfer(recipient.getAddress(), 200n);
await expect(call.prove()).rejects.toThrow(/Balance too low/);
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L148-L151

Under the hood, the send() method executes a simulation, so we can just call the usual send().wait() to catch the same failure.

local-tx-fails-send
const call = token.methods.transfer(recipient.getAddress(), 200n);
await expect(call.send().wait()).rejects.toThrow(/Balance too low/);
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L155-L158

A transaction is dropped

We can have private transactions that work fine locally, but are dropped by the sequencer when tried to be included due to a double-spend. In this example, we simulate two different transfers that would succeed individually, but not when both are tried to be mined. Here we need to send() the transaction and wait() for it to be mined.

tx-dropped
const call1 = token.methods.transfer(recipient.getAddress(), 80n);
const call2 = token.methods.transfer(recipient.getAddress(), 50n);

const provenCall1 = await call1.prove();
const provenCall2 = await call2.prove();

await provenCall1.send().wait();
await expect(provenCall2.send().wait()).rejects.toThrow(/dropped/);
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L162-L171

A public call fails locally

Public function calls can be caught failing locally similar to how we catch private function calls. For this example, we use a TokenContract (GitHub link) instead of a private one.

local-pub-fails
const call = token.methods.transfer_in_public(owner.getAddress(), recipient.getAddress(), 1000n, 0);
await expect(call.prove()).rejects.toThrow(U128_UNDERFLOW_ERROR);
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L175-L178

A public call fails on the sequencer

We can ignore a local simulation error for a public function via the skipPublicSimulation. This will submit a failing call to the sequencer, who will include the transaction, but without any side effects from our application logic. Requesting the receipt for the transaction will also show it has a reverted status.

pub-reverted
const call = token.methods.transfer_in_public(owner.getAddress(), recipient.getAddress(), 1000n, 0);
const receipt = await call.send({ skipPublicSimulation: true }).wait({ dontThrowOnRevert: true });
expect(receipt.status).toEqual(TxStatus.APP_LOGIC_REVERTED);
const ownerPublicBalanceSlot = cheats.aztec.computeSlotInMap(
TokenContract.storage.public_balances.slot,
owner.getAddress(),
);
const balance = await pxe.getPublicStorageAt(token.address, ownerPublicBalanceSlot);
expect(balance.value).toEqual(100n);
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L182-L192
WARN Error processing tx 06dc87c4d64462916ea58426ffcfaf20017880b353c9ec3e0f0ee5fab3ea923f: Assertion failed: Balance too low.

Querying state

We can check private or public state directly rather than going through view-only methods, as we did in the initial example by calling token.methods.balance().simulate().

To query storage directly, you'll need to know the slot you want to access. This can be checked in the contract's Storage definition directly for most data types. However, when it comes to mapping types, as in most EVM languages, we'll need to calculate the slot for a given key. To do this, we'll use the CheatCodes utility class:

calc-slot
cheats = await CheatCodes.create(ETHEREUM_HOST, pxe);
// The balances mapping is indexed by user address
ownerSlot = cheats.aztec.computeSlotInMap(TokenContract.storage.balances.slot, ownerAddress);
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L100-L104

Querying private state

Private state in the Aztec is represented via sets of private notes. We can query the Private Execution Environment (PXE) for all notes encrypted for a given user in a contract slot. For example, this gets all notes encrypted for the owner user that are stored on the token contract address and on the slot that was calculated earlier. To calculate the actual balance, it extracts the value of each note, which is the first element, and sums them up.

private-storage
await token.methods.sync_notes().simulate();
const notes = await pxe.getIncomingNotes({
owner: owner.getAddress(),
contractAddress: token.address,
storageSlot: ownerSlot,
scopes: [owner.getAddress()],
});
const values = notes.map(note => note.note.items[0]);
const balance = values.reduce((sum, current) => sum + current.toBigInt(), 0n);
expect(balance).toEqual(100n);
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L108-L119

Querying public state

Public state behaves as a key-value store, much like in the EVM. We can directly query the target slot and get the result back as a buffer. Note that we use the TokenContract (GitHub link) in this example, which defines a mapping of public balances on slot 6.

public-storage
await token.methods.mint_to_public(owner.getAddress(), 100n).send().wait();
const ownerPublicBalanceSlot = cheats.aztec.computeSlotInMap(
TokenContract.storage.public_balances.slot,
owner.getAddress(),
);
const balance = await pxe.getPublicStorageAt(token.address, ownerPublicBalanceSlot);
expect(balance.value).toEqual(100n);
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L123-L131

Logs

You can check the logs of events emitted by contracts. Contracts in Aztec can emit both encrypted and unencrypted events.

Querying unencrypted logs

We can query the PXE for the unencrypted logs emitted in the block where our transaction is mined. Logs need to be unrolled and formatted as strings for consumption.

unencrypted-logs
const value = Fr.fromString('ef'); // Only 1 bytes will make its way in there :( so no larger stuff
const tx = await testContract.methods.emit_unencrypted(value).send().wait();
const filter = {
fromBlock: tx.blockNumber!,
limit: 1, // 1 log expected
};
const logs = (await pxe.getUnencryptedLogs(filter)).logs;
expect(Fr.fromBuffer(logs[0].log.data)).toEqual(value);
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L135-L144

Cheats

The CheatCodes class, which we used for calculating the storage slot above, also includes a set of cheat methods for modifying the chain state that can be handy for testing.

Set next block timestamp

Since the rollup time is dependent on what "slot" the block is included in, time can be progressed by progressing slots. The duration of a slot is available by calling SLOT_DURATION() on the Rollup (code in Leonidas.sol).

You can then use the warp function on the EthCheatCodes to progress the underlying chain.

Further reading