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
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"position": 1,
"collapsible": true,
"collapsed": true,
"label": "Testing Contracts"
}
180 changes: 180 additions & 0 deletions docs/docs/guides/smart_contracts/testing_contracts/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
---
title: Testing Contracts
---

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.

critesjosh marked this conversation as resolved.
Show resolved Hide resolved
#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 Noir docs](https://Noir-lang.org/docs/tooling/testing/).

## TXE
critesjosh marked this conversation as resolved.
Show resolved Hide resolved

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

### 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:
Copy link
Contributor

Choose a reason for hiding this comment

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

@Thunkar can you update all of this with aztec test once you are done with your work?


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

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

You can find all of the methods available in the `TestEnvironment` [here](https://github.com/AztecProtocol/aztec-packages/blob/#include_aztec_version/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr).

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

:::warning
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. Here are the modules that are needed for testing the increment function in the counter contract.

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

#### Deploying contracts

```rust
let deployer = env.deploy("path_to_contract_ts_interface");
critesjosh marked this conversation as resolved.
Show resolved Hide resolved

// 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).

##### Private

For example, to call the private `transfer` function on the token contract:

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

##### Public

To call the public `transfer_public` function:

#include_code call_public /noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_public.nr rust

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

#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 to switch to the current contract's address in order to be able to read it's siloed state!
:::

Reading public state:
#include_code txe_test_read_public /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

#### Authwits

##### Private

You can add autwits to the TXE. Here is an example of testing a private token transfer using authwits:

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

##### Public

#include_code public_authwit /noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_public.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

#### Time traveling

TXE can force the generation of "new blocks" very quickly using:

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

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

You can also use the `assert_public_call_fails` or `assert_private_call_fails` methods on the `TestEnvironment` to check that a call fails.

#include_code assert_public_fail /noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_public.nr rust

## End-to-end
critesjosh marked this conversation as resolved.
Show resolved Hide resolved

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](../../js_apps/test.md)
2 changes: 1 addition & 1 deletion noir-projects/Dockerfile.test
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ RUN cd /usr/src/yarn-project/txe && yarn start & \
# Wait for TXE to initialize
sleep 5 && \
cd ./aztec-nr && \
nargo test --use-legacy --silence-warnings --oracle-resolver http://localhost:8080
NARGO_FOREIGN_CALL_TIMEOUT=300000 nargo test --use-legacy --silence-warnings --oracle-resolver http://localhost:8080
3 changes: 2 additions & 1 deletion noir-projects/Earthfile
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ test:
RUN cd /usr/src/yarn-project/txe && yarn start & \
# Wait for TXE to initialize
sleep 5 && \
cd /usr/src/noir-projects/aztec-nr && nargo test --use-legacy --silence-warnings --oracle-resolver http://localhost:8080
cd /usr/src/noir-projects/aztec-nr && \
NARGO_FOREIGN_CALL_TIMEOUT=300000 nargo test --use-legacy --silence-warnings --oracle-resolver http://localhost:8080

RUN cd /usr/src/yarn-project/txe && yarn start & \
# Wait for TXE to initialize
Expand Down
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 @@ -38,28 +38,31 @@ contract Counter {
}
// docs:end:get_counter

// docs:start:test_imports
use dep::aztec::test::{helpers::{cheatcodes, test_environment::TestEnvironment}};
use dep::aztec::protocol_types::storage::map::derive_storage_slot_in_map;
use dep::aztec::note::note_getter::{MAX_NOTES_PER_PAGE, view_notes};
use dep::aztec::note::note_viewer_options::NoteViewerOptions;
// docs:end:test_imports

// 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,14 +71,17 @@ 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);
env.call_private_void(increment_call_interface);
// 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}"
);
}
// 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
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
Loading
Loading