Sitemap

Aztec Contracts 0–100

84 min readDec 3, 2024

Making Expense Splitting Contracts in Aztec

In this walk-through tutorial we will explore two different options you have for making an expense splitting in Aztec, through writing the contracts, testing in jest and the TXE, creating your own notes, and interacting with a demo frontends. The tradeoff of both versions and improvements that will be available in future versions of Aztec.

In this walk through, we will increase your understanding by comparing the design patterns for Private Context and Public Context within Aztec.

Disclaimer: These contracts are written in version 0.60.0 of Aztec, Aztec is pre-release and breaking changes are common. This tutorial will be updated through future versions of Aztec, the git repo where they live will be linked.

These are also sample contracts to help develop understanding of developing in Aztec, they are not completely optimised, nor safe to deploy once Aztec is live.

The goal is to get you familiar with the lifecycle of developing contracts in the Aztec Sandbox, the current limitations that you may face and ways you can make you contract more efficient.

The migration notes are here if you want to adapt it for newer versions.

In this tutorial you will learn how to:

  • Write private contracts within Aztec
  • How design patterns between public and private state differ
  • Send notes to other participants
  • Testing Aztec Contracts using Jest and AztecJS
  • Testing Aztec Contracts using the PXE.
  • Using account contracts as a way to share private state.
  • Running multiple PXEs

Prerequisites:

  • Installed the Aztec Sandbox, running the PXE, aztec-nargo version 0.60.0.

To Begin

  • Install the Aztec Sandbox, instructions for this can be found here.
  • In your terminal create a new folder where you can follow along with this tutorial.

Project Structure

.
|-- src
| |-- circuits
| | |-- src
| | | |-- main.nr
| | |-- Nargo.toml
|-- package.json
|-- yarn.lock

This is the project structure that we need at the moment:

We are doing everything within a src folder for adding UI elements later.

Within your terminal in the folder you would like to start the project, write

yarn init -yp
  • This will create a package.json, this will be useful later.

Create a src folder with, and cd into it

mkdir src && cd src

Let’s create our new Aztec Project with:

aztec-nargo new --contract circuits

Go back to the project root and open in your code editor

cd .. && code .

This is the core structure that we will need for our projects.

Writing Private Contracts within Aztec

Now we will start with creating our first contract which will be used to create a private group where members can keep track of debts and credits to one another. This would be useful in a scenario where you go on a trip and want to keep track of expenses to settle at a later data.

Contract Structure

  • Within our contract we will need these aspects
  • Constructor
  • Adding members to a group
  • setting balances
  • making payments
  • viewing group members

Imports

To start writing our contracts, we are going to need to use some imports to use throughout the contract. Aztec has a some internal libraries with helpful tools for developers.

In your nargo.toml, add these dependencies under the dependencies section.

[dependencies]
aztec = { git = "<https://github.com/AztecProtocol/aztec-packages/>", tag = "aztec-packages-v0.60.0", directory = "noir-projects/aztec-nr/aztec" }
value_note = { git = "<https://github.com/AztecProtocol/aztec-packages/>", tag = "aztec-packages-v0.60.0", directory = "noir-projects/aztec-nr/value-note" }

Disclaimer: This version is written in version 0.60.0, and will be updated later.

In your main file include these imports inside the contract block.

use dep::aztec::macros::aztec; //ouside of the contract block
#[aztec]
contract PrivateGroups {
use aztec::{
prelude::{Map, AztecAddress, PrivateImmutable, PrivateSet},
encrypted_logs::encrypted_note_emission::encode_and_encrypt_note,
keys::getters::get_public_keys,
macros::{
storage::storage, events::event,
functions::{public, initializer, private, internal, view},
},
};
use aztec::note::note_getter::NoteGetterOptions;
use crate::NewAddressNote::NewAddressNote;
use crate::helpers::{get_balance, membership_check};
use value_note::{utils::{increment, decrement}, value_note::ValueNote};
use std::hash::poseidon2;

global ZERO_ADDRESS: AztecAddress = AztecAddress::from_field(
0x0000000000000000000000000000000000000000000000000000000000000000,
);
}

Let’s go through why we have these imports and what they do at a high level:

macro::aztec - This makes the Aztec macro available throughout the entire contract, to annotate various functions within the contract. ie, #[storage], #[public], #[private]

Map - This is used for mapping key, value pairs in contract storage.

AztecAddress - At a high level they are a representation of a contract within the Aztec Network, in this case, an account contract.

PrivateImmutable - A type of state variable that is private and immutable.

PrivateSet - A collection type for storing private notes of the same type. We will explain notes in more depth later.

encode_and_encrypt_note - used to encode and encrypt notes for emission as encrypted logs, ensuring privacy.

get_public_keys - Provides functions to retrieve public keys necessary for various cryptographic operations within the contract. In this case mainly to send notes to the correct participant.

macros - These macros are used within the contract to define the context of functions.

NoteGetterOptions - Used in note retrieval functions, allowing users to fetch notes that are assigned to them.

NewAddressNote - A custom implementation of an address note, used to store an Aztec address as a note for use in the private context.

get_balance - A helper function we will add to collect the balance from a set of Notes

membership_checK - Helper function to check if the msg_sender is part of the group.

value_note - utility functions for the value_note type, a note used to store value-related data in the private context.

poseidon2 - A cryptographic hash function

ZERO_ADDRESS - This will be used later as a helper.

Notes within Aztec

Before we jump into Aztec, let us understand what notes are in Aztec and why they are used.

If you have a good understanding of notes and why they are used within the private context, please skip ahead.

Notes are a key data structure that powers private, confidential transactions by representing unique, spendable units of data. A Note in Aztec functions similarly to a cash note in real life: once it’s used in a transaction, it is “spent” and can no longer be reused. This is comparable to Bitcoin’s UTXO(Unspent Transaction Output) model but differs significantly from Ethereum’s account-based model.

Concept Overview

Each Note in Aztec represents a discrete, unique unit, for example, let us compare a note that holds an integer similar to a cash note which can be passed around. For example, if you spend $5 to buy something costing $3, you’d receive $2 in change. In Aztec, this transaction would involve 3 notes for the system, 2 notes for the individual: one spent ($5) one returned as change ($2), and a $3 note for the recipient. Once a note is spent, it’s permanently consumed.

Note: Notes can be used to store an arbitrary piece of data. This could be an Address, a Value or a string as examples.

Ethereum’s Account-Based Model vs. Aztec’s UTXO Note Model

In Ethereum, transactions are tracked through an account-based model, where each account has a balance that updates with every transaction, similar to how bank accounts function. When you send funds, your account balance decreases, and the recipient’s balance increases by that same amount.

In contrast, Aztec’s Note model does not track a single account balance; instead,d it tracks a collection of notes, each Note stands as a unique item, representing either value or data, which can be privately spent or transferred.

Privacy and Arbitrary Data Encryption

Notes are not just used to encrypt a representation of value, you can store arbitrary encrypted values. For example, a Note can contain an encrypted Aztec Address through an AddressNote, allowing private communication of addresses or other sensitive data within the network. This flexibility extends Notes beyond simple monetary transactions, supporting applications with a range of confidential interactions.

Storage Within the Contract

Underneath your imports include this storage structure for the contract, I have also included some comparison for context to how a similar storage layout would be achieved in Aztec’s Public Context and in Solidity.

1. Aztec Private Context Storage Layout

This is the layout we are going to use in the contract

This includes

  • The Admin — The account that creates and manages the group.
  • The Group Members — A mapping of group members with a private set of group members so they can see all of the group members within the group.
  • Group Balances Credit — A map of balances, the key for the mapping is a Field which is the Poseidon hash of the Creditor and Debtor pair. The balances are represented by a private set of value notes.
  • Group Balances Debt — This is the same as for the credit balances, but is going to be used to represent a user's debts.
#[storage]
struct Storage<Context> {
admin: PrivateImmutable<NewAddressNote, Context>,
group_members: Map<AztecAddress, PrivateSet<NewAddressNote, Context>, Context>,
group_balances_credit: Map<Field, PrivateSet<ValueNote, Context>, Context>,
group_balances_debt: Map<Field, PrivateSet<ValueNote, Context>, Context>,
}

Notice how we specify the generic type Context to the Storage struct. Aztec Contracts have three execution contexts.

Private — This is the context used for private state, these are executed locally on the user’s device

Public — For public state which is executed by the sequencer.

Unconstrained — Used for executing state that does not need to be provable. Functions in the unconstrained context are executed without constraints, meaning their execution is not verified by the protocol. This is often used in scenarios where efficiency is prioritised over provability.

2. Aztec Public Context Storage Layout

For demonstration purposes, if the context of the contract was public, the contract storage would look something like this:

#[storage]
struct Storage<Context> {
admin: PublicImmutable<AztecAddress, Context>,
group_members: Map<AztecAddress, PublicMutable<bool, Context>, Context>,
group_balances: Map<Field, PublicMutable<Field, Context>, Context>,
}
  • Public Accessibility: In the public context, storage relies on direct mappings rather than encrypted notes. This provides transparency for all values within the public contract.
  • Public and Mutable Structures: PublicImmutable is used for the admin, maintaining immutability, while PublicMutable allows updates to values within the mappings, notice how no notes are needed to store the public data.

3. Solidity Storage Layout

Again for demonstration, the context storage would look something like this in solidity.

address public admin;
mapping(address => bool) public groupMembers;
mapping(address => mapping(address => uint256)) public balances;
  • Nested Mapping: The outer mapping maps a creditor address to an inner mapping, which then maps the debtor address to the corresponding balance. This effectively associates each creditor-debtor pair with a unique balance, just like using a hashed pair as the key.

Summary of Differences

Press enter or click to view image in full size

The reason we have two separate storage structures for credit and debt will be discussed later on, but it is to do with note visibility and encrypting and decrypting notes in the current version.

Constructor

The next thing we will need to do is to create our constructor which will be run during the contract deployment and will determine the starting state of the contract.

Again I will compare the differences in private and public contexts, this is the first time we will create notes that will be stored in the contract storage.

New concepts that are introduced will be discussed afterwards.

#[private]
#[initializer]
fn constructor(admin: AztecAddress, group_members: [AztecAddress; 3]) {
//setting the admin address note
let admin_keys = get_public_keys(admin);
let mut admin_address_note = NewAddressNote::new(admin, admin_keys.npk_m.hash());
storage.admin.initialize(&mut admin_address_note).emit(encode_and_encrypt_note(
&mut context,
admin_keys.ovpk_m,
admin_keys.ivpk_m,
admin,
));
// this is for adding members to the private set, so they can see the other members
for i in 0..3 {
let member = group_members[i as u32];
let member_keys = get_public_keys(member);
for i in 0..3 {
let member_add = group_members[i as u32];
let mut memberNote = NewAddressNote::new(member_add, member_keys.npk_m.hash());
storage.group_members.at(member).insert(&mut memberNote).emit(
encode_and_encrypt_note(
&mut context,
admin_keys.ovpk_m,
member_keys.ivpk_m,
member,
),
);
}
}
}

Flow of the Constructor

  1. The private and initializer macros are used so that the function is executed in the private context on the user’s device. The initializer macro is used to designate that this is the constructor function that will setup the initial state of the contract.
  2. The arguments are the admin and the group_members which include the admin.
  • The group_members could be added at another time through a separate function, but in this example we will add them during the constructor.
  • A note for the admin with the admin’s address is created using the NewAddressNote type, this is a custom note type that we have created which we will go through later on in the tutorial.
  • The public keys for the admin are fetched from the PXE of the deployer using the get_public_keys helper function.
  • A new note is initialised using the admins address and the admins npk_m.hash (nullifying public master key).hash (cryptographic commitment).
  • The admin’s note is inserted into the admin storage slot
  • An encrypted log for this note is emitted

3. Group member initialisation

  • The function iterates over the group_members array, which contains up to three addresses. (This could be more, we are using three for demonstration purposes)
  • For each member address, it retrieves the member’s public keys
  • for each member, a new address note is created for each of the group members, using the member’s address and the hash of the members nullifying public key.
  • each member’s note is inserted into the group members storage. The note is then emitted as an encrypted log, encrypted with the admin’s outgoing viewer key (ovpk_m) and the member’s incoming viewing key (ivpk_m)

Key Concept’s Introduced in the Constructor

  1. Storing Notes in Private Storage — The addresses of the admin and group members are stored in private storage using a NewAddressNote.

2. Keys Associated with an Aztec Address — Each Aztec address is associated with 4 key pairs, each serving a specific purpose

a. Nullifier Key Pair

  • Master Nullifier Secret Key (nsk_m): Used for note nullification, when a note is spent, it is nullified using this, without revealing its contents.
  • Master Nullifier Public Key(npk_m): Used in the creation of notes, providing a public commitment to the note.

b. Incoming Viewing Key Pair

  • Master Incoming Viewing Secret Key (ivsk_m): Allows the recipient to decrypt notes that have been sent to them.
  • Master Incoming Viewing Public Key(ivpk_m ): Used to encrpyt notes for the recipient

c. Outgoing Viewing Key Pair:

  • Master Outgoing Viewing Secret Key (ovsk_m): Used by the sender to encrypt notes they create.
  • Master Outgoing Viewing Public Key (Ovpk_m): Allows the sender to encrypt notes for others.

d. Tagging Key Pair:

  • Master Tagging Secret Key (tsk_m): Used to compute tags for note discovery.
  • Master Tagging Public Key (Tpk_m): Used in conjunction with the tagging mechanism to ensure notes can be discovered by the intended recipient.

3. Encryption — When a note is created, it is encrypted using the recipient’s incoming viewing public key ivpk_m and the sender’s outgoing viewing public key ovpk_m. This ensures that only the sender and the intended recipient can decrypt and view the note.

4. Decryption — The recipient uses their incoming viewing secret key( ivsk_m) to decrypt the note. This allows them to access the contents of the note securely.

5. The PXE

Press enter or click to view image in full size

When it comes to private state, the PXE (Private Execution Environment) is used for the private context, whereas public context is executed by the Aztec Virtual Machine (AVM).

The PXE is a client-side library for the execution of private operations. It generates proofs of private function execution and sends these proofs along with public function requests to the sequencer. Private inputs never leave the client-side PXE.

More information about the PXE can be found here.

In the context of initializing and emitting notes, the PXE

  • Executes transactions locally on the user’s device, creation and emission of notes without broadcasting them to the network immediately. Ensuring that all operations are valid.
  • Generates ZK proofs for the operations performed, such as the creation and nullification of notes. This verifies that the operations were executed correctly without revealing sensitive information
  • Handles the encryption of notes using the recipient’s ivpk_m and the sender’s ovpk_m. This ensures that only authorised parties can decrypt and access the note’s contents.
  • It then broadcasts the notes to the network. For the sequencer to order transactions and assemble them into roll-up blocks.
  • Interacts with an Aztec node to access the network’s public state and submit transactions. A bridge between the user’s local environment and the network.
  • The PXE maintains a local database that stores transactional data and notes. This database manages the users private states.

6. Using a fixed sized array for the group members

You may have noticed that we are using a fixed-sized array here for storing the members of the group. This is deliberate, when the contract is compiled in Aztec, it is compiled into ACIR (abstract circuit intermediate representation), which is the link between the noir compiler output and the proving system backend input. It is comprised of circuits which are used to generate proofs, as a side effect of this, the inputs of these circuits are needed to be known at compile time. For a variable-sized array of addresses, the size of this cannot be known at compile time, therefore a fixed-sized array of addresses is needed. This is a side effect of working with circuits and is something that the developer needs to keep in mind.

Note: You can implement something that replicates a variable sized array. You can do this by making an array of larger size, let’s say 10. Then including the array with the real addresses that you need, and padding the rest of the array with a filler address that gets filtered out in the loop so that only the real members are added. We have not done this in this example to keep it simple. But this is a workaround to the size of the array being needed to be known at compile time.

Disclaimer: The flow of encrpyting and decrypting notes is going to change in the future. This will be updated once it does.

Constructor for Public Storage

Let’s compare the constructor for private storage to what it might look like for a contract that uses entirely public state as a reference

#[public]
#[initializer]
fn constructor(admin:AztecAddress, group_members: [AztecAddress; 3]) {
storage.admin.write(admin);
for i in 0..3 {
let member_to_add = group_members[i as u32];
storage.group_members.at(member_to_add).write(true)
}
}

This is not exactly equivalent as it is mapping the Aztec addresses to a boolean value, but this can be used to illustrate the increased complexity and how the design patterns differ when working with private and public states and functions.

Viewing the Group Members

With the admin and group members now stored via the constructor, let’s create view functions to access these members.

Here we could use a normal view function in the private context, or view in the unconstrained context. Private view functions are executed client-side, they do not alter the private state, however, proofs are generated for the viewing which ensures that the results are verifiable and trusted.

In unconstrained view functions, they do not generate proofs and the results are not verified, they are safe to use in view functions that do not alter the contract state but are unsafe to use in private functions if u want to read a storage variable. They optimize performance by executing logic outside of a circuit to make for fast and cheap reads (in terms of proving time).

In a private context, each group member must have an individual note within a PrivateSet, keyed by their address. While this demonstrates the approach, it’s quite inefficient in practice due to the high cost of sending multiple notes. This is a limitation of the current setup without shared state, which is coming in the future of Aztec, and a variation of which will be discussed later in the tutorial.

Functions to View Members

#[private]
#[view]
fn get_admin() -> pub AztecAddress {
let admin_note = storage.admin.get_note();
admin_note.address
}

//For each group member, we have a private set of members so that they can see all of the members
#[private]
#[view]
fn get_group_members(member: AztecAddress) -> [AztecAddress; 3] {
let mut options = NoteGetterOptions::new();
let member_note = storage.group_members.at(member).get_notes(options);
let mut member_array: [AztecAddress; 3] = [ZERO_ADDRESS; 3];
//if you call this and u are not part of the group, it will return the zero address
for i in 0..3 {
let note = member_note.get_unchecked(i);
let address: AztecAddress = note.address;
member_array[i] = address;
}
member_array
}

Walk Through of the Functions

  1. get_admin — This approach makes it straightforward to retrieve the note stored in this slot. Notice that I haven’t added any constraints to this function — when creating the note containing the admin address, we used the admin’s keys for both the incoming and outgoing keys. This setup prevents anyone else from decrypting the note without the necessary private keys, meaning only the admin can access this storage. This function serves no real purpose as only the admin can access a storage slot that holds his own address, however it is good for demonstration purposes.
  2. get_group_members — This allows members of the group to view the other members of the group. Let’s go through the flow
  • NoteGetterOptions — This is used to filter and retrieve a selection of notes from a data oracle. This can be explored more here.
  • Note Retrieval — The function retrieves the notes at key (member) and fetches the notes based on the specified options, in this case, we are retrieving all of the notes and have not selected any extra options.
  • Initialization of member array — We initialise an array of size three with the ZERO_ADDRESS, this is just a placeholder.
  • Iterating over notes — The loop iterates over the first three notes retrieved, there will only be three notes stored here
  • get_unchecked(i) — accesses the note at a given index. Get unchecked means that the function assumes that (i) is within the valid range for note collection. This improves performance, by using the condition if i < notes.len() which ensures that i is within the bounds of the notes collection.
  • the address field of the note is extracted and assigned to the corresponding index in member_array
  • Return the array

