diff --git a/noir-projects/noir-contracts/contracts/test/note_getter_contract/src/main.nr b/noir-projects/noir-contracts/contracts/test/note_getter_contract/src/main.nr index d12e46e915b2..a228d67bc354 100644 --- a/noir-projects/noir-contracts/contracts/test/note_getter_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/test/note_getter_contract/src/main.nr @@ -1,4 +1,5 @@ use aztec::macros::aztec; +mod packed_note; /// Used to test note getter in e2e_note_getter.test.ts #[aztec] @@ -16,9 +17,12 @@ pub contract NoteGetter { use field_note::FieldNote; + use crate::packed_note::PackedNote; + #[storage] struct Storage { set: Owned, Context>, + packed_set: Owned, Context>, } #[external("private")] @@ -42,4 +46,42 @@ pub contract NoteGetter { )); notes.map(|note| note.value) } + + #[external("private")] + fn insert_packed_note(high: u8, low: u8) { + let owner = self.msg_sender(); + let note = PackedNote { high, low }; + + self.storage.packed_set.at(owner).insert(note).deliver(MessageDelivery.ONCHAIN_CONSTRAINED); + } + + #[external("utility")] + unconstrained fn select_packed_notes_by_high( + owner: AztecAddress, + comparator: u8, + value: Field, + ) -> BoundedVec<[u8; 2], 10> { + let selector = PackedNote::high_selector(); + let notes = self.storage.packed_set.at(owner).view_notes(NoteViewerOptions::new().select( + selector, + comparator, + value, + )); + notes.map(|note: PackedNote| [note.high, note.low]) + } + + #[external("utility")] + unconstrained fn select_packed_notes_by_low( + owner: AztecAddress, + comparator: u8, + value: Field, + ) -> BoundedVec<[u8; 2], 10> { + let selector = PackedNote::low_selector(); + let notes = self.storage.packed_set.at(owner).view_notes(NoteViewerOptions::new().select( + selector, + comparator, + value, + )); + notes.map(|note: PackedNote| [note.high, note.low]) + } } diff --git a/noir-projects/noir-contracts/contracts/test/note_getter_contract/src/packed_note.nr b/noir-projects/noir-contracts/contracts/test/note_getter_contract/src/packed_note.nr new file mode 100644 index 000000000000..635f97b50870 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/test/note_getter_contract/src/packed_note.nr @@ -0,0 +1,61 @@ +use aztec::{ + macros::notes::note, + note::note_getter_options::PropertySelector, + protocol::traits::{Deserialize, Packable, Serialize}, +}; + +/// A note that packs two u8 fields (high, low) into a single Field. +/// The layout in the packed Field is: (high << 8) + low. +/// This means in the big-endian 32-byte representation: +/// - low occupies offset=0, length=1 (the least significant byte) +/// - high occupies offset=1, length=1 (the second least significant byte) +#[derive(Deserialize, Eq, Serialize)] +#[note] +pub struct PackedNote { + pub high: u8, + pub low: u8, +} + +impl Packable for PackedNote { + let N: u32 = 1; + + fn pack(self) -> [Field; Self::N] { + [(self.high as Field) * 256 + (self.low as Field)] + } + + fn unpack(packed: [Field; Self::N]) -> Self { + let low = packed[0] as u8; + let high = ((packed[0] - low as Field) / 256) as u8; + Self { high, low } + } +} + +impl PackedNote { + pub fn high_selector() -> PropertySelector { + PropertySelector { index: 0, offset: 1, length: 1 } + } + + pub fn low_selector() -> PropertySelector { + PropertySelector { index: 0, offset: 0, length: 1 } + } +} + +mod test { + use super::{Packable, PackedNote}; + + #[test] + fn test_pack_unpack() { + let note = PackedNote { high: 42, low: 7 }; + let unpacked = PackedNote::unpack(note.pack()); + assert_eq(unpacked.high, 42); + assert_eq(unpacked.low, 7); + } + + #[test] + fn test_pack_unpack_max() { + let note = PackedNote { high: 255, low: 255 }; + let unpacked = PackedNote::unpack(note.pack()); + assert_eq(unpacked.high, 255); + assert_eq(unpacked.low, 255); + } +} diff --git a/yarn-project/end-to-end/src/e2e_note_getter.test.ts b/yarn-project/end-to-end/src/e2e_note_getter.test.ts index c48c9f291a53..a72a1fc61393 100644 --- a/yarn-project/end-to-end/src/e2e_note_getter.test.ts +++ b/yarn-project/end-to-end/src/e2e_note_getter.test.ts @@ -77,6 +77,72 @@ describe('e2e_note_getter', () => { }); }); + describe('sub-field property selector', () => { + let contract: NoteGetterContract; + + beforeAll(async () => { + ({ contract } = await NoteGetterContract.deploy(wallet).send({ from: defaultAddress })); + + // Insert packed notes with (high, low) pairs. + // PackedNote packs two u8s into one Field: (high << 8) + low. + // Sub-field selectors use Noir's LSB convention to extract individual u8 values. + await Promise.all([ + contract.methods.insert_packed_note(1, 10).send({ from: defaultAddress }), + contract.methods.insert_packed_note(2, 10).send({ from: defaultAddress }), + contract.methods.insert_packed_note(1, 20).send({ from: defaultAddress }), + contract.methods.insert_packed_note(3, 30).send({ from: defaultAddress }), + ]); + }); + + it('filters by high sub-field', async () => { + // high occupies offset=1, length=1 in the packed Field (second LSB) + const { result } = await contract.methods + .select_packed_notes_by_high(defaultAddress, Comparator.EQ, 1) + .simulate({ from: defaultAddress }); + + const notes = boundedVecToArray(result) as bigint[][]; + expect(notes).toHaveLength(2); + expect(notes.map(([h, l]) => [Number(h), Number(l)]).sort()).toEqual( + [ + [1, 10], + [1, 20], + ].sort(), + ); + }); + + it('filters by low sub-field', async () => { + // low occupies offset=0, length=1 in the packed Field (LSB) + const { result } = await contract.methods + .select_packed_notes_by_low(defaultAddress, Comparator.EQ, 10) + .simulate({ from: defaultAddress }); + + const notes = boundedVecToArray(result) as bigint[][]; + expect(notes).toHaveLength(2); + expect(notes.map(([h, l]) => [Number(h), Number(l)]).sort()).toEqual( + [ + [1, 10], + [2, 10], + ].sort(), + ); + }); + + it('filters with GT comparator on sub-field', async () => { + // low > 10 should match (1,20) and (3,30) + const { result } = await contract.methods + .select_packed_notes_by_low(defaultAddress, Comparator.GT, 10) + .simulate({ from: defaultAddress }); + + const notes = boundedVecToArray(result) as bigint[][]; + expect(notes).toHaveLength(2); + expect(notes.map(([h, l]) => [Number(h), Number(l)]).sort()).toEqual( + [ + [1, 20], + [3, 30], + ].sort(), + ); + }); + }); + describe('status filter', () => { let contract: TestContract; let owner: AztecAddress; diff --git a/yarn-project/pxe/src/contract_function_simulator/pick_notes.test.ts b/yarn-project/pxe/src/contract_function_simulator/pick_notes.test.ts index b0d608a112f4..2d75773ab5e9 100644 --- a/yarn-project/pxe/src/contract_function_simulator/pick_notes.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/pick_notes.test.ts @@ -259,6 +259,50 @@ describe('getNotes', () => { ]); }); + it('should select using sub-field byte range with LSB offset convention', () => { + // Each note has a single field. We'll use values where the low bytes differ: + // 0x0102 = 258: low byte (offset=0, len=1) = 0x02, second byte (offset=1, len=1) = 0x01 + // 0x0302 = 770: low byte = 0x02, second byte = 0x03 + // 0x0105 = 261: low byte = 0x05, second byte = 0x01 + const notes = [createNote([258n]), createNote([770n]), createNote([261n])]; + + // Select by low byte (offset=0, length=1) == 0x02 + { + const options = { + selects: [{ selector: { index: 0, offset: 0, length: 1 }, value: new Fr(0x02n), comparator: Comparator.EQ }], + }; + const result = pickNotes(notes, options); + expectNotes(result, [[258n], [770n]]); + } + + // Select by second byte (offset=1, length=1) == 0x01 + { + const options = { + selects: [{ selector: { index: 0, offset: 1, length: 1 }, value: new Fr(0x01n), comparator: Comparator.EQ }], + }; + const result = pickNotes(notes, options); + expectNotes(result, [[258n], [261n]]); + } + + // Select by two low bytes (offset=0, length=2) == 0x0102 = 258 + { + const options = { + selects: [{ selector: { index: 0, offset: 0, length: 2 }, value: new Fr(0x0102n), comparator: Comparator.EQ }], + }; + const result = pickNotes(notes, options); + expectNotes(result, [[258n]]); + } + + // GT on low byte: low byte > 0x02 matches only 261 (low byte = 0x05) + { + const options = { + selects: [{ selector: { index: 0, offset: 0, length: 1 }, value: new Fr(0x02n), comparator: Comparator.GT }], + }; + const result = pickNotes(notes, options); + expectNotes(result, [[261n]]); + } + }); + it('should get sorted matching notes with GTE and LTE', () => { const notes = [ createNote([2n, 1n, 1n]), diff --git a/yarn-project/pxe/src/contract_function_simulator/pick_notes.ts b/yarn-project/pxe/src/contract_function_simulator/pick_notes.ts index 68f11203abbe..21d181ca6fc4 100644 --- a/yarn-project/pxe/src/contract_function_simulator/pick_notes.ts +++ b/yarn-project/pxe/src/contract_function_simulator/pick_notes.ts @@ -86,8 +86,15 @@ interface ContainsNote { const selectPropertyFromPackedNoteContent = (noteData: Fr[], selector: PropertySelector): Fr => { const noteValueBuffer = noteData[selector.index].toBuffer(); - const noteValue = noteValueBuffer.subarray(selector.offset, selector.offset + selector.length); - return Fr.fromBuffer(noteValue); + // Noir's PropertySelector counts offset from the LSB (last byte of the big-endian buffer), + // so offset=0,length=Fr.SIZE_IN_BYTES reads the entire field, and offset=0,length=1 reads the last byte. + const start = Fr.SIZE_IN_BYTES - selector.offset - selector.length; + const end = Fr.SIZE_IN_BYTES - selector.offset; + const noteValue = noteValueBuffer.subarray(start, end); + // Left-pad to Fr.SIZE_IN_BYTES so Fr.fromBuffer interprets the value correctly. + const padded = Buffer.alloc(Fr.SIZE_IN_BYTES); + noteValue.copy(padded, Fr.SIZE_IN_BYTES - noteValue.length); + return Fr.fromBuffer(padded); }; const selectNotes = (noteDatas: T[], selects: Select[]): T[] =>