From 50d1bf9dfaa09f783cfa12cc8c42c49e469adaeb Mon Sep 17 00:00:00 2001 From: mverzilli Date: Wed, 10 Jun 2026 08:31:28 +0000 Subject: [PATCH 01/31] feat: richer EphemeralArray and TransientArray APIs Adds empty(), map, filter, any, all, find and read_as to EphemeralArray (spun off from #23928) and mirrors them on TransientArray. --- .../aztec-nr/aztec/src/ephemeral/mod.nr | 423 ++++++++++++++++++ .../aztec-nr/aztec/src/transient/mod.nr | 423 ++++++++++++++++++ 2 files changed, 846 insertions(+) diff --git a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr index 2b64fe54f4a3..74cc36b813aa 100644 --- a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr @@ -1,4 +1,5 @@ use crate::oracle::ephemeral; +use crate::oracle::random::random; use crate::protocol::traits::{Deserialize, Serialize}; use crate::protocol::utils::{reader::Reader, writer::Writer}; @@ -39,6 +40,15 @@ impl EphemeralArray { Self::at(slot).clear() } + /// Returns an empty ephemeral array at a fresh, randomly allocated slot. + /// + /// Use this when the caller does not need a specific slot: the random slot is isolated from every other ephemeral + /// array with overwhelming probability. Prefer [`EphemeralArray::empty_at`] when the slot must be a known value + /// (e.g. one shared with an oracle). + pub unconstrained fn empty() -> Self { + Self::empty_at(random()) + } + /// Returns the number of elements stored in the array. pub unconstrained fn len(self) -> u32 { ephemeral::len_oracle(self.slot) @@ -110,6 +120,104 @@ impl EphemeralArray { f(i, self.get(i)); } } + + /// Applies `f` to every element and collects the results into a fresh ephemeral array. + /// + /// Reads each element in order, transforms it with `f`, and pushes the result onto a new array at a freshly + /// allocated slot (see [`EphemeralArray::empty`]). The source array is left unchanged, and the result is isolated + /// from it, so `map` can be chained. + pub unconstrained fn map(self, f: unconstrained fn[Env](T) -> U) -> EphemeralArray + where + T: Deserialize, + U: Serialize, + { + let dest: EphemeralArray = EphemeralArray::empty(); + let n = self.len(); + for i in 0..n { + dest.push(f(self.get(i))); + } + dest + } + + /// Collects every element satisfying the predicate `f` into a fresh ephemeral array. + /// + /// Reads each element in order, keeps those for which `f` returns `true`, and pushes them onto a new array at a + /// freshly allocated slot (see [`EphemeralArray::empty`]). Relative order is preserved. The source array is left + /// unchanged, and the result is isolated from it, so `filter` can be chained. + pub unconstrained fn filter(self, f: unconstrained fn[Env](T) -> bool) -> Self + where + T: Serialize + Deserialize, + { + let dest: Self = EphemeralArray::empty(); + let n = self.len(); + for i in 0..n { + let value = self.get(i); + if f(value) { + dest.push(value); + } + } + dest + } + + /// Returns `true` if at least one element satisfies the predicate `f`. + /// + /// Matches the `any` combinator on Noir's [`array`](std::array), slice, and `BoundedVec` collections. Defined in + /// terms of [`EphemeralArray::filter`]: it keeps the matching elements and checks whether any survived. This is + /// not short-circuiting — every element is tested even after the first match. + pub unconstrained fn any(self, f: unconstrained fn[Env](T) -> bool) -> bool + where + T: Serialize + Deserialize, + { + self.filter(f).len() != 0 + } + + /// Returns `true` if every element satisfies the predicate `f` (vacuously `true` for an empty array). + /// + /// Matches the `all` combinator on Noir's [`array`](std::array) and slice collections. Defined in terms of + /// [`EphemeralArray::filter`]: every element matches exactly when filtering keeps all of them. This is not + /// short-circuiting — every element is tested even after the first failure. + pub unconstrained fn all(self, f: unconstrained fn[Env](T) -> bool) -> bool + where + T: Serialize + Deserialize, + { + self.filter(f).len() == self.len() + } + + /// Returns the first element satisfying the predicate `f`, or `Option::none` if none do. + /// + /// Mirrors Rust's `Iterator::find`. Defined in terms of [`EphemeralArray::filter`], which preserves order, so the + /// first kept element is the first match. This is not short-circuiting — every element is tested even after the + /// match is found. + pub unconstrained fn find(self, f: unconstrained fn[Env](T) -> bool) -> Option + where + T: Serialize + Deserialize, + { + let matches = self.filter(f); + if matches.len() != 0 { + Option::some(matches.get(0)) + } else { + Option::none() + } + } +} + +impl EphemeralArray { + /// Deserializes the whole array into a `T`. + /// + /// Asserts the array holds exactly `::N` fields, then reconstructs a `T` from them. This is the + /// read-back counterpart to pushing a value's serialized fields onto the array, and is defined only on + /// `EphemeralArray` because deserialization reconstructs a type from raw fields. + pub unconstrained fn read_as(self) -> T + where + T: Deserialize, + { + assert_eq(self.len(), ::N, "EphemeralArray length mismatch for read_as"); + let mut fields: [Field; ::N] = [0; ::N]; + for i in 0..::N { + fields[i] = self.get(i); + } + Deserialize::deserialize(fields) + } } /// Serializes an `EphemeralArray` as its slot identifier, allowing oracle function signatures to use @@ -141,6 +249,7 @@ impl Deserialize for EphemeralArray { } mod test { + use crate::protocol::traits::Serialize; use crate::test::helpers::test_environment::TestEnvironment; use crate::test::mocks::MockStruct; use super::EphemeralArray; @@ -372,6 +481,35 @@ mod test { }); } + #[test] + unconstrained fn read_as_reconstructs_serialized_value() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: EphemeralArray = EphemeralArray::empty_at(SLOT); + + let value = MockStruct::new(5, 6); + let serialized = value.serialize(); + for i in 0..serialized.len() { + array.push(serialized[i]); + } + + let reconstructed: MockStruct = array.read_as(); + assert_eq(reconstructed, value); + }); + } + + #[test(should_fail_with = "length mismatch")] + unconstrained fn read_as_rejects_length_mismatch() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: EphemeralArray = EphemeralArray::empty_at(SLOT); + // MockStruct deserializes from 2 fields, so a single field is too short. + array.push(1); + + let _: MockStruct = array.read_as(); + }); + } + #[test] unconstrained fn empty_at_wipes_previous_data() { let env = TestEnvironment::new(); @@ -415,4 +553,289 @@ mod test { assert_eq(fresh.get(0), 4); }); } + + #[test] + unconstrained fn empty_allocates_distinct_slots() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let a: EphemeralArray = EphemeralArray::empty(); + let b: EphemeralArray = EphemeralArray::empty(); + + assert(a.slot != b.slot, "empty() should allocate a fresh slot each time"); + + a.push(1); + b.push(2); + assert_eq(a.len(), 1); + assert_eq(a.get(0), 1); + assert_eq(b.len(), 1); + assert_eq(b.get(0), 2); + }); + } + + #[test] + unconstrained fn map_transforms_each_element() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: EphemeralArray = EphemeralArray::empty_at(SLOT); + source.push(1); + source.push(2); + source.push(3); + + let doubled: EphemeralArray = source.map(|x| x * 2); + + assert_eq(doubled.len(), 3); + assert_eq(doubled.get(0), 2); + assert_eq(doubled.get(1), 4); + assert_eq(doubled.get(2), 6); + + // The source array is left untouched. + assert_eq(source.len(), 3); + assert_eq(source.get(0), 1); + }); + } + + #[test] + unconstrained fn map_empty_source_gives_empty_dest() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: EphemeralArray = EphemeralArray::empty_at(SLOT); + let mapped: EphemeralArray = source.map(|x| x * 2); + assert_eq(mapped.len(), 0); + }); + } + + #[test] + unconstrained fn map_to_different_type() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: EphemeralArray = EphemeralArray::empty_at(SLOT); + source.push(5); + source.push(7); + + let structs: EphemeralArray = source.map(|x| MockStruct::new(x, x + 1)); + + assert_eq(structs.len(), 2); + assert_eq(structs.get(0), MockStruct::new(5, 6)); + assert_eq(structs.get(1), MockStruct::new(7, 8)); + }); + } + + #[test] + unconstrained fn map_results_are_isolated() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: EphemeralArray = EphemeralArray::empty_at(SLOT); + source.push(1); + source.push(2); + source.push(3); + + // Each map allocates its own fresh slot, so two maps of the same source do not clobber each other. + let doubled: EphemeralArray = source.map(|x| x * 2); + let tripled: EphemeralArray = source.map(|x| x * 3); + + assert(doubled.slot != tripled.slot, "each map should land in a distinct slot"); + assert_eq(doubled.get(0), 2); + assert_eq(doubled.get(2), 6); + assert_eq(tripled.get(0), 3); + assert_eq(tripled.get(2), 9); + }); + } + + #[test] + unconstrained fn filter_keeps_matching_elements_in_order() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: EphemeralArray = EphemeralArray::empty_at(SLOT); + source.push(1); + source.push(2); + source.push(3); + source.push(2); + source.push(5); + + let kept: EphemeralArray = source.filter(|x| x != 2); + + assert_eq(kept.len(), 3); + assert_eq(kept.get(0), 1); + assert_eq(kept.get(1), 3); + assert_eq(kept.get(2), 5); + + // The source array is left untouched. + assert_eq(source.len(), 5); + assert_eq(source.get(1), 2); + }); + } + + #[test] + unconstrained fn filter_empty_source_gives_empty_dest() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: EphemeralArray = EphemeralArray::empty_at(SLOT); + let kept: EphemeralArray = source.filter(|_| true); + assert_eq(kept.len(), 0); + }); + } + + #[test] + unconstrained fn filter_none_match_gives_empty_dest() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: EphemeralArray = EphemeralArray::empty_at(SLOT); + source.push(1); + source.push(2); + source.push(3); + + let kept: EphemeralArray = source.filter(|_| false); + assert_eq(kept.len(), 0); + }); + } + + #[test] + unconstrained fn filter_works_with_multi_field_type() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: EphemeralArray = EphemeralArray::empty_at(SLOT); + source.push(MockStruct::new(1, 10)); + source.push(MockStruct::new(2, 20)); + source.push(MockStruct::new(3, 30)); + + let kept: EphemeralArray = source.filter(|s: MockStruct| s.a != 2); + + assert_eq(kept.len(), 2); + assert_eq(kept.get(0), MockStruct::new(1, 10)); + assert_eq(kept.get(1), MockStruct::new(3, 30)); + }); + } + + #[test] + unconstrained fn filter_results_are_isolated() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: EphemeralArray = EphemeralArray::empty_at(SLOT); + source.push(1); + source.push(2); + source.push(3); + source.push(4); + + // Each filter allocates its own fresh slot, so two filters of the same source do not clobber each other. + let odds: EphemeralArray = source.filter(|x| (x != 2) & (x != 4)); + let evens: EphemeralArray = source.filter(|x| (x != 1) & (x != 3)); + + assert(odds.slot != evens.slot, "each filter should land in a distinct slot"); + assert_eq(odds.len(), 2); + assert_eq(odds.get(0), 1); + assert_eq(odds.get(1), 3); + assert_eq(evens.len(), 2); + assert_eq(evens.get(0), 2); + assert_eq(evens.get(1), 4); + }); + } + + #[test] + unconstrained fn any_is_true_when_an_element_matches() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: EphemeralArray = EphemeralArray::empty_at(SLOT); + array.push(1); + array.push(2); + array.push(3); + + assert(array.any(|x| x == 2)); + }); + } + + #[test] + unconstrained fn any_is_false_when_no_element_matches() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: EphemeralArray = EphemeralArray::empty_at(SLOT); + array.push(1); + array.push(2); + array.push(3); + + assert(!array.any(|x| x == 9)); + }); + } + + #[test] + unconstrained fn any_on_empty_array_is_false() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: EphemeralArray = EphemeralArray::empty_at(SLOT); + assert(!array.any(|_| true)); + }); + } + + #[test] + unconstrained fn all_is_true_when_every_element_matches() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: EphemeralArray = EphemeralArray::empty_at(SLOT); + array.push(1); + array.push(2); + array.push(3); + + assert(array.all(|x| x != 0)); + }); + } + + #[test] + unconstrained fn all_is_false_when_one_element_fails() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: EphemeralArray = EphemeralArray::empty_at(SLOT); + array.push(1); + array.push(2); + array.push(3); + + assert(!array.all(|x| x != 2)); + }); + } + + #[test] + unconstrained fn all_on_empty_array_is_true() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: EphemeralArray = EphemeralArray::empty_at(SLOT); + assert(array.all(|_| false)); + }); + } + + #[test] + unconstrained fn find_returns_first_matching_element() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: EphemeralArray = EphemeralArray::empty_at(SLOT); + array.push(1); + array.push(2); + array.push(3); + array.push(4); + + // Both 2 and 4 match; find must return the first one in order. + let found = array.find(|x| (x == 2) | (x == 4)); + assert(found.is_some()); + assert_eq(found.unwrap(), 2); + }); + } + + #[test] + unconstrained fn find_returns_none_when_no_element_matches() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: EphemeralArray = EphemeralArray::empty_at(SLOT); + array.push(1); + array.push(2); + array.push(3); + + assert(array.find(|x| x == 9).is_none()); + }); + } + + #[test] + unconstrained fn find_on_empty_array_is_none() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: EphemeralArray = EphemeralArray::empty_at(SLOT); + assert(array.find(|_| true).is_none()); + }); + } } diff --git a/noir-projects/aztec-nr/aztec/src/transient/mod.nr b/noir-projects/aztec-nr/aztec/src/transient/mod.nr index d143b0c294ed..ff70f59e4263 100644 --- a/noir-projects/aztec-nr/aztec/src/transient/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/transient/mod.nr @@ -1,3 +1,4 @@ +use crate::oracle::random::random; use crate::oracle::transient; use crate::protocol::traits::{Deserialize, Serialize}; use crate::protocol::utils::{reader::Reader, writer::Writer}; @@ -40,6 +41,15 @@ impl TransientArray { Self { slot } } + /// Returns an empty transient array at a fresh, randomly allocated slot. + /// + /// Use this when the caller does not need a specific slot: the random slot is isolated from every other transient + /// array with overwhelming probability. Prefer [`TransientArray::at`] when the slot must be a known value (e.g. + /// one agreed upon with another frame of the same contract). + pub unconstrained fn empty() -> Self { + Self::at(random()).clear() + } + /// Returns the number of elements stored in the array. pub unconstrained fn len(self) -> u32 { transient::len_oracle(self.slot) @@ -107,6 +117,104 @@ impl TransientArray { f(i, self.get(i)); } } + + /// Applies `f` to every element and collects the results into a fresh transient array. + /// + /// Reads each element in order, transforms it with `f`, and pushes the result onto a new array at a freshly + /// allocated slot (see [`TransientArray::empty`]). The source array is left unchanged, and the result is isolated + /// from it, so `map` can be chained. + pub unconstrained fn map(self, f: unconstrained fn[Env](T) -> U) -> TransientArray + where + T: Deserialize, + U: Serialize, + { + let dest: TransientArray = TransientArray::empty(); + let n = self.len(); + for i in 0..n { + dest.push(f(self.get(i))); + } + dest + } + + /// Collects every element satisfying the predicate `f` into a fresh transient array. + /// + /// Reads each element in order, keeps those for which `f` returns `true`, and pushes them onto a new array at a + /// freshly allocated slot (see [`TransientArray::empty`]). Relative order is preserved. The source array is left + /// unchanged, and the result is isolated from it, so `filter` can be chained. + pub unconstrained fn filter(self, f: unconstrained fn[Env](T) -> bool) -> Self + where + T: Serialize + Deserialize, + { + let dest: Self = TransientArray::empty(); + let n = self.len(); + for i in 0..n { + let value = self.get(i); + if f(value) { + dest.push(value); + } + } + dest + } + + /// Returns `true` if at least one element satisfies the predicate `f`. + /// + /// Matches the `any` combinator on Noir's [`array`](std::array), slice, and `BoundedVec` collections. Defined in + /// terms of [`TransientArray::filter`]: it keeps the matching elements and checks whether any survived. This is + /// not short-circuiting — every element is tested even after the first match. + pub unconstrained fn any(self, f: unconstrained fn[Env](T) -> bool) -> bool + where + T: Serialize + Deserialize, + { + self.filter(f).len() != 0 + } + + /// Returns `true` if every element satisfies the predicate `f` (vacuously `true` for an empty array). + /// + /// Matches the `all` combinator on Noir's [`array`](std::array) and slice collections. Defined in terms of + /// [`TransientArray::filter`]: every element matches exactly when filtering keeps all of them. This is not + /// short-circuiting — every element is tested even after the first failure. + pub unconstrained fn all(self, f: unconstrained fn[Env](T) -> bool) -> bool + where + T: Serialize + Deserialize, + { + self.filter(f).len() == self.len() + } + + /// Returns the first element satisfying the predicate `f`, or `Option::none` if none do. + /// + /// Mirrors Rust's `Iterator::find`. Defined in terms of [`TransientArray::filter`], which preserves order, so the + /// first kept element is the first match. This is not short-circuiting — every element is tested even after the + /// match is found. + pub unconstrained fn find(self, f: unconstrained fn[Env](T) -> bool) -> Option + where + T: Serialize + Deserialize, + { + let matches = self.filter(f); + if matches.len() != 0 { + Option::some(matches.get(0)) + } else { + Option::none() + } + } +} + +impl TransientArray { + /// Deserializes the whole array into a `T`. + /// + /// Asserts the array holds exactly `::N` fields, then reconstructs a `T` from them. This is the + /// read-back counterpart to pushing a value's serialized fields onto the array, and is defined only on + /// `TransientArray` because deserialization reconstructs a type from raw fields. + pub unconstrained fn read_as(self) -> T + where + T: Deserialize, + { + assert_eq(self.len(), ::N, "TransientArray length mismatch for read_as"); + let mut fields: [Field; ::N] = [0; ::N]; + for i in 0..::N { + fields[i] = self.get(i); + } + Deserialize::deserialize(fields) + } } /// Serializes a `TransientArray` as its slot identifier, allowing oracle function signatures to use `TransientArray` @@ -165,6 +273,7 @@ pub unconstrained fn delete(slot: Field) { } mod test { + use crate::protocol::traits::Serialize; use crate::test::helpers::test_environment::TestEnvironment; use crate::test::mocks::MockStruct; use super::{delete, load, store, TransientArray}; @@ -402,6 +511,35 @@ mod test { }); } + #[test] + unconstrained fn read_as_reconstructs_serialized_value() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: TransientArray = TransientArray::at(SLOT); + + let value = MockStruct::new(5, 6); + let serialized = value.serialize(); + for i in 0..serialized.len() { + array.push(serialized[i]); + } + + let reconstructed: MockStruct = array.read_as(); + assert_eq(reconstructed, value); + }); + } + + #[test(should_fail_with = "length mismatch")] + unconstrained fn read_as_rejects_length_mismatch() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: TransientArray = TransientArray::at(SLOT); + // MockStruct deserializes from 2 fields, so a single field is too short. + array.push(1); + + let _: MockStruct = array.read_as(); + }); + } + #[test] unconstrained fn clear_returns_self() { let env = TestEnvironment::new(); @@ -432,6 +570,291 @@ mod test { }); } + #[test] + unconstrained fn empty_allocates_distinct_slots() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let a: TransientArray = TransientArray::empty(); + let b: TransientArray = TransientArray::empty(); + + assert(a.slot != b.slot, "empty() should allocate a fresh slot each time"); + + a.push(1); + b.push(2); + assert_eq(a.len(), 1); + assert_eq(a.get(0), 1); + assert_eq(b.len(), 1); + assert_eq(b.get(0), 2); + }); + } + + #[test] + unconstrained fn map_transforms_each_element() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: TransientArray = TransientArray::at(SLOT); + source.push(1); + source.push(2); + source.push(3); + + let doubled: TransientArray = source.map(|x| x * 2); + + assert_eq(doubled.len(), 3); + assert_eq(doubled.get(0), 2); + assert_eq(doubled.get(1), 4); + assert_eq(doubled.get(2), 6); + + // The source array is left untouched. + assert_eq(source.len(), 3); + assert_eq(source.get(0), 1); + }); + } + + #[test] + unconstrained fn map_empty_source_gives_empty_dest() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: TransientArray = TransientArray::at(SLOT); + let mapped: TransientArray = source.map(|x| x * 2); + assert_eq(mapped.len(), 0); + }); + } + + #[test] + unconstrained fn map_to_different_type() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: TransientArray = TransientArray::at(SLOT); + source.push(5); + source.push(7); + + let structs: TransientArray = source.map(|x| MockStruct::new(x, x + 1)); + + assert_eq(structs.len(), 2); + assert_eq(structs.get(0), MockStruct::new(5, 6)); + assert_eq(structs.get(1), MockStruct::new(7, 8)); + }); + } + + #[test] + unconstrained fn map_results_are_isolated() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: TransientArray = TransientArray::at(SLOT); + source.push(1); + source.push(2); + source.push(3); + + // Each map allocates its own fresh slot, so two maps of the same source do not clobber each other. + let doubled: TransientArray = source.map(|x| x * 2); + let tripled: TransientArray = source.map(|x| x * 3); + + assert(doubled.slot != tripled.slot, "each map should land in a distinct slot"); + assert_eq(doubled.get(0), 2); + assert_eq(doubled.get(2), 6); + assert_eq(tripled.get(0), 3); + assert_eq(tripled.get(2), 9); + }); + } + + #[test] + unconstrained fn filter_keeps_matching_elements_in_order() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: TransientArray = TransientArray::at(SLOT); + source.push(1); + source.push(2); + source.push(3); + source.push(2); + source.push(5); + + let kept: TransientArray = source.filter(|x| x != 2); + + assert_eq(kept.len(), 3); + assert_eq(kept.get(0), 1); + assert_eq(kept.get(1), 3); + assert_eq(kept.get(2), 5); + + // The source array is left untouched. + assert_eq(source.len(), 5); + assert_eq(source.get(1), 2); + }); + } + + #[test] + unconstrained fn filter_empty_source_gives_empty_dest() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: TransientArray = TransientArray::at(SLOT); + let kept: TransientArray = source.filter(|_| true); + assert_eq(kept.len(), 0); + }); + } + + #[test] + unconstrained fn filter_none_match_gives_empty_dest() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: TransientArray = TransientArray::at(SLOT); + source.push(1); + source.push(2); + source.push(3); + + let kept: TransientArray = source.filter(|_| false); + assert_eq(kept.len(), 0); + }); + } + + #[test] + unconstrained fn filter_works_with_multi_field_type() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: TransientArray = TransientArray::at(SLOT); + source.push(MockStruct::new(1, 10)); + source.push(MockStruct::new(2, 20)); + source.push(MockStruct::new(3, 30)); + + let kept: TransientArray = source.filter(|s: MockStruct| s.a != 2); + + assert_eq(kept.len(), 2); + assert_eq(kept.get(0), MockStruct::new(1, 10)); + assert_eq(kept.get(1), MockStruct::new(3, 30)); + }); + } + + #[test] + unconstrained fn filter_results_are_isolated() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: TransientArray = TransientArray::at(SLOT); + source.push(1); + source.push(2); + source.push(3); + source.push(4); + + // Each filter allocates its own fresh slot, so two filters of the same source do not clobber each other. + let odds: TransientArray = source.filter(|x| (x != 2) & (x != 4)); + let evens: TransientArray = source.filter(|x| (x != 1) & (x != 3)); + + assert(odds.slot != evens.slot, "each filter should land in a distinct slot"); + assert_eq(odds.len(), 2); + assert_eq(odds.get(0), 1); + assert_eq(odds.get(1), 3); + assert_eq(evens.len(), 2); + assert_eq(evens.get(0), 2); + assert_eq(evens.get(1), 4); + }); + } + + #[test] + unconstrained fn any_is_true_when_an_element_matches() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: TransientArray = TransientArray::at(SLOT); + array.push(1); + array.push(2); + array.push(3); + + assert(array.any(|x| x == 2)); + }); + } + + #[test] + unconstrained fn any_is_false_when_no_element_matches() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: TransientArray = TransientArray::at(SLOT); + array.push(1); + array.push(2); + array.push(3); + + assert(!array.any(|x| x == 9)); + }); + } + + #[test] + unconstrained fn any_on_empty_array_is_false() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: TransientArray = TransientArray::at(SLOT); + assert(!array.any(|_| true)); + }); + } + + #[test] + unconstrained fn all_is_true_when_every_element_matches() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: TransientArray = TransientArray::at(SLOT); + array.push(1); + array.push(2); + array.push(3); + + assert(array.all(|x| x != 0)); + }); + } + + #[test] + unconstrained fn all_is_false_when_one_element_fails() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: TransientArray = TransientArray::at(SLOT); + array.push(1); + array.push(2); + array.push(3); + + assert(!array.all(|x| x != 2)); + }); + } + + #[test] + unconstrained fn all_on_empty_array_is_true() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: TransientArray = TransientArray::at(SLOT); + assert(array.all(|_| false)); + }); + } + + #[test] + unconstrained fn find_returns_first_matching_element() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: TransientArray = TransientArray::at(SLOT); + array.push(1); + array.push(2); + array.push(3); + array.push(4); + + // Both 2 and 4 match; find must return the first one in order. + let found = array.find(|x| (x == 2) | (x == 4)); + assert(found.is_some()); + assert_eq(found.unwrap(), 2); + }); + } + + #[test] + unconstrained fn find_returns_none_when_no_element_matches() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: TransientArray = TransientArray::at(SLOT); + array.push(1); + array.push(2); + array.push(3); + + assert(array.find(|x| x == 9).is_none()); + }); + } + + #[test] + unconstrained fn find_on_empty_array_is_none() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: TransientArray = TransientArray::at(SLOT); + assert(array.find(|_| true).is_none()); + }); + } + #[test] unconstrained fn store_and_load() { let env = TestEnvironment::new(); From fe86aae52b6b862824d02af9d0729f10a5b17c0f Mon Sep 17 00:00:00 2001 From: mverzilli Date: Wed, 10 Jun 2026 09:54:45 +0000 Subject: [PATCH 02/31] refactor: unify EphemeralArray and TransientArray over OracleArray Both types are now aliases of a single OracleArray generic, parameterized by an ArrayOracle backend trait whose impls wrap the existing #[oracle] declarations. Foreign-call names and the TS-side services are untouched; monomorphization emits the same calls as before. empty_at stays ephemeral-only via a specialized impl. Test suites are byte-for-byte unchanged. Expansion snapshots regenerated: nargo expand now prints the resolved OracleArray paths. --- .../aztec-nr/aztec/src/ephemeral/mod.nr | 232 ++------------- noir-projects/aztec-nr/aztec/src/lib.nr | 1 + .../aztec-nr/aztec/src/oracle_array/mod.nr | 265 ++++++++++++++++++ .../aztec-nr/aztec/src/transient/mod.nr | 224 ++------------- .../invalid_note/snapshots__stderr.snap | 5 +- .../amm_contract/snapshots__expanded.snap | 3 +- .../snapshots__expanded.snap | 3 +- .../snapshots__expanded.snap | 3 +- .../snapshots__expanded.snap | 3 +- .../snapshots__expanded.snap | 3 +- .../token_contract/snapshots__expanded.snap | 3 +- 11 files changed, 329 insertions(+), 416 deletions(-) create mode 100644 noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr diff --git a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr index 74cc36b813aa..86b3e63f36a8 100644 --- a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr @@ -1,7 +1,5 @@ use crate::oracle::ephemeral; -use crate::oracle::random::random; -use crate::protocol::traits::{Deserialize, Serialize}; -use crate::protocol::utils::{reader::Reader, writer::Writer}; +use crate::oracle_array::{ArrayOracle, OracleArray}; /// A dynamically sized array that exists only during a single contract call frame. /// @@ -24,227 +22,49 @@ use crate::protocol::utils::{reader::Reader, writer::Writer}; /// For data that must be shared across all frames of the same contract (private and utility) within one top-level PXE /// call (transaction simulation or utility call) but not persisted, use /// [`TransientArray`](crate::transient::TransientArray). -pub struct EphemeralArray { - pub slot: Field, -} +pub type EphemeralArray = OracleArray; -impl EphemeralArray { - /// Returns a handle to an ephemeral array at the given slot, which may already contain data (e.g. populated - /// by an oracle). - pub unconstrained fn at(slot: Field) -> Self { - Self { slot } - } +/// Routes [`OracleArray`] operations to the ephemeral array oracles, scoping arrays to a single contract call frame. +pub struct EphemeralOracle {} - /// Returns an empty ephemeral array at the given slot, clearing any pre-existing data. - pub unconstrained fn empty_at(slot: Field) -> Self { - Self::at(slot).clear() +impl ArrayOracle for EphemeralOracle { + unconstrained fn len_oracle(slot: Field) -> u32 { + ephemeral::len_oracle(slot) } - /// Returns an empty ephemeral array at a fresh, randomly allocated slot. - /// - /// Use this when the caller does not need a specific slot: the random slot is isolated from every other ephemeral - /// array with overwhelming probability. Prefer [`EphemeralArray::empty_at`] when the slot must be a known value - /// (e.g. one shared with an oracle). - pub unconstrained fn empty() -> Self { - Self::empty_at(random()) + unconstrained fn push_oracle(slot: Field, values: [Field; N]) -> u32 { + ephemeral::push_oracle(slot, values) } - /// Returns the number of elements stored in the array. - pub unconstrained fn len(self) -> u32 { - ephemeral::len_oracle(self.slot) + unconstrained fn pop_oracle(slot: Field) -> [Field; N] { + ephemeral::pop_oracle(slot) } - /// Stores a value at the end of the array. - pub unconstrained fn push(self, value: T) - where - T: Serialize, - { - let serialized = value.serialize(); - let _ = ephemeral::push_oracle(self.slot, serialized); - } - - /// Removes and returns the last element. Panics if the array is empty. - pub unconstrained fn pop(self) -> T - where - T: Deserialize, - { - let serialized = ephemeral::pop_oracle(self.slot); - Deserialize::deserialize(serialized) - } - - /// Retrieves the value stored at `index`. Panics if the index is out of bounds. - pub unconstrained fn get(self, index: u32) -> T - where - T: Deserialize, - { - let serialized = ephemeral::get_oracle(self.slot, index); - Deserialize::deserialize(serialized) - } - - /// Overwrites the value stored at `index`. Panics if the index is out of bounds. - pub unconstrained fn set(self, index: u32, value: T) - where - T: Serialize, - { - let serialized = value.serialize(); - ephemeral::set_oracle(self.slot, index, serialized); - } - - /// Removes the element at `index`, shifting subsequent elements backward. Panics if out of bounds. - pub unconstrained fn remove(self, index: u32) { - ephemeral::remove_oracle(self.slot, index); - } - - /// Removes all elements from the array and returns self for chaining. - /// - /// Prefer [`EphemeralArray::empty_at`] when the intent is to start with a fresh array. - pub unconstrained fn clear(self) -> Self { - ephemeral::clear_oracle(self.slot); - self - } - - /// 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. - /// - /// It is **not** safe to push new elements from inside the callback. - pub unconstrained fn for_each(self, f: unconstrained fn[Env](u32, T) -> ()) - where - T: Deserialize, - { - let mut i = self.len(); - while i > 0 { - i -= 1; - f(i, self.get(i)); - } - } - - /// Applies `f` to every element and collects the results into a fresh ephemeral array. - /// - /// Reads each element in order, transforms it with `f`, and pushes the result onto a new array at a freshly - /// allocated slot (see [`EphemeralArray::empty`]). The source array is left unchanged, and the result is isolated - /// from it, so `map` can be chained. - pub unconstrained fn map(self, f: unconstrained fn[Env](T) -> U) -> EphemeralArray - where - T: Deserialize, - U: Serialize, - { - let dest: EphemeralArray = EphemeralArray::empty(); - let n = self.len(); - for i in 0..n { - dest.push(f(self.get(i))); - } - dest - } - - /// Collects every element satisfying the predicate `f` into a fresh ephemeral array. - /// - /// Reads each element in order, keeps those for which `f` returns `true`, and pushes them onto a new array at a - /// freshly allocated slot (see [`EphemeralArray::empty`]). Relative order is preserved. The source array is left - /// unchanged, and the result is isolated from it, so `filter` can be chained. - pub unconstrained fn filter(self, f: unconstrained fn[Env](T) -> bool) -> Self - where - T: Serialize + Deserialize, - { - let dest: Self = EphemeralArray::empty(); - let n = self.len(); - for i in 0..n { - let value = self.get(i); - if f(value) { - dest.push(value); - } - } - dest + unconstrained fn get_oracle(slot: Field, index: u32) -> [Field; N] { + ephemeral::get_oracle(slot, index) } - /// Returns `true` if at least one element satisfies the predicate `f`. - /// - /// Matches the `any` combinator on Noir's [`array`](std::array), slice, and `BoundedVec` collections. Defined in - /// terms of [`EphemeralArray::filter`]: it keeps the matching elements and checks whether any survived. This is - /// not short-circuiting — every element is tested even after the first match. - pub unconstrained fn any(self, f: unconstrained fn[Env](T) -> bool) -> bool - where - T: Serialize + Deserialize, - { - self.filter(f).len() != 0 + unconstrained fn set_oracle(slot: Field, index: u32, values: [Field; N]) { + ephemeral::set_oracle(slot, index, values) } - /// Returns `true` if every element satisfies the predicate `f` (vacuously `true` for an empty array). - /// - /// Matches the `all` combinator on Noir's [`array`](std::array) and slice collections. Defined in terms of - /// [`EphemeralArray::filter`]: every element matches exactly when filtering keeps all of them. This is not - /// short-circuiting — every element is tested even after the first failure. - pub unconstrained fn all(self, f: unconstrained fn[Env](T) -> bool) -> bool - where - T: Serialize + Deserialize, - { - self.filter(f).len() == self.len() + unconstrained fn remove_oracle(slot: Field, index: u32) { + ephemeral::remove_oracle(slot, index) } - /// Returns the first element satisfying the predicate `f`, or `Option::none` if none do. - /// - /// Mirrors Rust's `Iterator::find`. Defined in terms of [`EphemeralArray::filter`], which preserves order, so the - /// first kept element is the first match. This is not short-circuiting — every element is tested even after the - /// match is found. - pub unconstrained fn find(self, f: unconstrained fn[Env](T) -> bool) -> Option - where - T: Serialize + Deserialize, - { - let matches = self.filter(f); - if matches.len() != 0 { - Option::some(matches.get(0)) - } else { - Option::none() - } + unconstrained fn clear_oracle(slot: Field) { + ephemeral::clear_oracle(slot) } } -impl EphemeralArray { - /// Deserializes the whole array into a `T`. +impl OracleArray { + /// Returns an empty ephemeral array at the given slot, clearing any pre-existing data. /// - /// Asserts the array holds exactly `::N` fields, then reconstructs a `T` from them. This is the - /// read-back counterpart to pushing a value's serialized fields onto the array, and is defined only on - /// `EphemeralArray` because deserialization reconstructs a type from raw fields. - pub unconstrained fn read_as(self) -> T - where - T: Deserialize, - { - assert_eq(self.len(), ::N, "EphemeralArray length mismatch for read_as"); - let mut fields: [Field; ::N] = [0; ::N]; - for i in 0..::N { - fields[i] = self.get(i); - } - Deserialize::deserialize(fields) - } -} - -/// Serializes an `EphemeralArray` as its slot identifier, allowing oracle function signatures to use -/// `EphemeralArray` instead of opaque `Field` slots. -impl Serialize for EphemeralArray { - let N: u32 = 1; - - fn serialize(self) -> [Field; Self::N] { - [self.slot] - } - - fn stream_serialize(self, writer: &mut Writer) { - writer.write(self.slot); - } -} - -/// Deserializes a single Field into an `EphemeralArray` handle, treating the field value as the slot identifier. -/// This is the inverse of [`Serialize`]. -impl Deserialize for EphemeralArray { - let N: u32 = 1; - - fn deserialize(fields: [Field; Self::N]) -> Self { - Self { slot: fields[0] } - } - - fn stream_deserialize(reader: &mut Reader) -> Self { - Self { slot: reader.read() } + /// This is deliberately ephemeral-only: a [`TransientArray`](crate::transient::TransientArray) slot may hold data + /// written by another frame of the same contract, so wiping a known slot there stays explicit via + /// `TransientArray::at(slot).clear()`. + pub unconstrained fn empty_at(slot: Field) -> Self { + Self::at(slot).clear() } } diff --git a/noir-projects/aztec-nr/aztec/src/lib.nr b/noir-projects/aztec-nr/aztec/src/lib.nr index 51d250108832..507cd51bed80 100644 --- a/noir-projects/aztec-nr/aztec/src/lib.nr +++ b/noir-projects/aztec-nr/aztec/src/lib.nr @@ -39,6 +39,7 @@ pub mod nullifier; pub mod oracle; pub mod state_vars; pub mod capsules; +pub mod oracle_array; pub mod ephemeral; pub mod transient; pub mod event; diff --git a/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr new file mode 100644 index 000000000000..572df64f87f3 --- /dev/null +++ b/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr @@ -0,0 +1,265 @@ +use crate::oracle::random::random; +use crate::protocol::traits::{Deserialize, Serialize}; +use crate::protocol::utils::{reader::Reader, writer::Writer}; + +/// Oracle backend for an [`OracleArray`]: the set of PXE-side operations that implement its storage. +/// +/// Each implementor routes these operations to a distinct family of oracles, and the oracle family determines the +/// array's lifetime and visibility (e.g. [`EphemeralArray`](crate::ephemeral::EphemeralArray) arrays live for one +/// contract call frame, while [`TransientArray`](crate::transient::TransientArray) arrays are shared across all frames +/// of the same contract within one top-level PXE call). Implementations are thin wrappers around `#[oracle]` +/// declarations, so method dispatch is resolved at compile time via monomorphization and each array type emits its own +/// foreign calls. +pub trait ArrayOracle { + /// Returns the number of elements in the array at `slot`. + unconstrained fn len_oracle(slot: Field) -> u32; + + /// Appends a serialized element to the array at `slot` and returns the new length. + unconstrained fn push_oracle(slot: Field, values: [Field; N]) -> u32; + + /// Removes and returns the last serialized element of the array at `slot`. + unconstrained fn pop_oracle(slot: Field) -> [Field; N]; + + /// Returns the serialized element at the given index of the array at `slot`. + unconstrained fn get_oracle(slot: Field, index: u32) -> [Field; N]; + + /// Overwrites the serialized element at the given index of the array at `slot`. + unconstrained fn set_oracle(slot: Field, index: u32, values: [Field; N]); + + /// Removes the element at the given index of the array at `slot`, shifting subsequent elements backward. + unconstrained fn remove_oracle(slot: Field, index: u32); + + /// Removes all elements from the array at `slot`. + unconstrained fn clear_oracle(slot: Field); +} + +/// A dynamically sized array backed by PXE-side in-memory storage via an [`ArrayOracle`] backend. +/// +/// Arrays are identified by a slot, and each logical operation (push, pop, get, etc.) is a single oracle call. The +/// `Oracle` backend determines the array's lifetime and visibility; contracts should not use this type directly but +/// rather one of its aliases: [`EphemeralArray`](crate::ephemeral::EphemeralArray) (scoped to a single contract call +/// frame) or [`TransientArray`](crate::transient::TransientArray) (shared across all frames of the same contract +/// within one top-level PXE call). +pub struct OracleArray { + pub slot: Field, +} + +impl OracleArray +where + Oracle: ArrayOracle, +{ + /// Returns a handle to the array at the given slot, which may already contain data (e.g. populated by an oracle + /// or by an earlier frame, depending on the backend's visibility). + pub unconstrained fn at(slot: Field) -> Self { + Self { slot } + } + + /// Returns an empty array at a fresh, randomly allocated slot. + /// + /// Use this when the caller does not need a specific slot: the random slot is isolated from every other array of + /// the same backend with overwhelming probability. Prefer [`OracleArray::at`] when the slot must be a known value + /// (e.g. one shared with an oracle or another call frame). + pub unconstrained fn empty() -> Self { + Self::at(random()).clear() + } + + /// Returns the number of elements stored in the array. + pub unconstrained fn len(self) -> u32 { + Oracle::len_oracle(self.slot) + } + + /// Stores a value at the end of the array. + pub unconstrained fn push(self, value: T) + where + T: Serialize, + { + let serialized = value.serialize(); + let _ = Oracle::push_oracle(self.slot, serialized); + } + + /// Removes and returns the last element. Panics if the array is empty. + pub unconstrained fn pop(self) -> T + where + T: Deserialize, + { + let serialized = Oracle::pop_oracle(self.slot); + Deserialize::deserialize(serialized) + } + + /// Retrieves the value stored at `index`. Panics if the index is out of bounds. + pub unconstrained fn get(self, index: u32) -> T + where + T: Deserialize, + { + let serialized = Oracle::get_oracle(self.slot, index); + Deserialize::deserialize(serialized) + } + + /// Overwrites the value stored at `index`. Panics if the index is out of bounds. + pub unconstrained fn set(self, index: u32, value: T) + where + T: Serialize, + { + let serialized = value.serialize(); + Oracle::set_oracle(self.slot, index, serialized); + } + + /// Removes the element at `index`, shifting subsequent elements backward. Panics if out of bounds. + pub unconstrained fn remove(self, index: u32) { + Oracle::remove_oracle(self.slot, index); + } + + /// Removes all elements from the array and returns self for chaining (e.g. `OracleArray::at(slot).clear()` to + /// get a guaranteed-empty array at a given slot). + pub unconstrained fn clear(self) -> Self { + Oracle::clear_oracle(self.slot); + self + } + + /// 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. + /// + /// It is **not** safe to push new elements from inside the callback. + pub unconstrained fn for_each(self, f: unconstrained fn[Env](u32, T) -> ()) + where + T: Deserialize, + { + let mut i = self.len(); + while i > 0 { + i -= 1; + f(i, self.get(i)); + } + } + + /// Applies `f` to every element and collects the results into a fresh array. + /// + /// Reads each element in order, transforms it with `f`, and pushes the result onto a new array at a freshly + /// allocated slot (see [`OracleArray::empty`]). The source array is left unchanged, and the result is isolated + /// from it, so `map` can be chained. + pub unconstrained fn map(self, f: unconstrained fn[Env](T) -> U) -> OracleArray + where + T: Deserialize, + U: Serialize, + { + let dest: OracleArray = OracleArray::empty(); + let n = self.len(); + for i in 0..n { + dest.push(f(self.get(i))); + } + dest + } + + /// Collects every element satisfying the predicate `f` into a fresh array. + /// + /// Reads each element in order, keeps those for which `f` returns `true`, and pushes them onto a new array at a + /// freshly allocated slot (see [`OracleArray::empty`]). Relative order is preserved. The source array is left + /// unchanged, and the result is isolated from it, so `filter` can be chained. + pub unconstrained fn filter(self, f: unconstrained fn[Env](T) -> bool) -> Self + where + T: Serialize + Deserialize, + { + let dest: Self = OracleArray::empty(); + let n = self.len(); + for i in 0..n { + let value = self.get(i); + if f(value) { + dest.push(value); + } + } + dest + } + + /// Returns `true` if at least one element satisfies the predicate `f`. + /// + /// Matches the `any` combinator on Noir's [`array`](std::array), slice, and `BoundedVec` collections. Defined in + /// terms of [`OracleArray::filter`]: it keeps the matching elements and checks whether any survived. This is not + /// short-circuiting — every element is tested even after the first match. + pub unconstrained fn any(self, f: unconstrained fn[Env](T) -> bool) -> bool + where + T: Serialize + Deserialize, + { + self.filter(f).len() != 0 + } + + /// Returns `true` if every element satisfies the predicate `f` (vacuously `true` for an empty array). + /// + /// Matches the `all` combinator on Noir's [`array`](std::array) and slice collections. Defined in terms of + /// [`OracleArray::filter`]: every element matches exactly when filtering keeps all of them. This is not + /// short-circuiting — every element is tested even after the first failure. + pub unconstrained fn all(self, f: unconstrained fn[Env](T) -> bool) -> bool + where + T: Serialize + Deserialize, + { + self.filter(f).len() == self.len() + } + + /// Returns the first element satisfying the predicate `f`, or `Option::none` if none do. + /// + /// Mirrors Rust's `Iterator::find`. Defined in terms of [`OracleArray::filter`], which preserves order, so the + /// first kept element is the first match. This is not short-circuiting — every element is tested even after the + /// match is found. + pub unconstrained fn find(self, f: unconstrained fn[Env](T) -> bool) -> Option + where + T: Serialize + Deserialize, + { + let matches = self.filter(f); + if matches.len() != 0 { + Option::some(matches.get(0)) + } else { + Option::none() + } + } +} + +impl OracleArray +where + Oracle: ArrayOracle, +{ + /// Deserializes the whole array into a `T`. + /// + /// Asserts the array holds exactly `::N` fields, then reconstructs a `T` from them. This is the + /// read-back counterpart to pushing a value's serialized fields onto the array, and is defined only on arrays of + /// `Field` because deserialization reconstructs a type from raw fields. + pub unconstrained fn read_as(self) -> T + where + T: Deserialize, + { + assert_eq(self.len(), ::N, "OracleArray length mismatch for read_as"); + let mut fields: [Field; ::N] = [0; ::N]; + for i in 0..::N { + fields[i] = self.get(i); + } + Deserialize::deserialize(fields) + } +} + +/// Serializes an `OracleArray` as its slot identifier, allowing oracle function signatures to use array types +/// instead of opaque `Field` slots. +impl Serialize for OracleArray { + let N: u32 = 1; + + fn serialize(self) -> [Field; Self::N] { + [self.slot] + } + + fn stream_serialize(self, writer: &mut Writer) { + writer.write(self.slot); + } +} + +/// Deserializes a single Field into an `OracleArray` handle, treating the field value as the slot identifier. +/// This is the inverse of [`Serialize`]. +impl Deserialize for OracleArray { + let N: u32 = 1; + + fn deserialize(fields: [Field; Self::N]) -> Self { + Self { slot: fields[0] } + } + + fn stream_deserialize(reader: &mut Reader) -> Self { + Self { slot: reader.read() } + } +} diff --git a/noir-projects/aztec-nr/aztec/src/transient/mod.nr b/noir-projects/aztec-nr/aztec/src/transient/mod.nr index ff70f59e4263..204fb253624c 100644 --- a/noir-projects/aztec-nr/aztec/src/transient/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/transient/mod.nr @@ -1,7 +1,6 @@ -use crate::oracle::random::random; use crate::oracle::transient; +use crate::oracle_array::{ArrayOracle, OracleArray}; use crate::protocol::traits::{Deserialize, Serialize}; -use crate::protocol::utils::{reader::Reader, writer::Writer}; /// A dynamically sized array that lives for the duration of a single top-level PXE call. /// @@ -30,218 +29,39 @@ use crate::protocol::utils::{reader::Reader, writer::Writer}; /// Use this to pass not-to-be-persisted data between a contract's own frames (private or utility) within one top-level /// PXE call. For data confined to a single call frame, prefer [`EphemeralArray`](crate::ephemeral::EphemeralArray). /// For data that must persist indefinitely, use [`CapsuleArray`](crate::capsules::CapsuleArray). -pub struct TransientArray { - pub slot: Field, -} +pub type TransientArray = OracleArray; -impl TransientArray { - /// Returns a handle to a transient array at the given slot, which may already contain data pushed by an earlier - /// frame of the same contract in this top-level PXE call. - pub unconstrained fn at(slot: Field) -> Self { - Self { slot } - } - - /// Returns an empty transient array at a fresh, randomly allocated slot. - /// - /// Use this when the caller does not need a specific slot: the random slot is isolated from every other transient - /// array with overwhelming probability. Prefer [`TransientArray::at`] when the slot must be a known value (e.g. - /// one agreed upon with another frame of the same contract). - pub unconstrained fn empty() -> Self { - Self::at(random()).clear() - } - - /// Returns the number of elements stored in the array. - pub unconstrained fn len(self) -> u32 { - transient::len_oracle(self.slot) - } - - /// Stores a value at the end of the array. - pub unconstrained fn push(self, value: T) - where - T: Serialize, - { - let serialized = value.serialize(); - let _ = transient::push_oracle(self.slot, serialized); - } - - /// Removes and returns the last element. Panics if the array is empty. - pub unconstrained fn pop(self) -> T - where - T: Deserialize, - { - let serialized = transient::pop_oracle(self.slot); - Deserialize::deserialize(serialized) - } - - /// Retrieves the value stored at `index`. Panics if the index is out of bounds. - pub unconstrained fn get(self, index: u32) -> T - where - T: Deserialize, - { - let serialized = transient::get_oracle(self.slot, index); - Deserialize::deserialize(serialized) - } - - /// Overwrites the value stored at `index`. Panics if the index is out of bounds. - pub unconstrained fn set(self, index: u32, value: T) - where - T: Serialize, - { - let serialized = value.serialize(); - transient::set_oracle(self.slot, index, serialized); - } - - /// Removes the element at `index`, shifting subsequent elements backward. Panics if out of bounds. - pub unconstrained fn remove(self, index: u32) { - transient::remove_oracle(self.slot, index); - } - - /// Removes all elements from the array and returns self for chaining (e.g. `TransientArray::at(slot).clear()` - /// to get a guaranteed-empty array at a given slot). - pub unconstrained fn clear(self) -> Self { - transient::clear_oracle(self.slot); - self - } - - /// Calls a function on each element of the array. - /// - /// Iteration proceeds backwards so it is safe to remove the current element (and only the current element) inside - /// the callback. It is **not** safe to push new elements from inside the callback. - pub unconstrained fn for_each(self, f: unconstrained fn[Env](u32, T) -> ()) - where - T: Deserialize, - { - let mut i = self.len(); - while i > 0 { - i -= 1; - f(i, self.get(i)); - } - } - - /// Applies `f` to every element and collects the results into a fresh transient array. - /// - /// Reads each element in order, transforms it with `f`, and pushes the result onto a new array at a freshly - /// allocated slot (see [`TransientArray::empty`]). The source array is left unchanged, and the result is isolated - /// from it, so `map` can be chained. - pub unconstrained fn map(self, f: unconstrained fn[Env](T) -> U) -> TransientArray - where - T: Deserialize, - U: Serialize, - { - let dest: TransientArray = TransientArray::empty(); - let n = self.len(); - for i in 0..n { - dest.push(f(self.get(i))); - } - dest - } - - /// Collects every element satisfying the predicate `f` into a fresh transient array. - /// - /// Reads each element in order, keeps those for which `f` returns `true`, and pushes them onto a new array at a - /// freshly allocated slot (see [`TransientArray::empty`]). Relative order is preserved. The source array is left - /// unchanged, and the result is isolated from it, so `filter` can be chained. - pub unconstrained fn filter(self, f: unconstrained fn[Env](T) -> bool) -> Self - where - T: Serialize + Deserialize, - { - let dest: Self = TransientArray::empty(); - let n = self.len(); - for i in 0..n { - let value = self.get(i); - if f(value) { - dest.push(value); - } - } - dest - } - - /// Returns `true` if at least one element satisfies the predicate `f`. - /// - /// Matches the `any` combinator on Noir's [`array`](std::array), slice, and `BoundedVec` collections. Defined in - /// terms of [`TransientArray::filter`]: it keeps the matching elements and checks whether any survived. This is - /// not short-circuiting — every element is tested even after the first match. - pub unconstrained fn any(self, f: unconstrained fn[Env](T) -> bool) -> bool - where - T: Serialize + Deserialize, - { - self.filter(f).len() != 0 - } - - /// Returns `true` if every element satisfies the predicate `f` (vacuously `true` for an empty array). - /// - /// Matches the `all` combinator on Noir's [`array`](std::array) and slice collections. Defined in terms of - /// [`TransientArray::filter`]: every element matches exactly when filtering keeps all of them. This is not - /// short-circuiting — every element is tested even after the first failure. - pub unconstrained fn all(self, f: unconstrained fn[Env](T) -> bool) -> bool - where - T: Serialize + Deserialize, - { - self.filter(f).len() == self.len() - } - - /// Returns the first element satisfying the predicate `f`, or `Option::none` if none do. - /// - /// Mirrors Rust's `Iterator::find`. Defined in terms of [`TransientArray::filter`], which preserves order, so the - /// first kept element is the first match. This is not short-circuiting — every element is tested even after the - /// match is found. - pub unconstrained fn find(self, f: unconstrained fn[Env](T) -> bool) -> Option - where - T: Serialize + Deserialize, - { - let matches = self.filter(f); - if matches.len() != 0 { - Option::some(matches.get(0)) - } else { - Option::none() - } - } -} +/// Routes [`OracleArray`] operations to the transient array oracles, sharing arrays across all call frames of the +/// same contract within one top-level PXE call. +pub struct TransientOracle {} -impl TransientArray { - /// Deserializes the whole array into a `T`. - /// - /// Asserts the array holds exactly `::N` fields, then reconstructs a `T` from them. This is the - /// read-back counterpart to pushing a value's serialized fields onto the array, and is defined only on - /// `TransientArray` because deserialization reconstructs a type from raw fields. - pub unconstrained fn read_as(self) -> T - where - T: Deserialize, - { - assert_eq(self.len(), ::N, "TransientArray length mismatch for read_as"); - let mut fields: [Field; ::N] = [0; ::N]; - for i in 0..::N { - fields[i] = self.get(i); - } - Deserialize::deserialize(fields) +impl ArrayOracle for TransientOracle { + unconstrained fn len_oracle(slot: Field) -> u32 { + transient::len_oracle(slot) } -} -/// Serializes a `TransientArray` as its slot identifier, allowing oracle function signatures to use `TransientArray` -/// instead of opaque `Field` slots. -impl Serialize for TransientArray { - let N: u32 = 1; + unconstrained fn push_oracle(slot: Field, values: [Field; N]) -> u32 { + transient::push_oracle(slot, values) + } - fn serialize(self) -> [Field; Self::N] { - [self.slot] + unconstrained fn pop_oracle(slot: Field) -> [Field; N] { + transient::pop_oracle(slot) } - fn stream_serialize(self, writer: &mut Writer) { - writer.write(self.slot); + unconstrained fn get_oracle(slot: Field, index: u32) -> [Field; N] { + transient::get_oracle(slot, index) } -} -/// Deserializes a single Field into a `TransientArray` handle, treating the field value as the slot identifier. -/// This is the inverse of [`Serialize`]. -impl Deserialize for TransientArray { - let N: u32 = 1; + unconstrained fn set_oracle(slot: Field, index: u32, values: [Field; N]) { + transient::set_oracle(slot, index, values) + } - fn deserialize(fields: [Field; Self::N]) -> Self { - Self { slot: fields[0] } + unconstrained fn remove_oracle(slot: Field, index: u32) { + transient::remove_oracle(slot, index) } - fn stream_deserialize(reader: &mut Reader) -> Self { - Self { slot: reader.read() } + unconstrained fn clear_oracle(slot: Field) { + transient::clear_oracle(slot) } } diff --git a/noir-projects/contract-snapshots/tests/snapshots/compile_failure/invalid_note/snapshots__stderr.snap b/noir-projects/contract-snapshots/tests/snapshots/compile_failure/invalid_note/snapshots__stderr.snap index 0e2fc502906b..c318cf3a0956 100644 --- a/noir-projects/contract-snapshots/tests/snapshots/compile_failure/invalid_note/snapshots__stderr.snap +++ b/noir-projects/contract-snapshots/tests/snapshots/compile_failure/invalid_note/snapshots__stderr.snap @@ -1,5 +1,6 @@ --- source: tests/snapshots.rs +assertion_line: 118 expression: stderr --- error: InvalidNote has a packed length of 9 fields, which exceeds the maximum allowed length of 8 fields. See https://docs.aztec.network/errors/4 @@ -13,8 +14,8 @@ error: InvalidNote has a packed length of 9 fields, which exceeds the maximum al at /noir-projects/aztec-nr/aztec/src/macros/aztec.nr:: 2: do_sync_state at /noir-projects/aztec-nr/aztec/src/messages/discovery/mod.nr:: - 3: EphemeralArray::for_each - at /noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr:: + 3: OracleArray::for_each + at /noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr:: 4: do_sync_state at /noir-projects/aztec-nr/aztec/src/messages/discovery/mod.nr:: 5: process_message_ciphertext diff --git a/noir-projects/contract-snapshots/tests/snapshots/expand/amm_contract/snapshots__expanded.snap b/noir-projects/contract-snapshots/tests/snapshots/expand/amm_contract/snapshots__expanded.snap index c44ddd355c2f..3ec1b3682c61 100644 --- a/noir-projects/contract-snapshots/tests/snapshots/expand/amm_contract/snapshots__expanded.snap +++ b/noir-projects/contract-snapshots/tests/snapshots/expand/amm_contract/snapshots__expanded.snap @@ -1,5 +1,6 @@ --- source: tests/snapshots.rs +assertion_line: 159 expression: stdout --- @@ -833,7 +834,7 @@ pub contract AMM { unconstrained fn sync_state(scope: AztecAddress) { let address: AztecAddress = aztec::context::UtilityContext::new().this_address(); - aztec::messages::discovery::do_sync_state(address, _compute_note_hash, _compute_note_nullifier, Option::, aztec::messages::processing::MessageContext, AztecAddress)>::none(), Option:: aztec::ephemeral::EphemeralArray>::some(aztec::messages::processing::offchain::sync_inbox), scope); + aztec::messages::discovery::do_sync_state(address, _compute_note_hash, _compute_note_nullifier, Option::, aztec::messages::processing::MessageContext, AztecAddress)>::none(), Option:: aztec::oracle_array::OracleArray>::some(aztec::messages::processing::offchain::sync_inbox), scope); } pub struct offchain_receive_parameters { diff --git a/noir-projects/contract-snapshots/tests/snapshots/expand/avm_gadgets_test_contract/snapshots__expanded.snap b/noir-projects/contract-snapshots/tests/snapshots/expand/avm_gadgets_test_contract/snapshots__expanded.snap index c7f2843131a6..d2b5233aa173 100644 --- a/noir-projects/contract-snapshots/tests/snapshots/expand/avm_gadgets_test_contract/snapshots__expanded.snap +++ b/noir-projects/contract-snapshots/tests/snapshots/expand/avm_gadgets_test_contract/snapshots__expanded.snap @@ -1,5 +1,6 @@ --- source: tests/snapshots.rs +assertion_line: 159 expression: stdout --- @@ -332,7 +333,7 @@ contract AvmGadgetsTest { unconstrained fn sync_state(scope: aztec::protocol::address::AztecAddress) { let address: aztec::protocol::address::AztecAddress = aztec::context::UtilityContext::new().this_address(); - aztec::messages::discovery::do_sync_state(address, _compute_note_hash, _compute_note_nullifier, Option::, aztec::messages::processing::MessageContext, aztec::protocol::address::AztecAddress)>::none(), Option:: aztec::ephemeral::EphemeralArray>::some(aztec::messages::processing::offchain::sync_inbox), scope); + aztec::messages::discovery::do_sync_state(address, _compute_note_hash, _compute_note_nullifier, Option::, aztec::messages::processing::MessageContext, aztec::protocol::address::AztecAddress)>::none(), Option:: aztec::oracle_array::OracleArray>::some(aztec::messages::processing::offchain::sync_inbox), scope); } pub struct offchain_receive_parameters { diff --git a/noir-projects/contract-snapshots/tests/snapshots/expand/avm_test_contract/snapshots__expanded.snap b/noir-projects/contract-snapshots/tests/snapshots/expand/avm_test_contract/snapshots__expanded.snap index cdfa8a6786b4..ab5416d3eb75 100644 --- a/noir-projects/contract-snapshots/tests/snapshots/expand/avm_test_contract/snapshots__expanded.snap +++ b/noir-projects/contract-snapshots/tests/snapshots/expand/avm_test_contract/snapshots__expanded.snap @@ -1,5 +1,6 @@ --- source: tests/snapshots.rs +assertion_line: 159 expression: stdout --- @@ -1718,7 +1719,7 @@ pub contract AvmTest { unconstrained fn sync_state(scope: AztecAddress) { let address: AztecAddress = aztec::context::UtilityContext::new().this_address(); - aztec::messages::discovery::do_sync_state(address, _compute_note_hash, _compute_note_nullifier, Option::, aztec::messages::processing::MessageContext, AztecAddress)>::none(), Option:: aztec::ephemeral::EphemeralArray>::some(aztec::messages::processing::offchain::sync_inbox), scope); + aztec::messages::discovery::do_sync_state(address, _compute_note_hash, _compute_note_nullifier, Option::, aztec::messages::processing::MessageContext, AztecAddress)>::none(), Option:: aztec::oracle_array::OracleArray>::some(aztec::messages::processing::offchain::sync_inbox), scope); } pub struct offchain_receive_parameters { diff --git a/noir-projects/contract-snapshots/tests/snapshots/expand/public_fns_with_emit_repro_contract/snapshots__expanded.snap b/noir-projects/contract-snapshots/tests/snapshots/expand/public_fns_with_emit_repro_contract/snapshots__expanded.snap index 445edf3cc082..ae15dd5d32cf 100644 --- a/noir-projects/contract-snapshots/tests/snapshots/expand/public_fns_with_emit_repro_contract/snapshots__expanded.snap +++ b/noir-projects/contract-snapshots/tests/snapshots/expand/public_fns_with_emit_repro_contract/snapshots__expanded.snap @@ -1,5 +1,6 @@ --- source: tests/snapshots.rs +assertion_line: 159 expression: stdout --- @@ -308,7 +309,7 @@ pub contract PublicFnsWithEmitRepro { unconstrained fn sync_state(scope: aztec::protocol::address::AztecAddress) { let address: aztec::protocol::address::AztecAddress = aztec::context::UtilityContext::new().this_address(); - aztec::messages::discovery::do_sync_state(address, _compute_note_hash, _compute_note_nullifier, Option::, aztec::messages::processing::MessageContext, aztec::protocol::address::AztecAddress)>::none(), Option:: aztec::ephemeral::EphemeralArray>::some(aztec::messages::processing::offchain::sync_inbox), scope); + aztec::messages::discovery::do_sync_state(address, _compute_note_hash, _compute_note_nullifier, Option::, aztec::messages::processing::MessageContext, aztec::protocol::address::AztecAddress)>::none(), Option:: aztec::oracle_array::OracleArray>::some(aztec::messages::processing::offchain::sync_inbox), scope); } pub struct offchain_receive_parameters { diff --git a/noir-projects/contract-snapshots/tests/snapshots/expand/storage_proof_test_contract/snapshots__expanded.snap b/noir-projects/contract-snapshots/tests/snapshots/expand/storage_proof_test_contract/snapshots__expanded.snap index 0f8dae5d076b..e26096f956fc 100644 --- a/noir-projects/contract-snapshots/tests/snapshots/expand/storage_proof_test_contract/snapshots__expanded.snap +++ b/noir-projects/contract-snapshots/tests/snapshots/expand/storage_proof_test_contract/snapshots__expanded.snap @@ -1,5 +1,6 @@ --- source: tests/snapshots.rs +assertion_line: 159 expression: stdout --- @@ -267,7 +268,7 @@ contract StorageProofTest { unconstrained fn sync_state(scope: AztecAddress) { let address: AztecAddress = aztec::context::UtilityContext::new().this_address(); - aztec::messages::discovery::do_sync_state(address, _compute_note_hash, _compute_note_nullifier, Option::, aztec::messages::processing::MessageContext, AztecAddress)>::none(), Option:: aztec::ephemeral::EphemeralArray>::some(aztec::messages::processing::offchain::sync_inbox), scope); + aztec::messages::discovery::do_sync_state(address, _compute_note_hash, _compute_note_nullifier, Option::, aztec::messages::processing::MessageContext, AztecAddress)>::none(), Option:: aztec::oracle_array::OracleArray>::some(aztec::messages::processing::offchain::sync_inbox), scope); } pub struct offchain_receive_parameters { diff --git a/noir-projects/contract-snapshots/tests/snapshots/expand/token_contract/snapshots__expanded.snap b/noir-projects/contract-snapshots/tests/snapshots/expand/token_contract/snapshots__expanded.snap index 61239ae02de8..e99dc969d8f8 100644 --- a/noir-projects/contract-snapshots/tests/snapshots/expand/token_contract/snapshots__expanded.snap +++ b/noir-projects/contract-snapshots/tests/snapshots/expand/token_contract/snapshots__expanded.snap @@ -1,5 +1,6 @@ --- source: tests/snapshots.rs +assertion_line: 159 expression: stdout --- @@ -1043,7 +1044,7 @@ pub contract Token { unconstrained fn sync_state(scope: AztecAddress) { let address: AztecAddress = aztec::context::UtilityContext::new().this_address(); - aztec::messages::discovery::do_sync_state(address, _compute_note_hash, _compute_note_nullifier, Option::, aztec::messages::processing::MessageContext, AztecAddress)>::none(), Option:: aztec::ephemeral::EphemeralArray>::some(aztec::messages::processing::offchain::sync_inbox), scope); + aztec::messages::discovery::do_sync_state(address, _compute_note_hash, _compute_note_nullifier, Option::, aztec::messages::processing::MessageContext, AztecAddress)>::none(), Option:: aztec::oracle_array::OracleArray>::some(aztec::messages::processing::offchain::sync_inbox), scope); } pub struct offchain_receive_parameters { From f75105743ddfc06efef5c3727c3af3c9b03402f4 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Wed, 10 Jun 2026 10:15:33 +0000 Subject: [PATCH 03/31] refactor: share OracleArray test bodies across backends Each behavior check is written once in oracle_array/test_helpers.nr, generic over the ArrayOracle backend; the ephemeral and transient test modules keep one named #[test] wrapper per behavior so counts, names and CI filters are unchanged. Backend-specific tests (empty_at, store/load/delete) stay with their backend. --- .../aztec-nr/aztec/src/ephemeral/mod.nr | 464 ++---------- .../aztec-nr/aztec/src/oracle_array/mod.nr | 2 + .../aztec/src/oracle_array/test_helpers.nr | 659 ++++++++++++++++++ .../aztec-nr/aztec/src/transient/mod.nr | 472 ++----------- 4 files changed, 745 insertions(+), 852 deletions(-) create mode 100644 noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr diff --git a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr index 86b3e63f36a8..6ae5ca8a0ed6 100644 --- a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr @@ -69,265 +69,94 @@ impl OracleArray { } mod test { - use crate::protocol::traits::Serialize; + use crate::oracle_array::test_helpers::SLOT; + use crate::oracle_array::test_helpers as checks; use crate::test::helpers::test_environment::TestEnvironment; - use crate::test::mocks::MockStruct; - use super::EphemeralArray; - - global SLOT: Field = 1230; - global OTHER_SLOT: Field = 5670; + use super::{EphemeralArray, EphemeralOracle}; #[test] unconstrained fn empty_array() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::at(SLOT); - assert_eq(array.len(), 0); - }); + checks::check_empty_array::(); } #[test(should_fail_with = "out of bounds")] unconstrained fn empty_array_read() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = EphemeralArray::at(SLOT); - let _: Field = array.get(0); - }); + checks::check_empty_array_read::(); } #[test(should_fail_with = "is empty")] unconstrained fn empty_array_pop() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = EphemeralArray::at(SLOT); - let _: Field = array.pop(); - }); + checks::check_empty_array_pop::(); } #[test] unconstrained fn array_push() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = EphemeralArray::at(SLOT); - array.push(5); - - assert_eq(array.len(), 1); - assert_eq(array.get(0), 5); - }); + checks::check_array_push::(); } #[test(should_fail_with = "out of bounds")] unconstrained fn read_past_len() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = EphemeralArray::at(SLOT); - array.push(5); - - let _ = array.get(1); - }); + checks::check_read_past_len::(); } #[test] unconstrained fn array_pop() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = EphemeralArray::at(SLOT); - array.push(5); - array.push(10); - - let popped: Field = array.pop(); - assert_eq(popped, 10); - assert_eq(array.len(), 1); - assert_eq(array.get(0), 5); - }); + checks::check_array_pop::(); } #[test] unconstrained fn array_set() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = EphemeralArray::at(SLOT); - array.push(5); - array.set(0, 99); - assert_eq(array.get(0), 99); - }); + checks::check_array_set::(); } #[test] unconstrained fn array_remove_last() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = EphemeralArray::at(SLOT); - array.push(5); - array.remove(0); - assert_eq(array.len(), 0); - }); + checks::check_array_remove_last::(); } #[test] unconstrained fn array_remove_some() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = EphemeralArray::at(SLOT); - - array.push(7); - array.push(8); - array.push(9); - - assert_eq(array.len(), 3); - - array.remove(1); - - assert_eq(array.len(), 2); - assert_eq(array.get(0), 7); - assert_eq(array.get(1), 9); - }); + checks::check_array_remove_some::(); } #[test] unconstrained fn array_remove_all() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = EphemeralArray::at(SLOT); - - array.push(7); - array.push(8); - array.push(9); - - array.remove(1); - array.remove(1); - array.remove(0); - - assert_eq(array.len(), 0); - }); + checks::check_array_remove_all::(); } #[test] unconstrained fn for_each_called_with_all_elements() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = EphemeralArray::at(SLOT); - - array.push(4); - array.push(5); - array.push(6); - - 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))); - }); + checks::check_for_each_called_with_all_elements::(); } #[test] unconstrained fn for_each_remove_some() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = EphemeralArray::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); - }); + checks::check_for_each_remove_some::(); } #[test] unconstrained fn for_each_remove_all() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = EphemeralArray::at(SLOT); - - array.push(4); - array.push(5); - array.push(6); - - array.for_each(|index, _| { array.remove(index); }); - - assert_eq(array.len(), 0); - }); + checks::check_for_each_remove_all::(); } #[test] unconstrained fn different_slots_are_isolated() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array_a = EphemeralArray::at(SLOT); - let array_b = EphemeralArray::at(OTHER_SLOT); - - array_a.push(10); - array_a.push(20); - array_b.push(99); - - assert_eq(array_a.len(), 2); - assert_eq(array_a.get(0), 10); - assert_eq(array_a.get(1), 20); - - assert_eq(array_b.len(), 1); - assert_eq(array_b.get(0), 99); - }); + checks::check_different_slots_are_isolated::(); } #[test] unconstrained fn works_with_multi_field_type() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::at(SLOT); - - let a = MockStruct::new(5, 6); - let b = MockStruct::new(7, 8); - array.push(a); - array.push(b); - - assert_eq(array.len(), 2); - assert_eq(array.get(0), a); - assert_eq(array.get(1), b); - - let popped: MockStruct = array.pop(); - assert_eq(popped, b); - assert_eq(array.len(), 1); - }); + checks::check_works_with_multi_field_type::(); } #[test] unconstrained fn read_as_reconstructs_serialized_value() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::empty_at(SLOT); - - let value = MockStruct::new(5, 6); - let serialized = value.serialize(); - for i in 0..serialized.len() { - array.push(serialized[i]); - } - - let reconstructed: MockStruct = array.read_as(); - assert_eq(reconstructed, value); - }); + checks::check_read_as_reconstructs_serialized_value::(); } #[test(should_fail_with = "length mismatch")] unconstrained fn read_as_rejects_length_mismatch() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::empty_at(SLOT); - // MockStruct deserializes from 2 fields, so a single field is too short. - array.push(1); - - let _: MockStruct = array.read_as(); - }); + checks::check_read_as_rejects_length_mismatch::(); } #[test] @@ -345,317 +174,106 @@ mod test { #[test] unconstrained fn clear_returns_self() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::at(SLOT).clear(); - assert_eq(array.len(), 0); - - array.push(42); - assert_eq(array.len(), 1); - assert_eq(array.get(0), 42); - }); + checks::check_clear_returns_self::(); } #[test] unconstrained fn clear_wipes_previous_data() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::at(SLOT); - array.push(1); - array.push(2); - array.push(3); - assert_eq(array.len(), 3); - - // Clear the same slot, previous data should be gone. - let fresh: EphemeralArray = EphemeralArray::at(SLOT).clear(); - assert_eq(fresh.len(), 0); - fresh.push(4); - assert_eq(fresh.get(0), 4); - }); + checks::check_clear_wipes_previous_data::(); } #[test] unconstrained fn empty_allocates_distinct_slots() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let a: EphemeralArray = EphemeralArray::empty(); - let b: EphemeralArray = EphemeralArray::empty(); - - assert(a.slot != b.slot, "empty() should allocate a fresh slot each time"); - - a.push(1); - b.push(2); - assert_eq(a.len(), 1); - assert_eq(a.get(0), 1); - assert_eq(b.len(), 1); - assert_eq(b.get(0), 2); - }); + checks::check_empty_allocates_distinct_slots::(); } #[test] unconstrained fn map_transforms_each_element() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: EphemeralArray = EphemeralArray::empty_at(SLOT); - source.push(1); - source.push(2); - source.push(3); - - let doubled: EphemeralArray = source.map(|x| x * 2); - - assert_eq(doubled.len(), 3); - assert_eq(doubled.get(0), 2); - assert_eq(doubled.get(1), 4); - assert_eq(doubled.get(2), 6); - - // The source array is left untouched. - assert_eq(source.len(), 3); - assert_eq(source.get(0), 1); - }); + checks::check_map_transforms_each_element::(); } #[test] unconstrained fn map_empty_source_gives_empty_dest() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: EphemeralArray = EphemeralArray::empty_at(SLOT); - let mapped: EphemeralArray = source.map(|x| x * 2); - assert_eq(mapped.len(), 0); - }); + checks::check_map_empty_source_gives_empty_dest::(); } #[test] unconstrained fn map_to_different_type() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: EphemeralArray = EphemeralArray::empty_at(SLOT); - source.push(5); - source.push(7); - - let structs: EphemeralArray = source.map(|x| MockStruct::new(x, x + 1)); - - assert_eq(structs.len(), 2); - assert_eq(structs.get(0), MockStruct::new(5, 6)); - assert_eq(structs.get(1), MockStruct::new(7, 8)); - }); + checks::check_map_to_different_type::(); } #[test] unconstrained fn map_results_are_isolated() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: EphemeralArray = EphemeralArray::empty_at(SLOT); - source.push(1); - source.push(2); - source.push(3); - - // Each map allocates its own fresh slot, so two maps of the same source do not clobber each other. - let doubled: EphemeralArray = source.map(|x| x * 2); - let tripled: EphemeralArray = source.map(|x| x * 3); - - assert(doubled.slot != tripled.slot, "each map should land in a distinct slot"); - assert_eq(doubled.get(0), 2); - assert_eq(doubled.get(2), 6); - assert_eq(tripled.get(0), 3); - assert_eq(tripled.get(2), 9); - }); + checks::check_map_results_are_isolated::(); } #[test] unconstrained fn filter_keeps_matching_elements_in_order() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: EphemeralArray = EphemeralArray::empty_at(SLOT); - source.push(1); - source.push(2); - source.push(3); - source.push(2); - source.push(5); - - let kept: EphemeralArray = source.filter(|x| x != 2); - - assert_eq(kept.len(), 3); - assert_eq(kept.get(0), 1); - assert_eq(kept.get(1), 3); - assert_eq(kept.get(2), 5); - - // The source array is left untouched. - assert_eq(source.len(), 5); - assert_eq(source.get(1), 2); - }); + checks::check_filter_keeps_matching_elements_in_order::(); } #[test] unconstrained fn filter_empty_source_gives_empty_dest() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: EphemeralArray = EphemeralArray::empty_at(SLOT); - let kept: EphemeralArray = source.filter(|_| true); - assert_eq(kept.len(), 0); - }); + checks::check_filter_empty_source_gives_empty_dest::(); } #[test] unconstrained fn filter_none_match_gives_empty_dest() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: EphemeralArray = EphemeralArray::empty_at(SLOT); - source.push(1); - source.push(2); - source.push(3); - - let kept: EphemeralArray = source.filter(|_| false); - assert_eq(kept.len(), 0); - }); + checks::check_filter_none_match_gives_empty_dest::(); } #[test] unconstrained fn filter_works_with_multi_field_type() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: EphemeralArray = EphemeralArray::empty_at(SLOT); - source.push(MockStruct::new(1, 10)); - source.push(MockStruct::new(2, 20)); - source.push(MockStruct::new(3, 30)); - - let kept: EphemeralArray = source.filter(|s: MockStruct| s.a != 2); - - assert_eq(kept.len(), 2); - assert_eq(kept.get(0), MockStruct::new(1, 10)); - assert_eq(kept.get(1), MockStruct::new(3, 30)); - }); + checks::check_filter_works_with_multi_field_type::(); } #[test] unconstrained fn filter_results_are_isolated() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: EphemeralArray = EphemeralArray::empty_at(SLOT); - source.push(1); - source.push(2); - source.push(3); - source.push(4); - - // Each filter allocates its own fresh slot, so two filters of the same source do not clobber each other. - let odds: EphemeralArray = source.filter(|x| (x != 2) & (x != 4)); - let evens: EphemeralArray = source.filter(|x| (x != 1) & (x != 3)); - - assert(odds.slot != evens.slot, "each filter should land in a distinct slot"); - assert_eq(odds.len(), 2); - assert_eq(odds.get(0), 1); - assert_eq(odds.get(1), 3); - assert_eq(evens.len(), 2); - assert_eq(evens.get(0), 2); - assert_eq(evens.get(1), 4); - }); + checks::check_filter_results_are_isolated::(); } #[test] unconstrained fn any_is_true_when_an_element_matches() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::empty_at(SLOT); - array.push(1); - array.push(2); - array.push(3); - - assert(array.any(|x| x == 2)); - }); + checks::check_any_is_true_when_an_element_matches::(); } #[test] unconstrained fn any_is_false_when_no_element_matches() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::empty_at(SLOT); - array.push(1); - array.push(2); - array.push(3); - - assert(!array.any(|x| x == 9)); - }); + checks::check_any_is_false_when_no_element_matches::(); } #[test] unconstrained fn any_on_empty_array_is_false() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::empty_at(SLOT); - assert(!array.any(|_| true)); - }); + checks::check_any_on_empty_array_is_false::(); } #[test] unconstrained fn all_is_true_when_every_element_matches() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::empty_at(SLOT); - array.push(1); - array.push(2); - array.push(3); - - assert(array.all(|x| x != 0)); - }); + checks::check_all_is_true_when_every_element_matches::(); } #[test] unconstrained fn all_is_false_when_one_element_fails() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::empty_at(SLOT); - array.push(1); - array.push(2); - array.push(3); - - assert(!array.all(|x| x != 2)); - }); + checks::check_all_is_false_when_one_element_fails::(); } #[test] unconstrained fn all_on_empty_array_is_true() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::empty_at(SLOT); - assert(array.all(|_| false)); - }); + checks::check_all_on_empty_array_is_true::(); } #[test] unconstrained fn find_returns_first_matching_element() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::empty_at(SLOT); - array.push(1); - array.push(2); - array.push(3); - array.push(4); - - // Both 2 and 4 match; find must return the first one in order. - let found = array.find(|x| (x == 2) | (x == 4)); - assert(found.is_some()); - assert_eq(found.unwrap(), 2); - }); + checks::check_find_returns_first_matching_element::(); } #[test] unconstrained fn find_returns_none_when_no_element_matches() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::empty_at(SLOT); - array.push(1); - array.push(2); - array.push(3); - - assert(array.find(|x| x == 9).is_none()); - }); + checks::check_find_returns_none_when_no_element_matches::(); } #[test] unconstrained fn find_on_empty_array_is_none() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::empty_at(SLOT); - assert(array.find(|_| true).is_none()); - }); + checks::check_find_on_empty_array_is_none::(); } } diff --git a/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr index 572df64f87f3..52173cfcb4d2 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr @@ -1,3 +1,5 @@ +pub(crate) mod test_helpers; + use crate::oracle::random::random; use crate::protocol::traits::{Deserialize, Serialize}; use crate::protocol::utils::{reader::Reader, writer::Writer}; diff --git a/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr b/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr new file mode 100644 index 000000000000..24a53aac0832 --- /dev/null +++ b/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr @@ -0,0 +1,659 @@ +//! Shared test suite for [`OracleArray`] backends. +//! +//! Each check exercises one behavior of the shared [`OracleArray`] API and is generic over the [`ArrayOracle`] +//! backend, so the ephemeral and transient test suites instantiate the same checks against their respective oracles +//! via thin `#[test]` wrappers. Tests for backend-specific API (e.g. `empty_at` on ephemeral arrays, or `store` / +//! `load` on transient ones) live with the backend instead. + +use crate::oracle_array::{ArrayOracle, OracleArray}; +use crate::protocol::traits::Serialize; +use crate::test::helpers::test_environment::TestEnvironment; +use crate::test::mocks::MockStruct; + +pub(crate) global SLOT: Field = 1230; +pub(crate) global OTHER_SLOT: Field = 5670; + +pub(crate) unconstrained fn check_empty_array() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + assert_eq(array.len(), 0); + }); +} + +pub(crate) unconstrained fn check_empty_array_read() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + let _: Field = array.get(0); + }); +} + +pub(crate) unconstrained fn check_empty_array_pop() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + let _: Field = array.pop(); + }); +} + +pub(crate) unconstrained fn check_array_push() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + array.push(5); + + assert_eq(array.len(), 1); + assert_eq(array.get(0), 5); + }); +} + +pub(crate) unconstrained fn check_read_past_len() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + array.push(5); + + let _ = array.get(1); + }); +} + +pub(crate) unconstrained fn check_array_pop() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + array.push(5); + array.push(10); + + let popped: Field = array.pop(); + assert_eq(popped, 10); + assert_eq(array.len(), 1); + assert_eq(array.get(0), 5); + }); +} + +pub(crate) unconstrained fn check_array_set() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + array.push(5); + array.set(0, 99); + assert_eq(array.get(0), 99); + }); +} + +pub(crate) unconstrained fn check_array_remove_last() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + array.push(5); + array.remove(0); + assert_eq(array.len(), 0); + }); +} + +pub(crate) unconstrained fn check_array_remove_some() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + + array.push(7); + array.push(8); + array.push(9); + + assert_eq(array.len(), 3); + + array.remove(1); + + assert_eq(array.len(), 2); + assert_eq(array.get(0), 7); + assert_eq(array.get(1), 9); + }); +} + +pub(crate) unconstrained fn check_array_remove_all() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + + array.push(7); + array.push(8); + array.push(9); + + array.remove(1); + array.remove(1); + array.remove(0); + + assert_eq(array.len(), 0); + }); +} + +pub(crate) unconstrained fn check_for_each_called_with_all_elements() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + + array.push(4); + array.push(5); + array.push(6); + + 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))); + }); +} + +pub(crate) unconstrained fn check_for_each_remove_some() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::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 check_for_each_remove_all() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + + array.push(4); + array.push(5); + array.push(6); + + array.for_each(|index, _| { array.remove(index); }); + + assert_eq(array.len(), 0); + }); +} + +pub(crate) unconstrained fn check_different_slots_are_isolated() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array_a: OracleArray = OracleArray::at(SLOT); + let array_b: OracleArray = OracleArray::at(OTHER_SLOT); + + array_a.push(10); + array_a.push(20); + array_b.push(99); + + assert_eq(array_a.len(), 2); + assert_eq(array_a.get(0), 10); + assert_eq(array_a.get(1), 20); + + assert_eq(array_b.len(), 1); + assert_eq(array_b.get(0), 99); + }); +} + +pub(crate) unconstrained fn check_works_with_multi_field_type() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + + let a = MockStruct::new(5, 6); + let b = MockStruct::new(7, 8); + array.push(a); + array.push(b); + + assert_eq(array.len(), 2); + assert_eq(array.get(0), a); + assert_eq(array.get(1), b); + + let popped: MockStruct = array.pop(); + assert_eq(popped, b); + assert_eq(array.len(), 1); + }); +} + +pub(crate) unconstrained fn check_read_as_reconstructs_serialized_value() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + + let value = MockStruct::new(5, 6); + let serialized = value.serialize(); + for i in 0..serialized.len() { + array.push(serialized[i]); + } + + let reconstructed: MockStruct = array.read_as(); + assert_eq(reconstructed, value); + }); +} + +pub(crate) unconstrained fn check_read_as_rejects_length_mismatch() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + // MockStruct deserializes from 2 fields, so a single field is too short. + array.push(1); + + let _: MockStruct = array.read_as(); + }); +} + +pub(crate) unconstrained fn check_clear_returns_self() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT).clear(); + assert_eq(array.len(), 0); + + array.push(42); + assert_eq(array.len(), 1); + assert_eq(array.get(0), 42); + }); +} + +pub(crate) unconstrained fn check_clear_wipes_previous_data() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + array.push(1); + array.push(2); + array.push(3); + assert_eq(array.len(), 3); + + // Clear the same slot, previous data should be gone. + let fresh: OracleArray = OracleArray::at(SLOT).clear(); + assert_eq(fresh.len(), 0); + fresh.push(4); + assert_eq(fresh.get(0), 4); + }); +} + +pub(crate) unconstrained fn check_empty_allocates_distinct_slots() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let a: OracleArray = OracleArray::empty(); + let b: OracleArray = OracleArray::empty(); + + assert(a.slot != b.slot, "empty() should allocate a fresh slot each time"); + + a.push(1); + b.push(2); + assert_eq(a.len(), 1); + assert_eq(a.get(0), 1); + assert_eq(b.len(), 1); + assert_eq(b.get(0), 2); + }); +} + +pub(crate) unconstrained fn check_map_transforms_each_element() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: OracleArray = OracleArray::at(SLOT); + source.push(1); + source.push(2); + source.push(3); + + let doubled: OracleArray = source.map(|x| x * 2); + + assert_eq(doubled.len(), 3); + assert_eq(doubled.get(0), 2); + assert_eq(doubled.get(1), 4); + assert_eq(doubled.get(2), 6); + + // The source array is left untouched. + assert_eq(source.len(), 3); + assert_eq(source.get(0), 1); + }); +} + +pub(crate) unconstrained fn check_map_empty_source_gives_empty_dest() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: OracleArray = OracleArray::at(SLOT); + let mapped: OracleArray = source.map(|x| x * 2); + assert_eq(mapped.len(), 0); + }); +} + +pub(crate) unconstrained fn check_map_to_different_type() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: OracleArray = OracleArray::at(SLOT); + source.push(5); + source.push(7); + + let structs: OracleArray = source.map(|x| MockStruct::new(x, x + 1)); + + assert_eq(structs.len(), 2); + assert_eq(structs.get(0), MockStruct::new(5, 6)); + assert_eq(structs.get(1), MockStruct::new(7, 8)); + }); +} + +pub(crate) unconstrained fn check_map_results_are_isolated() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: OracleArray = OracleArray::at(SLOT); + source.push(1); + source.push(2); + source.push(3); + + // Each map allocates its own fresh slot, so two maps of the same source do not clobber each other. + let doubled: OracleArray = source.map(|x| x * 2); + let tripled: OracleArray = source.map(|x| x * 3); + + assert(doubled.slot != tripled.slot, "each map should land in a distinct slot"); + assert_eq(doubled.get(0), 2); + assert_eq(doubled.get(2), 6); + assert_eq(tripled.get(0), 3); + assert_eq(tripled.get(2), 9); + }); +} + +pub(crate) unconstrained fn check_filter_keeps_matching_elements_in_order() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: OracleArray = OracleArray::at(SLOT); + source.push(1); + source.push(2); + source.push(3); + source.push(2); + source.push(5); + + let kept: OracleArray = source.filter(|x| x != 2); + + assert_eq(kept.len(), 3); + assert_eq(kept.get(0), 1); + assert_eq(kept.get(1), 3); + assert_eq(kept.get(2), 5); + + // The source array is left untouched. + assert_eq(source.len(), 5); + assert_eq(source.get(1), 2); + }); +} + +pub(crate) unconstrained fn check_filter_empty_source_gives_empty_dest() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: OracleArray = OracleArray::at(SLOT); + let kept: OracleArray = source.filter(|_| true); + assert_eq(kept.len(), 0); + }); +} + +pub(crate) unconstrained fn check_filter_none_match_gives_empty_dest() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: OracleArray = OracleArray::at(SLOT); + source.push(1); + source.push(2); + source.push(3); + + let kept: OracleArray = source.filter(|_| false); + assert_eq(kept.len(), 0); + }); +} + +pub(crate) unconstrained fn check_filter_works_with_multi_field_type() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: OracleArray = OracleArray::at(SLOT); + source.push(MockStruct::new(1, 10)); + source.push(MockStruct::new(2, 20)); + source.push(MockStruct::new(3, 30)); + + let kept: OracleArray = source.filter(|s: MockStruct| s.a != 2); + + assert_eq(kept.len(), 2); + assert_eq(kept.get(0), MockStruct::new(1, 10)); + assert_eq(kept.get(1), MockStruct::new(3, 30)); + }); +} + +pub(crate) unconstrained fn check_filter_results_are_isolated() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let source: OracleArray = OracleArray::at(SLOT); + source.push(1); + source.push(2); + source.push(3); + source.push(4); + + // Each filter allocates its own fresh slot, so two filters of the same source do not clobber each other. + let odds: OracleArray = source.filter(|x| (x != 2) & (x != 4)); + let evens: OracleArray = source.filter(|x| (x != 1) & (x != 3)); + + assert(odds.slot != evens.slot, "each filter should land in a distinct slot"); + assert_eq(odds.len(), 2); + assert_eq(odds.get(0), 1); + assert_eq(odds.get(1), 3); + assert_eq(evens.len(), 2); + assert_eq(evens.get(0), 2); + assert_eq(evens.get(1), 4); + }); +} + +pub(crate) unconstrained fn check_any_is_true_when_an_element_matches() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + array.push(1); + array.push(2); + array.push(3); + + assert(array.any(|x| x == 2)); + }); +} + +pub(crate) unconstrained fn check_any_is_false_when_no_element_matches() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + array.push(1); + array.push(2); + array.push(3); + + assert(!array.any(|x| x == 9)); + }); +} + +pub(crate) unconstrained fn check_any_on_empty_array_is_false() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + assert(!array.any(|_| true)); + }); +} + +pub(crate) unconstrained fn check_all_is_true_when_every_element_matches() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + array.push(1); + array.push(2); + array.push(3); + + assert(array.all(|x| x != 0)); + }); +} + +pub(crate) unconstrained fn check_all_is_false_when_one_element_fails() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + array.push(1); + array.push(2); + array.push(3); + + assert(!array.all(|x| x != 2)); + }); +} + +pub(crate) unconstrained fn check_all_on_empty_array_is_true() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + assert(array.all(|_| false)); + }); +} + +pub(crate) unconstrained fn check_find_returns_first_matching_element() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + array.push(1); + array.push(2); + array.push(3); + array.push(4); + + // Both 2 and 4 match; find must return the first one in order. + let found = array.find(|x| (x == 2) | (x == 4)); + assert(found.is_some()); + assert_eq(found.unwrap(), 2); + }); +} + +pub(crate) unconstrained fn check_find_returns_none_when_no_element_matches() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + array.push(1); + array.push(2); + array.push(3); + + assert(array.find(|x| x == 9).is_none()); + }); +} + +pub(crate) unconstrained fn check_find_on_empty_array_is_none() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + assert(array.find(|_| true).is_none()); + }); +} diff --git a/noir-projects/aztec-nr/aztec/src/transient/mod.nr b/noir-projects/aztec-nr/aztec/src/transient/mod.nr index 204fb253624c..4079ae8652e5 100644 --- a/noir-projects/aztec-nr/aztec/src/transient/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/transient/mod.nr @@ -93,586 +93,200 @@ pub unconstrained fn delete(slot: Field) { } mod test { - use crate::protocol::traits::Serialize; + use crate::oracle_array::test_helpers::SLOT; + use crate::oracle_array::test_helpers as checks; use crate::test::helpers::test_environment::TestEnvironment; use crate::test::mocks::MockStruct; - use super::{delete, load, store, TransientArray}; - - global SLOT: Field = 1230; - global OTHER_SLOT: Field = 5670; + use super::{delete, load, store, TransientArray, TransientOracle}; #[test] unconstrained fn empty_array() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: TransientArray = TransientArray::at(SLOT); - assert_eq(array.len(), 0); - }); + checks::check_empty_array::(); } #[test(should_fail_with = "out of bounds")] unconstrained fn empty_array_read() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = TransientArray::at(SLOT); - let _: Field = array.get(0); - }); + checks::check_empty_array_read::(); } #[test(should_fail_with = "is empty")] unconstrained fn empty_array_pop() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = TransientArray::at(SLOT); - let _: Field = array.pop(); - }); + checks::check_empty_array_pop::(); } #[test] unconstrained fn array_push() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = TransientArray::at(SLOT); - array.push(5); - - assert_eq(array.len(), 1); - assert_eq(array.get(0), 5); - }); + checks::check_array_push::(); } #[test(should_fail_with = "out of bounds")] unconstrained fn read_past_len() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = TransientArray::at(SLOT); - array.push(5); - - let _ = array.get(1); - }); + checks::check_read_past_len::(); } #[test] - unconstrained fn set_and_remove() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = TransientArray::at(SLOT); - array.push(7); - array.push(8); - array.push(9); - array.set(0, 99); - assert_eq(array.get(0), 99); - array.remove(1); - assert_eq(array.len(), 2); - assert_eq(array.get(0), 99); - assert_eq(array.get(1), 9); - }); + unconstrained fn array_pop() { + checks::check_array_pop::(); } #[test] - unconstrained fn array_pop() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = TransientArray::at(SLOT); - array.push(5); - array.push(10); - - let popped: Field = array.pop(); - assert_eq(popped, 10); - assert_eq(array.len(), 1); - assert_eq(array.get(0), 5); - }); + unconstrained fn array_set() { + checks::check_array_set::(); } #[test] unconstrained fn array_remove_last() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = TransientArray::at(SLOT); - array.push(5); - array.remove(0); - assert_eq(array.len(), 0); - }); + checks::check_array_remove_last::(); } #[test] unconstrained fn array_remove_some() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = TransientArray::at(SLOT); - - array.push(7); - array.push(8); - array.push(9); - - assert_eq(array.len(), 3); - - array.remove(1); - - assert_eq(array.len(), 2); - assert_eq(array.get(0), 7); - assert_eq(array.get(1), 9); - }); + checks::check_array_remove_some::(); } #[test] unconstrained fn array_remove_all() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = TransientArray::at(SLOT); - - array.push(7); - array.push(8); - array.push(9); - - array.remove(1); - array.remove(1); - array.remove(0); - - assert_eq(array.len(), 0); - }); + checks::check_array_remove_all::(); } #[test] unconstrained fn for_each_called_with_all_elements() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = TransientArray::at(SLOT); - - array.push(4); - array.push(5); - array.push(6); - - 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))); - }); + checks::check_for_each_called_with_all_elements::(); } #[test] unconstrained fn for_each_remove_some() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = TransientArray::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); - }); + checks::check_for_each_remove_some::(); } #[test] unconstrained fn for_each_remove_all() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array = TransientArray::at(SLOT); - - array.push(4); - array.push(5); - array.push(6); - - array.for_each(|index, _| { array.remove(index); }); - - assert_eq(array.len(), 0); - }); + checks::check_for_each_remove_all::(); } #[test] unconstrained fn different_slots_are_isolated() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array_a = TransientArray::at(SLOT); - let array_b = TransientArray::at(OTHER_SLOT); - - array_a.push(10); - array_a.push(20); - array_b.push(99); - - assert_eq(array_a.len(), 2); - assert_eq(array_a.get(0), 10); - assert_eq(array_a.get(1), 20); - - assert_eq(array_b.len(), 1); - assert_eq(array_b.get(0), 99); - }); + checks::check_different_slots_are_isolated::(); } #[test] unconstrained fn works_with_multi_field_type() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: TransientArray = TransientArray::at(SLOT); - - let a = MockStruct::new(5, 6); - let b = MockStruct::new(7, 8); - array.push(a); - array.push(b); - - assert_eq(array.len(), 2); - assert_eq(array.get(0), a); - assert_eq(array.get(1), b); - - let popped: MockStruct = array.pop(); - assert_eq(popped, b); - assert_eq(array.len(), 1); - }); + checks::check_works_with_multi_field_type::(); } #[test] unconstrained fn read_as_reconstructs_serialized_value() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: TransientArray = TransientArray::at(SLOT); - - let value = MockStruct::new(5, 6); - let serialized = value.serialize(); - for i in 0..serialized.len() { - array.push(serialized[i]); - } - - let reconstructed: MockStruct = array.read_as(); - assert_eq(reconstructed, value); - }); + checks::check_read_as_reconstructs_serialized_value::(); } #[test(should_fail_with = "length mismatch")] unconstrained fn read_as_rejects_length_mismatch() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: TransientArray = TransientArray::at(SLOT); - // MockStruct deserializes from 2 fields, so a single field is too short. - array.push(1); - - let _: MockStruct = array.read_as(); - }); + checks::check_read_as_rejects_length_mismatch::(); } #[test] unconstrained fn clear_returns_self() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: TransientArray = TransientArray::at(SLOT).clear(); - assert_eq(array.len(), 0); - - array.push(42); - assert_eq(array.len(), 1); - assert_eq(array.get(0), 42); - }); + checks::check_clear_returns_self::(); } #[test] unconstrained fn clear_wipes_previous_data() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: TransientArray = TransientArray::at(SLOT); - array.push(1); - array.push(2); - array.push(3); - assert_eq(array.len(), 3); - - let fresh: TransientArray = TransientArray::at(SLOT).clear(); - assert_eq(fresh.len(), 0); - fresh.push(4); - assert_eq(fresh.get(0), 4); - }); + checks::check_clear_wipes_previous_data::(); } #[test] unconstrained fn empty_allocates_distinct_slots() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let a: TransientArray = TransientArray::empty(); - let b: TransientArray = TransientArray::empty(); - - assert(a.slot != b.slot, "empty() should allocate a fresh slot each time"); - - a.push(1); - b.push(2); - assert_eq(a.len(), 1); - assert_eq(a.get(0), 1); - assert_eq(b.len(), 1); - assert_eq(b.get(0), 2); - }); + checks::check_empty_allocates_distinct_slots::(); } #[test] unconstrained fn map_transforms_each_element() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: TransientArray = TransientArray::at(SLOT); - source.push(1); - source.push(2); - source.push(3); - - let doubled: TransientArray = source.map(|x| x * 2); - - assert_eq(doubled.len(), 3); - assert_eq(doubled.get(0), 2); - assert_eq(doubled.get(1), 4); - assert_eq(doubled.get(2), 6); - - // The source array is left untouched. - assert_eq(source.len(), 3); - assert_eq(source.get(0), 1); - }); + checks::check_map_transforms_each_element::(); } #[test] unconstrained fn map_empty_source_gives_empty_dest() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: TransientArray = TransientArray::at(SLOT); - let mapped: TransientArray = source.map(|x| x * 2); - assert_eq(mapped.len(), 0); - }); + checks::check_map_empty_source_gives_empty_dest::(); } #[test] unconstrained fn map_to_different_type() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: TransientArray = TransientArray::at(SLOT); - source.push(5); - source.push(7); - - let structs: TransientArray = source.map(|x| MockStruct::new(x, x + 1)); - - assert_eq(structs.len(), 2); - assert_eq(structs.get(0), MockStruct::new(5, 6)); - assert_eq(structs.get(1), MockStruct::new(7, 8)); - }); + checks::check_map_to_different_type::(); } #[test] unconstrained fn map_results_are_isolated() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: TransientArray = TransientArray::at(SLOT); - source.push(1); - source.push(2); - source.push(3); - - // Each map allocates its own fresh slot, so two maps of the same source do not clobber each other. - let doubled: TransientArray = source.map(|x| x * 2); - let tripled: TransientArray = source.map(|x| x * 3); - - assert(doubled.slot != tripled.slot, "each map should land in a distinct slot"); - assert_eq(doubled.get(0), 2); - assert_eq(doubled.get(2), 6); - assert_eq(tripled.get(0), 3); - assert_eq(tripled.get(2), 9); - }); + checks::check_map_results_are_isolated::(); } #[test] unconstrained fn filter_keeps_matching_elements_in_order() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: TransientArray = TransientArray::at(SLOT); - source.push(1); - source.push(2); - source.push(3); - source.push(2); - source.push(5); - - let kept: TransientArray = source.filter(|x| x != 2); - - assert_eq(kept.len(), 3); - assert_eq(kept.get(0), 1); - assert_eq(kept.get(1), 3); - assert_eq(kept.get(2), 5); - - // The source array is left untouched. - assert_eq(source.len(), 5); - assert_eq(source.get(1), 2); - }); + checks::check_filter_keeps_matching_elements_in_order::(); } #[test] unconstrained fn filter_empty_source_gives_empty_dest() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: TransientArray = TransientArray::at(SLOT); - let kept: TransientArray = source.filter(|_| true); - assert_eq(kept.len(), 0); - }); + checks::check_filter_empty_source_gives_empty_dest::(); } #[test] unconstrained fn filter_none_match_gives_empty_dest() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: TransientArray = TransientArray::at(SLOT); - source.push(1); - source.push(2); - source.push(3); - - let kept: TransientArray = source.filter(|_| false); - assert_eq(kept.len(), 0); - }); + checks::check_filter_none_match_gives_empty_dest::(); } #[test] unconstrained fn filter_works_with_multi_field_type() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: TransientArray = TransientArray::at(SLOT); - source.push(MockStruct::new(1, 10)); - source.push(MockStruct::new(2, 20)); - source.push(MockStruct::new(3, 30)); - - let kept: TransientArray = source.filter(|s: MockStruct| s.a != 2); - - assert_eq(kept.len(), 2); - assert_eq(kept.get(0), MockStruct::new(1, 10)); - assert_eq(kept.get(1), MockStruct::new(3, 30)); - }); + checks::check_filter_works_with_multi_field_type::(); } #[test] unconstrained fn filter_results_are_isolated() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let source: TransientArray = TransientArray::at(SLOT); - source.push(1); - source.push(2); - source.push(3); - source.push(4); - - // Each filter allocates its own fresh slot, so two filters of the same source do not clobber each other. - let odds: TransientArray = source.filter(|x| (x != 2) & (x != 4)); - let evens: TransientArray = source.filter(|x| (x != 1) & (x != 3)); - - assert(odds.slot != evens.slot, "each filter should land in a distinct slot"); - assert_eq(odds.len(), 2); - assert_eq(odds.get(0), 1); - assert_eq(odds.get(1), 3); - assert_eq(evens.len(), 2); - assert_eq(evens.get(0), 2); - assert_eq(evens.get(1), 4); - }); + checks::check_filter_results_are_isolated::(); } #[test] unconstrained fn any_is_true_when_an_element_matches() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: TransientArray = TransientArray::at(SLOT); - array.push(1); - array.push(2); - array.push(3); - - assert(array.any(|x| x == 2)); - }); + checks::check_any_is_true_when_an_element_matches::(); } #[test] unconstrained fn any_is_false_when_no_element_matches() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: TransientArray = TransientArray::at(SLOT); - array.push(1); - array.push(2); - array.push(3); - - assert(!array.any(|x| x == 9)); - }); + checks::check_any_is_false_when_no_element_matches::(); } #[test] unconstrained fn any_on_empty_array_is_false() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: TransientArray = TransientArray::at(SLOT); - assert(!array.any(|_| true)); - }); + checks::check_any_on_empty_array_is_false::(); } #[test] unconstrained fn all_is_true_when_every_element_matches() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: TransientArray = TransientArray::at(SLOT); - array.push(1); - array.push(2); - array.push(3); - - assert(array.all(|x| x != 0)); - }); + checks::check_all_is_true_when_every_element_matches::(); } #[test] unconstrained fn all_is_false_when_one_element_fails() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: TransientArray = TransientArray::at(SLOT); - array.push(1); - array.push(2); - array.push(3); - - assert(!array.all(|x| x != 2)); - }); + checks::check_all_is_false_when_one_element_fails::(); } #[test] unconstrained fn all_on_empty_array_is_true() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: TransientArray = TransientArray::at(SLOT); - assert(array.all(|_| false)); - }); + checks::check_all_on_empty_array_is_true::(); } #[test] unconstrained fn find_returns_first_matching_element() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: TransientArray = TransientArray::at(SLOT); - array.push(1); - array.push(2); - array.push(3); - array.push(4); - - // Both 2 and 4 match; find must return the first one in order. - let found = array.find(|x| (x == 2) | (x == 4)); - assert(found.is_some()); - assert_eq(found.unwrap(), 2); - }); + checks::check_find_returns_first_matching_element::(); } #[test] unconstrained fn find_returns_none_when_no_element_matches() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: TransientArray = TransientArray::at(SLOT); - array.push(1); - array.push(2); - array.push(3); - - assert(array.find(|x| x == 9).is_none()); - }); + checks::check_find_returns_none_when_no_element_matches::(); } #[test] unconstrained fn find_on_empty_array_is_none() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: TransientArray = TransientArray::at(SLOT); - assert(array.find(|_| true).is_none()); - }); + checks::check_find_on_empty_array_is_none::(); } #[test] From 0b346bf9156d72eee4f697264ed780438c316307 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Wed, 10 Jun 2026 10:24:12 +0000 Subject: [PATCH 04/31] feat: empty_at for transient arrays Moves empty_at from the ephemeral-only impl into the shared OracleArray impl, making it available on TransientArray as well. Its doc notes that for cross-frame backends it wipes data other frames may have written. The empty_at test moves to the shared suite and runs on both backends. --- .../aztec-nr/aztec/src/ephemeral/mod.nr | 25 ++----------------- .../aztec-nr/aztec/src/oracle_array/mod.nr | 19 ++++++++++---- .../aztec/src/oracle_array/test_helpers.nr | 15 +++++++++++ .../aztec-nr/aztec/src/transient/mod.nr | 5 ++++ 4 files changed, 36 insertions(+), 28 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr index 6ae5ca8a0ed6..ad3dcb066d9c 100644 --- a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr @@ -57,22 +57,9 @@ impl ArrayOracle for EphemeralOracle { } } -impl OracleArray { - /// Returns an empty ephemeral array at the given slot, clearing any pre-existing data. - /// - /// This is deliberately ephemeral-only: a [`TransientArray`](crate::transient::TransientArray) slot may hold data - /// written by another frame of the same contract, so wiping a known slot there stays explicit via - /// `TransientArray::at(slot).clear()`. - pub unconstrained fn empty_at(slot: Field) -> Self { - Self::at(slot).clear() - } -} - mod test { - use crate::oracle_array::test_helpers::SLOT; use crate::oracle_array::test_helpers as checks; - use crate::test::helpers::test_environment::TestEnvironment; - use super::{EphemeralArray, EphemeralOracle}; + use super::EphemeralOracle; #[test] unconstrained fn empty_array() { @@ -161,15 +148,7 @@ mod test { #[test] unconstrained fn empty_at_wipes_previous_data() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: EphemeralArray = EphemeralArray::at(SLOT); - array.push(1); - assert_eq(array.len(), 1); - - let fresh: EphemeralArray = EphemeralArray::empty_at(SLOT); - assert_eq(fresh.len(), 0); - }); + checks::check_empty_at_wipes_previous_data::(); } #[test] diff --git a/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr index 52173cfcb4d2..bd1cfe75adc7 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr @@ -56,13 +56,21 @@ where Self { slot } } + /// Returns an empty array at the given slot, clearing any pre-existing data. + /// + /// For backends whose arrays are visible beyond a single call frame (e.g. transient arrays), this wipes data + /// other frames of the same contract may have written at the slot. + pub unconstrained fn empty_at(slot: Field) -> Self { + Self::at(slot).clear() + } + /// Returns an empty array at a fresh, randomly allocated slot. /// /// Use this when the caller does not need a specific slot: the random slot is isolated from every other array of - /// the same backend with overwhelming probability. Prefer [`OracleArray::at`] when the slot must be a known value - /// (e.g. one shared with an oracle or another call frame). + /// the same backend with overwhelming probability. Prefer [`OracleArray::empty_at`] when the slot must be a known + /// value (e.g. one shared with an oracle or another call frame). pub unconstrained fn empty() -> Self { - Self::at(random()).clear() + Self::empty_at(random()) } /// Returns the number of elements stored in the array. @@ -111,8 +119,9 @@ where Oracle::remove_oracle(self.slot, index); } - /// Removes all elements from the array and returns self for chaining (e.g. `OracleArray::at(slot).clear()` to - /// get a guaranteed-empty array at a given slot). + /// Removes all elements from the array and returns self for chaining. + /// + /// Prefer [`OracleArray::empty_at`] when the intent is to start with a fresh array. pub unconstrained fn clear(self) -> Self { Oracle::clear_oracle(self.slot); self diff --git a/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr b/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr index 24a53aac0832..e76130764008 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr @@ -657,3 +657,18 @@ where assert(array.find(|_| true).is_none()); }); } + +pub(crate) unconstrained fn check_empty_at_wipes_previous_data() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let array: OracleArray = OracleArray::at(SLOT); + array.push(1); + assert_eq(array.len(), 1); + + let fresh: OracleArray = OracleArray::empty_at(SLOT); + assert_eq(fresh.len(), 0); + }); +} diff --git a/noir-projects/aztec-nr/aztec/src/transient/mod.nr b/noir-projects/aztec-nr/aztec/src/transient/mod.nr index 4079ae8652e5..c4be81762e8a 100644 --- a/noir-projects/aztec-nr/aztec/src/transient/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/transient/mod.nr @@ -289,6 +289,11 @@ mod test { checks::check_find_on_empty_array_is_none::(); } + #[test] + unconstrained fn empty_at_wipes_previous_data() { + checks::check_empty_at_wipes_previous_data::(); + } + #[test] unconstrained fn store_and_load() { let env = TestEnvironment::new(); From 0f9f79fe3eac430733a759b7a075a6197ea60a81 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Wed, 10 Jun 2026 10:35:23 +0000 Subject: [PATCH 05/31] feat: store/load/delete for ephemeral arrays, symmetric array APIs Generic store/load/delete cores live in oracle_array; ephemeral and transient expose them via thin module-level wrappers, so both backends now have identical public surfaces (alias + oracle marker + store/load/delete). The store/load test bodies move to the shared suite; each backend keeps a store_and_load smoke test through its own wrappers. Also fixes the stale test_helpers doc that still described empty_at and store/load as backend-specific. --- .../aztec-nr/aztec/src/ephemeral/mod.nr | 65 +++++++++++++- .../aztec-nr/aztec/src/oracle_array/mod.nr | 34 ++++++++ .../aztec/src/oracle_array/test_helpers.nr | 84 +++++++++++++++++-- .../aztec-nr/aztec/src/transient/mod.nr | 58 +++---------- 4 files changed, 187 insertions(+), 54 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr index ad3dcb066d9c..006ad55d2240 100644 --- a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr @@ -1,5 +1,6 @@ use crate::oracle::ephemeral; use crate::oracle_array::{ArrayOracle, OracleArray}; +use crate::protocol::traits::{Deserialize, Serialize}; /// A dynamically sized array that exists only during a single contract call frame. /// @@ -57,9 +58,32 @@ impl ArrayOracle for EphemeralOracle { } } +/// Stores a single value at `slot`, overwriting any value previously stored there. +pub unconstrained fn store(slot: Field, value: T) +where + T: Serialize, +{ + crate::oracle_array::store::(slot, value) +} + +/// Returns the value previously stored at `slot` with [`store`], or `Option::none()` if the slot holds no value. +pub unconstrained fn load(slot: Field) -> Option +where + T: Deserialize, +{ + crate::oracle_array::load::(slot) +} + +/// Deletes the value stored at `slot`. Does nothing if the slot is already empty. +pub unconstrained fn delete(slot: Field) { + crate::oracle_array::delete::(slot) +} + mod test { + use crate::oracle_array::test_helpers::SLOT; use crate::oracle_array::test_helpers as checks; - use super::EphemeralOracle; + use crate::test::helpers::test_environment::TestEnvironment; + use super::{EphemeralOracle, load, store}; #[test] unconstrained fn empty_array() { @@ -255,4 +279,43 @@ mod test { unconstrained fn find_on_empty_array_is_none() { checks::check_find_on_empty_array_is_none::(); } + + #[test] + unconstrained fn store_and_load() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + store(SLOT, 42); + assert_eq(load(SLOT), Option::some(42)); + }); + } + + #[test] + unconstrained fn load_empty_returns_none() { + checks::check_load_empty_returns_none::(); + } + + #[test] + unconstrained fn store_overwrites() { + checks::check_store_overwrites::(); + } + + #[test] + unconstrained fn delete_removes_value() { + checks::check_delete_removes_value::(); + } + + #[test] + unconstrained fn delete_empty_is_noop() { + checks::check_delete_empty_is_noop::(); + } + + #[test] + unconstrained fn store_and_load_multi_field_type() { + checks::check_store_and_load_multi_field_type::(); + } + + #[test] + unconstrained fn stored_value_is_visible_as_a_length_one_array() { + checks::check_stored_value_is_visible_as_a_length_one_array::(); + } } diff --git a/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr index bd1cfe75adc7..39851bbcbe83 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr @@ -274,3 +274,37 @@ impl Deserialize for OracleArray { Self { slot: reader.read() } } } + +/// Stores a single value at `slot`, overwriting any value previously stored there. +/// +/// The value is stored as a length-one array at the slot: read it back with [`load`] and remove it with [`delete`]. +pub(crate) unconstrained fn store(slot: Field, value: T) +where + T: Serialize, + Oracle: ArrayOracle, +{ + let array: OracleArray = OracleArray::at(slot); + array.clear().push(value); +} + +/// Returns the value previously stored at `slot` with [`store`], or `Option::none()` if the slot holds no value. +pub(crate) unconstrained fn load(slot: Field) -> Option +where + T: Deserialize, + Oracle: ArrayOracle, +{ + let array: OracleArray = OracleArray::at(slot); + if array.len() == 0 { + Option::none() + } else { + Option::some(array.get(0)) + } +} + +/// Deletes the value stored at `slot`. Does nothing if the slot is already empty. +pub(crate) unconstrained fn delete(slot: Field) +where + Oracle: ArrayOracle, +{ + Oracle::clear_oracle(slot) +} diff --git a/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr b/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr index e76130764008..be1c9536c7c9 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr @@ -1,11 +1,12 @@ //! Shared test suite for [`OracleArray`] backends. //! -//! Each check exercises one behavior of the shared [`OracleArray`] API and is generic over the [`ArrayOracle`] -//! backend, so the ephemeral and transient test suites instantiate the same checks against their respective oracles -//! via thin `#[test]` wrappers. Tests for backend-specific API (e.g. `empty_at` on ephemeral arrays, or `store` / -//! `load` on transient ones) live with the backend instead. +//! Each check exercises one behavior of the shared [`OracleArray`] API (or of the generic `store` / `load` / `delete` +//! helpers) and is generic over the [`ArrayOracle`] backend, so the ephemeral and transient test suites instantiate +//! the same checks against their respective oracles via thin `#[test]` wrappers. Each backend module additionally +//! keeps a smoke test that routes through its own public helpers (e.g. `transient::store` / `transient::load`) to +//! cover the delegation wiring. -use crate::oracle_array::{ArrayOracle, OracleArray}; +use crate::oracle_array::{ArrayOracle, delete, load, OracleArray, store}; use crate::protocol::traits::Serialize; use crate::test::helpers::test_environment::TestEnvironment; use crate::test::mocks::MockStruct; @@ -672,3 +673,76 @@ where assert_eq(fresh.len(), 0); }); } + +pub(crate) unconstrained fn check_load_empty_returns_none() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let value: Option = load::(SLOT); + assert_eq(value, Option::none()); + }); +} + +pub(crate) unconstrained fn check_store_overwrites() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + store::(SLOT, 1); + store::(SLOT, 2); + assert_eq(load::(SLOT), Option::some(2)); + }); +} + +pub(crate) unconstrained fn check_delete_removes_value() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + store::(SLOT, 42); + delete::(SLOT); + let value: Option = load::(SLOT); + assert_eq(value, Option::none()); + }); +} + +pub(crate) unconstrained fn check_delete_empty_is_noop() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + delete::(SLOT); + let value: Option = load::(SLOT); + assert_eq(value, Option::none()); + }); +} + +pub(crate) unconstrained fn check_store_and_load_multi_field_type() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + let value = MockStruct::new(5, 6); + store::(SLOT, value); + assert_eq(load::(SLOT), Option::some(value)); + }); +} + +pub(crate) unconstrained fn check_stored_value_is_visible_as_a_length_one_array() +where + Oracle: ArrayOracle, +{ + let env = TestEnvironment::new(); + env.utility_context(|_| { + store::(SLOT, 42); + let array: OracleArray = OracleArray::at(SLOT); + assert_eq(array.len(), 1); + assert_eq(array.get(0), 42); + }); +} diff --git a/noir-projects/aztec-nr/aztec/src/transient/mod.nr b/noir-projects/aztec-nr/aztec/src/transient/mod.nr index c4be81762e8a..ad30325b2518 100644 --- a/noir-projects/aztec-nr/aztec/src/transient/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/transient/mod.nr @@ -70,8 +70,7 @@ pub unconstrained fn store(slot: Field, value: T) where T: Serialize, { - let array: TransientArray = TransientArray::at(slot); - array.clear().push(value); + crate::oracle_array::store::(slot, value) } /// Returns the value previously stored at `slot` with [`store`], or `Option::none()` if the slot holds no value. @@ -79,25 +78,19 @@ pub unconstrained fn load(slot: Field) -> Option where T: Deserialize, { - let array: TransientArray = TransientArray::at(slot); - if array.len() == 0 { - Option::none() - } else { - Option::some(array.get(0)) - } + crate::oracle_array::load::(slot) } /// Deletes the value stored at `slot`. Does nothing if the slot is already empty. pub unconstrained fn delete(slot: Field) { - transient::clear_oracle(slot); + crate::oracle_array::delete::(slot) } mod test { use crate::oracle_array::test_helpers::SLOT; use crate::oracle_array::test_helpers as checks; use crate::test::helpers::test_environment::TestEnvironment; - use crate::test::mocks::MockStruct; - use super::{delete, load, store, TransientArray, TransientOracle}; + use super::{load, store, TransientOracle}; #[test] unconstrained fn empty_array() { @@ -305,62 +298,31 @@ mod test { #[test] unconstrained fn load_empty_returns_none() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let value: Option = load(SLOT); - assert_eq(value, Option::none()); - }); + checks::check_load_empty_returns_none::(); } #[test] unconstrained fn store_overwrites() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - store(SLOT, 1); - store(SLOT, 2); - assert_eq(load(SLOT), Option::some(2)); - }); + checks::check_store_overwrites::(); } #[test] unconstrained fn delete_removes_value() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - store(SLOT, 42); - delete(SLOT); - let value: Option = load(SLOT); - assert_eq(value, Option::none()); - }); + checks::check_delete_removes_value::(); } #[test] unconstrained fn delete_empty_is_noop() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - delete(SLOT); - let value: Option = load(SLOT); - assert_eq(value, Option::none()); - }); + checks::check_delete_empty_is_noop::(); } #[test] unconstrained fn store_and_load_multi_field_type() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - let value = MockStruct::new(5, 6); - store(SLOT, value); - assert_eq(load(SLOT), Option::some(value)); - }); + checks::check_store_and_load_multi_field_type::(); } #[test] unconstrained fn stored_value_is_visible_as_a_length_one_array() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - store(SLOT, 42); - let array: TransientArray = TransientArray::at(SLOT); - assert_eq(array.len(), 1); - assert_eq(array.get(0), 42); - }); + checks::check_stored_value_is_visible_as_a_length_one_array::(); } } From c4857f976badc5a4079268687b932785a3fe6d6d Mon Sep 17 00:00:00 2001 From: mverzilli Date: Wed, 10 Jun 2026 11:01:07 +0000 Subject: [PATCH 06/31] refactor: generate per-backend OracleArray tests with a comptime macro Backend test modules no longer list one wrapper per shared check. Annotating a test module with ephemeral_oracle_array_tests / transient_oracle_array_tests reflects over test_helpers and emits one #[test] per unconstrained check with that backend's oracle swapped in, so new checks automatically run against every backend and the suites cannot drift. should_fail_with messages come from a single manifest in test_helpers; an unregistered failing check fails loudly rather than silently. Generated test names match the previous hand-written ones. --- .../aztec-nr/aztec/src/ephemeral/mod.nr | 229 +----------------- .../aztec/src/oracle_array/test_helpers.nr | 154 ++++++++---- .../aztec-nr/aztec/src/transient/mod.nr | 229 +----------------- 3 files changed, 108 insertions(+), 504 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr index 006ad55d2240..e444f29198ea 100644 --- a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr @@ -79,206 +79,11 @@ pub unconstrained fn delete(slot: Field) { crate::oracle_array::delete::(slot) } +#[crate::oracle_array::test_helpers::ephemeral_oracle_array_tests] mod test { use crate::oracle_array::test_helpers::SLOT; - use crate::oracle_array::test_helpers as checks; use crate::test::helpers::test_environment::TestEnvironment; - use super::{EphemeralOracle, load, store}; - - #[test] - unconstrained fn empty_array() { - checks::check_empty_array::(); - } - - #[test(should_fail_with = "out of bounds")] - unconstrained fn empty_array_read() { - checks::check_empty_array_read::(); - } - - #[test(should_fail_with = "is empty")] - unconstrained fn empty_array_pop() { - checks::check_empty_array_pop::(); - } - - #[test] - unconstrained fn array_push() { - checks::check_array_push::(); - } - - #[test(should_fail_with = "out of bounds")] - unconstrained fn read_past_len() { - checks::check_read_past_len::(); - } - - #[test] - unconstrained fn array_pop() { - checks::check_array_pop::(); - } - - #[test] - unconstrained fn array_set() { - checks::check_array_set::(); - } - - #[test] - unconstrained fn array_remove_last() { - checks::check_array_remove_last::(); - } - - #[test] - unconstrained fn array_remove_some() { - checks::check_array_remove_some::(); - } - - #[test] - unconstrained fn array_remove_all() { - checks::check_array_remove_all::(); - } - - #[test] - unconstrained fn for_each_called_with_all_elements() { - checks::check_for_each_called_with_all_elements::(); - } - - #[test] - unconstrained fn for_each_remove_some() { - checks::check_for_each_remove_some::(); - } - - #[test] - unconstrained fn for_each_remove_all() { - checks::check_for_each_remove_all::(); - } - - #[test] - unconstrained fn different_slots_are_isolated() { - checks::check_different_slots_are_isolated::(); - } - - #[test] - unconstrained fn works_with_multi_field_type() { - checks::check_works_with_multi_field_type::(); - } - - #[test] - unconstrained fn read_as_reconstructs_serialized_value() { - checks::check_read_as_reconstructs_serialized_value::(); - } - - #[test(should_fail_with = "length mismatch")] - unconstrained fn read_as_rejects_length_mismatch() { - checks::check_read_as_rejects_length_mismatch::(); - } - - #[test] - unconstrained fn empty_at_wipes_previous_data() { - checks::check_empty_at_wipes_previous_data::(); - } - - #[test] - unconstrained fn clear_returns_self() { - checks::check_clear_returns_self::(); - } - - #[test] - unconstrained fn clear_wipes_previous_data() { - checks::check_clear_wipes_previous_data::(); - } - - #[test] - unconstrained fn empty_allocates_distinct_slots() { - checks::check_empty_allocates_distinct_slots::(); - } - - #[test] - unconstrained fn map_transforms_each_element() { - checks::check_map_transforms_each_element::(); - } - - #[test] - unconstrained fn map_empty_source_gives_empty_dest() { - checks::check_map_empty_source_gives_empty_dest::(); - } - - #[test] - unconstrained fn map_to_different_type() { - checks::check_map_to_different_type::(); - } - - #[test] - unconstrained fn map_results_are_isolated() { - checks::check_map_results_are_isolated::(); - } - - #[test] - unconstrained fn filter_keeps_matching_elements_in_order() { - checks::check_filter_keeps_matching_elements_in_order::(); - } - - #[test] - unconstrained fn filter_empty_source_gives_empty_dest() { - checks::check_filter_empty_source_gives_empty_dest::(); - } - - #[test] - unconstrained fn filter_none_match_gives_empty_dest() { - checks::check_filter_none_match_gives_empty_dest::(); - } - - #[test] - unconstrained fn filter_works_with_multi_field_type() { - checks::check_filter_works_with_multi_field_type::(); - } - - #[test] - unconstrained fn filter_results_are_isolated() { - checks::check_filter_results_are_isolated::(); - } - - #[test] - unconstrained fn any_is_true_when_an_element_matches() { - checks::check_any_is_true_when_an_element_matches::(); - } - - #[test] - unconstrained fn any_is_false_when_no_element_matches() { - checks::check_any_is_false_when_no_element_matches::(); - } - - #[test] - unconstrained fn any_on_empty_array_is_false() { - checks::check_any_on_empty_array_is_false::(); - } - - #[test] - unconstrained fn all_is_true_when_every_element_matches() { - checks::check_all_is_true_when_every_element_matches::(); - } - - #[test] - unconstrained fn all_is_false_when_one_element_fails() { - checks::check_all_is_false_when_one_element_fails::(); - } - - #[test] - unconstrained fn all_on_empty_array_is_true() { - checks::check_all_on_empty_array_is_true::(); - } - - #[test] - unconstrained fn find_returns_first_matching_element() { - checks::check_find_returns_first_matching_element::(); - } - - #[test] - unconstrained fn find_returns_none_when_no_element_matches() { - checks::check_find_returns_none_when_no_element_matches::(); - } - - #[test] - unconstrained fn find_on_empty_array_is_none() { - checks::check_find_on_empty_array_is_none::(); - } + use super::{load, store}; #[test] unconstrained fn store_and_load() { @@ -288,34 +93,4 @@ mod test { assert_eq(load(SLOT), Option::some(42)); }); } - - #[test] - unconstrained fn load_empty_returns_none() { - checks::check_load_empty_returns_none::(); - } - - #[test] - unconstrained fn store_overwrites() { - checks::check_store_overwrites::(); - } - - #[test] - unconstrained fn delete_removes_value() { - checks::check_delete_removes_value::(); - } - - #[test] - unconstrained fn delete_empty_is_noop() { - checks::check_delete_empty_is_noop::(); - } - - #[test] - unconstrained fn store_and_load_multi_field_type() { - checks::check_store_and_load_multi_field_type::(); - } - - #[test] - unconstrained fn stored_value_is_visible_as_a_length_one_array() { - checks::check_stored_value_is_visible_as_a_length_one_array::(); - } } diff --git a/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr b/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr index be1c9536c7c9..6e36a38dc0de 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr @@ -1,10 +1,13 @@ //! Shared test suite for [`OracleArray`] backends. //! -//! Each check exercises one behavior of the shared [`OracleArray`] API (or of the generic `store` / `load` / `delete` -//! helpers) and is generic over the [`ArrayOracle`] backend, so the ephemeral and transient test suites instantiate -//! the same checks against their respective oracles via thin `#[test]` wrappers. Each backend module additionally -//! keeps a smoke test that routes through its own public helpers (e.g. `transient::store` / `transient::load`) to -//! cover the delegation wiring. +//! Every `unconstrained` function in this module checks one behavior of the shared [`OracleArray`] API (or of the +//! generic `store` / `load` / `delete` helpers) and is generic over the [`ArrayOracle`] backend. Backend test modules +//! do not reference the checks one by one: annotating a module with `ephemeral_oracle_array_tests` / +//! `transient_oracle_array_tests` generates one `#[test]` per check with that backend's oracle swapped in, so a check +//! added here automatically runs against every backend. Checks that are expected to fail must be registered in +//! `should_fail_message`, which supplies the message for the generated `#[test(should_fail_with = ...)]` attribute. +//! Each backend module additionally keeps a hand-written smoke test routing through its own public helpers (e.g. +//! `transient::store` / `transient::load`) to cover the delegation wiring. use crate::oracle_array::{ArrayOracle, delete, load, OracleArray, store}; use crate::protocol::traits::Serialize; @@ -14,7 +17,7 @@ use crate::test::mocks::MockStruct; pub(crate) global SLOT: Field = 1230; pub(crate) global OTHER_SLOT: Field = 5670; -pub(crate) unconstrained fn check_empty_array() +pub(crate) unconstrained fn empty_array() where Oracle: ArrayOracle, { @@ -25,7 +28,7 @@ where }); } -pub(crate) unconstrained fn check_empty_array_read() +pub(crate) unconstrained fn empty_array_read() where Oracle: ArrayOracle, { @@ -36,7 +39,7 @@ where }); } -pub(crate) unconstrained fn check_empty_array_pop() +pub(crate) unconstrained fn empty_array_pop() where Oracle: ArrayOracle, { @@ -47,7 +50,7 @@ where }); } -pub(crate) unconstrained fn check_array_push() +pub(crate) unconstrained fn array_push() where Oracle: ArrayOracle, { @@ -61,7 +64,7 @@ where }); } -pub(crate) unconstrained fn check_read_past_len() +pub(crate) unconstrained fn read_past_len() where Oracle: ArrayOracle, { @@ -74,7 +77,7 @@ where }); } -pub(crate) unconstrained fn check_array_pop() +pub(crate) unconstrained fn array_pop() where Oracle: ArrayOracle, { @@ -91,7 +94,7 @@ where }); } -pub(crate) unconstrained fn check_array_set() +pub(crate) unconstrained fn array_set() where Oracle: ArrayOracle, { @@ -104,7 +107,7 @@ where }); } -pub(crate) unconstrained fn check_array_remove_last() +pub(crate) unconstrained fn array_remove_last() where Oracle: ArrayOracle, { @@ -117,7 +120,7 @@ where }); } -pub(crate) unconstrained fn check_array_remove_some() +pub(crate) unconstrained fn array_remove_some() where Oracle: ArrayOracle, { @@ -139,7 +142,7 @@ where }); } -pub(crate) unconstrained fn check_array_remove_all() +pub(crate) unconstrained fn array_remove_all() where Oracle: ArrayOracle, { @@ -159,7 +162,7 @@ where }); } -pub(crate) unconstrained fn check_for_each_called_with_all_elements() +pub(crate) unconstrained fn for_each_called_with_all_elements() where Oracle: ArrayOracle, { @@ -181,7 +184,7 @@ where }); } -pub(crate) unconstrained fn check_for_each_remove_some() +pub(crate) unconstrained fn for_each_remove_some() where Oracle: ArrayOracle, { @@ -205,7 +208,7 @@ where }); } -pub(crate) unconstrained fn check_for_each_remove_all() +pub(crate) unconstrained fn for_each_remove_all() where Oracle: ArrayOracle, { @@ -223,7 +226,7 @@ where }); } -pub(crate) unconstrained fn check_different_slots_are_isolated() +pub(crate) unconstrained fn different_slots_are_isolated() where Oracle: ArrayOracle, { @@ -245,7 +248,7 @@ where }); } -pub(crate) unconstrained fn check_works_with_multi_field_type() +pub(crate) unconstrained fn works_with_multi_field_type() where Oracle: ArrayOracle, { @@ -268,7 +271,7 @@ where }); } -pub(crate) unconstrained fn check_read_as_reconstructs_serialized_value() +pub(crate) unconstrained fn read_as_reconstructs_serialized_value() where Oracle: ArrayOracle, { @@ -287,7 +290,7 @@ where }); } -pub(crate) unconstrained fn check_read_as_rejects_length_mismatch() +pub(crate) unconstrained fn read_as_rejects_length_mismatch() where Oracle: ArrayOracle, { @@ -301,7 +304,7 @@ where }); } -pub(crate) unconstrained fn check_clear_returns_self() +pub(crate) unconstrained fn clear_returns_self() where Oracle: ArrayOracle, { @@ -316,7 +319,7 @@ where }); } -pub(crate) unconstrained fn check_clear_wipes_previous_data() +pub(crate) unconstrained fn clear_wipes_previous_data() where Oracle: ArrayOracle, { @@ -336,7 +339,7 @@ where }); } -pub(crate) unconstrained fn check_empty_allocates_distinct_slots() +pub(crate) unconstrained fn empty_allocates_distinct_slots() where Oracle: ArrayOracle, { @@ -356,7 +359,7 @@ where }); } -pub(crate) unconstrained fn check_map_transforms_each_element() +pub(crate) unconstrained fn map_transforms_each_element() where Oracle: ArrayOracle, { @@ -380,7 +383,7 @@ where }); } -pub(crate) unconstrained fn check_map_empty_source_gives_empty_dest() +pub(crate) unconstrained fn map_empty_source_gives_empty_dest() where Oracle: ArrayOracle, { @@ -392,7 +395,7 @@ where }); } -pub(crate) unconstrained fn check_map_to_different_type() +pub(crate) unconstrained fn map_to_different_type() where Oracle: ArrayOracle, { @@ -410,7 +413,7 @@ where }); } -pub(crate) unconstrained fn check_map_results_are_isolated() +pub(crate) unconstrained fn map_results_are_isolated() where Oracle: ArrayOracle, { @@ -433,7 +436,7 @@ where }); } -pub(crate) unconstrained fn check_filter_keeps_matching_elements_in_order() +pub(crate) unconstrained fn filter_keeps_matching_elements_in_order() where Oracle: ArrayOracle, { @@ -459,7 +462,7 @@ where }); } -pub(crate) unconstrained fn check_filter_empty_source_gives_empty_dest() +pub(crate) unconstrained fn filter_empty_source_gives_empty_dest() where Oracle: ArrayOracle, { @@ -471,7 +474,7 @@ where }); } -pub(crate) unconstrained fn check_filter_none_match_gives_empty_dest() +pub(crate) unconstrained fn filter_none_match_gives_empty_dest() where Oracle: ArrayOracle, { @@ -487,7 +490,7 @@ where }); } -pub(crate) unconstrained fn check_filter_works_with_multi_field_type() +pub(crate) unconstrained fn filter_works_with_multi_field_type() where Oracle: ArrayOracle, { @@ -506,7 +509,7 @@ where }); } -pub(crate) unconstrained fn check_filter_results_are_isolated() +pub(crate) unconstrained fn filter_results_are_isolated() where Oracle: ArrayOracle, { @@ -532,7 +535,7 @@ where }); } -pub(crate) unconstrained fn check_any_is_true_when_an_element_matches() +pub(crate) unconstrained fn any_is_true_when_an_element_matches() where Oracle: ArrayOracle, { @@ -547,7 +550,7 @@ where }); } -pub(crate) unconstrained fn check_any_is_false_when_no_element_matches() +pub(crate) unconstrained fn any_is_false_when_no_element_matches() where Oracle: ArrayOracle, { @@ -562,7 +565,7 @@ where }); } -pub(crate) unconstrained fn check_any_on_empty_array_is_false() +pub(crate) unconstrained fn any_on_empty_array_is_false() where Oracle: ArrayOracle, { @@ -573,7 +576,7 @@ where }); } -pub(crate) unconstrained fn check_all_is_true_when_every_element_matches() +pub(crate) unconstrained fn all_is_true_when_every_element_matches() where Oracle: ArrayOracle, { @@ -588,7 +591,7 @@ where }); } -pub(crate) unconstrained fn check_all_is_false_when_one_element_fails() +pub(crate) unconstrained fn all_is_false_when_one_element_fails() where Oracle: ArrayOracle, { @@ -603,7 +606,7 @@ where }); } -pub(crate) unconstrained fn check_all_on_empty_array_is_true() +pub(crate) unconstrained fn all_on_empty_array_is_true() where Oracle: ArrayOracle, { @@ -614,7 +617,7 @@ where }); } -pub(crate) unconstrained fn check_find_returns_first_matching_element() +pub(crate) unconstrained fn find_returns_first_matching_element() where Oracle: ArrayOracle, { @@ -633,7 +636,7 @@ where }); } -pub(crate) unconstrained fn check_find_returns_none_when_no_element_matches() +pub(crate) unconstrained fn find_returns_none_when_no_element_matches() where Oracle: ArrayOracle, { @@ -648,7 +651,7 @@ where }); } -pub(crate) unconstrained fn check_find_on_empty_array_is_none() +pub(crate) unconstrained fn find_on_empty_array_is_none() where Oracle: ArrayOracle, { @@ -659,7 +662,7 @@ where }); } -pub(crate) unconstrained fn check_empty_at_wipes_previous_data() +pub(crate) unconstrained fn empty_at_wipes_previous_data() where Oracle: ArrayOracle, { @@ -674,7 +677,7 @@ where }); } -pub(crate) unconstrained fn check_load_empty_returns_none() +pub(crate) unconstrained fn load_empty_returns_none() where Oracle: ArrayOracle, { @@ -685,7 +688,7 @@ where }); } -pub(crate) unconstrained fn check_store_overwrites() +pub(crate) unconstrained fn store_overwrites() where Oracle: ArrayOracle, { @@ -697,7 +700,7 @@ where }); } -pub(crate) unconstrained fn check_delete_removes_value() +pub(crate) unconstrained fn delete_removes_value() where Oracle: ArrayOracle, { @@ -710,7 +713,7 @@ where }); } -pub(crate) unconstrained fn check_delete_empty_is_noop() +pub(crate) unconstrained fn delete_empty_is_noop() where Oracle: ArrayOracle, { @@ -722,7 +725,7 @@ where }); } -pub(crate) unconstrained fn check_store_and_load_multi_field_type() +pub(crate) unconstrained fn store_and_load_multi_field_type() where Oracle: ArrayOracle, { @@ -734,7 +737,7 @@ where }); } -pub(crate) unconstrained fn check_stored_value_is_visible_as_a_length_one_array() +pub(crate) unconstrained fn stored_value_is_visible_as_a_length_one_array() where Oracle: ArrayOracle, { @@ -746,3 +749,54 @@ where assert_eq(array.get(0), 42); }); } + +/// Returns the `should_fail_with` message for checks that are expected to fail, or `Option::none()` for checks that +/// are expected to pass. +comptime fn should_fail_message(name: Quoted) -> Option { + if name == quote { empty_array_read } { + Option::some(quote { "out of bounds" }) + } else if name == quote { empty_array_pop } { + Option::some(quote { "is empty" }) + } else if name == quote { read_past_len } { + Option::some(quote { "out of bounds" }) + } else if name == quote { read_as_rejects_length_mismatch } { + Option::some(quote { "length mismatch" }) + } else { + Option::none() + } +} + +comptime fn oracle_array_tests(oracle: Quoted) -> Quoted { + let helpers = quote { crate::oracle_array::test_helpers }.as_module().unwrap(); + let mut tests = quote {}; + for f in helpers.functions() { + if f.is_unconstrained() { + let name = f.name(); + let fail = should_fail_message(name); + let attr = if fail.is_some() { + let msg = fail.unwrap(); + quote { #[test(should_fail_with = $msg)] } + } else { + quote { #[test] } + }; + tests = quote { + $tests + $attr + unconstrained fn $name() { + crate::oracle_array::test_helpers::$name::<$oracle>(); + } + }; + } + } + tests +} + +/// Generates the shared [`OracleArray`] test suite against the ephemeral backend. Apply to a test module. +pub(crate) comptime fn ephemeral_oracle_array_tests(_m: Module) -> Quoted { + oracle_array_tests(quote { crate::ephemeral::EphemeralOracle }) +} + +/// Generates the shared [`OracleArray`] test suite against the transient backend. Apply to a test module. +pub(crate) comptime fn transient_oracle_array_tests(_m: Module) -> Quoted { + oracle_array_tests(quote { crate::transient::TransientOracle }) +} diff --git a/noir-projects/aztec-nr/aztec/src/transient/mod.nr b/noir-projects/aztec-nr/aztec/src/transient/mod.nr index ad30325b2518..0f1c807b6c2c 100644 --- a/noir-projects/aztec-nr/aztec/src/transient/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/transient/mod.nr @@ -86,206 +86,11 @@ pub unconstrained fn delete(slot: Field) { crate::oracle_array::delete::(slot) } +#[crate::oracle_array::test_helpers::transient_oracle_array_tests] mod test { use crate::oracle_array::test_helpers::SLOT; - use crate::oracle_array::test_helpers as checks; use crate::test::helpers::test_environment::TestEnvironment; - use super::{load, store, TransientOracle}; - - #[test] - unconstrained fn empty_array() { - checks::check_empty_array::(); - } - - #[test(should_fail_with = "out of bounds")] - unconstrained fn empty_array_read() { - checks::check_empty_array_read::(); - } - - #[test(should_fail_with = "is empty")] - unconstrained fn empty_array_pop() { - checks::check_empty_array_pop::(); - } - - #[test] - unconstrained fn array_push() { - checks::check_array_push::(); - } - - #[test(should_fail_with = "out of bounds")] - unconstrained fn read_past_len() { - checks::check_read_past_len::(); - } - - #[test] - unconstrained fn array_pop() { - checks::check_array_pop::(); - } - - #[test] - unconstrained fn array_set() { - checks::check_array_set::(); - } - - #[test] - unconstrained fn array_remove_last() { - checks::check_array_remove_last::(); - } - - #[test] - unconstrained fn array_remove_some() { - checks::check_array_remove_some::(); - } - - #[test] - unconstrained fn array_remove_all() { - checks::check_array_remove_all::(); - } - - #[test] - unconstrained fn for_each_called_with_all_elements() { - checks::check_for_each_called_with_all_elements::(); - } - - #[test] - unconstrained fn for_each_remove_some() { - checks::check_for_each_remove_some::(); - } - - #[test] - unconstrained fn for_each_remove_all() { - checks::check_for_each_remove_all::(); - } - - #[test] - unconstrained fn different_slots_are_isolated() { - checks::check_different_slots_are_isolated::(); - } - - #[test] - unconstrained fn works_with_multi_field_type() { - checks::check_works_with_multi_field_type::(); - } - - #[test] - unconstrained fn read_as_reconstructs_serialized_value() { - checks::check_read_as_reconstructs_serialized_value::(); - } - - #[test(should_fail_with = "length mismatch")] - unconstrained fn read_as_rejects_length_mismatch() { - checks::check_read_as_rejects_length_mismatch::(); - } - - #[test] - unconstrained fn clear_returns_self() { - checks::check_clear_returns_self::(); - } - - #[test] - unconstrained fn clear_wipes_previous_data() { - checks::check_clear_wipes_previous_data::(); - } - - #[test] - unconstrained fn empty_allocates_distinct_slots() { - checks::check_empty_allocates_distinct_slots::(); - } - - #[test] - unconstrained fn map_transforms_each_element() { - checks::check_map_transforms_each_element::(); - } - - #[test] - unconstrained fn map_empty_source_gives_empty_dest() { - checks::check_map_empty_source_gives_empty_dest::(); - } - - #[test] - unconstrained fn map_to_different_type() { - checks::check_map_to_different_type::(); - } - - #[test] - unconstrained fn map_results_are_isolated() { - checks::check_map_results_are_isolated::(); - } - - #[test] - unconstrained fn filter_keeps_matching_elements_in_order() { - checks::check_filter_keeps_matching_elements_in_order::(); - } - - #[test] - unconstrained fn filter_empty_source_gives_empty_dest() { - checks::check_filter_empty_source_gives_empty_dest::(); - } - - #[test] - unconstrained fn filter_none_match_gives_empty_dest() { - checks::check_filter_none_match_gives_empty_dest::(); - } - - #[test] - unconstrained fn filter_works_with_multi_field_type() { - checks::check_filter_works_with_multi_field_type::(); - } - - #[test] - unconstrained fn filter_results_are_isolated() { - checks::check_filter_results_are_isolated::(); - } - - #[test] - unconstrained fn any_is_true_when_an_element_matches() { - checks::check_any_is_true_when_an_element_matches::(); - } - - #[test] - unconstrained fn any_is_false_when_no_element_matches() { - checks::check_any_is_false_when_no_element_matches::(); - } - - #[test] - unconstrained fn any_on_empty_array_is_false() { - checks::check_any_on_empty_array_is_false::(); - } - - #[test] - unconstrained fn all_is_true_when_every_element_matches() { - checks::check_all_is_true_when_every_element_matches::(); - } - - #[test] - unconstrained fn all_is_false_when_one_element_fails() { - checks::check_all_is_false_when_one_element_fails::(); - } - - #[test] - unconstrained fn all_on_empty_array_is_true() { - checks::check_all_on_empty_array_is_true::(); - } - - #[test] - unconstrained fn find_returns_first_matching_element() { - checks::check_find_returns_first_matching_element::(); - } - - #[test] - unconstrained fn find_returns_none_when_no_element_matches() { - checks::check_find_returns_none_when_no_element_matches::(); - } - - #[test] - unconstrained fn find_on_empty_array_is_none() { - checks::check_find_on_empty_array_is_none::(); - } - - #[test] - unconstrained fn empty_at_wipes_previous_data() { - checks::check_empty_at_wipes_previous_data::(); - } + use super::{load, store}; #[test] unconstrained fn store_and_load() { @@ -295,34 +100,4 @@ mod test { assert_eq(load(SLOT), Option::some(42)); }); } - - #[test] - unconstrained fn load_empty_returns_none() { - checks::check_load_empty_returns_none::(); - } - - #[test] - unconstrained fn store_overwrites() { - checks::check_store_overwrites::(); - } - - #[test] - unconstrained fn delete_removes_value() { - checks::check_delete_removes_value::(); - } - - #[test] - unconstrained fn delete_empty_is_noop() { - checks::check_delete_empty_is_noop::(); - } - - #[test] - unconstrained fn store_and_load_multi_field_type() { - checks::check_store_and_load_multi_field_type::(); - } - - #[test] - unconstrained fn stored_value_is_visible_as_a_length_one_array() { - checks::check_stored_value_is_visible_as_a_length_one_array::(); - } } From 74132209d93ab4e1a29f92914f202555b05a8d44 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Wed, 10 Jun 2026 11:10:27 +0000 Subject: [PATCH 07/31] refactor: single parameterized oracle_array_tests macro Replaces the two per-backend attribute functions with one oracle_array_tests(module, oracle) attribute that takes the backend's oracle as a quoted path at the annotation site. --- .../aztec-nr/aztec/src/ephemeral/mod.nr | 2 +- .../aztec/src/oracle_array/test_helpers.nr | 18 +++++------------- .../aztec-nr/aztec/src/transient/mod.nr | 2 +- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr index e444f29198ea..bad14d5df485 100644 --- a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr @@ -79,7 +79,7 @@ pub unconstrained fn delete(slot: Field) { crate::oracle_array::delete::(slot) } -#[crate::oracle_array::test_helpers::ephemeral_oracle_array_tests] +#[crate::oracle_array::test_helpers::oracle_array_tests(quote { crate::ephemeral::EphemeralOracle })] mod test { use crate::oracle_array::test_helpers::SLOT; use crate::test::helpers::test_environment::TestEnvironment; diff --git a/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr b/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr index 6e36a38dc0de..4e66f211b670 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr @@ -2,8 +2,8 @@ //! //! Every `unconstrained` function in this module checks one behavior of the shared [`OracleArray`] API (or of the //! generic `store` / `load` / `delete` helpers) and is generic over the [`ArrayOracle`] backend. Backend test modules -//! do not reference the checks one by one: annotating a module with `ephemeral_oracle_array_tests` / -//! `transient_oracle_array_tests` generates one `#[test]` per check with that backend's oracle swapped in, so a check +//! do not reference the checks one by one: annotating a module with `oracle_array_tests` (passing the backend's +//! oracle as a quoted path) generates one `#[test]` per check with that oracle swapped in, so a check //! added here automatically runs against every backend. Checks that are expected to fail must be registered in //! `should_fail_message`, which supplies the message for the generated `#[test(should_fail_with = ...)]` attribute. //! Each backend module additionally keeps a hand-written smoke test routing through its own public helpers (e.g. @@ -766,7 +766,9 @@ comptime fn should_fail_message(name: Quoted) -> Option { } } -comptime fn oracle_array_tests(oracle: Quoted) -> Quoted { +/// Generates the shared [`OracleArray`] test suite against the given backend. Apply to a test module, passing the +/// backend's oracle as a quoted path: `#[oracle_array_tests(quote { crate::ephemeral::EphemeralOracle })]`. +pub(crate) comptime fn oracle_array_tests(_m: Module, oracle: Quoted) -> Quoted { let helpers = quote { crate::oracle_array::test_helpers }.as_module().unwrap(); let mut tests = quote {}; for f in helpers.functions() { @@ -790,13 +792,3 @@ comptime fn oracle_array_tests(oracle: Quoted) -> Quoted { } tests } - -/// Generates the shared [`OracleArray`] test suite against the ephemeral backend. Apply to a test module. -pub(crate) comptime fn ephemeral_oracle_array_tests(_m: Module) -> Quoted { - oracle_array_tests(quote { crate::ephemeral::EphemeralOracle }) -} - -/// Generates the shared [`OracleArray`] test suite against the transient backend. Apply to a test module. -pub(crate) comptime fn transient_oracle_array_tests(_m: Module) -> Quoted { - oracle_array_tests(quote { crate::transient::TransientOracle }) -} diff --git a/noir-projects/aztec-nr/aztec/src/transient/mod.nr b/noir-projects/aztec-nr/aztec/src/transient/mod.nr index 0f1c807b6c2c..e4a14969b6b1 100644 --- a/noir-projects/aztec-nr/aztec/src/transient/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/transient/mod.nr @@ -86,7 +86,7 @@ pub unconstrained fn delete(slot: Field) { crate::oracle_array::delete::(slot) } -#[crate::oracle_array::test_helpers::transient_oracle_array_tests] +#[crate::oracle_array::test_helpers::oracle_array_tests(quote { crate::transient::TransientOracle })] mod test { use crate::oracle_array::test_helpers::SLOT; use crate::test::helpers::test_environment::TestEnvironment; From d49fc3f414a021d6fe5f644db1eb2dae3c5a3eb6 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Wed, 10 Jun 2026 11:27:18 +0000 Subject: [PATCH 08/31] refactor: should_fail_ prefix for failing oracle array checks --- .../aztec/src/oracle_array/test_helpers.nr | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr b/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr index 4e66f211b670..5eb44214fc47 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr @@ -1,11 +1,8 @@ //! Shared test suite for [`OracleArray`] backends. //! -//! Every `unconstrained` function in this module checks one behavior of the shared [`OracleArray`] API (or of the -//! generic `store` / `load` / `delete` helpers) and is generic over the [`ArrayOracle`] backend. Backend test modules -//! do not reference the checks one by one: annotating a module with `oracle_array_tests` (passing the backend's -//! oracle as a quoted path) generates one `#[test]` per check with that oracle swapped in, so a check -//! added here automatically runs against every backend. Checks that are expected to fail must be registered in -//! `should_fail_message`, which supplies the message for the generated `#[test(should_fail_with = ...)]` attribute. +//! Every function in this module checks one behavior of the shared [`OracleArray`] API (or of the generic +//! `store` / `load` / `delete` kv helpers) and is generic over the [`ArrayOracle`] backend. +//! //! Each backend module additionally keeps a hand-written smoke test routing through its own public helpers (e.g. //! `transient::store` / `transient::load`) to cover the delegation wiring. @@ -28,7 +25,7 @@ where }); } -pub(crate) unconstrained fn empty_array_read() +pub(crate) unconstrained fn should_fail_empty_array_read() where Oracle: ArrayOracle, { @@ -39,7 +36,7 @@ where }); } -pub(crate) unconstrained fn empty_array_pop() +pub(crate) unconstrained fn should_fail_empty_array_pop() where Oracle: ArrayOracle, { @@ -64,7 +61,7 @@ where }); } -pub(crate) unconstrained fn read_past_len() +pub(crate) unconstrained fn should_fail_read_past_len() where Oracle: ArrayOracle, { @@ -290,7 +287,7 @@ where }); } -pub(crate) unconstrained fn read_as_rejects_length_mismatch() +pub(crate) unconstrained fn should_fail_read_as_rejects_length_mismatch() where Oracle: ArrayOracle, { @@ -753,13 +750,13 @@ where /// Returns the `should_fail_with` message for checks that are expected to fail, or `Option::none()` for checks that /// are expected to pass. comptime fn should_fail_message(name: Quoted) -> Option { - if name == quote { empty_array_read } { + if name == quote { should_fail_empty_array_read } { Option::some(quote { "out of bounds" }) - } else if name == quote { empty_array_pop } { + } else if name == quote { should_fail_empty_array_pop } { Option::some(quote { "is empty" }) - } else if name == quote { read_past_len } { + } else if name == quote { should_fail_read_past_len } { Option::some(quote { "out of bounds" }) - } else if name == quote { read_as_rejects_length_mismatch } { + } else if name == quote { should_fail_read_as_rejects_length_mismatch } { Option::some(quote { "length mismatch" }) } else { Option::none() From 07f089adf92b91039cc4e39b0d315d4be5fcabb1 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Wed, 10 Jun 2026 12:40:37 +0000 Subject: [PATCH 09/31] feat(pxe): EntityStore for entity-scoped fact storage Stores immutable facts about entities keyed by (contract, scope, entity type, entity id), with per-job staged writes flushed atomically on commit. Facts and entities carry an optional block anchor: anchored (retractable) records are deleted when their block is pruned, while unanchored ones survive reorgs as external inputs. Wired into openPxeStores with schema backwards-compatibility coverage. --- .../__snapshots__/EntityStore.json | 54 +++ .../__snapshots__/opened_stores.json | 24 + .../schema_tests.ts | 46 ++ .../storage/entity_store/entity_store.test.ts | 344 ++++++++++++++ .../src/storage/entity_store/entity_store.ts | 444 ++++++++++++++++++ .../pxe/src/storage/entity_store/index.ts | 1 + .../storage/entity_store/stored_fact.test.ts | 75 +++ .../src/storage/entity_store/stored_fact.ts | 149 ++++++ yarn-project/pxe/src/storage/index.ts | 1 + .../pxe/src/storage/open_pxe_stores.ts | 3 + 10 files changed, 1141 insertions(+) create mode 100644 yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/EntityStore.json create mode 100644 yarn-project/pxe/src/storage/entity_store/entity_store.test.ts create mode 100644 yarn-project/pxe/src/storage/entity_store/entity_store.ts create mode 100644 yarn-project/pxe/src/storage/entity_store/index.ts create mode 100644 yarn-project/pxe/src/storage/entity_store/stored_fact.test.ts create mode 100644 yarn-project/pxe/src/storage/entity_store/stored_fact.ts diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/EntityStore.json b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/EntityStore.json new file mode 100644 index 000000000000..6cf28f012500 --- /dev/null +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/EntityStore.json @@ -0,0 +1,54 @@ +{ + "entities": [ + { + "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000064:0x0000000000000000000000000000000000000000000000000000000000000001:0x0000000000000000000000000000000000000000000000000000000000000007:0x00000000000000000000000000000000000000000000000000000000000000aa", + "value": "00000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000aa00000001000000000000000000000000000000000000000000000000000000000000000500000001000000060000000000000000000000000000000000000000000000000000000000000002" + }, + { + "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000064:0x0000000000000000000000000000000000000000000000000000000000000001:0x0000000000000000000000000000000000000000000000000000000000000007:0x00000000000000000000000000000000000000000000000000000000000000bb", + "value": "00000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000bb00000001000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + ], + "entities_by_block": [ + { + "key": "num:6", + "value": "utf8:0x0000000000000000000000000000000000000000000000000000000000000064:0x0000000000000000000000000000000000000000000000000000000000000001:0x0000000000000000000000000000000000000000000000000000000000000007:0x00000000000000000000000000000000000000000000000000000000000000aa" + } + ], + "entities_by_scope": [ + { + "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000064:0x0000000000000000000000000000000000000000000000000000000000000001:0x0000000000000000000000000000000000000000000000000000000000000007", + "value": "utf8:0x00000000000000000000000000000000000000000000000000000000000000aa" + }, + { + "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000064:0x0000000000000000000000000000000000000000000000000000000000000001:0x0000000000000000000000000000000000000000000000000000000000000007", + "value": "utf8:0x00000000000000000000000000000000000000000000000000000000000000bb" + } + ], + "facts": [ + { + "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000064:0x0000000000000000000000000000000000000000000000000000000000000001:0x0000000000000000000000000000000000000000000000000000000000000007:0x00000000000000000000000000000000000000000000000000000000000000bb:0x0000000000000000000000000000000000000000000000000000000000000001:0x00a802572c436574f713d43f1852859f4496d694de0e5b17b89cf983439e6143", + "value": "00000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000bb000000000000000000000000000000000000000000000000000000000000000100000001000000000000000000000000000000000000000000000000000000000000000900000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000064:0x0000000000000000000000000000000000000000000000000000000000000001:0x0000000000000000000000000000000000000000000000000000000000000007:0x00000000000000000000000000000000000000000000000000000000000000bb:0x0000000000000000000000000000000000000000000000000000000000000002:0x00df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b811", + "value": "00000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000bb00000000000000000000000000000000000000000000000000000000000000020000000000000001000000050000000000000000000000000000000000000000000000000000000000000001" + } + ], + "facts_by_entity": [ + { + "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000064:0x0000000000000000000000000000000000000000000000000000000000000001:0x0000000000000000000000000000000000000000000000000000000000000007:0x00000000000000000000000000000000000000000000000000000000000000bb", + "value": "utf8:0x0000000000000000000000000000000000000000000000000000000000000064:0x0000000000000000000000000000000000000000000000000000000000000001:0x0000000000000000000000000000000000000000000000000000000000000007:0x00000000000000000000000000000000000000000000000000000000000000bb:0x0000000000000000000000000000000000000000000000000000000000000001:0x00a802572c436574f713d43f1852859f4496d694de0e5b17b89cf983439e6143" + }, + { + "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000064:0x0000000000000000000000000000000000000000000000000000000000000001:0x0000000000000000000000000000000000000000000000000000000000000007:0x00000000000000000000000000000000000000000000000000000000000000bb", + "value": "utf8:0x0000000000000000000000000000000000000000000000000000000000000064:0x0000000000000000000000000000000000000000000000000000000000000001:0x0000000000000000000000000000000000000000000000000000000000000007:0x00000000000000000000000000000000000000000000000000000000000000bb:0x0000000000000000000000000000000000000000000000000000000000000002:0x00df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b811" + } + ], + "facts_by_block": [ + { + "key": "num:5", + "value": "utf8:0x0000000000000000000000000000000000000000000000000000000000000064:0x0000000000000000000000000000000000000000000000000000000000000001:0x0000000000000000000000000000000000000000000000000000000000000007:0x00000000000000000000000000000000000000000000000000000000000000bb:0x0000000000000000000000000000000000000000000000000000000000000002:0x00df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b811" + } + ] +} diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json index 6c031f74812f..041e31309581 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json @@ -29,6 +29,18 @@ "name": "contracts_instances", "kind": "map" }, + { + "name": "entities", + "kind": "map" + }, + { + "name": "entities_by_block", + "kind": "multimap" + }, + { + "name": "entities_by_scope", + "kind": "multimap" + }, { "name": "events_by_block_number", "kind": "multimap" @@ -37,6 +49,18 @@ "name": "events_by_contract_selector", "kind": "multimap" }, + { + "name": "facts", + "kind": "map" + }, + { + "name": "facts_by_block", + "kind": "multimap" + }, + { + "name": "facts_by_entity", + "kind": "multimap" + }, { "name": "header", "kind": "singleton" diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts b/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts index 9a1e54a2a2cf..34e0c37b5411 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts @@ -39,6 +39,7 @@ import { AddressStore } from '../address_store/address_store.js'; import { AnchorBlockStore } from '../anchor_block_store/index.js'; import { CapsuleStore } from '../capsule_store/capsule_store.js'; import { ContractStore } from '../contract_store/contract_store.js'; +import { EntityStore } from '../entity_store/entity_store.js'; import { NoteStore } from '../note_store/note_store.js'; import { PrivateEventStore } from '../private_event_store/private_event_store.js'; import { RecipientTaggingStore, SenderAddressBookStore, SenderTaggingStore } from '../tagging_store/index.js'; @@ -214,6 +215,51 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ }), }, + { + name: 'EntityStore', + writeToStore: async kvStore => { + const entityStore = new EntityStore(kvStore); + const jobId = 'fixture-job'; + const contract = AztecAddress.fromBigInt(100n); + const scope = AztecAddress.fromBigInt(1n); + const entityTypeId = new Fr(7n); + const corrA = new Fr(0xaan); + const corrB = new Fr(0xbbn); + // Retractable entity (anchored at block 6): the entity and all its facts are pruned on a reorg above block 6. + await entityStore.createEntity( + contract, + scope, + entityTypeId, + corrA, + [new Fr(5n)], + { blockNumber: 6, blockHash: new Fr(2n) }, + jobId, + ); + // Non-retractable entity carrying a payload, with a non-retractable and a retractable fact. + await entityStore.createEntity(contract, scope, entityTypeId, corrB, [new Fr(8n)], undefined, jobId); + await entityStore.recordFact(contract, scope, entityTypeId, corrB, new Fr(1n), [new Fr(9n)], undefined, jobId); + await entityStore.recordFact( + contract, + scope, + entityTypeId, + corrB, + new Fr(2n), + [], + { blockNumber: 5, blockHash: new Fr(1n) }, + jobId, + ); + await kvStore.transactionAsync(() => entityStore.commit(jobId)); + }, + snapshotStore: async kvStore => ({ + entities: await snapshotMap(kvStore.openMap('entities')), + entities_by_block: await snapshotMap(kvStore.openMultiMap('entities_by_block')), + entities_by_scope: await snapshotMap(kvStore.openMultiMap('entities_by_scope')), + facts: await snapshotMap(kvStore.openMap('facts')), + facts_by_entity: await snapshotMap(kvStore.openMultiMap('facts_by_entity')), + facts_by_block: await snapshotMap(kvStore.openMultiMap('facts_by_block')), + }), + }, + { name: 'KeyStore', writeToStore: async kvStore => { diff --git a/yarn-project/pxe/src/storage/entity_store/entity_store.test.ts b/yarn-project/pxe/src/storage/entity_store/entity_store.test.ts new file mode 100644 index 000000000000..80d19dbf3dbe --- /dev/null +++ b/yarn-project/pxe/src/storage/entity_store/entity_store.test.ts @@ -0,0 +1,344 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import type { AztecAsyncKVStore } from '@aztec/kv-store'; +import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; + +import { EntityStore } from './entity_store.js'; + +describe('EntityStore', () => { + const contract = AztecAddress.fromBigInt(100n); + const scope = AztecAddress.fromBigInt(1n); + const ENTITY = new Fr(7n); + const RECEIVED = new Fr(1n); + const PROCESSED = new Fr(2n); + const corrA = new Fr(0xaan); + const corrB = new Fr(0xbbn); + const JOB = 'fact-store-test-job'; + + let kv: AztecAsyncKVStore; + let store: EntityStore; + + beforeEach(async () => { + kv = await openTmpStore('fact-store-test'); + store = new EntityStore(kv); + }); + afterEach(async () => { + await kv.close(); + }); + + it('records facts and loads an entity fact set after commit', async () => { + await store.recordFact(contract, scope, ENTITY, corrA, RECEIVED, [new Fr(9n)], undefined, JOB); + await store.recordFact( + contract, + scope, + ENTITY, + corrA, + PROCESSED, + [], + { blockNumber: 5, blockHash: new Fr(1n) }, + JOB, + ); + await kv.transactionAsync(() => store.commit(JOB)); + + const facts = await store.getEntityFacts(contract, scope, ENTITY, corrA, JOB); + const factTypes = facts.map(f => f.factTypeId.toBigInt()).sort(); + expect(factTypes).toEqual([RECEIVED.toBigInt(), PROCESSED.toBigInt()].sort()); + }); + + it('dedups identical (entity, factType, payload) records idempotently', async () => { + await store.recordFact(contract, scope, ENTITY, corrA, RECEIVED, [new Fr(9n)], undefined, JOB); + await store.recordFact(contract, scope, ENTITY, corrA, RECEIVED, [new Fr(9n)], undefined, JOB); + await kv.transactionAsync(() => store.commit(JOB)); + + const facts = await store.getEntityFacts(contract, scope, ENTITY, corrA, JOB); + expect(facts).toHaveLength(1); + }); + + it('enumerates active entities (created and not terminated) for a scope', async () => { + await store.createEntity(contract, scope, ENTITY, corrA, [], undefined, JOB); + await store.createEntity(contract, scope, ENTITY, corrB, [], undefined, JOB); + await store.recordFact(contract, scope, ENTITY, corrA, RECEIVED, [new Fr(9n)], undefined, JOB); + await store.recordFact(contract, scope, ENTITY, corrB, RECEIVED, [new Fr(8n)], undefined, JOB); + await kv.transactionAsync(() => store.commit(JOB)); + + const active = await store.activeEntities(contract, scope, ENTITY, JOB); + expect(active.map(c => c.toBigInt()).sort()).toEqual([corrA.toBigInt(), corrB.toBigInt()].sort()); + }); + + it('lists an entity as active even when it has zero facts', async () => { + await store.createEntity(contract, scope, ENTITY, corrA, [new Fr(5n)], undefined, JOB); + await kv.transactionAsync(() => store.commit(JOB)); + + expect((await store.activeEntities(contract, scope, ENTITY, JOB)).map(c => c.toBigInt())).toEqual([ + corrA.toBigInt(), + ]); + }); + + it("reflects a job's own staged createEntity before commit (read-your-writes)", async () => { + await store.createEntity(contract, scope, ENTITY, corrA, [new Fr(5n)], undefined, JOB); + await store.recordFact(contract, scope, ENTITY, corrA, RECEIVED, [new Fr(9n)], undefined, JOB); + + // Same job, before commit: the staged entity is active and its fact is visible. + const facts = await store.getEntityFacts(contract, scope, ENTITY, corrA, JOB); + expect(facts.map(f => f.factTypeId.toBigInt())).toEqual([RECEIVED.toBigInt()]); + expect((await store.activeEntities(contract, scope, ENTITY, JOB)).map(c => c.toBigInt())).toEqual([ + corrA.toBigInt(), + ]); + + // A different job does not see the uncommitted write. + expect(await store.getEntityFacts(contract, scope, ENTITY, corrA, 'other-job')).toHaveLength(0); + expect(await store.activeEntities(contract, scope, ENTITY, 'other-job')).toHaveLength(0); + }); + + it('getEntity returns the payload and both facts of an entity with facts', async () => { + await store.createEntity(contract, scope, ENTITY, corrA, [new Fr(5n), new Fr(6n)], undefined, JOB); + await store.recordFact(contract, scope, ENTITY, corrA, RECEIVED, [new Fr(9n)], undefined, JOB); + await store.recordFact( + contract, + scope, + ENTITY, + corrA, + PROCESSED, + [], + { blockNumber: 5, blockHash: new Fr(1n) }, + JOB, + ); + await kv.transactionAsync(() => store.commit(JOB)); + + const { payload, facts } = await store.getEntity(contract, scope, ENTITY, corrA, JOB); + expect(payload.map(f => f.toBigInt())).toEqual([5n, 6n]); + expect(facts.map(f => f.factTypeId.toBigInt()).sort()).toEqual([RECEIVED.toBigInt(), PROCESSED.toBigInt()].sort()); + }); + + it('getEntity returns the payload and empty facts for an entity with zero facts', async () => { + await store.createEntity(contract, scope, ENTITY, corrA, [new Fr(5n)], undefined, JOB); + await kv.transactionAsync(() => store.commit(JOB)); + + const { payload, facts } = await store.getEntity(contract, scope, ENTITY, corrA, JOB); + expect(payload.map(f => f.toBigInt())).toEqual([5n]); + expect(facts).toHaveLength(0); + }); + + it('getEntity returns an empty payload when no entity record exists', async () => { + const { payload, facts } = await store.getEntity(contract, scope, ENTITY, corrA, JOB); + expect(payload).toEqual([]); + expect(facts).toHaveLength(0); + }); + + it("getEntity reflects a job's own staged createEntity before commit (read-your-writes)", async () => { + await store.createEntity(contract, scope, ENTITY, corrA, [new Fr(5n)], undefined, JOB); + await store.recordFact(contract, scope, ENTITY, corrA, RECEIVED, [new Fr(9n)], undefined, JOB); + + const { payload, facts } = await store.getEntity(contract, scope, ENTITY, corrA, JOB); + expect(payload.map(f => f.toBigInt())).toEqual([5n]); + expect(facts.map(f => f.factTypeId.toBigInt())).toEqual([RECEIVED.toBigInt()]); + + // A different job does not see the uncommitted write. + expect((await store.getEntity(contract, scope, ENTITY, corrA, 'other-job')).payload).toEqual([]); + }); + + it('hides an entity from its own job after a staged terminate, even over committed facts', async () => { + await store.createEntity(contract, scope, ENTITY, corrA, [new Fr(5n)], undefined, JOB); + await store.recordFact(contract, scope, ENTITY, corrA, RECEIVED, [new Fr(9n)], undefined, JOB); + await kv.transactionAsync(() => store.commit(JOB)); + + // A later job stages a terminate; within that job the entity reads as gone, while other jobs still see it + // committed until the terminate commits. + const TERM = 'terminate-job'; + await store.terminateEntity(contract, scope, ENTITY, corrA, TERM); + + expect(await store.getEntityFacts(contract, scope, ENTITY, corrA, TERM)).toHaveLength(0); + expect((await store.getEntity(contract, scope, ENTITY, corrA, TERM)).payload).toEqual([]); + expect(await store.activeEntities(contract, scope, ENTITY, TERM)).toHaveLength(0); + expect(await store.getEntityFacts(contract, scope, ENTITY, corrA, 'reader')).toHaveLength(1); + }); + + it('terminateEntity deletes the entity record, all its facts, and drops it from active enumeration', async () => { + await store.createEntity(contract, scope, ENTITY, corrA, [new Fr(5n)], undefined, JOB); + await store.createEntity(contract, scope, ENTITY, corrB, [], undefined, JOB); + await store.recordFact(contract, scope, ENTITY, corrA, RECEIVED, [new Fr(9n)], undefined, JOB); + await store.recordFact( + contract, + scope, + ENTITY, + corrA, + PROCESSED, + [], + { blockNumber: 5, blockHash: new Fr(1n) }, + JOB, + ); + await store.recordFact(contract, scope, ENTITY, corrB, RECEIVED, [new Fr(8n)], undefined, JOB); + await kv.transactionAsync(() => store.commit(JOB)); + + const TERM = 'terminate-job'; + await store.terminateEntity(contract, scope, ENTITY, corrA, TERM); + await kv.transactionAsync(() => store.commit(TERM)); + + expect(await store.getEntityFacts(contract, scope, ENTITY, corrA, JOB)).toHaveLength(0); + expect((await store.getEntity(contract, scope, ENTITY, corrA, JOB)).payload).toEqual([]); + const active = await store.activeEntities(contract, scope, ENTITY, JOB); + expect(active.map(c => c.toBigInt())).toEqual([corrB.toBigInt()]); + // The neighbouring entity is untouched. + expect(await store.getEntityFacts(contract, scope, ENTITY, corrB, JOB)).toHaveLength(1); + }); + + it('rollback deletes a retractable entity wholesale (payload + every fact) above the target block', async () => { + // Retractable entity anchored at block 6, owning one unanchored and one anchored fact. + await store.createEntity( + contract, + scope, + ENTITY, + corrA, + [new Fr(5n)], + { blockNumber: 6, blockHash: new Fr(1n) }, + JOB, + ); + await store.recordFact(contract, scope, ENTITY, corrA, RECEIVED, [new Fr(9n)], undefined, JOB); + await store.recordFact( + contract, + scope, + ENTITY, + corrA, + PROCESSED, + [], + { blockNumber: 7, blockHash: new Fr(2n) }, + JOB, + ); + await kv.transactionAsync(() => store.commit(JOB)); + + await kv.transactionAsync(() => store.rollback(5)); + + const { payload, facts } = await store.getEntity(contract, scope, ENTITY, corrA, JOB); + expect(payload).toEqual([]); + expect(facts).toHaveLength(0); + expect(await store.getEntityFacts(contract, scope, ENTITY, corrA, JOB)).toHaveLength(0); + expect(await store.activeEntities(contract, scope, ENTITY, JOB)).toHaveLength(0); + }); + + it('rollback keeps a non-retractable entity, pruning only its retractable facts', async () => { + // Non-retractable entity (no anchor) with one unanchored fact + one anchored fact at block 6. + await store.createEntity(contract, scope, ENTITY, corrA, [new Fr(5n)], undefined, JOB); + await store.recordFact(contract, scope, ENTITY, corrA, RECEIVED, [new Fr(9n)], undefined, JOB); + await store.recordFact( + contract, + scope, + ENTITY, + corrA, + PROCESSED, + [], + { blockNumber: 6, blockHash: new Fr(1n) }, + JOB, + ); + await kv.transactionAsync(() => store.commit(JOB)); + + await kv.transactionAsync(() => store.rollback(5)); + + const { payload, facts } = await store.getEntity(contract, scope, ENTITY, corrA, JOB); + expect(payload.map(f => f.toBigInt())).toEqual([5n]); + expect(facts.map(f => f.factTypeId.toBigInt())).toEqual([RECEIVED.toBigInt()]); // Processed pruned, Received kept + // Entity stays active because the entity record survives. + expect((await store.activeEntities(contract, scope, ENTITY, JOB)).map(c => c.toBigInt())).toEqual([ + corrA.toBigInt(), + ]); + }); + + it('rollback above all anchors is a no-op', async () => { + await store.createEntity( + contract, + scope, + ENTITY, + corrA, + [new Fr(5n)], + { blockNumber: 6, blockHash: new Fr(1n) }, + JOB, + ); + await store.recordFact( + contract, + scope, + ENTITY, + corrA, + PROCESSED, + [], + { blockNumber: 7, blockHash: new Fr(2n) }, + JOB, + ); + await kv.transactionAsync(() => store.commit(JOB)); + + await kv.transactionAsync(() => store.rollback(10)); + + const { payload, facts } = await store.getEntity(contract, scope, ENTITY, corrA, JOB); + expect(payload.map(f => f.toBigInt())).toEqual([5n]); + expect(facts.map(f => f.factTypeId.toBigInt())).toEqual([PROCESSED.toBigInt()]); + expect((await store.activeEntities(contract, scope, ENTITY, JOB)).map(c => c.toBigInt())).toEqual([ + corrA.toBigInt(), + ]); + }); + + it('re-creating an entity with a changed anchor clears the stale by-block index', async () => { + await store.createEntity( + contract, + scope, + ENTITY, + corrA, + [new Fr(5n)], + { blockNumber: 6, blockHash: new Fr(1n) }, + JOB, + ); + await kv.transactionAsync(() => store.commit(JOB)); + + // Re-create the same entity anchored at a different block. + const JOB2 = 'recreate-job'; + await store.createEntity( + contract, + scope, + ENTITY, + corrA, + [new Fr(5n)], + { blockNumber: 8, blockHash: new Fr(2n) }, + JOB2, + ); + await kv.transactionAsync(() => store.commit(JOB2)); + + // Prune above block 5: the entity (anchored at 8) is deleted exactly once. A stale block-6 index entry would make + // pass 1 visit it a second time and throw "Entity not found". + await expect(kv.transactionAsync(() => store.rollback(5))).resolves.not.toThrow(); + expect((await store.getEntity(contract, scope, ENTITY, corrA, JOB)).payload).toEqual([]); + expect(await store.activeEntities(contract, scope, ENTITY, JOB)).toHaveLength(0); + }); + + it('re-creating a retractable entity as non-retractable lets it survive a prune', async () => { + await store.createEntity( + contract, + scope, + ENTITY, + corrA, + [new Fr(5n)], + { blockNumber: 6, blockHash: new Fr(1n) }, + JOB, + ); + await kv.transactionAsync(() => store.commit(JOB)); + + // Re-create the same entity without an anchor: it is now non-retractable and must survive reorgs. + const JOB2 = 'recreate-job'; + await store.createEntity(contract, scope, ENTITY, corrA, [new Fr(5n)], undefined, JOB2); + await kv.transactionAsync(() => store.commit(JOB2)); + + await kv.transactionAsync(() => store.rollback(5)); + + // Survived: the stale block-6 index entry was cleared when the entity was re-created. + const { payload } = await store.getEntity(contract, scope, ENTITY, corrA, JOB); + expect(payload.map(f => f.toBigInt())).toEqual([5n]); + expect((await store.activeEntities(contract, scope, ENTITY, JOB)).map(c => c.toBigInt())).toEqual([ + corrA.toBigInt(), + ]); + }); + + it('rollback throws while a job has staged writes', async () => { + await store.createEntity(contract, scope, ENTITY, corrA, [], undefined, 'uncommitted-job'); + await expect(kv.transactionAsync(() => store.rollback(0))).rejects.toThrow( + 'PXE entity store rollback is not allowed while jobs are running', + ); + await store.discardStaged('uncommitted-job'); + await expect(kv.transactionAsync(() => store.rollback(0))).resolves.not.toThrow(); + }); +}); diff --git a/yarn-project/pxe/src/storage/entity_store/entity_store.ts b/yarn-project/pxe/src/storage/entity_store/entity_store.ts new file mode 100644 index 000000000000..2327d513c143 --- /dev/null +++ b/yarn-project/pxe/src/storage/entity_store/entity_store.ts @@ -0,0 +1,444 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import { createLogger } from '@aztec/foundation/log'; +import { Semaphore } from '@aztec/foundation/queue'; +import type { AztecAsyncKVStore, AztecAsyncMap, AztecAsyncMultiMap } from '@aztec/kv-store'; +import type { AztecAddress } from '@aztec/stdlib/aztec-address'; + +import type { StagedStore } from '../../job_coordinator/job_coordinator.js'; +import { + type FactAnchor, + StoredEntity, + StoredFact, + entityKey, + entityKeyOf, + factRowKeyOf, + scopeKey, + scopeKeyOf, +} from './stored_fact.js'; + +type JobId = string; + +/** A pending mutation for a job: create an entity, record a fact, or terminate (delete) an entity. */ +type StagedOp = + | { kind: 'createEntity'; entity: StoredEntity } + | { kind: 'record'; fact: StoredFact } + | { kind: 'terminate'; contract: AztecAddress; scope: AztecAddress; entityTypeId: Fr; entityId: Fr }; + +/** + * Stores immutable facts about entities, grouped by contract, scope, entity type, and entity id. + * + * Append-only within a job commit. Retractable facts (those with a block anchor) are deleted on block prune. + * Non-retractable facts (anchor === undefined) survive reorgs as external inputs. Writes are staged per-job and + * flushed atomically on commit. + */ +export class EntityStore implements StagedStore { + readonly storeName: string = 'entity'; + + #store: AztecAsyncKVStore; + /** Primary entity records, keyed by entityKey; holds the entity payload and optional anchor. */ + #entities: AztecAsyncMap; + /** Index from blockNumber to entityKey, for delete-on-prune of retractable entities (anchored entities only). */ + #entitiesByBlock: AztecAsyncMultiMap; + /** Primary fact records, keyed by factRowKey (deduplication row key). */ + #facts: AztecAsyncMap; + /** Index from entityKey to factRowKey for efficient entity-level fold. */ + #factsByEntity: AztecAsyncMultiMap; + /** Index from scopeKey to entityId string for active-entity enumeration. */ + #entitiesByScope: AztecAsyncMultiMap; + /** Index from blockNumber to factRowKey, for delete-on-prune (retractable facts only). */ + #factsByBlock: AztecAsyncMultiMap; + + #opsForJob: Map; + #jobLocks: Map; + + logger = createLogger('entity_store'); + + constructor(store: AztecAsyncKVStore) { + this.#store = store; + this.#entities = store.openMap('entities'); + this.#entitiesByBlock = store.openMultiMap('entities_by_block'); + this.#facts = store.openMap('facts'); + this.#factsByEntity = store.openMultiMap('facts_by_entity'); + this.#entitiesByScope = store.openMultiMap('entities_by_scope'); + this.#factsByBlock = store.openMultiMap('facts_by_block'); + this.#opsForJob = new Map(); + this.#jobLocks = new Map(); + } + + /** + * Stages an entity record (with its own payload and optional anchor) under the given job. `anchor === undefined` + * marks the entity non-retractable (it survives reorgs; only its own retractable facts are pruned); a defined + * anchor marks the whole entity retractable — on a prune above its block, the entity and all its facts are deleted. + * The entity becomes active once committed, independently of whether it owns any facts. + */ + createEntity( + contract: AztecAddress, + scope: AztecAddress, + entityTypeId: Fr, + entityId: Fr, + payload: Fr[], + anchor: FactAnchor | undefined, + jobId: string, + ): Promise { + return this.#withJobLock(jobId, () => { + const entity = new StoredEntity(contract, scope, entityTypeId, entityId, payload, anchor); + this.#opsFor(jobId).push({ kind: 'createEntity', entity }); + return Promise.resolve(); + }); + } + + /** + * Stages a fact for recording under the given job. `anchor === undefined` marks the fact non-retractable (it + * survives reorgs); a defined anchor ties the fact to a specific block and it will be deleted on prune. + * Idempotent: duplicate (entity, factType, payload) tuples collapse to a single row via the dedup row key. + */ + recordFact( + contract: AztecAddress, + scope: AztecAddress, + entityTypeId: Fr, + entityId: Fr, + factTypeId: Fr, + payload: Fr[], + anchor: FactAnchor | undefined, + jobId: string, + ): Promise { + return this.#withJobLock(jobId, () => { + const fact = new StoredFact(contract, scope, entityTypeId, entityId, factTypeId, payload, anchor); + this.#opsFor(jobId).push({ kind: 'record', fact }); + return Promise.resolve(); + }); + } + + /** Permanently delete an entity (all its facts). Staged within the job; applied on commit. */ + terminateEntity( + contract: AztecAddress, + scope: AztecAddress, + entityTypeId: Fr, + entityId: Fr, + jobId: string, + ): Promise { + return this.#withJobLock(jobId, () => { + this.#opsFor(jobId).push({ kind: 'terminate', contract, scope, entityTypeId, entityId }); + return Promise.resolve(); + }); + } + + /** + * Returns the facts for one entity. + * + * @param contract - The contract address owning the entity. + * @param scope - The scope (recipient address) under which facts were recorded. + * @param entityTypeId - Discriminates entity kinds within a contract+scope. + * @param entityId - Identifies the specific entity instance. + * @param jobId - The job whose staged writes are layered over committed state. + */ + async getEntityFacts( + contract: AztecAddress, + scope: AztecAddress, + entityTypeId: Fr, + entityId: Fr, + jobId: string, + ): Promise { + const eKey = entityKey(contract, scope, entityTypeId, entityId); + const byRow = await this.#store.transactionAsync(() => this.#loadCommittedFacts(eKey)); + this.#replayFactOps(byRow, eKey, jobId); + return Array.from(byRow.values()); + } + + /** + * Returns one entity's payload together with its facts. + * + * The payload comes from the entity record (empty when no entity record exists); the facts come from the per-entity + * fact index. This job's staged ops are layered over committed state for read-your-writes: a createEntity sets the + * payload, a record adds/dedups a fact, a terminate clears both payload and facts. + * + * @param contract - The contract address owning the entity. + * @param scope - The scope (recipient address) under which the entity was created. + * @param entityTypeId - Discriminates entity kinds within a contract+scope. + * @param entityId - Identifies the specific entity instance. + * @param jobId - The job whose staged writes are layered over committed state. + */ + async getEntity( + contract: AztecAddress, + scope: AztecAddress, + entityTypeId: Fr, + entityId: Fr, + jobId: string, + ): Promise<{ payload: Fr[]; facts: StoredFact[] }> { + const eKey = entityKey(contract, scope, entityTypeId, entityId); + const { payload, byRow } = await this.#store.transactionAsync(async () => { + const entityBuf = await this.#entities.getAsync(eKey); + return { + payload: entityBuf ? StoredEntity.fromBuffer(entityBuf).payload : [], + byRow: await this.#loadCommittedFacts(eKey), + }; + }); + // Replay this job's staged ops in order over committed state. Order matters so a terminate-then-create sequence + // resolves to the re-created entity, and a terminate clears both payload and facts. + let currentPayload = payload; + for (const op of this.#stagedOps(jobId)) { + if (op.kind === 'createEntity') { + if (entityKeyOf(op.entity) === eKey) { + currentPayload = op.entity.payload; + } + } else if (op.kind === 'record') { + if (entityKeyOf(op.fact) === eKey) { + byRow.set(factRowKeyOf(op.fact), op.fact); + } + } else if (entityKey(op.contract, op.scope, op.entityTypeId, op.entityId) === eKey) { + currentPayload = []; + byRow.clear(); + } + } + return { payload: currentPayload, facts: Array.from(byRow.values()) }; + } + + /** + * Returns the entity ids of all active entities under (contract, scope, entityTypeId) — entities that have an + * entity record and have not been terminated. Entity presence is independent of whether the entity owns any facts. + */ + async activeEntities(contract: AztecAddress, scope: AztecAddress, entityTypeId: Fr, jobId: string): Promise { + const sKey = scopeKey(contract, scope, entityTypeId); + const active = await this.#store.transactionAsync(async () => { + const seen = new Set(); + const result = new Set(); + for await (const entityId of this.#entitiesByScope.getValuesAsync(sKey)) { + if (seen.has(entityId)) { + continue; + } + seen.add(entityId); + // Guard against stale index entries: once terminate/rollback delete an entity record, its entity id + // should be gone from #entitiesByScope, but we re-check the entity record exists so a missed index update + // can never surface a ghost entity as active. + if (await this.#entities.getAsync(`${sKey}:${entityId}`)) { + result.add(entityId); + } + } + return result; + }); + // Replay this job's staged ops in order: a createEntity activates the entity, a terminate deactivates it. + for (const op of this.#stagedOps(jobId)) { + if (op.kind === 'createEntity') { + if (scopeKeyOf(op.entity) === sKey) { + active.add(op.entity.entityId.toString()); + } + } else if (op.kind === 'terminate' && scopeKey(op.contract, op.scope, op.entityTypeId) === sKey) { + active.delete(op.entityId.toString()); + } + } + return Array.from(active, Fr.fromString); + } + + /** + * Commits all staged operations for the given job to persistent storage. + * + * Must be called inside a transaction owned by the caller (JobCoordinator wraps all commits in a single + * transactionAsync, and IndexedDB does not support nested transactions). Do not call #withJobLock here — awaiting + * the lock creates a microtask boundary that causes IndexedDB to auto-commit the outer transaction. + */ + async commit(jobId: string): Promise { + for (const op of this.#opsFor(jobId)) { + if (op.kind === 'createEntity') { + const entity = op.entity; + const eKey = entityKeyOf(entity); + // Re-creating an entity may change or drop its anchor; clear any stale by-block index entry from the prior + // record first, so a later prune can neither double-visit this entity (and throw) nor wrongly delete one that + // has since become non-retractable. + const priorBuf = await this.#entities.getAsync(eKey); + if (priorBuf) { + const prior = StoredEntity.fromBuffer(priorBuf); + if (prior.anchor !== undefined) { + await this.#entitiesByBlock.deleteValue(prior.anchor.blockNumber, eKey); + } + } + await this.#entities.set(eKey, entity.toBuffer()); + await this.#entitiesByScope.set(scopeKeyOf(entity), entity.entityId.toString()); + if (entity.anchor !== undefined) { + await this.#entitiesByBlock.set(entity.anchor.blockNumber, eKey); + } + } else if (op.kind === 'record') { + const fact = op.fact; + const rowKey = factRowKeyOf(fact); + await this.#facts.set(rowKey, fact.toBuffer()); + await this.#factsByEntity.set(entityKeyOf(fact), rowKey); + if (fact.anchor !== undefined) { + await this.#factsByBlock.set(fact.anchor.blockNumber, rowKey); + } + } else { + await this.#deleteEntity(op.contract, op.scope, op.entityTypeId, op.entityId); + } + } + this.#clearJobData(jobId); + } + + /** Discards all staged operations for the given job without persisting them. */ + discardStaged(jobId: string): Promise { + this.#clearJobData(jobId); + return Promise.resolve(); + } + + /** + * Delete-on-prune in two passes. Pass 1 deletes every retractable entity anchored to a block strictly above + * `toBlock` wholesale — its payload and every fact it owns, regardless of each fact's own flag. Pass 2 deletes any + * remaining retractable fact anchored above `toBlock` whose entity survived pass 1. Non-retractable entities and + * facts are untouched (they never enter the by-block indexes). Must run inside a caller-owned transaction (the + * reorg path wraps it with the sibling stores' rollbacks; IndexedDB has no nested transactions). Throws if any job + * has uncommitted staged writes, since rolling back mid-job could re-introduce records anchored to deleted blocks. + */ + async rollback(toBlock: number): Promise { + if (this.#opsForJob.size > 0) { + throw new Error('PXE entity store rollback is not allowed while jobs are running'); + } + + // Pass 1: delete retractable entities anchored above toBlock wholesale. Snapshot before mutating so we never + // delete from the multimap we are iterating. + const orphanedEntities: string[] = []; + for await (const [, eKey] of this.#entitiesByBlock.entriesAsync({ start: toBlock + 1 })) { + orphanedEntities.push(eKey); + } + const deletedEntities = new Set(); + let removedEntities = 0; + for (const eKey of orphanedEntities) { + const buf = await this.#entities.getAsync(eKey); + if (!buf) { + // An #entitiesByBlock entry must always reference a live #entities row; a missing one means the indexes are + // corrupt, so fail loudly rather than leave a ghost entity behind. + throw new Error(`Entity not found for entityKey ${eKey}`); + } + const entity = StoredEntity.fromBuffer(buf); + await this.#deleteEntity(entity.contractAddress, entity.scope, entity.entityTypeId, entity.entityId); + deletedEntities.add(eKey); + removedEntities++; + } + + // Pass 2: delete remaining retractable facts anchored above toBlock whose entity survived pass 1. Pass 1 already + // removed the facts_by_block rows of pruned entities, so any leftover row here belongs to a surviving entity. + const orphanedFacts: { block: number; rowKey: string }[] = []; + for await (const [block, rowKey] of this.#factsByBlock.entriesAsync({ start: toBlock + 1 })) { + orphanedFacts.push({ block, rowKey }); + } + let removedFacts = 0; + for (const { block, rowKey } of orphanedFacts) { + const buf = await this.#facts.getAsync(rowKey); + if (!buf) { + // A still-present by-block entry with no fact row means the indexes are corrupt (pass 1 removes both the row + // and the by-block entry for pruned entities), so fail loudly rather than leave a dangling index entry. + throw new Error(`Fact not found for rowKey ${rowKey}`); + } + const fact = StoredFact.fromBuffer(buf); + const eKey = entityKeyOf(fact); + // Belt-and-braces: a fact whose entity was pruned in pass 1 must not be re-processed here. With #deleteEntity + // clearing the by-block index this is unreachable, but the guard keeps pass 2 correct if that ever changes. + if (deletedEntities.has(eKey)) { + continue; + } + await this.#facts.delete(rowKey); + await this.#factsByBlock.deleteValue(block, rowKey); + await this.#factsByEntity.deleteValue(eKey, rowKey); + removedFacts++; + } + this.logger.verbose('rolled back entity store', { removedEntities, removedFacts, toBlock }); + } + + // ---- private helpers ---- + + /** Loads the committed facts for an entity keyed by their dedup row key. Caller may wrap in a transaction. */ + async #loadCommittedFacts(eKey: string): Promise> { + const rows = new Map(); + for await (const rowKey of this.#factsByEntity.getValuesAsync(eKey)) { + const buf = await this.#facts.getAsync(rowKey); + if (buf) { + rows.set(rowKey, StoredFact.fromBuffer(buf)); + } + } + return rows; + } + + /** + * Replays a job's staged ops over a committed fact map for read-your-writes: a record sets (dedups) a fact row, a + * terminate clears the entity's facts. Order matters so a terminate-then-record sequence resolves to the re-recorded + * fact. createEntity ops do not affect the fact set. + */ + #replayFactOps(byRow: Map, eKey: string, jobId: string): void { + for (const op of this.#stagedOps(jobId)) { + if (op.kind === 'record') { + if (entityKeyOf(op.fact) === eKey) { + byRow.set(factRowKeyOf(op.fact), op.fact); + } + } else if (op.kind === 'terminate' && entityKey(op.contract, op.scope, op.entityTypeId, op.entityId) === eKey) { + byRow.clear(); + } + } + } + + /** + * Deletes an entity wholesale from every index: its record, all its facts, and the scope/block index entries. + * Called during commit for 'terminate' ops and during pass 1 of rollback for pruned retractable entities. + */ + async #deleteEntity(contract: AztecAddress, scope: AztecAddress, entityTypeId: Fr, entityId: Fr): Promise { + const eKey = entityKey(contract, scope, entityTypeId, entityId); + const rowKeys: string[] = []; + for await (const rowKey of this.#factsByEntity.getValuesAsync(eKey)) { + rowKeys.push(rowKey); + } + for (const rowKey of rowKeys) { + const buf = await this.#facts.getAsync(rowKey); + if (!buf) { + // A #factsByEntity entry must always reference a live #facts row; a missing one means the indexes are + // corrupt, so fail loudly rather than silently skip cleanup. + throw new Error(`Fact not found for rowKey ${rowKey}`); + } + const fact = StoredFact.fromBuffer(buf); + await this.#facts.delete(rowKey); + await this.#factsByEntity.deleteValue(eKey, rowKey); + if (fact.anchor !== undefined) { + await this.#factsByBlock.deleteValue(fact.anchor.blockNumber, rowKey); + } + } + const entityBuf = await this.#entities.getAsync(eKey); + if (entityBuf) { + const entity = StoredEntity.fromBuffer(entityBuf); + await this.#entities.delete(eKey); + if (entity.anchor !== undefined) { + await this.#entitiesByBlock.deleteValue(entity.anchor.blockNumber, eKey); + } + } + await this.#entitiesByScope.deleteValue(scopeKey(contract, scope, entityTypeId), entityId.toString()); + } + + #opsFor(jobId: string): StagedOp[] { + let ops = this.#opsForJob.get(jobId); + if (ops === undefined) { + ops = []; + this.#opsForJob.set(jobId, ops); + } + return ops; + } + + /** + * Read-only view of a job's staged ops for read-your-writes. Unlike {@link #opsFor}, it never creates an entry, so a + * read for a job with no writes does not register a phantom in-flight job (which would trip the `rollback` guard). + */ + #stagedOps(jobId: string): StagedOp[] { + return this.#opsForJob.get(jobId) ?? []; + } + + #clearJobData(jobId: string) { + this.#opsForJob.delete(jobId); + this.#jobLocks.delete(jobId); + } + + async #withJobLock(jobId: string, fn: () => Promise): Promise { + let lock = this.#jobLocks.get(jobId); + if (!lock) { + lock = new Semaphore(1); + this.#jobLocks.set(jobId, lock); + } + await lock.acquire(); + try { + return await fn(); + } finally { + lock.release(); + } + } +} diff --git a/yarn-project/pxe/src/storage/entity_store/index.ts b/yarn-project/pxe/src/storage/entity_store/index.ts new file mode 100644 index 000000000000..8c58301b3bcc --- /dev/null +++ b/yarn-project/pxe/src/storage/entity_store/index.ts @@ -0,0 +1 @@ +export { EntityStore } from './entity_store.js'; diff --git a/yarn-project/pxe/src/storage/entity_store/stored_fact.test.ts b/yarn-project/pxe/src/storage/entity_store/stored_fact.test.ts new file mode 100644 index 000000000000..96204e29ccf2 --- /dev/null +++ b/yarn-project/pxe/src/storage/entity_store/stored_fact.test.ts @@ -0,0 +1,75 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; + +import { StoredEntity, StoredFact, entityKeyOf, factRowKeyOf, scopeKeyOf } from './stored_fact.js'; + +describe('StoredFact', () => { + const contract = AztecAddress.fromBigInt(100n); + const scope = AztecAddress.fromBigInt(1n); + const entityType = new Fr(7n); + const entityId = new Fr(42n); + const factType = new Fr(3n); + + it('round-trips a retractable fact through buffer serialization', () => { + const fact = new StoredFact(contract, scope, entityType, entityId, factType, [new Fr(9n), new Fr(10n)], { + blockNumber: 12, + blockHash: new Fr(0xabcn), + }); + const back = StoredFact.fromBuffer(fact.toBuffer()); + expect(back).toEqual(fact); + expect(back.isRetractable).toBe(true); + }); + + it('round-trips a non-retractable fact (no anchor)', () => { + const fact = new StoredFact(contract, scope, entityType, entityId, factType, [new Fr(9n)], undefined); + const back = StoredFact.fromBuffer(fact.toBuffer()); + expect(back).toEqual(fact); + expect(back.isRetractable).toBe(false); + }); + + it('derives stable composite keys', () => { + const fact = new StoredFact(contract, scope, entityType, entityId, factType, [new Fr(9n)], undefined); + expect(scopeKeyOf(fact)).toBe(`${contract}:${scope}:${entityType}`); + expect(entityKeyOf(fact)).toBe(`${contract}:${scope}:${entityType}:${entityId}`); + expect(factRowKeyOf(fact)).toBe(entityKeyOf(fact) + `:${factType}:${fact.payloadHash()}`); + }); + + it('gives distinct payload hashes for distinct payloads and equal for equal', () => { + const a = new StoredFact(contract, scope, entityType, entityId, factType, [new Fr(1n)], undefined); + const b = new StoredFact(contract, scope, entityType, entityId, factType, [new Fr(2n)], undefined); + const c = new StoredFact(contract, scope, entityType, entityId, factType, [new Fr(1n)], undefined); + expect(a.payloadHash()).not.toEqual(b.payloadHash()); + expect(a.payloadHash()).toEqual(c.payloadHash()); + }); +}); + +describe('StoredEntity', () => { + const contract = AztecAddress.fromBigInt(100n); + const scope = AztecAddress.fromBigInt(1n); + const entityType = new Fr(7n); + const entityId = new Fr(42n); + + it('round-trips a retractable entity through buffer serialization', () => { + const entity = new StoredEntity(contract, scope, entityType, entityId, [new Fr(9n), new Fr(10n)], { + blockNumber: 12, + blockHash: new Fr(0xabcn), + }); + const back = StoredEntity.fromBuffer(entity.toBuffer()); + expect(back).toEqual(entity); + expect(back.isRetractable).toBe(true); + }); + + it('round-trips a non-retractable entity (no anchor)', () => { + const entity = new StoredEntity(contract, scope, entityType, entityId, [new Fr(9n)], undefined); + const back = StoredEntity.fromBuffer(entity.toBuffer()); + expect(back).toEqual(entity); + expect(back.isRetractable).toBe(false); + }); + + it('round-trips an entity with an empty payload', () => { + const entity = new StoredEntity(contract, scope, entityType, entityId, [], undefined); + const back = StoredEntity.fromBuffer(entity.toBuffer()); + expect(back).toEqual(entity); + expect(back.payload).toEqual([]); + }); +}); diff --git a/yarn-project/pxe/src/storage/entity_store/stored_fact.ts b/yarn-project/pxe/src/storage/entity_store/stored_fact.ts new file mode 100644 index 000000000000..d2025e28004a --- /dev/null +++ b/yarn-project/pxe/src/storage/entity_store/stored_fact.ts @@ -0,0 +1,149 @@ +import { sha256ToField } from '@aztec/foundation/crypto/sha256'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; + +/** The block an anchored (retractable) fact is tied to. */ +export type FactAnchor = { blockNumber: number; blockHash: Fr }; + +/** + * A single immutable fact about an entity. `anchor === undefined` marks the fact non-retractable (an external + * input that survives reorgs); an anchor marks it retractable (re-derivable, deleted when its block is pruned). + */ +export class StoredFact { + constructor( + public readonly contractAddress: AztecAddress, + public readonly scope: AztecAddress, + public readonly entityTypeId: Fr, + public readonly entityId: Fr, + public readonly factTypeId: Fr, + public readonly payload: Fr[], + public readonly anchor: FactAnchor | undefined, + ) {} + + /** Whether this fact is deleted on block pruning (true) or survives reorgs (false). */ + get isRetractable(): boolean { + return this.anchor !== undefined; + } + + /** Stable digest of the payload, used in the dedup row key (keeps the LMDB key bounded for large payloads). */ + payloadHash(): Fr { + return sha256ToField([this.payload.length, ...this.payload]); + } + + toBuffer(): Buffer { + const anchorTag = this.anchor ? 1 : 0; + return serializeToBuffer( + this.contractAddress, + this.scope, + this.entityTypeId, + this.entityId, + this.factTypeId, + this.payload.length, + ...this.payload, + anchorTag, + this.anchor ? this.anchor.blockNumber : 0, + this.anchor ? this.anchor.blockHash : Fr.ZERO, + ); + } + + static fromBuffer(buffer: Buffer | BufferReader): StoredFact { + const reader = BufferReader.asReader(buffer); + const contractAddress = reader.readObject(AztecAddress); + const scope = reader.readObject(AztecAddress); + const entityTypeId = reader.readObject(Fr); + const entityId = reader.readObject(Fr); + const factTypeId = reader.readObject(Fr); + const payloadLen = reader.readNumber(); + const payload = reader.readArray(payloadLen, Fr); + const anchorTag = reader.readNumber(); + const blockNumber = reader.readNumber(); + const blockHash = reader.readObject(Fr); + const anchor = anchorTag === 1 ? { blockNumber, blockHash } : undefined; + return new StoredFact(contractAddress, scope, entityTypeId, entityId, factTypeId, [...payload], anchor); + } +} + +/** + * The record for a single entity, with its own payload and optional block anchor. `anchor === undefined` marks the + * entity non-retractable (it survives reorgs; only its own retractable facts are pruned); an anchor marks the whole + * entity retractable — on a prune above its block, the entity payload and every fact it owns are deleted wholesale. + */ +export class StoredEntity { + constructor( + public readonly contractAddress: AztecAddress, + public readonly scope: AztecAddress, + public readonly entityTypeId: Fr, + public readonly entityId: Fr, + public readonly payload: Fr[], + public readonly anchor: FactAnchor | undefined, + ) {} + + /** Whether the whole entity is deleted on block pruning (true) or survives reorgs (false). */ + get isRetractable(): boolean { + return this.anchor !== undefined; + } + + toBuffer(): Buffer { + const anchorTag = this.anchor ? 1 : 0; + return serializeToBuffer( + this.contractAddress, + this.scope, + this.entityTypeId, + this.entityId, + this.payload.length, + ...this.payload, + anchorTag, + this.anchor ? this.anchor.blockNumber : 0, + this.anchor ? this.anchor.blockHash : Fr.ZERO, + ); + } + + static fromBuffer(buffer: Buffer | BufferReader): StoredEntity { + const reader = BufferReader.asReader(buffer); + const contractAddress = reader.readObject(AztecAddress); + const scope = reader.readObject(AztecAddress); + const entityTypeId = reader.readObject(Fr); + const entityId = reader.readObject(Fr); + const payloadLen = reader.readNumber(); + const payload = reader.readArray(payloadLen, Fr); + const anchorTag = reader.readNumber(); + const blockNumber = reader.readNumber(); + const blockHash = reader.readObject(Fr); + const anchor = anchorTag === 1 ? { blockNumber, blockHash } : undefined; + return new StoredEntity(contractAddress, scope, entityTypeId, entityId, [...payload], anchor); + } +} + +/** Key that groups all entities of the same type within a contract+scope. */ +export function scopeKey(contract: AztecAddress, scope: AztecAddress, entityTypeId: Fr): string { + return `${contract}:${scope}:${entityTypeId}`; +} + +/** Key that uniquely identifies a single entity (all its facts share this key prefix). */ +export function entityKey(contract: AztecAddress, scope: AztecAddress, entityTypeId: Fr, entityId: Fr): string { + return `${scopeKey(contract, scope, entityTypeId)}:${entityId}`; +} + +/** The contract+scope+entityType+entityId coordinates shared by facts and entity records. */ +type EntityCoords = { + contractAddress: AztecAddress; + scope: AztecAddress; + entityTypeId: Fr; + entityId: Fr; +}; + +/** Key that groups all entities of the same type within a contract+scope. */ +export function scopeKeyOf(coords: EntityCoords): string { + return scopeKey(coords.contractAddress, coords.scope, coords.entityTypeId); +} + +/** Key that uniquely identifies a single entity (all its facts share this key prefix). */ +export function entityKeyOf(coords: EntityCoords): string { + return entityKey(coords.contractAddress, coords.scope, coords.entityTypeId, coords.entityId); +} + +/** Dedup row key for a specific fact; incorporates a payload hash to bound key size for large payloads. */ +export function factRowKeyOf(fact: StoredFact): string { + return `${entityKeyOf(fact)}:${fact.factTypeId}:${fact.payloadHash()}`; +} diff --git a/yarn-project/pxe/src/storage/index.ts b/yarn-project/pxe/src/storage/index.ts index 47c5b840f7ee..c52e353eb6d1 100644 --- a/yarn-project/pxe/src/storage/index.ts +++ b/yarn-project/pxe/src/storage/index.ts @@ -2,6 +2,7 @@ export * from './address_store/index.js'; export * from './anchor_block_store/index.js'; export * from './capsule_store/index.js'; export * from './contract_store/index.js'; +export * from './entity_store/index.js'; export * from './note_store/index.js'; export * from './tagging_store/index.js'; export * from './metadata.js'; diff --git a/yarn-project/pxe/src/storage/open_pxe_stores.ts b/yarn-project/pxe/src/storage/open_pxe_stores.ts index 573b85a40984..45d6d131ae79 100644 --- a/yarn-project/pxe/src/storage/open_pxe_stores.ts +++ b/yarn-project/pxe/src/storage/open_pxe_stores.ts @@ -7,6 +7,7 @@ import { AddressStore } from './address_store/address_store.js'; import { AnchorBlockStore } from './anchor_block_store/anchor_block_store.js'; import { CapsuleStore } from './capsule_store/capsule_store.js'; import { ContractStore } from './contract_store/contract_store.js'; +import { EntityStore } from './entity_store/entity_store.js'; import { NoteStore } from './note_store/note_store.js'; import { PrivateEventStore } from './private_event_store/private_event_store.js'; import { RecipientTaggingStore, SenderAddressBookStore, SenderTaggingStore } from './tagging_store/index.js'; @@ -26,6 +27,7 @@ export type PxeStores = { capsuleStore: CapsuleStore; keyStore: KeyStore; l2TipsStore: L2TipsKVStore; + entityStore: EntityStore; }; /** @@ -45,5 +47,6 @@ export function openPxeStores(store: AztecAsyncKVStore, initialBlockHash: BlockH capsuleStore: new CapsuleStore(store), keyStore: new KeyStore(store), l2TipsStore: new L2TipsKVStore(store, 'pxe', initialBlockHash), + entityStore: new EntityStore(store), }; } From e73c915caf4249f1edf936e9045dbc9dfe11579c Mon Sep 17 00:00:00 2001 From: mverzilli Date: Wed, 10 Jun 2026 12:45:54 +0000 Subject: [PATCH 10/31] feat(pxe): prune retractable entity facts on chain prune The block synchronizer rolls the entity store back alongside the note and private event stores when the chain is pruned: entities and facts anchored to orphaned blocks are deleted, unanchored ones survive. EntityStore is registered with the job coordinator so staged facts flush atomically with the other stores on job commit. --- .../block_synchronizer.test.ts | 58 +++++++++++++++++++ .../block_synchronizer/block_synchronizer.ts | 3 + yarn-project/pxe/src/pxe.ts | 3 + 3 files changed, 64 insertions(+) diff --git a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts index 057659691913..b32ca51d886a 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts @@ -27,6 +27,7 @@ import { type MockProxy, mock } from 'jest-mock-extended'; import type { BlockSynchronizerConfig } from '../config/index.js'; import type { ContractSyncService } from '../contract_sync/contract_sync_service.js'; import { AnchorBlockStore } from '../storage/anchor_block_store/anchor_block_store.js'; +import { EntityStore } from '../storage/entity_store/entity_store.js'; import { NoteStore } from '../storage/note_store/note_store.js'; import { PrivateEventStore } from '../storage/private_event_store/private_event_store.js'; import { BlockSynchronizer } from './block_synchronizer.js'; @@ -44,6 +45,7 @@ describe('BlockSynchronizer', () => { let anchorBlockStore: AnchorBlockStore; let noteStore: NoteStore; let privateEventStore: PrivateEventStore; + let entityStore: EntityStore; let aztecNode: MockProxy; let getBlock: NodeGetBlockMock; let blockStream: MockProxy; @@ -62,6 +64,7 @@ describe('BlockSynchronizer', () => { anchorBlockStore, noteStore, privateEventStore, + entityStore, tipsStore, contractSyncService, config, @@ -110,6 +113,7 @@ describe('BlockSynchronizer', () => { anchorBlockStore = new AnchorBlockStore(store); noteStore = new NoteStore(store); privateEventStore = new PrivateEventStore(store); + entityStore = new EntityStore(store); contractSyncService = mock(); synchronizer = createSynchronizer(); }); @@ -274,6 +278,60 @@ describe('BlockSynchronizer', () => { expect(await privateEventStore.eventIdsAtBlock(9)).toEqual([eventId9.toString()]); }); + it('chain-pruned deletes anchored facts and keeps unanchored ones via the synchronizer', async () => { + const contract = await AztecAddress.random(); + const scope = await AztecAddress.random(); + const entityTypeId = Fr.random(); + const entityId = Fr.random(); + // Two distinct fact-type ids stand in for RECEIVED (non-retractable) and PROCESSED (anchored). + const RECEIVED = Fr.random(); + const PROCESSED = Fr.random(); + const jobId = 'fact-job'; + + // Block 5 is the fork point; block 10 is on the abandoned fork (above the fork). + const forkBlock = await L2Block.random(BlockNumber(5)); + const block5 = makeL2BlockId(forkBlock.number, (await forkBlock.hash()).toString()); + + // Seed: one non-retractable (unanchored) fact and one anchored above the fork. + await entityStore.recordFact(contract, scope, entityTypeId, entityId, RECEIVED, [Fr.random()], undefined, jobId); + await entityStore.recordFact( + contract, + scope, + entityTypeId, + entityId, + PROCESSED, + [], + { blockNumber: 10, blockHash: Fr.random() }, + jobId, + ); + await store.transactionAsync(() => entityStore.commit(jobId)); + + // Both facts must be present before the prune. + expect(await entityStore.getEntityFacts(contract, scope, entityTypeId, entityId, jobId)).toHaveLength(2); + + // Set the anchor to block 10 so the prune guard passes (anchor is above the fork point). + const anchorBlock10 = await L2Block.random(BlockNumber(10)); + await anchorBlockStore.setHeader(anchorBlock10.header); + + // The node serves the fork-point block; it becomes the new anchor after the prune. + const forkResponse = await blockResponse(forkBlock); + getBlock.mockImplementation(param => + Promise.resolve(param instanceof BlockHash && param.equals(forkResponse.hash) ? forkResponse : undefined), + ); + + // Prune back to block 5 (orphaning block 10 where the PROCESSED fact is anchored). + await synchronizer.handleBlockStreamEvent({ + type: 'chain-pruned', + block: block5, + checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), + }); + + // The anchored PROCESSED fact must be gone; the unanchored RECEIVED fact must survive. + const remaining = await entityStore.getEntityFacts(contract, scope, entityTypeId, entityId, jobId); + expect(remaining).toHaveLength(1); + expect(remaining[0].factTypeId.equals(RECEIVED)).toBe(true); + }); + it('notes below the fork survive and remain queryable after a prune', async () => { const contract = await AztecAddress.random(); const scope = await AztecAddress.random(); diff --git a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts index f0e0735f9a0d..13e138c157fc 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts @@ -10,6 +10,7 @@ import type { BlockHeader } from '@aztec/stdlib/tx'; import type { BlockSynchronizerConfig } from '../config/index.js'; import type { ContractSyncService } from '../contract_sync/contract_sync_service.js'; import type { AnchorBlockStore } from '../storage/anchor_block_store/index.js'; +import type { EntityStore } from '../storage/entity_store/entity_store.js'; import type { NoteStore } from '../storage/note_store/index.js'; import type { PrivateEventStore } from '../storage/private_event_store/private_event_store.js'; import { blockStreamSourceFromAztecNode } from './block_stream_source.js'; @@ -31,6 +32,7 @@ export class BlockSynchronizer implements L2BlockStreamEventHandler { private anchorBlockStore: AnchorBlockStore, private noteStore: NoteStore, private privateEventStore: PrivateEventStore, + private entityStore: EntityStore, private l2TipsStore: L2TipsKVStore, private contractSyncService: ContractSyncService, private config: Partial = {}, @@ -131,6 +133,7 @@ export class BlockSynchronizer implements L2BlockStreamEventHandler { await this.store.transactionAsync(async () => { await this.noteStore.rollback(event.block.number); await this.privateEventStore.rollback(event.block.number); + await this.entityStore.rollback(event.block.number); await this.updateAnchorBlockHeader(newAnchorBlockHeader); }); break; diff --git a/yarn-project/pxe/src/pxe.ts b/yarn-project/pxe/src/pxe.ts index 29ff530195fc..004f57d04157 100644 --- a/yarn-project/pxe/src/pxe.ts +++ b/yarn-project/pxe/src/pxe.ts @@ -263,6 +263,7 @@ export class PXE { capsuleStore, keyStore, l2TipsStore, + entityStore, } = openPxeStores(store, initialBlockHash); const contractSyncService = new ContractSyncService( node, @@ -278,6 +279,7 @@ export class PXE { anchorBlockStore, noteStore, privateEventStore, + entityStore, l2TipsStore, contractSyncService, config, @@ -291,6 +293,7 @@ export class PXE { recipientTaggingStore, privateEventStore, noteStore, + entityStore, contractSyncService, ]); From 0792f7e66d20d7f6b4fa53794a081b0b7ae7a46b Mon Sep 17 00:00:00 2001 From: mverzilli Date: Wed, 10 Jun 2026 13:17:13 +0000 Subject: [PATCH 11/31] test(pxe): cover retractable entity prune in the synchronizer test Rewrite the chain-pruned entity store test in neutral terms (no offchain-message vocabulary) and extend it to cover entity-level retraction: a retractable entity anchored on the abandoned fork is deleted wholesale including its unanchored fact, while a non-retractable entity survives losing only its anchored fact. --- .../block_synchronizer.test.ts | 65 ++++++++++++++----- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts index b32ca51d886a..814bbd4f3eff 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts @@ -278,36 +278,63 @@ describe('BlockSynchronizer', () => { expect(await privateEventStore.eventIdsAtBlock(9)).toEqual([eventId9.toString()]); }); - it('chain-pruned deletes anchored facts and keeps unanchored ones via the synchronizer', async () => { + it('chain-pruned deletes entities and facts anchored above the fork and keeps unanchored ones', async () => { const contract = await AztecAddress.random(); const scope = await AztecAddress.random(); const entityTypeId = Fr.random(); - const entityId = Fr.random(); - // Two distinct fact-type ids stand in for RECEIVED (non-retractable) and PROCESSED (anchored). - const RECEIVED = Fr.random(); - const PROCESSED = Fr.random(); - const jobId = 'fact-job'; + const prunedEntityId = Fr.random(); + const survivingEntityId = Fr.random(); + const anchoredFactType = Fr.random(); + const unanchoredFactType = Fr.random(); + const jobId = 'entity-job'; // Block 5 is the fork point; block 10 is on the abandoned fork (above the fork). const forkBlock = await L2Block.random(BlockNumber(5)); const block5 = makeL2BlockId(forkBlock.number, (await forkBlock.hash()).toString()); + const orphanAnchor = { blockNumber: 10, blockHash: Fr.random() }; - // Seed: one non-retractable (unanchored) fact and one anchored above the fork. - await entityStore.recordFact(contract, scope, entityTypeId, entityId, RECEIVED, [Fr.random()], undefined, jobId); + // A retractable entity anchored on the abandoned fork: the prune must delete it wholesale, taking even its + // unanchored fact with it. + await entityStore.createEntity(contract, scope, entityTypeId, prunedEntityId, [Fr.random()], orphanAnchor, jobId); await entityStore.recordFact( contract, scope, entityTypeId, - entityId, - PROCESSED, + prunedEntityId, + unanchoredFactType, + [Fr.random()], + undefined, + jobId, + ); + + // A non-retractable entity: the prune must keep it, deleting only its fact anchored on the abandoned fork. + await entityStore.createEntity(contract, scope, entityTypeId, survivingEntityId, [Fr.random()], undefined, jobId); + await entityStore.recordFact( + contract, + scope, + entityTypeId, + survivingEntityId, + unanchoredFactType, + [Fr.random()], + undefined, + jobId, + ); + await entityStore.recordFact( + contract, + scope, + entityTypeId, + survivingEntityId, + anchoredFactType, [], - { blockNumber: 10, blockHash: Fr.random() }, + orphanAnchor, jobId, ); await store.transactionAsync(() => entityStore.commit(jobId)); - // Both facts must be present before the prune. - expect(await entityStore.getEntityFacts(contract, scope, entityTypeId, entityId, jobId)).toHaveLength(2); + // Both entities and all their facts must be present before the prune. + expect(await entityStore.activeEntities(contract, scope, entityTypeId, jobId)).toHaveLength(2); + expect(await entityStore.getEntityFacts(contract, scope, entityTypeId, prunedEntityId, jobId)).toHaveLength(1); + expect(await entityStore.getEntityFacts(contract, scope, entityTypeId, survivingEntityId, jobId)).toHaveLength(2); // Set the anchor to block 10 so the prune guard passes (anchor is above the fork point). const anchorBlock10 = await L2Block.random(BlockNumber(10)); @@ -319,17 +346,21 @@ describe('BlockSynchronizer', () => { Promise.resolve(param instanceof BlockHash && param.equals(forkResponse.hash) ? forkResponse : undefined), ); - // Prune back to block 5 (orphaning block 10 where the PROCESSED fact is anchored). + // Prune back to block 5, orphaning block 10 where the retractable records are anchored. await synchronizer.handleBlockStreamEvent({ type: 'chain-pruned', block: block5, checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), }); - // The anchored PROCESSED fact must be gone; the unanchored RECEIVED fact must survive. - const remaining = await entityStore.getEntityFacts(contract, scope, entityTypeId, entityId, jobId); + // The retractable entity is gone wholesale; the non-retractable one survives with only its unanchored fact. + const active = await entityStore.activeEntities(contract, scope, entityTypeId, jobId); + expect(active).toHaveLength(1); + expect(active[0].equals(survivingEntityId)).toBe(true); + expect(await entityStore.getEntityFacts(contract, scope, entityTypeId, prunedEntityId, jobId)).toHaveLength(0); + const remaining = await entityStore.getEntityFacts(contract, scope, entityTypeId, survivingEntityId, jobId); expect(remaining).toHaveLength(1); - expect(remaining[0].factTypeId.equals(RECEIVED)).toBe(true); + expect(remaining[0].factTypeId.equals(unanchoredFactType)).toBe(true); }); it('notes below the fork survive and remain queryable after a prune', async () => { From ac97814c8ed61f3304e0b63f83a3199a8c2e27eb Mon Sep 17 00:00:00 2001 From: mverzilli Date: Wed, 10 Jun 2026 13:22:08 +0000 Subject: [PATCH 12/31] refactor(pxe): rename entity store anchor to origin block 'Anchor' is already taken by the PXE anchor block concept; the block a retractable entity or fact is tied to is now its 'origin block' (FactAnchor -> OriginBlock, anchor -> originBlock). No serialization change: the buffer layout carries no field names, so the stored format and compatibility snapshots are untouched. --- .../block_synchronizer.test.ts | 38 +++++++----- .../schema_tests.ts | 2 +- .../storage/entity_store/entity_store.test.ts | 14 ++--- .../src/storage/entity_store/entity_store.ts | 62 ++++++++++--------- .../storage/entity_store/stored_fact.test.ts | 4 +- .../src/storage/entity_store/stored_fact.ts | 51 +++++++-------- 6 files changed, 91 insertions(+), 80 deletions(-) diff --git a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts index 814bbd4f3eff..ee2f51b579ad 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts @@ -278,43 +278,51 @@ describe('BlockSynchronizer', () => { expect(await privateEventStore.eventIdsAtBlock(9)).toEqual([eventId9.toString()]); }); - it('chain-pruned deletes entities and facts anchored above the fork and keeps unanchored ones', async () => { + it('chain-pruned deletes entities and facts originating above the fork, keeps non-retractable ones', async () => { const contract = await AztecAddress.random(); const scope = await AztecAddress.random(); const entityTypeId = Fr.random(); const prunedEntityId = Fr.random(); const survivingEntityId = Fr.random(); - const anchoredFactType = Fr.random(); - const unanchoredFactType = Fr.random(); + const retractableFactType = Fr.random(); + const nonRetractableFactType = Fr.random(); const jobId = 'entity-job'; // Block 5 is the fork point; block 10 is on the abandoned fork (above the fork). const forkBlock = await L2Block.random(BlockNumber(5)); const block5 = makeL2BlockId(forkBlock.number, (await forkBlock.hash()).toString()); - const orphanAnchor = { blockNumber: 10, blockHash: Fr.random() }; + const orphanedOriginBlock = { blockNumber: 10, blockHash: Fr.random() }; - // A retractable entity anchored on the abandoned fork: the prune must delete it wholesale, taking even its - // unanchored fact with it. - await entityStore.createEntity(contract, scope, entityTypeId, prunedEntityId, [Fr.random()], orphanAnchor, jobId); + // A retractable entity originating on the abandoned fork: the prune must delete it wholesale, taking even its + // non-retractable fact with it. + await entityStore.createEntity( + contract, + scope, + entityTypeId, + prunedEntityId, + [Fr.random()], + orphanedOriginBlock, + jobId, + ); await entityStore.recordFact( contract, scope, entityTypeId, prunedEntityId, - unanchoredFactType, + nonRetractableFactType, [Fr.random()], undefined, jobId, ); - // A non-retractable entity: the prune must keep it, deleting only its fact anchored on the abandoned fork. + // A non-retractable entity: the prune must keep it, deleting only its fact originating on the abandoned fork. await entityStore.createEntity(contract, scope, entityTypeId, survivingEntityId, [Fr.random()], undefined, jobId); await entityStore.recordFact( contract, scope, entityTypeId, survivingEntityId, - unanchoredFactType, + nonRetractableFactType, [Fr.random()], undefined, jobId, @@ -324,9 +332,9 @@ describe('BlockSynchronizer', () => { scope, entityTypeId, survivingEntityId, - anchoredFactType, + retractableFactType, [], - orphanAnchor, + orphanedOriginBlock, jobId, ); await store.transactionAsync(() => entityStore.commit(jobId)); @@ -346,21 +354,21 @@ describe('BlockSynchronizer', () => { Promise.resolve(param instanceof BlockHash && param.equals(forkResponse.hash) ? forkResponse : undefined), ); - // Prune back to block 5, orphaning block 10 where the retractable records are anchored. + // Prune back to block 5, orphaning block 10 where the retractable records originate. await synchronizer.handleBlockStreamEvent({ type: 'chain-pruned', block: block5, checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), }); - // The retractable entity is gone wholesale; the non-retractable one survives with only its unanchored fact. + // The retractable entity is gone wholesale; the non-retractable one keeps only its non-retractable fact. const active = await entityStore.activeEntities(contract, scope, entityTypeId, jobId); expect(active).toHaveLength(1); expect(active[0].equals(survivingEntityId)).toBe(true); expect(await entityStore.getEntityFacts(contract, scope, entityTypeId, prunedEntityId, jobId)).toHaveLength(0); const remaining = await entityStore.getEntityFacts(contract, scope, entityTypeId, survivingEntityId, jobId); expect(remaining).toHaveLength(1); - expect(remaining[0].factTypeId.equals(unanchoredFactType)).toBe(true); + expect(remaining[0].factTypeId.equals(nonRetractableFactType)).toBe(true); }); it('notes below the fork survive and remain queryable after a prune', async () => { diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts b/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts index 34e0c37b5411..3f60f89ed5a8 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts @@ -225,7 +225,7 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ const entityTypeId = new Fr(7n); const corrA = new Fr(0xaan); const corrB = new Fr(0xbbn); - // Retractable entity (anchored at block 6): the entity and all its facts are pruned on a reorg above block 6. + // Retractable entity (origin block 6): the entity and all its facts are pruned on a reorg above block 6. await entityStore.createEntity( contract, scope, diff --git a/yarn-project/pxe/src/storage/entity_store/entity_store.test.ts b/yarn-project/pxe/src/storage/entity_store/entity_store.test.ts index 80d19dbf3dbe..f39a336a60ac 100644 --- a/yarn-project/pxe/src/storage/entity_store/entity_store.test.ts +++ b/yarn-project/pxe/src/storage/entity_store/entity_store.test.ts @@ -183,7 +183,7 @@ describe('EntityStore', () => { }); it('rollback deletes a retractable entity wholesale (payload + every fact) above the target block', async () => { - // Retractable entity anchored at block 6, owning one unanchored and one anchored fact. + // Retractable entity originating at block 6, owning one fact without an origin block and one with. await store.createEntity( contract, scope, @@ -216,7 +216,7 @@ describe('EntityStore', () => { }); it('rollback keeps a non-retractable entity, pruning only its retractable facts', async () => { - // Non-retractable entity (no anchor) with one unanchored fact + one anchored fact at block 6. + // Non-retractable entity (no origin block) with a non-retractable fact + a fact originating at block 6. await store.createEntity(contract, scope, ENTITY, corrA, [new Fr(5n)], undefined, JOB); await store.recordFact(contract, scope, ENTITY, corrA, RECEIVED, [new Fr(9n)], undefined, JOB); await store.recordFact( @@ -242,7 +242,7 @@ describe('EntityStore', () => { ]); }); - it('rollback above all anchors is a no-op', async () => { + it('rollback above all origin blocks is a no-op', async () => { await store.createEntity( contract, scope, @@ -274,7 +274,7 @@ describe('EntityStore', () => { ]); }); - it('re-creating an entity with a changed anchor clears the stale by-block index', async () => { + it('re-creating an entity with a changed origin block clears the stale by-block index', async () => { await store.createEntity( contract, scope, @@ -286,7 +286,7 @@ describe('EntityStore', () => { ); await kv.transactionAsync(() => store.commit(JOB)); - // Re-create the same entity anchored at a different block. + // Re-create the same entity originating at a different block. const JOB2 = 'recreate-job'; await store.createEntity( contract, @@ -299,7 +299,7 @@ describe('EntityStore', () => { ); await kv.transactionAsync(() => store.commit(JOB2)); - // Prune above block 5: the entity (anchored at 8) is deleted exactly once. A stale block-6 index entry would make + // Prune above block 5: the entity (origin block 8) is deleted exactly once. A stale block-6 index entry would make // pass 1 visit it a second time and throw "Entity not found". await expect(kv.transactionAsync(() => store.rollback(5))).resolves.not.toThrow(); expect((await store.getEntity(contract, scope, ENTITY, corrA, JOB)).payload).toEqual([]); @@ -318,7 +318,7 @@ describe('EntityStore', () => { ); await kv.transactionAsync(() => store.commit(JOB)); - // Re-create the same entity without an anchor: it is now non-retractable and must survive reorgs. + // Re-create the same entity without an origin block: it is now non-retractable and must survive reorgs. const JOB2 = 'recreate-job'; await store.createEntity(contract, scope, ENTITY, corrA, [new Fr(5n)], undefined, JOB2); await kv.transactionAsync(() => store.commit(JOB2)); diff --git a/yarn-project/pxe/src/storage/entity_store/entity_store.ts b/yarn-project/pxe/src/storage/entity_store/entity_store.ts index 2327d513c143..ea45208f623a 100644 --- a/yarn-project/pxe/src/storage/entity_store/entity_store.ts +++ b/yarn-project/pxe/src/storage/entity_store/entity_store.ts @@ -6,7 +6,7 @@ import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { StagedStore } from '../../job_coordinator/job_coordinator.js'; import { - type FactAnchor, + type OriginBlock, StoredEntity, StoredFact, entityKey, @@ -27,17 +27,17 @@ type StagedOp = /** * Stores immutable facts about entities, grouped by contract, scope, entity type, and entity id. * - * Append-only within a job commit. Retractable facts (those with a block anchor) are deleted on block prune. - * Non-retractable facts (anchor === undefined) survive reorgs as external inputs. Writes are staged per-job and + * Append-only within a job commit. Retractable facts (those with an origin block) are deleted on block prune. + * Non-retractable facts (originBlock === undefined) survive reorgs as external inputs. Writes are staged per-job and * flushed atomically on commit. */ export class EntityStore implements StagedStore { readonly storeName: string = 'entity'; #store: AztecAsyncKVStore; - /** Primary entity records, keyed by entityKey; holds the entity payload and optional anchor. */ + /** Primary entity records, keyed by entityKey; holds the entity payload and optional origin block. */ #entities: AztecAsyncMap; - /** Index from blockNumber to entityKey, for delete-on-prune of retractable entities (anchored entities only). */ + /** Index from blockNumber to entityKey, for delete-on-prune of retractable entities (those with an origin block). */ #entitiesByBlock: AztecAsyncMultiMap; /** Primary fact records, keyed by factRowKey (deduplication row key). */ #facts: AztecAsyncMap; @@ -66,9 +66,10 @@ export class EntityStore implements StagedStore { } /** - * Stages an entity record (with its own payload and optional anchor) under the given job. `anchor === undefined` - * marks the entity non-retractable (it survives reorgs; only its own retractable facts are pruned); a defined - * anchor marks the whole entity retractable — on a prune above its block, the entity and all its facts are deleted. + * Stages an entity record (with its own payload and optional origin block) under the given job. + * `originBlock === undefined` marks the entity non-retractable (it survives reorgs; only its own retractable + * facts are pruned); a defined origin block marks the whole entity retractable — on a prune above its block, the + * entity and all its facts are deleted. * The entity becomes active once committed, independently of whether it owns any facts. */ createEntity( @@ -77,19 +78,19 @@ export class EntityStore implements StagedStore { entityTypeId: Fr, entityId: Fr, payload: Fr[], - anchor: FactAnchor | undefined, + originBlock: OriginBlock | undefined, jobId: string, ): Promise { return this.#withJobLock(jobId, () => { - const entity = new StoredEntity(contract, scope, entityTypeId, entityId, payload, anchor); + const entity = new StoredEntity(contract, scope, entityTypeId, entityId, payload, originBlock); this.#opsFor(jobId).push({ kind: 'createEntity', entity }); return Promise.resolve(); }); } /** - * Stages a fact for recording under the given job. `anchor === undefined` marks the fact non-retractable (it - * survives reorgs); a defined anchor ties the fact to a specific block and it will be deleted on prune. + * Stages a fact for recording under the given job. `originBlock === undefined` marks the fact non-retractable (it + * survives reorgs); a defined origin block ties the fact to a specific block and it will be deleted on prune. * Idempotent: duplicate (entity, factType, payload) tuples collapse to a single row via the dedup row key. */ recordFact( @@ -99,11 +100,11 @@ export class EntityStore implements StagedStore { entityId: Fr, factTypeId: Fr, payload: Fr[], - anchor: FactAnchor | undefined, + originBlock: OriginBlock | undefined, jobId: string, ): Promise { return this.#withJobLock(jobId, () => { - const fact = new StoredFact(contract, scope, entityTypeId, entityId, factTypeId, payload, anchor); + const fact = new StoredFact(contract, scope, entityTypeId, entityId, factTypeId, payload, originBlock); this.#opsFor(jobId).push({ kind: 'record', fact }); return Promise.resolve(); }); @@ -241,28 +242,28 @@ export class EntityStore implements StagedStore { if (op.kind === 'createEntity') { const entity = op.entity; const eKey = entityKeyOf(entity); - // Re-creating an entity may change or drop its anchor; clear any stale by-block index entry from the prior + // Re-creating an entity may change or drop its origin block; clear any stale by-block index entry from the // record first, so a later prune can neither double-visit this entity (and throw) nor wrongly delete one that // has since become non-retractable. const priorBuf = await this.#entities.getAsync(eKey); if (priorBuf) { const prior = StoredEntity.fromBuffer(priorBuf); - if (prior.anchor !== undefined) { - await this.#entitiesByBlock.deleteValue(prior.anchor.blockNumber, eKey); + if (prior.originBlock !== undefined) { + await this.#entitiesByBlock.deleteValue(prior.originBlock.blockNumber, eKey); } } await this.#entities.set(eKey, entity.toBuffer()); await this.#entitiesByScope.set(scopeKeyOf(entity), entity.entityId.toString()); - if (entity.anchor !== undefined) { - await this.#entitiesByBlock.set(entity.anchor.blockNumber, eKey); + if (entity.originBlock !== undefined) { + await this.#entitiesByBlock.set(entity.originBlock.blockNumber, eKey); } } else if (op.kind === 'record') { const fact = op.fact; const rowKey = factRowKeyOf(fact); await this.#facts.set(rowKey, fact.toBuffer()); await this.#factsByEntity.set(entityKeyOf(fact), rowKey); - if (fact.anchor !== undefined) { - await this.#factsByBlock.set(fact.anchor.blockNumber, rowKey); + if (fact.originBlock !== undefined) { + await this.#factsByBlock.set(fact.originBlock.blockNumber, rowKey); } } else { await this.#deleteEntity(op.contract, op.scope, op.entityTypeId, op.entityId); @@ -278,19 +279,20 @@ export class EntityStore implements StagedStore { } /** - * Delete-on-prune in two passes. Pass 1 deletes every retractable entity anchored to a block strictly above + * Delete-on-prune in two passes. Pass 1 deletes every retractable entity whose origin block is strictly above * `toBlock` wholesale — its payload and every fact it owns, regardless of each fact's own flag. Pass 2 deletes any - * remaining retractable fact anchored above `toBlock` whose entity survived pass 1. Non-retractable entities and + * remaining retractable fact originating above `toBlock` whose entity survived pass 1. Non-retractable entities and * facts are untouched (they never enter the by-block indexes). Must run inside a caller-owned transaction (the * reorg path wraps it with the sibling stores' rollbacks; IndexedDB has no nested transactions). Throws if any job - * has uncommitted staged writes, since rolling back mid-job could re-introduce records anchored to deleted blocks. + * has uncommitted staged writes, since rolling back mid-job could re-introduce records originating from deleted + * blocks. */ async rollback(toBlock: number): Promise { if (this.#opsForJob.size > 0) { throw new Error('PXE entity store rollback is not allowed while jobs are running'); } - // Pass 1: delete retractable entities anchored above toBlock wholesale. Snapshot before mutating so we never + // Pass 1: delete retractable entities originating above toBlock wholesale. Snapshot before mutating so we never // delete from the multimap we are iterating. const orphanedEntities: string[] = []; for await (const [, eKey] of this.#entitiesByBlock.entriesAsync({ start: toBlock + 1 })) { @@ -311,7 +313,7 @@ export class EntityStore implements StagedStore { removedEntities++; } - // Pass 2: delete remaining retractable facts anchored above toBlock whose entity survived pass 1. Pass 1 already + // Pass 2: delete remaining retractable facts originating above toBlock whose entity survived pass 1. Pass 1 already // removed the facts_by_block rows of pruned entities, so any leftover row here belongs to a surviving entity. const orphanedFacts: { block: number; rowKey: string }[] = []; for await (const [block, rowKey] of this.#factsByBlock.entriesAsync({ start: toBlock + 1 })) { @@ -391,16 +393,16 @@ export class EntityStore implements StagedStore { const fact = StoredFact.fromBuffer(buf); await this.#facts.delete(rowKey); await this.#factsByEntity.deleteValue(eKey, rowKey); - if (fact.anchor !== undefined) { - await this.#factsByBlock.deleteValue(fact.anchor.blockNumber, rowKey); + if (fact.originBlock !== undefined) { + await this.#factsByBlock.deleteValue(fact.originBlock.blockNumber, rowKey); } } const entityBuf = await this.#entities.getAsync(eKey); if (entityBuf) { const entity = StoredEntity.fromBuffer(entityBuf); await this.#entities.delete(eKey); - if (entity.anchor !== undefined) { - await this.#entitiesByBlock.deleteValue(entity.anchor.blockNumber, eKey); + if (entity.originBlock !== undefined) { + await this.#entitiesByBlock.deleteValue(entity.originBlock.blockNumber, eKey); } } await this.#entitiesByScope.deleteValue(scopeKey(contract, scope, entityTypeId), entityId.toString()); diff --git a/yarn-project/pxe/src/storage/entity_store/stored_fact.test.ts b/yarn-project/pxe/src/storage/entity_store/stored_fact.test.ts index 96204e29ccf2..100d078143f1 100644 --- a/yarn-project/pxe/src/storage/entity_store/stored_fact.test.ts +++ b/yarn-project/pxe/src/storage/entity_store/stored_fact.test.ts @@ -20,7 +20,7 @@ describe('StoredFact', () => { expect(back.isRetractable).toBe(true); }); - it('round-trips a non-retractable fact (no anchor)', () => { + it('round-trips a non-retractable fact (no origin block)', () => { const fact = new StoredFact(contract, scope, entityType, entityId, factType, [new Fr(9n)], undefined); const back = StoredFact.fromBuffer(fact.toBuffer()); expect(back).toEqual(fact); @@ -59,7 +59,7 @@ describe('StoredEntity', () => { expect(back.isRetractable).toBe(true); }); - it('round-trips a non-retractable entity (no anchor)', () => { + it('round-trips a non-retractable entity (no origin block)', () => { const entity = new StoredEntity(contract, scope, entityType, entityId, [new Fr(9n)], undefined); const back = StoredEntity.fromBuffer(entity.toBuffer()); expect(back).toEqual(entity); diff --git a/yarn-project/pxe/src/storage/entity_store/stored_fact.ts b/yarn-project/pxe/src/storage/entity_store/stored_fact.ts index d2025e28004a..6c177f02ec8d 100644 --- a/yarn-project/pxe/src/storage/entity_store/stored_fact.ts +++ b/yarn-project/pxe/src/storage/entity_store/stored_fact.ts @@ -3,12 +3,12 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -/** The block an anchored (retractable) fact is tied to. */ -export type FactAnchor = { blockNumber: number; blockHash: Fr }; +/** The block a retractable fact originates from. */ +export type OriginBlock = { blockNumber: number; blockHash: Fr }; /** - * A single immutable fact about an entity. `anchor === undefined` marks the fact non-retractable (an external - * input that survives reorgs); an anchor marks it retractable (re-derivable, deleted when its block is pruned). + * A single immutable fact about an entity. `originBlock === undefined` marks the fact non-retractable (an external + * input that survives reorgs); an origin block marks it retractable (re-derivable, deleted when its block is pruned). */ export class StoredFact { constructor( @@ -18,12 +18,12 @@ export class StoredFact { public readonly entityId: Fr, public readonly factTypeId: Fr, public readonly payload: Fr[], - public readonly anchor: FactAnchor | undefined, + public readonly originBlock: OriginBlock | undefined, ) {} /** Whether this fact is deleted on block pruning (true) or survives reorgs (false). */ get isRetractable(): boolean { - return this.anchor !== undefined; + return this.originBlock !== undefined; } /** Stable digest of the payload, used in the dedup row key (keeps the LMDB key bounded for large payloads). */ @@ -32,7 +32,7 @@ export class StoredFact { } toBuffer(): Buffer { - const anchorTag = this.anchor ? 1 : 0; + const originBlockTag = this.originBlock ? 1 : 0; return serializeToBuffer( this.contractAddress, this.scope, @@ -41,9 +41,9 @@ export class StoredFact { this.factTypeId, this.payload.length, ...this.payload, - anchorTag, - this.anchor ? this.anchor.blockNumber : 0, - this.anchor ? this.anchor.blockHash : Fr.ZERO, + originBlockTag, + this.originBlock ? this.originBlock.blockNumber : 0, + this.originBlock ? this.originBlock.blockHash : Fr.ZERO, ); } @@ -56,18 +56,19 @@ export class StoredFact { const factTypeId = reader.readObject(Fr); const payloadLen = reader.readNumber(); const payload = reader.readArray(payloadLen, Fr); - const anchorTag = reader.readNumber(); + const originBlockTag = reader.readNumber(); const blockNumber = reader.readNumber(); const blockHash = reader.readObject(Fr); - const anchor = anchorTag === 1 ? { blockNumber, blockHash } : undefined; - return new StoredFact(contractAddress, scope, entityTypeId, entityId, factTypeId, [...payload], anchor); + const originBlock = originBlockTag === 1 ? { blockNumber, blockHash } : undefined; + return new StoredFact(contractAddress, scope, entityTypeId, entityId, factTypeId, [...payload], originBlock); } } /** - * The record for a single entity, with its own payload and optional block anchor. `anchor === undefined` marks the - * entity non-retractable (it survives reorgs; only its own retractable facts are pruned); an anchor marks the whole - * entity retractable — on a prune above its block, the entity payload and every fact it owns are deleted wholesale. + * The record for a single entity, with its own payload and optional origin block. `originBlock === undefined` marks + * the entity non-retractable (it survives reorgs; only its own retractable facts are pruned); an origin block marks + * the whole entity retractable — on a prune above its block, the entity payload and every fact it owns are deleted + * wholesale. */ export class StoredEntity { constructor( @@ -76,16 +77,16 @@ export class StoredEntity { public readonly entityTypeId: Fr, public readonly entityId: Fr, public readonly payload: Fr[], - public readonly anchor: FactAnchor | undefined, + public readonly originBlock: OriginBlock | undefined, ) {} /** Whether the whole entity is deleted on block pruning (true) or survives reorgs (false). */ get isRetractable(): boolean { - return this.anchor !== undefined; + return this.originBlock !== undefined; } toBuffer(): Buffer { - const anchorTag = this.anchor ? 1 : 0; + const originBlockTag = this.originBlock ? 1 : 0; return serializeToBuffer( this.contractAddress, this.scope, @@ -93,9 +94,9 @@ export class StoredEntity { this.entityId, this.payload.length, ...this.payload, - anchorTag, - this.anchor ? this.anchor.blockNumber : 0, - this.anchor ? this.anchor.blockHash : Fr.ZERO, + originBlockTag, + this.originBlock ? this.originBlock.blockNumber : 0, + this.originBlock ? this.originBlock.blockHash : Fr.ZERO, ); } @@ -107,11 +108,11 @@ export class StoredEntity { const entityId = reader.readObject(Fr); const payloadLen = reader.readNumber(); const payload = reader.readArray(payloadLen, Fr); - const anchorTag = reader.readNumber(); + const originBlockTag = reader.readNumber(); const blockNumber = reader.readNumber(); const blockHash = reader.readObject(Fr); - const anchor = anchorTag === 1 ? { blockNumber, blockHash } : undefined; - return new StoredEntity(contractAddress, scope, entityTypeId, entityId, [...payload], anchor); + const originBlock = originBlockTag === 1 ? { blockNumber, blockHash } : undefined; + return new StoredEntity(contractAddress, scope, entityTypeId, entityId, [...payload], originBlock); } } From 4b6091166e414aa732db3e1ad992756624ad1115 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Wed, 10 Jun 2026 13:51:33 +0000 Subject: [PATCH 13/31] refactor(pxe): split StoredFact and StoredEntity into separate files stored_fact.ts held both record classes. Give each its own file (stored_fact.ts, stored_entity.ts) with a matching test file, and move the shared primitives they both depend on -- the OriginBlock type, the EntityCoords type, and the scope/entity key derivation -- into a new entity_keys.ts so neither class file imports the other. factRowKeyOf stays with StoredFact as it is fact-specific. --- .../src/storage/entity_store/entity_keys.ts | 33 ++++++++ .../src/storage/entity_store/entity_store.ts | 13 +-- .../entity_store/stored_entity.test.ts | 35 ++++++++ .../src/storage/entity_store/stored_entity.ts | 57 +++++++++++++ .../storage/entity_store/stored_fact.test.ts | 34 +------- .../src/storage/entity_store/stored_fact.ts | 83 +------------------ 6 files changed, 131 insertions(+), 124 deletions(-) create mode 100644 yarn-project/pxe/src/storage/entity_store/entity_keys.ts create mode 100644 yarn-project/pxe/src/storage/entity_store/stored_entity.test.ts create mode 100644 yarn-project/pxe/src/storage/entity_store/stored_entity.ts diff --git a/yarn-project/pxe/src/storage/entity_store/entity_keys.ts b/yarn-project/pxe/src/storage/entity_store/entity_keys.ts new file mode 100644 index 000000000000..5ba542884f6a --- /dev/null +++ b/yarn-project/pxe/src/storage/entity_store/entity_keys.ts @@ -0,0 +1,33 @@ +import type { Fr } from '@aztec/foundation/curves/bn254'; +import type { AztecAddress } from '@aztec/stdlib/aztec-address'; + +/** The block a retractable entity or fact originates from. */ +export type OriginBlock = { blockNumber: number; blockHash: Fr }; + +/** The contract+scope+entityType+entityId coordinates shared by facts and entity records. */ +export type EntityCoords = { + contractAddress: AztecAddress; + scope: AztecAddress; + entityTypeId: Fr; + entityId: Fr; +}; + +/** Key that groups all entities of the same type within a contract+scope. */ +export function scopeKey(contract: AztecAddress, scope: AztecAddress, entityTypeId: Fr): string { + return `${contract}:${scope}:${entityTypeId}`; +} + +/** Key that uniquely identifies a single entity (all its facts share this key prefix). */ +export function entityKey(contract: AztecAddress, scope: AztecAddress, entityTypeId: Fr, entityId: Fr): string { + return `${scopeKey(contract, scope, entityTypeId)}:${entityId}`; +} + +/** Key that groups all entities of the same type within a contract+scope. */ +export function scopeKeyOf(coords: EntityCoords): string { + return scopeKey(coords.contractAddress, coords.scope, coords.entityTypeId); +} + +/** Key that uniquely identifies a single entity (all its facts share this key prefix). */ +export function entityKeyOf(coords: EntityCoords): string { + return entityKey(coords.contractAddress, coords.scope, coords.entityTypeId, coords.entityId); +} diff --git a/yarn-project/pxe/src/storage/entity_store/entity_store.ts b/yarn-project/pxe/src/storage/entity_store/entity_store.ts index ea45208f623a..ec98e0f4ca2b 100644 --- a/yarn-project/pxe/src/storage/entity_store/entity_store.ts +++ b/yarn-project/pxe/src/storage/entity_store/entity_store.ts @@ -5,16 +5,9 @@ import type { AztecAsyncKVStore, AztecAsyncMap, AztecAsyncMultiMap } from '@azte import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { StagedStore } from '../../job_coordinator/job_coordinator.js'; -import { - type OriginBlock, - StoredEntity, - StoredFact, - entityKey, - entityKeyOf, - factRowKeyOf, - scopeKey, - scopeKeyOf, -} from './stored_fact.js'; +import { type OriginBlock, entityKey, entityKeyOf, scopeKey, scopeKeyOf } from './entity_keys.js'; +import { StoredEntity } from './stored_entity.js'; +import { StoredFact, factRowKeyOf } from './stored_fact.js'; type JobId = string; diff --git a/yarn-project/pxe/src/storage/entity_store/stored_entity.test.ts b/yarn-project/pxe/src/storage/entity_store/stored_entity.test.ts new file mode 100644 index 000000000000..5901e1ad0322 --- /dev/null +++ b/yarn-project/pxe/src/storage/entity_store/stored_entity.test.ts @@ -0,0 +1,35 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; + +import { StoredEntity } from './stored_entity.js'; + +describe('StoredEntity', () => { + const contract = AztecAddress.fromBigInt(100n); + const scope = AztecAddress.fromBigInt(1n); + const entityType = new Fr(7n); + const entityId = new Fr(42n); + + it('round-trips a retractable entity through buffer serialization', () => { + const entity = new StoredEntity(contract, scope, entityType, entityId, [new Fr(9n), new Fr(10n)], { + blockNumber: 12, + blockHash: new Fr(0xabcn), + }); + const back = StoredEntity.fromBuffer(entity.toBuffer()); + expect(back).toEqual(entity); + expect(back.isRetractable).toBe(true); + }); + + it('round-trips a non-retractable entity (no origin block)', () => { + const entity = new StoredEntity(contract, scope, entityType, entityId, [new Fr(9n)], undefined); + const back = StoredEntity.fromBuffer(entity.toBuffer()); + expect(back).toEqual(entity); + expect(back.isRetractable).toBe(false); + }); + + it('round-trips an entity with an empty payload', () => { + const entity = new StoredEntity(contract, scope, entityType, entityId, [], undefined); + const back = StoredEntity.fromBuffer(entity.toBuffer()); + expect(back).toEqual(entity); + expect(back.payload).toEqual([]); + }); +}); diff --git a/yarn-project/pxe/src/storage/entity_store/stored_entity.ts b/yarn-project/pxe/src/storage/entity_store/stored_entity.ts new file mode 100644 index 000000000000..0f8e9b0ecbd5 --- /dev/null +++ b/yarn-project/pxe/src/storage/entity_store/stored_entity.ts @@ -0,0 +1,57 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; + +import type { OriginBlock } from './entity_keys.js'; + +/** + * The record for a single entity, with its own payload and optional origin block. `originBlock === undefined` marks + * the entity non-retractable (it survives reorgs; only its own retractable facts are pruned); an origin block marks + * the whole entity retractable — on a prune above its block, the entity payload and every fact it owns are deleted + * wholesale. + */ +export class StoredEntity { + constructor( + public readonly contractAddress: AztecAddress, + public readonly scope: AztecAddress, + public readonly entityTypeId: Fr, + public readonly entityId: Fr, + public readonly payload: Fr[], + public readonly originBlock: OriginBlock | undefined, + ) {} + + /** Whether the whole entity is deleted on block pruning (true) or survives reorgs (false). */ + get isRetractable(): boolean { + return this.originBlock !== undefined; + } + + toBuffer(): Buffer { + const originBlockTag = this.originBlock ? 1 : 0; + return serializeToBuffer( + this.contractAddress, + this.scope, + this.entityTypeId, + this.entityId, + this.payload.length, + ...this.payload, + originBlockTag, + this.originBlock ? this.originBlock.blockNumber : 0, + this.originBlock ? this.originBlock.blockHash : Fr.ZERO, + ); + } + + static fromBuffer(buffer: Buffer | BufferReader): StoredEntity { + const reader = BufferReader.asReader(buffer); + const contractAddress = reader.readObject(AztecAddress); + const scope = reader.readObject(AztecAddress); + const entityTypeId = reader.readObject(Fr); + const entityId = reader.readObject(Fr); + const payloadLen = reader.readNumber(); + const payload = reader.readArray(payloadLen, Fr); + const originBlockTag = reader.readNumber(); + const blockNumber = reader.readNumber(); + const blockHash = reader.readObject(Fr); + const originBlock = originBlockTag === 1 ? { blockNumber, blockHash } : undefined; + return new StoredEntity(contractAddress, scope, entityTypeId, entityId, [...payload], originBlock); + } +} diff --git a/yarn-project/pxe/src/storage/entity_store/stored_fact.test.ts b/yarn-project/pxe/src/storage/entity_store/stored_fact.test.ts index 100d078143f1..481db557186c 100644 --- a/yarn-project/pxe/src/storage/entity_store/stored_fact.test.ts +++ b/yarn-project/pxe/src/storage/entity_store/stored_fact.test.ts @@ -1,7 +1,8 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { StoredEntity, StoredFact, entityKeyOf, factRowKeyOf, scopeKeyOf } from './stored_fact.js'; +import { entityKeyOf, scopeKeyOf } from './entity_keys.js'; +import { StoredFact, factRowKeyOf } from './stored_fact.js'; describe('StoredFact', () => { const contract = AztecAddress.fromBigInt(100n); @@ -42,34 +43,3 @@ describe('StoredFact', () => { expect(a.payloadHash()).toEqual(c.payloadHash()); }); }); - -describe('StoredEntity', () => { - const contract = AztecAddress.fromBigInt(100n); - const scope = AztecAddress.fromBigInt(1n); - const entityType = new Fr(7n); - const entityId = new Fr(42n); - - it('round-trips a retractable entity through buffer serialization', () => { - const entity = new StoredEntity(contract, scope, entityType, entityId, [new Fr(9n), new Fr(10n)], { - blockNumber: 12, - blockHash: new Fr(0xabcn), - }); - const back = StoredEntity.fromBuffer(entity.toBuffer()); - expect(back).toEqual(entity); - expect(back.isRetractable).toBe(true); - }); - - it('round-trips a non-retractable entity (no origin block)', () => { - const entity = new StoredEntity(contract, scope, entityType, entityId, [new Fr(9n)], undefined); - const back = StoredEntity.fromBuffer(entity.toBuffer()); - expect(back).toEqual(entity); - expect(back.isRetractable).toBe(false); - }); - - it('round-trips an entity with an empty payload', () => { - const entity = new StoredEntity(contract, scope, entityType, entityId, [], undefined); - const back = StoredEntity.fromBuffer(entity.toBuffer()); - expect(back).toEqual(entity); - expect(back.payload).toEqual([]); - }); -}); diff --git a/yarn-project/pxe/src/storage/entity_store/stored_fact.ts b/yarn-project/pxe/src/storage/entity_store/stored_fact.ts index 6c177f02ec8d..198818a7db61 100644 --- a/yarn-project/pxe/src/storage/entity_store/stored_fact.ts +++ b/yarn-project/pxe/src/storage/entity_store/stored_fact.ts @@ -3,8 +3,7 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -/** The block a retractable fact originates from. */ -export type OriginBlock = { blockNumber: number; blockHash: Fr }; +import { type OriginBlock, entityKeyOf } from './entity_keys.js'; /** * A single immutable fact about an entity. `originBlock === undefined` marks the fact non-retractable (an external @@ -64,86 +63,6 @@ export class StoredFact { } } -/** - * The record for a single entity, with its own payload and optional origin block. `originBlock === undefined` marks - * the entity non-retractable (it survives reorgs; only its own retractable facts are pruned); an origin block marks - * the whole entity retractable — on a prune above its block, the entity payload and every fact it owns are deleted - * wholesale. - */ -export class StoredEntity { - constructor( - public readonly contractAddress: AztecAddress, - public readonly scope: AztecAddress, - public readonly entityTypeId: Fr, - public readonly entityId: Fr, - public readonly payload: Fr[], - public readonly originBlock: OriginBlock | undefined, - ) {} - - /** Whether the whole entity is deleted on block pruning (true) or survives reorgs (false). */ - get isRetractable(): boolean { - return this.originBlock !== undefined; - } - - toBuffer(): Buffer { - const originBlockTag = this.originBlock ? 1 : 0; - return serializeToBuffer( - this.contractAddress, - this.scope, - this.entityTypeId, - this.entityId, - this.payload.length, - ...this.payload, - originBlockTag, - this.originBlock ? this.originBlock.blockNumber : 0, - this.originBlock ? this.originBlock.blockHash : Fr.ZERO, - ); - } - - static fromBuffer(buffer: Buffer | BufferReader): StoredEntity { - const reader = BufferReader.asReader(buffer); - const contractAddress = reader.readObject(AztecAddress); - const scope = reader.readObject(AztecAddress); - const entityTypeId = reader.readObject(Fr); - const entityId = reader.readObject(Fr); - const payloadLen = reader.readNumber(); - const payload = reader.readArray(payloadLen, Fr); - const originBlockTag = reader.readNumber(); - const blockNumber = reader.readNumber(); - const blockHash = reader.readObject(Fr); - const originBlock = originBlockTag === 1 ? { blockNumber, blockHash } : undefined; - return new StoredEntity(contractAddress, scope, entityTypeId, entityId, [...payload], originBlock); - } -} - -/** Key that groups all entities of the same type within a contract+scope. */ -export function scopeKey(contract: AztecAddress, scope: AztecAddress, entityTypeId: Fr): string { - return `${contract}:${scope}:${entityTypeId}`; -} - -/** Key that uniquely identifies a single entity (all its facts share this key prefix). */ -export function entityKey(contract: AztecAddress, scope: AztecAddress, entityTypeId: Fr, entityId: Fr): string { - return `${scopeKey(contract, scope, entityTypeId)}:${entityId}`; -} - -/** The contract+scope+entityType+entityId coordinates shared by facts and entity records. */ -type EntityCoords = { - contractAddress: AztecAddress; - scope: AztecAddress; - entityTypeId: Fr; - entityId: Fr; -}; - -/** Key that groups all entities of the same type within a contract+scope. */ -export function scopeKeyOf(coords: EntityCoords): string { - return scopeKey(coords.contractAddress, coords.scope, coords.entityTypeId); -} - -/** Key that uniquely identifies a single entity (all its facts share this key prefix). */ -export function entityKeyOf(coords: EntityCoords): string { - return entityKey(coords.contractAddress, coords.scope, coords.entityTypeId, coords.entityId); -} - /** Dedup row key for a specific fact; incorporates a payload hash to bound key size for large payloads. */ export function factRowKeyOf(fact: StoredFact): string { return `${entityKeyOf(fact)}:${fact.factTypeId}:${fact.payloadHash()}`; From 7845f7b9c6193c9bb0963532bd1530ed859efe6c Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 08:23:23 +0000 Subject: [PATCH 14/31] refactor(aztec-nr): rename oracle::ephemeral module to ephemeral_oracles The module name collided with crate::ephemeral, which owns the EphemeralArray type. Name the oracle module after what it holds -- the #[oracle(...)] declarations -- so the array module keeps the plain ephemeral name and the two are unambiguous at use sites. --- .../aztec-nr/aztec/src/ephemeral/mod.nr | 16 ++++++++-------- .../{ephemeral.nr => ephemeral_oracles.nr} | 0 noir-projects/aztec-nr/aztec/src/oracle/mod.nr | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) rename noir-projects/aztec-nr/aztec/src/oracle/{ephemeral.nr => ephemeral_oracles.nr} (100%) diff --git a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr index bad14d5df485..c67fcd05de9c 100644 --- a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr @@ -1,4 +1,4 @@ -use crate::oracle::ephemeral; +use crate::oracle::ephemeral_oracles; use crate::oracle_array::{ArrayOracle, OracleArray}; use crate::protocol::traits::{Deserialize, Serialize}; @@ -30,31 +30,31 @@ pub struct EphemeralOracle {} impl ArrayOracle for EphemeralOracle { unconstrained fn len_oracle(slot: Field) -> u32 { - ephemeral::len_oracle(slot) + ephemeral_oracles::len_oracle(slot) } unconstrained fn push_oracle(slot: Field, values: [Field; N]) -> u32 { - ephemeral::push_oracle(slot, values) + ephemeral_oracles::push_oracle(slot, values) } unconstrained fn pop_oracle(slot: Field) -> [Field; N] { - ephemeral::pop_oracle(slot) + ephemeral_oracles::pop_oracle(slot) } unconstrained fn get_oracle(slot: Field, index: u32) -> [Field; N] { - ephemeral::get_oracle(slot, index) + ephemeral_oracles::get_oracle(slot, index) } unconstrained fn set_oracle(slot: Field, index: u32, values: [Field; N]) { - ephemeral::set_oracle(slot, index, values) + ephemeral_oracles::set_oracle(slot, index, values) } unconstrained fn remove_oracle(slot: Field, index: u32) { - ephemeral::remove_oracle(slot, index) + ephemeral_oracles::remove_oracle(slot, index) } unconstrained fn clear_oracle(slot: Field) { - ephemeral::clear_oracle(slot) + ephemeral_oracles::clear_oracle(slot) } } diff --git a/noir-projects/aztec-nr/aztec/src/oracle/ephemeral.nr b/noir-projects/aztec-nr/aztec/src/oracle/ephemeral_oracles.nr similarity index 100% rename from noir-projects/aztec-nr/aztec/src/oracle/ephemeral.nr rename to noir-projects/aztec-nr/aztec/src/oracle/ephemeral_oracles.nr diff --git a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr index 85953142230d..8174bfab6c5b 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr @@ -7,7 +7,7 @@ pub mod block_header; pub mod call_private_function; pub mod call_utility_function; pub mod capsules; -pub mod ephemeral; +pub mod ephemeral_oracles; pub mod transient; pub mod contract_sync; pub mod public_call; From b5948b019d2d6a53e7d5dca649c2699c306ab1db Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 08:26:29 +0000 Subject: [PATCH 15/31] refactor(aztec-nr): rename oracle::transient module to transient_oracles The module name collided with crate::transient, which owns the TransientArray type. Name the oracle module after what it holds -- the #[oracle(...)] declarations -- so the array module keeps the plain transient name and the two are unambiguous at use sites. --- noir-projects/aztec-nr/aztec/src/oracle/mod.nr | 2 +- .../{transient.nr => transient_oracles.nr} | 0 .../aztec-nr/aztec/src/transient/mod.nr | 16 ++++++++-------- 3 files changed, 9 insertions(+), 9 deletions(-) rename noir-projects/aztec-nr/aztec/src/oracle/{transient.nr => transient_oracles.nr} (100%) diff --git a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr index 8174bfab6c5b..3ff645c72d56 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr @@ -8,7 +8,7 @@ pub mod call_private_function; pub mod call_utility_function; pub mod capsules; pub mod ephemeral_oracles; -pub mod transient; +pub mod transient_oracles; pub mod contract_sync; pub mod public_call; pub mod tx_phase; diff --git a/noir-projects/aztec-nr/aztec/src/oracle/transient.nr b/noir-projects/aztec-nr/aztec/src/oracle/transient_oracles.nr similarity index 100% rename from noir-projects/aztec-nr/aztec/src/oracle/transient.nr rename to noir-projects/aztec-nr/aztec/src/oracle/transient_oracles.nr diff --git a/noir-projects/aztec-nr/aztec/src/transient/mod.nr b/noir-projects/aztec-nr/aztec/src/transient/mod.nr index e4a14969b6b1..4b0202d7f402 100644 --- a/noir-projects/aztec-nr/aztec/src/transient/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/transient/mod.nr @@ -1,4 +1,4 @@ -use crate::oracle::transient; +use crate::oracle::transient_oracles; use crate::oracle_array::{ArrayOracle, OracleArray}; use crate::protocol::traits::{Deserialize, Serialize}; @@ -37,31 +37,31 @@ pub struct TransientOracle {} impl ArrayOracle for TransientOracle { unconstrained fn len_oracle(slot: Field) -> u32 { - transient::len_oracle(slot) + transient_oracles::len_oracle(slot) } unconstrained fn push_oracle(slot: Field, values: [Field; N]) -> u32 { - transient::push_oracle(slot, values) + transient_oracles::push_oracle(slot, values) } unconstrained fn pop_oracle(slot: Field) -> [Field; N] { - transient::pop_oracle(slot) + transient_oracles::pop_oracle(slot) } unconstrained fn get_oracle(slot: Field, index: u32) -> [Field; N] { - transient::get_oracle(slot, index) + transient_oracles::get_oracle(slot, index) } unconstrained fn set_oracle(slot: Field, index: u32, values: [Field; N]) { - transient::set_oracle(slot, index, values) + transient_oracles::set_oracle(slot, index, values) } unconstrained fn remove_oracle(slot: Field, index: u32) { - transient::remove_oracle(slot, index) + transient_oracles::remove_oracle(slot, index) } unconstrained fn clear_oracle(slot: Field) { - transient::clear_oracle(slot) + transient_oracles::clear_oracle(slot) } } From 62ad8548d8965e2928c94769f08a449860f0cf9b Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 08:56:08 +0000 Subject: [PATCH 16/31] refactor(aztec-nr): rename OracleArray struct to UnconstrainedArray OracleArray and ArrayOracle were near-anagrams, forcing readers to disambiguate which was the struct and which the backend trait. Keep ArrayOracle (it names the set of oracles that implement an array) and rename the struct to UnconstrainedArray, after the property every backend shares: its operations are unconstrained foreign calls into PXE-side storage, so the data is host-provided and must be verified before being trusted in constrained code. The oracle_array module, the ArrayOracle trait, and the Oracle type parameter are unchanged. --- .../aztec-nr/aztec/src/ephemeral/mod.nr | 7 +- .../aztec-nr/aztec/src/oracle_array/mod.nr | 47 +++---- .../aztec/src/oracle_array/test_helpers.nr | 119 +++++++++--------- .../aztec-nr/aztec/src/transient/mod.nr | 7 +- 4 files changed, 94 insertions(+), 86 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr index c67fcd05de9c..aaa9704d52e2 100644 --- a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr @@ -1,5 +1,5 @@ use crate::oracle::ephemeral_oracles; -use crate::oracle_array::{ArrayOracle, OracleArray}; +use crate::oracle_array::{ArrayOracle, UnconstrainedArray}; use crate::protocol::traits::{Deserialize, Serialize}; /// A dynamically sized array that exists only during a single contract call frame. @@ -23,9 +23,10 @@ use crate::protocol::traits::{Deserialize, Serialize}; /// For data that must be shared across all frames of the same contract (private and utility) within one top-level PXE /// call (transaction simulation or utility call) but not persisted, use /// [`TransientArray`](crate::transient::TransientArray). -pub type EphemeralArray = OracleArray; +pub type EphemeralArray = UnconstrainedArray; -/// Routes [`OracleArray`] operations to the ephemeral array oracles, scoping arrays to a single contract call frame. +/// Routes [`UnconstrainedArray`] operations to the ephemeral array oracles, scoping arrays to a single contract call +/// frame. pub struct EphemeralOracle {} impl ArrayOracle for EphemeralOracle { diff --git a/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr index 39851bbcbe83..b6e27b7fc218 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr @@ -4,7 +4,7 @@ use crate::oracle::random::random; use crate::protocol::traits::{Deserialize, Serialize}; use crate::protocol::utils::{reader::Reader, writer::Writer}; -/// Oracle backend for an [`OracleArray`]: the set of PXE-side operations that implement its storage. +/// Oracle backend for an [`UnconstrainedArray`]: the set of PXE-side operations that implement its storage. /// /// Each implementor routes these operations to a distinct family of oracles, and the oracle family determines the /// array's lifetime and visibility (e.g. [`EphemeralArray`](crate::ephemeral::EphemeralArray) arrays live for one @@ -42,11 +42,11 @@ pub trait ArrayOracle { /// rather one of its aliases: [`EphemeralArray`](crate::ephemeral::EphemeralArray) (scoped to a single contract call /// frame) or [`TransientArray`](crate::transient::TransientArray) (shared across all frames of the same contract /// within one top-level PXE call). -pub struct OracleArray { +pub struct UnconstrainedArray { pub slot: Field, } -impl OracleArray +impl UnconstrainedArray where Oracle: ArrayOracle, { @@ -67,7 +67,8 @@ where /// Returns an empty array at a fresh, randomly allocated slot. /// /// Use this when the caller does not need a specific slot: the random slot is isolated from every other array of - /// the same backend with overwhelming probability. Prefer [`OracleArray::empty_at`] when the slot must be a known + /// the same backend with overwhelming probability. Prefer [`UnconstrainedArray::empty_at`] when the slot must be a + /// known /// value (e.g. one shared with an oracle or another call frame). pub unconstrained fn empty() -> Self { Self::empty_at(random()) @@ -121,7 +122,7 @@ where /// Removes all elements from the array and returns self for chaining. /// - /// Prefer [`OracleArray::empty_at`] when the intent is to start with a fresh array. + /// Prefer [`UnconstrainedArray::empty_at`] when the intent is to start with a fresh array. pub unconstrained fn clear(self) -> Self { Oracle::clear_oracle(self.slot); self @@ -148,14 +149,15 @@ where /// Applies `f` to every element and collects the results into a fresh array. /// /// Reads each element in order, transforms it with `f`, and pushes the result onto a new array at a freshly - /// allocated slot (see [`OracleArray::empty`]). The source array is left unchanged, and the result is isolated + /// allocated slot (see [`UnconstrainedArray::empty`]). The source array is left unchanged, and the result is + /// isolated /// from it, so `map` can be chained. - pub unconstrained fn map(self, f: unconstrained fn[Env](T) -> U) -> OracleArray + pub unconstrained fn map(self, f: unconstrained fn[Env](T) -> U) -> UnconstrainedArray where T: Deserialize, U: Serialize, { - let dest: OracleArray = OracleArray::empty(); + let dest: UnconstrainedArray = UnconstrainedArray::empty(); let n = self.len(); for i in 0..n { dest.push(f(self.get(i))); @@ -166,13 +168,14 @@ where /// Collects every element satisfying the predicate `f` into a fresh array. /// /// Reads each element in order, keeps those for which `f` returns `true`, and pushes them onto a new array at a - /// freshly allocated slot (see [`OracleArray::empty`]). Relative order is preserved. The source array is left + /// freshly allocated slot (see [`UnconstrainedArray::empty`]). Relative order is preserved. The source array is + /// left /// unchanged, and the result is isolated from it, so `filter` can be chained. pub unconstrained fn filter(self, f: unconstrained fn[Env](T) -> bool) -> Self where T: Serialize + Deserialize, { - let dest: Self = OracleArray::empty(); + let dest: Self = UnconstrainedArray::empty(); let n = self.len(); for i in 0..n { let value = self.get(i); @@ -186,7 +189,8 @@ where /// Returns `true` if at least one element satisfies the predicate `f`. /// /// Matches the `any` combinator on Noir's [`array`](std::array), slice, and `BoundedVec` collections. Defined in - /// terms of [`OracleArray::filter`]: it keeps the matching elements and checks whether any survived. This is not + /// terms of [`UnconstrainedArray::filter`]: it keeps the matching elements and checks whether any survived. This is + /// not /// short-circuiting — every element is tested even after the first match. pub unconstrained fn any(self, f: unconstrained fn[Env](T) -> bool) -> bool where @@ -198,7 +202,7 @@ where /// Returns `true` if every element satisfies the predicate `f` (vacuously `true` for an empty array). /// /// Matches the `all` combinator on Noir's [`array`](std::array) and slice collections. Defined in terms of - /// [`OracleArray::filter`]: every element matches exactly when filtering keeps all of them. This is not + /// [`UnconstrainedArray::filter`]: every element matches exactly when filtering keeps all of them. This is not /// short-circuiting — every element is tested even after the first failure. pub unconstrained fn all(self, f: unconstrained fn[Env](T) -> bool) -> bool where @@ -209,7 +213,8 @@ where /// Returns the first element satisfying the predicate `f`, or `Option::none` if none do. /// - /// Mirrors Rust's `Iterator::find`. Defined in terms of [`OracleArray::filter`], which preserves order, so the + /// Mirrors Rust's `Iterator::find`. Defined in terms of [`UnconstrainedArray::filter`], which preserves order, so + /// the /// first kept element is the first match. This is not short-circuiting — every element is tested even after the /// match is found. pub unconstrained fn find(self, f: unconstrained fn[Env](T) -> bool) -> Option @@ -225,7 +230,7 @@ where } } -impl OracleArray +impl UnconstrainedArray where Oracle: ArrayOracle, { @@ -238,7 +243,7 @@ where where T: Deserialize, { - assert_eq(self.len(), ::N, "OracleArray length mismatch for read_as"); + assert_eq(self.len(), ::N, "UnconstrainedArray length mismatch for read_as"); let mut fields: [Field; ::N] = [0; ::N]; for i in 0..::N { fields[i] = self.get(i); @@ -247,9 +252,9 @@ where } } -/// Serializes an `OracleArray` as its slot identifier, allowing oracle function signatures to use array types +/// Serializes an `UnconstrainedArray` as its slot identifier, allowing oracle function signatures to use array types /// instead of opaque `Field` slots. -impl Serialize for OracleArray { +impl Serialize for UnconstrainedArray { let N: u32 = 1; fn serialize(self) -> [Field; Self::N] { @@ -261,9 +266,9 @@ impl Serialize for OracleArray { } } -/// Deserializes a single Field into an `OracleArray` handle, treating the field value as the slot identifier. +/// Deserializes a single Field into an `UnconstrainedArray` handle, treating the field value as the slot identifier. /// This is the inverse of [`Serialize`]. -impl Deserialize for OracleArray { +impl Deserialize for UnconstrainedArray { let N: u32 = 1; fn deserialize(fields: [Field; Self::N]) -> Self { @@ -283,7 +288,7 @@ where T: Serialize, Oracle: ArrayOracle, { - let array: OracleArray = OracleArray::at(slot); + let array: UnconstrainedArray = UnconstrainedArray::at(slot); array.clear().push(value); } @@ -293,7 +298,7 @@ where T: Deserialize, Oracle: ArrayOracle, { - let array: OracleArray = OracleArray::at(slot); + let array: UnconstrainedArray = UnconstrainedArray::at(slot); if array.len() == 0 { Option::none() } else { diff --git a/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr b/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr index 5eb44214fc47..7d56b6e972cf 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr @@ -1,12 +1,12 @@ -//! Shared test suite for [`OracleArray`] backends. +//! Shared test suite for [`UnconstrainedArray`] backends. //! -//! Every function in this module checks one behavior of the shared [`OracleArray`] API (or of the generic +//! Every function in this module checks one behavior of the shared [`UnconstrainedArray`] API (or of the generic //! `store` / `load` / `delete` kv helpers) and is generic over the [`ArrayOracle`] backend. //! //! Each backend module additionally keeps a hand-written smoke test routing through its own public helpers (e.g. //! `transient::store` / `transient::load`) to cover the delegation wiring. -use crate::oracle_array::{ArrayOracle, delete, load, OracleArray, store}; +use crate::oracle_array::{ArrayOracle, delete, load, store, UnconstrainedArray}; use crate::protocol::traits::Serialize; use crate::test::helpers::test_environment::TestEnvironment; use crate::test::mocks::MockStruct; @@ -20,7 +20,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); assert_eq(array.len(), 0); }); } @@ -31,7 +31,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); let _: Field = array.get(0); }); } @@ -42,7 +42,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); let _: Field = array.pop(); }); } @@ -53,7 +53,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(5); assert_eq(array.len(), 1); @@ -67,7 +67,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(5); let _ = array.get(1); @@ -80,7 +80,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(5); array.push(10); @@ -97,7 +97,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(5); array.set(0, 99); assert_eq(array.get(0), 99); @@ -110,7 +110,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(5); array.remove(0); assert_eq(array.len(), 0); @@ -123,7 +123,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(7); array.push(8); @@ -145,7 +145,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(7); array.push(8); @@ -165,7 +165,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(4); array.push(5); @@ -187,7 +187,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(4); array.push(5); @@ -211,7 +211,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(4); array.push(5); @@ -229,8 +229,8 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array_a: OracleArray = OracleArray::at(SLOT); - let array_b: OracleArray = OracleArray::at(OTHER_SLOT); + let array_a: UnconstrainedArray = UnconstrainedArray::at(SLOT); + let array_b: UnconstrainedArray = UnconstrainedArray::at(OTHER_SLOT); array_a.push(10); array_a.push(20); @@ -251,7 +251,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); let a = MockStruct::new(5, 6); let b = MockStruct::new(7, 8); @@ -274,7 +274,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); let value = MockStruct::new(5, 6); let serialized = value.serialize(); @@ -293,7 +293,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); // MockStruct deserializes from 2 fields, so a single field is too short. array.push(1); @@ -307,7 +307,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT).clear(); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT).clear(); assert_eq(array.len(), 0); array.push(42); @@ -322,14 +322,14 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(1); array.push(2); array.push(3); assert_eq(array.len(), 3); // Clear the same slot, previous data should be gone. - let fresh: OracleArray = OracleArray::at(SLOT).clear(); + let fresh: UnconstrainedArray = UnconstrainedArray::at(SLOT).clear(); assert_eq(fresh.len(), 0); fresh.push(4); assert_eq(fresh.get(0), 4); @@ -342,8 +342,8 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let a: OracleArray = OracleArray::empty(); - let b: OracleArray = OracleArray::empty(); + let a: UnconstrainedArray = UnconstrainedArray::empty(); + let b: UnconstrainedArray = UnconstrainedArray::empty(); assert(a.slot != b.slot, "empty() should allocate a fresh slot each time"); @@ -362,12 +362,12 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let source: OracleArray = OracleArray::at(SLOT); + let source: UnconstrainedArray = UnconstrainedArray::at(SLOT); source.push(1); source.push(2); source.push(3); - let doubled: OracleArray = source.map(|x| x * 2); + let doubled: UnconstrainedArray = source.map(|x| x * 2); assert_eq(doubled.len(), 3); assert_eq(doubled.get(0), 2); @@ -386,8 +386,8 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let source: OracleArray = OracleArray::at(SLOT); - let mapped: OracleArray = source.map(|x| x * 2); + let source: UnconstrainedArray = UnconstrainedArray::at(SLOT); + let mapped: UnconstrainedArray = source.map(|x| x * 2); assert_eq(mapped.len(), 0); }); } @@ -398,11 +398,11 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let source: OracleArray = OracleArray::at(SLOT); + let source: UnconstrainedArray = UnconstrainedArray::at(SLOT); source.push(5); source.push(7); - let structs: OracleArray = source.map(|x| MockStruct::new(x, x + 1)); + let structs: UnconstrainedArray = source.map(|x| MockStruct::new(x, x + 1)); assert_eq(structs.len(), 2); assert_eq(structs.get(0), MockStruct::new(5, 6)); @@ -416,14 +416,14 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let source: OracleArray = OracleArray::at(SLOT); + let source: UnconstrainedArray = UnconstrainedArray::at(SLOT); source.push(1); source.push(2); source.push(3); // Each map allocates its own fresh slot, so two maps of the same source do not clobber each other. - let doubled: OracleArray = source.map(|x| x * 2); - let tripled: OracleArray = source.map(|x| x * 3); + let doubled: UnconstrainedArray = source.map(|x| x * 2); + let tripled: UnconstrainedArray = source.map(|x| x * 3); assert(doubled.slot != tripled.slot, "each map should land in a distinct slot"); assert_eq(doubled.get(0), 2); @@ -439,14 +439,14 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let source: OracleArray = OracleArray::at(SLOT); + let source: UnconstrainedArray = UnconstrainedArray::at(SLOT); source.push(1); source.push(2); source.push(3); source.push(2); source.push(5); - let kept: OracleArray = source.filter(|x| x != 2); + let kept: UnconstrainedArray = source.filter(|x| x != 2); assert_eq(kept.len(), 3); assert_eq(kept.get(0), 1); @@ -465,8 +465,8 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let source: OracleArray = OracleArray::at(SLOT); - let kept: OracleArray = source.filter(|_| true); + let source: UnconstrainedArray = UnconstrainedArray::at(SLOT); + let kept: UnconstrainedArray = source.filter(|_| true); assert_eq(kept.len(), 0); }); } @@ -477,12 +477,12 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let source: OracleArray = OracleArray::at(SLOT); + let source: UnconstrainedArray = UnconstrainedArray::at(SLOT); source.push(1); source.push(2); source.push(3); - let kept: OracleArray = source.filter(|_| false); + let kept: UnconstrainedArray = source.filter(|_| false); assert_eq(kept.len(), 0); }); } @@ -493,12 +493,12 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let source: OracleArray = OracleArray::at(SLOT); + let source: UnconstrainedArray = UnconstrainedArray::at(SLOT); source.push(MockStruct::new(1, 10)); source.push(MockStruct::new(2, 20)); source.push(MockStruct::new(3, 30)); - let kept: OracleArray = source.filter(|s: MockStruct| s.a != 2); + let kept: UnconstrainedArray = source.filter(|s: MockStruct| s.a != 2); assert_eq(kept.len(), 2); assert_eq(kept.get(0), MockStruct::new(1, 10)); @@ -512,15 +512,15 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let source: OracleArray = OracleArray::at(SLOT); + let source: UnconstrainedArray = UnconstrainedArray::at(SLOT); source.push(1); source.push(2); source.push(3); source.push(4); // Each filter allocates its own fresh slot, so two filters of the same source do not clobber each other. - let odds: OracleArray = source.filter(|x| (x != 2) & (x != 4)); - let evens: OracleArray = source.filter(|x| (x != 1) & (x != 3)); + let odds: UnconstrainedArray = source.filter(|x| (x != 2) & (x != 4)); + let evens: UnconstrainedArray = source.filter(|x| (x != 1) & (x != 3)); assert(odds.slot != evens.slot, "each filter should land in a distinct slot"); assert_eq(odds.len(), 2); @@ -538,7 +538,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(1); array.push(2); array.push(3); @@ -553,7 +553,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(1); array.push(2); array.push(3); @@ -568,7 +568,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); assert(!array.any(|_| true)); }); } @@ -579,7 +579,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(1); array.push(2); array.push(3); @@ -594,7 +594,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(1); array.push(2); array.push(3); @@ -609,7 +609,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); assert(array.all(|_| false)); }); } @@ -620,7 +620,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(1); array.push(2); array.push(3); @@ -639,7 +639,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(1); array.push(2); array.push(3); @@ -654,7 +654,7 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); assert(array.find(|_| true).is_none()); }); } @@ -665,11 +665,11 @@ where { let env = TestEnvironment::new(); env.utility_context(|_| { - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); array.push(1); assert_eq(array.len(), 1); - let fresh: OracleArray = OracleArray::empty_at(SLOT); + let fresh: UnconstrainedArray = UnconstrainedArray::empty_at(SLOT); assert_eq(fresh.len(), 0); }); } @@ -741,7 +741,7 @@ where let env = TestEnvironment::new(); env.utility_context(|_| { store::(SLOT, 42); - let array: OracleArray = OracleArray::at(SLOT); + let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); assert_eq(array.len(), 1); assert_eq(array.get(0), 42); }); @@ -763,7 +763,8 @@ comptime fn should_fail_message(name: Quoted) -> Option { } } -/// Generates the shared [`OracleArray`] test suite against the given backend. Apply to a test module, passing the +/// Generates the shared [`UnconstrainedArray`] test suite against the given backend. Apply to a test module, passing +/// the /// backend's oracle as a quoted path: `#[oracle_array_tests(quote { crate::ephemeral::EphemeralOracle })]`. pub(crate) comptime fn oracle_array_tests(_m: Module, oracle: Quoted) -> Quoted { let helpers = quote { crate::oracle_array::test_helpers }.as_module().unwrap(); diff --git a/noir-projects/aztec-nr/aztec/src/transient/mod.nr b/noir-projects/aztec-nr/aztec/src/transient/mod.nr index 4b0202d7f402..7cf53154da3a 100644 --- a/noir-projects/aztec-nr/aztec/src/transient/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/transient/mod.nr @@ -1,5 +1,5 @@ use crate::oracle::transient_oracles; -use crate::oracle_array::{ArrayOracle, OracleArray}; +use crate::oracle_array::{ArrayOracle, UnconstrainedArray}; use crate::protocol::traits::{Deserialize, Serialize}; /// A dynamically sized array that lives for the duration of a single top-level PXE call. @@ -29,9 +29,10 @@ use crate::protocol::traits::{Deserialize, Serialize}; /// Use this to pass not-to-be-persisted data between a contract's own frames (private or utility) within one top-level /// PXE call. For data confined to a single call frame, prefer [`EphemeralArray`](crate::ephemeral::EphemeralArray). /// For data that must persist indefinitely, use [`CapsuleArray`](crate::capsules::CapsuleArray). -pub type TransientArray = OracleArray; +pub type TransientArray = UnconstrainedArray; -/// Routes [`OracleArray`] operations to the transient array oracles, sharing arrays across all call frames of the +/// Routes [`UnconstrainedArray`] operations to the transient array oracles, sharing arrays across all call frames of +/// the /// same contract within one top-level PXE call. pub struct TransientOracle {} From e8ad51f1ec508d4250e9aa44b98be842fdaf65c1 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 09:01:29 +0000 Subject: [PATCH 17/31] refactor(aztec-nr): rename oracle_array module to unconstrained_array Follows the struct rename: the module and its test macro were still named after the old OracleArray struct. Rename the module directory to unconstrained_array and the oracle_array_tests macro to unconstrained_array_tests so the module mirrors the type it houses. The ArrayOracle trait and the store/load/delete helpers keep their names. --- noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr | 12 ++++++------ noir-projects/aztec-nr/aztec/src/lib.nr | 2 +- noir-projects/aztec-nr/aztec/src/transient/mod.nr | 12 ++++++------ .../src/{oracle_array => unconstrained_array}/mod.nr | 0 .../test_helpers.nr | 10 +++++----- 5 files changed, 18 insertions(+), 18 deletions(-) rename noir-projects/aztec-nr/aztec/src/{oracle_array => unconstrained_array}/mod.nr (100%) rename noir-projects/aztec-nr/aztec/src/{oracle_array => unconstrained_array}/test_helpers.nr (97%) diff --git a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr index aaa9704d52e2..e99b3a252114 100644 --- a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr @@ -1,6 +1,6 @@ use crate::oracle::ephemeral_oracles; -use crate::oracle_array::{ArrayOracle, UnconstrainedArray}; use crate::protocol::traits::{Deserialize, Serialize}; +use crate::unconstrained_array::{ArrayOracle, UnconstrainedArray}; /// A dynamically sized array that exists only during a single contract call frame. /// @@ -64,7 +64,7 @@ pub unconstrained fn store(slot: Field, value: T) where T: Serialize, { - crate::oracle_array::store::(slot, value) + crate::unconstrained_array::store::(slot, value) } /// Returns the value previously stored at `slot` with [`store`], or `Option::none()` if the slot holds no value. @@ -72,18 +72,18 @@ pub unconstrained fn load(slot: Field) -> Option where T: Deserialize, { - crate::oracle_array::load::(slot) + crate::unconstrained_array::load::(slot) } /// Deletes the value stored at `slot`. Does nothing if the slot is already empty. pub unconstrained fn delete(slot: Field) { - crate::oracle_array::delete::(slot) + crate::unconstrained_array::delete::(slot) } -#[crate::oracle_array::test_helpers::oracle_array_tests(quote { crate::ephemeral::EphemeralOracle })] +#[crate::unconstrained_array::test_helpers::unconstrained_array_tests(quote { crate::ephemeral::EphemeralOracle })] mod test { - use crate::oracle_array::test_helpers::SLOT; use crate::test::helpers::test_environment::TestEnvironment; + use crate::unconstrained_array::test_helpers::SLOT; use super::{load, store}; #[test] diff --git a/noir-projects/aztec-nr/aztec/src/lib.nr b/noir-projects/aztec-nr/aztec/src/lib.nr index 507cd51bed80..387f820d22da 100644 --- a/noir-projects/aztec-nr/aztec/src/lib.nr +++ b/noir-projects/aztec-nr/aztec/src/lib.nr @@ -39,7 +39,7 @@ pub mod nullifier; pub mod oracle; pub mod state_vars; pub mod capsules; -pub mod oracle_array; +pub mod unconstrained_array; pub mod ephemeral; pub mod transient; pub mod event; diff --git a/noir-projects/aztec-nr/aztec/src/transient/mod.nr b/noir-projects/aztec-nr/aztec/src/transient/mod.nr index 7cf53154da3a..57d2a9353dde 100644 --- a/noir-projects/aztec-nr/aztec/src/transient/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/transient/mod.nr @@ -1,6 +1,6 @@ use crate::oracle::transient_oracles; -use crate::oracle_array::{ArrayOracle, UnconstrainedArray}; use crate::protocol::traits::{Deserialize, Serialize}; +use crate::unconstrained_array::{ArrayOracle, UnconstrainedArray}; /// A dynamically sized array that lives for the duration of a single top-level PXE call. /// @@ -71,7 +71,7 @@ pub unconstrained fn store(slot: Field, value: T) where T: Serialize, { - crate::oracle_array::store::(slot, value) + crate::unconstrained_array::store::(slot, value) } /// Returns the value previously stored at `slot` with [`store`], or `Option::none()` if the slot holds no value. @@ -79,18 +79,18 @@ pub unconstrained fn load(slot: Field) -> Option where T: Deserialize, { - crate::oracle_array::load::(slot) + crate::unconstrained_array::load::(slot) } /// Deletes the value stored at `slot`. Does nothing if the slot is already empty. pub unconstrained fn delete(slot: Field) { - crate::oracle_array::delete::(slot) + crate::unconstrained_array::delete::(slot) } -#[crate::oracle_array::test_helpers::oracle_array_tests(quote { crate::transient::TransientOracle })] +#[crate::unconstrained_array::test_helpers::unconstrained_array_tests(quote { crate::transient::TransientOracle })] mod test { - use crate::oracle_array::test_helpers::SLOT; use crate::test::helpers::test_environment::TestEnvironment; + use crate::unconstrained_array::test_helpers::SLOT; use super::{load, store}; #[test] diff --git a/noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr similarity index 100% rename from noir-projects/aztec-nr/aztec/src/oracle_array/mod.nr rename to noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr diff --git a/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr similarity index 97% rename from noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr rename to noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr index 7d56b6e972cf..678126b56d2d 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle_array/test_helpers.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr @@ -6,10 +6,10 @@ //! Each backend module additionally keeps a hand-written smoke test routing through its own public helpers (e.g. //! `transient::store` / `transient::load`) to cover the delegation wiring. -use crate::oracle_array::{ArrayOracle, delete, load, store, UnconstrainedArray}; use crate::protocol::traits::Serialize; use crate::test::helpers::test_environment::TestEnvironment; use crate::test::mocks::MockStruct; +use crate::unconstrained_array::{ArrayOracle, delete, load, store, UnconstrainedArray}; pub(crate) global SLOT: Field = 1230; pub(crate) global OTHER_SLOT: Field = 5670; @@ -765,9 +765,9 @@ comptime fn should_fail_message(name: Quoted) -> Option { /// Generates the shared [`UnconstrainedArray`] test suite against the given backend. Apply to a test module, passing /// the -/// backend's oracle as a quoted path: `#[oracle_array_tests(quote { crate::ephemeral::EphemeralOracle })]`. -pub(crate) comptime fn oracle_array_tests(_m: Module, oracle: Quoted) -> Quoted { - let helpers = quote { crate::oracle_array::test_helpers }.as_module().unwrap(); +/// backend's oracle as a quoted path: `#[unconstrained_array_tests(quote { crate::ephemeral::EphemeralOracle })]`. +pub(crate) comptime fn unconstrained_array_tests(_m: Module, oracle: Quoted) -> Quoted { + let helpers = quote { crate::unconstrained_array::test_helpers }.as_module().unwrap(); let mut tests = quote {}; for f in helpers.functions() { if f.is_unconstrained() { @@ -783,7 +783,7 @@ pub(crate) comptime fn oracle_array_tests(_m: Module, oracle: Quoted) -> Quoted $tests $attr unconstrained fn $name() { - crate::oracle_array::test_helpers::$name::<$oracle>(); + crate::unconstrained_array::test_helpers::$name::<$oracle>(); } }; } From 91772da91c7355e80ddfdd8cda779e4f927a6f66 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 09:09:47 +0000 Subject: [PATCH 18/31] docs(aztec-nr): reflow doc comments orphaned by the UnconstrainedArray rename UnconstrainedArray is longer than the old OracleArray, so several doc lines that referenced the type (or its method links) tipped past the 120-column limit. nargo fmt breaks an over-long comment line by pushing only the trailing word onto a new line, which left mid-paragraph orphans like '/// not' and '/// the'. Rejoin those words and rewrap the affected paragraphs at natural points, keeping every line within 120 columns. --- .../aztec-nr/aztec/src/ephemeral/mod.nr | 4 ++-- .../aztec-nr/aztec/src/transient/mod.nr | 5 ++--- .../aztec/src/unconstrained_array/mod.nr | 17 ++++++----------- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr index e99b3a252114..8dd2acaa35ae 100644 --- a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr @@ -25,8 +25,8 @@ use crate::unconstrained_array::{ArrayOracle, UnconstrainedArray}; /// [`TransientArray`](crate::transient::TransientArray). pub type EphemeralArray = UnconstrainedArray; -/// Routes [`UnconstrainedArray`] operations to the ephemeral array oracles, scoping arrays to a single contract call -/// frame. +/// Routes [`UnconstrainedArray`] operations to the ephemeral array oracles, scoping arrays to a single contract +/// call frame. pub struct EphemeralOracle {} impl ArrayOracle for EphemeralOracle { diff --git a/noir-projects/aztec-nr/aztec/src/transient/mod.nr b/noir-projects/aztec-nr/aztec/src/transient/mod.nr index 57d2a9353dde..b01b2381e90e 100644 --- a/noir-projects/aztec-nr/aztec/src/transient/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/transient/mod.nr @@ -31,9 +31,8 @@ use crate::unconstrained_array::{ArrayOracle, UnconstrainedArray}; /// For data that must persist indefinitely, use [`CapsuleArray`](crate::capsules::CapsuleArray). pub type TransientArray = UnconstrainedArray; -/// Routes [`UnconstrainedArray`] operations to the transient array oracles, sharing arrays across all call frames of -/// the -/// same contract within one top-level PXE call. +/// Routes [`UnconstrainedArray`] operations to the transient array oracles, sharing arrays across all call frames +/// of the same contract within one top-level PXE call. pub struct TransientOracle {} impl ArrayOracle for TransientOracle { diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr index b6e27b7fc218..f9e4f61fa166 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr @@ -68,8 +68,7 @@ where /// /// Use this when the caller does not need a specific slot: the random slot is isolated from every other array of /// the same backend with overwhelming probability. Prefer [`UnconstrainedArray::empty_at`] when the slot must be a - /// known - /// value (e.g. one shared with an oracle or another call frame). + /// known value (e.g. one shared with an oracle or another call frame). pub unconstrained fn empty() -> Self { Self::empty_at(random()) } @@ -150,8 +149,7 @@ where /// /// Reads each element in order, transforms it with `f`, and pushes the result onto a new array at a freshly /// allocated slot (see [`UnconstrainedArray::empty`]). The source array is left unchanged, and the result is - /// isolated - /// from it, so `map` can be chained. + /// isolated from it, so `map` can be chained. pub unconstrained fn map(self, f: unconstrained fn[Env](T) -> U) -> UnconstrainedArray where T: Deserialize, @@ -169,8 +167,7 @@ where /// /// Reads each element in order, keeps those for which `f` returns `true`, and pushes them onto a new array at a /// freshly allocated slot (see [`UnconstrainedArray::empty`]). Relative order is preserved. The source array is - /// left - /// unchanged, and the result is isolated from it, so `filter` can be chained. + /// left unchanged, and the result is isolated from it, so `filter` can be chained. pub unconstrained fn filter(self, f: unconstrained fn[Env](T) -> bool) -> Self where T: Serialize + Deserialize, @@ -190,8 +187,7 @@ where /// /// Matches the `any` combinator on Noir's [`array`](std::array), slice, and `BoundedVec` collections. Defined in /// terms of [`UnconstrainedArray::filter`]: it keeps the matching elements and checks whether any survived. This is - /// not - /// short-circuiting — every element is tested even after the first match. + /// not short-circuiting — every element is tested even after the first match. pub unconstrained fn any(self, f: unconstrained fn[Env](T) -> bool) -> bool where T: Serialize + Deserialize, @@ -214,9 +210,8 @@ where /// Returns the first element satisfying the predicate `f`, or `Option::none` if none do. /// /// Mirrors Rust's `Iterator::find`. Defined in terms of [`UnconstrainedArray::filter`], which preserves order, so - /// the - /// first kept element is the first match. This is not short-circuiting — every element is tested even after the - /// match is found. + /// the first kept element is the first match. This is not short-circuiting — every element is tested even after + /// the match is found. pub unconstrained fn find(self, f: unconstrained fn[Env](T) -> bool) -> Option where T: Serialize + Deserialize, From 15a7e004235986da39d1064ee0f3787ce5b87ee4 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 09:17:23 +0000 Subject: [PATCH 19/31] refactor(aztec-nr): pluralize ArrayOracles, EphemeralOracles, TransientOracles Each of these names a set of oracle operations -- the full backend an array needs (ArrayOracles) and the concrete ephemeral/transient bundles that implement it -- so the plural reads truer and matches the ephemeral_oracles/transient_oracles module names. The singular Oracle remains as the type parameter: one backend slot filled by one bundle. --- .../aztec-nr/aztec/src/ephemeral/mod.nr | 16 ++-- .../aztec-nr/aztec/src/transient/mod.nr | 16 ++-- .../aztec/src/unconstrained_array/mod.nr | 21 ++-- .../src/unconstrained_array/test_helpers.nr | 96 +++++++++---------- 4 files changed, 71 insertions(+), 78 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr index 8dd2acaa35ae..1dc263e46726 100644 --- a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr @@ -1,6 +1,6 @@ use crate::oracle::ephemeral_oracles; use crate::protocol::traits::{Deserialize, Serialize}; -use crate::unconstrained_array::{ArrayOracle, UnconstrainedArray}; +use crate::unconstrained_array::{ArrayOracles, UnconstrainedArray}; /// A dynamically sized array that exists only during a single contract call frame. /// @@ -23,13 +23,13 @@ use crate::unconstrained_array::{ArrayOracle, UnconstrainedArray}; /// For data that must be shared across all frames of the same contract (private and utility) within one top-level PXE /// call (transaction simulation or utility call) but not persisted, use /// [`TransientArray`](crate::transient::TransientArray). -pub type EphemeralArray = UnconstrainedArray; +pub type EphemeralArray = UnconstrainedArray; /// Routes [`UnconstrainedArray`] operations to the ephemeral array oracles, scoping arrays to a single contract /// call frame. -pub struct EphemeralOracle {} +pub struct EphemeralOracles {} -impl ArrayOracle for EphemeralOracle { +impl ArrayOracles for EphemeralOracles { unconstrained fn len_oracle(slot: Field) -> u32 { ephemeral_oracles::len_oracle(slot) } @@ -64,7 +64,7 @@ pub unconstrained fn store(slot: Field, value: T) where T: Serialize, { - crate::unconstrained_array::store::(slot, value) + crate::unconstrained_array::store::(slot, value) } /// Returns the value previously stored at `slot` with [`store`], or `Option::none()` if the slot holds no value. @@ -72,15 +72,15 @@ pub unconstrained fn load(slot: Field) -> Option where T: Deserialize, { - crate::unconstrained_array::load::(slot) + crate::unconstrained_array::load::(slot) } /// Deletes the value stored at `slot`. Does nothing if the slot is already empty. pub unconstrained fn delete(slot: Field) { - crate::unconstrained_array::delete::(slot) + crate::unconstrained_array::delete::(slot) } -#[crate::unconstrained_array::test_helpers::unconstrained_array_tests(quote { crate::ephemeral::EphemeralOracle })] +#[crate::unconstrained_array::test_helpers::unconstrained_array_tests(quote { crate::ephemeral::EphemeralOracles })] mod test { use crate::test::helpers::test_environment::TestEnvironment; use crate::unconstrained_array::test_helpers::SLOT; diff --git a/noir-projects/aztec-nr/aztec/src/transient/mod.nr b/noir-projects/aztec-nr/aztec/src/transient/mod.nr index b01b2381e90e..43d7ee2b1666 100644 --- a/noir-projects/aztec-nr/aztec/src/transient/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/transient/mod.nr @@ -1,6 +1,6 @@ use crate::oracle::transient_oracles; use crate::protocol::traits::{Deserialize, Serialize}; -use crate::unconstrained_array::{ArrayOracle, UnconstrainedArray}; +use crate::unconstrained_array::{ArrayOracles, UnconstrainedArray}; /// A dynamically sized array that lives for the duration of a single top-level PXE call. /// @@ -29,13 +29,13 @@ use crate::unconstrained_array::{ArrayOracle, UnconstrainedArray}; /// Use this to pass not-to-be-persisted data between a contract's own frames (private or utility) within one top-level /// PXE call. For data confined to a single call frame, prefer [`EphemeralArray`](crate::ephemeral::EphemeralArray). /// For data that must persist indefinitely, use [`CapsuleArray`](crate::capsules::CapsuleArray). -pub type TransientArray = UnconstrainedArray; +pub type TransientArray = UnconstrainedArray; /// Routes [`UnconstrainedArray`] operations to the transient array oracles, sharing arrays across all call frames /// of the same contract within one top-level PXE call. -pub struct TransientOracle {} +pub struct TransientOracles {} -impl ArrayOracle for TransientOracle { +impl ArrayOracles for TransientOracles { unconstrained fn len_oracle(slot: Field) -> u32 { transient_oracles::len_oracle(slot) } @@ -70,7 +70,7 @@ pub unconstrained fn store(slot: Field, value: T) where T: Serialize, { - crate::unconstrained_array::store::(slot, value) + crate::unconstrained_array::store::(slot, value) } /// Returns the value previously stored at `slot` with [`store`], or `Option::none()` if the slot holds no value. @@ -78,15 +78,15 @@ pub unconstrained fn load(slot: Field) -> Option where T: Deserialize, { - crate::unconstrained_array::load::(slot) + crate::unconstrained_array::load::(slot) } /// Deletes the value stored at `slot`. Does nothing if the slot is already empty. pub unconstrained fn delete(slot: Field) { - crate::unconstrained_array::delete::(slot) + crate::unconstrained_array::delete::(slot) } -#[crate::unconstrained_array::test_helpers::unconstrained_array_tests(quote { crate::transient::TransientOracle })] +#[crate::unconstrained_array::test_helpers::unconstrained_array_tests(quote { crate::transient::TransientOracles })] mod test { use crate::test::helpers::test_environment::TestEnvironment; use crate::unconstrained_array::test_helpers::SLOT; diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr index f9e4f61fa166..aff0208d2d5f 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr @@ -5,14 +5,7 @@ use crate::protocol::traits::{Deserialize, Serialize}; use crate::protocol::utils::{reader::Reader, writer::Writer}; /// Oracle backend for an [`UnconstrainedArray`]: the set of PXE-side operations that implement its storage. -/// -/// Each implementor routes these operations to a distinct family of oracles, and the oracle family determines the -/// array's lifetime and visibility (e.g. [`EphemeralArray`](crate::ephemeral::EphemeralArray) arrays live for one -/// contract call frame, while [`TransientArray`](crate::transient::TransientArray) arrays are shared across all frames -/// of the same contract within one top-level PXE call). Implementations are thin wrappers around `#[oracle]` -/// declarations, so method dispatch is resolved at compile time via monomorphization and each array type emits its own -/// foreign calls. -pub trait ArrayOracle { +pub trait ArrayOracles { /// Returns the number of elements in the array at `slot`. unconstrained fn len_oracle(slot: Field) -> u32; @@ -35,7 +28,7 @@ pub trait ArrayOracle { unconstrained fn clear_oracle(slot: Field); } -/// A dynamically sized array backed by PXE-side in-memory storage via an [`ArrayOracle`] backend. +/// A dynamically sized array backed by PXE-side in-memory storage via an [`ArrayOracles`] backend. /// /// Arrays are identified by a slot, and each logical operation (push, pop, get, etc.) is a single oracle call. The /// `Oracle` backend determines the array's lifetime and visibility; contracts should not use this type directly but @@ -48,7 +41,7 @@ pub struct UnconstrainedArray { impl UnconstrainedArray where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { /// Returns a handle to the array at the given slot, which may already contain data (e.g. populated by an oracle /// or by an earlier frame, depending on the backend's visibility). @@ -227,7 +220,7 @@ where impl UnconstrainedArray where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { /// Deserializes the whole array into a `T`. /// @@ -281,7 +274,7 @@ impl Deserialize for UnconstrainedArray { pub(crate) unconstrained fn store(slot: Field, value: T) where T: Serialize, - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let array: UnconstrainedArray = UnconstrainedArray::at(slot); array.clear().push(value); @@ -291,7 +284,7 @@ where pub(crate) unconstrained fn load(slot: Field) -> Option where T: Deserialize, - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let array: UnconstrainedArray = UnconstrainedArray::at(slot); if array.len() == 0 { @@ -304,7 +297,7 @@ where /// Deletes the value stored at `slot`. Does nothing if the slot is already empty. pub(crate) unconstrained fn delete(slot: Field) where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { Oracle::clear_oracle(slot) } diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr index 678126b56d2d..1ed1398884ce 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr @@ -1,7 +1,7 @@ //! Shared test suite for [`UnconstrainedArray`] backends. //! //! Every function in this module checks one behavior of the shared [`UnconstrainedArray`] API (or of the generic -//! `store` / `load` / `delete` kv helpers) and is generic over the [`ArrayOracle`] backend. +//! `store` / `load` / `delete` kv helpers) and is generic over the [`ArrayOracles`] backend. //! //! Each backend module additionally keeps a hand-written smoke test routing through its own public helpers (e.g. //! `transient::store` / `transient::load`) to cover the delegation wiring. @@ -9,14 +9,14 @@ use crate::protocol::traits::Serialize; use crate::test::helpers::test_environment::TestEnvironment; use crate::test::mocks::MockStruct; -use crate::unconstrained_array::{ArrayOracle, delete, load, store, UnconstrainedArray}; +use crate::unconstrained_array::{ArrayOracles, delete, load, store, UnconstrainedArray}; pub(crate) global SLOT: Field = 1230; pub(crate) global OTHER_SLOT: Field = 5670; pub(crate) unconstrained fn empty_array() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -27,7 +27,7 @@ where pub(crate) unconstrained fn should_fail_empty_array_read() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -38,7 +38,7 @@ where pub(crate) unconstrained fn should_fail_empty_array_pop() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -49,7 +49,7 @@ where pub(crate) unconstrained fn array_push() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -63,7 +63,7 @@ where pub(crate) unconstrained fn should_fail_read_past_len() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -76,7 +76,7 @@ where pub(crate) unconstrained fn array_pop() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -93,7 +93,7 @@ where pub(crate) unconstrained fn array_set() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -106,7 +106,7 @@ where pub(crate) unconstrained fn array_remove_last() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -119,7 +119,7 @@ where pub(crate) unconstrained fn array_remove_some() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -141,7 +141,7 @@ where pub(crate) unconstrained fn array_remove_all() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -161,7 +161,7 @@ where pub(crate) unconstrained fn for_each_called_with_all_elements() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -183,7 +183,7 @@ where pub(crate) unconstrained fn for_each_remove_some() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -207,7 +207,7 @@ where pub(crate) unconstrained fn for_each_remove_all() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -225,7 +225,7 @@ where pub(crate) unconstrained fn different_slots_are_isolated() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -247,7 +247,7 @@ where pub(crate) unconstrained fn works_with_multi_field_type() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -270,7 +270,7 @@ where pub(crate) unconstrained fn read_as_reconstructs_serialized_value() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -289,7 +289,7 @@ where pub(crate) unconstrained fn should_fail_read_as_rejects_length_mismatch() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -303,7 +303,7 @@ where pub(crate) unconstrained fn clear_returns_self() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -318,7 +318,7 @@ where pub(crate) unconstrained fn clear_wipes_previous_data() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -338,7 +338,7 @@ where pub(crate) unconstrained fn empty_allocates_distinct_slots() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -358,7 +358,7 @@ where pub(crate) unconstrained fn map_transforms_each_element() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -382,7 +382,7 @@ where pub(crate) unconstrained fn map_empty_source_gives_empty_dest() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -394,7 +394,7 @@ where pub(crate) unconstrained fn map_to_different_type() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -412,7 +412,7 @@ where pub(crate) unconstrained fn map_results_are_isolated() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -435,7 +435,7 @@ where pub(crate) unconstrained fn filter_keeps_matching_elements_in_order() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -461,7 +461,7 @@ where pub(crate) unconstrained fn filter_empty_source_gives_empty_dest() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -473,7 +473,7 @@ where pub(crate) unconstrained fn filter_none_match_gives_empty_dest() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -489,7 +489,7 @@ where pub(crate) unconstrained fn filter_works_with_multi_field_type() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -508,7 +508,7 @@ where pub(crate) unconstrained fn filter_results_are_isolated() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -534,7 +534,7 @@ where pub(crate) unconstrained fn any_is_true_when_an_element_matches() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -549,7 +549,7 @@ where pub(crate) unconstrained fn any_is_false_when_no_element_matches() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -564,7 +564,7 @@ where pub(crate) unconstrained fn any_on_empty_array_is_false() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -575,7 +575,7 @@ where pub(crate) unconstrained fn all_is_true_when_every_element_matches() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -590,7 +590,7 @@ where pub(crate) unconstrained fn all_is_false_when_one_element_fails() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -605,7 +605,7 @@ where pub(crate) unconstrained fn all_on_empty_array_is_true() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -616,7 +616,7 @@ where pub(crate) unconstrained fn find_returns_first_matching_element() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -635,7 +635,7 @@ where pub(crate) unconstrained fn find_returns_none_when_no_element_matches() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -650,7 +650,7 @@ where pub(crate) unconstrained fn find_on_empty_array_is_none() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -661,7 +661,7 @@ where pub(crate) unconstrained fn empty_at_wipes_previous_data() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -676,7 +676,7 @@ where pub(crate) unconstrained fn load_empty_returns_none() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -687,7 +687,7 @@ where pub(crate) unconstrained fn store_overwrites() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -699,7 +699,7 @@ where pub(crate) unconstrained fn delete_removes_value() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -712,7 +712,7 @@ where pub(crate) unconstrained fn delete_empty_is_noop() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -724,7 +724,7 @@ where pub(crate) unconstrained fn store_and_load_multi_field_type() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -736,7 +736,7 @@ where pub(crate) unconstrained fn stored_value_is_visible_as_a_length_one_array() where - Oracle: ArrayOracle, + Oracle: ArrayOracles, { let env = TestEnvironment::new(); env.utility_context(|_| { @@ -765,7 +765,7 @@ comptime fn should_fail_message(name: Quoted) -> Option { /// Generates the shared [`UnconstrainedArray`] test suite against the given backend. Apply to a test module, passing /// the -/// backend's oracle as a quoted path: `#[unconstrained_array_tests(quote { crate::ephemeral::EphemeralOracle })]`. +/// backend's oracle as a quoted path: `#[unconstrained_array_tests(quote { crate::ephemeral::EphemeralOracles })]`. pub(crate) comptime fn unconstrained_array_tests(_m: Module, oracle: Quoted) -> Quoted { let helpers = quote { crate::unconstrained_array::test_helpers }.as_module().unwrap(); let mut tests = quote {}; From aa90bebd4f51085297c935e8304c2cc921dc6ec4 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 09:21:47 +0000 Subject: [PATCH 20/31] docs(aztec-nr): restore ArrayOracles trait doc paragraph The explanatory paragraph on the ArrayOracles trait was dropped during the previous pluralize commit (a formatter re-stage race on the shared working tree); the rename itself only swapped the identifier. Restore the paragraph verbatim. nargo fmt run directly keeps it intact. --- .../aztec-nr/aztec/src/unconstrained_array/mod.nr | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr index aff0208d2d5f..aa1d5319686c 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr @@ -5,6 +5,13 @@ use crate::protocol::traits::{Deserialize, Serialize}; use crate::protocol::utils::{reader::Reader, writer::Writer}; /// Oracle backend for an [`UnconstrainedArray`]: the set of PXE-side operations that implement its storage. +/// +/// Each implementor routes these operations to a distinct family of oracles, and the oracle family determines the +/// array's lifetime and visibility (e.g. [`EphemeralArray`](crate::ephemeral::EphemeralArray) arrays live for one +/// contract call frame, while [`TransientArray`](crate::transient::TransientArray) arrays are shared across all frames +/// of the same contract within one top-level PXE call). Implementations are thin wrappers around `#[oracle]` +/// declarations, so method dispatch is resolved at compile time via monomorphization and each array type emits its own +/// foreign calls. pub trait ArrayOracles { /// Returns the number of elements in the array at `slot`. unconstrained fn len_oracle(slot: Field) -> u32; From e174bcee3559669c34b2834c6c3dd5ca3d426a75 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 09:52:01 +0000 Subject: [PATCH 21/31] properly document panics --- .../aztec/src/oracle/ephemeral_oracles.nr | 8 ++++---- .../aztec/src/oracle/transient_oracles.nr | 8 ++++---- .../aztec/src/unconstrained_array/mod.nr | 19 ++++++++++++------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/oracle/ephemeral_oracles.nr b/noir-projects/aztec-nr/aztec/src/oracle/ephemeral_oracles.nr index 9f2721fc53c8..cb053b599841 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/ephemeral_oracles.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/ephemeral_oracles.nr @@ -8,15 +8,15 @@ #[oracle(aztec_utl_pushEphemeral)] pub(crate) unconstrained fn push_oracle(slot: Field, values: [Field; N]) -> u32 {} -/// Removes and returns the last serialized element from the ephemeral array. +/// Removes and returns the last serialized element from the ephemeral array. Panics if the array is empty. #[oracle(aztec_utl_popEphemeral)] pub(crate) unconstrained fn pop_oracle(slot: Field) -> [Field; N] {} -/// Returns the serialized element at the given index. +/// Returns the serialized element at the given index. Panics if `index` is out of bounds. #[oracle(aztec_utl_getEphemeral)] pub(crate) unconstrained fn get_oracle(slot: Field, index: u32) -> [Field; N] {} -/// Overwrites the serialized element at the given index. +/// Overwrites the serialized element at the given index. Panics if `index` is out of bounds. #[oracle(aztec_utl_setEphemeral)] pub(crate) unconstrained fn set_oracle(slot: Field, index: u32, values: [Field; N]) {} @@ -24,7 +24,7 @@ pub(crate) unconstrained fn set_oracle(slot: Field, index: u32, valu #[oracle(aztec_utl_getEphemeralLen)] pub(crate) unconstrained fn len_oracle(slot: Field) -> u32 {} -/// Removes the element at the given index, shifting subsequent elements backward. +/// Removes the element at the given index, shifting subsequent elements backward. Panics if `index` is out of bounds. #[oracle(aztec_utl_removeEphemeral)] pub(crate) unconstrained fn remove_oracle(slot: Field, index: u32) {} diff --git a/noir-projects/aztec-nr/aztec/src/oracle/transient_oracles.nr b/noir-projects/aztec-nr/aztec/src/oracle/transient_oracles.nr index c7db29c51b2d..9c76dca0e635 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/transient_oracles.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/transient_oracles.nr @@ -9,15 +9,15 @@ #[oracle(aztec_utl_pushTransient)] pub(crate) unconstrained fn push_oracle(slot: Field, values: [Field; N]) -> u32 {} -/// Removes and returns the last serialized element from the transient array. +/// Removes and returns the last serialized element from the transient array. Panics if the array is empty. #[oracle(aztec_utl_popTransient)] pub(crate) unconstrained fn pop_oracle(slot: Field) -> [Field; N] {} -/// Returns the serialized element at the given index. +/// Returns the serialized element at the given index. Panics if `index` is out of bounds. #[oracle(aztec_utl_getTransient)] pub(crate) unconstrained fn get_oracle(slot: Field, index: u32) -> [Field; N] {} -/// Overwrites the serialized element at the given index. +/// Overwrites the serialized element at the given index. Panics if `index` is out of bounds. #[oracle(aztec_utl_setTransient)] pub(crate) unconstrained fn set_oracle(slot: Field, index: u32, values: [Field; N]) {} @@ -25,7 +25,7 @@ pub(crate) unconstrained fn set_oracle(slot: Field, index: u32, valu #[oracle(aztec_utl_getTransientLen)] pub(crate) unconstrained fn len_oracle(slot: Field) -> u32 {} -/// Removes the element at the given index, shifting subsequent elements backward. +/// Removes the element at the given index, shifting subsequent elements backward. Panics if `index` is out of bounds. #[oracle(aztec_utl_removeTransient)] pub(crate) unconstrained fn remove_oracle(slot: Field, index: u32) {} diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr index aa1d5319686c..fafceb17674b 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr @@ -19,16 +19,20 @@ pub trait ArrayOracles { /// Appends a serialized element to the array at `slot` and returns the new length. unconstrained fn push_oracle(slot: Field, values: [Field; N]) -> u32; - /// Removes and returns the last serialized element of the array at `slot`. + /// Removes and returns the last serialized element of the array at `slot`. Implementors must panic if the + /// array is empty. unconstrained fn pop_oracle(slot: Field) -> [Field; N]; - /// Returns the serialized element at the given index of the array at `slot`. + /// Returns the serialized element at the given index of the array at `slot`. Implementors must panic if `index` + /// is out of bounds. unconstrained fn get_oracle(slot: Field, index: u32) -> [Field; N]; - /// Overwrites the serialized element at the given index of the array at `slot`. + /// Overwrites the serialized element at the given index of the array at `slot`. Implementors must panic if + /// `index` is out of bounds. unconstrained fn set_oracle(slot: Field, index: u32, values: [Field; N]); /// Removes the element at the given index of the array at `slot`, shifting subsequent elements backward. + /// Implementors must panic if `index` is out of bounds. unconstrained fn remove_oracle(slot: Field, index: u32); /// Removes all elements from the array at `slot`. @@ -87,7 +91,7 @@ where let _ = Oracle::push_oracle(self.slot, serialized); } - /// Removes and returns the last element. Panics if the array is empty. + /// Removes and returns the last element. Implementors are required to panic if the array is empty. pub unconstrained fn pop(self) -> T where T: Deserialize, @@ -96,7 +100,7 @@ where Deserialize::deserialize(serialized) } - /// Retrieves the value stored at `index`. Panics if the index is out of bounds. + /// Retrieves the value stored at `index`. Implementors are required to panic if the index is out of bounds. pub unconstrained fn get(self, index: u32) -> T where T: Deserialize, @@ -105,7 +109,7 @@ where Deserialize::deserialize(serialized) } - /// Overwrites the value stored at `index`. Panics if the index is out of bounds. + /// Overwrites the value stored at `index`. Implementors are required to panic if the index is out of bounds. pub unconstrained fn set(self, index: u32, value: T) where T: Serialize, @@ -114,7 +118,8 @@ where Oracle::set_oracle(self.slot, index, serialized); } - /// Removes the element at `index`, shifting subsequent elements backward. Panics if out of bounds. + /// Removes the element at `index`, shifting subsequent elements backward. Implementors are required to panic if + /// the index is out of bounds. pub unconstrained fn remove(self, index: u32) { Oracle::remove_oracle(self.slot, index); } From 950a4a2ed435418340dda3741252116a990af066 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 10:21:53 +0000 Subject: [PATCH 22/31] empty does not need to clear --- noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr index fafceb17674b..f05f03ab01af 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr @@ -74,7 +74,7 @@ where /// the same backend with overwhelming probability. Prefer [`UnconstrainedArray::empty_at`] when the slot must be a /// known value (e.g. one shared with an oracle or another call frame). pub unconstrained fn empty() -> Self { - Self::empty_at(random()) + Self::at(random()) } /// Returns the number of elements stored in the array. @@ -125,8 +125,6 @@ where } /// Removes all elements from the array and returns self for chaining. - /// - /// Prefer [`UnconstrainedArray::empty_at`] when the intent is to start with a fresh array. pub unconstrained fn clear(self) -> Self { Oracle::clear_oracle(self.slot); self From c85569f8d9ea3f5370339d179572a4ca9f1fd929 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 11:19:03 +0000 Subject: [PATCH 23/31] make find short-circuit --- .../aztec/src/unconstrained_array/mod.nr | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr index f05f03ab01af..77e202e26efd 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr @@ -211,20 +211,21 @@ where } /// Returns the first element satisfying the predicate `f`, or `Option::none` if none do. - /// - /// Mirrors Rust's `Iterator::find`. Defined in terms of [`UnconstrainedArray::filter`], which preserves order, so - /// the first kept element is the first match. This is not short-circuiting — every element is tested even after - /// the match is found. pub unconstrained fn find(self, f: unconstrained fn[Env](T) -> bool) -> Option where - T: Serialize + Deserialize, + T: Deserialize, { - let matches = self.filter(f); - if matches.len() != 0 { - Option::some(matches.get(0)) - } else { - Option::none() + let n = self.len(); + let mut result: Option = Option::none(); + let mut i = 0; + while (i < n) & result.is_none() { + let value = self.get(i); + if f(value) { + result = Option::some(value); + } + i += 1; } + result } } From 0ca93c66ef43362b370dbe7c940b42d925120650 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 11:23:59 +0000 Subject: [PATCH 24/31] remove read_as for now --- .../aztec/src/unconstrained_array/mod.nr | 22 ------------ .../src/unconstrained_array/test_helpers.nr | 35 ------------------- 2 files changed, 57 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr index 77e202e26efd..c0d53a12f4de 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr @@ -229,28 +229,6 @@ where } } -impl UnconstrainedArray -where - Oracle: ArrayOracles, -{ - /// Deserializes the whole array into a `T`. - /// - /// Asserts the array holds exactly `::N` fields, then reconstructs a `T` from them. This is the - /// read-back counterpart to pushing a value's serialized fields onto the array, and is defined only on arrays of - /// `Field` because deserialization reconstructs a type from raw fields. - pub unconstrained fn read_as(self) -> T - where - T: Deserialize, - { - assert_eq(self.len(), ::N, "UnconstrainedArray length mismatch for read_as"); - let mut fields: [Field; ::N] = [0; ::N]; - for i in 0..::N { - fields[i] = self.get(i); - } - Deserialize::deserialize(fields) - } -} - /// Serializes an `UnconstrainedArray` as its slot identifier, allowing oracle function signatures to use array types /// instead of opaque `Field` slots. impl Serialize for UnconstrainedArray { diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr index 1ed1398884ce..dd87da5d2af2 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr @@ -268,39 +268,6 @@ where }); } -pub(crate) unconstrained fn read_as_reconstructs_serialized_value() -where - Oracle: ArrayOracles, -{ - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); - - let value = MockStruct::new(5, 6); - let serialized = value.serialize(); - for i in 0..serialized.len() { - array.push(serialized[i]); - } - - let reconstructed: MockStruct = array.read_as(); - assert_eq(reconstructed, value); - }); -} - -pub(crate) unconstrained fn should_fail_read_as_rejects_length_mismatch() -where - Oracle: ArrayOracles, -{ - let env = TestEnvironment::new(); - env.utility_context(|_| { - let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); - // MockStruct deserializes from 2 fields, so a single field is too short. - array.push(1); - - let _: MockStruct = array.read_as(); - }); -} - pub(crate) unconstrained fn clear_returns_self() where Oracle: ArrayOracles, @@ -756,8 +723,6 @@ comptime fn should_fail_message(name: Quoted) -> Option { Option::some(quote { "is empty" }) } else if name == quote { should_fail_read_past_len } { Option::some(quote { "out of bounds" }) - } else if name == quote { should_fail_read_as_rejects_length_mismatch } { - Option::some(quote { "length mismatch" }) } else { Option::none() } From 5fac01ba70fd6f8e318490b1a12f31a913684344 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 11:57:56 +0000 Subject: [PATCH 25/31] move Serialize/Deserialize traits from UnconstrainedArray to EphemeralArray --- .../aztec-nr/aztec/src/ephemeral/mod.nr | 28 ++++++++++++++++++ .../aztec/src/unconstrained_array/mod.nr | 29 ------------------- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr index 1dc263e46726..80207dab8aa7 100644 --- a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr @@ -1,5 +1,6 @@ use crate::oracle::ephemeral_oracles; use crate::protocol::traits::{Deserialize, Serialize}; +use crate::protocol::utils::{reader::Reader, writer::Writer}; use crate::unconstrained_array::{ArrayOracles, UnconstrainedArray}; /// A dynamically sized array that exists only during a single contract call frame. @@ -59,6 +60,33 @@ impl ArrayOracles for EphemeralOracles { } } +/// Serializes an `EphemeralArray` as its slot, allowing oracle function signatures to use ephemeral array types +/// instead of opaque `Field` slots. +impl Serialize for UnconstrainedArray { + let N: u32 = 1; + + fn serialize(self) -> [Field; Self::N] { + [self.slot] + } + + fn stream_serialize(self, writer: &mut Writer) { + writer.write(self.slot); + } +} + +/// Deserializes a single Field into an `EphemeralArray` handle, treating the field value as the slot identifier. +impl Deserialize for UnconstrainedArray { + let N: u32 = 1; + + fn deserialize(fields: [Field; Self::N]) -> Self { + Self { slot: fields[0] } + } + + fn stream_deserialize(reader: &mut Reader) -> Self { + Self { slot: reader.read() } + } +} + /// Stores a single value at `slot`, overwriting any value previously stored there. pub unconstrained fn store(slot: Field, value: T) where diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr index c0d53a12f4de..7f7c32cb6368 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr @@ -2,7 +2,6 @@ pub(crate) mod test_helpers; use crate::oracle::random::random; use crate::protocol::traits::{Deserialize, Serialize}; -use crate::protocol::utils::{reader::Reader, writer::Writer}; /// Oracle backend for an [`UnconstrainedArray`]: the set of PXE-side operations that implement its storage. /// @@ -229,34 +228,6 @@ where } } -/// Serializes an `UnconstrainedArray` as its slot identifier, allowing oracle function signatures to use array types -/// instead of opaque `Field` slots. -impl Serialize for UnconstrainedArray { - let N: u32 = 1; - - fn serialize(self) -> [Field; Self::N] { - [self.slot] - } - - fn stream_serialize(self, writer: &mut Writer) { - writer.write(self.slot); - } -} - -/// Deserializes a single Field into an `UnconstrainedArray` handle, treating the field value as the slot identifier. -/// This is the inverse of [`Serialize`]. -impl Deserialize for UnconstrainedArray { - let N: u32 = 1; - - fn deserialize(fields: [Field; Self::N]) -> Self { - Self { slot: fields[0] } - } - - fn stream_deserialize(reader: &mut Reader) -> Self { - Self { slot: reader.read() } - } -} - /// Stores a single value at `slot`, overwriting any value previously stored there. /// /// The value is stored as a length-one array at the slot: read it back with [`load`] and remove it with [`delete`]. From f7cbb0175b0b878b1536cb5f6268023949953c9c Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 12:50:20 +0000 Subject: [PATCH 26/31] refactor kv functions --- .../aztec-nr/aztec/src/ephemeral/mod.nr | 38 +---- noir-projects/aztec-nr/aztec/src/lib.nr | 1 + .../aztec-nr/aztec/src/transient/mod.nr | 150 ++++++++++++++---- .../aztec/src/unconstrained_array/mod.nr | 34 ---- .../src/unconstrained_array/test_helpers.nr | 84 +--------- 5 files changed, 126 insertions(+), 181 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr index 80207dab8aa7..87ce7cda2d39 100644 --- a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr @@ -26,8 +26,6 @@ use crate::unconstrained_array::{ArrayOracles, UnconstrainedArray}; /// [`TransientArray`](crate::transient::TransientArray). pub type EphemeralArray = UnconstrainedArray; -/// Routes [`UnconstrainedArray`] operations to the ephemeral array oracles, scoping arrays to a single contract -/// call frame. pub struct EphemeralOracles {} impl ArrayOracles for EphemeralOracles { @@ -87,39 +85,5 @@ impl Deserialize for UnconstrainedArray { } } -/// Stores a single value at `slot`, overwriting any value previously stored there. -pub unconstrained fn store(slot: Field, value: T) -where - T: Serialize, -{ - crate::unconstrained_array::store::(slot, value) -} - -/// Returns the value previously stored at `slot` with [`store`], or `Option::none()` if the slot holds no value. -pub unconstrained fn load(slot: Field) -> Option -where - T: Deserialize, -{ - crate::unconstrained_array::load::(slot) -} - -/// Deletes the value stored at `slot`. Does nothing if the slot is already empty. -pub unconstrained fn delete(slot: Field) { - crate::unconstrained_array::delete::(slot) -} - #[crate::unconstrained_array::test_helpers::unconstrained_array_tests(quote { crate::ephemeral::EphemeralOracles })] -mod test { - use crate::test::helpers::test_environment::TestEnvironment; - use crate::unconstrained_array::test_helpers::SLOT; - use super::{load, store}; - - #[test] - unconstrained fn store_and_load() { - let env = TestEnvironment::new(); - env.utility_context(|_| { - store(SLOT, 42); - assert_eq(load(SLOT), Option::some(42)); - }); - } -} +mod test {} diff --git a/noir-projects/aztec-nr/aztec/src/lib.nr b/noir-projects/aztec-nr/aztec/src/lib.nr index 387f820d22da..1a164ce8a1e2 100644 --- a/noir-projects/aztec-nr/aztec/src/lib.nr +++ b/noir-projects/aztec-nr/aztec/src/lib.nr @@ -40,6 +40,7 @@ pub mod oracle; pub mod state_vars; pub mod capsules; pub mod unconstrained_array; +pub mod unconstrained_value; pub mod ephemeral; pub mod transient; pub mod event; diff --git a/noir-projects/aztec-nr/aztec/src/transient/mod.nr b/noir-projects/aztec-nr/aztec/src/transient/mod.nr index 43d7ee2b1666..57cb24091f4e 100644 --- a/noir-projects/aztec-nr/aztec/src/transient/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/transient/mod.nr @@ -31,8 +31,53 @@ use crate::unconstrained_array::{ArrayOracles, UnconstrainedArray}; /// For data that must persist indefinitely, use [`CapsuleArray`](crate::capsules::CapsuleArray). pub type TransientArray = UnconstrainedArray; -/// Routes [`UnconstrainedArray`] operations to the transient array oracles, sharing arrays across all call frames -/// of the same contract within one top-level PXE call. +/// A single value that lives for the duration of a single top-level PXE call. +/// +/// Shared across all call frames of the same contract (private and utility alike) within one top-level PXE call. +/// A contract can only ever access its own transient values; other contracts called within the same top-level PXE call +/// cannot see them. +/// +/// Values share the slot space with [`TransientArray`]s: a value and an array at the same slot alias each other, so +/// use distinct slots. +pub struct TransientValue { + slot: Field, +} + +impl TransientValue { + /// Returns a handle to the value at the given slot, which may already hold data stored by an earlier frame. + pub unconstrained fn at(slot: Field) -> Self { + Self { slot } + } + + /// Stores a value at the slot, overwriting any value previously stored there. + pub unconstrained fn store(self, value: T) + where + T: Serialize, + { + transient_oracles::clear_oracle(self.slot); + let _ = transient_oracles::push_oracle(self.slot, value.serialize()); + } + + /// Returns the value previously stored at the slot with [`TransientValue::store`], or `Option::none()` if the + /// slot holds no value. + pub unconstrained fn read(self) -> Option + where + T: Deserialize, + { + if transient_oracles::len_oracle(self.slot) == 0 { + Option::none() + } else { + let serialized = transient_oracles::get_oracle(self.slot, 0); + Option::some(Deserialize::deserialize(serialized)) + } + } + + /// Deletes the stored value. Does nothing if the slot holds no value. + pub unconstrained fn delete(self) { + transient_oracles::clear_oracle(self.slot) + } +} + pub struct TransientOracles {} impl ArrayOracles for TransientOracles { @@ -65,39 +110,88 @@ impl ArrayOracles for TransientOracles { } } -/// Stores a single value at `slot`, overwriting any value previously stored there. -pub unconstrained fn store(slot: Field, value: T) -where - T: Serialize, -{ - crate::unconstrained_array::store::(slot, value) -} +#[crate::unconstrained_array::test_helpers::unconstrained_array_tests(quote { crate::transient::TransientOracles })] +mod test {} -/// Returns the value previously stored at `slot` with [`store`], or `Option::none()` if the slot holds no value. -pub unconstrained fn load(slot: Field) -> Option -where - T: Deserialize, -{ - crate::unconstrained_array::load::(slot) -} +mod value_test { + use crate::test::helpers::test_environment::TestEnvironment; + use crate::test::mocks::MockStruct; + use super::{TransientArray, TransientValue}; -/// Deletes the value stored at `slot`. Does nothing if the slot is already empty. -pub unconstrained fn delete(slot: Field) { - crate::unconstrained_array::delete::(slot) -} + global SLOT: Field = 9870; -#[crate::unconstrained_array::test_helpers::unconstrained_array_tests(quote { crate::transient::TransientOracles })] -mod test { - use crate::test::helpers::test_environment::TestEnvironment; - use crate::unconstrained_array::test_helpers::SLOT; - use super::{load, store}; + #[test] + unconstrained fn read_empty_returns_none() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let value: TransientValue = TransientValue::at(SLOT); + assert_eq(value.read(), Option::none()); + }); + } + + #[test] + unconstrained fn store_and_read_roundtrips() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let value: TransientValue = TransientValue::at(SLOT); + value.store(42); + assert_eq(value.read(), Option::some(42)); + }); + } + + #[test] + unconstrained fn store_overwrites() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let value: TransientValue = TransientValue::at(SLOT); + value.store(1); + value.store(2); + assert_eq(value.read(), Option::some(2)); + }); + } + + #[test] + unconstrained fn delete_removes_value() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let value: TransientValue = TransientValue::at(SLOT); + value.store(42); + value.delete(); + assert_eq(value.read(), Option::none()); + }); + } + + #[test] + unconstrained fn delete_empty_is_noop() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let value: TransientValue = TransientValue::at(SLOT); + value.delete(); + assert_eq(value.read(), Option::none()); + }); + } #[test] - unconstrained fn store_and_load() { + unconstrained fn works_with_multi_field_type() { let env = TestEnvironment::new(); env.utility_context(|_| { - store(SLOT, 42); - assert_eq(load(SLOT), Option::some(42)); + let value: TransientValue = TransientValue::at(SLOT); + let stored = MockStruct::new(5, 6); + value.store(stored); + assert_eq(value.read(), Option::some(stored)); + }); + } + + #[test] + unconstrained fn stored_value_is_visible_as_a_length_one_array() { + let env = TestEnvironment::new(); + env.utility_context(|_| { + let value: TransientValue = TransientValue::at(SLOT); + value.store(42); + + let array: TransientArray = TransientArray::at(SLOT); + assert_eq(array.len(), 1); + assert_eq(array.get(0), 42); }); } } diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr index 7f7c32cb6368..af5da1e47893 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr @@ -227,37 +227,3 @@ where result } } - -/// Stores a single value at `slot`, overwriting any value previously stored there. -/// -/// The value is stored as a length-one array at the slot: read it back with [`load`] and remove it with [`delete`]. -pub(crate) unconstrained fn store(slot: Field, value: T) -where - T: Serialize, - Oracle: ArrayOracles, -{ - let array: UnconstrainedArray = UnconstrainedArray::at(slot); - array.clear().push(value); -} - -/// Returns the value previously stored at `slot` with [`store`], or `Option::none()` if the slot holds no value. -pub(crate) unconstrained fn load(slot: Field) -> Option -where - T: Deserialize, - Oracle: ArrayOracles, -{ - let array: UnconstrainedArray = UnconstrainedArray::at(slot); - if array.len() == 0 { - Option::none() - } else { - Option::some(array.get(0)) - } -} - -/// Deletes the value stored at `slot`. Does nothing if the slot is already empty. -pub(crate) unconstrained fn delete(slot: Field) -where - Oracle: ArrayOracles, -{ - Oracle::clear_oracle(slot) -} diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr index dd87da5d2af2..680e26178fc7 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr @@ -1,15 +1,8 @@ //! Shared test suite for [`UnconstrainedArray`] backends. -//! -//! Every function in this module checks one behavior of the shared [`UnconstrainedArray`] API (or of the generic -//! `store` / `load` / `delete` kv helpers) and is generic over the [`ArrayOracles`] backend. -//! -//! Each backend module additionally keeps a hand-written smoke test routing through its own public helpers (e.g. -//! `transient::store` / `transient::load`) to cover the delegation wiring. - -use crate::protocol::traits::Serialize; + use crate::test::helpers::test_environment::TestEnvironment; use crate::test::mocks::MockStruct; -use crate::unconstrained_array::{ArrayOracles, delete, load, store, UnconstrainedArray}; +use crate::unconstrained_array::{ArrayOracles, UnconstrainedArray}; pub(crate) global SLOT: Field = 1230; pub(crate) global OTHER_SLOT: Field = 5670; @@ -641,79 +634,6 @@ where }); } -pub(crate) unconstrained fn load_empty_returns_none() -where - Oracle: ArrayOracles, -{ - let env = TestEnvironment::new(); - env.utility_context(|_| { - let value: Option = load::(SLOT); - assert_eq(value, Option::none()); - }); -} - -pub(crate) unconstrained fn store_overwrites() -where - Oracle: ArrayOracles, -{ - let env = TestEnvironment::new(); - env.utility_context(|_| { - store::(SLOT, 1); - store::(SLOT, 2); - assert_eq(load::(SLOT), Option::some(2)); - }); -} - -pub(crate) unconstrained fn delete_removes_value() -where - Oracle: ArrayOracles, -{ - let env = TestEnvironment::new(); - env.utility_context(|_| { - store::(SLOT, 42); - delete::(SLOT); - let value: Option = load::(SLOT); - assert_eq(value, Option::none()); - }); -} - -pub(crate) unconstrained fn delete_empty_is_noop() -where - Oracle: ArrayOracles, -{ - let env = TestEnvironment::new(); - env.utility_context(|_| { - delete::(SLOT); - let value: Option = load::(SLOT); - assert_eq(value, Option::none()); - }); -} - -pub(crate) unconstrained fn store_and_load_multi_field_type() -where - Oracle: ArrayOracles, -{ - let env = TestEnvironment::new(); - env.utility_context(|_| { - let value = MockStruct::new(5, 6); - store::(SLOT, value); - assert_eq(load::(SLOT), Option::some(value)); - }); -} - -pub(crate) unconstrained fn stored_value_is_visible_as_a_length_one_array() -where - Oracle: ArrayOracles, -{ - let env = TestEnvironment::new(); - env.utility_context(|_| { - store::(SLOT, 42); - let array: UnconstrainedArray = UnconstrainedArray::at(SLOT); - assert_eq(array.len(), 1); - assert_eq(array.get(0), 42); - }); -} - /// Returns the `should_fail_with` message for checks that are expected to fail, or `Option::none()` for checks that /// are expected to pass. comptime fn should_fail_message(name: Quoted) -> Option { From 1523392085e7fbe8c6b3a06e7884ea52c85eb907 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 12:53:10 +0000 Subject: [PATCH 27/31] commit missing file --- noir-projects/aztec-nr/aztec/src/lib.nr | 1 - 1 file changed, 1 deletion(-) diff --git a/noir-projects/aztec-nr/aztec/src/lib.nr b/noir-projects/aztec-nr/aztec/src/lib.nr index 1a164ce8a1e2..387f820d22da 100644 --- a/noir-projects/aztec-nr/aztec/src/lib.nr +++ b/noir-projects/aztec-nr/aztec/src/lib.nr @@ -40,7 +40,6 @@ pub mod oracle; pub mod state_vars; pub mod capsules; pub mod unconstrained_array; -pub mod unconstrained_value; pub mod ephemeral; pub mod transient; pub mod event; From 788bd2f83ed69477002ab3f34c5a835cd53f564f Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 13:12:07 +0000 Subject: [PATCH 28/31] refactor test helpers --- .../aztec-nr/aztec/src/ephemeral/mod.nr | 2 +- .../aztec-nr/aztec/src/transient/mod.nr | 2 +- .../aztec/src/unconstrained_array/mod.nr | 1 + .../src/unconstrained_array/test_helpers.nr | 53 ++++--------------- 4 files changed, 14 insertions(+), 44 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr index 87ce7cda2d39..8a666c3366da 100644 --- a/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/ephemeral/mod.nr @@ -85,5 +85,5 @@ impl Deserialize for UnconstrainedArray { } } -#[crate::unconstrained_array::test_helpers::unconstrained_array_tests(quote { crate::ephemeral::EphemeralOracles })] +#[crate::unconstrained_array::test_suite::unconstrained_array_tests(quote { crate::ephemeral::EphemeralOracles })] mod test {} diff --git a/noir-projects/aztec-nr/aztec/src/transient/mod.nr b/noir-projects/aztec-nr/aztec/src/transient/mod.nr index 57cb24091f4e..688e279e7c03 100644 --- a/noir-projects/aztec-nr/aztec/src/transient/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/transient/mod.nr @@ -110,7 +110,7 @@ impl ArrayOracles for TransientOracles { } } -#[crate::unconstrained_array::test_helpers::unconstrained_array_tests(quote { crate::transient::TransientOracles })] +#[crate::unconstrained_array::test_suite::unconstrained_array_tests(quote { crate::transient::TransientOracles })] mod test {} mod value_test { diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr index af5da1e47893..622688c76b82 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr @@ -1,4 +1,5 @@ pub(crate) mod test_helpers; +pub(crate) mod test_suite; use crate::oracle::random::random; use crate::protocol::traits::{Deserialize, Serialize}; diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr index 680e26178fc7..cc7c96e753fd 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_helpers.nr @@ -1,4 +1,12 @@ //! Shared test suite for [`UnconstrainedArray`] backends. +//! +//! Every function in this module must be an unconstrained test helper, generic over the [`ArrayOracles`] backend. +//! The [`unconstrained_array_tests`](crate::unconstrained_array::test_suite::unconstrained_array_tests) macro turns +//! each helper into a `#[test]` for every backend. +//! +//! To add a test, write a new unconstrained helper here. If it is expected to fail, mark it with +//! [`should_fail_test`](crate::unconstrained_array::test_suite::should_fail_test) and the expected message; otherwise +//! the generated test expects it to pass. use crate::test::helpers::test_environment::TestEnvironment; use crate::test::mocks::MockStruct; @@ -18,6 +26,7 @@ where }); } +#[crate::unconstrained_array::test_suite::should_fail_test(quote { "out of bounds" })] pub(crate) unconstrained fn should_fail_empty_array_read() where Oracle: ArrayOracles, @@ -29,6 +38,7 @@ where }); } +#[crate::unconstrained_array::test_suite::should_fail_test(quote { "is empty" })] pub(crate) unconstrained fn should_fail_empty_array_pop() where Oracle: ArrayOracles, @@ -54,6 +64,7 @@ where }); } +#[crate::unconstrained_array::test_suite::should_fail_test(quote { "out of bounds" })] pub(crate) unconstrained fn should_fail_read_past_len() where Oracle: ArrayOracles, @@ -633,45 +644,3 @@ where assert_eq(fresh.len(), 0); }); } - -/// Returns the `should_fail_with` message for checks that are expected to fail, or `Option::none()` for checks that -/// are expected to pass. -comptime fn should_fail_message(name: Quoted) -> Option { - if name == quote { should_fail_empty_array_read } { - Option::some(quote { "out of bounds" }) - } else if name == quote { should_fail_empty_array_pop } { - Option::some(quote { "is empty" }) - } else if name == quote { should_fail_read_past_len } { - Option::some(quote { "out of bounds" }) - } else { - Option::none() - } -} - -/// Generates the shared [`UnconstrainedArray`] test suite against the given backend. Apply to a test module, passing -/// the -/// backend's oracle as a quoted path: `#[unconstrained_array_tests(quote { crate::ephemeral::EphemeralOracles })]`. -pub(crate) comptime fn unconstrained_array_tests(_m: Module, oracle: Quoted) -> Quoted { - let helpers = quote { crate::unconstrained_array::test_helpers }.as_module().unwrap(); - let mut tests = quote {}; - for f in helpers.functions() { - if f.is_unconstrained() { - let name = f.name(); - let fail = should_fail_message(name); - let attr = if fail.is_some() { - let msg = fail.unwrap(); - quote { #[test(should_fail_with = $msg)] } - } else { - quote { #[test] } - }; - tests = quote { - $tests - $attr - unconstrained fn $name() { - crate::unconstrained_array::test_helpers::$name::<$oracle>(); - } - }; - } - } - tests -} From 51d06cb361ff1985f31e83da38674264efacb7f8 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 13:15:51 +0000 Subject: [PATCH 29/31] another one --- .../src/unconstrained_array/test_suite.nr | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 noir-projects/aztec-nr/aztec/src/unconstrained_array/test_suite.nr diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_suite.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_suite.nr new file mode 100644 index 000000000000..b4f1eea93f87 --- /dev/null +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/test_suite.nr @@ -0,0 +1,46 @@ +//! Generates a shared [`UnconstrainedArray`](crate::unconstrained_array::UnconstrainedArray) +//! test suite from the helpers in [`test_helpers`](crate::unconstrained_array::test_helpers). + +use crate::utils::cmap::CHashMap; + +/// Maps test helpers marked with [`should_fail_test`] to their expected failure message. +pub(crate) comptime mut global EXPECTED_FAILURES: CHashMap = CHashMap::new(); + +/// Marks a test helper as expected to fail with the given message, passed as a quoted string literal: +/// `#[should_fail_test(quote { "out of bounds" })]`. The tests generated from the helper get +/// `#[test(should_fail_with = ...)]` instead of plain `#[test]`. +pub(crate) comptime fn should_fail_test(f: FunctionDefinition, message: Quoted) { + EXPECTED_FAILURES.insert(f, message); +} + +/// Generates a shared [`UnconstrainedArray`](crate::unconstrained_array::UnconstrainedArray) test suite against the +/// given backend. Apply to a test module, passing the backend's oracle as a quoted path: +/// `#[unconstrained_array_tests(quote { crate::ephemeral::EphemeralOracles })]`. +pub(crate) comptime fn unconstrained_array_tests(_m: Module, oracle: Quoted) -> Quoted { + let helpers = quote { crate::unconstrained_array::test_helpers }.as_module().unwrap(); + let mut tests = quote {}; + for f in helpers.functions() { + let name = f.name(); + if f.is_unconstrained() { + let fail = EXPECTED_FAILURES.get(f); + let attr = if fail.is_some() { + let msg = fail.unwrap(); + quote { #[test(should_fail_with = $msg)] } + } else { + quote { #[test] } + }; + tests = quote { + $tests + $attr + unconstrained fn $name() { + crate::unconstrained_array::test_helpers::$name::<$oracle>(); + } + }; + } else { + panic( + f"unexpected non-unconstrained function `{name}` in test_helpers: every test helper must be unconstrained", + ) + } + } + tests +} From 261faceea0f46931bc911853ae81051152e32672 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 13:31:34 +0000 Subject: [PATCH 30/31] tighten access --- noir-projects/aztec-nr/aztec/src/oracle/mod.nr | 4 ++-- noir-projects/aztec-nr/aztec/src/transient/mod.nr | 2 +- .../aztec-nr/aztec/src/unconstrained_array/mod.nr | 8 +++----- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr index 3ff645c72d56..6d677346a72f 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr @@ -7,8 +7,8 @@ pub mod block_header; pub mod call_private_function; pub mod call_utility_function; pub mod capsules; -pub mod ephemeral_oracles; -pub mod transient_oracles; +pub(crate) mod ephemeral_oracles; +pub(crate) mod transient_oracles; pub mod contract_sync; pub mod public_call; pub mod tx_phase; diff --git a/noir-projects/aztec-nr/aztec/src/transient/mod.nr b/noir-projects/aztec-nr/aztec/src/transient/mod.nr index 688e279e7c03..569f0731e252 100644 --- a/noir-projects/aztec-nr/aztec/src/transient/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/transient/mod.nr @@ -49,7 +49,7 @@ impl TransientValue { Self { slot } } - /// Stores a value at the slot, overwriting any value previously stored there. + /// Stores a value at the given slot. pub unconstrained fn store(self, value: T) where T: Serialize, diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr index 622688c76b82..01f0d9e88014 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr @@ -9,10 +9,8 @@ use crate::protocol::traits::{Deserialize, Serialize}; /// Each implementor routes these operations to a distinct family of oracles, and the oracle family determines the /// array's lifetime and visibility (e.g. [`EphemeralArray`](crate::ephemeral::EphemeralArray) arrays live for one /// contract call frame, while [`TransientArray`](crate::transient::TransientArray) arrays are shared across all frames -/// of the same contract within one top-level PXE call). Implementations are thin wrappers around `#[oracle]` -/// declarations, so method dispatch is resolved at compile time via monomorphization and each array type emits its own -/// foreign calls. -pub trait ArrayOracles { +/// of the same contract within one top-level PXE call). +pub(crate) trait ArrayOracles { /// Returns the number of elements in the array at `slot`. unconstrained fn len_oracle(slot: Field) -> u32; @@ -47,7 +45,7 @@ pub trait ArrayOracles { /// frame) or [`TransientArray`](crate::transient::TransientArray) (shared across all frames of the same contract /// within one top-level PXE call). pub struct UnconstrainedArray { - pub slot: Field, + pub(crate) slot: Field, } impl UnconstrainedArray From 8e9e6eea29a0a68d23d8d471bda4cb76fe9cf6b1 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Thu, 11 Jun 2026 13:36:15 +0000 Subject: [PATCH 31/31] remove excess comments --- .../aztec/src/unconstrained_array/mod.nr | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr index 01f0d9e88014..139d80bb42da 100644 --- a/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/unconstrained_array/mod.nr @@ -147,10 +147,6 @@ where } /// Applies `f` to every element and collects the results into a fresh array. - /// - /// Reads each element in order, transforms it with `f`, and pushes the result onto a new array at a freshly - /// allocated slot (see [`UnconstrainedArray::empty`]). The source array is left unchanged, and the result is - /// isolated from it, so `map` can be chained. pub unconstrained fn map(self, f: unconstrained fn[Env](T) -> U) -> UnconstrainedArray where T: Deserialize, @@ -165,10 +161,6 @@ where } /// Collects every element satisfying the predicate `f` into a fresh array. - /// - /// Reads each element in order, keeps those for which `f` returns `true`, and pushes them onto a new array at a - /// freshly allocated slot (see [`UnconstrainedArray::empty`]). Relative order is preserved. The source array is - /// left unchanged, and the result is isolated from it, so `filter` can be chained. pub unconstrained fn filter(self, f: unconstrained fn[Env](T) -> bool) -> Self where T: Serialize + Deserialize, @@ -185,10 +177,6 @@ where } /// Returns `true` if at least one element satisfies the predicate `f`. - /// - /// Matches the `any` combinator on Noir's [`array`](std::array), slice, and `BoundedVec` collections. Defined in - /// terms of [`UnconstrainedArray::filter`]: it keeps the matching elements and checks whether any survived. This is - /// not short-circuiting — every element is tested even after the first match. pub unconstrained fn any(self, f: unconstrained fn[Env](T) -> bool) -> bool where T: Serialize + Deserialize, @@ -197,10 +185,6 @@ where } /// Returns `true` if every element satisfies the predicate `f` (vacuously `true` for an empty array). - /// - /// Matches the `all` combinator on Noir's [`array`](std::array) and slice collections. Defined in terms of - /// [`UnconstrainedArray::filter`]: every element matches exactly when filtering keeps all of them. This is not - /// short-circuiting — every element is tested even after the first failure. pub unconstrained fn all(self, f: unconstrained fn[Env](T) -> bool) -> bool where T: Serialize + Deserialize,