Learning Points from this Section

Just to reiterate the concepts that have been shown here

Notes — Notes are stored in private state, their contents are encrypted and only accessible by authorised parties. If an unauthorised party were to call the get_group_members function they would see an array of size 3 full of Zero Addresses.

Storage Trees — Notes are stored in append-only Merkle trees, once a note is created, it cannot be altered, only nullified when spent, and a new note can be created.

get_note — In this tutorial, we are using get_note() and get_notes(), when we view the note, under the hood we are retrieving it, nullifying it (marking it as spent), and replacing the note. The reason we nullify notes during reads is to ensure that someone reading the note is using the latest values. Eg, if the value was modified between the time the user created the tx on their device and the tx is processed by the sequencer, it should fail since the value is not what the sender expected it to be.

When this is done a new note is created and inserted into the note hash tree. This prevents double-spending.

Fetching Notes NoteGetterOptions is a mechanism to specify criteria for retrieving notes, we haven’t discussed these criteria in this tutorial. But allows you to specify criteria for retrieving notes, which can optimize the process by fetching only the necessary notes. This can improve efficiency by reducing the need to create new notes if the correct filters are applied.

Here is a diagram of what we discussed above:

Press enter or click to view image in full size

Source: https://azt3c-st.webflow.io/blog/privacy-abstraction-with-aztec

Setting Balances between Group Members

The next part of the contract will be setting the balance between different members within the contract.

Set Balance Function

#[private]
fn set_balance(creditor: AztecAddress, debtor: AztecAddress, amount: Field) {
//Assertion that the sender is part of the group.
let sender = context.msg_sender();
let location = storage.group_members.at(sender);
assert(membership_check(location), "Sender is not in the group");

//notes for the creditor
let hash_inputs_credit = [creditor.to_field(), debtor.to_field()];
let key_credit = poseidon2::Poseidon2::hash(hash_inputs_credit, 2);
let loc_credit = storage.group_balances_credit.at(key_credit);
increment(loc_credit, amount, creditor, debtor);
// notes for the debtor
let hash_inputs_debt = [debtor.to_field(), creditor.to_field()];
let key_debt = poseidon2::Poseidon2::hash(hash_inputs_debt, 2);
let loc_debt = storage.group_balances_debt.at(key_debt);
increment(loc_debt, amount, debtor, creditor);
}

Membership Check

Here we introduce our first helper function to keep the code cleaner.

Create a new file called helpers.nr.

|-- src
| |-- circuits
| | |-- src
|. |. |. |-- helpers.nr
| | | |-- main.nr
| | |-- Nargo.toml
|-- package.json
|-- yarn.lock

Paste this into the helpers.nr

use dep::aztec::prelude::{AztecAddress, PrivateContext, PrivateSet, NoteGetterOptions};
use crate::NewAddressNote::NewAddressNote;

global ZERO_ADDRESS: AztecAddress = AztecAddress::from_field(
0x0000000000000000000000000000000000000000000000000000000000000000,
);

pub fn membership_check(location: PrivateSet<NewAddressNote, &mut PrivateContext>) -> pub bool {
let options = NoteGetterOptions::new();
let member_note = location.get_notes(options);
let mut member_array: [AztecAddress; 3] = [ZERO_ADDRESS; 3];
for i in 0..3 {
let note = member_note.get_unchecked(i);
let address: AztecAddress = note.address;
member_array[i] = address;
assert(address != ZERO_ADDRESS, "Sender is not in the group");
}
true
}

This is pretty much identical to the previous view_members function but allows us to assert whether the message sender is part of the group or not. To know if they have authorisation to change the balances within the group. If the member is not in the group, the Assertion will fail, else it will return true.

As you can see it uses NewAddressNote which we have used in our code but we have not created it yet. Do not worry if u get an error about this, its coming.

For this file to be visible in the main contract we need to add the module at the very top of the contract.

mod helpers;

Walk Through of the Function

  1. Access Control — This is done using the membership_check helper function in the helpers crate.
  2. The message sender does not have to be the creditor or debtor, so they can change someone elses balance. They just have to be part of the group. If you wanted to make this change, you can assert that the msg_sender() is the creditoror debtor .
  3. Creating the hash for the Key for the mapping — We are using the poseidon hash of the creditor and debtor for key to the contract storage, this allows us to map different balances that are unique to the two addresses.
  • The reason we are using a Poseidon hash is due to its efficiency in ZK circuits, it minimises the number of constraints which reduces the cost in comparison to other hash functions such as a Pedersen hash.

4. Increment — Increment is a helper function supplied in the value_note library, this is one of our imports, I am going to show what it does under the hood below to help to increase your understanding.

pub fn increment(
// docs:start:increment_args
balance: PrivateSet<ValueNote, &mut PrivateContext>,
amount: Field,
recipient: AztecAddress,
outgoing_viewer: AztecAddress // docs:end:increment_args
) {
let recipient_keys = get_public_keys(recipient);
let outgoing_viewer_ovpk_m = get_public_keys(outgoing_viewer).ovpk_m;

let mut note = ValueNote::new(amount, recipient_keys.npk_m.hash());
// Insert the new note to the owner's set of notes and emit the log if value is non-zero.
balance.insert(&mut note).emit(
encode_and_encrypt_note(
balance.context,
outgoing_viewer_ovpk_m,
recipient_keys.ivpk_m,
recipient
)
);
}

As you can see it is an abstraction that creates a new value note for us without having to worry about the public_keys as we did for the NewAddressNote before.

5. Different Notes for Creditor and Debtor — The reason for this is for simplicity in who the incoming viewer is for the different notes and their retrieval.

There are no new concepts introduced with this function, you should start to get the design patterns with notes and how they can be used to store data from a series of data types.

Similar Function in the Public Context

Here is a similar set balance function in the public context so you can compare the two design patterns.

#[public]
fn set_balance(creditor: AztecAddress, debtor: AztecAddress, amount: Field) {
assert(creditor == context.msg_sender(), "cannot adjust someone else's balances");
assert(storage.group_members.at(creditor).read() == true, "Creditor is not in group");
assert(storage.group_members.at(debtor).read() == true, "Debtor is not in group");

// Hash the addresses together
let hash_inputs = [creditor.to_field(), debtor.to_field()];
let key = poseidon2::Poseidon2::hash(hash_inputs, 2);
let balance = storage.group_balances.at(key).read();
let amount_to_write = amount + balance;
storage.group_balances.at(key).write(amount_to_write);
}

I will outline the main differences below:

  1. Access Control — You can see how in the public context it is easy to read the storage values as they are open to everyone and do not have to fetch notes.
  2. Increasing the storage — The key difference here is that you can directly access the value of the storage, and then overwrite it with the new value, in the private context, we are just adding a note to the PrivateSet of notes, we do not have to know the previous balance, just add the new note.

Making a Payment

Here is the function for the contract that will act like making a payment between two members.

#[private]
fn make_payment(debtor: AztecAddress, creditor: AztecAddress, amount: Field) {
let sender = context.msg_sender();
let location = storage.group_members.at(sender);
assert(membership_check(location), "Sender is not in the group");

//notes for the creditor
let hash_inputs_credit = [creditor.to_field(), debtor.to_field()];
let key_credit = poseidon2::Poseidon2::hash(hash_inputs_credit, 2);
let loc_credit = storage.group_balances_credit.at(key_credit);
decrement(loc_credit, amount, creditor, debtor);

// notes for the debtor
let hash_inputs_debt = [debtor.to_field(), creditor.to_field()];
let key_debt = poseidon2::Poseidon2::hash(hash_inputs_debt, 2);
let loc_debt = storage.group_balances_debt.at(key_debt);
decrement(loc_debt, amount, debtor, creditor);
}

The only difference with this and make payment is the decrement function, another helper from the Value_note library, it behaves slightly different to increment and we will go through this below

// Find some of the `owner`'s notes whose values add up to the `amount`.
// Remove those notes.
// If the value of the removed notes exceeds the requested `amount`, create a new note containing the excess value, so that exactly `amount` is removed.
// Fail if the sum of the selected notes is less than the amount.
pub fn decrement(
balance: PrivateSet<ValueNote, &mut PrivateContext>,
amount: Field,
owner: AztecAddress,
outgoing_viewer: AztecAddress
) {
let sum = decrement_by_at_most(balance, amount, owner, outgoing_viewer);
assert(sum == amount, "Balance too low");
}

// Sort the note values (0th field) in descending order.
// Pick the fewest notes whose sum is equal to or greater than `amount`.
pub fn create_note_getter_options_for_decreasing_balance(amount: Field) -> NoteGetterOptions<ValueNote, VALUE_NOTE_LEN, Field, Field> {
NoteGetterOptions::with_filter(filter_notes_min_sum, amount).sort(ValueNote::properties().value, SortOrder.DESC)
}

// Similar to `decrement`, except that it doesn't fail if the decremented amount is less than max_amount.
// The motivation behind this function is that there is an upper-bound on the number of notes a function may
// read and nullify. The requested decrementation `amount` might be spread across too many of the `owner`'s
// notes to 'fit' within this upper-bound, so we might have to remove an amount less than `amount`. A common
// pattern is to repeatedly call this function across many function calls, until enough notes have been nullified to
// equal `amount`.
//
// It returns the decremented amount, which should be less than or equal to max_amount.
pub fn decrement_by_at_most(
balance: PrivateSet<ValueNote, &mut PrivateContext>,
max_amount: Field,
owner: AztecAddress,
outgoing_viewer: AztecAddress
) -> Field {
let options = create_note_getter_options_for_decreasing_balance(max_amount);
let notes = balance.pop_notes(options);
let mut decremented = 0;
for i in 0..options.limit {
if i < notes.len() {
let note = notes.get_unchecked(i);
decremented += note.value;
}
}
// Add the change value back to the owner's balance.
let mut change_value = 0;
if max_amount.lt(decremented) {
change_value = decremented - max_amount;
decremented -= change_value;
}
increment(balance, change_value, owner, outgoing_viewer);
decremented
}

This uses the decrement_by_at_most function which retrieves the notes that a user has, iterates over the selected notes and sums their value. If the total value of the removed notes exceeds the max_amount , it calculates the change_value and reduces decremented by this value. Then increments by the change value, creates a new note with this new value which is placed in storage.

Here you can see one of the filters for the NoteGetterOptions being used to pick the fewest notes whose sum is equal to or greater than amount.

In the public context, this is very similar to the set_balance function we used before, except it would reduce the value by the amount specified. This again shows you the added complexity of using notes and how you can think of it equivalently to working with real banknotes.

#[public]
fn make_payment(creditor: AztecAddress, debtor: AztecAddress, amount: Field) {
assert(creditor == context.msg_sender(), "cannot adjust someone else's balances");
assert(storage.group_members.at(creditor).read() == true, "Creditor is not in group");
assert(storage.group_members.at(debtor).read() == true, "Debtor is not in group");

// Hash the addresses together
let hash_inputs = [creditor.to_field(), debtor.to_field()];
let key = poseidon2::Poseidon2::hash(hash_inputs, 2);
let balance = storage.group_balances.at(key).read();
assert(balance >= amount, "payment is larger than the balance")
let amount_to_write = balance - amount;
storage.group_balances.at(key).write(amount_to_write);
}

Setup Group Payments

In this function, we are going to replicate what would happen if you were to increase the balance for multiple members, similar to how you would share an expense and spread it equally between the group members.

#[private]
fn setup_group_payments(creditor: AztecAddress, debtors: [AztecAddress; 2], amount: Field) {
let sender = context.msg_sender();
let location = storage.group_members.at(sender);
assert(membership_check(location), "Sender is not in the group");

//increase the credit for the creditor with each member
let shared_amount: u32 = amount as u32 / (debtors.len() + 1);
for i in 0..2 {
let debtor = debtors[i];
let hash_inputs_credit = [creditor.to_field(), debtor.to_field()];
let key_credit = poseidon2::Poseidon2::hash(hash_inputs_credit, 2);
let loc_credit = storage.group_balances_credit.at(key_credit);
increment(loc_credit, shared_amount.to_field(), creditor, debtor);
}
//increase the debt for each member with the creditor
for i in 0..2 {
let debtor = debtors[i];
let hash_inputs_debt = [debtor.to_field(), creditor.to_field()];
let key_debt = poseidon2::Poseidon2::hash(hash_inputs_debt, 2);
let loc_debt = storage.group_balances_debt.at(key_debt);
increment(loc_debt, shared_amount.to_field(), debtor, creditor);
}
}

It is very similar to the set_balance function, except we find out the amount that is being shared between the group_members and updates the credit and debt of each group member by storing and emitting notes that these members can decrypt.

How this would look like in the Public Context

#[public]
fn split_group_balance(
creditor: AztecAddress,
debtors: [AztecAddress; 2],
amount: u64,
) {
assert(creditor == context.msg_sender(), "cannot adjust someone else's balances");
assert(storage.group_members.at(creditor).read() == true, "Must be part of the group");

let amount_per_participant = amount / (debtors.len()+ 1);
for i in 0..2 {
let debtor = debtors[i as u32];
assert(
storage.group_members.at(debtor).read() == true,
"Debtor is not part of the group",
);
let hash_inputs = [creditor.to_field(), debtor.to_field()];
let key = poseidon2::Poseidon2::hash(hash_inputs, 2);
let current_balance = storage.group_balances.at(key).read() as u64;
let new_balance = current_balance + amount_per_participant;
storage.group_balances.at(key).write(new_balance.to_field());
}
}
}
}

As you can see, the increment helper function, is starting to look very similar for the public and private context.

Reading Balances

The next and final part of the contract includes reading balances for credit, debt and the total amount.

#[private]
#[view]
fn read_balance_credit(creditor: AztecAddress, debtor: AztecAddress) -> u64 {
let hash_inputs_credit = [creditor.to_field(), debtor.to_field()];
let key_credit = poseidon2::Poseidon2::hash(hash_inputs_credit, 2);
let location = storage.group_balances_credit.at(key_credit);
let balance = get_balance(location);
balance as u64
}

#[private]
#[view]
fn read_balance_debt(debtor: AztecAddress, creditor: AztecAddress) -> u64 {
let hash_inputs_debt = [debtor.to_field(), creditor.to_field()];
let key_debt = poseidon2::Poseidon2::hash(hash_inputs_debt, 2);
let location = storage.group_balances_debt.at(key_debt);
let balance = get_balance(location);
balance as u64
}

#[private]
#[view]
fn read_total_balance(creditor: AztecAddress, debtor: AztecAddress) -> i64 {
//going to have to hand type this, could not call the other private functions
//keys to check in storage
let hash_inputs_credit = [creditor.to_field(), debtor.to_field()];
let key = poseidon2::Poseidon2::hash(hash_inputs_credit, 2);
let location_credit = storage.group_balances_credit.at(key);
let location_debt = storage.group_balances_debt.at(key);
let credit = get_balance(location_credit);
let debt = get_balance(location_debt);
let credit_int = credit as i64;
let debt_int = debt as i64;
let total_balance = credit_int - debt_int;
total_balance
}

As you can see here we have three functions that follow the same flow. I will go over the first one, read_balance_credit and the others are equivalent. They all use the get_balance helper function which I will show below.

get_balance helper function

Lets go back to our helpers.nr file that we created earlier. Add this import at the top of the file and add the get_balance function into the file.

use dep::value_note::{value_note::{ValueNote, VALUE_NOTE_LEN}};
pub fn get_balance(location: PrivateSet<ValueNote, &mut PrivateContext>) -> pub u64 {
let options = NoteGetterOptions::new();
let balance_notes: BoundedVec<ValueNote, 16> = location.get_notes(options);

let mut total_balance = 0 as Field;
for i in 0..balance_notes.max_len() {
if i < balance_notes.len() {
let note = balance_notes.get_unchecked(i);
total_balance += note.value;
}
}
total_balance as u64
}

This is a function that deals with the note-fetching logic. It fetches the notes at a particular storage location. As you can see it is very similar to the flow we have used before.

  • The input is the storage location which is a PrivateSet of ValueNotes within the PrivateContext.
  • Specify the options for the notes you would like to fetch.
  • Retrieve the notes from the in a Bounded Vector with a maximum length of 16. This is a constraint at the minute with the maximum number of notes that you can receive at the same time. More information on this can be found here. As Aztec is in Alpha, these are always changing so keep that in mind.
  • We calculate the total balance by iterating over the values within the notes, these are Fields so we then cast the balance as an unsigned integer.

Read Total Balance

This function retrieves the credit and debt values for the notes at a particular storage slot and calculates the sum. We calculate this as a signed integer because if the debt is more than the credit we do not want this to overflow to a very large number.

Complete Contract

The complete contract can be found below

mod NewAddressNote;
mod helpers;
use dep::aztec::macros::aztec;

