Skip to content

Commit 381d7bb

Browse files
nchamoaztec-bot
authored andcommitted
feat(pxe): deduplicate class ID verification per contract (#22966)
1 parent 6a9a9f7 commit 381d7bb

2 files changed

Lines changed: 77 additions & 1 deletion

File tree

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
@@ -225,6 +225,53 @@ describe('ContractSyncService', () => {
225225
});
226226
});
227227

228+
describe('class ID verification deduplication', () => {
229+
const contract2 = AztecAddress.fromBigInt(300n);
230+
231+
it('verifies class ID only once per contract across scope batches', async () => {
232+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]);
233+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeB]);
234+
expectVerifiedContracts(contractAddress);
235+
});
236+
237+
it('verifies class ID separately for different contracts', async () => {
238+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]);
239+
await service.ensureContractSynced(contract2, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]);
240+
expectVerifiedContracts(contractAddress, contract2);
241+
});
242+
243+
it('re-verifies class ID after wipe', async () => {
244+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]);
245+
service.wipe();
246+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeB]);
247+
expectVerifiedContracts(contractAddress, contractAddress);
248+
});
249+
250+
it('re-verifies class ID after discardStaged', async () => {
251+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]);
252+
await service.discardStaged(jobId);
253+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]);
254+
expectVerifiedContracts(contractAddress, contractAddress);
255+
});
256+
257+
it('re-verifies class ID after verification failure', async () => {
258+
contractStore.getContractInstance.mockRejectedValueOnce(new Error('node unavailable'));
259+
await expect(
260+
service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]),
261+
).rejects.toThrow('node unavailable');
262+
263+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]);
264+
expectVerifiedContracts(contractAddress, contractAddress);
265+
});
266+
267+
it('does not re-verify class ID when only scope cache is invalidated', async () => {
268+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]);
269+
service.invalidateContractForScopes(contractAddress, [scopeA]);
270+
await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]);
271+
expectVerifiedContracts(contractAddress);
272+
});
273+
});
274+
228275
describe('invalidateContractForScopes', () => {
229276
const contract2 = AztecAddress.fromBigInt(300n);
230277

@@ -337,5 +384,13 @@ describe('ContractSyncService', () => {
337384
}
338385
};
339386

387+
/** Asserts that class ID verification was triggered for each contract address in the given sequence. */
388+
const expectVerifiedContracts = (...addresses: AztecAddress[]) => {
389+
expect(contractStore.getContractInstance).toHaveBeenCalledTimes(addresses.length);
390+
for (let i = 0; i < addresses.length; i++) {
391+
expect(contractStore.getContractInstance).toHaveBeenNthCalledWith(i + 1, addresses[i]);
392+
}
393+
};
394+
340395
const expectNoSync = () => expect(utilityExecutor).not.toHaveBeenCalled();
341396
});

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export class ContractSyncService implements StagedStore {
2727
// The value is a promise that resolves when the contract is synced.
2828
private syncedContracts: Map<string, Promise<void>> = new Map();
2929

30+
// Tracks class ID verification per contract. Keyed by contract address only (no scope), since
31+
// class ID verification is scope-independent. Cleared on wipe/discard.
32+
private verifiedClassIds: Map<string, Promise<void>> = new Map();
33+
3034
// Per-job excluded contract addresses - these contracts should not be synced.
3135
private excludedFromSync: Map<string, Set<string>> = new Map();
3236

@@ -101,6 +105,7 @@ export class ContractSyncService implements StagedStore {
101105
wipe(): void {
102106
this.log.debug(`Wiping contract sync cache (${this.syncedContracts.size} entries)`);
103107
this.syncedContracts.clear();
108+
this.verifiedClassIds.clear();
104109
}
105110

106111
commit(jobId: string): Promise<void> {
@@ -113,6 +118,7 @@ export class ContractSyncService implements StagedStore {
113118
// We clear the synced contracts cache here because, when the job is discarded, any associated database writes from
114119
// the sync are also undone.
115120
this.syncedContracts.clear();
121+
this.verifiedClassIds.clear();
116122
this.excludedFromSync.delete(jobId);
117123
return Promise.resolve();
118124
}
@@ -138,7 +144,7 @@ export class ContractSyncService implements StagedStore {
138144
}
139145

140146
this.log.debug(`Syncing contract ${contractAddress} for ${scopesToSync.length} scope(s)`);
141-
const verifyPromise = verifyFn();
147+
const verifyPromise = this.#getOrStartVerification(contractAddress, verifyFn);
142148

143149
for (const scope of scopesToSync) {
144150
const key = toKey(contractAddress, scope);
@@ -152,6 +158,21 @@ export class ContractSyncService implements StagedStore {
152158
}
153159
}
154160

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> {
163+
const contractKey = contractAddress.toString();
164+
const cached = this.verifiedClassIds.get(contractKey);
165+
if (cached) {
166+
return cached;
167+
}
168+
const promise = verifyFn().catch(err => {
169+
this.verifiedClassIds.delete(contractKey);
170+
throw err;
171+
});
172+
this.verifiedClassIds.set(contractKey, promise);
173+
return promise;
174+
}
175+
155176
/** Runs fn while holding a slot in #syncSlot, bounding total concurrent scope syncs. */
156177
async #runBounded<T>(fn: () => Promise<T>): Promise<T> {
157178
await this.#syncSlot.acquire();

0 commit comments

Comments
 (0)