From ede54c4bcd034250656985fc2a6f55bd2a1113c2 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Wed, 6 May 2026 10:22:44 -0300 Subject: [PATCH 1/3] feat(pxe): batch RPC calls for note and event validation --- .../foundation/src/collection/array.test.ts | 19 ++ .../foundation/src/collection/array.ts | 20 ++ .../oracle/utility_execution_oracle.ts | 57 ++--- .../pxe/src/events/event_service.test.ts | 37 ++-- yarn-project/pxe/src/events/event_service.ts | 49 +++-- .../pxe/src/notes/note_service.test.ts | 140 ++++++------ yarn-project/pxe/src/notes/note_service.ts | 206 ++++++++++-------- 7 files changed, 309 insertions(+), 219 deletions(-) diff --git a/yarn-project/foundation/src/collection/array.test.ts b/yarn-project/foundation/src/collection/array.test.ts index 9348a90d2191..86b19b4b32d6 100644 --- a/yarn-project/foundation/src/collection/array.test.ts +++ b/yarn-project/foundation/src/collection/array.test.ts @@ -13,6 +13,7 @@ import { stdDev, times, unique, + uniqueBy, variance, } from './array.js'; @@ -143,6 +144,24 @@ describe('unique', () => { }); }); +describe('uniqueBy', () => { + it('keeps the first occurrence per key', () => { + const items = [ + { id: 'a', n: 1 }, + { id: 'b', n: 2 }, + { id: 'a', n: 3 }, + ]; + expect(uniqueBy(items, x => x.id)).toEqual([ + { id: 'a', n: 1 }, + { id: 'b', n: 2 }, + ]); + }); + + it('returns an empty array for an empty input', () => { + expect(uniqueBy([], x => x)).toEqual([]); + }); +}); + describe('maxBy', () => { it('returns the max value', () => { expect(maxBy([1, 2, 3], x => x)).toEqual(3); diff --git a/yarn-project/foundation/src/collection/array.ts b/yarn-project/foundation/src/collection/array.ts index e2636c27d1fc..8ce8c3f7ebbe 100644 --- a/yarn-project/foundation/src/collection/array.ts +++ b/yarn-project/foundation/src/collection/array.ts @@ -138,6 +138,26 @@ export function unique(arr: T[]): T[] { return [...new Set(arr)]; } +/** + * Removes duplicates from the given array using a key function. The first occurrence of each key is kept. + * @param arr - The array. + * @param keyFn - A function that returns a primitive key for each element. Elements with the same key are + * considered duplicates. + * @returns A new array. + */ +export function uniqueBy(arr: T[], keyFn: (item: T) => K): T[] { + const seen = new Set(); + const result: T[] = []; + for (const item of arr) { + const key = keyFn(item); + if (!seen.has(key)) { + seen.add(key); + result.push(item); + } + } + return result; +} + /** * Removes all undefined elements from the array. * @param arr - The array. 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 8b213c348496..07a9e72e627f 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 @@ -1,5 +1,6 @@ import type { ARCHIVE_HEIGHT, NOTE_HASH_TREE_HEIGHT } from '@aztec/constants'; import type { BlockNumber } from '@aztec/foundation/branded-types'; +import { uniqueBy } from '@aztec/foundation/collection'; import { Aes128 } from '@aztec/foundation/crypto/aes128'; import { Fr } from '@aztec/foundation/curves/bn254'; import { Point } from '@aztec/foundation/curves/grumpkin'; @@ -28,7 +29,7 @@ import { MessageContext, deriveAppSiloedSharedSecret } from '@aztec/stdlib/logs' import { getNonNullifiedL1ToL2MessageWitness } from '@aztec/stdlib/messaging'; import type { NoteStatus } from '@aztec/stdlib/note'; import { MerkleTreeId, type NullifierMembershipWitness, PublicDataWitness } from '@aztec/stdlib/trees'; -import type { BlockHeader, Capsule, OffchainEffect } from '@aztec/stdlib/tx'; +import type { BlockHeader, Capsule, IndexedTxEffect, OffchainEffect, TxHash } from '@aztec/stdlib/tx'; import { createContractLogger, logContractMessage, stripAztecnrLogPrefix } from '../../contract_logging.js'; import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; @@ -633,42 +634,28 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra * * This function is an auxiliary to support legacy (capsule backed) and new (ephemeral array backed) versions of the * `validateAndStoreEnqueuedNotesAndEvents` oracle. + * + * Tx effects are pre-fetched and de-duplicated here so that multiple notes/events from the same transaction share + * a single `getTxEffect` round-trip. The note service then performs a single batched `findLeavesIndexes` call across + * all the request nullifiers, instead of one per note. */ async #processValidationRequests( noteValidationRequests: NoteValidationRequest[], eventValidationRequests: EventValidationRequest[], scope: AztecAddress, ) { - const noteService = new NoteService(this.noteStore, this.aztecNode, this.anchorBlockHeader, this.jobId); - const noteStorePromises = noteValidationRequests.map(request => - noteService.validateAndStoreNote( - request.contractAddress, - request.owner, - request.storageSlot, - request.randomness, - request.noteNonce, - request.content, - request.noteHash, - request.nullifier, - request.txHash, - scope, - ), - ); + const txEffects = await this.#fetchTxEffects([ + ...noteValidationRequests.map(r => r.txHash), + ...eventValidationRequests.map(r => r.txHash), + ]); + const noteService = new NoteService(this.noteStore, this.aztecNode, this.anchorBlockHeader, this.jobId); const eventService = new EventService(this.anchorBlockHeader, this.aztecNode, this.privateEventStore, this.jobId); - const eventStorePromises = eventValidationRequests.map(request => - eventService.validateAndStoreEvent( - request.contractAddress, - request.eventTypeId, - request.randomness, - request.serializedEvent, - request.eventCommitment, - request.txHash, - scope, - ), - ); - await Promise.all([...noteStorePromises, ...eventStorePromises]); + await Promise.all([ + noteService.validateAndStoreNotes(noteValidationRequests, scope, txEffects), + eventService.validateAndStoreEvents(eventValidationRequests, scope, txEffects), + ]); } public async getLogsByTag( @@ -976,6 +963,20 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra return this.offchainEffects; } + /** + * Fetches tx effects for the given hashes in parallel, deduplicating repeated hashes so each tx is only requested + * once. Returns a map keyed by `TxHash.toString()`; hashes for which the node has no tx effect are omitted. + */ + async #fetchTxEffects(txHashes: TxHash[]): Promise> { + const uniqueTxHashes = uniqueBy(txHashes, h => h.toString()); + const fetched = await Promise.all(uniqueTxHashes.map(h => this.aztecNode.getTxEffect(h))); + return new Map( + uniqueTxHashes + .map((h, i): [string, IndexedTxEffect | undefined] => [h.toString(), fetched[i]]) + .filter((entry): entry is [string, IndexedTxEffect] => entry[1] !== undefined), + ); + } + /** Runs a query concurrently with a validation that the block hash is not ahead of the anchor block. */ async #queryWithBlockHashNotAfterAnchor(blockHash: BlockHash, query: () => Promise): Promise { const [response] = await Promise.all([ diff --git a/yarn-project/pxe/src/events/event_service.test.ts b/yarn-project/pxe/src/events/event_service.test.ts index 58b35009573b..643d57c2345a 100644 --- a/yarn-project/pxe/src/events/event_service.test.ts +++ b/yarn-project/pxe/src/events/event_service.test.ts @@ -12,10 +12,11 @@ import { type IndexedTxEffect, TxEffect } from '@aztec/stdlib/tx'; import { mock } from 'jest-mock-extended'; +import { EventValidationRequest } from '../contract_function_simulator/noir-structs/event_validation_request.js'; import { PrivateEventStore } from '../storage/private_event_store/private_event_store.js'; import { EventService } from './event_service.js'; -describe('validateAndStoreEvent', () => { +describe('validateAndStoreEvents', () => { let blockNumber: BlockNumber; let eventSelector: EventSelector; let randomness: Fr; @@ -66,12 +67,11 @@ describe('validateAndStoreEvent', () => { /* Happy path context conditions: ** - PXE is sync'd to _at least_ block including tx - ** - Node returns the corresponding tx effect and the tx effect includes the event commitment + ** - Caller provides the corresponding tx effect via the prefetched map and the tx effect includes the event + ** commitment. */ const anchorBlockHeader = makeBlockHeader(0, { blockNumber }); - aztecNode.getTxEffect.mockImplementation(() => Promise.resolve(indexedTxEffect)); - logger = mock(); eventService = new EventService(anchorBlockHeader, aztecNode, privateEventStore, 'test', logger); }); @@ -80,34 +80,33 @@ describe('validateAndStoreEvent', () => { overrides: { eventContent?: Fr[]; eventCommitment?: Fr; + txEffectsMap?: Map; } = {}, ) { - await eventService.validateAndStoreEvent( + const request = new EventValidationRequest( contractAddress, eventSelector, randomness, - overrides.eventContent || eventContent, - overrides.eventCommitment || eventCommitment, + overrides.eventContent ?? eventContent, + overrides.eventCommitment ?? eventCommitment, txEffect.txHash, - recipient, ); + const map = overrides.txEffectsMap ?? defaultTxEffectsMap(); + await eventService.validateAndStoreEvents([request], recipient, map); + await privateEventStore.commit('test'); } it('should throw when tx does not exist or has no effects', async () => { - aztecNode.getTxEffect.mockImplementation(() => Promise.resolve(undefined)); - await expect(runStoreEvent).rejects.toThrow(/Could not find tx effect for tx hash/); + const txEffectsMap = new Map(); + await expect(() => runStoreEvent({ txEffectsMap })).rejects.toThrow(/Could not find tx effect for tx hash/); }); it('should throw when tx block has not yet been synchronized', async () => { - indexedTxEffect = { - ...indexedTxEffect, - l2BlockNumber: BlockNumber(blockNumber + 1), - }; - aztecNode.getTxEffect.mockImplementation(() => Promise.resolve(indexedTxEffect)); - - await expect(runStoreEvent).rejects.toThrow( + const laterIndexedTxEffect = { ...indexedTxEffect, l2BlockNumber: BlockNumber(blockNumber + 1) }; + const txEffectsMap = new Map([[txEffect.txHash.toString(), laterIndexedTxEffect]]); + await expect(() => runStoreEvent({ txEffectsMap })).rejects.toThrow( /Obtained a newer tx effect for .* for an event validation request than the anchor block/, ); }); @@ -159,4 +158,8 @@ describe('validateAndStoreEvent', () => { expect(result.length).toEqual(1); expect(result[0].packedEvent).toEqual(eventContent); }); + + function defaultTxEffectsMap() { + return new Map([[txEffect.txHash.toString(), indexedTxEffect]]); + } }); diff --git a/yarn-project/pxe/src/events/event_service.ts b/yarn-project/pxe/src/events/event_service.ts index 8ab69a80e6ba..069684de664f 100644 --- a/yarn-project/pxe/src/events/event_service.ts +++ b/yarn-project/pxe/src/events/event_service.ts @@ -1,11 +1,10 @@ -import type { Fr } from '@aztec/foundation/curves/bn254'; import { createLogger } from '@aztec/foundation/log'; -import type { EventSelector } from '@aztec/stdlib/abi'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import { computePrivateEventCommitment, siloNullifier } from '@aztec/stdlib/hash'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; -import type { BlockHeader, TxHash } from '@aztec/stdlib/tx'; +import type { BlockHeader, IndexedTxEffect } from '@aztec/stdlib/tx'; +import type { EventValidationRequest } from '../contract_function_simulator/noir-structs/event_validation_request.js'; import { PrivateEventStore } from '../storage/private_event_store/private_event_store.js'; export class EventService { @@ -17,15 +16,37 @@ export class EventService { private readonly log = createLogger('pxe:event_service'), ) {} - public async validateAndStoreEvent( - contractAddress: AztecAddress, - selector: EventSelector, - randomness: Fr, - content: Fr[], - eventCommitment: Fr, - txHash: TxHash, + /** + * Validates and stores a batch of private events against pre-fetched tx effects. + * + * @param requests - The events to validate and store. + * @param scope - The scope under which the events are being stored. + * @param txEffects - Pre-fetched tx effects keyed by `TxHash.toString()`. Must contain entries for every request's + * txHash; missing entries are treated as a node bug and cause an error. + */ + public async validateAndStoreEvents( + requests: EventValidationRequest[], scope: AztecAddress, + txEffects: Map, ): Promise { + if (requests.length === 0) { + return; + } + + const anchorBlockNumber = this.anchorBlockHeader.getBlockNumber(); + + await Promise.all(requests.map(req => this.#validateAndStoreEvent(req, scope, txEffects, anchorBlockNumber))); + } + + async #validateAndStoreEvent( + request: EventValidationRequest, + scope: AztecAddress, + txEffects: Map, + anchorBlockNumber: number, + ): Promise { + const { contractAddress, eventTypeId: selector, randomness, serializedEvent: content, eventCommitment, txHash } = + request; + // Defense-in-depth: the built-in private-event path derives this commitment from content before enqueueing, but // unconstrained PXE-side code (e.g. a custom message handler) can reach this oracle with arbitrary // (content, commitment) pairs. Without this check it could bind arbitrary content to a legitimate tx nullifier, @@ -42,13 +63,9 @@ export class EventService { // (and thus we're less concerned about being ahead of the synced block), we use the synced block number to // maintain consistent behavior in the PXE. Additionally, events should never be ahead of the synced block here // since `fetchTaggedLogs` only processes logs up to the synced block. - const [siloedEventCommitment, txEffect] = await Promise.all([ - siloNullifier(contractAddress, eventCommitment), - this.aztecNode.getTxEffect(txHash), - ]); - - const anchorBlockNumber = this.anchorBlockHeader.getBlockNumber(); + const siloedEventCommitment = await siloNullifier(contractAddress, eventCommitment); + const txEffect = txEffects.get(txHash.toString()); if (!txEffect) { // We error out instead of just logging a warning and skipping the event because this would indicate a bug. This // is because the node has already served info about this tx either when obtaining the log (TxScopedL2Log contain diff --git a/yarn-project/pxe/src/notes/note_service.test.ts b/yarn-project/pxe/src/notes/note_service.test.ts index 667fc26f2cea..60f6dce5374c 100644 --- a/yarn-project/pxe/src/notes/note_service.test.ts +++ b/yarn-project/pxe/src/notes/note_service.test.ts @@ -15,6 +15,7 @@ import { type IndexedTxEffect, TxEffect, TxHash } from '@aztec/stdlib/tx'; import { jest } from '@jest/globals'; import { mock } from 'jest-mock-extended'; +import { NoteValidationRequest } from '../contract_function_simulator/noir-structs/note_validation_request.js'; import { NoteStore } from '../storage/note_store/note_store.js'; import { NoteService } from './note_service.js'; @@ -195,7 +196,7 @@ describe('NoteService', () => { expect(getNotesSpy).toHaveBeenCalledWith(expect.objectContaining({ contractAddress }), 'test'); }); - describe('validateAndStoreNote', () => { + describe('validateAndStoreNotes', () => { // Recipient is different from the owner because recipient refers to the // recipient of the message containing the note, while owner refers to the // owner of the note. @@ -214,6 +215,8 @@ describe('NoteService', () => { let indexedTxEffect: IndexedTxEffect; let blockNumber: BlockNumber; + let buildRequest: (overrides?: Partial) => NoteValidationRequest; + // beforeEach sets up the happy path case, so error modes are tested // by minimally failing happy path conditions beforeEach(async () => { @@ -244,34 +247,32 @@ describe('NoteService', () => { /* Happy path context conditions: ** - PXE is sync'd to _at least_ block including tx - ** - Node knows tx effect + ** - Node knows tx effect (passed in via the prefetched map) ** - Node knows unique note hash (and siloed nullifier if requested) */ setSyncedBlockNumber(blockNumber); - aztecNode.getTxEffect.mockImplementation(queryTxHash => - Promise.resolve(queryTxHash == txHash ? indexedTxEffect : undefined), - ); + buildRequest = (overrides = {}) => + new NoteValidationRequest( + overrides.contractAddress ?? contractAddress, + overrides.owner ?? owner, + overrides.storageSlot ?? storageSlot, + overrides.randomness ?? randomness, + overrides.noteNonce ?? noteNonce, + overrides.content ?? content, + overrides.noteHash ?? noteHash, + overrides.nullifier ?? nullifier, + overrides.txHash ?? txHash, + ); - aztecNode.findLeavesIndexes.mockImplementation((_queryBlockParam, _treeId, _leaves) => { - // By default the note is not yet nullified. - return Promise.resolve([undefined]); + aztecNode.findLeavesIndexes.mockImplementation((_queryBlockParam, _treeId, leaves) => { + // By default the notes are not yet nullified. + return Promise.resolve(leaves.map(() => undefined)); }); }); it('should store note if it exists in a tx effect', async () => { - await noteService.validateAndStoreNote( - contractAddress, - owner, - storageSlot, - randomness, - noteNonce, - content, - noteHash, - nullifier, - txHash, - recipient.address, - ); + await noteService.validateAndStoreNotes([buildRequest()], recipient.address, defaultTxEffectsMap()); // Verify note was stored const notes = await noteStore.getNotes({ contractAddress, scopes: [recipient.address] }, 'test'); @@ -292,34 +293,20 @@ describe('NoteService', () => { it('should throw if tx hash does not exist', async () => { await expect( - noteService.validateAndStoreNote( - contractAddress, - owner, - storageSlot, - randomness, - noteNonce, - content, - noteHash, - nullifier, - TxHash.random(), + noteService.validateAndStoreNotes( + [buildRequest({ txHash: TxHash.random() })], recipient.address, + defaultTxEffectsMap(), ), ).rejects.toThrow(/Could not find tx effect/); }); it('should throw if note was not emitted in the tx', async () => { await expect( - noteService.validateAndStoreNote( - contractAddress, - owner, - storageSlot, - randomness, - noteNonce, - content, - Fr.random(), // note hash - nullifier, - txHash, + noteService.validateAndStoreNotes( + [buildRequest({ noteHash: Fr.random() })], recipient.address, + defaultTxEffectsMap(), ), ).rejects.toThrow(/is not present in tx/); }); @@ -328,45 +315,58 @@ describe('NoteService', () => { setSyncedBlockNumber(BlockNumber(blockNumber - 1)); await expect( - noteService.validateAndStoreNote( - contractAddress, - owner, - storageSlot, - randomness, - noteNonce, - content, - noteHash, - nullifier, - txHash, - recipient.address, - ), + noteService.validateAndStoreNotes([buildRequest()], recipient.address, defaultTxEffectsMap()), ).rejects.toThrow(/Obtained a newer tx effect for .* for a note validation request than the anchor block/); }); + it('should batch findLeavesIndexes across notes', async () => { + // Two notes from the same tx so we can reuse the indexedTxEffect; each carries its own unique note hash. + const otherNoteHash = Fr.random(); + const otherNullifier = Fr.random(); + const otherNoteNonce = Fr.random(); + const otherUniqueNoteHash = await computeUniqueNoteHash( + otherNoteNonce, + await siloNoteHash(contractAddress, otherNoteHash), + ); + + const sharedTxEffect: IndexedTxEffect = { + ...indexedTxEffect, + data: TxEffect.from({ + ...indexedTxEffect.data, + noteHashes: [uniqueNoteHash, otherUniqueNoteHash], + }), + }; + const map = new Map([[txHash.toString(), sharedTxEffect]]); + + await noteService.validateAndStoreNotes( + [ + buildRequest(), + buildRequest({ noteHash: otherNoteHash, nullifier: otherNullifier, noteNonce: otherNoteNonce }), + ], + recipient.address, + map, + ); + + // Both notes should have been looked up in a single batched call. + expect(aztecNode.findLeavesIndexes).toHaveBeenCalledTimes(1); + const [, , leaves] = aztecNode.findLeavesIndexes.mock.calls[0]; + expect(leaves).toHaveLength(2); + }); + it('should nullify note if nullifier index is found', async () => { const siloedNullifier = await siloNullifier(contractAddress, nullifier); const nullifierIndex = randomDataInBlock(123n); // Override the mock to return a nullifier index (indicating the note has been nullified) aztecNode.findLeavesIndexes.mockImplementation((_queryBlockNum, treeId, leaves) => { - if (treeId == MerkleTreeId.NULLIFIER_TREE && leaves[0].equals(siloedNullifier)) { - return Promise.resolve([nullifierIndex]); - } - return Promise.resolve([undefined]); + return Promise.resolve( + leaves.map(leaf => + treeId == MerkleTreeId.NULLIFIER_TREE && leaf.equals(siloedNullifier) ? nullifierIndex : undefined, + ), + ); }); - await noteService.validateAndStoreNote( - contractAddress, - owner, - storageSlot, - randomness, - noteNonce, - content, - noteHash, - nullifier, - txHash, - recipient.address, - ); + await noteService.validateAndStoreNotes([buildRequest()], recipient.address, defaultTxEffectsMap()); const verifyNoteNullifiedInJobContext = async (jobId: string) => { // Now we verify that the note is stored as nullified by checking it can be retrieved only with @@ -398,5 +398,9 @@ describe('NoteService', () => { await noteStore.commit('test'); await verifyNoteNullifiedInJobContext('fresh-job'); }); + + function defaultTxEffectsMap() { + return new Map([[txHash.toString(), indexedTxEffect]]); + } }); }); diff --git a/yarn-project/pxe/src/notes/note_service.ts b/yarn-project/pxe/src/notes/note_service.ts index bf839db370b8..a42de2c5d95f 100644 --- a/yarn-project/pxe/src/notes/note_service.ts +++ b/yarn-project/pxe/src/notes/note_service.ts @@ -1,3 +1,4 @@ +import { chunk } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { DataInBlock } from '@aztec/stdlib/block'; @@ -5,8 +6,9 @@ import { computeUniqueNoteHash, siloNoteHash, siloNullifier } from '@aztec/stdli import { type AztecNode, MAX_RPC_LEN } from '@aztec/stdlib/interfaces/client'; import { Note, NoteDao, NoteStatus } from '@aztec/stdlib/note'; import { MerkleTreeId } from '@aztec/stdlib/trees'; -import type { BlockHeader, TxHash } from '@aztec/stdlib/tx'; +import type { BlockHeader, IndexedTxEffect } from '@aztec/stdlib/tx'; +import type { NoteValidationRequest } from '../contract_function_simulator/noir-structs/note_validation_request.js'; import type { NoteStore } from '../storage/note_store/note_store.js'; export class NoteService { @@ -80,24 +82,7 @@ export class NoteService { } const nullifiersToCheck = contractNotes.map(note => note.siloedNullifier); - const nullifierBatches = nullifiersToCheck.reduce( - (acc, nullifier) => { - if (acc[acc.length - 1].length < MAX_RPC_LEN) { - acc[acc.length - 1].push(nullifier); - } else { - acc.push([nullifier]); - } - return acc; - }, - [[]] as Fr[][], - ); - const nullifierIndexes = ( - await Promise.all( - nullifierBatches.map(batch => - this.aztecNode.findLeavesIndexes(anchorBlockHash, MerkleTreeId.NULLIFIER_TREE, batch), - ), - ) - ).flat(); + const nullifierIndexes = await this.#findNullifierIndexes(anchorBlockHash, nullifiersToCheck); const foundNullifiers = nullifiersToCheck .map((nullifier, i) => { @@ -110,30 +95,34 @@ export class NoteService { await this.noteStore.applyNullifiers(foundNullifiers, this.jobId); } - public async validateAndStoreNote( - contractAddress: AztecAddress, - owner: AztecAddress, - storageSlot: Fr, - randomness: Fr, - noteNonce: Fr, - content: Fr[], - noteHash: Fr, - nullifier: Fr, - txHash: TxHash, + /** + * Validates and stores a batch of notes against pre-fetched tx effects. + * + * For each request we must verify that: + * - the note actually exists in the corresponding tx effect (and thus in the note hash tree), and + * - the note has not already been nullified. + * + * Failing to do either would result in circuits getting either non-existent notes and failing to produce inclusion + * proofs for them, or getting nullified notes and producing duplicate nullifiers, both of which are catastrophic + * failure modes. + * + * Note that adding a note and removing it is *not* equivalent to never adding it in the first place. A nullifier + * emitted in a block that comes after note creation might result in the note being de-nullified by a chain reorg, + * so we must store both the note hash and nullifier block information. + * + * @param requests - The notes to validate and store. + * @param scope - The scope under which the notes are being stored. + * @param txEffects - Pre-fetched tx effects keyed by `TxHash.toString()`. Must contain entries for every request's + * txHash; missing entries are treated as a node bug and cause an error. + */ + public async validateAndStoreNotes( + requests: NoteValidationRequest[], scope: AztecAddress, + txEffects: Map, ): Promise { - // We are going to store the new note in the NoteStore, which will let us later return it via `getNotes`. - // There's two things we need to check before we do this however: - // - we must make sure the note does actually exist in the note hash tree - // - we need to check if the note has already been nullified - // - // Failing to do either of the above would result in circuits getting either non-existent notes and failing to - // produce inclusion proofs for them, or getting nullified notes and producing duplicate nullifiers, both of which - // are catastrophic failure modes. - // - // Note that adding a note and removing it is *not* equivalent to never adding it in the first place. A nullifier - // emitted in a block that comes after note creation might result in the note being de-nullified by a chain reorg, - // so we must store both the note hash and nullifier block information. + if (requests.length === 0) { + return; + } // We avoid making node queries at 'latest' since we don't want to process notes or nullifiers that only exist ahead // in time of the locally synced state. @@ -146,61 +135,98 @@ export class NoteService { // By computing siloed and unique note hashes ourselves we prevent contracts from interfering with the note storage // of other contracts, which would constitute a security breach. - const uniqueNoteHash = await computeUniqueNoteHash(noteNonce, await siloNoteHash(contractAddress, noteHash)); - const siloedNullifier = await siloNullifier(contractAddress, nullifier); - - const [txEffect, [nullifierIndex]] = await Promise.all([ - this.aztecNode.getTxEffect(txHash), - this.aztecNode.findLeavesIndexes(anchorBlockHash, MerkleTreeId.NULLIFIER_TREE, [siloedNullifier]), - ]); - if (!txEffect) { - // We error out instead of just logging a warning and skipping the note because this would indicate a bug. This - // is because the node has already served info about this tx either when obtaining the log (TxScopedL2Log contain - // tx info) or when getting metadata for the offchain message (before the message got passed to `process_log`). - throw new Error(`Could not find tx effect for tx hash ${txHash} when processing a note.`); - } + const computed = await Promise.all( + requests.map(async ({ contractAddress, noteHash, nullifier, noteNonce }) => { + const [siloedNoteHash, siloedNullifier] = await Promise.all([ + siloNoteHash(contractAddress, noteHash), + siloNullifier(contractAddress, nullifier), + ]); + const uniqueNoteHash = await computeUniqueNoteHash(noteNonce, siloedNoteHash); + return { uniqueNoteHash, siloedNullifier }; + }), + ); - if (txEffect.l2BlockNumber > anchorBlockNumber) { - // If the message was delivered onchain, this would indicate a bug: log sync should never load logs from blocks - // newer than the anchor block. If the note came via an offchain message, it would likely also be a bug, since we - // sync a new anchor block before calling `process_message`. For this not to be a bug, the message would need to - // come from a newer block than the anchor served by the node, implying the node isn't properly synced. - // We therefore error out here rather than assuming the offchain message was constructed by a malicious - // sender with the intention of bricking recipient's PXE (if we assumed that we would just ignore the message). - throw new Error( - `Obtained a newer tx effect for ${txHash} for a note validation request than the anchor block ${anchorBlockNumber}. This is a bug as we should not ever be processing a note from a newer block than the anchor block.`, + const nullifierIndexes = await this.#findNullifierIndexes( + anchorBlockHash, + computed.map(c => c.siloedNullifier), + ); + + const noteDaos: NoteDao[] = []; + const foundNullifiers: DataInBlock[] = []; + + for (let i = 0; i < requests.length; i++) { + const { contractAddress, owner, storageSlot, randomness, noteNonce, content, noteHash, txHash } = requests[i]; + const { uniqueNoteHash, siloedNullifier } = computed[i]; + const nullifierIndex = nullifierIndexes[i]; + + const txEffect = txEffects.get(txHash.toString()); + if (!txEffect) { + // We error out instead of just logging a warning and skipping the note because this would indicate a bug. This + // is because the node has already served info about this tx either when obtaining the log (TxScopedL2Log + // contain tx info) or when getting metadata for the offchain message (before the message got passed to + // `process_log`). + throw new Error(`Could not find tx effect for tx hash ${txHash} when processing a note.`); + } + + if (txEffect.l2BlockNumber > anchorBlockNumber) { + // If the message was delivered onchain, this would indicate a bug: log sync should never load logs from blocks + // newer than the anchor block. If the note came via an offchain message, it would likely also be a bug, since + // we sync a new anchor block before calling `process_message`. For this not to be a bug, the message would + // need to come from a newer block than the anchor served by the node, implying the node isn't properly synced. + // We therefore error out here rather than assuming the offchain message was constructed by a malicious + // sender with the intention of bricking recipient's PXE (if we assumed that we would just ignore the message). + throw new Error( + `Obtained a newer tx effect for ${txHash} for a note validation request than the anchor block ${anchorBlockNumber}. This is a bug as we should not ever be processing a note from a newer block than the anchor block.`, + ); + } + + // Find the index of the note hash in the noteHashes array to determine note ordering within the tx + const noteIndexInTx = txEffect.data.noteHashes.findIndex(nh => nh.equals(uniqueNoteHash)); + if (noteIndexInTx === -1) { + // Similar to the comment above - we error out as this would indicate a bug in nonce discovery. + throw new Error(`Note hash ${noteHash} (uniqued as ${uniqueNoteHash}) is not present in tx ${txHash}`); + } + + noteDaos.push( + new NoteDao( + new Note(content), + contractAddress, + owner, + storageSlot, + randomness, + noteNonce, + noteHash, + siloedNullifier, + txHash, + txEffect.l2BlockNumber, + txEffect.l2BlockHash.toString(), + txEffect.txIndexInBlock, + noteIndexInTx, + ), ); - } - // Find the index of the note hash in the noteHashes array to determine note ordering within the tx - const noteIndexInTx = txEffect.data.noteHashes.findIndex(nh => nh.equals(uniqueNoteHash)); - if (noteIndexInTx === -1) { - // Similar to the comment above - we error out as this would indicate a bug in nonce discovery. - throw new Error(`Note hash ${noteHash} (uniqued as ${uniqueNoteHash}) is not present in tx ${txHash}`); + if (nullifierIndex !== undefined) { + // We found a nullifier index which implies that the note has already been nullified. + const { data: _, ...blockHashAndNum } = nullifierIndex; + foundNullifiers.push({ data: siloedNullifier, ...blockHashAndNum }); + } } - const noteDao = new NoteDao( - new Note(content), - contractAddress, - owner, - storageSlot, - randomness, - noteNonce, - noteHash, - siloedNullifier, - txHash, - txEffect.l2BlockNumber, - txEffect.l2BlockHash.toString(), - txEffect.txIndexInBlock, - noteIndexInTx, - ); + await this.noteStore.addNotes(noteDaos, scope, this.jobId); - await this.noteStore.addNotes([noteDao], scope, this.jobId); - - if (nullifierIndex !== undefined) { - // We found nullifier index which implies that the note has already been nullified. - const { data: _, ...blockHashAndNum } = nullifierIndex; - await this.noteStore.applyNullifiers([{ data: siloedNullifier, ...blockHashAndNum }], this.jobId); + if (foundNullifiers.length > 0) { + await this.noteStore.applyNullifiers(foundNullifiers, this.jobId); } } + + async #findNullifierIndexes(anchorBlockHash: Fr, siloedNullifiers: Fr[]) { + const batches = chunk(siloedNullifiers, MAX_RPC_LEN); + return ( + await Promise.all( + batches.map(batch => + this.aztecNode.findLeavesIndexes(anchorBlockHash, MerkleTreeId.NULLIFIER_TREE, batch), + ), + ) + ).flat(); + } } From e2a27c2e3164472d2d5d26e848205f819757e502 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Wed, 6 May 2026 10:26:36 -0300 Subject: [PATCH 2/3] refactor(pxe): remove explanatory comment from #processValidationRequests --- .../oracle/utility_execution_oracle.ts | 4 ---- 1 file changed, 4 deletions(-) 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 07a9e72e627f..ce872b0561ce 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 @@ -634,10 +634,6 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra * * This function is an auxiliary to support legacy (capsule backed) and new (ephemeral array backed) versions of the * `validateAndStoreEnqueuedNotesAndEvents` oracle. - * - * Tx effects are pre-fetched and de-duplicated here so that multiple notes/events from the same transaction share - * a single `getTxEffect` round-trip. The note service then performs a single batched `findLeavesIndexes` call across - * all the request nullifiers, instead of one per note. */ async #processValidationRequests( noteValidationRequests: NoteValidationRequest[], From 97817fddb6465a045fa101d2a711fb3d7946175a Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Wed, 6 May 2026 10:36:07 -0300 Subject: [PATCH 3/3] fix(pxe): correct BlockHash type and formatting in note/event services --- yarn-project/pxe/src/events/event_service.ts | 10 ++++++++-- yarn-project/pxe/src/notes/note_service.ts | 8 +++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/yarn-project/pxe/src/events/event_service.ts b/yarn-project/pxe/src/events/event_service.ts index 069684de664f..ada53c685031 100644 --- a/yarn-project/pxe/src/events/event_service.ts +++ b/yarn-project/pxe/src/events/event_service.ts @@ -44,8 +44,14 @@ export class EventService { txEffects: Map, anchorBlockNumber: number, ): Promise { - const { contractAddress, eventTypeId: selector, randomness, serializedEvent: content, eventCommitment, txHash } = - request; + const { + contractAddress, + eventTypeId: selector, + randomness, + serializedEvent: content, + eventCommitment, + txHash, + } = request; // Defense-in-depth: the built-in private-event path derives this commitment from content before enqueueing, but // unconstrained PXE-side code (e.g. a custom message handler) can reach this oracle with arbitrary diff --git a/yarn-project/pxe/src/notes/note_service.ts b/yarn-project/pxe/src/notes/note_service.ts index a42de2c5d95f..7695d8614307 100644 --- a/yarn-project/pxe/src/notes/note_service.ts +++ b/yarn-project/pxe/src/notes/note_service.ts @@ -1,7 +1,7 @@ import { chunk } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; -import type { DataInBlock } from '@aztec/stdlib/block'; +import { BlockHash, type DataInBlock } from '@aztec/stdlib/block'; import { computeUniqueNoteHash, siloNoteHash, siloNullifier } from '@aztec/stdlib/hash'; import { type AztecNode, MAX_RPC_LEN } from '@aztec/stdlib/interfaces/client'; import { Note, NoteDao, NoteStatus } from '@aztec/stdlib/note'; @@ -219,13 +219,11 @@ export class NoteService { } } - async #findNullifierIndexes(anchorBlockHash: Fr, siloedNullifiers: Fr[]) { + async #findNullifierIndexes(anchorBlockHash: BlockHash, siloedNullifiers: Fr[]) { const batches = chunk(siloedNullifiers, MAX_RPC_LEN); return ( await Promise.all( - batches.map(batch => - this.aztecNode.findLeavesIndexes(anchorBlockHash, MerkleTreeId.NULLIFIER_TREE, batch), - ), + batches.map(batch => this.aztecNode.findLeavesIndexes(anchorBlockHash, MerkleTreeId.NULLIFIER_TREE, batch)), ) ).flat(); }