#[aztec]
contract PrivateGroups {
use aztec::{
prelude::{Map, AztecAddress, PrivateImmutable, PrivateSet},
encrypted_logs::encrypted_note_emission::encode_and_encrypt_note,
keys::getters::get_public_keys,
macros::{
storage::storage, events::event,
functions::{public, initializer, private, internal, view},
},
};
use aztec::note::note_getter::NoteGetterOptions;
use crate::types::NewAddressNote::NewAddressNote;
use crate::helpers::{get_balance, membership_check};
use value_note::{utils::{increment, decrement}, value_note::ValueNote};
use std::hash::poseidon2;
global ZERO_ADDRESS: AztecAddress = AztecAddress::from_field(
0x0000000000000000000000000000000000000000000000000000000000000000,
);
//Contract Storage
// - `admin`: The admin who creates and manages the group
// - `group_members`: A map of group members, each with a private set of other group members so they can see all members
// - `group_balances_credit`: A map of balances owed to the creditor, stored with a unique key for each creditor and debtor pair
// - `group_balances_debt`: A map of balances owed by the debtor, stored with a unique key for each debtor and creditor pair
#[storage]
struct Storage<Context> {
admin: PrivateImmutable<NewAddressNote, Context>,
group_members: Map<AztecAddress, PrivateSet<NewAddressNote, Context>, Context>,
group_balances_credit: Map<Field, PrivateSet<ValueNote, Context>, Context>,
group_balances_debt: Map<Field, PrivateSet<ValueNote, Context>, Context>,
}
// Contract Constructor:
// - The admin creates the group and initializes group members.
// - Each group member is added to the private set, allowing them to see other members.
// - This operation can be expensive due to encryption and note handling for each member.
#[private]
#[initializer]
fn constructor(admin: AztecAddress, group_members: [AztecAddress; 3]) {
//setting the admin address note
let admin_keys = get_public_keys(admin);
let mut admin_address_note = NewAddressNote::new(admin, admin_keys.npk_m.hash());
storage.admin.initialize(&mut admin_address_note).emit(encode_and_encrypt_note(
&mut context,
admin_keys.ovpk_m,
admin_keys.ivpk_m,
admin,
));
// this is for adding members to the private set, so they can see the other members, going to be expensive
for i in 0..3 {
let member = group_members[i as u32];
if (member != ZERO_ADDRESS) {
let member_keys = get_public_keys(member);
for i in 0..3 {
let member_add = group_members[i as u32];
let mut memberNote = NewAddressNote::new(member_add, member_keys.npk_m.hash());
storage.group_members.at(member).insert(&mut memberNote).emit(
encode_and_encrypt_note(
&mut context,
admin_keys.ovpk_m,
member_keys.ivpk_m,
member,
),
);
}
}
}
}
// Retrieves the admin's address.
// for testing purposes, only the admin can read this.
#[private]
#[view]
fn get_admin() -> pub AztecAddress {
let admin_note = storage.admin.get_note();
admin_note.address
}
//For each group member, we have a private set of members so that they can see all of the members
#[private]
#[view]
fn get_group_members(member: AztecAddress) -> [AztecAddress; 3] {
let mut options = NoteGetterOptions::new();
let member_note = storage.group_members.at(member).get_notes(options);
let mut member_array: [AztecAddress; 3] = [ZERO_ADDRESS; 3];
//if you call this and u are not part of the group, it will return the zero address
for i in 0..3 {
let note = member_note.get_unchecked(i);
let address: AztecAddress = note.address;
member_array[i] = address;
}
member_array
}
//Setting the balance for the creditor and debtor
#[private]
fn set_balance(creditor: AztecAddress, debtor: AztecAddress, amount: Field) {
//This is just an assertion that the sender is part of the group.
let sender = context.msg_sender();
let location = storage.group_members.at(sender);
assert(membership_check(location), "Sender is not in the group");
//notes for the creditor
let hash_inputs_credit = [creditor.to_field(), debtor.to_field()];
let key_credit = poseidon2::Poseidon2::hash(hash_inputs_credit, 2);
let loc_credit = storage.group_balances_credit.at(key_credit);
increment(loc_credit, amount, creditor, debtor);
// notes for the debtor
let hash_inputs_debt = [debtor.to_field(), creditor.to_field()];
let key_debt = poseidon2::Poseidon2::hash(hash_inputs_debt, 2);
let loc_debt = storage.group_balances_debt.at(key_debt);
increment(loc_debt, amount, debtor, creditor);
}
//can have a timestamp here for when the payment is made.
#[private]
fn make_payment(debtor: AztecAddress, creditor: AztecAddress, amount: Field) {
let sender = context.msg_sender();
let location = storage.group_members.at(sender);
assert(membership_check(location), "Sender is not in the group");
//notes for the creditor
let hash_inputs_credit = [creditor.to_field(), debtor.to_field()];
let key_credit = poseidon2::Poseidon2::hash(hash_inputs_credit, 2);
let loc_credit = storage.group_balances_credit.at(key_credit);
decrement(loc_credit, amount, creditor, debtor);
// notes for the debtor
let hash_inputs_debt = [debtor.to_field(), creditor.to_field()];
let key_debt = poseidon2::Poseidon2::hash(hash_inputs_debt, 2);
let loc_debt = storage.group_balances_debt.at(key_debt);
decrement(loc_debt, amount, debtor, creditor);
}
#[private]
fn setup_group_payments(creditor: AztecAddress, debtors: [AztecAddress; 2], amount: Field) {
let sender = context.msg_sender();
let location = storage.group_members.at(sender);
assert(membership_check(location), "Sender is not in the group");
//increase the credit for the creditor with each member
let shared_amount: u32 = amount as u32 / (debtors.len() + 1);
for i in 0..2 {
let debtor = debtors[i];
let hash_inputs_credit = [creditor.to_field(), debtor.to_field()];
let key_credit = poseidon2::Poseidon2::hash(hash_inputs_credit, 2);
let loc_credit = storage.group_balances_credit.at(key_credit);
increment(loc_credit, shared_amount.to_field(), creditor, debtor);
}
//increase the debt for each member with the creditor
for i in 0..2 {
let debtor = debtors[i];
let hash_inputs_debt = [debtor.to_field(), creditor.to_field()];
let key_debt = poseidon2::Poseidon2::hash(hash_inputs_debt, 2);
let loc_debt = storage.group_balances_debt.at(key_debt);
increment(loc_debt, shared_amount.to_field(), debtor, creditor);
}
}
#[private]
#[view]
fn read_balance_credit(creditor: AztecAddress, debtor: AztecAddress) -> u64 {
let hash_inputs_credit = [creditor.to_field(), debtor.to_field()];
let key_credit = poseidon2::Poseidon2::hash(hash_inputs_credit, 2);
let location = storage.group_balances_credit.at(key_credit);
let balance = get_balance(location);
balance as u64
}
#[private]
#[view]
fn read_balance_debt(debtor: AztecAddress, creditor: AztecAddress) -> u64 {
let hash_inputs_debt = [debtor.to_field(), creditor.to_field()];
let key_debt = poseidon2::Poseidon2::hash(hash_inputs_debt, 2);
let location = storage.group_balances_debt.at(key_debt);
let balance = get_balance(location);
balance as u64
}

#[private]
#[view]
fn read_total_balance(creditor: AztecAddress, debtor: AztecAddress) -> i64 {
//going to have to hand type this, could not call the other private functions
//keys to check in storage
let hash_inputs_credit = [creditor.to_field(), debtor.to_field()];
let key = poseidon2::Poseidon2::hash(hash_inputs_credit, 2);
let location_credit = storage.group_balances_credit.at(key);
let location_debt = storage.group_balances_debt.at(key);
let credit = get_balance(location_credit);
let debt = get_balance(location_debt);
let credit_int = credit as i64;
let debt_int = debt as i64;
let total_balance = credit_int - debt_int;
total_balance
}
}

And our helpers.nr file looks like this.

use dep::aztec::prelude::{AztecAddress, PrivateContext, PrivateSet, NoteGetterOptions};
use dep::value_note::{value_note::{ValueNote, VALUE_NOTE_LEN}};
use crate::NewAddressNote::NewAddressNote;

global ZERO_ADDRESS: AztecAddress = AztecAddress::from_field(
0x0000000000000000000000000000000000000000000000000000000000000000,
);

pub fn get_balance(location: PrivateSet<ValueNote, &mut PrivateContext>) -> pub u64 {
let options = NoteGetterOptions::new();
let balance_notes: BoundedVec<ValueNote, 16> = location.get_notes(options);
let mut total_balance = 0 as Field;
for i in 0..balance_notes.max_len() {
if i < balance_notes.len() {
let note = balance_notes.get_unchecked(i);
total_balance += note.value;
}
}
total_balance as u64
}

pub fn membership_check(location: PrivateSet<NewAddressNote, &mut PrivateContext>) -> pub bool {
let options = NoteGetterOptions::new();
let member_note = location.get_notes(options);
let mut member_array: [AztecAddress; 3] = [ZERO_ADDRESS; 3];
for i in 0..3 {
let note = member_note.get_unchecked(i);
let address: AztecAddress = note.address;
member_array[i] = address;
assert(address != ZERO_ADDRESS, "Sender is not in the group");
}
true
}

Creating the NewAddressNote

Disclaimer: This is for 0.60.0, there are new versions available.

As we discussed when making the contract, we have had this new address note which we used many times but did not define. Let’s do this now.

Notes in Aztec can hold an arbitrary amount of information, just be mindful that the more information that you hold in a note, the more expensive it becomes.

New File Structure

Create a new file.

|-- src
| |-- circuits
|. | |-- src
|. |. |. |-- helpers.nr
|. |. |. |-- NewAddressNote.nr
| | | |-- main.nr
| | |-- Nargo.toml
|-- package.json
|-- yarn.lock

At the top of the contract file include, the module below the helpers module that we added earlier.

mod NewAddressNote;

Structure

Within the NewAddressNote file include this code. We will go through it below.

use dep::aztec::{
protocol_types::{
address::AztecAddress,
traits::{Serialize, Deserialize},
constants::GENERATOR_INDEX__NOTE_NULLIFIER,
hash::poseidon2_hash_with_separator
},
macros::notes::note,
note::{note_header::NoteHeader, note_interface::NullifiableNote, utils::compute_note_hash_for_nullify},
oracle::random::random,
keys::getters::get_nsk_app,
context::PrivateContext
};

// Custom Address Note implementation, as the default one lacks serialization and the Eq trait.
#[note]
#[derive(Serialize)] // Adds serialization support for the struct.
#[derive(Deserialize)] // Adds deserialization support for the struct.
struct NewAddressNote {
address: AztecAddress, // The AztecAddress for the note.
// The nullifying public key hash is used with the nsk_app to ensure that the note can be privately spent.
randomness: Field,
npk_m_hash: Field,
}

impl NullifiableNote for NewAddressNote {
/**
* Computes the nullifier for the note using the provided private context.
* This is necessary to ensure that the note can be nullified (spent) privately.
*
* @param context - A mutable reference to the private context, used to retrieve the note's nullifying secret key.
* @param note_hash_for_nullify - The precomputed note hash used as part of the nullifier.
* @returns A `Field` value representing the nullifier for this note.
*/
fn compute_nullifier(self, context: &mut PrivateContext, note_hash_for_nullify: Field) -> Field {
// Retrieves the nullifying secret key from the context.
let secret = context.request_nsk_app(self.npk_m_hash);

// Uses Poseidon hashing to compute the nullifier, incorporating the note hash and the secret.
poseidon2_hash_with_separator(
[
note_hash_for_nullify,
secret
],
GENERATOR_INDEX__NOTE_NULLIFIER as Field
)
}
/**
* Computes the nullifier for the note without needing the context.
* This is an unconstrained function that allows generating the nullifier for use in tests or specific use cases
* where the context is not required.
*
* @returns Field value representing the nullifier for this note, computed without context.
*/
unconstrained fn compute_nullifier_without_context(self) -> Field {
// Computes the note hash for nullification.
let note_hash_for_nullify = compute_note_hash_for_nullify(self);
// Retrieves the nullifying secret key directly without context.
let secret = get_nsk_app(self.npk_m_hash);

// Uses Poseidon hashing to compute the nullifier, incorporating the note hash and the secret.
poseidon2_hash_with_separator(
[
note_hash_for_nullify,
secret
],
GENERATOR_INDEX__NOTE_NULLIFIER as Field
)
}
}

impl NewAddressNote {
/**
* Creates a new instance of `NewAddressNote` with the provided Aztec address and nullifying public key hash.
*
* @param address - The AztecAddress associated with the note.
* @param npk_m_hash - The nullifying public key hash, used to ensure private spending.
* @returns A new `NewAddressNote` instance.
*/
pub fn new(address: AztecAddress, npk_m_hash: Field) -> Self {
// Initializes an empty note header (can be extended with more fields if necessary).
let randomness = unsafe { random() };
let header = NoteHeader::empty();
NewAddressNote { address, randomness, npk_m_hash, header }
}
}

impl Eq for NewAddressNote {
/**
* Custom equality implementation for `NewAddressNote`.
* Compares the address and npk_m_hash fields to determine if two notes are equal.
*
* @param other - The other note to compare with.
* @returns `true` if both notes are equal, `false` otherwise.
*/
fn eq(self, other: Self) -> bool {
(self.address == other.address) & (self.npk_m_hash == other.npk_m_hash) & (self.randomness == other.randomness)
}
}

Breakdown of the Note

Imports

AztecAddress - The Aztec Address Type

Serialize and Deserialise traits - Used to convert the data structures to and from a serialized format, which is needed for storing and transmitting data. Serialized converts the data structure into a array of fields. Deserialize is needed for restructuring the original data back to its original form from its serialized form.

GENERAOR_INDEX_NOTE_NULLIFIER - This is a constant used by Aztec to specify a particular generator index for hashing operations related to note nullifiers.

posedon2_hash_with_separator - Uses the Poseidon hash to compute the nullifier.

note macro - used to define custom not types within smart contracts.

NoteHeader - struct that represents the header of a note, the contract address, nonce, storage_slot and note_hash_counter.

NullifiableNote - trait that defines the interface for notes that can be nullified

compute_note_hash_for_nullify - computes the hash of a note for the nullification process

random - returns an unconstrained random value

get_nsk_app - helper function to retrieve the nullifier secret key for a given npk_m_hash

These are all helper functions but hopefully this gives you an idea of what they are doing in the context of creating a custom note

Struct Definition

The NewAddressNote is a struct defined with three fields,

address - An aztec address representing the address associated with the note.

randomness - A field used to add randomness to enhance privacy for the note, obtained through the random oracle.

npk_m_hash - A field representing the nullifying public key hash, ensuring the note can be privately spent by the owner.

Macros

Serialize, Deserialize and note are macros used to define the note.

NullifiableNote Trait Implementation

Calculates the nullifier for the note which allows the note to be marked as spent. there are two functions to calculate this, with and without context.

NewAddressNote Methods

This includes a constructor method for creating a new note.

The header for the note is set to empty and the random value is generated by the oracle.

Eq Trait Implementation

Eq implements comparison for NewAddressNote , compares the address, npk_m_hash and randomness fields to determine if two notes are equal.

Custom Notes

The patterns used in creating a new address note hold true for creating any custom note, the logic remains the same and then you would change the address in the note Struct to be any arbitrary value. For example, this is the implementation of the value note for comparison.

use dep::aztec::{
protocol_types::{
traits::Serialize, constants::GENERATOR_INDEX__NOTE_NULLIFIER,
hash::poseidon2_hash_with_separator,
}, macros::notes::note,
note::{
note_header::NoteHeader, note_interface::NullifiableNote,
utils::compute_note_hash_for_nullify,
}, oracle::random::random, keys::getters::get_nsk_app, context::PrivateContext,
};

// It is important that the order of these annotations is preserved so that derive(Serialize) runs AFTER the note macro, which injects the note header.
#[note]
#[derive(Serialize)]
pub struct ValueNote {
value: Field,
// The nullifying public key hash is used with the nsk_app to ensure that the note can be privately spent.
npk_m_hash: Field,
randomness: Field,
}


impl NullifiableNote for ValueNote {

fn compute_nullifier(
self,
context: &mut PrivateContext,
note_hash_for_nullify: Field,
) -> Field {
let secret = context.request_nsk_app(self.npk_m_hash);
poseidon2_hash_with_separator(
[note_hash_for_nullify, secret],
GENERATOR_INDEX__NOTE_NULLIFIER as Field,
)
}

unconstrained fn compute_nullifier_without_context(self) -> Field {
let note_hash_for_nullify = compute_note_hash_for_nullify(self);
let secret = get_nsk_app(self.npk_m_hash);
poseidon2_hash_with_separator(
[note_hash_for_nullify, secret],
GENERATOR_INDEX__NOTE_NULLIFIER as Field,
)
}
}

impl ValueNote {
pub fn new(value: Field, npk_m_hash: Field) -> Self {
// We use the randomness to preserve the privacy of the note recipient by preventing brute-forcing, so a
// malicious sender could use non-random values to make the note less private. But they already know the full
// note pre-image anyway, and so the recipient already trusts them to not disclose this information. We can
// therefore assume that the sender will cooperate in the random value generation.
let randomness = unsafe { random() };
let header = NoteHeader::empty();
ValueNote { value, npk_m_hash, randomness, header }
}
}

impl Eq for ValueNote {
fn eq(self, other: Self) -> bool {
(self.value == other.value)
& (self.npk_m_hash == other.npk_m_hash)
& (self.randomness == other.randomness)
}
}

You can see the similarities between the two notes and can now use this as the basis of creating and implementing your own custom note types.

Finalising your Contract

Now you have all the pieces to make your contract.

Run this command to compile your contract, from the root of our project

cd src && cd circuits && aztec-nargo compile

Testing your Contract

Now we have created the contract, let’s make some tests for the contract.

There are two main ways to test your contracts in Aztec.

  1. End-to-end tests with AztecJS
  2. Localised testing with the TXE (Testing Execution Environment)

Exploring the two testing options

End-to-End Tests using AztecJS

These are tests that leverage the jest testing framework, AztecJs and the PXE. This is suitable for testing and simulating real-world interactions with your contract.

These tests are slower to run but follow the rules and scenarios that your contract will experience in the real world.

A guide for some more context can be found here.

Testing Using the TXE

The TXE allows you to test your contract and are written in Noir. The tests are executed in a JSON RPC server similar to the PXE but with additional Oracle functions called “cheatcodes” for state manipulation.

It is designed for fast and iterative testing of smart contract logic without the need for full protocol checks.

You can access specific storage slots within your contract to make sure that they are being populated correctly.

A quick guide for some more context can be found here.

Note: TXE tests are written in Noir and executed with aztec-nargo , they all run in parallel. This means that every test creates its own isolated environment, so state modifications are local to each one of them.

Comparison

End-to-end Tests are best for comprehensive testing that mimics real-world usage and ensures protocol compliance

The TXE is best for rapid testing of contract logic and isolated functions with a sprinkle of “magic” without full protocol simulation.

End-to-end Tests with Typescript

Creating our TS Artifact

When we compiled our contract before we created a target folder which contains two json files. The first is the Noir ABI artifact for our contract. This contains all of the information needed to interact with our contract. The second is a backup.

To make this usable by AztecJS we need to run the following command in the privategroup directory,

aztec codegen -o src/artifacts target

This will create a new folder called artifacts within our contract src folder. Have a look at the PrivateGroups.ts file that should be inside there now. This is what we are going to use to interact with out contract. You will see that is contains the PrivateGroupContract instance, various deploy methods, our contract storage, notes, and methods for our contract.

Issue: There is currently an issue with the artifact generation that will be patched soon.

You will need to cast the type of the PrivateGroupsContractArtifactJson to unknown for it to work in the current version.

export const PrivateGroupsContractArtifact = loadContractArtifact(
PrivateGroupsContractArtifactJson as unknown as NoirCompiledContract
);

Setting Up Our Testing Environment

Let’s update the structure of our project so that it looks like this:

I will go through the main principles of setting up your testing environment and the different calls and patterns you will need.

