Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
50d1bf9
feat: richer EphemeralArray and TransientArray APIs
mverzilli Jun 10, 2026
fe86aae
refactor: unify EphemeralArray and TransientArray over OracleArray
mverzilli Jun 10, 2026
f751057
refactor: share OracleArray test bodies across backends
mverzilli Jun 10, 2026
0b346bf
feat: empty_at for transient arrays
mverzilli Jun 10, 2026
0f9f79f
feat: store/load/delete for ephemeral arrays, symmetric array APIs
mverzilli Jun 10, 2026
c4857f9
refactor: generate per-backend OracleArray tests with a comptime macro
mverzilli Jun 10, 2026
7413220
refactor: single parameterized oracle_array_tests macro
mverzilli Jun 10, 2026
d49fc3f
refactor: should_fail_ prefix for failing oracle array checks
mverzilli Jun 10, 2026
eb07d6c
Merge branch 'merge-train/fairies-v5' into martin/richer-ephemeral-an…
mverzilli Jun 10, 2026
7845f7b
refactor(aztec-nr): rename oracle::ephemeral module to ephemeral_oracles
mverzilli Jun 11, 2026
b5948b0
refactor(aztec-nr): rename oracle::transient module to transient_oracles
mverzilli Jun 11, 2026
62ad854
refactor(aztec-nr): rename OracleArray struct to UnconstrainedArray
mverzilli Jun 11, 2026
e8ad51f
refactor(aztec-nr): rename oracle_array module to unconstrained_array
mverzilli Jun 11, 2026
91772da
docs(aztec-nr): reflow doc comments orphaned by the UnconstrainedArra…
mverzilli Jun 11, 2026
15a7e00
refactor(aztec-nr): pluralize ArrayOracles, EphemeralOracles, Transie…
mverzilli Jun 11, 2026
aa90beb
docs(aztec-nr): restore ArrayOracles trait doc paragraph
mverzilli Jun 11, 2026
e174bce
properly document panics
mverzilli Jun 11, 2026
950a4a2
empty does not need to clear
mverzilli Jun 11, 2026
c85569f
make find short-circuit
mverzilli Jun 11, 2026
0ca93c6
remove read_as for now
mverzilli Jun 11, 2026
5fac01b
move Serialize/Deserialize traits from UnconstrainedArray
mverzilli Jun 11, 2026
f7cbb01
refactor kv functions
mverzilli Jun 11, 2026
1523392
commit missing file
mverzilli Jun 11, 2026
788bd2f
refactor test helpers
mverzilli Jun 11, 2026
51d06cb
another one
mverzilli Jun 11, 2026
261face
tighten access
mverzilli Jun 11, 2026
8e9e6ee
remove excess comments
mverzilli Jun 11, 2026
6f881e0
Merge branch 'merge-train/fairies-v5' into martin/richer-ephemeral-an…
mverzilli Jun 11, 2026
3275759
fix(aztec-nr): iterate for_each forward in UnconstrainedArray and Cap…
mverzilli Jun 11, 2026
981a212
refactor(aztec-nr): use for_each to build log retrieval requests
mverzilli Jun 11, 2026
025cdb8
remove unneeded comment
mverzilli Jun 11, 2026
02a1080
Update migration_notes.md
mverzilli Jun 11, 2026
b2794c7
fix(aztec-nr): defer pending partial note removal until iteration is …
mverzilli Jun 11, 2026
61030ca
refactor(aztec-nr): process partial note completion logs in a backwar…
mverzilli Jun 11, 2026
84ea55b
Merge branch 'merge-train/fairies-v5' into martin/f-729-fix-array-for…
mverzilli Jun 12, 2026
e85a246
Merge branch 'merge-train/fairies-v5' into martin/f-729-fix-array-for…
mverzilli Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/docs-developers/docs/resources/migration_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,43 @@ Aztec is in active development. Each version may introduce breaking changes that

## TBD

### [Aztec.nr] `for_each` visits elements in order; removing during iteration no longer supported

`CapsuleArray::for_each` and `EphemeralArray::for_each` previously iterated backwards (from the last element to the first) so that the callback could safely remove the current element. They now visit elements in order, from first to last, as is usually expected in other languages. Structurally mutating the array (e.g. via `push` or `remove`) from inside the callback is no longer supported.

For `EphemeralArray`, replace remove-during-iteration with `filter`:

```diff
- array.for_each(|index, value| {
- if should_remove(value) {
- array.remove(index);
- }
- });
+ let kept = array.filter(|value| !should_remove(value));
```

`filter` collects the kept elements into a fresh array at a new slot. If the original slot matters (e.g. a `TransientArray` slot shared with other call frames), rebuild it from the filtered result:

```noir
let kept = array.filter(|value| !should_remove(value));
let _ = array.clear();
kept.for_each(|_index, value| array.push(value));
```

