Skip to main content

Private State

On this page we will look at how to manage private state in Aztec contracts. We will look at how to declare private state, how to read and write to it, and how to use it in your contracts.

For a higher level overview of the state model in Aztec, see the hybrid state model page.

Overview

In contrast to public state, private state is persistent state that is not visible to the whole world. Depending on the logic of the smart contract, a private state variable's current value will only be known to one entity, or a closed group of entities.

The value of a private state variable can either be shared via an encrypted log, or offchain via web2, or completely offline: it's up to the app developer.

Aztec private state follows a UTXO-based model. That is, a private state's current value is represented as one or many notes.

To greatly simplify the experience of writing private state, Aztec.nr provides three different types of private state variable:

These three structs abstract-away many of Aztec's protocol complexities, by providing intuitive methods to modify notes in the utxo tree in a privacy-preserving way.

info

An app can also choose to emit data via unencrypted log, or to define a note whose data is easy to figure out, then the information is technically not private and could be visible to anyone.

Notes

Unlike public state variables, which can be arbitrary types, private state variables operate on NoteType.

Notes are the fundamental elements in the private world.

A note should implement the following traits:

note_interface
// Autogenerated by the #[note] macro

pub trait NoteInterface<let N: u32> {
// Autogenerated by the #[note] macro
fn serialize_content(self) -> [Field; N];

// Autogenerated by the #[note] macro
fn deserialize_content(fields: [Field; N]) -> Self;

// Autogenerated by the #[note] macro
fn get_header(self) -> NoteHeader;

// Autogenerated by the #[note] macro
fn set_header(&mut self, header: NoteHeader) -> ();

// Autogenerated by the #[note] macro
fn get_note_type_id() -> Field;

// Autogenerated by the #[note] macro
fn to_be_bytes(self, storage_slot: Field) -> [u8; N * 32 + 64];

// Autogenerated by the #[note] macro
fn compute_note_hash(self) -> Field;
}
Source code: noir-projects/aztec-nr/aztec/src/note/note_interface.nr#L32-L57
serialize
#[derive_via(derive_serialize)]
pub trait Serialize<let N: u32> {
fn serialize(self) -> [Field; N];
}
Source code: noir-projects/noir-protocol-circuits/crates/types/src/traits.nr#L157-L162
deserialize
#[derive_via(derive_deserialize)]
pub trait Deserialize<let N: u32> {
fn deserialize(fields: [Field; N]) -> Self;
}
Source code: noir-projects/noir-protocol-circuits/crates/types/src/traits.nr#L175-L180

The interplay between a private state variable and its notes can be confusing. Here's a summary to aid intuition:

A private state variable (of type PrivateMutable, PrivateImmutable or PrivateSet) may be declared in storage.

Every note contains a header, which contains the contract address and storage slot of the state variable to which it is associated. A note is associated with a private state variable if the storage slot of the private state variable matches the storage slot contained in the note's header. The header provides information that helps the user interpret the note's data.

Management of the header is abstracted-away from developers who use the PrivateImmutable, PrivateMutable and PrivateSet types.

A private state variable points to one or many notes (depending on the type). The note(s) are all valid private state if the note(s) haven't yet been nullified.

An PrivateImmutable will point to one note over the lifetime of the contract. This note is a struct of information that is persisted forever.

A PrivateMutable may point to one note at a time. But since it's not "immutable", the note that it points to may be replaced by functions of the contract. The current value of a PrivateMutable is interpreted as the one note which has not-yet been nullified. The act of replacing a PrivateMutable's note is how a PrivateMutable state may be modified by functions.

PrivateMutable is a useful type when declaring a private state variable which may only ever be modified by those who are privy to the current value of that state.

A PrivateSet may point to multiple notes at a time. The "current value" of a private state variable of type PrivateSet is some accumulation of all not-yet nullified notes which belong to the PrivateSet.

note

The term "some accumulation" is intentionally vague. The interpretation of the "current value" of a PrivateSet must be expressed by the smart contract developer. A common use case for a PrivateSet is to represent the sum of a collection of values (in which case 'accumulation' is 'summation').

Think of a ZCash balance (or even a Bitcoin balance). The "current value" of a user's ZCash balance is the sum of all unspent (not-yet nullified) notes belonging to that user. To modify the "current value" of a PrivateSet state variable, is to insert new notes into the PrivateSet, or remove notes from that set.

Interestingly, if a developer requires a private state to be modifiable by users who aren't privy to the value of that state, a PrivateSet is a very useful type. The insert method allows new notes to be added to the PrivateSet without knowing any of the other notes in the set! (Like posting an envelope into a post box, you don't know what else is in there!).