|-- src
| |-- circuits
| | |-- src
|. |. |. |-- helpers.nr
|. |. |. |-- NewAddressNote.nr
| | | |-- main.nr
| | |-- Nargo.toml
|-- test
| |-- index.test.ts
| |-- utils.ts
|-- package.json
|-- yarn.lock
|-- jest.integration.config.json
|-- tsconfig.json

Here we are adding the test folder, index.test.ts & utils.ts file, a tsconfig.json and a jest.integration.config.json.

Updating Package.json

In the root of your project add the following dependencies.

yarn add @aztec/noir-contracts.js@0.60.0 @aztec/accounts@0.60.0 @aztec/aztec.js@0.60.0 @aztec/builder@0.60.0 typescript@^5.6.2 @types/node@^22.5.4

And dev dependencies

yarn add --dev @jest/globals@^29.7.0 @types/jest@^29.5.13 @types/mocha@^10.0.6 @typescript-eslint/eslint-plugin@^6.0.0 @typescript-eslint/parser@^6.0.0 ts-jest@^29.2.5 ts-loader@^9.5.1 ts-node@^10.9.2 jest@^29.7.0

Also add these scripts for ease of use in your package.json, these will allow us to run the most important commands in our root directory.

"scripts": {
"clean": "rm -rf ./src/circuits/artifacts ./src/circuits/target",
"codegen": "aztec codegen ./src/circuits/target --outdir src/circuits/src/artifacts",
"compile": "cd src/circuits && ${AZTEC_NARGO:-aztec-nargo} compile && cd ../..",
"test": "NODE_NO_WARNINGS=1 node --experimental-vm-modules $(yarn bin jest) --no-cache --runInBand --config jest.integration.config.json && cd src/privategroup && aztec test"
}

jest.integration.config.json

Create a jest config file in the root directory.

{
"preset": "ts-jest/presets/default-esm",
"globals": {
"ts-jest": {
"useESM": true
}
},
"moduleNameMapper": {
"^(\\\\.{1,2}/.*)\\\\.js$": "$1"
},
"testRegex": ".*\\\\.test\\\\.ts$",
"rootDir": ".",
"testTimeout": 30000
}

tsconfig.json

Create a tsconfig file in the root directory of your project.