`EphemeralArray`'s are cheap and by nature not persistent though, so in most cases you probably can just work with the new copy instead of going through this hassle.

`CapsuleArray` has no `filter`, so iterate manually, backwards. Removing the current element is safe in a backward loop because it only shifts elements at higher indices:

```noir
let mut i = array.len();
while i > 0 {
i -= 1;
if should_remove(array.get(i)) {
array.remove(i);
}
}
```

### [Aztec.js] Prefunded local network test accounts are now initializerless

The genesis-funded test accounts in the local network (sandbox), returned by `getInitialTestAccountsData()`, are now initializerless Schnorr accounts (`schnorr_initializerless`). An initializerless account has no onchain deployment transaction: its address commits to the signing public key (through `immutables_hash`) and its contract state is materialized locally in the PXE, so these accounts are usable right away.
Expand Down
109 changes: 10 additions & 99 deletions noir-projects/aztec-nr/aztec/src/capsules/mod.nr
Original file line number Diff line number Diff line change
Expand Up @@ -103,40 +103,17 @@ impl<T> CapsuleArray<T> {

/// Calls a function on each element of the array.
///
/// The function `f` is called once with each array value and its corresponding index. The order in which values
/// are processed is arbitrary.
/// The function `f` is called once with each array value and its corresponding index, in order (from the first
/// element to the last).
///
/// ## Array Mutation
///
/// It is safe to delete the current element (and only the current element) from inside the callback via `remove`:
/// ```noir
/// array.for_each(|index, value| {
/// if some_condition(value) {
/// array.remove(index); // safe only for this index
/// }
/// }
/// ```
///
/// If all elements in the array need to iterated over and then removed, then using `for_each` results in optimal
/// efficiency.
///
/// It is **not** safe to push new elements into the array from inside the callback.
/// Structurally mutating the array from inside the callback (e.g. via `push` or `remove`) is **not** supported:
/// it can cause elements to be skipped, visited more than once, or read out of bounds.
pub unconstrained fn for_each<Env>(self, f: unconstrained fn[Env](u32, T) -> ())
where
T: Deserialize,
{
// Iterating over all elements is simple, but we want to do it in such a way that a) deleting the current
// element is safe to do, and b) deleting *all* elements is optimally efficient. This is because CapsuleArrays
// are typically used to hold pending tasks, so iterating them while clearing completed tasks (sometimes
// unconditionally, resulting in a full clear) is a very common access pattern.
//
// The way we achieve this is by iterating backwards: each element can always be deleted since it won't change
// any preceding (lower) indices, and if every element is deleted then every element will (in turn) be the last
// element. This results in an optimal full clear since `remove` will be able to skip the `capsules::copy` call
// to shift any elements past the deleted one (because there will be none).
let mut i = self.len();
while i > 0 {
i -= 1;
let n = self.len();
for i in 0..n {
f(i, self.get(i));
}
}
Expand Down Expand Up @@ -271,7 +248,7 @@ mod test {
}

#[test]
unconstrained fn for_each_called_with_all_elements() {
unconstrained fn for_each_called_with_all_elements_in_order() {
let mut env = TestEnvironment::new();
let scope = env.create_light_account();
env.private_context(|context| {
Expand All @@ -282,79 +259,13 @@ mod test {
array.push(5);
array.push(6);

// We store all values that we were called with and check that all (value, index) tuples are present. Note
// that we do not care about the order in which each tuple was passed to the closure.
let called_with = &mut BoundedVec::<(u32, Field), 3>::new();
array.for_each(|index, value| { called_with.push((index, value)); });

assert_eq(called_with.len(), 3);
assert(called_with.any(|(index, value)| (index == 0) & (value == 4)));
assert(called_with.any(|(index, value)| (index == 1) & (value == 5)));
assert(called_with.any(|(index, value)| (index == 2) & (value == 6)));
});
}

#[test]
unconstrained fn for_each_remove_some() {
let mut env = TestEnvironment::new();
let scope = env.create_light_account();
env.private_context(|context| {
let contract_address = context.this_address();
let array = CapsuleArray::at(contract_address, SLOT, scope);

array.push(4);
array.push(5);
array.push(6);

array.for_each(|index, _| {
if index == 1 {
array.remove(index);
}
});

assert_eq(array.len(), 2);
assert_eq(array.get(0), 4);
assert_eq(array.get(1), 6);
});
}

#[test]
unconstrained fn for_each_remove_all() {
let mut env = TestEnvironment::new();
let scope = env.create_light_account();
env.private_context(|context| {
let contract_address = context.this_address();
let array = CapsuleArray::at(contract_address, SLOT, scope);

array.push(4);
array.push(5);
array.push(6);

array.for_each(|index, _| { array.remove(index); });

assert_eq(array.len(), 0);
});
}

#[test]
unconstrained fn for_each_remove_all_no_copy() {
let mut env = TestEnvironment::new();
let scope = env.create_light_account();
env.private_context(|context| {
let contract_address = context.this_address();
let array = CapsuleArray::at(contract_address, SLOT, scope);

array.push(4);
array.push(5);
array.push(6);

// We test that the aztec_utl_copyCapsule was never called, which is the expensive operation we want to
// avoid.
let mock = std::test::OracleMock::mock("aztec_utl_copyCapsule");

array.for_each(|index, _| { array.remove(index); });

assert_eq(mock.times_called(), 0);
assert_eq(called_with.get(0), (0, 4));
assert_eq(called_with.get(1), (1, 5));
assert_eq(called_with.get(2), (2, 6));
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,13 @@ pub(crate) unconstrained fn fetch_and_process_partial_note_completion_logs(
// index. Each inner array contains all matching LogRetrievalResponses.
assert_eq(completion_logs.len(), pending_partial_notes.len());

completion_logs.for_each(|i, logs_for_tag: EphemeralArray<LogRetrievalResponse>| {
// Note: the loop runs backwards because it removes completed entries from `pending_partial_notes`, which is
// index-aligned with `completion_logs`. Going from last to first keeps the indices of not-yet-visited entries
// stable.
let mut i = completion_logs.len();
while i > 0 {
i -= 1;
let logs_for_tag: EphemeralArray<LogRetrievalResponse> = completion_logs.get(i);
let pending_partial_note = pending_partial_notes.get(i);
let num_logs = logs_for_tag.len();

Expand Down Expand Up @@ -172,5 +178,5 @@ pub(crate) unconstrained fn fetch_and_process_partial_note_completion_logs(
// delete the pending work entry, regardless of whether it was actually completed or not.
pending_partial_notes.remove(i);
}
});
}
}
12 changes: 2 additions & 10 deletions noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr
Original file line number Diff line number Diff line change
Expand Up @@ -155,23 +155,15 @@ pub(crate) unconstrained fn get_pending_partial_notes_completion_logs(
) -> EphemeralArray<EphemeralArray<LogRetrievalResponse>> {
let log_retrieval_requests = EphemeralArray::at(LOG_RETRIEVAL_REQUESTS_ARRAY_BASE_SLOT);

// We create a LogRetrievalRequest for each PendingPartialNote in the EphemeralArray. Because we need the indices
// in the request array to match the indices in the partial note array, we can't use EphemeralArray::for_each, as
// that function has arbitrary iteration order. Instead, we manually iterate the array from the beginning and push
// into the requests array, which we expect to be empty.
let mut i = 0;
let pending_partial_notes_count = pending_partial_notes.len();
while i < pending_partial_notes_count {
let pending_partial_note = pending_partial_notes.get(i);
pending_partial_notes.for_each(|_i, pending_partial_note| {
// Partial note completion logs are emitted with a domain-separated tag. To find matching logs, we apply the
// same domain separation to the stored raw tag.
let log_tag = compute_log_tag(
pending_partial_note.note_completion_log_tag,
DOM_SEP__NOTE_COMPLETION_LOG_TAG,
);
log_retrieval_requests.push(LogRetrievalRequest::new(contract_address, log_tag));
i += 1;
}
});

let responses = message_processing::get_logs_by_tag(log_retrieval_requests);

Expand Down
13 changes: 6 additions & 7 deletions noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr
Original file line number Diff line number Diff line change
Expand Up @@ -130,18 +130,17 @@ where

/// Calls a function on each element of the array.
///
/// The function `f` is called once with each array value and its corresponding index. Iteration proceeds
/// backwards so that it is safe to remove the current element (and only the current element) inside the
/// callback.
/// The function `f` is called once with each array value and its corresponding index, in order (from the first
/// element to the last).
///
/// It is **not** safe to push new elements from inside the callback.
/// Structurally mutating the array from inside the callback (e.g. via `push`, `pop`, `remove` or `clear`) is
/// **not** supported: it can cause elements to be skipped, visited more than once, or read out of bounds.
pub unconstrained fn for_each<Env>(self, f: unconstrained fn[Env](u32, T) -> ())
where
T: Deserialize,
{
let mut i = self.len();
while i > 0 {
i -= 1;
let n = self.len();
for i in 0..n {
f(i, self.get(i));
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ where
});
}

pub(crate) unconstrained fn for_each_called_with_all_elements<Oracle>()
pub(crate) unconstrained fn for_each_called_with_all_elements_in_order<Oracle>()
where
Oracle: ArrayOracles,
{
Expand All @@ -179,51 +179,9 @@ where
array.for_each(|index, value| { called_with.push((index, value)); });

assert_eq(called_with.len(), 3);
assert(called_with.any(|(index, value)| (index == 0) & (value == 4)));
assert(called_with.any(|(index, value)| (index == 1) & (value == 5)));
assert(called_with.any(|(index, value)| (index == 2) & (value == 6)));
});
}

pub(crate) unconstrained fn for_each_remove_some<Oracle>()
where
Oracle: ArrayOracles,
{
let env = TestEnvironment::new();
env.utility_context(|_| {
let array: UnconstrainedArray<Field, Oracle> = UnconstrainedArray::at(SLOT);

array.push(4);
array.push(5);
array.push(6);

array.for_each(|index, _| {
if index == 1 {
array.remove(index);
}
});

assert_eq(array.len(), 2);
assert_eq(array.get(0), 4);
assert_eq(array.get(1), 6);
});
}

pub(crate) unconstrained fn for_each_remove_all<Oracle>()
where
Oracle: ArrayOracles,
{
let env = TestEnvironment::new();
env.utility_context(|_| {
let array: UnconstrainedArray<Field, Oracle> = UnconstrainedArray::at(SLOT);

array.push(4);
array.push(5);
array.push(6);

array.for_each(|index, _| { array.remove(index); });

assert_eq(array.len(), 0);
assert_eq(called_with.get(0), (0, 4));
assert_eq(called_with.get(1), (1, 5));
assert_eq(called_with.get(2), (2, 6));
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4035,6 +4035,18 @@ mod test {
utils::check_private_balance(env, token_contract_address, owner, 0_u128);
}

/// Completes multiple partial notes in a single message-discovery sync: the recipient does not sync until both
/// completion logs have been emitted, so both pending partial notes are processed together.
#[test]
unconstrained fn transfer_to_private_external_orchestration_multiple_notes() {
let (env, token_contract_address, owner, recipient, amount): (aztec::test::helpers::test_environment::TestEnvironment, aztec::protocol::address::AztecAddress, aztec::protocol::address::AztecAddress, aztec::protocol::address::AztecAddress, u128) = utils::setup_and_mint_to_public(false);
let partial_uint_note_a: PartialUintNote = env.call_private(owner, Token::at(token_contract_address).prepare_private_balance_increase(recipient));
let partial_uint_note_b: PartialUintNote = env.call_private(owner, Token::at(token_contract_address).prepare_private_balance_increase(recipient));
env.call_public(owner, Token::at(token_contract_address).finalize_transfer_to_private(amount - 3_u128, partial_uint_note_a));
env.call_public(owner, Token::at(token_contract_address).finalize_transfer_to_private(3_u128, partial_uint_note_b));
utils::check_private_balance(env, token_contract_address, recipient, amount);
}

#[test(should_fail_with = "Invalid partial note or completer")]
unconstrained fn transfer_to_private_transfer_not_prepared() {
let (env, token_contract_address, owner, _, amount): (aztec::test::helpers::test_environment::TestEnvironment, aztec::protocol::address::AztecAddress, aztec::protocol::address::AztecAddress, aztec::protocol::address::AztecAddress, u128) = utils::setup_and_mint_to_public(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,29 @@ unconstrained fn transfer_to_private_from_private_external_orchestration() {
utils::check_private_balance(env, token_contract_address, owner, 0);
}

/// Completes multiple partial notes in a single message-discovery sync: the recipient does not sync until both
/// completion logs have been emitted, so both pending partial notes are processed together.
#[test]
unconstrained fn transfer_to_private_external_orchestration_multiple_notes() {
// Setup without account contracts. We are not using authwits here, so dummy accounts are enough
let (env, token_contract_address, owner, recipient, amount) =
utils::setup_and_mint_to_public(/* with_account_contracts */ false);

let partial_uint_note_a =
env.call_private(owner, Token::at(token_contract_address).prepare_private_balance_increase(recipient));
let partial_uint_note_b =
env.call_private(owner, Token::at(token_contract_address).prepare_private_balance_increase(recipient));

env.call_public(
owner,
Token::at(token_contract_address).finalize_transfer_to_private(amount - 3, partial_uint_note_a),
);
env.call_public(owner, Token::at(token_contract_address).finalize_transfer_to_private(3, partial_uint_note_b));

// Recipient's private balance should be equal to the sum of both finalized amounts
utils::check_private_balance(env, token_contract_address, recipient, amount);
}

Comment on lines +62 to +84

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

regression test. changing the behavior of for_each created a subtle bug in partial note processing, since it was relying on aligning two CapsuleArray's and removing from one of them in-loop. the bug was thankfully caught by an e2e test, but this regression test makes it cheaper to reproduce and should help detect future issues earlier

#[test(should_fail_with = "Invalid partial note or completer")]
unconstrained fn transfer_to_private_transfer_not_prepared() {
// Setup without account contracts. We are not using authwits here, so dummy accounts are enough
Expand Down
Loading