PrivateMutable<NoteType>

PrivateMutable (formerly known as Singleton) is a private state variable that is unique in a way. When a PrivateMutable is initialized, a note is created to represent its value. And the way to update the value is to destroy the current note, and create a new one with the updated value.

Like for public state, we define the struct to have context and a storage slot. You can view the implementation here (GitHub link).

An example of PrivateMutable usage in the account contracts is keeping track of public keys. The PrivateMutable is added to the Storage struct as follows:

storage-private-mutable-declaration
legendary_card: PrivateMutable<CardNote, Context>,
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L38-L40

new

As part of the initialization of the Storage struct, the PrivateMutable is created as follows at the specified storage slot.

start_vars_private_mutable
legendary_card: PrivateMutable::new(context, 3),
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L68-L70

initialize

As mentioned, the PrivateMutable is initialized to create the first note and value.

When this function is called, a nullifier of the storage slot is created, preventing this PrivateMutable from being initialized again.

Privacy-Leak

Beware that because this nullifier is created only from the storage slot without randomness it leaks privacy. This means that it is possible for an external observer to determine when the note is nullified.

For example, if the storage slot depends on the an address then it is possible to link the nullifier to the address. If the PrivateMutable is part of a map with an AztecAddress as the key then the nullifier will be linked to the address.

Unlike public states, which have a default initial value of 0 (or many zeros, in the case of a struct, array or map), a private state (of type PrivateMutable, PrivateImmutable or PrivateSet) does not have a default initial value. The initialize method (or insert, in the case of a PrivateSet) must be called.

info

Extend on what happens if you try to use non-initialized state.

is_initialized

An unconstrained method to check whether the PrivateMutable has been initialized or not. It takes an optional owner and returns a boolean. You can view the implementation here (GitHub link).

private_mutable_is_initialized
unconstrained fn is_legendary_initialized() -> bool {
storage.legendary_card.is_initialized()
}
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L295-L299

replace

To update the value of a PrivateMutable, we can use the replace method. The method takes a new note as input, and replaces the current note with the new one. It emits a nullifier for the old value, and inserts the new note into the data tree.

An example of this is seen in a example card game, where we create a new note (a CardNote) containing some new data, and replace the current note with it:

state_vars-PrivateMutableReplace
storage.legendary_card.replace(&mut new_card).emit(encode_and_encrypt_note(
&mut context,
msg_sender_ovpk_m,
context.msg_sender(),
context.msg_sender(),
));
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L262-L269

If two people are trying to modify the PrivateMutable at the same time, only one will succeed as we don't allow duplicate nullifiers! Developers should put in place appropriate access controls to avoid race conditions (unless a race is intended!).

get_note

This function allows us to get the note of a PrivateMutable, essentially reading the value.

state_vars-PrivateMutableGet
let card = storage.legendary_card.get_note().note;
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L255-L257

Nullifying Note reads

To ensure that a user's private execution always uses the latest value of a PrivateMutable, the get_note function will nullify the note that it is reading. This means that if two people are trying to use this function with the same note, only one will succeed (no duplicate nullifiers allowed).

This also makes read operations indistinguishable from write operations and allows the sequencer to verifying correct execution without learning anything about the value of the note.

view_note

Functionally similar to get_note, but executed in unconstrained functions and can be used by the wallet to fetch notes for use by front-ends etc.

PrivateImmutable<NoteType>

PrivateImmutable (formerly known as ImmutableSingleton) represents a unique private state variable that, as the name suggests, is immutable. Once initialized, its value cannot be altered. You can view the implementation here (GitHub link).

new

As part of the initialization of the Storage struct, the PrivateMutable is created as follows, here at storage slot 1.

storage-private-immutable-declaration
private_immutable: PrivateImmutable<CardNote, Context>,
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L46-L48

initialize

When this function is invoked, it creates a nullifier for the storage slot, ensuring that the PrivateImmutable cannot be initialized again.

Privacy-Leak

Beware that because this nullifier is created only from the storage slot without randomness it leaks privacy. This means that it is possible for an external observer to determine when the note is nullified.

For example, if the storage slot depends on the an address then it is possible to link the nullifier to the address. If the PrivateImmutable is part of a map with an AztecAddress as the key then the nullifier will be linked to the address.

Set the value of an PrivateImmutable by calling the initialize method:

initialize-private-mutable
#[private]
fn initialize_private_immutable(randomness: Field, points: u8) {
let mut new_card = CardNote::new(points, randomness, context.msg_sender());

let msg_sender_ovpk_m = get_public_keys(context.msg_sender()).ovpk_m;
storage.private_immutable.initialize(&mut new_card).emit(encode_and_encrypt_note(
&mut context,
msg_sender_ovpk_m,
context.msg_sender(),
context.msg_sender(),
));
}
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L174-L187

Once initialized, an PrivateImmutable's value remains unchangeable. This method can only be called once.

is_initialized

An unconstrained method to check if the PrivateImmutable has been initialized. Takes an optional owner and returns a boolean. You can find the implementation here (GitHub link).

get_note

Similar to the PrivateMutable, we can use the get_note method to read the value of an PrivateImmutable.

Use this method to retrieve the value of an initialized PrivateImmutable.

get_note-private-immutable
#[private]
fn get_imm_card() -> CardNote {
storage.private_immutable.get_note()
}
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L300-L305

Unlike a PrivateMutable, the get_note function for an PrivateImmutable doesn't nullify the current note in the background. This means that multiple accounts can concurrently call this function to read the value.

This function will throw if the PrivateImmutable hasn't been initialized.

view_note

Functionally similar to get_note, but executed unconstrained and can be used by the wallet to fetch notes for use by front-ends etc.

PrivateSet<NoteType>

PrivateSet is used for managing a collection of notes. All notes in a PrivateSet are of the same NoteType. But whether these notes all belong to one entity, or are accessible and editable by different entities, is up to the developer. The set is a collection of notes inserted into the data-tree, but notes are never removed from the tree itself, they are only nullified.

You can view the implementation here (GitHub link).

And can be added to the Storage struct as follows. Here adding a set for a custom note.

storage-set-declaration
set: PrivateSet<CardNote, Context>,
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L43-L45

new

The new method tells the contract how to operate on the underlying storage.

We can initialize the set as follows:

storage-set-init
set: PrivateSet::new(context, 5),
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L79-L81

insert

Allows us to modify the storage by inserting a note into the PrivateSet.

A hash of the note will be generated, and inserted into the note hash tree, allowing us to later use in contract interactions. Recall that the content of the note should be shared with the owner to allow them to use it, as mentioned this can be done via an encrypted log or offchain via web2, or completely offline.

insert
self.set.insert(&mut addend_note).emit(encode_and_encrypt_note(
self.context,
outgoing_viewer_keys.ovpk_m,
owner,
sender,
));
Source code: noir-projects/aztec-nr/easy-private-state/src/easy_private_uint.nr#L37-L44

insert_from_public

The insert_from_public allow public function to insert notes into private storage. This is very useful when we want to support private function calls that have been initiated in public.

The usage is similar to using the insert method with the difference that this one is called in public functions.

pop_notes

This function pops (gets, removes and returns) the notes the account has access to based on the provided filter.

The kernel circuits are constrained to a maximum number of notes this function can return at a time. Check here (GitHub link) and look for MAX_NOTE_HASH_READ_REQUESTS_PER_CALL for the up-to-date number.

Because of this limit, we should always consider using the second argument NoteGetterOptions to limit the number of notes we need to read and constrain in our programs. This is quite important as every extra call increases the time used to prove the program and we don't want to spend more time than necessary.

An example of such options is using the filter_notes_min_sum (GitHub link) to get "enough" notes to cover a given value. Essentially, this function will return just enough notes to cover the amount specified such that we don't need to read all our notes. For users with a lot of notes, this becomes increasingly important.

pop_notes
let options = NoteGetterOptions::with_filter(filter_notes_min_sum, subtrahend as Field);
let notes = self.set.pop_notes(options);
Source code: noir-projects/aztec-nr/easy-private-state/src/easy_private_uint.nr#L57-L60

get_notes

This function has the same behavior as pop_notes above but it does not delete the notes.

remove

Will remove a note from the PrivateSet if it previously has been read from storage, e.g. you have fetched it through a get_notes call. This is useful when you want to remove a note that you have previously read from storage and do not have to read it again.

Note that if you obtained the note you are about to remove via get_notes it's much better to use pop_notes as pop_notes results in significantly fewer constraints since it doesn't need to check that the note has been previously read, as it reads and deletes at once.

view_notes

Functionally similar to get_notes, but executed unconstrained and can be used by the wallet to fetch notes for use by front-ends etc.

view_notes
let mut options = NoteViewerOptions::new();
let notes = set.view_notes(options.set_offset(offset));
Source code: noir-projects/aztec-nr/value-note/src/balance_utils.nr#L16-L19

