From 0cd318ac1a95189af43442a3ad477295b451ec30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:37:58 -0300 Subject: [PATCH] fix(heze): read inclusion lists under the real dependent root at epoch start At the first slot of every epoch after the first prune, a node saw zero inclusion lists for the previous slot. It built blocks with an empty inclusion_list_bits and judged payloads against an empty list. Lists are stored under the block at the epoch's shuffling dependent slot. For the last slot of an epoch, that block sits one slot below the block finality has just pruned the live chain down to. The walk over the live chain stops at its lowest block, so it answered with the finalized block and the lookup key missed. inclusion_list_dependent_root now keeps walking through the stored blocks, which are never pruned, once the index runs out. get_shuffling_dependent_root is unchanged, since its anchor behavior is pinned and its other caller only compares two results. A step onto a root the store does not hold ends the walk, which is never worse than the old answer. --- .../src/beacon/fork_choice.rs | 6 +- .../src/beacon/inclusion_list.rs | 93 ++++++++++++++++++- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/crates/blockchain/state_transition/src/beacon/fork_choice.rs b/crates/blockchain/state_transition/src/beacon/fork_choice.rs index f61bfa5ac..0b3f4d86c 100644 --- a/crates/blockchain/state_transition/src/beacon/fork_choice.rs +++ b/crates/blockchain/state_transition/src/beacon/fork_choice.rs @@ -5639,7 +5639,7 @@ pub fn on_attester_slashing(store: &mut Store, attester_slashing: &AttesterSlash } #[cfg(test)] -mod tests { +pub(crate) mod tests { use ethlambda_storage::backend::InMemoryBackend; use super::*; @@ -5653,7 +5653,7 @@ mod tests { /// A store backed by a fresh in-memory backend, with every checkpoint at /// its default (genesis) value and no anchor block or state written. /// Tests populate only what the function under test actually reads. - fn empty_store() -> Store { + pub(crate) fn empty_store() -> Store { store_anchored_at(Root::ZERO) } @@ -5688,7 +5688,7 @@ mod tests { /// A signed block with an empty body and a zero signature, for tests that /// only care about `slot` and `parent_root`. Phase0-shaped since nothing /// under test here reads anything fork-specific. - fn block(slot: Slot, parent_root: Root) -> SignedBeaconBlock { + pub(crate) fn block(slot: Slot, parent_root: Root) -> SignedBeaconBlock { SignedBeaconBlock::Phase0(phase0::SignedBeaconBlock { message: phase0::BeaconBlock { slot, diff --git a/crates/blockchain/state_transition/src/beacon/inclusion_list.rs b/crates/blockchain/state_transition/src/beacon/inclusion_list.rs index 4e2ec9246..f64d9c040 100644 --- a/crates/blockchain/state_transition/src/beacon/inclusion_list.rs +++ b/crates/blockchain/state_transition/src/beacon/inclusion_list.rs @@ -121,9 +121,31 @@ pub fn transactions_size(inclusion_list: &InclusionList) -> u64 { /// head is `head_root`: `get_shuffling_dependent_root(store, head_root, /// epoch(slot))`. What a committee member signs over, and the key a proposer /// or builder reads the previous slot's lists under. +/// +/// The walk continues past the live-chain index through the stored blocks. +/// The index starts at the finalized block, while the lists of an epoch's last +/// slot depend on the block just below it once the previous epoch finalizes at +/// the boundary, so the index walk alone answered with the finalized block and +/// every epoch's first slot read no lists. Stored blocks are never pruned, so +/// they can finish the walk. A step onto a root the store does not hold (a +/// checkpoint anchor's parent) ends it there, which is the true ancestor when +/// the dependent slot is the one just below the anchor. pub fn inclusion_list_dependent_root(store: &Store, head_root: Root, slot: Slot) -> Root { + let epoch = compute_epoch_at_slot(slot); + let dependent_slot = compute_shuffling_dependent_slot(epoch); let index = store.block_index(); - get_shuffling_dependent_root(&index, head_root, compute_epoch_at_slot(slot)) + let mut root = get_shuffling_dependent_root(&index, head_root, epoch); + loop { + // The index first: `block_entry` decodes the whole block. + let entry = index + .get(&root) + .copied() + .or_else(|| store.block_entry(&root)); + match entry { + Some((block_slot, parent_root)) if block_slot > dependent_slot => root = parent_root, + _ => return root, + } + } } /// A state that answers `get_inclusion_list_committee` for a list at `slot` @@ -295,3 +317,72 @@ pub fn payload_inclusion_list_transactions( .transactions(slot, dependent_root, true), ) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::beacon::fork_choice::tests::{block, empty_store}; + + /// The slot finality prunes the live chain to, and the last slot of the + /// epoch two past it, whose dependent slot is the one just below it. + const FINALIZED: Slot = 128; + const LAST_SLOT: Slot = 191; + + /// A store holding one block per `slots` entry, each the parent of the + /// next, written the way fork choice writes them (`BlockHeaders` and + /// `LiveChain` both get a row). Returns the roots in the order given. + fn chain_store(slots: &[Slot]) -> (Store, Vec) { + let mut store = empty_store(); + let mut parent = Root::ZERO; + let mut roots = Vec::new(); + for (position, &slot) in slots.iter().enumerate() { + let signed = block(slot, parent); + let root = Root::repeat_byte(position as u8 + 1); + store + .insert_signed_block(root, signed) + .expect("insert block"); + roots.push(root); + parent = root; + } + (store, roots) + } + + #[test] + fn the_dependent_root_below_a_pruned_live_chain_is_the_real_ancestor() { + let (mut store, roots) = chain_store(&[0, 100, 127, 128, 150, 191]); + assert_eq!( + compute_shuffling_dependent_slot(compute_epoch_at_slot(LAST_SLOT)), + FINALIZED - 1 + ); + store.prune_live_chain(FINALIZED).expect("prune"); + + let dependent = inclusion_list_dependent_root(&store, roots[5], LAST_SLOT); + + assert_eq!(dependent, roots[2], "the block at the dependent slot"); + } + + #[test] + fn a_skipped_dependent_slot_resolves_to_the_block_before_it_past_the_prune() { + let (mut store, roots) = chain_store(&[0, 100, 120, 128, 150, 191]); + store.prune_live_chain(FINALIZED).expect("prune"); + + let dependent = inclusion_list_dependent_root(&store, roots[5], LAST_SLOT); + + assert_eq!(dependent, roots[2], "the latest block at or below the slot"); + } + + #[test] + fn an_unpruned_live_chain_answers_as_the_index_walk_does() { + let (store, roots) = chain_store(&[0, 100, 127, 128, 150, 191]); + let index = store.block_index(); + let epoch = compute_epoch_at_slot(LAST_SLOT); + + let dependent = inclusion_list_dependent_root(&store, roots[5], LAST_SLOT); + + assert_eq!(dependent, roots[2]); + assert_eq!( + dependent, + get_shuffling_dependent_root(&index, roots[5], epoch) + ); + } +}