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
- A compiled contract with TS interface (read how to compile)
- Your sandbox running (read getting started)
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:
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 { 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:
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:
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.
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.
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.
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.
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.
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.
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:
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.
await token.methods.sync_notes().simulate();
const notes = await pxe.getNotes({
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