There's also a limit on the maximum number of notes that can be returned in one go. To find the current limit, refer to this file (GitHub link) and look for MAX_NOTES_PER_PAGE.

The key distinction is that this method is unconstrained. It does not perform a check to verify if the notes actually exist, which is something the get_notes method does under the hood. Therefore, it should only be used in an unconstrained contract function.

This function requires a NoteViewerOptions. The NoteViewerOptions is essentially similar to the NoteGetterOptions, except that it doesn't take a custom filter.

NoteGetterOptions

NoteGetterOptions encapsulates a set of configurable options for filtering and retrieving a selection of notes from a data oracle. Developers can design instances of NoteGetterOptions, to determine how notes should be filtered and returned to the functions of their smart contracts.

You can view the implementation here (GitHub link).

selects: BoundedVec<Option<Select>, N>

selects is a collection of filtering criteria, specified by Select { property_selector: PropertySelector, comparator: u8, value: Field } structs. It instructs the data oracle to find notes whose serialized field (as specified by the PropertySelector) matches the provided value, according to the comparator. The PropertySelector is in turn specified as having an index (nth position of the selected field in the serialized note), an offset (byte offset inside the selected serialized field) and length (bytes to read of the field from the offset). These values are not expected to be manually computed, but instead specified by passing functions autogenerated from the note definition.

sorts: BoundedVec<Option<Sort>, N>

sorts is a set of sorting instructions defined by Sort { property_selector: PropertySelector, order: u2 } structs. This directs the data oracle to sort the matching notes based on the value of the specified PropertySelector and in the indicated order. The value of order is 1 for DESCENDING and 2 for ASCENDING.

limit: u32

When the limit is set to a non-zero value, the data oracle will return a maximum of limit notes.

offset: u32

This setting enables us to skip the first offset notes. It's particularly useful for pagination.

preprocessor: fn ([Option<Note>; MAX_NOTE_HASH_READ_REQUESTS_PER_CALL], PREPROCESSOR_ARGS) -> [Option<Note>; MAX_NOTE_HASH_READ_REQUESTS_PER_CALL]

Developers have the option to provide a custom preprocessor. This allows specific logic to be applied to notes that meet the criteria outlined above. The preprocessor takes the notes returned from the oracle and preprocessor_args as its parameters.

An important distinction from the filter function described below is that preprocessor is applied first and unlike filter it is applied in an unconstrained context.

preprocessor_args: PREPROCESSOR_ARGS

preprocessor_args provides a means to furnish additional data or context to the custom preprocessor.

filter: fn ([Option<Note>; MAX_NOTE_HASH_READ_REQUESTS_PER_CALL], FILTER_ARGS) -> [Option<Note>; MAX_NOTE_HASH_READ_REQUESTS_PER_CALL]

Just like preprocessor just applied in a constrained context (correct execution is proven) and applied after the preprocessor.

filter_args: FILTER_ARGS

filter_args provides a means to furnish additional data or context to the custom filter.

status: u2

status allows the caller to retrieve notes that have been nullified, which can be useful to prove historical data. Note that when querying for both active and nullified notes the caller cannot know if each note retrieved has or has not been nullified.

Methods

Several methods are available on NoteGetterOptions to construct the options in a more readable manner:

fn new() -> NoteGetterOptions<Note, N, Field>

This function initializes a NoteGetterOptions that simply returns the maximum number of notes allowed in a call.

fn with_filter(filter, filter_args) -> NoteGetterOptions<Note, N, FILTER_ARGS>

This function initializes a NoteGetterOptions with a filter and filter_args.

.select

This method adds a Select criterion to the options.

.sort

This method adds a Sort criterion to the options.

.set_limit

This method lets you set a limit for the maximum number of notes to be retrieved.

.set_offset

This method sets the offset value, which determines where to start retrieving notes.

.set_status

This method sets the status of notes to retrieve (active or nullified).

Examples

Example 1

The following code snippet creates an instance of NoteGetterOptions, which has been configured to find the cards that belong to an account with nullifying key hash equal to account_npk_m_hash. The returned cards are sorted by their points in descending order, and the first offset cards with the highest points are skipped.

state_vars-NoteGetterOptionsSelectSortOffset
pub fn create_npk_card_getter_options(
account: AztecAddress,
offset: u32,
) -> NoteGetterOptions<CardNote, CARD_NOTE_LEN, Field, Field> {
let mut options = NoteGetterOptions::new();
options
.select(CardNote::properties().owner, Comparator.EQ, account)
.sort(CardNote::properties().points, SortOrder.DESC)
.set_offset(offset)
}
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/options.nr#L11-L22

