Skip to content

Commit 7a4df18

Browse files
authored
Merge branch 'backport-to-v4-next-staging' into jb/ci-revert-compat-e2e-to-keys
2 parents d43f671 + 9760bcf commit 7a4df18

4 files changed

Lines changed: 114 additions & 70 deletions

File tree

yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ import { Matcher, type MatcherCreator, type MockProxy, mock } from 'jest-mock-ex
6666
import { toFunctionSelector } from 'viem';
6767

6868
import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js';
69-
import { syncState } from '../../contract_sync/helpers.js';
69+
import { syncScope } from '../../contract_sync/helpers.js';
7070
import type { MessageContextService } from '../../messages/message_context_service.js';
7171
import type { AddressStore } from '../../storage/address_store/address_store.js';
7272
import type { CapsuleStore } from '../../storage/capsule_store/capsule_store.js';
@@ -326,19 +326,9 @@ describe('Private Execution test suite', () => {
326326
messageContextService.getMessageContextsByTxHash.mockResolvedValue([]);
327327
// Configure mock to actually perform sync_state calls (needed for nested call tests)
328328
contractSyncService.ensureContractSynced.mockImplementation(
329-
async (contractAddress, functionToInvokeAfterSync, utilityExecutor, anchorBlockHeader, jobId, scopes) => {
329+
async (contractAddress, functionToInvokeAfterSync, utilityExecutor, _anchorBlockHeader, _jobId, scopes) => {
330330
for (const scope of scopes) {
331-
await syncState(
332-
contractAddress,
333-
contractStore,
334-
functionToInvokeAfterSync,
335-
utilityExecutor,
336-
noteStore,
337-
aztecNode,
338-
anchorBlockHeader,
339-
jobId,
340-
scope,
341-
);
331+
await syncScope(contractAddress, contractStore, functionToInvokeAfterSync, utilityExecutor, scope);
342332
}
343333
},
344334
);

yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,61 @@ describe('ContractSyncService', () => {
272272
});
273273
});
274274

275+
describe('multi-scope sync batching', () => {
276+
it('batches nullifier sync across all unsynced scopes', async () => {
277+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [
278+
scopeA,
279+
scopeB,
280+
]);
281+
expect(noteStore.getNotes).toHaveBeenCalledTimes(1);
282+
expect(noteStore.getNotes).toHaveBeenCalledWith(
283+
expect.objectContaining({ contractAddress, scopes: [scopeA, scopeB] }),
284+
jobId,
285+
);
286+
});
287+
288+
it('only includes unsynced scopes in nullifier sync', async () => {
289+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]);
290+
expect(noteStore.getNotes).toHaveBeenCalledTimes(1);
291+
expect(noteStore.getNotes).toHaveBeenCalledWith(
292+
expect.objectContaining({ contractAddress, scopes: [scopeA] }),
293+
jobId,
294+
);
295+
296+
noteStore.getNotes.mockClear();
297+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [
298+
scopeA,
299+
scopeB,
300+
]);
301+
// scopeA is already cached, so nullifier sync only runs for scopeB
302+
expect(noteStore.getNotes).toHaveBeenCalledTimes(1);
303+
expect(noteStore.getNotes).toHaveBeenCalledWith(
304+
expect.objectContaining({ contractAddress, scopes: [scopeB] }),
305+
jobId,
306+
);
307+
});
308+
309+
it('re-runs nullifier sync after scope invalidation', async () => {
310+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [
311+
scopeA,
312+
scopeB,
313+
]);
314+
noteStore.getNotes.mockClear();
315+
316+
service.invalidateContractForScopes(contractAddress, [scopeA]);
317+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [
318+
scopeA,
319+
scopeB,
320+
]);
321+
// Only scopeA was invalidated, so nullifier sync runs for just scopeA
322+
expect(noteStore.getNotes).toHaveBeenCalledTimes(1);
323+
expect(noteStore.getNotes).toHaveBeenCalledWith(
324+
expect.objectContaining({ contractAddress, scopes: [scopeA] }),
325+
jobId,
326+
);
327+
});
328+
});
329+
275330
describe('invalidateContractForScopes', () => {
276331
const contract2 = AztecAddress.fromBigInt(300n);
277332

yarn-project/pxe/src/contract_sync/contract_sync_service.ts

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import type { Logger } from '@aztec/foundation/log';
22
import { Semaphore } from '@aztec/foundation/queue';
3+
import { isProtocolContract } from '@aztec/protocol-contracts';
34
import type { FunctionCall, FunctionSelector } from '@aztec/stdlib/abi';
45
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
56
import type { AztecNode } from '@aztec/stdlib/interfaces/client';
67
import type { BlockHeader } from '@aztec/stdlib/tx';
78

89
import type { StagedStore } from '../job_coordinator/job_coordinator.js';
10+
import { NoteService } from '../notes/note_service.js';
911
import type { ContractStore } from '../storage/contract_store/contract_store.js';
1012
import type { NoteStore } from '../storage/note_store/note_store.js';
11-
import { syncState, verifyCurrentClassId } from './helpers.js';
13+
import { syncScope, verifyCurrentClassId } from './helpers.js';
1214

1315
/** Maximum number of scope syncs running concurrently across the PXE. */
1416
const MAX_CONCURRENT_SCOPE_SYNCS = 5;
@@ -29,7 +31,7 @@ export class ContractSyncService implements StagedStore {
2931

3032
// Tracks class ID verification per contract. Keyed by contract address only (no scope), since
3133
// class ID verification is scope-independent. Cleared on wipe/discard.
32-
private verifiedClassIds: Map<string, Promise<void>> = new Map();
34+
private classIdVerificationCache: Map<string, Promise<void>> = new Map();
3335

3436
// Per-job excluded contract addresses - these contracts should not be synced.
3537
private excludedFromSync: Map<string, Set<string>> = new Map();
@@ -72,22 +74,8 @@ export class ContractSyncService implements StagedStore {
7274
return;
7375
}
7476

75-
this.#startSyncIfNeeded(
76-
contractAddress,
77-
scopes,
78-
() => verifyCurrentClassId(contractAddress, this.aztecNode, this.contractStore, anchorBlockHeader),
79-
scope =>
80-
syncState(
81-
contractAddress,
82-
this.contractStore,
83-
functionToInvokeAfterSync,
84-
utilityExecutor,
85-
this.noteStore,
86-
this.aztecNode,
87-
anchorBlockHeader,
88-
jobId,
89-
scope,
90-
),
77+
this.#startSyncIfNeeded(contractAddress, scopes, anchorBlockHeader, jobId, scope =>
78+
syncScope(contractAddress, this.contractStore, functionToInvokeAfterSync, utilityExecutor, scope),
9179
);
9280

9381
await this.#awaitSync(contractAddress, scopes);
@@ -105,7 +93,7 @@ export class ContractSyncService implements StagedStore {
10593
wipe(): void {
10694
this.log.debug(`Wiping contract sync cache (${this.syncedContracts.size} entries)`);
10795
this.syncedContracts.clear();
108-
this.verifiedClassIds.clear();
96+
this.classIdVerificationCache.clear();
10997
}
11098

11199
commit(jobId: string): Promise<void> {
@@ -118,7 +106,7 @@ export class ContractSyncService implements StagedStore {
118106
// We clear the synced contracts cache here because, when the job is discarded, any associated database writes from
119107
// the sync are also undone.
120108
this.syncedContracts.clear();
121-
this.verifiedClassIds.clear();
109+
this.classIdVerificationCache.clear();
122110
this.excludedFromSync.delete(jobId);
123111
return Promise.resolve();
124112
}
@@ -128,14 +116,16 @@ export class ContractSyncService implements StagedStore {
128116
}
129117

130118
/**
131-
* If there are unsynced scopes, starts one sync per scope (bounded by #syncSlot) and stores each promise in the
132-
* cache with per-scope error cleanup. The verifyFn runs once for the whole fan-out and is awaited by every new
133-
* scope's promise, matching the pre-parallelization invariant that a cache-miss batch re-verifies the class id.
119+
* For each unsynced scope, creates a promise that waits on:
120+
* 1. Class ID verification (cached per contract, scope-independent).
121+
* 2. Note nullifier sync (shared, batched across all unsynced scopes).
122+
* 3. Per-scope sync (individual, semaphore-bounded).
134123
*/
135124
#startSyncIfNeeded(
136125
contractAddress: AztecAddress,
137126
scopes: AztecAddress[],
138-
verifyFn: () => Promise<void>,
127+
anchorBlockHeader: BlockHeader,
128+
jobId: string,
139129
syncScopeFn: (scope: AztecAddress) => Promise<void>,
140130
): void {
141131
const scopesToSync = scopes.filter(scope => !this.syncedContracts.has(toKey(contractAddress, scope)));
@@ -144,11 +134,13 @@ export class ContractSyncService implements StagedStore {
144134
}
145135

146136
this.log.debug(`Syncing contract ${contractAddress} for ${scopesToSync.length} scope(s)`);
147-
const verifyPromise = this.#getOrStartVerification(contractAddress, verifyFn);
137+
138+
const verifyPromise = this.#verifyClassId(contractAddress, anchorBlockHeader);
139+
const syncNullifiersPromise = this.#syncNoteNullifiers(contractAddress, anchorBlockHeader, jobId, scopesToSync);
148140

149141
for (const scope of scopesToSync) {
150142
const key = toKey(contractAddress, scope);
151-
const promise = Promise.all([verifyPromise, this.#runBounded(() => syncScopeFn(scope))])
143+
const promise = Promise.all([verifyPromise, syncNullifiersPromise, this.#runBounded(() => syncScopeFn(scope))])
152144
.then(() => {})
153145
.catch(err => {
154146
this.syncedContracts.delete(key);
@@ -158,21 +150,40 @@ export class ContractSyncService implements StagedStore {
158150
}
159151
}
160152

161-
/** Returns the cached verification promise for a contract, starting a new one if needed. Evicts from cache on failure so retries re-verify. */
162-
#getOrStartVerification(contractAddress: AztecAddress, verifyFn: () => Promise<void>): Promise<void> {
153+
/** Verifies the local class ID matches the on-chain value (cached, evicts on failure so retries re-verify). */
154+
#verifyClassId(contractAddress: AztecAddress, anchorBlockHeader: BlockHeader): Promise<void> {
163155
const contractKey = contractAddress.toString();
164-
const cached = this.verifiedClassIds.get(contractKey);
156+
const cached = this.classIdVerificationCache.get(contractKey);
165157
if (cached) {
166158
return cached;
167159
}
168-
const promise = verifyFn().catch(err => {
169-
this.verifiedClassIds.delete(contractKey);
170-
throw err;
171-
});
172-
this.verifiedClassIds.set(contractKey, promise);
160+
const promise = verifyCurrentClassId(contractAddress, this.aztecNode, this.contractStore, anchorBlockHeader).catch(
161+
err => {
162+
this.classIdVerificationCache.delete(contractKey);
163+
throw err;
164+
},
165+
);
166+
this.classIdVerificationCache.set(contractKey, promise);
173167
return promise;
174168
}
175169

170+
/** Syncs note nullifiers across all unsynced scopes in a single batched call. */
171+
async #syncNoteNullifiers(
172+
contractAddress: AztecAddress,
173+
anchorBlockHeader: BlockHeader,
174+
jobId: string,
175+
scopes: AztecAddress[],
176+
): Promise<void> {
177+
// Protocol contracts don't have private state to sync
178+
if (isProtocolContract(contractAddress)) {
179+
return;
180+
}
181+
// This runs in parallel with per-scope sync (which also writes to the note store). That's safe because
182+
// the note store handles concurrent operations.
183+
const noteService = new NoteService(this.noteStore, this.aztecNode, anchorBlockHeader, jobId);
184+
await noteService.syncNoteNullifiers(contractAddress, scopes);
185+
}
186+
176187
/** Runs fn while holding a slot in #syncSlot, bounding total concurrent scope syncs. */
177188
async #runBounded<T>(fn: () => Promise<T>): Promise<T> {
178189
await this.#syncSlot.acquire();

yarn-project/pxe/src/contract_sync/helpers.ts

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ import { DelayedPublicMutableValues, DelayedPublicMutableValuesWithHash } from '
66
import type { AztecNode } from '@aztec/stdlib/interfaces/client';
77
import type { BlockHeader } from '@aztec/stdlib/tx';
88

9-
import { NoteService } from '../notes/note_service.js';
109
import type { ContractStore } from '../storage/contract_store/contract_store.js';
11-
import type { NoteStore } from '../storage/note_store/note_store.js';
1210

1311
/**
1412
* Read the current class id of a contract from the execution data provider or AztecNode. If not found, class id
@@ -38,36 +36,26 @@ export async function readCurrentClassId(
3836
return currentClassId;
3937
}
4038

41-
export async function syncState(
39+
export async function syncScope(
4240
contractAddress: AztecAddress,
4341
contractStore: ContractStore,
4442
functionToInvokeAfterSync: FunctionSelector | null,
4543
utilityExecutor: (privateSyncCall: FunctionCall, scopes: AztecAddress[]) => Promise<any>,
46-
noteStore: NoteStore,
47-
aztecNode: AztecNode,
48-
anchorBlockHeader: BlockHeader,
49-
jobId: string,
5044
scope: AztecAddress,
5145
) {
5246
// Protocol contracts don't have private state to sync
53-
if (!isProtocolContract(contractAddress)) {
54-
const syncStateFunctionCall = await contractStore.getFunctionCall('sync_state', [scope], contractAddress);
55-
if (functionToInvokeAfterSync && functionToInvokeAfterSync.equals(syncStateFunctionCall.selector)) {
56-
throw new Error(
57-
'Forbidden `sync_state` invocation. `sync_state` can only be invoked by PXE, manual execution can lead to inconsistencies.',
58-
);
59-
}
60-
61-
const noteService = new NoteService(noteStore, aztecNode, anchorBlockHeader, jobId);
62-
const scopes: AztecAddress[] = [scope];
47+
if (isProtocolContract(contractAddress)) {
48+
return;
49+
}
6350

64-
// Both sync_state and syncNoteNullifiers interact with the note store, but running them in parallel is safe
65-
// because note store is designed to handle concurrent operations.
66-
await Promise.all([
67-
utilityExecutor(syncStateFunctionCall, scopes),
68-
noteService.syncNoteNullifiers(contractAddress, scopes),
69-
]);
51+
const syncStateFunctionCall = await contractStore.getFunctionCall('sync_state', [scope], contractAddress);
52+
if (functionToInvokeAfterSync && functionToInvokeAfterSync.equals(syncStateFunctionCall.selector)) {
53+
throw new Error(
54+
'Forbidden `sync_state` invocation. `sync_state` can only be invoked by PXE, manual execution can lead to inconsistencies.',
55+
);
7056
}
57+
58+
await utilityExecutor(syncStateFunctionCall, [scope]);
7159
}
7260

7361
/**

0 commit comments

Comments
 (0)