From 2f525c687eb46a45fd3b17d09c94180b5e7c9e5c Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Tue, 17 Mar 2026 12:47:15 -0300 Subject: [PATCH 1/2] Cap ancestor chain walk depth to prevent infinite block re-processing loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When fallback pruning deletes states for already-processed blocks (STATES_TO_KEEP=900 is much smaller than BLOCKS_TO_KEEP=21600), the pending block chain walk can reach protected checkpoints (justified/finalized) whose states survive pruning. This triggers a massive cascade that re-processes hundreds of old blocks whose states are immediately re-pruned — creating an infinite loop. Limit the walk to MAX_ANCESTOR_WALK_SLOTS (512) slots behind the current head. Since 512 < STATES_TO_KEEP (900), any cascade stays within the state retention window and states won't be immediately pruned. Blocks beyond the limit fall through to a network request instead. Observed in devnet4 where all three ethlambda nodes entered this loop at slot ~15276, generating ~3.5GB of logs each while finalization was stalled. --- crates/blockchain/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 872e6aa..dd84b05 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -314,6 +314,12 @@ impl BlockChainServer { let parent_root = signed_block.message.block.parent_root; let proposer = signed_block.message.block.proposer_index; + // Never process blocks at or below the finalized slot — they are + // already part of the canonical chain and cannot affect fork choice. + if slot <= self.store.latest_finalized().slot { + return; + } + // Check if parent state exists before attempting to process if !self.store.has_state(&parent_root) { info!(%slot, %parent_root, %block_root, "Block parent missing, storing as pending"); From 26f6a4141dc9aead44cb6b1b2dc0a37da2536e11 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Thu, 26 Mar 2026 16:33:01 -0300 Subject: [PATCH 2/2] Discard non-canonical pending children when skipping finalized-era blocks When a block at or below the finalized slot is skipped, recursively remove its pending children from the in-memory maps. Without this, children of non-canonical fork blocks would remain stuck in pending_blocks/pending_block_parents indefinitely since the parent will never be processed. --- crates/blockchain/src/lib.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index a2445dd..249c9e5 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -323,7 +323,10 @@ impl BlockChainServer { // Never process blocks at or below the finalized slot — they are // already part of the canonical chain and cannot affect fork choice. + // Discard any pending children: since we won't process this block, + // children referencing it as parent would remain stuck indefinitely. if slot <= self.store.latest_finalized().slot { + self.discard_pending_subtree(block_root); return; } @@ -454,6 +457,20 @@ impl BlockChainServer { } } + /// Recursively discard a block and all its pending descendants. + /// + /// Used when a block is rejected (e.g., at/below finalized slot) to clean up + /// children that would otherwise remain stuck in the pending maps indefinitely. + fn discard_pending_subtree(&mut self, block_root: H256) { + let Some(child_roots) = self.pending_blocks.remove(&block_root) else { + return; + }; + for child_root in child_roots { + self.pending_block_parents.remove(&child_root); + self.discard_pending_subtree(child_root); + } + } + fn on_gossip_attestation(&mut self, attestation: SignedAttestation) { if !self.is_aggregator { warn!("Received unaggregated attestation but node is not an aggregator");