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,
ExtendedNote,
Fr,
Note,
type PXE,
TxStatus,
computeSecretHash,
createPXEClient,
waitForPXE,
} from '@aztec/aztec.js';
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L1-L15
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#L16-L18
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#L28-L31
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#L81-L87
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 receiptClaim = await txClaim.wait({ debug: true });
Source code: yarn-project/end-to-end/src/e2e_token_contract/minting.test.ts#L87-L89
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#L216-L219
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#L223-L226
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);
await call1.prove();
await call2.prove();
await call1.send().wait();
await expect(call2.send().wait()).rejects.toThrow(/dropped/);
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L230-L239
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
instead of a private one.
const call = token.methods.transfer_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#L243-L246
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_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(6n, 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#L250-L257
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 = 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#L173-L177
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.
const notes = await pxe.getIncomingNotes({
owner: owner.getAddress(),
contractAddress: token.address,
storageSlot: ownerSlot,
});
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#L181-L190
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
in this example, which defines a mapping of public balances on slot 6.
await token.methods.mint_public(owner.getAddress(), 100n).send().wait();
const ownerPublicBalanceSlot = cheats.aztec.computeSlotInMap(6n, 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#L194-L199
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.
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#L203-L212
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
The warp
method sets the time for next execution, both on L1 and L2. We can test this using an isTimeEqual
function in a Test
contract defined like the following:
#[aztec(public)]
fn is_time_equal(time: u64) -> u64 {
assert(context.timestamp() == time);
time
}
Source code: noir-projects/noir-contracts/contracts/test_contract/src/main.nr#L336-L342
We can then call warp
and rely on the isTimeEqual
function to check that the timestamp was properly modified.
const newTimestamp = Math.floor(Date.now() / 1000) + 60 * 60 * 24;
await cheats.aztec.warp(newTimestamp);
await testContract.methods.is_time_equal(newTimestamp).send().wait();
Source code: yarn-project/end-to-end/src/guides/dapp_testing.test.ts#L128-L132