Skip to content

Commit d6bd33f

Browse files
committed
fix(seismic-txpool): rebuild recent block cache on next-height reorgs
1 parent 39d04d1 commit d6bd33f

3 files changed

Lines changed: 65 additions & 20 deletions

File tree

crates/seismic/txpool/src/maintain.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ pub async fn maintain_seismic_freshness<Client, Pool>(
9898
let mut heads_since_scan = 0u64;
9999
while let Some(notification) = events.next().await {
100100
let Some(tip) = notification.tip_checked() else { continue };
101-
cache.update(tip.hash(), tip.number(), |n| {
101+
cache.update(tip.hash(), tip.number(), tip.parent_hash(), |n| {
102102
client.header_by_number(n).ok()?.map(|h| h.hash_slow())
103103
});
104104

crates/seismic/txpool/src/recent_block_cache.rs

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

crates/seismic/txpool/src/validator.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,9 +212,12 @@ where
212212
self.inner.on_new_head_block(new_tip_block);
213213

214214
let mut cache = self.recent_blocks.write().unwrap_or_else(|e| e.into_inner());
215-
cache.update(new_tip_block.hash(), new_tip_block.header().number(), |n| {
216-
self.inner.client().header_by_number(n).ok()?.map(|h| h.hash_slow())
217-
});
215+
cache.update(
216+
new_tip_block.hash(),
217+
new_tip_block.header().number(),
218+
new_tip_block.header().parent_hash(),
219+
|n| self.inner.client().header_by_number(n).ok()?.map(|h| h.hash_slow()),
220+
);
218221
}
219222
}
220223

0 commit comments

Comments
 (0)