@@ -4,18 +4,24 @@ import { Fr } from '@aztec/foundation/curves/bn254';
44import type { AztecAsyncKVStore } from '@aztec/kv-store' ;
55import { openTmpStore } from '@aztec/kv-store/lmdb-v2' ;
66import { L2TipsKVStore } from '@aztec/kv-store/stores' ;
7+ import { EventSelector } from '@aztec/stdlib/abi' ;
8+ import { AztecAddress } from '@aztec/stdlib/aztec-address' ;
79import {
810 type BlockData ,
911 BlockHash ,
1012 GENESIS_BLOCK_HEADER_HASH ,
1113 GENESIS_CHECKPOINT_HEADER_HASH ,
1214 L2Block ,
15+ type L2BlockId ,
1316 type L2BlockStream ,
17+ makeL2BlockId ,
18+ makeL2CheckpointId ,
1419} from '@aztec/stdlib/block' ;
1520import { Checkpoint , L1PublishedData , PublishedCheckpoint } from '@aztec/stdlib/checkpoint' ;
16- import type { AztecNode } from '@aztec/stdlib/interfaces/client' ;
21+ import type { AztecNode , BlockResponse } from '@aztec/stdlib/interfaces/client' ;
22+ import { NoteDao , NoteStatus } from '@aztec/stdlib/note' ;
23+ import { TxHash } from '@aztec/stdlib/tx' ;
1724
18- import { jest } from '@jest/globals' ;
1925import { type MockProxy , mock } from 'jest-mock-extended' ;
2026
2127import type { BlockSynchronizerConfig } from '../config/index.js' ;
@@ -25,6 +31,12 @@ import { NoteStore } from '../storage/note_store/note_store.js';
2531import { PrivateEventStore } from '../storage/private_event_store/private_event_store.js' ;
2632import { BlockSynchronizer } from './block_synchronizer.js' ;
2733
34+ // `AztecNode.getBlock` is generic over its include-options; `Parameters`/`ReturnType` collapse that
35+ // type parameter to its `BlockIncludeOptions` constraint, yielding a concrete signature the mock can satisfy.
36+ type NodeGetBlockMock = jest . MockedFunction <
37+ ( ...args : Parameters < AztecNode [ 'getBlock' ] > ) => ReturnType < AztecNode [ 'getBlock' ] >
38+ > ;
39+
2840describe ( 'BlockSynchronizer' , ( ) => {
2941 let synchronizer : BlockSynchronizer ;
3042 let store : AztecAsyncKVStore ;
@@ -33,6 +45,7 @@ describe('BlockSynchronizer', () => {
3345 let noteStore : NoteStore ;
3446 let privateEventStore : PrivateEventStore ;
3547 let aztecNode : MockProxy < AztecNode > ;
48+ let getBlock : NodeGetBlockMock ;
3649 let blockStream : MockProxy < L2BlockStream > ;
3750 let contractSyncService : MockProxy < ContractSyncService > ;
3851
@@ -55,10 +68,44 @@ describe('BlockSynchronizer', () => {
5568 ) ;
5669 } ;
5770
71+ // Builds the BlockResponse the node returns for a block (handlers read its header to update the anchor).
72+ const blockResponse = async ( block : L2Block ) : Promise < BlockResponse > => ( {
73+ header : block . header ,
74+ archive : block . archive ,
75+ hash : await block . hash ( ) ,
76+ checkpointNumber : block . checkpointNumber ,
77+ indexWithinCheckpoint : block . indexWithinCheckpoint ,
78+ number : block . number ,
79+ } ) ;
80+
81+ // Builds a note DAO anchored to the given block id (caller adds it to the store and commits).
82+ const noteAt = ( contract : AztecAddress , block : L2BlockId ) : Promise < NoteDao > =>
83+ NoteDao . random ( { contractAddress : contract , l2BlockNumber : block . number , l2BlockHash : block . hash } ) ;
84+
85+ // Stores one private event anchored to the given block id under the 'event-job' (caller commits).
86+ const storeEvent = ( contract : AztecAddress , scope : AztecAddress , eventId : Fr , block : L2BlockId ) =>
87+ privateEventStore . storePrivateEventLog (
88+ EventSelector . random ( ) ,
89+ Fr . random ( ) ,
90+ [ Fr . random ( ) ] ,
91+ eventId ,
92+ {
93+ contractAddress : contract ,
94+ scope,
95+ txHash : TxHash . random ( ) ,
96+ l2BlockNumber : block . number ,
97+ l2BlockHash : BlockHash . fromString ( block . hash ) ,
98+ txIndexInBlock : 0 ,
99+ eventIndexInTx : 0 ,
100+ } ,
101+ 'event-job' ,
102+ ) ;
103+
58104 beforeEach ( async ( ) => {
59105 store = await openTmpStore ( 'test' ) ;
60106 blockStream = mock < L2BlockStream > ( ) ;
61107 aztecNode = mock < AztecNode > ( ) ;
108+ getBlock = aztecNode . getBlock as NodeGetBlockMock ;
62109 tipsStore = new L2TipsKVStore ( store , 'pxe' , GENESIS_BLOCK_HEADER_HASH ) ;
63110 anchorBlockStore = new AnchorBlockStore ( store ) ;
64111 noteStore = new NoteStore ( store ) ;
@@ -75,66 +122,26 @@ describe('BlockSynchronizer', () => {
75122 expect ( obtainedHeader . equals ( block . header ) ) . toBe ( true ) ;
76123 } ) ;
77124
78- it ( 'removes notes from db on a reorg' , async ( ) => {
79- const rollback = jest . spyOn ( noteStore , 'rollback' ) . mockImplementation ( ( ) => Promise . resolve ( ) ) ;
80- const block3Hash = Fr . fromString ( '0x3' ) ;
81- aztecNode . getBlock . mockImplementation ( async ( block : any ) => {
82- if ( block instanceof BlockHash && block . equals ( block3Hash ) ) {
83- const b = await L2Block . random ( BlockNumber ( 3 ) ) ;
84- return {
85- header : b . header ,
86- archive : b . archive ,
87- hash : await b . hash ( ) ,
88- checkpointNumber : b . checkpointNumber ,
89- indexWithinCheckpoint : b . indexWithinCheckpoint ,
90- number : b . number ,
91- } as any ;
92- }
93- return undefined ;
94- } ) ;
95-
96- await synchronizer . handleBlockStreamEvent ( {
97- type : 'blocks-added' ,
98- blocks : await timesParallel ( 5 , i => L2Block . random ( BlockNumber ( i ) ) ) ,
99- } ) ;
100- await synchronizer . handleBlockStreamEvent ( {
101- type : 'chain-pruned' ,
102- block : { number : BlockNumber ( 3 ) , hash : block3Hash . toString ( ) } ,
103- checkpoint : { number : CheckpointNumber . ZERO , hash : GENESIS_CHECKPOINT_HEADER_HASH . toString ( ) } ,
104- } ) ;
105-
106- expect ( rollback ) . toHaveBeenCalledWith ( 3 , 4 ) ;
107- } ) ;
108-
109- it ( 'removes private events from db on a reorg' , async ( ) => {
110- const rollback = jest . spyOn ( privateEventStore , 'rollback' ) . mockImplementation ( ( ) => Promise . resolve ( ) ) ;
111- const block3Hash = Fr . fromString ( '0x3' ) ;
112- aztecNode . getBlock . mockImplementation ( async ( block : any ) => {
113- if ( block instanceof BlockHash && block . equals ( block3Hash ) ) {
114- const b = await L2Block . random ( BlockNumber ( 3 ) ) ;
115- return {
116- header : b . header ,
117- archive : b . archive ,
118- hash : await b . hash ( ) ,
119- checkpointNumber : b . checkpointNumber ,
120- indexWithinCheckpoint : b . indexWithinCheckpoint ,
121- number : b . number ,
122- } as any ;
123- }
124- return undefined ;
125- } ) ;
125+ it ( 'updates anchor block on a reorg' , async ( ) => {
126+ const reorgBlock = await L2Block . random ( BlockNumber ( 3 ) ) ;
127+ const reorgResponse = await blockResponse ( reorgBlock ) ;
128+ getBlock . mockImplementation ( param =>
129+ Promise . resolve ( param instanceof BlockHash && param . equals ( reorgResponse . hash ) ? reorgResponse : undefined ) ,
130+ ) ;
126131
127132 await synchronizer . handleBlockStreamEvent ( {
128133 type : 'blocks-added' ,
129134 blocks : await timesParallel ( 5 , i => L2Block . random ( BlockNumber ( i ) ) ) ,
130135 } ) ;
131136 await synchronizer . handleBlockStreamEvent ( {
132137 type : 'chain-pruned' ,
133- block : { number : BlockNumber ( 3 ) , hash : block3Hash . toString ( ) } ,
134- checkpoint : { number : CheckpointNumber . ZERO , hash : GENESIS_CHECKPOINT_HEADER_HASH . toString ( ) } ,
138+ block : makeL2BlockId ( reorgBlock . number , reorgResponse . hash . toString ( ) ) ,
139+ checkpoint : makeL2CheckpointId ( CheckpointNumber . ZERO , GENESIS_CHECKPOINT_HEADER_HASH . toString ( ) ) ,
135140 } ) ;
136141
137- expect ( rollback ) . toHaveBeenCalledWith ( 3 , 4 ) ;
142+ // The anchor block should be updated to the reorg block header.
143+ const obtainedHeader = await anchorBlockStore . getBlockHeader ( ) ;
144+ expect ( obtainedHeader . equals ( reorgBlock . header ) ) . toBe ( true ) ;
138145 } ) ;
139146
140147 describe ( 'stop' , ( ) => {
@@ -182,6 +189,139 @@ describe('BlockSynchronizer', () => {
182189 } ) ;
183190 } ) ;
184191
192+ describe ( 'delete-on-prune' , ( ) => {
193+ it ( 'chain-pruned deletes rows anchored above the fork and keeps rows at or below it' , async ( ) => {
194+ const contract = await AztecAddress . random ( ) ;
195+ const scope = await AztecAddress . random ( ) ;
196+
197+ // Block 3 is the fork point (a real block the node still serves); 4 and 5 are on the abandoned fork.
198+ const forkBlock = await L2Block . random ( BlockNumber ( 3 ) ) ;
199+ const block3 = makeL2BlockId ( forkBlock . number , ( await forkBlock . hash ( ) ) . toString ( ) ) ;
200+ const block4 = makeL2BlockId ( BlockNumber ( 4 ) , Fr . random ( ) . toString ( ) ) ;
201+ const block5 = makeL2BlockId ( BlockNumber ( 5 ) , Fr . random ( ) . toString ( ) ) ;
202+
203+ // Seed a note at each block, anchored to that block's id.
204+ const noteAt3 = await noteAt ( contract , block3 ) ;
205+ const noteAt4 = await noteAt ( contract , block4 ) ;
206+ const noteAt5 = await noteAt ( contract , block5 ) ;
207+ await noteStore . addNotes ( [ noteAt3 , noteAt4 , noteAt5 ] , scope , 'note-job' ) ;
208+ await noteStore . commit ( 'note-job' ) ;
209+
210+ // Seed an event at each block.
211+ const eventIdAt3 = Fr . random ( ) ;
212+ const eventIdAt4 = Fr . random ( ) ;
213+ const eventIdAt5 = Fr . random ( ) ;
214+ await storeEvent ( contract , scope , eventIdAt3 , block3 ) ;
215+ await storeEvent ( contract , scope , eventIdAt4 , block4 ) ;
216+ await storeEvent ( contract , scope , eventIdAt5 , block5 ) ;
217+ await privateEventStore . commit ( 'event-job' ) ;
218+
219+ // Set the anchor to block 5 so the prune guard passes.
220+ const anchorBlock5 = await L2Block . random ( BlockNumber ( 5 ) ) ;
221+ await anchorBlockStore . setHeader ( anchorBlock5 . header ) ;
222+
223+ // The node serves the fork-point block; it becomes the new anchor after the prune.
224+ const forkResponse = await blockResponse ( forkBlock ) ;
225+ getBlock . mockImplementation ( param =>
226+ Promise . resolve ( param instanceof BlockHash && param . equals ( forkResponse . hash ) ? forkResponse : undefined ) ,
227+ ) ;
228+
229+ // Prune back to block 3 (orphaning blocks 4 and 5).
230+ await synchronizer . handleBlockStreamEvent ( {
231+ type : 'chain-pruned' ,
232+ block : block3 ,
233+ checkpoint : makeL2CheckpointId ( CheckpointNumber . ZERO , GENESIS_CHECKPOINT_HEADER_HASH . toString ( ) ) ,
234+ } ) ;
235+
236+ // Rows at blocks 4 and 5 must be gone.
237+ expect ( await noteStore . nullifiersOfNotesAtBlock ( 4 ) ) . toHaveLength ( 0 ) ;
238+ expect ( await noteStore . nullifiersOfNotesAtBlock ( 5 ) ) . toHaveLength ( 0 ) ;
239+ expect ( await privateEventStore . eventIdsAtBlock ( 4 ) ) . toHaveLength ( 0 ) ;
240+ expect ( await privateEventStore . eventIdsAtBlock ( 5 ) ) . toHaveLength ( 0 ) ;
241+
242+ // Rows at block 3 (the fork point, not an orphan) must survive.
243+ expect ( await noteStore . nullifiersOfNotesAtBlock ( 3 ) ) . toEqual ( [ noteAt3 . siloedNullifier . toString ( ) ] ) ;
244+ expect ( await privateEventStore . eventIdsAtBlock ( 3 ) ) . toEqual ( [ eventIdAt3 . toString ( ) ] ) ;
245+ } ) ;
246+
247+ it ( 'chain-finalized does not delete any rows' , async ( ) => {
248+ const contract = await AztecAddress . random ( ) ;
249+ const scope = await AztecAddress . random ( ) ;
250+
251+ // Canonical rows at two heights: one below the finalized block, one at it.
252+ const block8 = makeL2BlockId ( BlockNumber ( 8 ) , Fr . random ( ) . toString ( ) ) ;
253+ const block9 = makeL2BlockId ( BlockNumber ( 9 ) , Fr . random ( ) . toString ( ) ) ;
254+ const note8 = await noteAt ( contract , block8 ) ;
255+ const note9 = await noteAt ( contract , block9 ) ;
256+ await noteStore . addNotes ( [ note8 , note9 ] , scope , 'note-job' ) ;
257+ await noteStore . commit ( 'note-job' ) ;
258+
259+ const eventId8 = Fr . random ( ) ;
260+ const eventId9 = Fr . random ( ) ;
261+ await storeEvent ( contract , scope , eventId8 , block8 ) ;
262+ await storeEvent ( contract , scope , eventId9 , block9 ) ;
263+ await privateEventStore . commit ( 'event-job' ) ;
264+
265+ await synchronizer . handleBlockStreamEvent ( {
266+ type : 'chain-finalized' ,
267+ block : block9 ,
268+ } ) ;
269+
270+ // Finalization is a no-op for storage under delete-on-prune, every row at and below the tip survives.
271+ expect ( await noteStore . nullifiersOfNotesAtBlock ( 8 ) ) . toEqual ( [ note8 . siloedNullifier . toString ( ) ] ) ;
272+ expect ( await noteStore . nullifiersOfNotesAtBlock ( 9 ) ) . toEqual ( [ note9 . siloedNullifier . toString ( ) ] ) ;
273+ expect ( await privateEventStore . eventIdsAtBlock ( 8 ) ) . toEqual ( [ eventId8 . toString ( ) ] ) ;
274+ expect ( await privateEventStore . eventIdsAtBlock ( 9 ) ) . toEqual ( [ eventId9 . toString ( ) ] ) ;
275+ } ) ;
276+
277+ it ( 'notes below the fork survive and remain queryable after a prune' , async ( ) => {
278+ const contract = await AztecAddress . random ( ) ;
279+ const scope = await AztecAddress . random ( ) ;
280+
281+ // Block 1 is the fork point (a real block the node still serves); 2 and 3 are on the abandoned fork.
282+ const forkBlock = await L2Block . random ( BlockNumber ( 1 ) ) ;
283+ const block1 = makeL2BlockId ( forkBlock . number , ( await forkBlock . hash ( ) ) . toString ( ) ) ;
284+ const block2 = makeL2BlockId ( BlockNumber ( 2 ) , Fr . random ( ) . toString ( ) ) ;
285+ const block3 = makeL2BlockId ( BlockNumber ( 3 ) , Fr . random ( ) . toString ( ) ) ;
286+
287+ const noteAt1 = await noteAt ( contract , block1 ) ;
288+ const noteAt2 = await noteAt ( contract , block2 ) ;
289+ const noteAt3 = await noteAt ( contract , block3 ) ;
290+ await noteStore . addNotes ( [ noteAt1 , noteAt2 , noteAt3 ] , scope , 'note-job' ) ;
291+ await noteStore . commit ( 'note-job' ) ;
292+
293+ // Anchor at block 3.
294+ const anchorBlock3 = await L2Block . random ( BlockNumber ( 3 ) ) ;
295+ await anchorBlockStore . setHeader ( anchorBlock3 . header ) ;
296+
297+ // The node serves the fork-point block; it becomes the new anchor after the prune.
298+ const forkResponse = await blockResponse ( forkBlock ) ;
299+ getBlock . mockImplementation ( param =>
300+ Promise . resolve ( param instanceof BlockHash && param . equals ( forkResponse . hash ) ? forkResponse : undefined ) ,
301+ ) ;
302+
303+ // Prune back to block 1 (orphaning blocks 2 and 3).
304+ await synchronizer . handleBlockStreamEvent ( {
305+ type : 'chain-pruned' ,
306+ block : block1 ,
307+ checkpoint : makeL2CheckpointId ( CheckpointNumber . ZERO , GENESIS_CHECKPOINT_HEADER_HASH . toString ( ) ) ,
308+ } ) ;
309+
310+ // Blocks 2 and 3 deleted.
311+ expect ( await noteStore . nullifiersOfNotesAtBlock ( 2 ) ) . toHaveLength ( 0 ) ;
312+ expect ( await noteStore . nullifiersOfNotesAtBlock ( 3 ) ) . toHaveLength ( 0 ) ;
313+
314+ // Block 1 note still present and visible via getNotes.
315+ expect ( await noteStore . nullifiersOfNotesAtBlock ( 1 ) ) . toEqual ( [ noteAt1 . siloedNullifier . toString ( ) ] ) ;
316+ const found = await noteStore . getNotes (
317+ { contractAddress : contract , scopes : [ scope ] , status : NoteStatus . ACTIVE } ,
318+ 'read-job' ,
319+ ) ;
320+ expect ( found ) . toHaveLength ( 1 ) ;
321+ expect ( found [ 0 ] . siloedNullifier . equals ( noteAt1 . siloedNullifier ) ) . toBe ( true ) ;
322+ } ) ;
323+ } ) ;
324+
185325 describe ( 'syncChainTip config' , ( ) => {
186326 it ( 'updates anchor on blocks-added when syncChainTip is proposed (default)' , async ( ) => {
187327 synchronizer = createSynchronizer ( { syncChainTip : 'proposed' } ) ;
@@ -263,7 +403,7 @@ describe('BlockSynchronizer', () => {
263403
264404 // Mock node to return block
265405 const provenBlock = await L2Block . random ( BlockNumber ( 5 ) ) ;
266- aztecNode . getBlock . mockResolvedValue ( { header : provenBlock . header } as any ) ;
406+ getBlock . mockResolvedValue ( await blockResponse ( provenBlock ) ) ;
267407
268408 await synchronizer . handleBlockStreamEvent ( {
269409 type : 'chain-proven' ,
@@ -283,7 +423,7 @@ describe('BlockSynchronizer', () => {
283423
284424 // Mock node to return block
285425 const finalizedBlock = await L2Block . random ( BlockNumber ( 10 ) ) ;
286- aztecNode . getBlock . mockResolvedValue ( { header : finalizedBlock . header } as any ) ;
426+ getBlock . mockResolvedValue ( await blockResponse ( finalizedBlock ) ) ;
287427
288428 await synchronizer . handleBlockStreamEvent ( {
289429 type : 'chain-finalized' ,
@@ -301,17 +441,13 @@ describe('BlockSynchronizer', () => {
301441 const anchorBlock = await L2Block . random ( BlockNumber ( 2 ) ) ;
302442 await anchorBlockStore . setHeader ( anchorBlock . header ) ;
303443
304- const rollback = jest . spyOn ( noteStore , 'rollback' ) . mockImplementation ( ( ) => Promise . resolve ( ) ) ;
305-
306444 // Prune to block 3 (above anchor) - should be ignored
307445 await synchronizer . handleBlockStreamEvent ( {
308446 type : 'chain-pruned' ,
309447 block : { number : BlockNumber ( 3 ) , hash : '0x3' } ,
310- checkpoint : { number : CheckpointNumber . ZERO , hash : GENESIS_CHECKPOINT_HEADER_HASH . toString ( ) } ,
448+ checkpoint : makeL2CheckpointId ( CheckpointNumber . ZERO , GENESIS_CHECKPOINT_HEADER_HASH . toString ( ) ) ,
311449 } ) ;
312450
313- expect ( rollback ) . not . toHaveBeenCalled ( ) ;
314-
315451 // Anchor should be unchanged
316452 const obtainedHeader = await anchorBlockStore . getBlockHeader ( ) ;
317453 expect ( obtainedHeader . equals ( anchorBlock . header ) ) . toBe ( true ) ;
0 commit comments