Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: TXE docs and usability #7305

Merged
merged 15 commits into from
Jul 9, 2024
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"position": 2,
"collapsible": true,
"collapsed": true,
"label": "Testing"
}
120 changes: 120 additions & 0 deletions docs/docs/reference/smart_contract_reference/testing/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
---
critesjosh marked this conversation as resolved.
Show resolved Hide resolved
title: Testing
---

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.

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

#include_code pure_noir_testing /noir-projects/noir-contracts/contracts/card_game_contract/src/cards.nr rust

To learn more about Noir testing, please refer to [the docs](https://Noir-lang.org/docs/tooling/testing/)

## TXE

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 the modifications to the blockchain state 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, and all that we need is an entity to provide values consistent with real execution. This is where our TXE (Testing eXecution Environment) 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, blockbuilding and other intrincacies of the aztec protocol, it is much faster to run than simulating everything in the sandbox.

### Running TXE

In order to use the TXE, it must be running on a known address. Assuming the default `http://localhost:8080`, contract tests would be run with:

`nargo test --oracle-resolver http://localhost:8080`

:::warning
Since TXE tests are written in Noir and executed with `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.
Furthermore, executing many tests in parallel might slow processing of the RPC calls down to the point of making them timeout. To control this timeout the `NARGO_FOREIGN_CALL_TIMEOUT` env variable is used.
:::

### Writing TXE contract tests in Noir

`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**

#include_code txe_test_increment /noir-projects/noir-contracts/contracts/counter_contract/src/main.nr rust

#### Deploying contracts

```rust
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can these not be hardcoded?

Copy link
Contributor Author

@Thunkar Thunkar Jul 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried to provide examples wherever it made sense, but in some places they're just generic pieces of code suggesting possible use cases. Don't really know what's best, too specific might be bad too.

let deployer = env.deploy("path_to_contract_ts_interface");

// 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 = env..with_private_initializer(my_private_initializer_call_interface);

// or

let my_public_initializer_call_interface = MyContract::interface().public_constructor(...);
let my_contract_instance = env.with_public_initializer(my_public_initializer_call_interface);

// or

let my_contract_instance = env.without_initializer();
```

:::warning
At the moment, TXE uses the generated contract TypeScript interfaces to perform deployments, and they must be provided as either an absolute path, a relative path to TXE's location or a module in an npm direct dependency such as `@aztec/noir-contracts.js`. It is not always necessary to deploy a contract in order to test it, but sometimes it's inevitable (when testing functions that depend on the contract being initialized, or contracts that call others for example) **It is important to keep them up to date**, as TXE cannot recompile them on changes. This will be improved in the future.
:::

#### 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)

#include_code txe_test_transfer_private /noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_private.nr rust

Unconstrained functions can be directly called from the contract interface:

#include_code txe_test_call_unconstrained /noir-projects/noir-contracts/contracts/token_contract/src/test/utils.nr rust

#### 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:

```rust
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:

```rust
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:

```rust
env.impersonate(account_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.

:::warning
Remember switching to the current contract's address in order to be able to read it's siloed state!
:::

Reading public state:
#include_code txe_test_call_unconstrained /noir-projects/noir-contracts/contracts/token_contract/src/test/utils.nr rust

Reading notes:
#include_code txe_test_read_notes /noir-projects/noir-contracts/contracts/counter_contract/src/main.nr rust

#### 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:

#include_code txe_test_store_note /noir-projects/noir-contracts/contracts/token_contract/src/test/utils.nr rust

## End-to-end

If you need the rules of the protocol to be enforce or require more complex interactions (such as with L1 contracts), please refer to [Testing Aztec.nr contracts with TypeScript](../../../guides/js_apps/test.md)
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ impl TestEnvironment {
cheatcodes::get_contract_address()
}

fn impersonate(self, address: AztecAddress) {
cheatcodes::set_contract_address(address)
}

fn advance_block_to(&mut self, block_number: u32) {
let difference = block_number - cheatcodes::get_block_number();
self.advance_block_by(difference);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@ impl Serialize<2> for Card {
}
}

// docs:start:pure_noir_testing
#[test]
fn test_to_from_field() {
let field = 1234567890;
let card = Card::from_field(field);
assert(card.to_field() == field);
}
// docs:end:pure_noir_testing

struct CardNote {
card: Card,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,24 @@ contract Counter {
use dep::aztec::note::note_getter::{MAX_NOTES_PER_PAGE, view_notes};
use dep::aztec::note::note_viewer_options::NoteViewerOptions;

// docs:start:txe_test_increment
#[test]
fn test_increment() {
// Setup env, generate keys
let mut env = TestEnvironment::new();
let owner = env.create_account();
let outgoing_viewer = env.create_account();
let initial_value: Field = 5;
cheatcodes::set_contract_address(owner);
env.impersonate(owner);

// Deploy contract and initialize
let initializer = Counter::interface().initialize(initial_value as u64, owner, outgoing_viewer);
let counter_contract = env.deploy("@aztec/noir-contracts.js/Counter").with_private_initializer(initializer);
let contract_address = counter_contract.to_address();

// docs:start:txe_test_read_notes
// Read the stored value in the note

cheatcodes::set_contract_address(contract_address);
env.impersonate(contract_address);
let counter_slot = Counter::storage().counters.slot;
let owner_slot = derive_storage_slot_in_map(counter_slot, owner);
let mut options = NoteViewerOptions::new();
Expand All @@ -68,6 +69,7 @@ contract Counter {
assert(
initial_note_value == initial_value, f"Expected {initial_value} but got {initial_note_value}"
);
// docs:end:txe_test_read_notes

// Increment the counter
let increment_call_interface = Counter::at(contract_address).increment(owner, outgoing_viewer);
Expand All @@ -78,4 +80,5 @@ contract Counter {
expected_current_value == current_value_for_owner, f"Expected {expected_current_value} but got {current_value_for_owner}"
);
}
// docs:end:txe_test_increment
}
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ contract Parent {
let result: Field = env.call_private(parent_private_set_call_interface);
assert(result == value_to_set);
// Read the stored value in the note. We have to change the contract address to the child contract in order to read its notes
cheatcodes::set_contract_address(child_contract_address);
env.impersonate(child_contract_address);
let counter_slot = Child::storage().a_map_with_private_values.slot;
let owner_slot = derive_storage_slot_in_map(counter_slot, owner);
let mut options = NoteViewerOptions::new();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ unconstrained fn transfer_success() {

authwit_cheatcodes::add_private_authwit_from_call_interface(owner, recipient, transfer_private_from_call_interface);

cheatcodes::set_contract_address(recipient);
env.impersonate(recipient);
// Transfer tokens
env.call_private_void(transfer_private_from_call_interface);
// Check balances
Expand Down Expand Up @@ -53,7 +53,7 @@ unconstrained fn setup_refund_success() {

authwit_cheatcodes::add_private_authwit_from_call_interface(owner, recipient, setup_refund_from_call_interface);

cheatcodes::set_contract_address(recipient);
env.impersonate(recipient);

env.call_private_void(setup_refund_from_call_interface);
let mut context = env.private();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ pub fn setup(with_account_contracts: bool) -> (&mut TestEnvironment, AztecAddres
};

// Start the test in the account contract address
cheatcodes::set_contract_address(owner);
env.impersonate(owner);

// Deploy token contract
let initializer_call_interface = PrivateToken::interface().constructor(
Expand Down Expand Up @@ -59,12 +59,12 @@ pub fn check_private_balance(
address_amount: Field
) {
let current_contract_address = cheatcodes::get_contract_address();
cheatcodes::set_contract_address(token_contract_address);
env.impersonate(token_contract_address);

let header = context.get_header();
let owner_npk_m_hash = header.get_npk_m_hash(context, address);

let balance_of_private = PrivateToken::balance_of_unconstrained(owner_npk_m_hash);
assert(balance_of_private == address_amount, "Private balance is not correct");
cheatcodes::set_contract_address(current_contract_address);
env.impersonate(current_contract_address);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ unconstrained fn access_control() {
assert(admin == recipient.to_field());

// Impersonate new admin
cheatcodes::set_contract_address(recipient);
env.impersonate(recipient);

// Check new admin is not a minter
let is_minter_call_interface = Token::at(token_contract_address).is_minter(recipient);
Expand All @@ -40,7 +40,7 @@ unconstrained fn access_control() {
assert(is_minter == false);

// Impersonate original admin
cheatcodes::set_contract_address(owner);
env.impersonate(owner);

// Try to set ourselves as admin, fail miserably
let set_admin_call_interface = Token::at(token_contract_address).set_admin(recipient);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ unconstrained fn burn_public_on_behalf_of_other() {
let burn_call_interface = Token::at(token_contract_address).burn_public(owner, burn_amount, unsafe_rand());
authwit_cheatcodes::add_public_authwit_from_call_interface(owner, recipient, burn_call_interface);
// Impersonate recipient to perform the call
cheatcodes::set_contract_address(recipient);
env.impersonate(recipient);
// Burn tokens
env.call_public(burn_call_interface);
utils::check_public_balance(token_contract_address, owner, mint_amount - burn_amount);
Expand Down Expand Up @@ -59,15 +59,15 @@ unconstrained fn burn_public_failure_on_behalf_of_other_without_approval() {
let burn_amount = mint_amount / 10;
let burn_call_interface = Token::at(token_contract_address).burn_public(owner, burn_amount, unsafe_rand());
// Impersonate recipient to perform the call
cheatcodes::set_contract_address(recipient);
env.impersonate(recipient);
env.assert_public_call_fails(burn_call_interface);
utils::check_public_balance(token_contract_address, owner, mint_amount);

// Burn on behalf of other, wrong designated caller
let burn_call_interface = Token::at(token_contract_address).burn_public(owner, burn_amount, unsafe_rand());
authwit_cheatcodes::add_public_authwit_from_call_interface(owner, owner, burn_call_interface);
// Impersonate recipient to perform the call
cheatcodes::set_contract_address(recipient);
env.impersonate(recipient);
env.assert_public_call_fails(burn_call_interface);
utils::check_public_balance(token_contract_address, owner, mint_amount);
}
Expand All @@ -81,7 +81,7 @@ unconstrained fn burn_public_failure_on_behalf_of_other_wrong_caller() {
let burn_call_interface = Token::at(token_contract_address).burn_public(owner, burn_amount, unsafe_rand());
authwit_cheatcodes::add_public_authwit_from_call_interface(owner, owner, burn_call_interface);
// Impersonate recipient to perform the call
cheatcodes::set_contract_address(recipient);
env.impersonate(recipient);
env.assert_public_call_fails(burn_call_interface);
utils::check_public_balance(token_contract_address, owner, mint_amount);
}
Expand All @@ -106,7 +106,7 @@ unconstrained fn burn_private_on_behalf_of_other() {
let burn_call_interface = Token::at(token_contract_address).burn(owner, burn_amount, unsafe_rand());
authwit_cheatcodes::add_private_authwit_from_call_interface(owner, recipient, burn_call_interface);
// Impersonate recipient to perform the call
cheatcodes::set_contract_address(recipient);
env.impersonate(recipient);
// Burn tokens
env.call_private_void(burn_call_interface);
utils::check_private_balance(token_contract_address, owner, mint_amount - burn_amount);
Expand Down Expand Up @@ -144,7 +144,7 @@ unconstrained fn burn_private_failure_on_behalf_of_other_more_than_balance() {
let burn_call_interface = Token::at(token_contract_address).burn(owner, burn_amount, unsafe_rand());
authwit_cheatcodes::add_private_authwit_from_call_interface(owner, recipient, burn_call_interface);
// Impersonate recipient to perform the call
cheatcodes::set_contract_address(recipient);
env.impersonate(recipient);
env.call_private_void(burn_call_interface);
// Private doesnt revert, so we cannot check balances here since notes have already been nullified. Test is done.
}
Expand All @@ -158,7 +158,7 @@ unconstrained fn burn_private_failure_on_behalf_of_other_without_approval() {
// Burn on behalf of other
let burn_call_interface = Token::at(token_contract_address).burn(owner, burn_amount, unsafe_rand());
// Impersonate recipient to perform the call
cheatcodes::set_contract_address(recipient);
env.impersonate(recipient);
env.call_private_void(burn_call_interface);
// Private doesnt revert, so we cannot check balances here since notes have already been nullified. Test is done.
}
Expand All @@ -173,7 +173,7 @@ unconstrained fn burn_private_failure_on_behalf_of_other_wrong_designated_caller
let burn_call_interface = Token::at(token_contract_address).burn(owner, burn_amount, unsafe_rand());
authwit_cheatcodes::add_private_authwit_from_call_interface(owner, owner, burn_call_interface);
// Impersonate recipient to perform the call
cheatcodes::set_contract_address(recipient);
env.impersonate(recipient);
env.call_private_void(burn_call_interface);
// Private doesnt revert, so we cannot check balances here since notes have already been nullified. Test is done.
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ unconstrained fn mint_public_failures() {

// As non-minter
let mint_amount = 10000;
cheatcodes::set_contract_address(recipient);
env.impersonate(recipient);
let mint_public_call_interface = Token::at(token_contract_address).mint_public(owner, mint_amount);
env.assert_public_call_fails(mint_public_call_interface);

utils::check_public_balance(token_contract_address, owner, 0);

cheatcodes::set_contract_address(owner);
env.impersonate(owner);

// Overflow recipient

Expand Down Expand Up @@ -129,7 +129,7 @@ unconstrained fn mint_private_failure_non_minter() {
let (env, token_contract_address, _, recipient) = utils::setup(/* with_account_contracts */ false);
let mint_amount = 10000;
// Try to mint some tokens impersonating recipient
cheatcodes::set_contract_address(recipient);
env.impersonate(recipient);

let secret = unsafe_rand();
let secret_hash = compute_secret_hash(secret);
Expand Down Expand Up @@ -217,19 +217,19 @@ unconstrained fn mint_private_failure_overflow_total_supply() {
);

// Redeem owner's shielded tokens
cheatcodes::set_contract_address(owner);
env.impersonate(owner);
let redeem_shield_call_interface = Token::at(token_contract_address).redeem_shield(owner, mint_amount, secret_owner);
env.call_private_void(redeem_shield_call_interface);

// Redeem recipient's shielded tokens
cheatcodes::set_contract_address(recipient);
env.impersonate(recipient);
let redeem_shield_call_interface = Token::at(token_contract_address).redeem_shield(recipient, mint_amount, secret_recipient);
env.call_private_void(redeem_shield_call_interface);

utils::check_private_balance(token_contract_address, owner, mint_amount);
utils::check_private_balance(token_contract_address, recipient, mint_amount);

cheatcodes::set_contract_address(owner);
env.impersonate(owner);
let mint_amount = 2.pow_32(128) - 2 * mint_amount;
// Try to mint some tokens
let secret = unsafe_rand();
Expand Down
Loading
Loading