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 10 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"
}
134 changes: 134 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,134 @@
---
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 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 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:
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`
critesjosh marked this conversation as resolved.
Show resolved Hide resolved

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

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

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

#### 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, generate inclusion proofs, etc.

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

#### 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
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 @@ -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,14 +69,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
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.
}
Loading
Loading