Skip to content

Commit dfb007f

Browse files
nventurobenesjan
authored and
AztecBot
committed
feat: partial note handling in aztec-nr (#12122)
We're back! Reopening AztecProtocol/aztec-packages#11641, which was reverted in AztecProtocol/aztec-packages#11797. --------- Co-authored-by: Jan Beneš <janbenes1234@gmail.com>
1 parent 870c507 commit dfb007f

File tree

16 files changed

+790
-273
lines changed

16 files changed

+790
-273
lines changed

aztec/src/discovery/mod.nr

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
use dep::protocol_types::{
2+
address::AztecAddress, constants::PRIVATE_LOG_SIZE_IN_FIELDS, debug_log::debug_log,
3+
};
4+
5+
pub mod private_logs;
6+
pub mod partial_notes;
7+
pub mod nonce_discovery;
8+
9+
/// We reserve two fields in the note private log that are not part of the note content: one for the storage slot, and
10+
/// one for the combined log and note type ID.
11+
global NOTE_PRIVATE_LOG_RESERVED_FIELDS: u32 = 2;
12+
13+
/// The maximum length of the packed representation of a note's contents. This is limited by private log size and extra
14+
/// fields in the log (e.g. the combined log and note type ID).
15+
// TODO (#11634): we're assuming here that the entire log is plaintext, which is not true due to headers, encryption
16+
// padding, etc. Notes can't actually be this large.
17+
pub global MAX_NOTE_PACKED_LEN: u32 = PRIVATE_LOG_SIZE_IN_FIELDS - NOTE_PRIVATE_LOG_RESERVED_FIELDS;
18+
19+
pub struct NoteHashAndNullifier {
20+
/// The result of NoteInterface::compute_note_hash
21+
pub note_hash: Field,
22+
/// The result of NullifiableNote::compute_nullifier_without_context
23+
pub inner_nullifier: Field,
24+
}
25+
26+
/// A function which takes a note's packed content, address of the emitting contract, nonce, storage slot and note type
27+
/// ID and attempts to compute its note hash (not siloed by nonce nor address) and inner nullifier (not siloed by
28+
/// address).
29+
///
30+
/// This function must be user-provided as its implementation requires knowledge of how note type IDs are allocated in a
31+
/// contract. A typical implementation would look like this:
32+
///
33+
/// ```
34+
/// |packed_note_content, contract_address, nonce, storage_slot, note_type_id| {
35+
/// if note_type_id == MyNoteType::get_note_type_id() {
36+
/// assert(packed_note_content.len() == MY_NOTE_TYPE_SERIALIZATION_LENGTH);
37+
/// let hashes = dep::aztec::note::utils::compute_note_hash_and_optionally_a_nullifier(
38+
/// MyNoteType::unpack_content,
39+
/// note_header,
40+
/// true,
41+
/// packed_note_content.storage(),
42+
/// )
43+
///
44+
/// Option::some(dep::aztec::oracle::management::NoteHashesAndNullifier {
45+
/// note_hash: hashes[0],
46+
/// inner_nullifier: hashes[3],
47+
/// })
48+
/// } else if note_type_id == MyOtherNoteType::get_note_type_id() {
49+
/// ... // Similar to above but calling MyOtherNoteType::unpack_content
50+
/// } else {
51+
/// Option::none() // Unknown note type ID
52+
/// };
53+
/// }
54+
/// ```
55+
type ComputeNoteHashAndNullifier<Env> = fn[Env](/* packed_note_content */BoundedVec<Field, MAX_NOTE_PACKED_LEN>, /* contract_address */ AztecAddress, /* nonce */ Field, /* storage_slot */ Field, /* note_type_id */ Field) -> Option<NoteHashAndNullifier>;
56+
57+
/// Performs the note discovery process, in which private and public logs are downloaded and inspected to find private
58+
/// notes, partial notes, and their completion. This is the mechanism via which PXE learns of new notes.
59+
///
60+
/// Receives the address of the contract on which discovery is performed (i.e. the contract that emitted the notes)
61+
/// along with its `compute_note_hash_and_nullifier` function.
62+
pub unconstrained fn discover_new_notes<Env>(
63+
contract_address: AztecAddress,
64+
compute_note_hash_and_nullifier: ComputeNoteHashAndNullifier<Env>,
65+
) {
66+
debug_log("Performing note discovery");
67+
68+
private_logs::fetch_and_process_private_tagged_logs(
69+
contract_address,
70+
compute_note_hash_and_nullifier,
71+
);
72+
73+
partial_notes::fetch_and_process_public_partial_note_completion_logs(
74+
contract_address,
75+
compute_note_hash_and_nullifier,
76+
);
77+
}
+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
use crate::{discovery::{MAX_NOTE_PACKED_LEN, NoteHashAndNullifier}, utils::array};
2+
3+
use dep::protocol_types::{
4+
address::AztecAddress,
5+
constants::MAX_NOTE_HASHES_PER_TX,
6+
debug_log::debug_log_format,
7+
hash::{compute_note_hash_nonce, compute_siloed_note_hash, compute_unique_note_hash},
8+
traits::ToField,
9+
};
10+
11+
/// A struct with the discovered information of a complete note, required for delivery to PXE. Note that this is *not*
12+
/// the complete note information, since it does not include content, storage slot, etc.
13+
pub struct DiscoveredNoteInfo {
14+
pub nonce: Field,
15+
pub note_hash: Field,
16+
pub inner_nullifier: Field,
17+
}
18+
19+
/// Searches for note nonces that will result in a note that was emitted in a transaction. While rare, it is possible
20+
/// for multiple notes to have the exact same packed content and storage slot but different nonces, resulting in
21+
/// different unique note hashes. Because of this this function returns a *vector* of discovered notes, though in most
22+
/// cases it will contain a single element.
23+
///
24+
/// Due to how nonces are computed, this function requires knowledge of the transaction in which the note was created,
25+
/// more specifically the list of all unique note hashes in it plus the value of its first nullifier.
26+
pub unconstrained fn attempt_note_nonce_discovery<Env>(
27+
unique_note_hashes_in_tx: BoundedVec<Field, MAX_NOTE_HASHES_PER_TX>,
28+
first_nullifier_in_tx: Field,
29+
compute_note_hash_and_nullifier: fn[Env](BoundedVec<Field, MAX_NOTE_PACKED_LEN>, AztecAddress, Field, Field, Field) -> Option<NoteHashAndNullifier>,
30+
contract_address: AztecAddress,
31+
storage_slot: Field,
32+
note_type_id: Field,
33+
packed_note_content: BoundedVec<Field, MAX_NOTE_PACKED_LEN>,
34+
) -> BoundedVec<DiscoveredNoteInfo, MAX_NOTE_HASHES_PER_TX> {
35+
let discovered_notes = &mut BoundedVec::new();
36+
37+
debug_log_format(
38+
"Attempting note discovery on {0} potential notes on contract {1} for storage slot {2}",
39+
[unique_note_hashes_in_tx.len() as Field, contract_address.to_field(), storage_slot],
40+
);
41+
42+
// We need to find nonces (typically just one) that result in a note hash that, once siloed into a unique note hash,
43+
// is one of the note hashes created by the transaction.
44+
array::for_each_in_bounded_vec(
45+
unique_note_hashes_in_tx,
46+
|expected_unique_note_hash, i| {
47+
// Nonces are computed by hashing the first nullifier in the transaction with the index of the note in the
48+
// new note hashes array. We therefore know for each note in every transaction what its nonce is.
49+
let candidate_nonce = compute_note_hash_nonce(first_nullifier_in_tx, i);
50+
51+
// Given nonce, note content and metadata, we can compute the note hash and silo it to check if it matches
52+
// the note hash at the array index we're currently processing.
53+
// TODO(#11157): handle failed note_hash_and_nullifier computation
54+
let hashes = compute_note_hash_and_nullifier(
55+
packed_note_content,
56+
contract_address,
57+
candidate_nonce,
58+
storage_slot,
59+
note_type_id,
60+
)
61+
.expect(f"Failed to compute a note hash for note type {note_type_id}");
62+
63+
let siloed_note_hash = compute_siloed_note_hash(contract_address, hashes.note_hash);
64+
let unique_note_hash = compute_unique_note_hash(candidate_nonce, siloed_note_hash);
65+
66+
if unique_note_hash == expected_unique_note_hash {
67+
// Note that while we did check that the note hash is the preimage of the expected unique note hash, we
68+
// perform no validations on the nullifier - we fundamentally cannot, since only the application knows
69+
// how to compute nullifiers. We simply trust it to have provided the correct one: if it hasn't, then
70+
// PXE may fail to realize that a given note has been nullified already, and calls to the application
71+
// could result in invalid transactions (with duplicate nullifiers). This is not a concern because an
72+
// application already has more direct means of making a call to it fail the transaction.
73+
discovered_notes.push(
74+
DiscoveredNoteInfo {
75+
nonce: candidate_nonce,
76+
note_hash: hashes.note_hash,
77+
inner_nullifier: hashes.inner_nullifier,
78+
},
79+
);
80+
81+
// We don't exit the loop - it is possible (though rare) for the exact same note content to be present
82+
// multiple times in the same transaction with different nonces. This typically doesn't happen due to
83+
// notes containing random values in order to hide their contents.
84+
}
85+
},
86+
);
87+
88+
debug_log_format(
89+
"Discovered a total of {0} notes",
90+
[discovered_notes.len() as Field],
91+
);
92+
93+
*discovered_notes
94+
}

aztec/src/discovery/partial_notes.nr

+153
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
use crate::{
2+
capsules::CapsuleArray,
3+
discovery::{
4+
ComputeNoteHashAndNullifier,
5+
nonce_discovery::{attempt_note_nonce_discovery, DiscoveredNoteInfo},
6+
private_logs::MAX_PARTIAL_NOTE_PRIVATE_PACKED_LEN,
7+
},
8+
oracle::note_discovery::{deliver_note, get_log_by_tag},
9+
utils::array,
10+
};
11+
12+
use dep::protocol_types::{
13+
address::AztecAddress,
14+
constants::PUBLIC_LOG_DATA_SIZE_IN_FIELDS,
15+
debug_log::debug_log_format,
16+
traits::{Deserialize, Serialize, ToField},
17+
};
18+
19+
/// The slot in the PXE capsules where we store a `CapsuleArray` of `DeliveredPendingPartialNote`.
20+
// TODO(#11630): come up with some sort of slot allocation scheme.
21+
pub global DELIVERED_PENDING_PARTIAL_NOTE_ARRAY_LENGTH_CAPSULES_SLOT: Field = 77;
22+
23+
/// Public logs contain an extra field at the beginning with the address of the contract that emitted them, and partial
24+
/// notes emit their completion tag in the log, resulting in the first two fields in the public log not being part of
25+
/// the packed public content.
26+
// TODO(#10273): improve how contract log siloing is handled
27+
pub global NON_PACKED_CONTENT_FIELDS_IN_PUBLIC_LOG: u32 = 2;
28+
29+
/// The maximum length of the packed representation of public fields in a partial note. This is limited by public log
30+
/// size and extra fields in the log (e.g. the tag).
31+
pub global MAX_PUBLIC_PARTIAL_NOTE_PACKED_CONTENT_LENGTH: u32 =
32+
PUBLIC_LOG_DATA_SIZE_IN_FIELDS - NON_PACKED_CONTENT_FIELDS_IN_PUBLIC_LOG;
33+
34+
/// A partial note that was delivered but is still pending completion. Contains the information necessary to find the
35+
/// log that will complete it and lead to a note being discovered and delivered.
36+
#[derive(Serialize, Deserialize)]
37+
pub(crate) struct DeliveredPendingPartialNote {
38+
pub(crate) note_completion_log_tag: Field,
39+
pub(crate) storage_slot: Field,
40+
pub(crate) note_type_id: Field,
41+
pub(crate) packed_private_note_content: BoundedVec<Field, MAX_PARTIAL_NOTE_PRIVATE_PACKED_LEN>,
42+
pub(crate) recipient: AztecAddress,
43+
}
44+
45+
/// Searches for public logs that would result in the completion of pending partial notes, ultimately resulting in the
46+
/// notes being delivered to PXE if completed.
47+
pub unconstrained fn fetch_and_process_public_partial_note_completion_logs<Env>(
48+
contract_address: AztecAddress,
49+
compute_note_hash_and_nullifier: ComputeNoteHashAndNullifier<Env>,
50+
) {
51+
let pending_partial_notes = CapsuleArray::at(
52+
contract_address,
53+
DELIVERED_PENDING_PARTIAL_NOTE_ARRAY_LENGTH_CAPSULES_SLOT,
54+
);
55+
56+
debug_log_format(
57+
"{} pending partial notes",
58+
[pending_partial_notes.len() as Field],
59+
);
60+
61+
let mut i = &mut 0;
62+
whyle(
63+
|| *i < pending_partial_notes.len(),
64+
|| {
65+
let pending_partial_note: DeliveredPendingPartialNote = pending_partial_notes.get(*i);
66+
67+
let maybe_log = get_log_by_tag(pending_partial_note.note_completion_log_tag);
68+
if maybe_log.is_none() {
69+
debug_log_format(
70+
"Found no completion logs for partial note #{}",
71+
[(*i) as Field],
72+
);
73+
*i += 1 as u32;
74+
// Note that we're not removing the pending partial note from the PXE DB, so we will continue searching
75+
// for this tagged log when performing note discovery in the future until we either find it or the entry
76+
// is somehow removed from the PXE DB.
77+
} else {
78+
debug_log_format("Completion log found for partial note #{}", [(*i) as Field]);
79+
let log = maybe_log.unwrap();
80+
81+
// Public logs have an extra field at the beginning with the contract address, which we use to verify
82+
// that we're getting the logs from the expected contract.
83+
// TODO(#10273): improve how contract log siloing is handled
84+
assert_eq(
85+
log.log_content.get(0),
86+
contract_address.to_field(),
87+
"Got a public log emitted by a different contract",
88+
);
89+
90+
// Public fields are assumed to all be placed at the end of the packed representation, so we combine the
91+
// private and public packed fields (i.e. the contents of the log sans the extra fields) to get the
92+
// complete packed content.
93+
let packed_public_note_content: BoundedVec<_, MAX_PUBLIC_PARTIAL_NOTE_PACKED_CONTENT_LENGTH> =
94+
array::subbvec(log.log_content, NON_PACKED_CONTENT_FIELDS_IN_PUBLIC_LOG);
95+
let complete_packed_note_content = array::append(
96+
pending_partial_note.packed_private_note_content,
97+
packed_public_note_content,
98+
);
99+
100+
let discovered_notes = attempt_note_nonce_discovery(
101+
log.unique_note_hashes_in_tx,
102+
log.first_nullifier_in_tx,
103+
compute_note_hash_and_nullifier,
104+
contract_address,
105+
pending_partial_note.storage_slot,
106+
pending_partial_note.note_type_id,
107+
complete_packed_note_content,
108+
);
109+
110+
debug_log_format(
111+
"Discovered {0} notes for partial note {1}",
112+
[discovered_notes.len() as Field, (*i) as Field],
113+
);
114+
115+
array::for_each_in_bounded_vec(
116+
discovered_notes,
117+
|discovered_note: DiscoveredNoteInfo, _| {
118+
// TODO:(#10728): decide how to handle notes that fail delivery. This could be due to e.g. a
119+
// temporary node connectivity issue - is simply throwing good enough here?
120+
assert(
121+
deliver_note(
122+
contract_address,
123+
pending_partial_note.storage_slot,
124+
discovered_note.nonce,
125+
complete_packed_note_content,
126+
discovered_note.note_hash,
127+
discovered_note.inner_nullifier,
128+
log.tx_hash,
129+
pending_partial_note.recipient,
130+
),
131+
"Failed to deliver note",
132+
);
133+
},
134+
);
135+
136+
// Because there is only a single log for a given tag, once we've processed the tagged log then we
137+
// simply delete the pending work entry, regardless of whether it was actually completed or not.
138+
// TODO(#11627): only remove the pending entry if we actually process a log that results in the note
139+
// being completed.
140+
pending_partial_notes.remove(*i);
141+
}
142+
},
143+
);
144+
}
145+
146+
/// Custom version of a while loop, calls `body` repeatedly until `condition` returns false. To be removed once Noir
147+
/// supports looping in unconstrained code.
148+
fn whyle<Env, Env2>(condition: fn[Env]() -> bool, body: fn[Env2]() -> ()) {
149+
if condition() {
150+
body();
151+
whyle(condition, body);
152+
}
153+
}

0 commit comments

Comments
 (0)