@@ -150,28 +150,39 @@ impl RecentBlockCache {
150150 /// accepts exactly the hashes that the RPC layer would return for
151151 /// `eth_getBlockByNumber("latest")`. Three cases:
152152 ///
153- /// 1. **Sequential block** (`new == cached + 1`): The common case during normal operation. We
154- /// just append the new hash — O(1).
153+ /// 1. **Sequential block** (`new == cached + 1` and `new.parent == cached.hash`): The common
154+ /// case during normal operation. We just append the new hash — O(1).
155155 ///
156156 /// 2. **Gap but still canonical** (`new > cached` and our latest hash is still on the canonical
157157 /// chain): Multiple blocks were produced between callbacks (e.g. between `new()` and the
158158 /// first callback, or a slow consumer). We backfill only the missing blocks.
159159 ///
160160 /// 3. **Stale cache** (reorg, empty, or same/lower height): The cache contains hashes from a
161- /// fork that is no longer canonical. We clear and rebuild the full lookback window to purge
162- /// stale fork hashes.
161+ /// fork that is no longer canonical. This includes a numerically sequential block whose
162+ /// parent does not match the cached tip. We clear and rebuild the full lookback window to
163+ /// purge stale fork hashes.
163164 pub fn update (
164165 & mut self ,
165166 new_hash : B256 ,
166167 new_number : u64 ,
168+ new_parent_hash : B256 ,
167169 canonical_hash_at : impl Fn ( u64 ) -> Option < B256 > ,
168170 ) {
169- // Happy path: sequential block, just append
170- if new_number == self . current_block_number + 1 {
171+ let is_next_height = new_number == self . current_block_number + 1 ;
172+
173+ // Happy path: a direct extension of the cached tip, just append without a provider lookup.
174+ if is_next_height && self . latest_hash ( ) . is_some_and ( |hash| * hash == new_parent_hash) {
171175 self . insert ( new_hash, new_number) ;
172176 return ;
173177 }
174178
179+ // A block at the next height with a different parent is a known reorg, not a gap. Rebuild
180+ // immediately so hashes from the replaced fork cannot remain in the cache.
181+ if is_next_height {
182+ self . rebuild_window ( new_number, Some ( new_hash) , "rebuild" , canonical_hash_at) ;
183+ return ;
184+ }
185+
175186 // Non-sequential: check if the cache is still on the canonical chain
176187 if new_number > self . current_block_number && self . is_on_canonical_chain ( & canonical_hash_at)
177188 {
@@ -306,13 +317,44 @@ mod tests {
306317 cache. insert ( h1, 1 ) ;
307318 // Sequential: block 2 follows block 1
308319 #[ allow( clippy:: panic) ]
309- cache. update ( h2, 2 , |_| panic ! ( "should not be called for sequential" ) ) ;
320+ cache. update ( h2, 2 , h1 , |_| panic ! ( "should not be called for sequential" ) ) ;
310321
311322 assert ! ( cache. contains( & h1) ) ;
312323 assert ! ( cache. contains( & h2) ) ;
313324 assert_eq ! ( cache. current_block_number( ) , 2 ) ;
314325 }
315326
327+ #[ test]
328+ fn test_update_next_height_reorg_triggers_rebuild ( ) {
329+ let mut cache = RecentBlockCache :: new ( 5 ) ;
330+ let h8_old = B256 :: from ( [ 80u8 ; 32 ] ) ;
331+ let h9_old = B256 :: from ( [ 90u8 ; 32 ] ) ;
332+ let h10_old = B256 :: from ( [ 100u8 ; 32 ] ) ;
333+ cache. insert ( h8_old, 8 ) ;
334+ cache. insert ( h9_old, 9 ) ;
335+ cache. insert ( h10_old, 10 ) ;
336+
337+ // The replacement chain is one block taller than the cached losing fork. Numeric height
338+ // alone looks sequential, but the new tip's parent is not the cached height-10 hash.
339+ let h6_new = B256 :: from ( [ 6u8 ; 32 ] ) ;
340+ let h7_new = B256 :: from ( [ 7u8 ; 32 ] ) ;
341+ let h8_new = B256 :: from ( [ 8u8 ; 32 ] ) ;
342+ let h9_new = B256 :: from ( [ 9u8 ; 32 ] ) ;
343+ let h10_new = B256 :: from ( [ 10u8 ; 32 ] ) ;
344+ let h11_new = B256 :: from ( [ 11u8 ; 32 ] ) ;
345+ let replacement = [ ( h6_new, 6 ) , ( h7_new, 7 ) , ( h8_new, 8 ) , ( h9_new, 9 ) , ( h10_new, 10 ) ] ;
346+
347+ cache. update ( h11_new, 11 , h10_new, mock_canonical ( & replacement) ) ;
348+
349+ assert ! ( !cache. contains( & h8_old) ) ;
350+ assert ! ( !cache. contains( & h9_old) ) ;
351+ assert ! ( !cache. contains( & h10_old) ) ;
352+ for hash in [ h7_new, h8_new, h9_new, h10_new, h11_new] {
353+ assert ! ( cache. contains( & hash) ) ;
354+ }
355+ assert_eq ! ( cache. current_block_number( ) , 11 ) ;
356+ }
357+
316358 #[ test]
317359 fn test_update_gap_still_canonical ( ) {
318360 let mut cache = RecentBlockCache :: new ( 10 ) ;
@@ -325,7 +367,7 @@ mod tests {
325367
326368 // Gap: jump from 5 to 8, but cache is still canonical
327369 let blocks = [ ( h5, 5 ) , ( h6, 6 ) , ( h7, 7 ) , ( h8, 8 ) ] ;
328- cache. update ( h8, 8 , mock_canonical ( & blocks) ) ;
370+ cache. update ( h8, 8 , h7 , mock_canonical ( & blocks) ) ;
329371
330372 assert ! ( cache. contains( & h5) ) ;
331373 assert ! ( cache. contains( & h6) ) ;
@@ -344,7 +386,7 @@ mod tests {
344386 let h6 = B256 :: from ( [ 6u8 ; 32 ] ) ;
345387 let h7 = B256 :: from ( [ 7u8 ; 32 ] ) ;
346388 let tip = B256 :: from ( [ 88u8 ; 32 ] ) ;
347- cache. update ( tip, 8 , mock_canonical ( & [ ( h5, 5 ) , ( h6, 6 ) , ( h7, 7 ) ] ) ) ;
389+ cache. update ( tip, 8 , h7 , mock_canonical ( & [ ( h5, 5 ) , ( h6, 6 ) , ( h7, 7 ) ] ) ) ;
348390
349391 // The tip is taken from `new_hash`, not the (missing) callback result.
350392 assert ! ( cache. contains( & tip) ) ;
@@ -359,7 +401,7 @@ mod tests {
359401 // Same-height, different hash -> stale -> full rebuild. The callback returns nothing, but
360402 // the tip must still be taken from `new_hash`.
361403 let tip = B256 :: from ( [ 66u8 ; 32 ] ) ;
362- cache. update ( tip, 5 , |_| None ) ;
404+ cache. update ( tip, 5 , B256 :: ZERO , |_| None ) ;
363405
364406 assert ! ( cache. contains( & tip) ) ;
365407 assert_eq ! ( cache. current_block_number( ) , 5 ) ;
@@ -381,7 +423,7 @@ mod tests {
381423 // Reorg: new canonical chain is shorter (tip at 6), old fork hashes are stale.
382424 // new_number (6) <= current_block_number (7), so this triggers a full rebuild.
383425 let blocks = [ ( h5_new, 5 ) , ( h6_new, 6 ) ] ;
384- cache. update ( h6_new, 6 , mock_canonical ( & blocks) ) ;
426+ cache. update ( h6_new, 6 , h5_new , mock_canonical ( & blocks) ) ;
385427
386428 // Old fork hashes should be gone, new canonical hashes present
387429 assert ! ( !cache. contains( & h5_old) ) ;
@@ -402,7 +444,7 @@ mod tests {
402444
403445 // Same height but different hash (reorg at same level)
404446 let blocks = [ ( h5_new, 5 ) ] ;
405- cache. update ( h5_new, 5 , mock_canonical ( & blocks) ) ;
447+ cache. update ( h5_new, 5 , B256 :: ZERO , mock_canonical ( & blocks) ) ;
406448
407449 assert ! ( !cache. contains( & h5_old) ) ;
408450 assert ! ( cache. contains( & h5_new) ) ;
@@ -428,14 +470,14 @@ mod tests {
428470 assert ! ( !cache. is_complete( ) ) ;
429471
430472 // Sequential appends must not paper over the hole while it is still in the window...
431- cache. update ( h ( 4 ) , 4 , |_| None ) ;
473+ cache. update ( h ( 4 ) , 4 , h ( 3 ) , |_| None ) ;
432474 assert ! ( !cache. is_complete( ) ) ;
433- cache. update ( h ( 5 ) , 5 , |_| None ) ;
434- cache. update ( h ( 6 ) , 6 , |_| None ) ;
475+ cache. update ( h ( 5 ) , 5 , h ( 4 ) , |_| None ) ;
476+ cache. update ( h ( 6 ) , 6 , h ( 5 ) , |_| None ) ;
435477 assert ! ( !cache. is_complete( ) ) ;
436478
437479 // ...but once block 2 ages out of the window (tip reaches 7), it self-heals — no rebuild.
438- cache. update ( h ( 7 ) , 7 , |_| None ) ;
480+ cache. update ( h ( 7 ) , 7 , h ( 6 ) , |_| None ) ;
439481 assert ! ( cache. is_complete( ) ) ;
440482
441483 // A clean full rebuild also restores completeness directly.
0 commit comments