From 911a9f3fa224a8ac28d4e412ed74391533855bc9 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Fri, 15 May 2026 18:00:37 -0300 Subject: [PATCH 1/3] feat(pxe): add source and block-range filtering to get_logs_by_tag --- .../src/messages/discovery/partial_notes.nr | 23 +- .../processing/log_retrieval_request.nr | 68 +++++- .../aztec/src/messages/processing/mod.nr | 32 +-- .../aztec/src/oracle/message_processing.nr | 7 +- .../log_retrieval_request.test.ts | 73 ++++++- .../noir-structs/log_retrieval_request.ts | 37 +++- .../oracle/utility_execution_oracle.ts | 14 +- yarn-project/pxe/src/logs/log_service.test.ts | 198 ++++++++++++++---- yarn-project/pxe/src/logs/log_service.ts | 106 ++++++---- 9 files changed, 435 insertions(+), 123 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/messages/discovery/partial_notes.nr b/noir-projects/aztec-nr/aztec/src/messages/discovery/partial_notes.nr index ff61bfc6b69b..be2d4983a83c 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/discovery/partial_notes.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/discovery/partial_notes.nr @@ -1,5 +1,6 @@ use crate::{ capsules::CapsuleArray, + ephemeral::EphemeralArray, messages::{ discovery::{ComputeNoteHash, ComputeNoteNullifier, nonce_discovery::attempt_note_nonce_discovery}, encoding::MAX_MESSAGE_CONTENT_LEN, @@ -88,17 +89,18 @@ pub(crate) unconstrained fn fetch_and_process_partial_note_completion_logs( // Each of the pending partial notes might get completed by a log containing its public values. For performance // reasons, we fetch all of these logs concurrently and then process them one by one, minimizing the amount of time // waiting for the node roundtrip. - let maybe_completion_logs = get_pending_partial_notes_completion_logs(contract_address, pending_partial_notes); + let completion_log_slots = get_pending_partial_notes_completion_logs(contract_address, pending_partial_notes); - // Each entry in the maybe completion logs array corresponds to the entry in the pending partial notes array at the - // same index. This means we can use the same index as we iterate through the responses to get both the partial - // note and the log that might complete it. - assert_eq(maybe_completion_logs.len(), pending_partial_notes.len()); + // Each entry in the completion log slots array corresponds to the entry in the pending partial notes array at + // the same index. Each slot points to an inner EphemeralArray with all matching logs. + assert_eq(completion_log_slots.len(), pending_partial_notes.len()); - maybe_completion_logs.for_each(|i, maybe_log: Option| { + completion_log_slots.for_each(|i, inner_slot: Field| { + let logs_for_tag: EphemeralArray = EphemeralArray::at(inner_slot); let pending_partial_note = pending_partial_notes.get(i); + let num_logs = logs_for_tag.len(); - if maybe_log.is_none() { + if num_logs == 0 { aztecnr_debug_log_format!("Found no completion logs for partial note with tag {}")( [pending_partial_note.note_completion_log_tag], ); @@ -107,10 +109,15 @@ pub(crate) unconstrained fn fetch_and_process_partial_note_completion_logs( // searching for this tagged log when performing message discovery in the future until we either find it or // the entry is somehow removed from the array. } else { + assert( + num_logs == 1, + f"Expected at most 1 completion log per partial note, got {num_logs}", + ); + aztecnr_debug_log_format!("Completion log found for partial note with tag {}")([ pending_partial_note.note_completion_log_tag, ]); - let log = maybe_log.unwrap(); + let log = logs_for_tag.get(0); // The first field in the completion log payload is the storage slot, followed by the public note // content fields. diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/log_retrieval_request.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/log_retrieval_request.nr index c303df34e850..38d93b11acd3 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/log_retrieval_request.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/log_retrieval_request.nr @@ -1,30 +1,76 @@ use crate::protocol::{address::AztecAddress, traits::Serialize}; -/// A request for the `bulk_retrieve_logs` oracle to fetch either: -/// - a public log emitted by `contract_address` with `unsiloed_tag` -/// - a private log with tag equal to `compute_siloed_private_log_first_field(contract_address, unsiloed_tag)`. +pub(crate) struct LogSourceEnum { + pub PRIVATE: Field, + pub PUBLIC: Field, + pub PUBLIC_AND_PRIVATE: Field, +} + +pub(crate) global LogSource: LogSourceEnum = + LogSourceEnum { PRIVATE: 0, PUBLIC: 1, PUBLIC_AND_PRIVATE: 2 }; + +/// A request for the `bulk_retrieve_logs` oracle to fetch all logs matching a tag. #[derive(Serialize)] pub(crate) struct LogRetrievalRequest { pub contract_address: AztecAddress, pub unsiloed_tag: Field, - // TODO(#15052): choose source: public, private or either (current behavior) + /// Which log source to query: public, private, or both (the default). See [`LogSource`]. + pub source: Field, + /// Inclusive lower bound on block number. When unset, logs from the first block are included. + pub from_block: Option, + /// Exclusive upper bound on block number. When unset, logs up to the anchor block are included. + pub to_block: Option, } mod test { use crate::protocol::{address::AztecAddress, traits::{FromField, Serialize}}; - use super::LogRetrievalRequest; + use super::{LogRetrievalRequest, LogSource}; #[test] - fn serialization_matches_typescript() { - let request = LogRetrievalRequest { contract_address: AztecAddress::from_field(1), unsiloed_tag: 2 }; + fn serialization_of_defaults_matches_typescript() { + let request = LogRetrievalRequest { + contract_address: AztecAddress::from_field(1), + unsiloed_tag: 2, + source: LogSource.PUBLIC_AND_PRIVATE, + from_block: Option::none(), + to_block: Option::none(), + }; - // We define the serialization in Noir and the deserialization in TS. If the deserialization changes from the - // snapshot value below, then log_retrieval_request.test.ts must be updated with the same value. Ideally we'd - // autogenerate this, but for now we only have single-sided snapshot generation, from TS to Noir, which is not - // what we need here. + // We define the serialization in Noir and the deserialization in TS. If the deserialization changes from + // the snapshot value below, then log_retrieval_request.test.ts must be updated with the same value. + // Ideally we'd autogenerate this, but for now we only have single-sided snapshot generation, from TS to + // Noir, which is not what we need here. let expected_serialization = [ 0x0000000000000000000000000000000000000000000000000000000000000001, 0x0000000000000000000000000000000000000000000000000000000000000002, + 0x0000000000000000000000000000000000000000000000000000000000000002, + 0x0000000000000000000000000000000000000000000000000000000000000000, + 0x0000000000000000000000000000000000000000000000000000000000000000, + 0x0000000000000000000000000000000000000000000000000000000000000000, + 0x0000000000000000000000000000000000000000000000000000000000000000, + ]; + + assert_eq(request.serialize(), expected_serialization); + } + + #[test] + fn serialization_with_values_matches_typescript() { + let request = LogRetrievalRequest { + contract_address: AztecAddress::from_field(1), + unsiloed_tag: 2, + source: LogSource.PUBLIC, + from_block: Option::some(10), + to_block: Option::some(20), + }; + + let expected_serialization = [ + 0x0000000000000000000000000000000000000000000000000000000000000001, + 0x0000000000000000000000000000000000000000000000000000000000000002, + 0x0000000000000000000000000000000000000000000000000000000000000001, + 0x0000000000000000000000000000000000000000000000000000000000000001, + 0x000000000000000000000000000000000000000000000000000000000000000a, + 0x0000000000000000000000000000000000000000000000000000000000000001, + 0x0000000000000000000000000000000000000000000000000000000000000014, ]; assert_eq(request.serialize(), expected_serialization); diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr index a793fe5df64f..70a168900cdc 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr @@ -19,7 +19,10 @@ use crate::{ discovery::partial_notes::DeliveredPendingPartialNote, encoding::MESSAGE_CIPHERTEXT_LEN, logs::{event::MAX_EVENT_SERIALIZED_LEN, note::MAX_NOTE_PACKED_LEN}, - processing::{log_retrieval_request::LogRetrievalRequest, log_retrieval_response::LogRetrievalResponse}, + processing::{ + log_retrieval_request::{LogRetrievalRequest, LogSource}, + log_retrieval_response::LogRetrievalResponse, + }, }, oracle::message_processing, }; @@ -151,22 +154,19 @@ pub unconstrained fn validate_and_store_enqueued_notes_and_events(scope: AztecAd } /// Efficiently queries the node for logs that result in the completion of all `DeliveredPendingPartialNote`s stored in -/// a `CapsuleArray` by performing all node communication concurrently. Returns an `EphemeralArray` with Options -/// for the responses that correspond to the pending partial notes at the same index. -/// -/// For example, given an array with pending partial notes `[ p1, p2, p3 ]`, where `p1` and `p3` have corresponding -/// completion logs but `p2` does not, the returned `EphemeralArray` will have contents `[some(p1_log), none(), -/// some(p3_log)]`. +/// a `CapsuleArray` by performing all node communication concurrently. Returns an `EphemeralArray` of slots, one per +/// pending partial note. Each slot identifies an inner `EphemeralArray` with all matching logs +/// for that note (which may be empty if no logs were found). pub(crate) unconstrained fn get_pending_partial_notes_completion_logs( contract_address: AztecAddress, pending_partial_notes: CapsuleArray, -) -> EphemeralArray> { +) -> EphemeralArray { let log_retrieval_requests = EphemeralArray::at(LOG_RETRIEVAL_REQUESTS_ARRAY_BASE_SLOT); - // We create a LogRetrievalRequest for each PendingPartialNote in the EphemeralArray. Because we need the indices in - // the request array to match the indices in the partial note array, we can't use EphemeralArray::for_each, as that - // function has arbitrary iteration order. Instead, we manually iterate the array from the beginning and push into - // the requests array, which we expect to be empty. + // We create a LogRetrievalRequest for each PendingPartialNote in the EphemeralArray. Because we need the indices + // in the request array to match the indices in the partial note array, we can't use EphemeralArray::for_each, as + // that function has arbitrary iteration order. Instead, we manually iterate the array from the beginning and push + // into the requests array, which we expect to be empty. let mut i = 0; let pending_partial_notes_count = pending_partial_notes.len(); while i < pending_partial_notes_count { @@ -177,7 +177,13 @@ pub(crate) unconstrained fn get_pending_partial_notes_completion_logs( pending_partial_note.note_completion_log_tag, DOM_SEP__NOTE_COMPLETION_LOG_TAG, ); - log_retrieval_requests.push(LogRetrievalRequest { contract_address, unsiloed_tag: log_tag }); + log_retrieval_requests.push(LogRetrievalRequest { + contract_address, + unsiloed_tag: log_tag, + source: LogSource.PUBLIC_AND_PRIVATE, + from_block: Option::none(), + to_block: Option::none(), + }); i += 1; } diff --git a/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr b/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr index 49f7de27589e..f3ba76a72153 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr @@ -42,10 +42,13 @@ unconstrained fn validate_and_store_enqueued_notes_and_events_oracle( scope: AztecAddress, ) {} -/// Fetches logs by tag from an ephemeral request array and returns a response ephemeral array. +/// Fetches all logs matching each request's tag and returns an ephemeral array of slots. +/// +/// Each element in the returned array is a `Field` slot that identifies an inner `EphemeralArray` +/// containing all matching logs for the request at the same index (which may be empty if no logs were found). pub(crate) unconstrained fn get_logs_by_tag( requests: EphemeralArray, -) -> EphemeralArray> { +) -> EphemeralArray { let response_slot = get_logs_by_tag_v2_oracle(requests.slot); EphemeralArray::at(response_slot) } diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/log_retrieval_request.test.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/log_retrieval_request.test.ts index d33ba2470a10..175f7f906bcb 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/log_retrieval_request.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/log_retrieval_request.test.ts @@ -1,19 +1,88 @@ +import { BlockNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { Tag } from '@aztec/stdlib/logs'; -import { LogRetrievalRequest } from './log_retrieval_request.js'; +import { LogRetrievalRequest, LogSource } from './log_retrieval_request.js'; describe('LogRetrievalRequest', () => { - it('output of Noir serialization deserializes as expected', () => { + it('output of Noir serialization with defaults deserializes as expected', () => { const serialized = [ '0x0000000000000000000000000000000000000000000000000000000000000001', '0x0000000000000000000000000000000000000000000000000000000000000002', + '0x0000000000000000000000000000000000000000000000000000000000000002', + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000000000000000000000000000', + ].map(Fr.fromHexString); + + const request = LogRetrievalRequest.fromFields(serialized); + + expect(request.contractAddress).toEqual(AztecAddress.fromBigInt(1n)); + expect(request.tag).toEqual(new Tag(new Fr(2))); + expect(request.source).toEqual(LogSource.PUBLIC_AND_PRIVATE); + expect(request.fromBlock).toBeUndefined(); + expect(request.toBlock).toBeUndefined(); + }); + + it('output of Noir serialization with values deserializes as expected', () => { + const serialized = [ + '0x0000000000000000000000000000000000000000000000000000000000000001', + '0x0000000000000000000000000000000000000000000000000000000000000002', + '0x0000000000000000000000000000000000000000000000000000000000000001', + '0x0000000000000000000000000000000000000000000000000000000000000001', + '0x000000000000000000000000000000000000000000000000000000000000000a', + '0x0000000000000000000000000000000000000000000000000000000000000001', + '0x0000000000000000000000000000000000000000000000000000000000000014', ].map(Fr.fromHexString); const request = LogRetrievalRequest.fromFields(serialized); expect(request.contractAddress).toEqual(AztecAddress.fromBigInt(1n)); expect(request.tag).toEqual(new Tag(new Fr(2))); + expect(request.source).toEqual(LogSource.PUBLIC); + expect(request.fromBlock).toEqual(BlockNumber(10)); + expect(request.toBlock).toEqual(BlockNumber(20)); + }); + + it('rejects an invalid LogSource value', () => { + const serialized = [ + '0x0000000000000000000000000000000000000000000000000000000000000001', + '0x0000000000000000000000000000000000000000000000000000000000000002', + '0x000000000000000000000000000000000000000000000000000000000000002a', // 42 — invalid + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000000000000000000000000000', + ].map(Fr.fromHexString); + + expect(() => LogRetrievalRequest.fromFields(serialized)).toThrow(/Invalid LogSource value 42/); + }); + + it('accepts all valid LogSource values', () => { + for (const source of [LogSource.PRIVATE, LogSource.PUBLIC, LogSource.PUBLIC_AND_PRIVATE]) { + const fields = new LogRetrievalRequest(AztecAddress.fromBigInt(1n), new Tag(new Fr(2)), source).toFields(); + const restored = LogRetrievalRequest.fromFields(fields); + expect(restored.source).toEqual(source); + } + }); + + it('round-trips through toFields and fromFields', () => { + const original = new LogRetrievalRequest( + AztecAddress.fromBigInt(42n), + new Tag(new Fr(99)), + LogSource.PRIVATE, + BlockNumber(5), + BlockNumber(100), + ); + + const restored = LogRetrievalRequest.fromFields(original.toFields()); + + expect(restored.contractAddress).toEqual(original.contractAddress); + expect(restored.tag).toEqual(original.tag); + expect(restored.source).toEqual(original.source); + expect(restored.fromBlock).toEqual(original.fromBlock); + expect(restored.toBlock).toEqual(original.toBlock); }); }); diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/log_retrieval_request.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/log_retrieval_request.ts index 377213a23106..549b452d2d39 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/log_retrieval_request.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/log_retrieval_request.ts @@ -1,8 +1,16 @@ +import { BlockNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { FieldReader } from '@aztec/foundation/serialize'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { Tag } from '@aztec/stdlib/logs'; +/** Discriminant for which log source to query. */ +export enum LogSource { + PRIVATE = 0, + PUBLIC = 1, + PUBLIC_AND_PRIVATE = 2, +} + /** * Intermediate struct used to perform batch log retrieval by PXE. The `utilityBulkRetrieveLogs` oracle expects values of this * type to be stored in a `EphemeralArray`. @@ -11,10 +19,21 @@ export class LogRetrievalRequest { constructor( public contractAddress: AztecAddress, public tag: Tag, + public source: LogSource = LogSource.PUBLIC_AND_PRIVATE, + public fromBlock?: BlockNumber, + public toBlock?: BlockNumber, ) {} toFields(): Fr[] { - return [this.contractAddress.toField(), this.tag.value]; + return [ + this.contractAddress.toField(), + this.tag.value, + new Fr(this.source), + new Fr(this.fromBlock !== undefined ? 1 : 0), + new Fr(this.fromBlock ?? 0), + new Fr(this.toBlock !== undefined ? 1 : 0), + new Fr(this.toBlock ?? 0), + ]; } static fromFields(fields: Fr[] | FieldReader): LogRetrievalRequest { @@ -22,7 +41,21 @@ export class LogRetrievalRequest { const contractAddress = AztecAddress.fromField(reader.readField()); const tag = new Tag(reader.readField()); + const sourceNum = reader.readField().toNumber(); + if (!(sourceNum in LogSource)) { + const validNames = Object.keys(LogSource).filter(k => isNaN(Number(k))); + throw new Error(`Invalid LogSource value ${sourceNum}, expected one of ${validNames.join(', ')}`); + } + const source = sourceNum as LogSource; + + const fromBlockIsSome = reader.readBoolean(); + const fromBlockValue = reader.readField(); + const fromBlock = fromBlockIsSome ? BlockNumber(fromBlockValue.toNumber()) : undefined; + + const toBlockIsSome = reader.readBoolean(); + const toBlockValue = reader.readField(); + const toBlock = toBlockIsSome ? BlockNumber(toBlockValue.toNumber()) : undefined; - return new LogRetrievalRequest(contractAddress, tag); + return new LogRetrievalRequest(contractAddress, tag, source, fromBlock, toBlock); } } diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts index b49d74b9a8b9..0a878273f5a3 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts @@ -685,7 +685,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra ).map(LogRetrievalRequest.fromFields); const logService = this.#createLogService(); - const maybeLogRetrievalResponses = await logService.fetchLogsByTag(contractAddress, logRetrievalRequests); + const logRetrievalResponses = await logService.fetchLogsByTag(contractAddress, logRetrievalRequests); // Requests are cleared once we're done. await this.capsuleService.setCapsuleArray( @@ -696,6 +696,9 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra scope, ); + // The deprecated v1 oracle expects Option (at most one per tag). + const maybeLogRetrievalResponses = logRetrievalResponses.map(logs => logs[0] ?? null); + // The responses are stored as Option in a second CapsuleArray. await this.capsuleService.setCapsuleArray( contractAddress, @@ -712,9 +715,14 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra .map(LogRetrievalRequest.fromFields); const logService = this.#createLogService(); - const maybeLogRetrievalResponses = await logService.fetchLogsByTag(this.contractAddress, logRetrievalRequests); + const logRetrievalResponses = await logService.fetchLogsByTag(this.contractAddress, logRetrievalRequests); + + // Create an inner ephemeral array for each request's matching logs, then wrap all slots in an outer array. + const innerSlots = logRetrievalResponses.map(responses => + this.ephemeralArrayService.newArray(responses.map(r => r.toFields())), + ); - return this.ephemeralArrayService.newArray(maybeLogRetrievalResponses.map(LogRetrievalResponse.toSerializedOption)); + return this.ephemeralArrayService.newArray(innerSlots.map(slot => [slot])); } // Deprecated, only kept for backwards compatibility until Alpha v5 rolls out. diff --git a/yarn-project/pxe/src/logs/log_service.test.ts b/yarn-project/pxe/src/logs/log_service.test.ts index fbaef16f94d9..7eb33274d934 100644 --- a/yarn-project/pxe/src/logs/log_service.test.ts +++ b/yarn-project/pxe/src/logs/log_service.test.ts @@ -6,12 +6,12 @@ import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { L2TipsProvider } from '@aztec/stdlib/block'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; -import { Tag } from '@aztec/stdlib/logs'; +import { SiloedTag, Tag } from '@aztec/stdlib/logs'; import { makeBlockHeader, randomTxScopedPrivateL2Log } from '@aztec/stdlib/testing'; import { type MockProxy, mock } from 'jest-mock-extended'; -import { LogRetrievalRequest } from '../contract_function_simulator/noir-structs/log_retrieval_request.js'; +import { LogRetrievalRequest, LogSource } from '../contract_function_simulator/noir-structs/log_retrieval_request.js'; import { AddressStore } from '../storage/address_store/address_store.js'; import { RecipientTaggingStore } from '../storage/tagging_store/recipient_tagging_store.js'; import { SenderAddressBookStore } from '../storage/tagging_store/sender_address_book_store.js'; @@ -26,7 +26,7 @@ describe('LogService', () => { let senderAddressBookStore: SenderAddressBookStore; let logService: LogService; - describe('bulkRetrieveLogs', () => { + describe('fetchLogsByTag', () => { const tag = Tag.random(); beforeEach(async () => { @@ -58,30 +58,15 @@ describe('LogService', () => { ); }); - it('returns no logs if none are found', async () => { + it('returns empty arrays if no logs are found', async () => { aztecNode.getPrivateLogsByTags.mockResolvedValue([[]]); aztecNode.getPublicLogsByTagsFromContract.mockResolvedValue([[]]); const request = new LogRetrievalRequest(contractAddress, tag); const responses = await logService.fetchLogsByTag(contractAddress, [request]); - expect(responses.length).toEqual(1); - expect(responses[0]).toBeNull(); + expect(responses).toEqual([[]]); }); - it('returns a public log if one is found', async () => { - const scopedLog = randomTxScopedPrivateL2Log(); - - aztecNode.getPublicLogsByTagsFromContract.mockResolvedValue([[scopedLog]]); - aztecNode.getPrivateLogsByTags.mockResolvedValue([[]]); - - const request = new LogRetrievalRequest(contractAddress, new Tag(scopedLog.logData[0])); - - const responses = await logService.fetchLogsByTag(contractAddress, [request]); - - expect(responses.length).toEqual(1); - expect(responses[0]).not.toBeNull(); - }); - - it('returns first log when multiple public logs are found for a single tag', async () => { + it('returns all logs when multiple public logs exist for a single tag', async () => { const scopedLog1 = randomTxScopedPrivateL2Log(); const scopedLog2 = randomTxScopedPrivateL2Log(); @@ -91,11 +76,12 @@ describe('LogService', () => { const request = new LogRetrievalRequest(contractAddress, tag); const responses = await logService.fetchLogsByTag(contractAddress, [request]); - expect(responses.length).toEqual(1); - expect(responses[0]).not.toBeNull(); + expect(responses[0]).toHaveLength(2); + expect(responses[0][0].txHash).toEqual(scopedLog1.txHash); + expect(responses[0][1].txHash).toEqual(scopedLog2.txHash); }); - it('returns first log when multiple private logs are found for a single tag', async () => { + it('returns all logs when multiple private logs exist for a single tag', async () => { const scopedLog1 = randomTxScopedPrivateL2Log(); const scopedLog2 = randomTxScopedPrivateL2Log(); @@ -105,11 +91,12 @@ describe('LogService', () => { const request = new LogRetrievalRequest(contractAddress, tag); const responses = await logService.fetchLogsByTag(contractAddress, [request]); - expect(responses.length).toEqual(1); - expect(responses[0]).not.toBeNull(); + expect(responses[0]).toHaveLength(2); + expect(responses[0][0].txHash).toEqual(scopedLog1.txHash); + expect(responses[0][1].txHash).toEqual(scopedLog2.txHash); }); - it('returns first log when both a public and private log are found for a single tag', async () => { + it('returns combined public and private logs for a single tag', async () => { const publicLog = randomTxScopedPrivateL2Log(); const privateLog = randomTxScopedPrivateL2Log(); @@ -119,22 +106,9 @@ describe('LogService', () => { const request = new LogRetrievalRequest(contractAddress, tag); const responses = await logService.fetchLogsByTag(contractAddress, [request]); - expect(responses.length).toEqual(1); - expect(responses[0]).not.toBeNull(); - }); - - it('returns a private log if one is found', async () => { - const scopedLog = randomTxScopedPrivateL2Log(); - - aztecNode.getPrivateLogsByTags.mockResolvedValue([[scopedLog]]); - aztecNode.getPublicLogsByTagsFromContract.mockResolvedValue([[]]); - - const request = new LogRetrievalRequest(contractAddress, new Tag(scopedLog.logData[0])); - - const responses = await logService.fetchLogsByTag(contractAddress, [request]); - - expect(responses.length).toEqual(1); - expect(responses[0]).not.toBeNull(); + expect(responses[0]).toHaveLength(2); + expect(responses[0][0].txHash).toEqual(publicLog.txHash); + expect(responses[0][1].txHash).toEqual(privateLog.txHash); }); it('rejects a batch where at least one request targets a different contract', async () => { @@ -167,9 +141,11 @@ describe('LogService', () => { const responses = await logService.fetchLogsByTag(contractAddress, requests); expect(responses).toHaveLength(3); - expect(responses[0]).toEqual(expect.objectContaining({ txHash: publicLog1.txHash })); - expect(responses[1]).toEqual(expect.objectContaining({ txHash: privateLog2.txHash })); - expect(responses[2]).toBeNull(); + expect(responses[0]).toHaveLength(1); + expect(responses[0][0].txHash).toEqual(publicLog1.txHash); + expect(responses[1]).toHaveLength(1); + expect(responses[1][0].txHash).toEqual(privateLog2.txHash); + expect(responses[2]).toEqual([]); expect(aztecNode.getPublicLogsByTagsFromContract).toHaveBeenCalledTimes(1); expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledTimes(1); @@ -181,5 +157,135 @@ describe('LogService', () => { expect(aztecNode.getPublicLogsByTagsFromContract).not.toHaveBeenCalled(); expect(aztecNode.getPrivateLogsByTags).not.toHaveBeenCalled(); }); + + describe('block range filtering', () => { + it('fromBlock is inclusive', async () => { + const logBefore = randomTxScopedPrivateL2Log({ blockNumber: 9 }); + const logAtBoundary = randomTxScopedPrivateL2Log({ blockNumber: 10 }); + + aztecNode.getPublicLogsByTagsFromContract.mockResolvedValue([[logBefore, logAtBoundary]]); + aztecNode.getPrivateLogsByTags.mockResolvedValue([[]]); + + const request = new LogRetrievalRequest(contractAddress, tag, LogSource.PUBLIC_AND_PRIVATE, BlockNumber(10)); + const responses = await logService.fetchLogsByTag(contractAddress, [request]); + + expect(responses[0]).toHaveLength(1); + expect(responses[0][0].txHash).toEqual(logAtBoundary.txHash); + }); + + it('toBlock is exclusive', async () => { + const logBeforeBoundary = randomTxScopedPrivateL2Log({ blockNumber: 9 }); + const logAtBoundary = randomTxScopedPrivateL2Log({ blockNumber: 10 }); + + aztecNode.getPublicLogsByTagsFromContract.mockResolvedValue([[logBeforeBoundary, logAtBoundary]]); + aztecNode.getPrivateLogsByTags.mockResolvedValue([[]]); + + const request = new LogRetrievalRequest( + contractAddress, + tag, + LogSource.PUBLIC_AND_PRIVATE, + undefined, + BlockNumber(10), + ); + const responses = await logService.fetchLogsByTag(contractAddress, [request]); + + expect(responses[0]).toHaveLength(1); + expect(responses[0][0].txHash).toEqual(logBeforeBoundary.txHash); + }); + + it('filters with both fromBlock and toBlock', async () => { + const logBefore = randomTxScopedPrivateL2Log({ blockNumber: 3 }); + const logInRange = randomTxScopedPrivateL2Log({ blockNumber: 15 }); + const logAfter = randomTxScopedPrivateL2Log({ blockNumber: 25 }); + + aztecNode.getPublicLogsByTagsFromContract.mockResolvedValue([[logBefore, logInRange, logAfter]]); + aztecNode.getPrivateLogsByTags.mockResolvedValue([[]]); + + const request = new LogRetrievalRequest( + contractAddress, + tag, + LogSource.PUBLIC_AND_PRIVATE, + BlockNumber(10), + BlockNumber(20), + ); + const responses = await logService.fetchLogsByTag(contractAddress, [request]); + + expect(responses[0]).toHaveLength(1); + expect(responses[0][0].txHash).toEqual(logInRange.txHash); + }); + }); + + describe('source filtering', () => { + it('returns only public logs and skips private RPC when source is PUBLIC', async () => { + const publicLog = randomTxScopedPrivateL2Log(); + + aztecNode.getPublicLogsByTagsFromContract.mockResolvedValue([[publicLog]]); + + const request = new LogRetrievalRequest(contractAddress, tag, LogSource.PUBLIC); + const responses = await logService.fetchLogsByTag(contractAddress, [request]); + + expect(responses[0]).toHaveLength(1); + expect(responses[0][0].txHash).toEqual(publicLog.txHash); + expect(aztecNode.getPrivateLogsByTags).not.toHaveBeenCalled(); + }); + + it('returns only private logs and skips public RPC when source is PRIVATE', async () => { + const privateLog = randomTxScopedPrivateL2Log(); + + aztecNode.getPrivateLogsByTags.mockResolvedValue([[privateLog]]); + + const request = new LogRetrievalRequest(contractAddress, tag, LogSource.PRIVATE); + const responses = await logService.fetchLogsByTag(contractAddress, [request]); + + expect(responses[0]).toHaveLength(1); + expect(responses[0][0].txHash).toEqual(privateLog.txHash); + expect(aztecNode.getPublicLogsByTagsFromContract).not.toHaveBeenCalled(); + }); + + it('only sends relevant tags per source in a mixed batch', async () => { + const tag1 = Tag.random(); + const tag2 = Tag.random(); + const tag3 = Tag.random(); + + const publicLog1 = randomTxScopedPrivateL2Log(); + const privateLog2 = randomTxScopedPrivateL2Log(); + const publicLog3 = randomTxScopedPrivateL2Log(); + const privateLog3 = randomTxScopedPrivateL2Log(); + + aztecNode.getPublicLogsByTagsFromContract.mockResolvedValue([[publicLog1], [publicLog3]]); + aztecNode.getPrivateLogsByTags.mockResolvedValue([[privateLog2], [privateLog3]]); + + const requests = [ + new LogRetrievalRequest(contractAddress, tag1, LogSource.PUBLIC), + new LogRetrievalRequest(contractAddress, tag2, LogSource.PRIVATE), + new LogRetrievalRequest(contractAddress, tag3, LogSource.PUBLIC_AND_PRIVATE), + ]; + + const responses = await logService.fetchLogsByTag(contractAddress, requests); + + // Public RPC receives tag1 and tag3, private RPC receives tag2 and tag3 + expect(aztecNode.getPublicLogsByTagsFromContract).toHaveBeenCalledTimes(1); + const publicCallTags = aztecNode.getPublicLogsByTagsFromContract.mock.calls[0][1]; + expect(publicCallTags).toHaveLength(2); + expect(publicCallTags[0]).toEqual(tag1); + expect(publicCallTags[1]).toEqual(tag3); + + expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledTimes(1); + const privateCallTags = aztecNode.getPrivateLogsByTags.mock.calls[0][0]; + expect(privateCallTags).toHaveLength(2); + const expectedSiloedTag2 = await SiloedTag.computeFromTagAndApp(tag2, contractAddress); + const expectedSiloedTag3 = await SiloedTag.computeFromTagAndApp(tag3, contractAddress); + expect(privateCallTags[0]).toEqual(expectedSiloedTag2); + expect(privateCallTags[1]).toEqual(expectedSiloedTag3); + + expect(responses[0]).toHaveLength(1); + expect(responses[0][0].txHash).toEqual(publicLog1.txHash); + expect(responses[1]).toHaveLength(1); + expect(responses[1][0].txHash).toEqual(privateLog2.txHash); + expect(responses[2]).toHaveLength(2); + expect(responses[2][0].txHash).toEqual(publicLog3.txHash); + expect(responses[2][1].txHash).toEqual(privateLog3.txHash); + }); + }); }); }); diff --git a/yarn-project/pxe/src/logs/log_service.ts b/yarn-project/pxe/src/logs/log_service.ts index 6edd28fae40f..27bb8093deba 100644 --- a/yarn-project/pxe/src/logs/log_service.ts +++ b/yarn-project/pxe/src/logs/log_service.ts @@ -1,7 +1,8 @@ +import type { BlockNumber } from '@aztec/foundation/branded-types'; import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; import type { KeyStore } from '@aztec/key-store'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import type { L2TipsProvider } from '@aztec/stdlib/block'; +import type { BlockHash, L2TipsProvider } from '@aztec/stdlib/block'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { ExtendedDirectionalAppTaggingSecret, @@ -11,7 +12,10 @@ import { } from '@aztec/stdlib/logs'; import type { BlockHeader } from '@aztec/stdlib/tx'; -import type { LogRetrievalRequest } from '../contract_function_simulator/noir-structs/log_retrieval_request.js'; +import { + type LogRetrievalRequest, + LogSource, +} from '../contract_function_simulator/noir-structs/log_retrieval_request.js'; import { LogRetrievalResponse } from '../contract_function_simulator/noir-structs/log_retrieval_response.js'; import { AddressStore } from '../storage/address_store/address_store.js'; import type { RecipientTaggingStore } from '../storage/tagging_store/recipient_tagging_store.js'; @@ -39,10 +43,11 @@ export class LogService { this.log = createLogger('pxe:log_service', bindings); } + /** Fetches all logs matching each request's tag, returning an array of log arrays (one per request). */ public async fetchLogsByTag( contractAddress: AztecAddress, logRetrievalRequests: LogRetrievalRequest[], - ): Promise<(LogRetrievalResponse | null)[]> { + ): Promise { for (const request of logRetrievalRequests) { if (!contractAddress.equals(request.contractAddress)) { throw new Error(`Got a log retrieval request from ${request.contractAddress}, expected ${contractAddress}`); @@ -54,51 +59,80 @@ export class LogService { } const anchorBlockHash = await this.anchorBlockHeader.hash(); - const tags = logRetrievalRequests.map(r => r.tag); - const siloedTags = await Promise.all( - logRetrievalRequests.map(r => SiloedTag.computeFromTagAndApp(r.tag, r.contractAddress)), - ); - const [allPublicLogsPerTag, allPrivateLogsPerTag] = await Promise.all([ - getAllPublicLogsByTagsFromContract(this.aztecNode, contractAddress, tags, anchorBlockHash), - getAllPrivateLogsByTags(this.aztecNode, siloedTags, anchorBlockHash), + const [publicLogsPerTag, privateLogsPerTag] = await Promise.all([ + this.#fetchPublicLogs(contractAddress, logRetrievalRequests, anchorBlockHash), + this.#fetchPrivateLogs(logRetrievalRequests, anchorBlockHash), ]); - return logRetrievalRequests.map((request, i) => { - const publicLog = this.#extractSingleLog( - allPublicLogsPerTag[i], - `public log for tag ${request.tag} and contract ${request.contractAddress.toString()}`, - ); - const privateLog = this.#extractSingleLog(allPrivateLogsPerTag[i], `private log for tag ${siloedTags[i]}`); + return logRetrievalRequests.map((request, i) => [ + ...this.#extractLogs(publicLogsPerTag[i], request.fromBlock, request.toBlock), + ...this.#extractLogs(privateLogsPerTag[i], request.fromBlock, request.toBlock), + ]); + } - if (publicLog !== null && privateLog !== null) { - this.log.warn( - `Found both a public and private log for tag ${request.tag} from contract ${request.contractAddress}. This may indicate a contract bug. Returning the public log.`, - ); - } + async #fetchPublicLogs( + contractAddress: AztecAddress, + requests: LogRetrievalRequest[], + anchorBlockHash: BlockHash, + ): Promise { + const indices = requests.flatMap((r, i) => (r.source !== LogSource.PRIVATE ? [i] : [])); + if (indices.length === 0) { + return requests.map(() => []); + } - return publicLog ?? privateLog; + const results = await getAllPublicLogsByTagsFromContract( + this.aztecNode, + contractAddress, + indices.map(i => requests[i].tag), + anchorBlockHash, + ); + + const logsPerTag: TxScopedL2Log[][] = requests.map(() => []); + indices.forEach((originalIdx, resultIdx) => { + logsPerTag[originalIdx] = results[resultIdx]; }); + return logsPerTag; } - #extractSingleLog(logsForTag: TxScopedL2Log[], description: string): LogRetrievalResponse | null { - if (logsForTag.length === 0) { - return null; + async #fetchPrivateLogs(requests: LogRetrievalRequest[], anchorBlockHash: BlockHash): Promise { + const indices = requests.flatMap((r, i) => (r.source !== LogSource.PUBLIC ? [i] : [])); + if (indices.length === 0) { + return requests.map(() => []); } - if (logsForTag.length > 1) { - this.log.warn( - `Expected at most 1 ${description}, got ${logsForTag.length}. This may indicate a contract bug. Returning the first log.`, - ); - } + const siloedTags = await Promise.all( + indices.map(i => SiloedTag.computeFromTagAndApp(requests[i].tag, requests[i].contractAddress)), + ); - const scopedLog = logsForTag[0]; + const results = await getAllPrivateLogsByTags(this.aztecNode, siloedTags, anchorBlockHash); - return new LogRetrievalResponse( - scopedLog.logData.slice(1), // Skip the tag - scopedLog.txHash, - scopedLog.noteHashes, - scopedLog.firstNullifier, + const logsPerTag: TxScopedL2Log[][] = requests.map(() => []); + indices.forEach((originalIdx, resultIdx) => { + logsPerTag[originalIdx] = results[resultIdx]; + }); + return logsPerTag; + } + + #extractLogs(logsForTag: TxScopedL2Log[], fromBlock?: BlockNumber, toBlock?: BlockNumber): LogRetrievalResponse[] { + // TODO(F-650): push the block range filter down to the node query instead of filtering in memory. + const filtered = + fromBlock !== undefined || toBlock !== undefined + ? logsForTag.filter( + log => + (fromBlock === undefined || log.blockNumber >= fromBlock) && + (toBlock === undefined || log.blockNumber < toBlock), + ) + : logsForTag; + + return filtered.map( + scopedLog => + new LogRetrievalResponse( + scopedLog.logData.slice(1), // Skip the tag + scopedLog.txHash, + scopedLog.noteHashes, + scopedLog.firstNullifier, + ), ); } From 73fc45a1e08af32495152c897877f535854d9163 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Fri, 15 May 2026 18:08:30 -0300 Subject: [PATCH 2/3] chore(pxe): bump oracle version and add migration notes --- .../docs/resources/migration_notes.md | 17 +++++++++++++++++ .../aztec/src/messages/processing/mod.nr | 5 +---- .../aztec/src/oracle/message_processing.nr | 3 +-- .../aztec-nr/aztec/src/oracle/version.nr | 4 ++-- yarn-project/pxe/src/oracle_version.ts | 4 ++-- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index 1458fba95b12..8276fd0c07a5 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,6 +9,23 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD +### [Aztec.nr] `LogRetrievalRequest` now includes `source`, `from_block`, and `to_block` fields + +`LogRetrievalRequest` has been extended with three new fields to support filtering logs by source and block range. The `get_logs_by_tag` oracle now also returns all matching logs per tag instead of only the first match. + +If you construct `LogRetrievalRequest` manually, you must provide the new fields: + +```diff + LogRetrievalRequest { + tag: my_tag, ++ source: LogSource.PUBLIC_AND_PRIVATE, ++ from_block: Option::none(), ++ to_block: Option::none(), + } +``` + +`source` controls which RPCs are queried: `LogSource.PRIVATE`, `LogSource.PUBLIC`, or `LogSource.PUBLIC_AND_PRIVATE`. `from_block` and `to_block` define a half-open `[from, to)` block range filter. Both are `Option` and default to `Option::none()` (no filtering). + ### [Aztec.nr] `attempt_note_discovery` is no longer exposed; use `process_private_note_msg` `attempt_note_discovery` is now crate-private. Custom message handlers (implementations of `CustomMessageHandler`) that previously called it directly should call `process_private_note_msg` instead, which runs the standard private note message decoding and discovery pipeline. diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr index 70a168900cdc..7d67daea1d27 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr @@ -19,10 +19,7 @@ use crate::{ discovery::partial_notes::DeliveredPendingPartialNote, encoding::MESSAGE_CIPHERTEXT_LEN, logs::{event::MAX_EVENT_SERIALIZED_LEN, note::MAX_NOTE_PACKED_LEN}, - processing::{ - log_retrieval_request::{LogRetrievalRequest, LogSource}, - log_retrieval_response::LogRetrievalResponse, - }, + processing::log_retrieval_request::{LogRetrievalRequest, LogSource}, }, oracle::message_processing, }; diff --git a/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr b/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr index f3ba76a72153..5d9a50813e57 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr @@ -1,7 +1,6 @@ use crate::ephemeral::EphemeralArray; use crate::messages::processing::{ - log_retrieval_request::LogRetrievalRequest, log_retrieval_response::LogRetrievalResponse, MessageContext, - pending_tagged_log::PendingTaggedLog, + log_retrieval_request::LogRetrievalRequest, MessageContext, pending_tagged_log::PendingTaggedLog, }; use crate::protocol::address::AztecAddress; use crate::protocol::blob_data::TxEffect; diff --git a/noir-projects/aztec-nr/aztec/src/oracle/version.nr b/noir-projects/aztec-nr/aztec/src/oracle/version.nr index 39ce39d881aa..2910e98a80c1 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/version.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/version.nr @@ -10,8 +10,8 @@ /// the PXE and used to provide helpful error messages if a contract calls an oracle that doesn't exist. We don't throw /// immediately if AZTEC_NR_MINOR > PXE_MINOR because if a contract is updated to use a newer Aztec.nr dependency /// without actually using any of the new oracles then there is no reason to throw. -pub global ORACLE_VERSION_MAJOR: Field = 22; -pub global ORACLE_VERSION_MINOR: Field = 3; +pub global ORACLE_VERSION_MAJOR: Field = 23; +pub global ORACLE_VERSION_MINOR: Field = 0; /// Asserts that the version of the oracle is compatible with the version expected by the contract. pub fn assert_compatible_oracle_version() { diff --git a/yarn-project/pxe/src/oracle_version.ts b/yarn-project/pxe/src/oracle_version.ts index 226a84631cba..3ff1b5e0e460 100644 --- a/yarn-project/pxe/src/oracle_version.ts +++ b/yarn-project/pxe/src/oracle_version.ts @@ -10,8 +10,8 @@ /// used to provide helpful error messages if a contract calls an oracle that doesn't exist. We don't throw immediately /// if AZTEC_NR_MINOR > PXE_MINOR because if a contract is updated to use a newer Aztec.nr dependency without actually /// using any of the new oracles then there is no reason to throw. -export const ORACLE_VERSION_MAJOR = 22; -export const ORACLE_VERSION_MINOR = 3; +export const ORACLE_VERSION_MAJOR = 23; +export const ORACLE_VERSION_MINOR = 0; /// This hash is computed from the Oracle interface and is used to detect when that interface changes. When it does, /// you need to either: From e1fc0f2e30982bb9d89996636c785b0abaccdbd3 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Fri, 15 May 2026 21:49:08 +0000 Subject: [PATCH 3/3] chore(pxe): bump oracle version in aztec_sublib for protocol contracts --- .../contracts/protocol/aztec_sublib/src/oracle/version.nr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/noir-projects/noir-contracts/contracts/protocol/aztec_sublib/src/oracle/version.nr b/noir-projects/noir-contracts/contracts/protocol/aztec_sublib/src/oracle/version.nr index 7c7f7c0785b7..0f596c646a13 100644 --- a/noir-projects/noir-contracts/contracts/protocol/aztec_sublib/src/oracle/version.nr +++ b/noir-projects/noir-contracts/contracts/protocol/aztec_sublib/src/oracle/version.nr @@ -10,8 +10,8 @@ /// the PXE and used to provide helpful error messages if a contract calls an oracle that doesn't exist. We don't throw /// immediately if AZTEC_NR_MINOR > PXE_MINOR because if a contract is updated to use a newer Aztec.nr dependency /// without actually using any of the new oracles then there is no reason to throw. -pub global ORACLE_VERSION_MAJOR: Field = 22; -pub global ORACLE_VERSION_MINOR: Field = 1; +pub global ORACLE_VERSION_MAJOR: Field = 23; +pub global ORACLE_VERSION_MINOR: Field = 0; /// Asserts that the version of the oracle is compatible with the version expected by the contract. pub fn assert_compatible_oracle_version() {