@@ -92,6 +92,9 @@ describe('Archiver Sync', () => {
9292 // Create epoch cache mock (separate from fake)
9393 epochCache = mock < EpochCache > ( ) ;
9494 epochCache . getCommitteeForEpoch . mockResolvedValue ( { committee : [ ] as EthAddress [ ] } as EpochCommitteeInfo ) ;
95+ // Default to no pipelining offset; the orphan-prune tests below override this. Keeps the prune
96+ // deadline well ahead of wall-clock time for the other tests so it never fires spuriously.
97+ epochCache . pipeliningOffset . mockReturnValue ( 0 ) ;
9598
9699 // Create instrumentation mock
97100 const tracer = getTelemetryClient ( ) . getTracer ( '' ) ;
@@ -118,6 +121,7 @@ describe('Archiver Sync', () => {
118121 maxAllowedEthClientDriftSeconds : 300 ,
119122 ethereumAllowNoDebugHosts : true ,
120123 skipHistoricalLogsCheck : true ,
124+ orphanProposedBlockPruneGraceSeconds : 2 ,
121125 } ;
122126
123127 // Create event emitter shared by archiver and synchronizer
@@ -2143,4 +2147,124 @@ describe('Archiver Sync', () => {
21432147 expect ( tips . proposedCheckpoint . block . number ) . toEqual ( tips . checkpointed . block . number ) ;
21442148 } , 15_000 ) ;
21452149 } ) ;
2150+
2151+ describe ( 'pruning orphan proposed blocks' , ( ) => {
2152+ let pruneSpy : jest . Mock ;
2153+
2154+ // Slot the orphan block targets. With slotDuration=24, slot S starts at l1GenesisTime + S*24.
2155+ const orphanSlot = SlotNumber ( 1 ) ;
2156+ // Grace period configured for these tests (see the `config` object above).
2157+ const graceSeconds = 2 ;
2158+
2159+ beforeEach ( ( ) => {
2160+ pruneSpy = jest . fn ( ) ;
2161+ archiver . events . on ( L2BlockSourceEvents . L2PruneUncheckpointed , pruneSpy ) ;
2162+ // Normal proposer pipelining: a block targeting slot S is built during slot S-1, so its proposed
2163+ // checkpoint is expected by the start of slot S.
2164+ epochCache . pipeliningOffset . mockReturnValue ( 1 ) ;
2165+ } ) ;
2166+
2167+ afterEach ( ( ) => {
2168+ archiver . events . off ( L2BlockSourceEvents . L2PruneUncheckpointed , pruneSpy ) ;
2169+ } ) ;
2170+
2171+ // Wall-clock time (seconds) at which the orphan tip becomes prunable: start(orphanSlot) + grace.
2172+ const pruneDeadline = ( ) => now + Number ( orphanSlot ) * l1Constants . slotDuration + graceSeconds ;
2173+
2174+ // Syncs checkpoint 1 (slot 0), then writes uncheckpointed blocks for slot 1 (checkpoint 2) straight
2175+ // into the store as a block-only tip with no matching proposed checkpoint. L1 is held at slot 1 so
2176+ // the L1-sync prune (which only fires once the build slot has ended on L1) stays out of the way.
2177+ const setupOrphanTip = async ( ) => {
2178+ const { checkpoint : cp1 } = await fake . addCheckpoint ( CheckpointNumber ( 1 ) , {
2179+ l1BlockNumber : 1n ,
2180+ messagesL1BlockNumber : 1n ,
2181+ numL1ToL2Messages : 3 ,
2182+ slotNumber : SlotNumber ( 0 ) ,
2183+ } ) ;
2184+ const cp1Archive = cp1 . blocks . at ( - 1 ) ! . archive ;
2185+ fake . setL1BlockNumber ( 1n ) ;
2186+ await archiver . syncImmediate ( ) ;
2187+ expect ( await archiver . getCheckpointNumber ( ) ) . toEqual ( CheckpointNumber ( 1 ) ) ;
2188+
2189+ const lastBlockInCp1 = cp1 . blocks . at ( - 1 ) ! . number ;
2190+ const provisionalBlocks = await fake . makeBlocks ( CheckpointNumber ( 2 ) , {
2191+ l1BlockNumber : 2n ,
2192+ previousArchive : cp1Archive ,
2193+ slotNumber : orphanSlot ,
2194+ } ) ;
2195+ for ( const block of provisionalBlocks ) {
2196+ await archiverStore . blocks . addProposedBlock ( block , { force : true } ) ;
2197+ }
2198+
2199+ // Hold L1 at slot 1 so the slot has not ended from L1's perspective.
2200+ fake . setL1BlockNumber ( 2n ) ;
2201+ return { lastBlockInCp1, lastProvisional : provisionalBlocks . at ( - 1 ) ! . number , provisionalBlocks } ;
2202+ } ;
2203+
2204+ const makeProposedCheckpoint = ( lastBlockInCp1 : BlockNumber , blockCount : number ) : ProposedCheckpointInput => ( {
2205+ checkpointNumber : CheckpointNumber ( 2 ) ,
2206+ header : CheckpointHeader . empty ( { slotNumber : orphanSlot } ) ,
2207+ startBlock : BlockNumber ( lastBlockInCp1 + 1 ) ,
2208+ blockCount,
2209+ totalManaUsed : 0n ,
2210+ feeAssetPriceModifier : 0n ,
2211+ } ) ;
2212+
2213+ it ( 'does not prune before the grace window elapses' , async ( ) => {
2214+ const { lastProvisional } = await setupOrphanTip ( ) ;
2215+
2216+ dateProvider . setTime ( ( pruneDeadline ( ) - 1 ) * 1000 ) ;
2217+ await archiver . syncImmediate ( ) ;
2218+
2219+ expect ( pruneSpy ) . not . toHaveBeenCalled ( ) ;
2220+ expect ( await archiver . getBlockNumber ( ) ) . toEqual ( lastProvisional ) ;
2221+ } , 15_000 ) ;
2222+
2223+ it ( 'prunes the orphan tip once the grace window elapses' , async ( ) => {
2224+ const { lastBlockInCp1, provisionalBlocks } = await setupOrphanTip ( ) ;
2225+
2226+ dateProvider . setTime ( ( pruneDeadline ( ) + 1 ) * 1000 ) ;
2227+ await archiver . syncImmediate ( ) ;
2228+
2229+ expect ( pruneSpy ) . toHaveBeenCalledWith (
2230+ expect . objectContaining ( {
2231+ type : L2BlockSourceEvents . L2PruneUncheckpointed ,
2232+ slotNumber : orphanSlot ,
2233+ blocks : expect . arrayContaining ( provisionalBlocks . map ( b => expect . objectContaining ( { number : b . number } ) ) ) ,
2234+ } ) ,
2235+ ) ;
2236+ expect ( await archiver . getBlockNumber ( ) ) . toEqual ( lastBlockInCp1 ) ;
2237+ expect ( await archiver . getCheckpointNumber ( ) ) . toEqual ( CheckpointNumber ( 1 ) ) ;
2238+ } , 15_000 ) ;
2239+
2240+ it ( 'does not prune when a matching proposed checkpoint exists' , async ( ) => {
2241+ const { lastBlockInCp1, lastProvisional, provisionalBlocks } = await setupOrphanTip ( ) ;
2242+
2243+ await archiver . addProposedCheckpoint ( makeProposedCheckpoint ( lastBlockInCp1 , provisionalBlocks . length ) ) ;
2244+
2245+ dateProvider . setTime ( ( pruneDeadline ( ) + 100 ) * 1000 ) ;
2246+ await archiver . syncImmediate ( ) ;
2247+
2248+ expect ( pruneSpy ) . not . toHaveBeenCalled ( ) ;
2249+ expect ( await archiver . getBlockNumber ( ) ) . toEqual ( lastProvisional ) ;
2250+ expect ( await archiverStore . blocks . getLastProposedCheckpoint ( ) ) . toBeDefined ( ) ;
2251+ } , 15_000 ) ;
2252+
2253+ it ( 'processes a queued proposed checkpoint before pruning, sparing the tip' , async ( ) => {
2254+ const { lastBlockInCp1, lastProvisional, provisionalBlocks } = await setupOrphanTip ( ) ;
2255+
2256+ // Past the grace window: without the matching checkpoint the next sync would prune the tip.
2257+ dateProvider . setTime ( ( pruneDeadline ( ) + 100 ) * 1000 ) ;
2258+
2259+ // Queue the proposed checkpoint. The triggered sync drains the inbound queue (storing the
2260+ // checkpoint) before running the orphan prune, so the prune sees it and stands down. If the
2261+ // order were reversed, this sync would prune the tip before storing the checkpoint.
2262+ await archiver . addProposedCheckpoint ( makeProposedCheckpoint ( lastBlockInCp1 , provisionalBlocks . length ) ) ;
2263+ await archiver . syncImmediate ( ) ;
2264+
2265+ expect ( pruneSpy ) . not . toHaveBeenCalled ( ) ;
2266+ expect ( await archiver . getBlockNumber ( ) ) . toEqual ( lastProvisional ) ;
2267+ expect ( await archiverStore . blocks . getLastProposedCheckpoint ( ) ) . toBeDefined ( ) ;
2268+ } , 15_000 ) ;
2269+ } ) ;
21462270} ) ;
0 commit comments