Testing Contracts in the TXE
Aztec contracts can be tested in a variety of ways depending on the needs of a particular application and the complexity of the interactions they must support.
To test individual contract functions, you can use the Testing eXecution Environment (TXE) described below. For more complex interactions that require checking that the protocol rules are enforced, you should write end-to-end tests using TypeScript.
Pure Noir tests
Noir supports the #[test]
annotation which can be used to write simple logic tests on isolated utility functions. These tests only make assertions on algorithms and cannot interact with protocol-specific constructs such as storage
or context
, but are extremely fast and can be useful in certain scenarios.
#[test]
fn test_to_from_field() {
let field = 1234567890;
let card = Card::from_field(field);
assert(card.to_field() == field);
}
Source code: noir-projects/noir-contracts/contracts/card_game_contract/src/cards.nr#L44-L51
To learn more about Noir testing, please refer to the Noir docs.
TXE (pronounced "trixie")
In order to interact with the protocol, Aztec contracts leverage the power of oracles: functions that reach out to the outside world and are able to query and manipulate data outside of itself. The values returned by oracles are then constrained inside Noir and modifications to the blockchain state are later verified to adhere to the protocol rules by our kernel circuits.
However, all of this is often not necessary to ensure the contract logic itself is sound. All that we need is an entity to provide values consistent with real execution. This is where our TXE (Testing eXecution Environment, pronounced "trixie") comes in!
TXE is a JSON RPC server much like PXE, but provides an extra set of oracle functions called cheatcodes
that allow developers to manipulate the state of the chain and simulate contract execution. Since TXE skips most of the checks, block building and other intricacies of the Aztec protocol, it is much faster to run than simulating everything in the sandbox.
TXE vs End-to-end tests
End-to-end tests are written in typescripts and use compiled Aztec contracts and generated Typescript interfaces, a private execution environment (PXE) and a simulated execution environment to process transactions, create blocks and apply state updates. This allows for advanced checks on state updates like generation the of logs, cross-chain messages and checking transaction status and also enforce the rules of the protocol (e.g. checks in our rollup circuits). If you need the rules of the protocol to be enforced or require complex interactions (such as with L1 contracts), please refer to Testing Aztec.nr contracts with Typescript.
The TXE is a super fast framework in Noir to quickly test your smart contract code.
So to summarize:
- End-to-end tests are written in Typescript. TXE in Noir.
- End-to-end tests are most similar to using mocha + ethers.js to test Solidity Contracts. TXE is like foundry (fast tests in solidity)
Running TXE
If you have the sandbox installed, you can run TXE tests using:
aztec test
The complete process for running tests:
- Compile contracts
- Start the sandbox
- Run
aztec test
In order to use the TXE, it must be running on a known address.
Since TXE tests are written in Noir and executed with aztec-nargo
, they all run in parallel. This also means every test creates their own isolated environment, so state modifications are local to each one of them.
Writing TXE tests
aztec-nr
provides an utility class called TestEnvironment
, that should take care of the most common operations needed to setup contract testing. Setting up a new test environment with TestEnvironment::new()
will reset the current test's TXE state.
You can find all of the methods available in the TestEnvironment
here (Github link).
#[test]
unconstrained fn test_increment() {
// Setup env, generate keys
let mut env = TestEnvironment::new();
let owner = env.create_account();
let sender = env.create_account();
let initial_value: Field = 5;
env.impersonate(owner);
// Deploy contract and initialize
let initializer = Counter::interface().initialize(initial_value as u64, owner);
let counter_contract = env.deploy_self("Counter").with_private_initializer(initializer);
let contract_address = counter_contract.to_address();
// Read the stored value in the note
env.impersonate(contract_address);
let counter_slot = Counter::storage_layout().counters.slot;
let owner_slot = derive_storage_slot_in_map(counter_slot, owner);
let mut options = NoteViewerOptions::new();
let notes: BoundedVec<ValueNote, MAX_NOTES_PER_PAGE> = view_notes(owner_slot, options);
let initial_note_value = notes.get(0).value;
assert(
initial_note_value == initial_value,
f"Expected {initial_value} but got {initial_note_value}",
);
// Increment the counter
Counter::at(contract_address).increment(owner, sender).call(&mut env.private());
// get_counter is an unconstrained function, so we call it directly (we're in the same module)
let current_value_for_owner = get_counter(owner);
let expected_current_value = initial_value + 1;
assert(
expected_current_value == current_value_for_owner,
f"Expected {expected_current_value} but got {current_value_for_owner}",
);
}
Source code: noir-projects/noir-contracts/contracts/counter_contract/src/main.nr#L101-L137
Tests run significantly faster as unconstrained
functions. This means we generate bytecode (Brillig) and not circuits (ACIR), which should yield exactly the same results. Any other behavior is considered a bug.
Imports
Writing tests in contracts requires importing additional modules from Aztec.nr. Here are the modules that are needed for testing the increment function in the counter contract.
use dep::aztec::note::lifecycle::destroy_note;
use dep::aztec::note::note_getter::{MAX_NOTES_PER_PAGE, view_notes};
use dep::aztec::note::note_viewer_options::NoteViewerOptions;
use crate::test;
use dep::aztec::protocol_types::storage::map::derive_storage_slot_in_map;
use dep::aztec::test::helpers::test_environment::TestEnvironment;
Source code: noir-projects/noir-contracts/contracts/counter_contract/src/main.nr#L91-L100
Deploying contracts
// Deploy the contract we're currently on
let deployer = env.deploy_self("ContractName");
// Deploy a standalone contract in a path relative to the current one (always from the location of Nargo.toml)
let deployer = env.deploy("path_to_contract_root_folder_where_nargo_toml_is", "ContractName");
// Deploy a contract in a workspace
let deployer = env.deploy("path_to_workspace_root_folder_where_main_nargo_toml_is@package_name", "ContractName");
// Now one of these can be called, depending on the contract and their possible initialization options.
// Remember a contract can only be initialized once.
let my_private_initializer_call_interface = MyContract::interface().private_constructor(...);
let my_contract_instance = deployer.with_private_initializer(my_private_initializer_call_interface);
// or
let my_public_initializer_call_interface = MyContract::interface().public_constructor(...);
let my_contract_instance = deployer.with_public_initializer(my_public_initializer_call_interface);
// or
let my_contract_instance = deployer.without_initializer();
It is always necessary to deploy a contract in order to test it. It is important to keep them up to date, as TXE cannot recompile them on changes. Think of it as regenerating the bytecode and ABI so it becomes accessible externally.
Calling functions
Our test environment is capable of utilizing the autogenerated contract interfaces to abstract calls, but without going through the usual external call flow (meaning much faster execution).
Private
For example, to call the private transfer
function on the token contract:
// Transfer tokens
let transfer_amount = 1000;
Token::at(token_contract_address).transfer(recipient, transfer_amount).call(&mut env.private());
Source code: noir-projects/noir-contracts/contracts/token_contract/src/test/transfer.nr#L12-L16
Public
To call the public transfer_in_public
function:
Token::at(token_contract_address).transfer_in_public(owner, owner, transfer_amount, 0).call(
&mut env.public(),
);
Source code: noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_in_public.nr#L29-L33
Unconstrained
Unconstrained functions can be directly called from the contract interface. Notice that we need to set the contract address to the specific token contract that we are calling before making the call. This is to ensure that view_notes
works properly.
pub unconstrained fn check_private_balance(
token_contract_address: AztecAddress,
address: AztecAddress,
address_amount: Field,
) {
let current_contract_address = get_contract_address();
cheatcodes::set_contract_address(token_contract_address);
// Direct call to unconstrained
let balance_of_private = Token::balance_of_private(address);
assert(balance_of_private == address_amount, "Private balance is not correct");
cheatcodes::set_contract_address(current_contract_address);
}
Source code: noir-projects/noir-contracts/contracts/token_contract/src/test/utils.nr#L119-L132
Creating accounts
The test environment provides two different ways of creating accounts, depending on the testing needs. For most cases, it is only necessary to obtain a valid AztecAddress
that represents the user's account contract. For this, is is enough to do:
let mocked_account_address = env.create_account();
These accounts also create the necessary keys to ensure notes can be created/nullified, etc.
For more advanced flows, such as authwits, it is necessary to create a real AccountContract
, with valid signing keys that gets actually deployed to TXE. For that you can use:
let real_account_address = env.create_account_contract(secret);
Besides deploying a complete SchnorrAccountContract
, key derivation is performed so that authwits can be signed. It is slightly slower than the mocked version.
Once accounts have been created, you can impersonate them in your test by calling:
env.impersonate(account_address);
// or (these are equivalent)
cheatcodes::set_contract_address(contract_address);
Checking state
It is possible to use the regular oracles in tests in order to retrieve public and private state and make assertions about them.
Remember to switch to the current contract's address in order to be able to read it's siloed state!
Reading public state:
pub unconstrained fn check_public_balance(
token_contract_address: AztecAddress,
address: AztecAddress,
address_amount: Field,
) {
let current_contract_address = get_contract_address();
cheatcodes::set_contract_address(token_contract_address);
let block_number = get_block_number();
let balances_slot = Token::storage_layout().public_balances.slot;
let address_slot = derive_storage_slot_in_map(balances_slot, address);
let amount: U128 = storage_read(token_contract_address, address_slot, block_number);
assert(amount.to_field() == address_amount, "Public balance is not correct");
cheatcodes::set_contract_address(current_contract_address);
}
Source code: noir-projects/noir-contracts/contracts/token_contract/src/test/utils.nr#L101-L117
Reading notes:
// Read the stored value in the note
env.impersonate(contract_address);
let counter_slot = Counter::storage_layout().counters.slot;
let owner_slot = derive_storage_slot_in_map(counter_slot, owner);
let mut options = NoteViewerOptions::new();
let notes: BoundedVec<ValueNote, MAX_NOTES_PER_PAGE> = view_notes(owner_slot, options);
let initial_note_value = notes.get(0).value;
assert(
initial_note_value == initial_value,
f"Expected {initial_value} but got {initial_note_value}",
);
Source code: noir-projects/noir-contracts/contracts/counter_contract/src/main.nr#L114-L126
Authwits
Private
You can add authwits to the TXE. Here is an example of testing a private token transfer using authwits:
let transfer_amount = 1000;
let transfer_private_from_call_interface =
Token::at(token_contract_address).transfer_in_private(owner, recipient, transfer_amount, 1);
authwit_cheatcodes::add_private_authwit_from_call_interface(
owner,
recipient,
transfer_private_from_call_interface,
);
// Impersonate recipient to perform the call
env.impersonate(recipient);
// Transfer tokens
transfer_private_from_call_interface.call(&mut env.private());
Source code: noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_in_private.nr#L11-L24
Public
let public_transfer_in_private_call_interface =
Token::at(token_contract_address).transfer_in_public(owner, recipient, transfer_amount, 1);
authwit_cheatcodes::add_public_authwit_from_call_interface(
owner,
recipient,
public_transfer_in_private_call_interface,
);
Source code: noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_in_public.nr#L115-L123
Storing notes in cache
Sometimes we have to tell TXE about notes that are not generated by ourselves, but someone else. This allows us to check if we are able to decrypt them:
let balances_owner_slot =
derive_storage_slot_in_map(Token::storage_layout().balances.slot, owner);
env.add_note(
&mut UintNote {
value: U128::from_integer(amount),
owner: owner,
randomness: note_randomness,
header: NoteHeader::empty(),
},
balances_owner_slot,
token_contract_address,
);
Source code: noir-projects/noir-contracts/contracts/token_contract/src/test/utils.nr#L143-L157
Time traveling
TXE can force the generation of "new blocks" very quickly using:
env.advance_block_by(n_blocks);
This will effectively consolidate state transitions into TXE's internal trees, allowing things such as reading "historical state" from private, generating inclusion proofs, etc.
Failing cases
You can test functions that you expect to fail generically, with the #[test(should_fail)]
annotation, or that it should fail with a specific message with #[test(should_fail_with = "Failure message")]
.
For example:
#[test(should_fail_with = "invalid nonce")]
unconstrained fn transfer_private_failure_on_behalf_of_self_non_zero_nonce() {
// Setup without account contracts. We are not using authwits here, so dummy accounts are enough
let (env, token_contract_address, owner, recipient, _) =
utils::setup_and_mint_to_private(/* with_account_contracts */ false);
// Add authwit
let transfer_amount = 1000;
let transfer_private_from_call_interface =
Token::at(token_contract_address).transfer_in_private(owner, recipient, transfer_amount, 1);
authwit_cheatcodes::add_private_authwit_from_call_interface(
owner,
recipient,
transfer_private_from_call_interface,
);
// Transfer tokens
transfer_private_from_call_interface.call(&mut env.private());
}
Source code: noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_in_private.nr#L30-L48
You can also use the assert_public_call_fails
or assert_private_call_fails
methods on the TestEnvironment
to check that a call fails.
// Try to set ourselves as admin, fail miserably
let set_admin_call_interface = Token::at(token_contract_address).set_admin(recipient);
env.assert_public_call_fails(set_admin_call_interface);
Source code: noir-projects/noir-contracts/contracts/token_contract/src/test/access_control.nr#L33-L37
Logging
You can use aztec.nr
's oracles as usual for debug logging, as explained here
Remember to set the following environment variables to activate debug logging:
export LOG_LEVEL="debug"
All Cheatcodes
You can find the full list of cheatcodes available in the TXE here