{
"compilerOptions": {
"outDir": "dist",
"tsBuildInfoFile": ".tsbuildinfo",
"target": "es2020",
"lib": ["esnext", "dom", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"declaration": true,
"allowSyntheticDefaultImports": true,
"allowJs": true,
"esModuleInterop": true,
"downlevelIteration": true,
"inlineSourceMap": true,
"declarationMap": true,
"importHelpers": true,
"resolveJsonModule": true,
"composite": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"typeRoots": [
"./node_modules/@types"
]
},
"include": [
"src/**/*.ts",
"test/**/*.ts"
]
}

Imports

Now lets head into our index.test.ts file.

import {
AccountWallet,
CompleteAddress,
ContractDeployer,
createDebugLogger,
Fr,
waitForPXE,
TxStatus,
createPXEClient,
getContractInstanceFromDeployParams,
Contract,
GrumpkinScalar,
PXE,
DebugLogger,
AztecAddress,
} from "@aztec/aztec.js";
import {
PrivateGroupsContractArtifact,
PrivateGroupsContract,
} from "../src/circuits/src/artifacts/PrivateGroups";
import { setupSandbox, createAccount } from "./utils";

Let’s briefly go through these imports

AccountWallet: Wallet that can manage accounts and interact with contracts.

CompleteAddress: A type representing a fully resolved address, including the AztecAddress, Public Keys and PartialAddress.

createDebugLogger: A function to create a logger for debugging purposes.

Contract: Represents a deployed contract on the Aztec network, allowing interaction with its methods.

PXE: Represents the Private Execution Environment, which handles private transactions and state.

DebugLogger: A type for a logger used for debugging purposes.

AztecAddress: Represents an address in the Aztec network, which may include additional cryptographic information.

PrivateGroupContract & Artifact - From the TS artifact that we just generated.

setupSandbox createAccount - helper functions from our utils file we will go through now.

Utils.ts

Create a new file in the same directory called utils.ts

import {
AztecAddress,
createPXEClient,
deriveMasterIncomingViewingSecretKey,
Fr,
GrumpkinScalar,
PXE,
Schnorr,
} from "@aztec/aztec.js";
import { SingleKeyAccountContract } from "@aztec/accounts/single_key";
import { AccountManager, AccountWalletWithSecretKey } from "@aztec/aztec.js";
import { waitForPXE } from "@aztec/aztec.js";

export const setupSandbox = async () => {
const { PXE_URL = "<http://localhost:8080>" } = process.env;
const pxe = createPXEClient(PXE_URL);
await waitForPXE(pxe);
return pxe;
};

export const createAccount = async (pxe: PXE) => {
// Generate a new secret key for each wallet
const secretKey = Fr.random();
const encryptionPrivateKey = deriveMasterIncomingViewingSecretKey(secretKey);
const accountContract = new SingleKeyAccountContract(encryptionPrivateKey);
// Create a new AccountManager instance
const account = new AccountManager(pxe, secretKey, accountContract);
// Register the account and get the wallet
const wallet = await account.register(); // Returns AccountWalletWithSecretKey
return wallet;
};

These helper functions set up the PXE and create the accounts that we are going to use to interact with the contract.

createAccount

  1. We are generating a secretKey for the account with a random value.
  2. We are using this to derive the encryption key with the deriveMasterIncomingViewingSecretKey helper function.
  3. Creating an account contract instance with this key. SingleKeyAccountContract is a type of account contract. We will not worry to much about the details but remember in Aztec Accounts are just smart contracts.
  4. We then create an AccountManager instance with the PXE that it is going to be registered on, the secretKey and the account contract.
  5. We then register this wallet on the network.
  6. Return the wallet.

Setting up the Testing Environment

Under the imports, paste this code which we will go through below

describe("PrivateGroups", () => {
let pxe: PXE;
let wallets: AccountWallet[] = [];
let accounts: CompleteAddress[] = [];
let addresses: string[] = [];
let logger: DebugLogger;

//Contract
let private_group_contract: Contract;

//Member Wallets
let adminWallet: AccountWallet;
let aliceWallet: AccountWallet;
let bobWallet: AccountWallet;

//Member addresses
let adminAddress: AztecAddress;
let aliceAddress: AztecAddress;
let bobAddress: AztecAddress;

//ContractInstances
let adminInstance: PrivateGroupsContract;
let aliceInstance: PrivateGroupsContract;
let bobInstance: PrivateGroupsContract;

beforeAll(async () => {
logger = createDebugLogger("aztec:PrivateGroups");
logger.info("Aztec-PrivateGroups tests running");

// Setup PXE
pxe = await setupSandbox();
const GroupsArtifact = PrivateGroupsContractArtifact;
console.log("GroupsArtifact");

// Create admin, Alice, and Bob wallets
adminWallet = await createAccount(pxe);
aliceWallet = await createAccount(pxe);
bobWallet = await createAccount(pxe);
console.log("wallets created");

// Store wallets
wallets = [adminWallet, aliceWallet, bobWallet];

// Store complete addresses
accounts = [
adminWallet.getCompleteAddress(),
aliceWallet.getCompleteAddress(),
bobWallet.getCompleteAddress(),
];
console.log("accounts created");

// Store just the wallet addresses
addresses = [
adminWallet.getCompleteAddress().address.toString(),
aliceWallet.getCompleteAddress().address.toString(),
bobWallet.getCompleteAddress().address.toString(),
];
console.log("addresses", addresses);

// Deploy contract with admin address
adminAddress = adminWallet.getCompleteAddress().address;
aliceAddress = aliceWallet.getCompleteAddress().address;
bobAddress = bobWallet.getCompleteAddress().address;
console.log("addresses", addresses);

private_group_contract = await Contract.deploy(
adminWallet,
GroupsArtifact,
[adminAddress, [adminAddress, aliceAddress, bobAddress]]
)
.send()
.deployed();
console.log("contract deployed");
});
  1. At the start we declare all of the variables that we are going to use during the tests.
  • Wallets & Accounts
  • Member Wallets
  • Contract and Instances

2. beforeAll hook

  • Logging Initialization: Sets up a debug logger for test information, helpful for debugging.
  • Sandbox Setup: The PXE (Private Execution Environment) is initialized with setupSandbox, simulating a test blockchain environment.
  • GroupsArtifact — This is just an alias of our PrivateGroupsContractArtifact
  • Wallet Creation — We create three wallets that we are going to use throughout the tests to simulate different users of the contract. Admin, Alice and Bob
  • Address Collection — With these wallets we can extract the addresses from the wallets using wallet.getCompleteAddress().address
  • Contract Deployment — The contract is deployed with Contract.deploy , we need to pass in the adminWallet as the deployer, the contract artifact which is our alias GroupsArtifact and the constructor arguments which are the admin and group_members . After deployment send().deployed(), ensures the contract is deployed.

Setting up an instance of the contract to call functions

it("should have added all members to group", async () => {
adminInstance = await PrivateGroupsContract.at(
private_group_contract.address,
adminWallet
);

//assume we are impersonating the admin
let getMembers = await adminInstance.methods
.get_group_members(adminAddress)
.simulate();
expect(getMembers).toEqual([adminAddress, aliceAddress, bobAddress]);
}, 300_000);
  1. Create an Instance of the Contract for the Admin

You can see here we initialize the admins instance of the contract using the contracts address and the admins wallet. This allows you to interact with the contract as the admin. If you want to interact with the contract as Alice or Bob, you use thier wallet to create an instance.

2. Simulating the get_group_members method.

Here was are calling the get group members method of the contract using the admins instance. This is a view function so we use .simulate() to execute this method locally and read the data in the storage.

3. Verify that the members are what we expect.

In this case we expect the contract to return an array of addresses containing the adminAddress, aliceAddress and bobAddress.

Setting the balance between two members

it("sets the balance for admin and alice", async () => {
const setBalance = await adminInstance.methods
.set_balance(adminAddress, aliceAddress, 100)
.send()
.wait();
console.log("setBalance", setBalance);

const getBalance = await adminInstance.methods
.read_balance_credit(adminAddress, aliceAddress)
.simulate();
console.log("getBalance", getBalance);
expect(getBalance).toBe(100n);
});

This is another simple test that will set the balance between two members then read the storage to see if the balance has been set correctly.

  1. Setting the balance

Again we are using the admins instance here. This function changes the state of the contract so we use send().wait() to send the transaction and wait for it to complete.

2. Reading the balance

Here this is calling a view function so we use simulate() to obtain the value and check to see it is what we expect.

Running the Tests

To run the tests we will make sure we are at the root directory of our project and run

Don’t forget to have the Aztec Sandbox running!

yarn test

This will run the testing script that we defined earlier.

Feel free to create more tests to test the contracts logic!!

If you are not sure check out the repo where this contract lives here to look at the other tests you can carry out.

Summary

This section gives a quick introduction to setting up your test environment and running some end-to-end tests using AztecJS. There are many more tools you can leverage and you can explore within AztecJS that you can find here.

  • End-to-end tests simulate real-world interactions, using Jest, AztecJs, and the PXE to execute contract functions. They provide a realistic test environment, however they run slower as they run sequentially and do a whole protocol simulation.
  • Setting up the Testing Environment — Generating the TS artifact which is needed to interact with it in AztecJS. The aztec codegen command is used to do this.
  • Necessary Files — These include your index.test.ts test file, tsconfig.json, and jest.integration.config.json to support Typescript and Jest Configurations.
  • Writing Tests — Use your beforeAll to initialise the PXE, create wallet instances for test users, and deploy the contract.
  • Creating Contract Instances — Use each user’s wallet to create a contract instance that is associated with the,,allowing tests to simulate contract interactions from different users’ perspectives
  • Example Tests — We have included some simple test scenarios to get you started.

Using the TXE

We will now turn our focus on using the TXE to test out the contract.

Project Structure

Lets make some new files

|-- src
| |-- circuits
| | |-- src
|. |. |. |-- test
|. |. |. | |-- get_group_members.nr
|. |. |. | |-- setting_balance.nr
|. |. |. | |-- utils.nr
|. |. |. | |-- setup.nr
|. |. |. |-- helpers.nr
|. |. |. |-- NewAddressNote.nr
| | | |-- main.nr
|. |. | |-- test.nr
| | |-- Nargo.toml
|-- test
| |-- index.test.ts
|-- package.json
|-- yarn.lock
|-- jest.integration.config.json
|-- tsconfig.json

Within our contract we will create a new test folder that will contain our tests, we have them in different files to make it more modular and organised.

Note: The tests do not have to be in a different folder, you can have them at the bottom of the contract. I have separated them here to improve readability and to make sure our main contract does not get to cluttered.

test.nr

This will include the modules that live within the test file

mod utils;
mod setup;
mod setting_balance;
mod get_group_members

main.nr

At the top of our main.nr where we have the imports for the other modules, include

mod test;

utils.nr

Lets dive into our utils.nr which we will use to deploy our contract and setup the accounts we will use to interact with it. This is similar to our beforeAll during our jest end-to-end tests.

use dep::aztec::{prelude::AztecAddress, test::helpers::{cheatcodes, test_environment::TestEnvironment}};
use dep::std::println;
use crate::PrivateGroups;

//Testing within the TXE, this setup function deploys the contract, creates the accounts that will be used in the tests.

unconstrained pub fn setup() -> (&mut TestEnvironment, AztecAddress, AztecAddress, AztecAddress, AztecAddress) {
let mut env = TestEnvironment::new();

let admin = env.create_account();
let alice = env.create_account();
let bob = env.create_account();

env.advance_block_by(1);
let group_members = [admin, alice, bob];

env.impersonate(admin);
let initializer = PrivateGroups::interface().constructor(admin, group_members);
let private_group_contract_deploy = env.deploy_self("PrivateGroups").with_private_initializer(initializer);
let private_group_contract = private_group_contract_deploy.to_address();
env.advance_block_by(1);

println("Private group contract deployed");
(&mut env, private_group_contract, admin, alice, bob)
}

Imports

The important new imports that we are using are the test::helpers::{cheatcodes, test_environment} . This is needed for setting up the TXE test environment and the cheatcodes used throughout testing.

Return Types

We have our return types from the function which contain:

  • &mut TestEnvironment - which we call &mut env when we initialise the Test Environment
  • Our accounts which we will use to interact with the contract. These are admin , bob and alice all of type AztecAddress
  • We also have our contract address private_group_contract also of type AztecAddress

Setup Logic

We mark the function as unconstrained because proofs for this function to not have to be created during compile time as it is just used for testing purposes. It also allows the tests to run significantly faster. The bytecode is generated as Brillig and not ACIR, which should yield exactly the same results.

  1. We initalize the Test Environment with
let mut env = TestEnvironment::new();

2. We Create the Accounts that will be used in the tests

let admin = env.create_account();
let alice = env.create_account();
let bob = env.create_account();

3. Advance the blockchain state, this is not necesssary but more a demonstration of the cheatcodes you can use. This advances the block number by one.

env.advance_block_by(1);

4. Define group members for the constructor

let initializer = PrivateGroups::interface().constructor(admin, group_members);

PrivateGroups::interface() - accesses the contract interface, allowing you to call its functions.

constructor(admin, group_members) - We are calling the constructor and inputting our arguments which are the admin of the contract and the group_members which includes the admin.

5. Deploying the Contract

let private_group_contract_deploy = env.deploy_self("PrivateGroups").with_private_initializer(initializer);
let private_group_contract = private_group_contract_deploy.to_address();

Here we deploy the contract, the deploy_self method looks for your Noir Abi Json that you created when u compiled and finds the name of the contract.

with_private_initializer - This is used because the constructor of this contract is private. If it was public you would use with_public_void_initializer .

We then pass in the initializer which is the constructor that we just created.

Finally we extract the address of the contract using .address .

6. Why do we have this setup function. As the tests in the TXE run in parallel. Every test creates its own isolated environment, whereas in the PXE the tests run sequentially. As you will see, this setup function will run at the start of every test block as the contract and addresses need to be deployed in every environment.

Note: You can print text when using the TXE as shown in the example above. If you want to return a value, use

  • println(f”value: {value}")
  • To see these values when the tests are running, use aztec test --show-output when running the tests.

setup.nr

Will use this file to check that our TestEnvironment is working correctly and we will show case some of the things you can do in the TXE.

The following tests will be run in our setup.nr file.

test_contract_deployment

use crate::test::utils;
use dep::aztec::test::{helpers::{cheatcodes, test_environment::TestEnvironment}};
use dep::aztec::note::note_getter::{MAX_NOTES_PER_PAGE, view_notes};
use dep::aztec::note::note_viewer_options::NoteViewerOptions;
use dep::aztec::{
protocol_types::address::AztecAddress,
macros::{storage::storage, events::event, functions::{public, initializer, private, internal}}
};
use dep::aztec::{oracle::{execution::{get_block_number, get_contract_address}, storage::storage_read}};
use crate::NewAddressNote::NewAddressNote;
use dep::std::println;
use crate::PrivateGroups;

#[test]
unconstrained fn test_contract_deployment() {
let ( env, private_group_contract, admin, alice, bob) = utils::setup();
env.advance_block_by(1);
assert(!private_group_contract.is_zero(), "Contract not deployed");
}

Firstly we want to use the macro [#test] . Which marks it as a test function

  • We run our setup function that we created in utils to setup the test environment
  • We are simply asserting that the contract is deployed by checking that is is non-zero

get_Admin_unconstrained

#[test]
unconstrained fn get_Admin_unconstrained() {
let ( env, private_group_contract, admin, alice, bob) = utils::setup();

env.impersonate(private_group_contract);
let unconstrained_context = env.unkonstrained();
env.advance_block_by(1);

let storage = PrivateGroups::Storage::init(unconstrained_context);
let admin_note = storage.admin.view_note();

assert(admin_note.address == admin, "Admin not set correctly");
}

Here we

  1. Impersonate the contract
  2. Set up an unconstrained context using the unkonstrained function, this is deliberately miss spelled here.
  3. Access the storage slot in an unconstrained context
  4. View the note
  5. Check that the address in the admin_note the same as the admin address.

Note: NoteViewerOptions is different than NoteGetterOptions like we have been using in the contract. It allows you to view notes in an unconstrained context, whereas the latter is for private contexts.

get_group_members.nr

Here we will show some of the different ways you can get the group members in the TXE. Through going through the get_group_members function that we already created in the contract.

get_group_members_through_admin

use crate::test::utils;


use dep::aztec::note::note_getter::view_notes;
use dep::aztec::note::note_viewer_options::NoteViewerOptions;
use dep::aztec::{protocol_types::address::AztecAddress};
use crate::NewAddressNote::NewAddressNote;

use dep::std::println;
use crate::PrivateGroups;

#[test]
unconstrained fn get_group_members_through_admin() {
let (env, private_group_contract, admin, alice, bob) = utils::setup();

env.impersonate(admin);
let members: [AztecAddress; 3] = PrivateGroups::at(private_group_contract).get_group_members(admin).view(&mut env.private());
println(f"members: {members}");
assert(members[0] == admin, "Admin not in group");
assert(members[1] == alice, "Alice not in group");
assert(members[2] == bob, "Bob not in group");
}

As you can see this is an easy way of getting the group members from the storage by calling the get_group_members function from within our contract.

When testing your contract this will most likely be the primary flow that you will be using.

PrivateGroups::at(private_group_contract).get_group_members(admin).view(&mut env.private())
  1. PrivateGroups:at(private_group_contract) - creates an instance of the PrivateGroups contract at with the specified address private_group_contract. This allows you to interact with the contract functions.
  2. .get_group_members(admin) - calls the get_group_members function of the PrivateGroups contract. The admin is the argument that is being passed in.
  3. view(&mut env.private) executes the get_group_members function in a view context, as the get_group_members is a view function within our contract. (A read-only operation). &mut env.private() indicates that this operation is performed in the private context of the test environment.

Note:

  • If this was not a view function we would use .call
  • If this operation was happening in the public context we would use &mut env.public()

get_group_members

However, let’s say that we did not create the get_group_members function within the contract, we could still access these storage slots to check that the group_members storage has been populated correctly, i will demonstrate this below.

#[test]
unconstrained fn get_group_members() {
let ( env, private_group_contract, admin, alice, bob) = utils::setup();

env.impersonate(private_group_contract);
let unconstrained_context = env.unkonstrained();
let ZERO_ADDRESS: AztecAddress = AztecAddress::from_field(0x0000000000000000000000000000000000000000000000000000000000000000);

let storage = PrivateGroups::Storage::init(unconstrained_context);
let options = NoteViewerOptions::new();

let group_members: BoundedVec<NewAddressNote, 10> = storage.group_members.at(admin).view_notes(options);
let mut member_addresses = Vec::new();
let mut member_count = 0;

for i in 0..group_members.len() {
if group_members.get_unchecked(i).address != ZERO_ADDRESS {
let group_member = group_members.get_unchecked(i);
let group_member_address = group_member.address;
member_addresses.push(group_member_address);
member_count += 1;
}
}

assert(member_count == 3, "Group members not set correctly");
assert(member_addresses.get(0) == admin, "Group members not set correctly");
assert(member_addresses.get(1) == alice, "Group members not set correctly");
assert(member_addresses.get(2) == bob, "Group members not set correctly");
}

But let’s say we never created the get_group_members , we can still test whether the storage slots were populated correctly.

It takes a little longer to do it as we are essentially replicating the function within the contract, but we can still do it if we please.

The steps for this are similar to the logic for the get_group_members function in the contract that we used to view the group members from storage.

  1. Setup: The function begins by setting up the test environment and initializing the contract and accounts. This is done using utils::setup(), which returns the environment, the private group contract address, and the addresses of the admin, Alice, and Bob.
  2. Impersonation: The environment is set to impersonate the private group contract using env.impersonate(private_group_contract). This allows the test to execute actions as if they were performed by the contract itself.
  3. Unconstrained Context: An unconstrained context is obtained from the environment using env.unkonstrained(). This context is used to perform operations that do not affect the blockchain state.
  4. View Notes: The function retrieves the group members’ notes from storage using view_notes(options). This returns a BoundedVec of NewAddressNote objects, each representing a group member.
  5. Member Collection: The function iterates over the group_members vector, checking each member's address. If the address is not the ZERO_ADDRESS, it is considered a valid group member. The address is then added to the member_addresses vector, and the member_count is incremented.
  6. Assertions: Finally, the function asserts that the member_count is 3, indicating that there are exactly three group members. It also checks that the addresses in member_addresses match the expected addresses of the admin, Alice, and Bob.

You can do a similar flow to get the values in any of the storage slots within the contract.

setting_balance.nr

Here we will test the setting of balances from within our contract through testing the make_payment, set_balance and setup_group_payments functions.

multiple_group_balances

use crate::test::utils;

use dep::aztec::test::helpers::{cheatcodes, test_environment::TestEnvironment};
use aztec::note::note_getter::NoteGetterOptions;
use crate::NewAddressNote::NewAddressNote;

use dep::std::println;
use crate::PrivateGroups;

#[test]
unconstrained fn multiple_group_balances() {
let (env, private_group_contract, admin, alice, bob) = utils::setup();

//group payment from the admin
env.impersonate(admin);
let mut amount: Field = 150;
let debtors = [alice, bob];
let set_balance_admin_group = PrivateGroups::at(private_group_contract).setup_group_payments(admin, debtors, amount).call(&mut env.private());
env.advance_block_by(1);

//group payment from alice
env.impersonate(alice);
amount = 210;
let debtors = [admin, bob];
let set_balance_alice_group = PrivateGroups::at(private_group_contract).setup_group_payments(alice, debtors, amount).call(&mut env.private());
env.advance_block_by(1);

//group payment from bob
env.impersonate(bob);
amount = 300;
let debtors = [admin, alice];
let set_balance_bob_group = PrivateGroups::at(private_group_contract).setup_group_payments(bob, debtors, amount).call(&mut env.private());
env.advance_block_by(1);

env.impersonate(admin);
let balance_admin_alice = PrivateGroups::at(private_group_contract).read_total_balance(admin, alice).view(&mut env.private());
assert(balance_admin_alice == -20, "Balance is not correct");

env.impersonate(alice);
let balance_alice_admin = PrivateGroups::at(private_group_contract).read_total_balance(alice, admin).view(&mut env.private());
assert(balance_alice_admin == 20, "Balance is not correct");

env.impersonate(bob);
let balance_bob_admin = PrivateGroups::at(private_group_contract).read_total_balance(bob, admin).view(&mut env.private());
assert(balance_bob_admin == 50, "Balance is not correct");
}
  1. Impersonate the admin to simulate actions as if performed by the ‘admin’.
  2. We are setting the balance to split as 150. (50 each), with alice and bob being get as debtors. notice how we a are using .call here
  3. This repeats for Bob and Alice
  4. Next we check the balances, and asserting that they are what we expect. Notice we are using .view as it is a view function.

setting_balance_and_making_payment

#[test]
unconstrained fn setting_balance_and_making_payment() {
let (env, private_group_contract, admin, alice, bob) = utils::setup();

env.impersonate(admin);
let set_balance = PrivateGroups::at(private_group_contract).set_balance(admin, alice, 100).call(&mut env.private());
env.advance_block_by(1);
let set_balance_2 = PrivateGroups::at(private_group_contract).set_balance(admin, alice, 100).call(&mut env.private());

env.impersonate(alice);
let make_payment = PrivateGroups::at(private_group_contract).make_payment(alice, admin, 150).call(&mut env.private());
env.advance_block_by(1);

env.impersonate(admin);
let balance_admin_alice = PrivateGroups::at(private_group_contract).read_total_balance(admin, alice).view(&mut env.private());
println(f"balance admin alice: {balance_admin_alice}");

env.impersonate(alice);
let debt_alice = PrivateGroups::at(private_group_contract).read_balance_debt(alice, admin).view(&mut env.private());
println(f"debt alice: {debt_alice}");
assert(debt_alice == 50, "Debt is not correct");

let balance_alice_admin = PrivateGroups::at(private_group_contract).read_total_balance(alice, admin).view(&mut env.private());
println(f"balance alice admin: {balance_alice_admin}");
assert(balance_alice_admin == -50, "Balance is not correct");
}

The flow here is very similar, here we are testing the functionality of set_balance and make_payment. Checking that the balances are correct through read_total_balance.

set_balance_fail

#[test(should_fail)]
unconstrained fn set_balance_fail() {
let (env, private_group_contract, admin, alice, bob) = utils::setup();

env.create_account();
let charlie = env.create_account();
env.impersonate(charlie);
let set_balance = PrivateGroups::at(private_group_contract).set_balance(charlie, admin, 20).call(&mut env.private());
assert(false, "Should not get here");
}

If you expect a test to fail in the TXE you can use the macro #[test(should_fail)]. Here we are creating a new account charlie who is not part of the group. He should not be able to set_balances with other group members as the membership check will fail.

Running the Tests

To run the tests use aztec test in the src directory for your contract.

If you want to print logs in the test, you can see these logs by using aztec test --show-output

Summary

The TXE (Testing Execution Environment) provides a specialised testing environment for Noir-Based contracts in Aztec, offering “cheatcodes” to enable quick and iterative contract testing without full protocol checks, making it idea for verifying contract storage and logic.

Key Points

  1. Test Setup with TXE :

Create and initialize accounts and deploy contracts using a setup function.

Use unconstrained functions to enable faster test execution with Brillig bytecode, bypassing compile-time proof generation.

2. Writing Tests:

  • Direct Storage Access — Utilise the TXE to directly access storage slots within your contract by retrieving and verifying values without relying on contract functions.
  • Impersonation and Contexts — Use env.impersonate to simulate actions by specific account, use view or call based on whether the functions are read-only or alter state, and &mut private() or &mut public() if the function needs to be executed in the private or public context.
  • Annotate tests with [#test] if they should pass and [#test(should_fail)] if they should fail/

3. Running tests — Execute tests with aztec test and use aztec test --show-output to display println statements for detailed insights during test execution.

Using an Account Contract for Shared State

Now we have a guide on creating a fully private contract with Splitwise Like functionality. Showcasing the difference in design patterns for private and public contexts. Creating new notes and testing your contract.

These are the key takeaways so far:

  • Private vs. Public Storage: Private contracts require additional complexity with encrypted notes and member-only visibility, while public contracts are simpler.
  • Testing Environment Options: AztecJS + PXE offers realistic end-to-end testing, while TXE is useful for fast, localized tests of specific contract logic.

Let’s move on to something you can do in Aztec tocreate a contract with similar functionality but with fewer notes being sent out, which reduces the expense of using the contract.

Native Account Abstraction

Let’s do a little recap. In Aztec account abstraction is implemented at the protocol level, unlike Ethereum which uses EOAs (Externally owned accounts), every account is a smart contract with extra steps, these define the rules for transaction validity.

One of the account contracts is the Schnorr account contract which authenticates transactions using Schnorr signatures. The tutorial for this is discussed here in the documentation.

“Since the entrypoint interface of an account is not enshrined, there is nothing that differentiates an account contract from an application one in the protocol. This allows implementing functions that do not need to be called by any particular user and are just intended to advance the state of a contract.”

By registering this account contract in the PXE, and adding the smart contract functionality to an account. Any PXE with the registered “account” can privately access and alter the contract’s state among trusted users that have access to this account.

This means that if multiple people have access to the same account within a trusted group of people, they can all access the same state.

With this principle let’s create an account contract that has the same functionality as the PrivateGroups contract that we just created and showcase how this can be used to have a shared private state.

The Schnorr Account Contract

Let’s start with the Schnorr account contract with its most basic implementation.

I do recommend that you go through the account contract tutorial here, if you haven’t already before continuing which explains the contract in more depth. We will give a high-level overview of the contract anyway.

mod notes;

// Account contract that uses Schnorr signatures for authentication.
// The signing key is stored in an immutable private note and should be different from the encryption/nullifying key.
use dep::aztec::macros::aztec;

#[aztec]
contract SchnorrAccount {
use dep::std;

use dep::aztec::prelude::{AztecAddress, PrivateContext, PrivateImmutable};
use dep::aztec::encrypted_logs::encrypted_note_emission::encode_and_encrypt_note;
use dep::authwit::{
entrypoint::{app::AppPayload, fee::FeePayload}, account::AccountActions,
auth_witness::get_auth_witness,
auth::{compute_authwit_nullifier, compute_authwit_message_hash},
};
use dep::aztec::{hash::compute_siloed_nullifier, keys::getters::get_public_keys};
use dep::aztec::oracle::get_nullifier_membership_witness::get_low_nullifier_membership_witness;
use dep::aztec::macros::{
storage::storage, functions::{private, initializer, view, noinitcheck},
};

use crate::public_key_note::PublicKeyNote;

#[storage]
struct Storage<Context> {
signing_public_key: PrivateImmutable<PublicKeyNote, Context>,
}

// Constructs the contract
#[private]
#[initializer]
fn constructor(signing_pub_key_x: Field, signing_pub_key_y: Field) {
let this = context.this_address();
let this_keys = get_public_keys(this);
// Not emitting outgoing for msg_sender here to not have to register keys for the contract through which we
// deploy this (typically MultiCallEntrypoint). I think it's ok here as I feel the outgoing here is not that
// important.
let mut pub_key_note =
PublicKeyNote::new(signing_pub_key_x, signing_pub_key_y, this_keys.npk_m.hash());
storage.signing_public_key.initialize(&mut pub_key_note).emit(encode_and_encrypt_note(
&mut context,
this_keys.ovpk_m,
this_keys.ivpk_m,
this,
));
}

// Note: If you globally change the entrypoint signature don't forget to update account_entrypoint.ts file
#[private]
#[noinitcheck]
fn entrypoint(app_payload: AppPayload, fee_payload: FeePayload, cancellable: bool) {
let actions = AccountActions::init(&mut context, is_valid_impl);
actions.entrypoint(app_payload, fee_payload, cancellable);
}

#[private]
#[noinitcheck]
#[view]
fn verify_private_authwit(inner_hash: Field) -> Field {
let actions = AccountActions::init(&mut context, is_valid_impl);
actions.verify_private_authwit(inner_hash)
}

#[contract_library_method]
fn is_valid_impl(context: &mut PrivateContext, outer_hash: Field) -> bool {
// docs:start:is_valid_impl
// Load public key from storage
let storage = Storage::init(context);
let public_key = storage.signing_public_key.get_note();
// Load auth witness
let witness: [Field; 64] = unsafe { get_auth_witness(outer_hash) };
let mut signature: [u8; 64] = [0; 64];
for i in 0..64 {
signature[i] = witness[i] as u8;
}

// Verify signature of the payload bytes
std::schnorr::verify_signature(
public_key.x,
public_key.y,
signature,
outer_hash.to_be_bytes::<32>(),
)
// docs:end:is_valid_impl
}
}

Storage

signing_public_key - This is a PrivateImmutable type, which means it is a private state variable that cannot be changed once it is set. It holds a PublicKeyNote the source code for this note can be found here. It is similar to the other notes we have explored in this tutorial and holds the x and y co-ordinates of the public key used for verifying Schnorr signatures.

Constructor

The constructor function initializes the contract. It takes the signing_pub_key_x and signing_pub_key_y as parameters, representing the x and y coordinates of the signing public key.

It creates a PublicKeyNote with the public key and the contracts nullifying public key hash. npk_m.hash.

The public key note is initialized and emitted as an encrypted note.

Entrypoint

The entrypoint is responsible for handling transaction execution requests. If we wanted to use this account contract purely for handling state we could assert that this function fails so that it cannot do anything outside of being used for storage.

AccountActions is a structure that manages the execution actions from the account, ensuring that they are valid and authorised.

Verify Private Authwit:

The verify_private_authwit function checks the validity of a private authentication witness (authwit ) using the inner_hash . More information on authwits can be found here.

is_valid_impl:

This function verifies the Schnorr signature of the transaction, it retrieves the public key from storage, and verifies the signature using the Schnorr signature scheme.

This is a brief overview of the components within the account contract, we will build upon this for the construction of our contract that can be used to store shared private state.

Setting up our project

Within your terminal in the folder you would like to start the project, write

yarn init -yp
  • This will create a package.json and yarn.lock, this will be useful later.

Create a src folder with, and cd into it

mkdir src && cd src

Now we need to create our new Aztec Project with:

aztec-nargo new --contract circuits

Go back to the project root and open in your code editor

cd .. && code .

This will be the same as we have done before, this time we are going to create a notes folder and file as we are going to use more than one custom note.

|-- src
| |-- circuits
| | |-- src
| | | |-- notes.nr
| | | | |-- public_key_note.nr
| | | | |-- NewAddressNote.nr
|. |. | |-- notes.nr
| | | |-- main.nr
| | |-- Nargo.toml
|-- package.json
|-- yarn.lock

This is the project structure skeleton that we need at the moment:

Imports

[dependencies]
aztec = { git = "<https://github.com/AztecProtocol/aztec-packages/>", tag = "aztec-packages-v0.60.0", directory = "noir-projects/aztec-nr/aztec" }
authwit = { git = "<https://github.com/AztecProtocol/aztec-packages/>", tag = "aztec-packages-v0.60.0", directory = "noir-projects/aztec-nr/authwit" }
value_note = { git = "<https://github.com/AztecProtocol/aztec-packages/>", tag = "aztec-packages-v0.60.0", directory = "noir-projects/aztec-nr/value-note" }

notes.nr

Within our notes.nr include the public_key_note

mod public_key_note;
mod NewAddressNote;

Then in our public_key_note.nr file copy in the public_key_note logic which is found here.

use dep::aztec::prelude::{NoteHeader, NullifiableNote, PrivateContext};
use dep::aztec::{
note::utils::compute_note_hash_for_nullify, keys::getters::get_nsk_app,
protocol_types::{
constants::GENERATOR_INDEX__NOTE_NULLIFIER, hash::poseidon2_hash_with_separator,
}, macros::notes::note,
};

// Stores a public key composed of two fields
// TODO: Do we need to include a nonce, in case we want to read/nullify/recreate with the same pubkey value?
#[note]
pub struct PublicKeyNote {
x: Field,
y: Field,
// We store the npk_m_hash only to get the secret key to compute the nullifier
npk_m_hash: Field,
}

impl NullifiableNote for PublicKeyNote {
fn compute_nullifier(
self,
context: &mut PrivateContext,
note_hash_for_nullify: Field,
) -> Field {
let secret = context.request_nsk_app(self.npk_m_hash);
poseidon2_hash_with_separator(
[note_hash_for_nullify, secret],
GENERATOR_INDEX__NOTE_NULLIFIER as Field,
)
}

unconstrained fn compute_nullifier_without_context(self) -> Field {
let note_hash_for_nullify = compute_note_hash_for_nullify(self);
let secret = get_nsk_app(self.npk_m_hash);
poseidon2_hash_with_separator(
[note_hash_for_nullify, secret],
GENERATOR_INDEX__NOTE_NULLIFIER as Field,
)
}
}

impl PublicKeyNote {
pub fn new(x: Field, y: Field, npk_m_hash: Field) -> Self {
PublicKeyNote { x, y, npk_m_hash, header: NoteHeader::empty() }
}
}

The NewAddressNote is the same NewAddressNote that we used earlier in the contract.

main.nr

In our main.nr file copy in the Schnorr account contract that i have provided above.

Then change the imports and name of the contract to these

mod notes;
mod helpers;

use dep::aztec::macros::aztec;
#[aztec]
contract AccountGroup {

use dep::aztec::{prelude::{Map, AztecAddress, PrivateContext, PrivateImmutable, PrivateSet},
encrypted_logs::encrypted_note_emission::{encode_and_encrypt_note, encode_and_encrypt_note_unconstrained},
keys::getters::get_public_keys,
oracle::get_nullifier_membership_witness::get_low_nullifier_membership_witness,
macros::{storage::storage, functions::{private, initializer, view, noinitcheck}},
note::note_getter::NoteGetterOptions
};
use dep::authwit::{
entrypoint::{app::AppPayload, fee::FeePayload}, account::AccountActions,
auth_witness::get_auth_witness
};

// use value_note::{balance_utils, utils::{increment, decrement}, value_note::ValueNote};
use crate::notes::{public_key_note::PublicKeyNote, NewAddressNote::NewAddressNote};
use crate::helpers::{increment, decrement, membership_check, membership_check_multiple};
use value_note::{value_note::ValueNote};
use std::hash::poseidon2;

global ZERO_ADDRESS: AztecAddress = AztecAddress::from_field(0x0000000000000000000000000000000000000000000000000000000000000000);

Let’s go through these imports and what they do at a high level.

Map - This is used for mapping key, value pairs in contract storage.

AztecAddress - At a high level they are a representation of an account within the Aztec Network.

PrivateContext - The context for executing functions privately

PrivateImmutable - A type of state variable that is private and immutable.

PrivateSet - A collection type for storing private notes of the same type.

encode_and_encrypt_note - used to encode and encrypt notes for emission as encrypted logs, ensuring privacy.

encode_and_encrypt_note_unconstrained - similar to the function above except, the contract will not enforce that you are encrypting the note correctly, we will explain this a little more later

get_public_keys - Provides functions to retrieve public keys necessary to for various cryptographic operations within the contract. In this case mainly to send notes to the correct participant.

get_low_nullifier_membership_witness: An oracle function to obtain a witness for nullifier membership checks.

macros - These macros are used within the contract to define the context of functions.

NoteGetterOptions - Used in note retrieval functions, allowing users to fetch notes that are assigned to them.

Authwit Imports - used in the entrypoint, for managing actions within accounts, we will not worry about these here.

NewAddressNote - A custom implementation of an address note, used to store an aztec address as a note for use in the private context. Same note which we used earlier

PublicKeyNote - As discussed above, a note to store the x and y coordinate of the Schnorr signature.

increment & decrement — custom implementations of the increment and decrement, the same ones we used from the value_note library earlier with minor modifications.

membership_check - a helper function to assert membership in the group for Addresses

value_note - A note used to store value related data in the private context.

poseidon2 - A cryptographic hash function

Storage Within the Contract

We are going to add a few more storage slots to add to the contract functionality.

Replace the storage structure with this.

#[storage]
struct Storage<Context> {
signing_public_key: PrivateImmutable<PublicKeyNote, Context>, //Stores the public key for the Schnorr account (immutably)
group_members: PrivateSet<NewAddressNote, Context>, //Stores the group members (privately)
member_balances : Map<Field, PrivateSet<ValueNote, Context>, Context>, //Stores the balances of the group members (privately)
}

Let’s discuss the storage slots that we have added.

Group_members — This is a PrivateSet of new address Notes, notice how we do not need a mapping like we did in the previous contract.

member_balances — A map of balances, the key for the mapping is a Field which is the Poseidon hash of the Creditor and Debtor pair. The balances is represented by a private set of value notes. Notice how in this contract we only have one storage slot for balances, this is to do with the visibility of the notes created and we will discuss this more later.

Note : Notice how we do not have an admin for this contract in the storage. Since all members will have access to the contract, and will be calling the contract as itself. All parties will have access to all the state in the contract.

Constructor

We are only going to add one extra piece of logic to the constructor. This is adding the owner who creates the account contract to the set of group members. Replace the constructor with the code below

#[private]
#[initializer]
fn constructor(signing_pub_key_x: Field, signing_pub_key_y: Field, owner: AztecAddress) {
let this = context.this_address();
let this_keys = get_public_keys(this);
//not emitting outgoing for msg_sender here to not have to register keys for the contract through which we deploy this (typically MultiCallEntryPoint).

//Create the public key note for the Schnorr Account
let mut pub_key_note = PublicKeyNote::new(signing_pub_key_x, signing_pub_key_y, this_keys.npk_m.hash());
storage.signing_public_key
.initialize(&mut pub_key_note)
.emit(encode_and_encrypt_note_unconstrained(
&mut context,
this_keys.ovpk_m,
this_keys.ivpk_m,
this
));

//Add the owner to the group members set.
let mut owner_member_note = NewAddressNote::new(owner, this_keys.npk_m.hash());
storage.group_members
.insert(&mut owner_member_note)
.emit(encode_and_encrypt_note_unconstrained(
&mut context,
this_keys.ovpk_m,
this_keys.ivpk_m,
owner
)
);
}

We initialise an AddressNote with the owner's address and this is getting added to the group_members storage.

Notice how when we are emitting these notes we are using the contracts public keys for the outgoing viewer and incoming viewer. We are sending the not to ourselves as only the contract is going to have access to it.

Note:

Using unconstrained notes: Notice how in this example we are using unconstrained notes. The reasoning behind this I will explain below.

Unconstrained notes are used in scenarios where the contract does not enforce encryption constraints on the notes. This means that the contract assumes the notes are handled correctly by trusted parties.

The owner/creator of the contract must share account details with trusted members to register the contract in their PXE. This implies a trust assumption that these members will handle the notes appropriately.

Since the contract does not enforce encryption, there is an inherent trust that the notes are encrypted correctly by the parties involved.

Benefits

Using unconstrained notes reduces the circuit size of the contract significantly. This reduction leads to lower proving time and cost, making the contract more efficient.

While this approach improves performance, it requires consideration of trust among the parties involved, The lack of enforced constraints means that the security relies on the trustworthiness of the parties.

In this contract we are not sending/receiving real money, just changing balances so this is a tradeoff I am willing to make.

If you don’t want to rely on this assumption, you can make sure that the notes are constrained.

Adding Members

This contract is going to differ slightly to the other group contract that we explored, in that contract you added the members to the group in the constructor, here we are going to allow you to add members afterwards.

 #[private]
fn add_member(member: AztecAddress) {
let contract_address = context.this_address();
assert(context.msg_sender() == contract_address, "Only the contract can call this function");
let contract_address_keys = get_public_keys(contract_address);
let mut member_address_note = NewAddressNote::new(member, contract_address_keys.npk_m.hash());
storage.group_members.insert(&mut member_address_note).emit(
encode_and_encrypt_note_unconstrained(
&mut context,
contract_address_keys.ovpk_m,
contract_address_keys.ivpk_m,
member
)
);
}

We have an assertion that only the message sender needs to be the contract address. This is so that only people who have the contract registered in their PXE can use the account to call itself and add members.

Setting Balances

#[private]
fn set_balance(creditor: AztecAddress, debtor: AztecAddress, amount: Field) {
let contract_address = context.this_address();
assert(context.msg_sender() == contract_address, "Only the contract can call this function");
let location = storage.group_members;
assert(membership_check(creditor, debtor, contract_address, location), "Creditor or debtor are not in the group");

let hash_inputs = [creditor.to_field(), debtor.to_field()];
let key = poseidon2::Poseidon2::hash(hash_inputs, 2);

let storage_location = storage.member_balances.at(key);
increment(storage_location, amount, contract_address, contract_address);
}

Once again the logic is very similar to the contract we had previously made. The key difference is that one not is sent to update the balance table instead of two. Let’s explore how this is the case in this contract compared to the other one. The incoming viewer and outgoing viewer is the same as the notes here. The contract has access to the state whereas in the other example, we needed to have two storage slots to do the same thing.

Only the creditor in the previous example had access to the credit notes and the debtor could only see their notes. By tracking these independently, only the relevant members could see the details of their transactions.

In this contract since the contract itself is managing the balances and has the necessary permissions to access the notes, you can use a single not to represent the balance for each member.

Membership Check

Notice the membership check at the start of the function.

This is a helper function which I will show below.

Make a new file called helpers.nr

|-- src
| |-- circuits
| | |-- src
| | | |-- notes.nr
| | | | |-- public_key_note.nr
| | | | |-- NewAddressNote.nr
|. |. | |-- notes.nr
|. |. | |-- **helpers.nr**
| | | |-- main.nr
| | |-- Nargo.toml
|-- package.json
|-- yarn.lock
use dep::aztec::prelude::{AztecAddress, PrivateContext, PrivateSet, NoteGetterOptions};
use dep::aztec::note::note_getter_options::SortOrder;
use dep::aztec::encrypted_logs::encrypted_note_emission::{encode_and_encrypt_note, encode_and_encrypt_note_unconstrained};
use dep::aztec::keys::getters::get_public_keys;
use dep::value_note::{filter::filter_notes_min_sum, value_note::{ValueNote, VALUE_NOTE_LEN}};
use crate::notes::NewAddressNote::NewAddressNote;

pub fn membership_check(creditor: AztecAddress, debtor: AztecAddress, contract_address: AztecAddress, location: PrivateSet<NewAddressNote, &mut PrivateContext>) -> bool {
let mut creditor_in_group = false;
let mut debtor_in_group = false;
let contract_address_keys = get_public_keys(contract_address);
let options = NoteGetterOptions::new();
let members_notes: BoundedVec<NewAddressNote, 16> = location.get_notes(options);
for i in 0..members_notes.max_len() {
if i < members_notes.len() {
let note = members_notes.get_unchecked(i);
if note.address == creditor {
creditor_in_group = true;
}
if note.address == debtor {
debtor_in_group = true;
}
}
}
assert(creditor_in_group, "Creditor is not in the group");
assert(debtor_in_group, "Debtor is not in the group");
true
}

Here we iterate over the members that are stored in the group_members storage location.

Include this in a helpers.nr function

and at the top of your contract file make sure to include the module.

mod helpers;

Increment

During our imports at the top of the contract, you may have noticed that we are also adding the increment function from the helpers crate. We are using a custom implementation with I will show below. Add this function to the helpers.nr file.

pub fn increment(
// docs:start:increment_args
balance: PrivateSet<ValueNote, &mut PrivateContext>,
amount: Field,
recipient: AztecAddress,
outgoing_viewer: AztecAddress // docs:end:increment_args
) {
let recipient_keys = get_public_keys(recipient);
let outgoing_viewer_ovpk_m = get_public_keys(outgoing_viewer).ovpk_m;

let mut note = ValueNote::new(amount, recipient_keys.npk_m.hash());
// Insert the new note to the owner's set of notes and emit the log if value is non-zero.
balance.insert(&mut note).emit(
encode_and_encrypt_note_unconstrained(
balance.context,
outgoing_viewer_ovpk_m,
recipient_keys.ivpk_m,
recipient
)
);
}

The only difference is that we are now emitting an unconstrained note, the reasonings for which we discussed above.

Setting up split expenses

Back in main.nr , add this function

#[private]
fn setup_group_payments(creditor: AztecAddress, debtors: [AztecAddress; 2], amount: Field) {
let contract_address = context.this_address();
assert(context.msg_sender() == contract_address, "Only the contract can call this function");
assert(membership_check_multiple(creditor, debtors, contract_address, storage.group_members), "Creditor or debtors are not in the group");

let shared_amount : u32 = amount as u32 / (debtors.len() + 1);
for i in 0..2 {
let debtor = debtors[i];
let hash_inputs = [creditor.to_field(), debtor.to_field()];
let key = poseidon2::Poseidon2::hash(hash_inputs, 2);

let storage_location = storage.member_balances.at(key);
//Using the ValueNote helpers to increment the balance
increment(
storage_location,
shared_amount.to_field(),
contract_address,
contract_address
);
}
}

Membership Check Multiple

Here the assertion is using membership_check_multiple . I will show this below, this is also going to be put into the helpers.nr file.

pub fn membership_check_multiple(creditor: AztecAddress, debtors: [AztecAddress; 2], contract_address: AztecAddress, location: PrivateSet<NewAddressNote, &mut PrivateContext>) -> bool {
membership_check(creditor, debtors[0], contract_address, location) & membership_check(creditor, debtors[1], contract_address, location)
}

Here we are just performing the membership check for both the debtors that we are splitting the expense with.

Make Payment

In main.nr

 #[private]
fn make_payment(debtor: AztecAddress, creditor: AztecAddress, amount: Field) {
let contract_address = context.this_address();
assert(context.msg_sender() == contract_address, "Only the contract can call this function");
let location = storage.group_members;
assert(membership_check(creditor, debtor, contract_address, location), "Creditor or debtor are not in the group");

let hash_inputs = [creditor.to_field(), debtor.to_field()];
let key = poseidon2::Poseidon2::hash(hash_inputs, 2);

let storage_location = storage.member_balances.at(key);
decrement(storage_location, amount, contract_address, contract_address);
}

This is the same logic as setting balance except we are using decrement instead of increment. Once again to make sure that it is using unconstrained notes we will include the decrement function in the helper.nr file.

pub fn decrement(
balance: PrivateSet<ValueNote, &mut PrivateContext>,
amount: Field,
owner: AztecAddress,
outgoing_viewer: AztecAddress
) {
let sum = decrement_by_at_most(balance, amount, owner, outgoing_viewer);
assert(sum == amount, "Balance too low");
}

// Sort the note values (0th field) in descending order.
// Pick the fewest notes whose sum is equal to or greater than `amount`.
pub fn create_note_getter_options_for_decreasing_balance(amount: Field) -> NoteGetterOptions<ValueNote, VALUE_NOTE_LEN, Field, Field> {
NoteGetterOptions::with_filter(filter_notes_min_sum, amount).sort(ValueNote::properties().value, SortOrder.DESC)
}


// Similar to `decrement`, except that it doesn't fail if the decremented amount is less than max_amount.
// The motivation behind this function is that there is an upper-bound on the number of notes a function may
// read and nullify. The requested decrementation `amount` might be spread across too many of the `owner`'s
// notes to 'fit' within this upper-bound, so we might have to remove an amount less than `amount`. A common
// pattern is to repeatedly call this function across many function calls, until enough notes have been nullified to
// equal `amount`.
//
// It returns the decremented amount, which should be less than or equal to max_amount.
pub fn decrement_by_at_most(
balance: PrivateSet<ValueNote, &mut PrivateContext>,
max_amount: Field,
owner: AztecAddress,
outgoing_viewer: AztecAddress
) -> Field {
let options = create_note_getter_options_for_decreasing_balance(max_amount);
let notes = balance.pop_notes(options);

let mut decremented = 0;
for i in 0..options.limit {
if i < notes.len() {
let note = notes.get_unchecked(i);
decremented += note.value;
}
}

// Add the change value back to the owner's balance.
let mut change_value = 0;
if max_amount.lt(decremented) {
change_value = decremented - max_amount;
decremented -= change_value;
}
increment(balance, change_value, owner, outgoing_viewer);

decremented
}

Get Balance

The final piece of the contract is adding a view function to allow users to see their balance.

#[private]
#[view]
fn get_balance(creditor: AztecAddress, debtor: AztecAddress) -> Field {
let contract_address = context.this_address();
assert(context.msg_sender() == contract_address, "Only the contract can call this function");

let hash_inputs = [creditor.to_field(), debtor.to_field()];
let key = poseidon2::Poseidon2::hash(hash_inputs, 2);

let options = NoteGetterOptions::new();
let balance_notes: BoundedVec<ValueNote, 16> = storage.member_balances.at(key).get_notes(options);

let mut total_balance = 0 as Field;
for i in 0..balance_notes.max_len() {
if i < balance_notes.len() {
let note = balance_notes.get_unchecked(i);
total_balance += note.value;
}
}
total_balance
}

This follows the same design patterns that we have used before.

Complete Contract

This is what the final contract will look like once complete.

mod notes;
mod helpers;

use dep::aztec::macros::aztec;

#[aztec]
contract AccountGroup {

use dep::aztec::{prelude::{Map, AztecAddress, PrivateContext, PrivateImmutable, PrivateSet},
encrypted_logs::encrypted_note_emission::{encode_and_encrypt_note, encode_and_encrypt_note_unconstrained},
keys::getters::get_public_keys,
oracle::get_nullifier_membership_witness::get_low_nullifier_membership_witness,
macros::{storage::storage, functions::{private, initializer, view, noinitcheck}},
note::note_getter::NoteGetterOptions
};
use dep::authwit::{
entrypoint::{app::AppPayload, fee::FeePayload}, account::AccountActions,
auth_witness::get_auth_witness
};
use crate::notes::{public_key_note::PublicKeyNote, NewAddressNote::NewAddressNote};
use crate::helpers::{increment, decrement, membership_check, membership_check_multiple};
use value_note::{value_note::ValueNote};
use std::hash::poseidon2;

global ZERO_ADDRESS: AztecAddress = AztecAddress::from_field(0x0000000000000000000000000000000000000000000000000000000000000000);

#[storage]
struct Storage<Context> {
signing_public_key: PrivateImmutable<PublicKeyNote, Context>, //Stores the public key for the Schnorr account (immutably)
group_members: PrivateSet<NewAddressNote, Context>, //Stores the group members (privately)
member_balances : Map<Field, PrivateSet<ValueNote, Context>, Context>, //Stores the balances of the group members (privately)
}

/**
* Constructor: Initializes the contract with a signing public key, owner address
*
* @param signing_pub_key_x - The x coordinate of the public key for the Schnorr signature
* @param signing_pub_key_y - The y coordinate of the public key for the Schnorr signature
* @param owner - The owner address
*/

#[private]
#[initializer]
fn constructor(signing_pub_key_x: Field, signing_pub_key_y: Field, owner: AztecAddress) {
let this = context.this_address();
let this_keys = get_public_keys(this);
//not emitting outgoing for msg_sender here to not have to register keys for the contract through which we deploy this (typically MultiCallEntryPoint).

//Create the public key note for the Schnorr Account
let mut pub_key_note = PublicKeyNote::new(signing_pub_key_x, signing_pub_key_y, this_keys.npk_m.hash());
storage.signing_public_key
.initialize(&mut pub_key_note)
.emit(encode_and_encrypt_note_unconstrained(
&mut context,
this_keys.ovpk_m,
this_keys.ivpk_m,
this
));

//Add the owner to the group members set.
let mut owner_member_note = NewAddressNote::new(owner, this_keys.npk_m.hash());
storage.group_members
.insert(&mut owner_member_note)
.emit(encode_and_encrypt_note_unconstrained(
&mut context,
this_keys.ovpk_m,
this_keys.ivpk_m,
owner
)
);
}

/**
* Entrypoint function to initialize actions for the Schnorr Account
* Can make this do nothing as this contract is not meant to interact with anything, just to store state.
*/
#[private]
fn entrypoint(app_payload: AppPayload, fee_payload: FeePayload, cancellable: bool) {
// assert(false, "This contract is not meant to be interacted with");
let actions = AccountActions::init(&mut context, is_valid_impl);
actions.entrypoint(app_payload, fee_payload, cancellable);
}

/**
* Adds a member to the group.
* In the future this can be improved to send the member a note to register in their PXE, currently not possible with the current note system.
*
* @param member - Aztec address of the new group member.
*/
#[private]
fn add_member(member: AztecAddress) {
let contract_address = context.this_address();
assert(context.msg_sender() == contract_address, "Only the contract can call this function");
let contract_address_keys = get_public_keys(contract_address);
let mut member_address_note = NewAddressNote::new(member, contract_address_keys.npk_m.hash());
storage.group_members.insert(&mut member_address_note).emit(
encode_and_encrypt_note_unconstrained(
&mut context,
contract_address_keys.ovpk_m,
contract_address_keys.ivpk_m,
member
)
);
}


/**
* View a specific group member at a given position.
* This is a workarounf for testing purposes
*
* @param position - Index of the group member to view.
* @returns {AztecAddress} The Aztec address of the member, or ZERO_ADDRESS if out of bounds.
*/
#[private]
#[view]
fn view_member(position: u32) -> AztecAddress {
let contract_address = context.this_address();
let contract_address_keys = get_public_keys(contract_address);
let options = NoteGetterOptions::new();
let members_notes: BoundedVec<NewAddressNote, 16> = storage.group_members.get_notes(options);

if position < members_notes.len() as u32 {
let note = members_notes.get_unchecked(position as u32);
note.address
} else {
ZERO_ADDRESS
};
}

/**
* Sets up shared payments between a creditor and debtors.
*
* @param creditor - The creditor's Aztec address.
* @param debtors - An array of debtor Aztec addresses.
* @param amount - The amount to distribute between the debtors.
*/
#[private]
fn setup_group_payments(creditor: AztecAddress, debtors: [AztecAddress; 2], amount: Field) {
let contract_address = context.this_address();
assert(context.msg_sender() == contract_address, "Only the contract can call this function");
assert(membership_check_multiple(creditor, debtors, contract_address, storage.group_members), "Creditor or debtors are not in the group");

let shared_amount : u32 = amount as u32 / (debtors.len() + 1);
for i in 0..2 {
let debtor = debtors[i];
let hash_inputs = [creditor.to_field(), debtor.to_field()];
let key = poseidon2::Poseidon2::hash(hash_inputs, 2);

let storage_location = storage.member_balances.at(key);
//Using the ValueNote helpers to increment the balance
increment(
storage_location,
shared_amount.to_field(),
contract_address,
contract_address
);
}
}

/**
* Sets the balance between a creditor and a debtor.
*
* @param creditor - The creditor's Aztec address.
* @param debtor - The debtor's Aztec address.
* @param amount - The amount of the balance.
*/
#[private]
fn set_balance(creditor: AztecAddress, debtor: AztecAddress, amount: Field) {
let contract_address = context.this_address();
assert(context.msg_sender() == contract_address, "Only the contract can call this function");
let location = storage.group_members;
assert(membership_check(creditor, debtor, contract_address, location), "Creditor or debtor are not in the group");

let hash_inputs = [creditor.to_field(), debtor.to_field()];
let key = poseidon2::Poseidon2::hash(hash_inputs, 2);

let storage_location = storage.member_balances.at(key);
increment(storage_location, amount, contract_address, contract_address);
}

/**
* Processes a payment from a debtor to a creditor.
*
* @param debtor - The debtor's Aztec address.
* @param creditor - The creditor's Aztec address.
* @param amount - The payment amount.
*/
#[private]
fn make_payment(debtor: AztecAddress, creditor: AztecAddress, amount: Field) {
let contract_address = context.this_address();
assert(context.msg_sender() == contract_address, "Only the contract can call this function");
let location = storage.group_members;
assert(membership_check(creditor, debtor, contract_address, location), "Creditor or debtor are not in the group");

let hash_inputs = [creditor.to_field(), debtor.to_field()];
let key = poseidon2::Poseidon2::hash(hash_inputs, 2);

let storage_location = storage.member_balances.at(key);
decrement(storage_location, amount, contract_address, contract_address);
}


/**
* Retrieves the balance between a creditor and a debtor.
*
* @param creditor - The creditor's Aztec address.
* @param debtor - The debtor's Aztec address.
* @returns {Field} The balance amount between the creditor and debtor.
*/
#[private]
#[view]
fn get_balance(creditor: AztecAddress, debtor: AztecAddress) -> Field {
let contract_address = context.this_address();
assert(context.msg_sender() == contract_address, "Only the contract can call this function");

let hash_inputs = [creditor.to_field(), debtor.to_field()];
let key = poseidon2::Poseidon2::hash(hash_inputs, 2);

let options = NoteGetterOptions::new();
let balance_notes: BoundedVec<ValueNote, 16> = storage.member_balances.at(key).get_notes(options);

let mut total_balance = 0 as Field;
for i in 0..balance_notes.max_len() {
if i < balance_notes.len() {
let note = balance_notes.get_unchecked(i);
total_balance += note.value;
}
}
total_balance
}

/**
* Verifies an authorization witness using a private signature.
*
* @param inner_hash - The inner hash for authentication.
* @returns {Field} A field representing the verification result.
*/
#[private]
#[view]
fn verify_private_authwit(inner_hash: Field) -> Field {
let actions = AccountActions::init(&mut context, is_valid_impl);
actions.verify_private_authwit(inner_hash)
}

/**
* Verifies if the provided signature is valid for the account.
*
* @param context - The private context of the contract.
* @param outer_hash - The hash used for signature verification.
* @returns {bool} True if the signature is valid, false otherwise.
*/
#[contract_library_method]
fn is_valid_impl(context: &mut PrivateContext, outer_hash: Field) -> bool {
let storage = Storage::init(context);
let public_key = storage.signing_public_key.get_note();
// Load auth witness and format as an u8 array
let witness: [Field; 64] = unsafe {
get_auth_witness(outer_hash)
};
let mut signature: [u8; 64] = [0; 64];
for i in 0..64 {
signature[i] = witness[i] as u8;
}
let mut is_member = false;

// Verify signature using hardcoded public key
std::schnorr::verify_signature(
public_key.x,
public_key.y,
signature,
outer_hash.to_be_bytes::<32>()
)
}
}

helpers.nr

Your helpers.nr file should look like this

use dep::aztec::prelude::{AztecAddress, PrivateContext, PrivateSet, NoteGetterOptions};
use dep::aztec::note::note_getter_options::SortOrder;
use dep::aztec::encrypted_logs::encrypted_note_emission::{
encode_and_encrypt_note, encode_and_encrypt_note_unconstrained,
};
use dep::aztec::keys::getters::get_public_keys;
use dep::value_note::{filter::filter_notes_min_sum, value_note::{ValueNote, VALUE_NOTE_LEN}};
use crate::notes::NewAddressNote::NewAddressNote;

pub fn membership_check(
creditor: AztecAddress,
debtor: AztecAddress,
contract_address: AztecAddress,
location: PrivateSet<NewAddressNote, &mut PrivateContext>,
) -> bool {
let mut creditor_in_group = false;
let mut debtor_in_group = false;
let contract_address_keys = get_public_keys(contract_address);
let options = NoteGetterOptions::new();
let members_notes: BoundedVec<NewAddressNote, 16> = location.get_notes(options);
for i in 0..members_notes.max_len() {
if i < members_notes.len() {
let note = members_notes.get_unchecked(i);
if note.address == creditor {
creditor_in_group = true;
}
if note.address == debtor {
debtor_in_group = true;
}
}
}
assert(creditor_in_group, "Creditor is not in the group");
assert(debtor_in_group, "Debtor is not in the group");
true
}

pub fn membership_check_multiple(
creditor: AztecAddress,
debtors: [AztecAddress; 2],
contract_address: AztecAddress,
location: PrivateSet<NewAddressNote, &mut PrivateContext>,
) -> bool {
membership_check(creditor, debtors[0], contract_address, location)
& membership_check(creditor, debtors[1], contract_address, location)
}

pub fn increment(
// docs:start:increment_args
balance: PrivateSet<ValueNote, &mut PrivateContext>,
amount: Field,
recipient: AztecAddress,
outgoing_viewer: AztecAddress, // docs:end:increment_args
) {
let recipient_keys = get_public_keys(recipient);
let outgoing_viewer_ovpk_m = get_public_keys(outgoing_viewer).ovpk_m;

let mut note = ValueNote::new(amount, recipient_keys.npk_m.hash());
// Insert the new note to the owner's set of notes and emit the log if value is non-zero.
balance.insert(&mut note).emit(encode_and_encrypt_note_unconstrained(
balance.context,
outgoing_viewer_ovpk_m,
recipient_keys.ivpk_m,
recipient,
));
}

pub fn decrement(
balance: PrivateSet<ValueNote, &mut PrivateContext>,
amount: Field,
owner: AztecAddress,
outgoing_viewer: AztecAddress,
) {
let sum = decrement_by_at_most(balance, amount, owner, outgoing_viewer);
assert(sum == amount, "Balance too low");
}

// Sort the note values (0th field) in descending order.
// Pick the fewest notes whose sum is equal to or greater than `amount`.
pub fn create_note_getter_options_for_decreasing_balance(
amount: Field,
) -> NoteGetterOptions<ValueNote, VALUE_NOTE_LEN, Field, Field> {
NoteGetterOptions::with_filter(filter_notes_min_sum, amount).sort(
ValueNote::properties().value,
SortOrder.DESC,
)
}

// Similar to `decrement`, except that it doesn't fail if the decremented amount is less than max_amount.
// The motivation behind this function is that there is an upper-bound on the number of notes a function may
// read and nullify. The requested decrementation `amount` might be spread across too many of the `owner`'s
// notes to 'fit' within this upper-bound, so we might have to remove an amount less than `amount`. A common
// pattern is to repeatedly call this function across many function calls, until enough notes have been nullified to
// equal `amount`.
//
// It returns the decremented amount, which should be less than or equal to max_amount.
pub fn decrement_by_at_most(
balance: PrivateSet<ValueNote, &mut PrivateContext>,
max_amount: Field,
owner: AztecAddress,
outgoing_viewer: AztecAddress,
) -> Field {
let options = create_note_getter_options_for_decreasing_balance(max_amount);
let notes = balance.pop_notes(options);

let mut decremented = 0;
for i in 0..options.limit {
if i < notes.len() {
let note = notes.get_unchecked(i);
decremented += note.value;
}
}

// Add the change value back to the owner's balance.
let mut change_value = 0;
if max_amount.lt(decremented) {
change_value = decremented - max_amount;
decremented -= change_value;
}
increment(balance, change_value, owner, outgoing_viewer);

decremented
}

Now it is time to compile the contract. From the root of your directory.

cd src && cd groupaccount && aztec-nargo compile

Summary

This section focuses on building a shared-state account contract in Aztec that implements Splitwise-like functionality, specifically showing how account abstraction and private storage can be used to more efficiently manage shared expenses.

Key Takeaways:

  • Account Abstraction: Unlike Ethereum’s EOAs, Aztec accounts are smart contracts that allow shared private state. This design lets multiple users within a trusted group access and update the same state without needing multiple notes for each transaction, reducing costs.
  • Efficient Note Emission: By using unconstrained notes and sharing state through the account itself, the contract minimizes the number of emitted notes. This reduces transaction costs while keeping shared state accessible to trusted users.
  • This is a cost-efficient way to manage shared, private data in Aztec.

Testing the Contract

For this tutorial we are going to focus on testing the contract using multiple PXEs using the jest testing framework and AztecJS. At the time of writing (version 0.60.0) it is not easy to test custom account contracts, so we will include this in a future update.

Creating our TS Artifact

aztec codegen -o src/artifacts target

Issue: There is currently an issue with the artifact generation that will be patched soon.

You will need to cast the type of the PrivateGroupsContractArtifactJson to unknown for it to work in the current version.

export const AccountGroupContractArtifact = loadContractArtifact(
AccountGroupContractArtifactJson as unknown as NoirCompiledContract
);

Testing Setup

This will be very similar to the setup for the end-to-end tests that we did before.

Let’s great the new files needed in our project.

|-- src
| |-- circuits
| | |-- src
| | | |-- notes.nr
| | | | |-- public_key_note.nr
| | | | |-- NewAddressNote.nr
|. |. | |-- notes.nr
| | | |-- main.nr
| | |-- Nargo.toml
|-- test
|. |-- index.test.ts
|. |-- utils.ts
|. |-- types.ts
|-- package.json
|-- yarn.lock
|-- jest.integration.config.json
|-- tsconfig.json

Updating the Package.json

In the root of your project add the following dependencies.

yarn add @aztec/noir-contracts.js@0.60.0 @aztec/accounts@0.60.0 @aztec/aztec.js@0.60.0 @aztec/builder@0.60.0 typescript@^5.6.2 @types/node@^22.5.4

And dev dependencies

yarn add --dev @jest/globals@^29.7.0 @types/jest@^29.5.13 @types/mocha@^10.0.6 @typescript-eslint/eslint-plugin@^6.0.0 @typescript-eslint/parser@^6.0.0 ts-jest@^29.2.5 ts-loader@^9.5.1 ts-node@^10.9.2 jest@^29.7.0

Also add these scripts for ease of use in your package.json

"scripts": {
"clean": "rm -rf ./src/circuits/artifacts ./src/circuits/target",
"codegen": "aztec codegen ./src/circuits/target --outdir src/circuits/src/artifacts",
"compile": "cd src/circuits && ${AZTEC_NARGO:-aztec-nargo} compile && cd ../..",
"test": "NODE_NO_WARNINGS=1 node --experimental-vm-modules $(yarn bin jest) --no-cache --runInBand --config jest.integration.config.json && cd src/groupaccount && aztec test"
}

jest.integration.config.json

Create this file in the route of your project.

{
"preset": "ts-jest/presets/default-esm",
"globals": {
"ts-jest": {
"useESM": true
}
},
"moduleNameMapper": {
"^(\\\\.{1,2}/.*)\\\\.js$": "$1"
},
"testRegex": ".*\\\\.test\\\\.ts$",
"rootDir": ".",
"testTimeout": 30000
}

tsconfig.json

Create this file in the route of your project.

{
"compilerOptions": {
"outDir": "dist",
"tsBuildInfoFile": ".tsbuildinfo",
"target": "es2020",
"lib": ["esnext", "dom", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"declaration": true,
"allowSyntheticDefaultImports": true,
"allowJs": true,
"esModuleInterop": true,
"downlevelIteration": true,
"inlineSourceMap": true,
"declarationMap": true,
"importHelpers": true,
"resolveJsonModule": true,
"composite": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@aztec/*": ["node_modules/@aztec/*"]
}
},
"include": [
"src/**/*.ts*",
"test/**/*.ts",
"src/contracts/target/*.json",
"src/groupaccount/target/*.json",
"artifacts/**/*.ts",
"src/contracts/src/test/**/*.ts"
]
}

types.ts

Background Information on Contract Instances and Classes

When working with Aztec Contracts, it’s important to understand the distinction between contract instances and contract classes. This concept is fundamental to interacting with and deploying contracts on the Aztec network

Contract Classes

A contract class represents the blueprint or template for the contract. It contains the code and logic that defines how the contract should behave. Key points about contract classes:

  • They are immutable once deployed
  • They can be used to create multiple instances of the same smart contract
  • Deploying a contract class incurs a one-time cost.

Contract Instances

A contract instance is a specific deployment of a contract class. It has its own state and storage.

Important aspects of contract instances.

  • Multiple instances can be created from the same contract class.
  • Each instnace has its own unique address
  • Instances can have different initial states or constructor parameters.

Interaction in AztecJS

When using AztecJS, you typically interact with contract instances. Th process usually involves:

  1. Deploying a contract class (if not already deployed)
  2. Creating a new instance of the contract
  3. Interacting with the instance using its methods

This separation allows for more efficient and flexible contract deployments on the Aztec Network.

This brings us to our types file.

import {
AccountManager,
AuthWitness,
AztecAddress,
CompleteAddress,
Fr,
GrumpkinScalar,
PXE,
Schnorr,
} from "@aztec/aztec.js";
import { AccountGroupContractArtifact } from "../src/circuits/src/artifacts/AccountGroup";
import { DefaultAccountContract } from "@aztec/accounts/defaults";
import { Salt } from "@aztec/aztec.js/account";

/**
* A class extending the DefaultAccountContract to create a contract that supports group
* functionality. The contract includes a signing private key and an owner address.
* The owner address is there to have a way to identify the group.
*/
export class AccountGroupContractClass extends DefaultAccountContract {
private signingPrivateKey: GrumpkinScalar;
private ownerAddress: AztecAddress;

/**
* Constructs a new instance of the AccountGroupContractClass.
* @param signingPrivateKey - The Grumpkin scalar private key used for signing.
* @param ownerAddress - The AztecAddress of the contract owner.
*/
constructor(signingPrivateKey: GrumpkinScalar, ownerAddress: AztecAddress) {
super(AccountGroupContractArtifact); // Use the AccountGroup contract artifact.
this.signingPrivateKey = signingPrivateKey;
this.ownerAddress = ownerAddress;
}

/**
* Returns the deployment arguments for the contract.
* This includes the Schnorr signature public key (x, y) and the owner address.
* @returns {Array} An array containing the public key components and owner address.
*/
getDeploymentArgs() {
const signingPublicKey = new Schnorr().computePublicKey(
this.signingPrivateKey
);
return [signingPublicKey.x, signingPublicKey.y, this.ownerAddress];
}

/**
* Provides an authentication witness provider for a given address.
* @param _address - The complete address (not used in this case).
* @returns {SchnorrAuthWitnessProvider} An instance of the SchnorrAuthWitnessProvider class.
*/
getAuthWitnessProvider(_address: CompleteAddress) {
return new SchnorrAuthWitnessProvider(this.signingPrivateKey);
}
}

/**
* A class to create authentication witnesses using Schnorr signatures. This class provides
* a way to sign messages with a private key, producing valid authentication witnesses.
*/
class SchnorrAuthWitnessProvider {
private signingPrivateKey: GrumpkinScalar;

/**
* Constructs a new instance of the SchnorrAuthWitnessProvider.
* @param signingPrivateKey - The private key used to create Schnorr signatures.
*/
constructor(signingPrivateKey: GrumpkinScalar) {
this.signingPrivateKey = signingPrivateKey;
}

/**
* Creates an authentication witness by signing the given message hash using the Schnorr signature.
* @param messageHash - The Fr object representing the hash of the message to be signed.
* @returns {Promise<AuthWitness>} A promise that resolves with an AuthWitness object.
*/
createAuthWit(messageHash: Fr) {
const schnorr = new Schnorr();
const signature = schnorr
.constructSignature(messageHash.toBuffer(), this.signingPrivateKey)
.toBuffer();
return Promise.resolve(new AuthWitness(messageHash, [...signature]));
}
}

/**
* A class extending AccountManager to manage group accounts.
* This manager adds an owner address to the account deployment process.
*/
export class AccountGroupManager extends AccountManager {
private owner: AztecAddress;

/**
* Constructs a new instance of AccountGroupManager.
* @param pxe - The PXE instance for managing contract execution.
* @param secretKey - The Fr object representing the secret key of the account.
* @param accountGroupContract - An instance of the AccountGroupContractClass used for account deployment.
* @param owner - The owner AztecAddress for the group account.
* @param salt - Optional salt value for the account (default is undefined).
*/
constructor(
pxe: PXE,
secretKey: Fr,
accountGroupContract: AccountGroupContractClass,
owner: AztecAddress,
salt?: Salt
) {
super(pxe, secretKey, accountGroupContract, salt); // Call the parent class constructor.
this.owner = owner;
}
}

In our custom account contract, we are extending the existing DefaultAccountContract class and AccountManager class to handle additional arguments during deployment and implement extra logic.

AccountGroupContractClass

This extends the DefaultAccountContract class, to add a signing private key and an owner address.

getDeploymentArgs() - Prepares deployment arguments, including Schnorr signature public key components and the owner address.

getAuthWitnessProvider() - Provides an authentication witness provider using Schnorr signatures.

AccountGroupManager

This extends the AccountManager adding the owner address to the account deployment process.

Benefits

You can tailor the contract’s functionality to meet specific requirements.

Leveraging existing classes builds upon tested and proven components.

utils.ts


import { getSchnorrAccount } from "@aztec/accounts/schnorr";
import {
AztecAddress,
createPXEClient,
Fr,
GrumpkinScalar,
PXE,
Schnorr,
} from "@aztec/aztec.js";
import { waitForPXE } from "@aztec/aztec.js";

/**
* Sets up a PXE instance at the given URL.
*
* @param PXE_URL - The URL of the PXE instance.
* @returns {Promise<PXE>} A promise that resolves to the PXE instance.
*/
export const setupSandbox = async (PXE_URL: string): Promise<PXE> => {
const pxe = createPXEClient(PXE_URL);
await waitForPXE(pxe); // Waits for the PXE to be fully ready.
return pxe;
};

/**
* Generates a Schnorr account for testing purposes, creating secret and signing keys.
*
* @param pxe - The PXE instance used to create the Schnorr account.
* @returns {Promise<any>} A promise that resolves to the Schnorr wallet.
*/
export const createSchnorrAccount = async (pxe: PXE) => {
const secret = Fr.random(); // Generates a random secret.
console.log("secret", secret);

const signingPrivateKey = GrumpkinScalar.random(); // Generates a random Schnorr signing private key.
console.log("signingPrivateKey", signingPrivateKey);

// Waits for Schnorr account wallet setup.
const wallet = getSchnorrAccount(pxe, secret, signingPrivateKey).waitSetup();
console.log("wallet", wallet);
return wallet;
};

/**
* Generates Schnorr public keys for a Schnorr account contract.
*
* @returns {Promise<{signingPrivateKey: GrumpkinScalar, x: Fr, y: Fr}>}
* An object containing the signing private key and the public key components (x, y).
*/
export const generatePublicKeys = async () => {
const signingPrivateKey = GrumpkinScalar.random(); // Generates a random Schnorr signing private key.
const schnorr = new Schnorr();

// Computes the Schnorr public key.
const publicKey = schnorr.computePublicKey(signingPrivateKey);

// Returns the public key fields (x, y).
const [x, y] = publicKey.toFields();
return { signingPrivateKey, x, y };
};

export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

This file provides helper functions for setting up the sandbox with multiple PXE instances (which we will explain later), creating a Schnorr account, a helper function for generating public keys that we will need for our account contract and a delay to ensure synchronisation.

Setting up Multiple PXE Instances

In order to test our contract we need to simulate that the contract is being used by different parties that are in different locations. We can do this by running multiple instances of the PXE.

There is a guide which shows you how to do this here.

In this case we are going to need three PXE instances, in three different terminals as a minimum for our three group members. These are going to run on port 8080, 8001 and 8082.

index.test.ts

This is where the bulk of our tests are going to take place.

Setting up the testing environment

import {
AccountWallet,
createDebugLogger,
Fr,
PXE,
DebugLogger,
AztecAddress,
GrumpkinScalar,
Wallet,
PartialAddress,
} from "@aztec/aztec.js";
import { type DeployAccountOptions } from "@aztec/aztec.js";
import {
AccountGroupContractArtifact,
AccountGroupContract,
} from "../src/groupaccount/src/artifacts/AccountGroup";
import { AccountGroupManager, AccountGroupContractClass } from "./types";
import {
delay,
createSchnorrAccount,
generatePublicKeys,
setupSandbox,
} from "./utils";

const { PXE_URL1 = "http://localhost:8080" } = process.env;
const { PXE_URL2 = "http://localhost:8081" } = process.env;
const { PXE_URL3 = "http://localhost:8082" } = process.env;

// Test case to deploy the contract
describe("AccountGroup Contract Deployment", () => {
//PXE instances
let pxe1: PXE;
let pxe2: PXE;
let pxe3: PXE;

//Logger
let logger: DebugLogger;

//Wallets
let ownerAccount: AccountWallet;
let aliceWallet: Wallet;
let bobWallet: Wallet;
let charlieWallet: Wallet;

//Addresses
let contractAddressPXE1: AztecAddress;
let owner: AztecAddress;
let aliceAddress: AztecAddress;
let bobAddress: AztecAddress;
let charlieAddress: AztecAddress;

//Keys
let accountPrivateKey: GrumpkinScalar;
let contractAddressPXE2: AztecAddress;
let contractAddressPXE3: AztecAddress;
let salt: Fr;
let secret: Fr;

//Contract instances and Wallets
let contractAccountPXE1: Wallet;
let contractAccountPXE2: Wallet;
let contractAccountPXE3: Wallet;
let contractInstancePXE1: AccountGroupContract;
let contractInstancePXE2: AccountGroupContract;
let contractInstancePXE3: AccountGroupContract;

let partialAddress: Fr;

//Contract Class
let accountContractPXE1: AccountGroupContractClass;

//-----------------------------------Setup-----------------------------------

beforeAll(async () => {
//Initialiing the Logger
logger = createDebugLogger("aztec:account-group");

//Setting up the PXE instances
pxe1 = await setupSandbox(PXE_URL1);
pxe2 = await setupSandbox(PXE_URL2);
pxe3 = await setupSandbox(PXE_URL3);

//Creating the owner account on PXE1
ownerAccount = await createSchnorrAccount(pxe1);
owner = ownerAccount.getAddress();

//Creating Alice's account on PXE2
aliceWallet = await createSchnorrAccount(pxe2);
aliceAddress = aliceWallet.getAddress();

//Creating Bob's account on PXE3
bobWallet = await createSchnorrAccount(pxe3);
bobAddress = bobWallet.getAddress();

//Creating Charlie's account on PXE1, (not added to the group)
charlieWallet = await createSchnorrAccount(pxe1);
charlieAddress = charlieWallet.getAddress();
});
});

Here, we you’ll set up a testing environment to deploy and interact with an AccountGroup contract across multiple PXE (Private Execution Environment) instances. This setup will enable you to simulate a distributed network with different users and accounts, allowing thorough testing of the contract's functionality across separate simulated nodes.

Overview

You’ll create and configure three PXE instances, each representing different users. This setup includes:

  • Logger: To capture and display logs for debugging.
  • Wallets and Addresses: Separate accounts for contract interaction.
  • Keys and Contracts: To manage transaction signing and contract-specific logic.

Key Components

1. PXE Instances

  • You’ll create three PXE instances (pxe1, pxe2, pxe3) connected to unique URLs.
  • This setup lets you test contract interactions across these PXE instances.

2. Logger

  • A logger is initialized to track events and operations related to the AccountGroup contract.
  • This provides useful debug information, helping to identify issues during the testing process.

3. Wallets and Addresses

  • Owner Account: This account, created on pxe1, acts as the administrator or owner of the contract.
  • User Accounts (Alice, Bob, Charlie): These are separate user accounts created across different PXE instances:
  • Alice: Account created on pxe2.
  • Bob: Account created on pxe3.
  • Charlie: Account created on pxe1 alongside the owner account.
  • Each of these accounts represents a user who will interact with the contract.

4. Keys and Contracts

  • Account Private Key: Used for signing transactions and verifying actions within the contract.
  • Contract Instances: Variables to represent contract instances on each PXE, which will be used for deploying and interacting with the AccountGroup contract.
  • Contract Class: AccountGroupContractClass handles contract-specific logic, including deployment parameters and authentication requirements.

Registering the AccountGroup in Multiple PXE instances

//-----------------------------------Registering accounts on PXEs -----------------------------------

it("Deploys the AccountGroupContract", async () => {
//Generate random salt and secret for account deploymnet
salt = Fr.random();
secret = Fr.random();

//Generate public and private keys for the account contract
const { signingPrivateKey, x, y } = await generatePublicKeys();
accountPrivateKey = signingPrivateKey;

//Create an instance of the AccountGroupContractClass with the signing private key and owner address
accountContractPXE1 = new AccountGroupContractClass(
signingPrivateKey,
owner
);

// Initialize AccountGroupManager with the contract.
const accountManagerPXE1 = new AccountGroupManager(
pxe1,
secret,
accountContractPXE1,
owner,
salt
);

//Register the account contract in PXE1
await accountManagerPXE1.register();

// Deployment options for the account
const deployOptions: DeployAccountOptions = {
skipClassRegistration: false,
skipPublicDeployment: false,
};

//Deploy the account and get the wallet instance
const deployTx = accountManagerPXE1.deploy(deployOptions);
const walletPXE1 = await deployTx.getWallet();
contractAccountPXE1 = walletPXE1;
contractAddressPXE1 = walletPXE1.getAddress();

//this is not used, just a check
partialAddress = walletPXE1.getCompleteAddress().partialAddress;
console.log("partialAddress", partialAddress.toString());

//Create an instance of the contract in PXE1
//Using the account contracts associated wallet to call methods on the contract
contractInstancePXE1 = await AccountGroupContract.at(
contractAddressPXE1,
contractAccountPXE1
);

expect(walletPXE1.getCompleteAddress()).toBeDefined();

//Delay to ensure sychronization
await delay(2000);
});

it("Registers the AccountGroupContract in Alice's PXE", async () => {
//This initializes the Account Contract in the Second PXE instance, using the same secret and salt
//These are the two secrets that need to be known to register the contract in a new PXE instance
const aliceManagerPXE2 = new AccountGroupManager(
pxe2,
secret,
accountContractPXE1,
owner,
salt
);

// Register contract wallet in Alice’s PXE
await aliceManagerPXE2.register();
const walletPXE2 = await aliceManagerPXE2.getWallet();
contractAccountPXE2 = walletPXE2;
contractAddressPXE2 = walletPXE2.getAddress();

//Ensure the contract addresses match across the PXE instances
expect(walletPXE2.getCompleteAddress().address.toString()).toBe(
contractAddressPXE1.toString()
);
expect(walletPXE2.getCompleteAddress().toString()).toBeDefined();

//Create an instance of the contract in PXE2
contractInstancePXE2 = await AccountGroupContract.at(
contractAddressPXE2,
contractAccountPXE2
);

//Getting the block number for tracking purposes
const blockNumber = await pxe1.getBlockNumber();
console.log("blockNumber", blockNumber);
await delay(2000);
});

it("Registers the AccountGroupContract in Bob's PXE", async () => {
//Initialize the Account Contract for Bob in PXE3
const bobManagerPXE3 = new AccountGroupManager(
pxe3,
secret,
accountContractPXE1,
owner,
salt
);

//Register the contract wallet in Bob's PXE
await bobManagerPXE3.register();
const walletPXE3 = await bobManagerPXE3.getWallet();
contractAccountPXE3 = walletPXE3;
contractAddressPXE3 = walletPXE3.getAddress();

contractInstancePXE3 = await AccountGroupContract.at(
contractAddressPXE3,
contractAccountPXE3
);

//Ensure the contract addresses match across the PXE instances
expect(walletPXE3.getCompleteAddress().address.toString()).toBe(
contractAddressPXE1.toString()
);
});

This part of the test script focuses on deploying the AccountGroupContract and registering it on different PXE (Private Execution Environment) instances. The goal is to confirm that the contract is consistently deployed and accessible across multiple environments, ensuring seamless interaction and testing in a distributed setup.

Key Components

1. Salt and Secret Generation

  • Salt and Secret: Random values are generated using Fr.random() for contract deployment and registration.

2. Key Generation

  • Public and Private Keys: The generatePublicKeys() function generates signing keys for the contract.

Deployment and Registration Process

Step 1: Deploying on PXE1

  • Deployment: The contract is deployed on pxe1 using AccountGroupManager, with deployment options to control class registration and public deployment settings.
  • Wallet Setup: A wallet instance is created post-deployment, allowing interactions with the deployed contract.
  • Contract Instance: An instance of the AccountGroupContract is created using the deployed contract's address and wallet, enabling interaction with the contract on pxe1.

Step 2: Registering on PXE2 (Alice’s PXE)

  • Registration: The contract is registered on pxe2 using the previously generated salt and secret, ensuring the contract is accessible on this instance.
  • Address Verification: The contract address is validated to ensure consistency across PXE instances, confirming that the same contract is referenced.
  • Contract Instance: An instance of the contract is created on pxe2, allowing Alice to interact with the same deployed contract.

Step 3: Registering on PXE3 (Bob’s PXE)

  • Registration: The contract is registered on pxe3 using the same salt and secret.
  • Address Verification: The contract address is checked for consistency, ensuring that all PXE instances reference the same contract.
  • Contract Instance: An instance of the contract is created on pxe3 for Bob to interact with.

Summary

By deploying and registering the AccountGroupContract across multiple PXE instances, this setup enables consistent interaction and testing of the contract in a distributed environment. Each PXE instance, connected to the same contract address, allows different users to interact with the shared contract, enabling thorough, distributed testing.

Setting Balances in Multiple PXEs

Just as a demonstration we will now update the balances in multiple PXE environments to show how you can do this.

it("Makes balance between Alice and Bob PXE1", async () => {
//Add Alice and Bob to the group, from different PXE instances
await contractInstancePXE1.methods.add_member(aliceAddress).send().wait();
await contractInstancePXE2.methods.add_member(bobAddress).send().wait();

const set_balance = await contractInstancePXE1.methods
.set_balance(aliceAddress, bobAddress, 100)
.send()
.wait();
console.log("set_balance", set_balance);

const balance = await contractInstancePXE1.methods
.get_balance(aliceAddress, bobAddress)
.simulate();
console.log("balance", balance);
expect(balance).toBe(100n);
});

it("Makes balance between Alice and Bob PXE2", async () => {
const set_balance = await contractInstancePXE2.methods
.set_balance(aliceAddress, bobAddress, 100)
.send()
.wait();
console.log("set_balance", set_balance);

const balance = await contractInstancePXE2.methods
.get_balance(aliceAddress, bobAddress)
.simulate();
console.log("balance", balance);
expect(balance).toBe(200n);
});

it("Makes balance between Alice and Bob PXE3", async () => {
const set_balance = await contractInstancePXE3.methods
.set_balance(aliceAddress, bobAddress, 100)
.send()
.wait();
console.log("set_balance", set_balance);

const balance = await contractInstancePXE3.methods
.get_balance(aliceAddress, bobAddress)
.simulate();
console.log("balance", balance);
expect(balance).toBe(300n);
});

These test cases verify that balance updates between Alice and Bob are correctly managed across multiple PXE instances. Each PXE instance (PXE1, PXE2, PXE3) independently sets an additional balance of 100 between Alice and Bob, incrementing the cumulative balance by 100 with each test.

These are just a small number of tests to get u familiar with the flow of registering the account contract to multiple PXEs and interacting with it. If you would like to create more tests yourself please to do so. Otherwise more tests are available in the Github Repo for this project, linked here.

Summary of Testing the Contract.

This section of the tutorial demonstrates how to set up, deploy, and test the AccountGroup contract across multiple PXE instances using Jest and AztecJS. By running the tests in a distributed environment, you can verify that the contract behaves as expected for different users.

The Journey So Far

So far we have developed two Splitwise like application in Aztec, one using a traditional contract, and the other using a custom account contract to share private state.

Contract one:

We began writing private contracts in aztec, creating a PrivateGroups contract that uses private storage and custom notes like NewAddressNote to securely manage group members and balances. This involved defining storage structures to hold the admin, group members, and balances. Implementing functions to set balances, make payments and read balances. The contract ensured that all interactions were private and accessible only to authorised users by leveraging notes.

We conducted tests using two approaches. First, we performed end-t-end tests with AztecJS and the PXE, which simulated realistic user interactions. Second, we used the TXE for localized, rapid testing of specific contract logic, allowing us to access and verify internal states directly.

Contract two:

We used account abstraction with the AccountGroup contract, an account contract that allows shared private state among trusted users. Unlike the PrivateGroups contract, the AccountGroup contract reduced the need for emitting an excessive number of notes and reduced the proving time and cost of the contract by utilizing unconstrained notes and sharing state through the account itself.

We then tested the Account Group contract across multiple PXE instances to simulate a distributed environment where different users operate with different PXEs. We verified that the state remained consistent and accessible to users across all PXE instances.

We have gained experience with Aztec’s private state, note system and testing methodologies. By building and testing both contracts, we have showed multiple ways to create private applications on the Aztec Network.

I hope that this acts as a good base for developers to build more complex and scalable private applications, emphasising the importance of understanding the different design patterns with public and private state, managing notes and testing contracts.

Interacting with the Contracts Using Demo Frontends.

Demo frontends for the contracts discussed in this tutorial can be found here:

For the PrivateGroup contract — https://github.com/Cheetah0x/aztec-privategroup

For the AccountGroup contract — https://github.com/Cheetah0x/AccountContract

Follow the instructions in the READMe for the repos to run a demo frontend that you can use to interact with the contracts. These frontends were created using the “aztec box” tool which is helps you to quickly made frontends in Aztec.

Acknowledgements

Thanks to Josh, Maddiaa, James, Joe, Ilyas, Facundo and Rafi for their feedback and help making this.

--

--

No responses yet