The first value of .select and .sort indicates the property of the note we're looking for. For this we use helper functions that are autogenerated from the note definition. CardNote that has the following fields:

state_vars-CardNote
global CARD_NOTE_LEN: u32 = 3; // 3 plus a header.

#[note]
pub struct CardNote {
points: u8,
randomness: Field,
owner: AztecAddress,
}
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/types/card_note.nr#L12-L21

CardNote::properties() will return a struct with the values to pass for each field, which are related to their indices inside the CardNote struct, internal offset and length.

In the example, .select(CardNote::properties().npk_m_hash, Comparator.EQ, account_npk_m_hash) matches notes which have the npk_m_hash field set to account_npk_m_hash. In this case we're using the equality comparator, but other operations exist in the Comparator utility struct.

.sort(0, SortOrder.DESC) sorts the 0th field of CardNote, which is points, in descending order.

There can be as many conditions as the number of fields a note type has. The following example finds cards whose fields match the three given values:

state_vars-NoteGetterOptionsMultiSelects
pub fn create_exact_card_getter_options(
points: u8,
secret: Field,
account: AztecAddress,
) -> NoteGetterOptions<CardNote, CARD_NOTE_LEN, Field, Field> {
let mut options = NoteGetterOptions::new();
options
.select(CardNote::properties().points, Comparator.EQ, points as Field)
.select(CardNote::properties().randomness, Comparator.EQ, secret)
.select(CardNote::properties().owner, Comparator.EQ, account)
}
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/options.nr#L24-L36

While selects lets us find notes with specific values, filter lets us find notes in a more dynamic way. The function below picks the cards whose points are at least min_points, although this now can be done by using the select function with a GTE comparator:

state_vars-OptionFilter
pub fn filter_min_points(
cards: [Option<CardNote>; MAX_NOTE_HASH_READ_REQUESTS_PER_CALL],
min_points: u8,
) -> [Option<CardNote>; MAX_NOTE_HASH_READ_REQUESTS_PER_CALL] {
let mut selected_cards = [Option::none(); MAX_NOTE_HASH_READ_REQUESTS_PER_CALL];
let mut num_selected = 0;
for i in 0..cards.len() {
if cards[i].is_some() & cards[i].unwrap_unchecked().points >= min_points {
selected_cards[num_selected] = cards[i];
num_selected += 1;
}
}
selected_cards
}
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/options.nr#L38-L53

We can use it as a filter to further reduce the number of the final notes:

state_vars-NoteGetterOptionsFilter
pub fn create_cards_with_min_points_getter_options(
min_points: u8,
) -> NoteGetterOptions<CardNote, CARD_NOTE_LEN, Field, u8> {
NoteGetterOptions::with_filter(filter_min_points, min_points).sort(
CardNote::properties().points,
SortOrder.ASC,
)
}
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/options.nr#L55-L64

One thing to remember is, filter will be applied on the notes after they are picked from the database, so it is more efficient to use select with comparators where possible. Another side effect of this is that it's possible that the actual notes we end up getting are fewer than the limit.

The limit is MAX_NOTE_HASH_READ_REQUESTS_PER_CALL by default. But we can set it to any value smaller than that:

state_vars-NoteGetterOptionsPickOne
pub fn create_largest_card_getter_options() -> NoteGetterOptions<CardNote, CARD_NOTE_LEN, Field, Field> {
let mut options = NoteGetterOptions::new();
options.sort(CardNote::properties().points, SortOrder.DESC).set_limit(1)
}
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/options.nr#L66-L71

Example 2

An example of how we can use a Comparator to select notes when calling a Noir contract from aztec.js is below.

state_vars-NoteGetterOptionsComparatorExampleTs
contract.methods.read_note(Comparator.GTE, 5).simulate(),
Source code: yarn-project/end-to-end/src/e2e_note_getter.test.ts#L57-L59

In this example, we use the above typescript code to invoke a call to our Noir contract below. This Noir contract function takes an input to match with, and a comparator to use when fetching and selecting notes from storage.

state_vars-NoteGetterOptionsComparatorExampleNoir
unconstrained fn read_note(comparator: u8, amount: Field) -> BoundedVec<CardNote, 10> {
let mut options = NoteViewerOptions::new();
storage.set.view_notes(options.select(CardNote::properties().points, comparator, amount))
}
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L230-L235