Skip to content

Commit fe1ba71

Browse files
nchamoaztec-bot
authored andcommitted
refactor(pxe): batch RPC calls for note and event validation (#22988)
1 parent 2a3e928 commit fe1ba71

7 files changed

Lines changed: 310 additions & 220 deletions

File tree

yarn-project/foundation/src/collection/array.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
stdDev,
1414
times,
1515
unique,
16+
uniqueBy,
1617
variance,
1718
} from './array.js';
1819

@@ -143,6 +144,24 @@ describe('unique', () => {
143144
});
144145
});
145146

147+
describe('uniqueBy', () => {
148+
it('keeps the first occurrence per key', () => {
149+
const items = [
150+
{ id: 'a', n: 1 },
151+
{ id: 'b', n: 2 },
152+
{ id: 'a', n: 3 },
153+
];
154+
expect(uniqueBy(items, x => x.id)).toEqual([
155+
{ id: 'a', n: 1 },
156+
{ id: 'b', n: 2 },
157+
]);
158+
});
159+
160+
it('returns an empty array for an empty input', () => {
161+
expect(uniqueBy([], x => x)).toEqual([]);
162+
});
163+
});
164+
146165
describe('maxBy', () => {
147166
it('returns the max value', () => {
148167
expect(maxBy([1, 2, 3], x => x)).toEqual(3);

yarn-project/foundation/src/collection/array.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,26 @@ export function unique<T>(arr: T[]): T[] {
138138
return [...new Set(arr)];
139139
}
140140

141+
/**
142+
* Removes duplicates from the given array using a key function. The first occurrence of each key is kept.
143+
* @param arr - The array.
144+
* @param keyFn - A function that returns a primitive key for each element. Elements with the same key are
145+
* considered duplicates.
146+
* @returns A new array.
147+
*/
148+
export function uniqueBy<T, K extends string | number | bigint>(arr: T[], keyFn: (item: T) => K): T[] {
149+
const seen = new Set<K>();
150+
const result: T[] = [];
151+
for (const item of arr) {
152+
const key = keyFn(item);
153+
if (!seen.has(key)) {
154+
seen.add(key);
155+
result.push(item);
156+
}
157+
}
158+
return result;
159+
}
160+
141161
/**
142162
* Removes all undefined elements from the array.
143163
* @param arr - The array.

yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ARCHIVE_HEIGHT, NOTE_HASH_TREE_HEIGHT } from '@aztec/constants';
22
import type { BlockNumber } from '@aztec/foundation/branded-types';
3+
import { uniqueBy } from '@aztec/foundation/collection';
34
import { Aes128 } from '@aztec/foundation/crypto/aes128';
45
import { Fr } from '@aztec/foundation/curves/bn254';
56
import { Point } from '@aztec/foundation/curves/grumpkin';
@@ -28,7 +29,7 @@ import { MessageContext, deriveAppSiloedSharedSecret } from '@aztec/stdlib/logs'
2829
import { getNonNullifiedL1ToL2MessageWitness } from '@aztec/stdlib/messaging';
2930
import type { NoteStatus } from '@aztec/stdlib/note';
3031
import { MerkleTreeId, type NullifierMembershipWitness, PublicDataWitness } from '@aztec/stdlib/trees';
31-
import type { BlockHeader, Capsule, OffchainEffect } from '@aztec/stdlib/tx';
32+
import type { BlockHeader, Capsule, IndexedTxEffect, OffchainEffect, TxHash } from '@aztec/stdlib/tx';
3233

3334
import { createContractLogger, logContractMessage, stripAztecnrLogPrefix } from '../../contract_logging.js';
3435
import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js';
@@ -639,36 +640,18 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra
639640
eventValidationRequests: EventValidationRequest[],
640641
scope: AztecAddress,
641642
) {
642-
const noteService = new NoteService(this.noteStore, this.aztecNode, this.anchorBlockHeader, this.jobId);
643-
const noteStorePromises = noteValidationRequests.map(request =>
644-
noteService.validateAndStoreNote(
645-
request.contractAddress,
646-
request.owner,
647-
request.storageSlot,
648-
request.randomness,
649-
request.noteNonce,
650-
request.content,
651-
request.noteHash,
652-
request.nullifier,
653-
request.txHash,
654-
scope,
655-
),
656-
);
643+
const txEffects = await this.#fetchTxEffects([
644+
...noteValidationRequests.map(r => r.txHash),
645+
...eventValidationRequests.map(r => r.txHash),
646+
]);
657647

648+
const noteService = new NoteService(this.noteStore, this.aztecNode, this.anchorBlockHeader, this.jobId);
658649
const eventService = new EventService(this.anchorBlockHeader, this.aztecNode, this.privateEventStore, this.jobId);
659-
const eventStorePromises = eventValidationRequests.map(request =>
660-
eventService.validateAndStoreEvent(
661-
request.contractAddress,
662-
request.eventTypeId,
663-
request.randomness,
664-
request.serializedEvent,
665-
request.eventCommitment,
666-
request.txHash,
667-
scope,
668-
),
669-
);
670650

671-
await Promise.all([...noteStorePromises, ...eventStorePromises]);
651+
await Promise.all([
652+
noteService.validateAndStoreNotes(noteValidationRequests, scope, txEffects),
653+
eventService.validateAndStoreEvents(eventValidationRequests, scope, txEffects),
654+
]);
672655
}
673656

674657
public async getLogsByTag(
@@ -976,6 +959,20 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra
976959
return this.offchainEffects;
977960
}
978961

962+
/**
963+
* Fetches tx effects for the given hashes in parallel, deduplicating repeated hashes so each tx is only requested
964+
* once. Returns a map keyed by `TxHash.toString()`; hashes for which the node has no tx effect are omitted.
965+
*/
966+
async #fetchTxEffects(txHashes: TxHash[]): Promise<Map<string, IndexedTxEffect>> {
967+
const uniqueTxHashes = uniqueBy(txHashes, h => h.toString());
968+
const fetched = await Promise.all(uniqueTxHashes.map(h => this.aztecNode.getTxEffect(h)));
969+
return new Map(
970+
uniqueTxHashes
971+
.map((h, i): [string, IndexedTxEffect | undefined] => [h.toString(), fetched[i]])
972+
.filter((entry): entry is [string, IndexedTxEffect] => entry[1] !== undefined),
973+
);
974+
}
975+
979976
/** Runs a query concurrently with a validation that the block hash is not ahead of the anchor block. */
980977
async #queryWithBlockHashNotAfterAnchor<T>(blockHash: BlockHash, query: () => Promise<T>): Promise<T> {
981978
const [response] = await Promise.all([

yarn-project/pxe/src/events/event_service.test.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import { type IndexedTxEffect, TxEffect } from '@aztec/stdlib/tx';
1212

1313
import { mock } from 'jest-mock-extended';
1414

15+
import { EventValidationRequest } from '../contract_function_simulator/noir-structs/event_validation_request.js';
1516
import { PrivateEventStore } from '../storage/private_event_store/private_event_store.js';
1617
import { EventService } from './event_service.js';
1718

18-
describe('validateAndStoreEvent', () => {
19+
describe('validateAndStoreEvents', () => {
1920
let blockNumber: BlockNumber;
2021
let eventSelector: EventSelector;
2122
let randomness: Fr;
@@ -66,12 +67,11 @@ describe('validateAndStoreEvent', () => {
6667

6768
/* Happy path context conditions:
6869
** - PXE is sync'd to _at least_ block including tx
69-
** - Node returns the corresponding tx effect and the tx effect includes the event commitment
70+
** - Caller provides the corresponding tx effect via the prefetched map and the tx effect includes the event
71+
** commitment.
7072
*/
7173
const anchorBlockHeader = makeBlockHeader(0, { blockNumber });
7274

73-
aztecNode.getTxEffect.mockImplementation(() => Promise.resolve(indexedTxEffect));
74-
7575
logger = mock<Logger>();
7676
eventService = new EventService(anchorBlockHeader, aztecNode, privateEventStore, 'test', logger);
7777
});
@@ -80,34 +80,33 @@ describe('validateAndStoreEvent', () => {
8080
overrides: {
8181
eventContent?: Fr[];
8282
eventCommitment?: Fr;
83+
txEffectsMap?: Map<string, IndexedTxEffect>;
8384
} = {},
8485
) {
85-
await eventService.validateAndStoreEvent(
86+
const request = new EventValidationRequest(
8687
contractAddress,
8788
eventSelector,
8889
randomness,
89-
overrides.eventContent || eventContent,
90-
overrides.eventCommitment || eventCommitment,
90+
overrides.eventContent ?? eventContent,
91+
overrides.eventCommitment ?? eventCommitment,
9192
txEffect.txHash,
92-
recipient,
9393
);
9494

95+
const map = overrides.txEffectsMap ?? defaultTxEffectsMap();
96+
await eventService.validateAndStoreEvents([request], recipient, map);
97+
9598
await privateEventStore.commit('test');
9699
}
97100

98101
it('should throw when tx does not exist or has no effects', async () => {
99-
aztecNode.getTxEffect.mockImplementation(() => Promise.resolve(undefined));
100-
await expect(runStoreEvent).rejects.toThrow(/Could not find tx effect for tx hash/);
102+
const txEffectsMap = new Map();
103+
await expect(() => runStoreEvent({ txEffectsMap })).rejects.toThrow(/Could not find tx effect for tx hash/);
101104
});
102105

103106
it('should throw when tx block has not yet been synchronized', async () => {
104-
indexedTxEffect = {
105-
...indexedTxEffect,
106-
l2BlockNumber: BlockNumber(blockNumber + 1),
107-
};
108-
aztecNode.getTxEffect.mockImplementation(() => Promise.resolve(indexedTxEffect));
109-
110-
await expect(runStoreEvent).rejects.toThrow(
107+
const laterIndexedTxEffect = { ...indexedTxEffect, l2BlockNumber: BlockNumber(blockNumber + 1) };
108+
const txEffectsMap = new Map([[txEffect.txHash.toString(), laterIndexedTxEffect]]);
109+
await expect(() => runStoreEvent({ txEffectsMap })).rejects.toThrow(
111110
/Obtained a newer tx effect for .* for an event validation request than the anchor block/,
112111
);
113112
});
@@ -159,4 +158,8 @@ describe('validateAndStoreEvent', () => {
159158
expect(result.length).toEqual(1);
160159
expect(result[0].packedEvent).toEqual(eventContent);
161160
});
161+
162+
function defaultTxEffectsMap() {
163+
return new Map([[txEffect.txHash.toString(), indexedTxEffect]]);
164+
}
162165
});

yarn-project/pxe/src/events/event_service.ts

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import type { Fr } from '@aztec/foundation/curves/bn254';
21
import { createLogger } from '@aztec/foundation/log';
3-
import type { EventSelector } from '@aztec/stdlib/abi';
42
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
53
import { computePrivateEventCommitment, siloNullifier } from '@aztec/stdlib/hash';
64
import type { AztecNode } from '@aztec/stdlib/interfaces/server';
7-
import type { BlockHeader, TxHash } from '@aztec/stdlib/tx';
5+
import type { BlockHeader, IndexedTxEffect } from '@aztec/stdlib/tx';
86

7+
import type { EventValidationRequest } from '../contract_function_simulator/noir-structs/event_validation_request.js';
98
import { PrivateEventStore } from '../storage/private_event_store/private_event_store.js';
109

1110
export class EventService {
@@ -17,15 +16,43 @@ export class EventService {
1716
private readonly log = createLogger('pxe:event_service'),
1817
) {}
1918

20-
public async validateAndStoreEvent(
21-
contractAddress: AztecAddress,
22-
selector: EventSelector,
23-
randomness: Fr,
24-
content: Fr[],
25-
eventCommitment: Fr,
26-
txHash: TxHash,
19+
/**
20+
* Validates and stores a batch of private events against pre-fetched tx effects.
21+
*
22+
* @param requests - The events to validate and store.
23+
* @param scope - The scope under which the events are being stored.
24+
* @param txEffects - Pre-fetched tx effects keyed by `TxHash.toString()`. Must contain entries for every request's
25+
* txHash; missing entries are treated as a node bug and cause an error.
26+
*/
27+
public async validateAndStoreEvents(
28+
requests: EventValidationRequest[],
2729
scope: AztecAddress,
30+
txEffects: Map<string, IndexedTxEffect>,
2831
): Promise<void> {
32+
if (requests.length === 0) {
33+
return;
34+
}
35+
36+
const anchorBlockNumber = this.anchorBlockHeader.getBlockNumber();
37+
38+
await Promise.all(requests.map(req => this.#validateAndStoreEvent(req, scope, txEffects, anchorBlockNumber)));
39+
}
40+
41+
async #validateAndStoreEvent(
42+
request: EventValidationRequest,
43+
scope: AztecAddress,
44+
txEffects: Map<string, IndexedTxEffect>,
45+
anchorBlockNumber: number,
46+
): Promise<void> {
47+
const {
48+
contractAddress,
49+
eventTypeId: selector,
50+
randomness,
51+
serializedEvent: content,
52+
eventCommitment,
53+
txHash,
54+
} = request;
55+
2956
// Defense-in-depth: the built-in private-event path derives this commitment from content before enqueueing, but
3057
// unconstrained PXE-side code (e.g. a custom message handler) can reach this oracle with arbitrary
3158
// (content, commitment) pairs. Without this check it could bind arbitrary content to a legitimate tx nullifier,
@@ -42,13 +69,9 @@ export class EventService {
4269
// (and thus we're less concerned about being ahead of the synced block), we use the synced block number to
4370
// maintain consistent behavior in the PXE. Additionally, events should never be ahead of the synced block here
4471
// since `fetchTaggedLogs` only processes logs up to the synced block.
45-
const [siloedEventCommitment, txEffect] = await Promise.all([
46-
siloNullifier(contractAddress, eventCommitment),
47-
this.aztecNode.getTxEffect(txHash),
48-
]);
49-
50-
const anchorBlockNumber = this.anchorBlockHeader.getBlockNumber();
72+
const siloedEventCommitment = await siloNullifier(contractAddress, eventCommitment);
5173

74+
const txEffect = txEffects.get(txHash.toString());
5275
if (!txEffect) {
5376
// We error out instead of just logging a warning and skipping the event because this would indicate a bug. This
5477
// is because the node has already served info about this tx either when obtaining the log (TxScopedL2Log contain

0 commit comments

Comments
 (0)