11import type { Logger } from '@aztec/foundation/log' ;
22import { Semaphore } from '@aztec/foundation/queue' ;
3+ import { isProtocolContract } from '@aztec/protocol-contracts' ;
34import type { FunctionCall , FunctionSelector } from '@aztec/stdlib/abi' ;
45import type { AztecAddress } from '@aztec/stdlib/aztec-address' ;
56import type { AztecNode } from '@aztec/stdlib/interfaces/client' ;
67import type { BlockHeader } from '@aztec/stdlib/tx' ;
78
89import type { StagedStore } from '../job_coordinator/job_coordinator.js' ;
10+ import { NoteService } from '../notes/note_service.js' ;
911import type { ContractStore } from '../storage/contract_store/contract_store.js' ;
1012import 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. */
1416const 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 ( ) ;
0 commit comments