From 9611956931150da4546bb2e89eaaf0648b91997a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:54:24 -0300 Subject: [PATCH 1/6] chore: add test Makefile target --- Makefile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0802def..ecc724f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help lint docker-build run-devnet +.PHONY: help lint docker-build run-devnet test help: ## ๐Ÿ“š Show help for each of the Makefile recipes @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @@ -6,6 +6,10 @@ help: ## ๐Ÿ“š Show help for each of the Makefile recipes lint: ## ๐Ÿ” Run clippy on all workspace crates cargo clippy --workspace --all-targets -- -D warnings +test: ## ๐Ÿงช Run all tests, then forkchoice tests with skip-signature-verification + cargo test --workspace + cargo test -p ethlambda-blockchain --features skip-signature-verification --test forkchoice_spectests + docker-build: ## ๐Ÿณ Build the Docker image docker build -t ethlambda:latest . From ceb72e111ed52492ae78439cd0fa8c716467191a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:57:25 -0300 Subject: [PATCH 2/6] ci: run forkchoice tests too --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13d385f..637c299 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,4 +63,4 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Run tests - run: cargo test --workspace + run: make test From 385018b8dfea87fcd5863f45720e956ea969fa0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 19 Jan 2026 12:12:50 -0300 Subject: [PATCH 3/6] chore: bump fixture version --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ecc724f..c999c2f 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ test: ## ๐Ÿงช Run all tests, then forkchoice tests with skip-signature-verificat docker-build: ## ๐Ÿณ Build the Docker image docker build -t ethlambda:latest . -LEAN_SPEC_COMMIT_HASH:=bf0f606a75095cf1853529bc770516b1464d9716 +LEAN_SPEC_COMMIT_HASH:=fbbacbea4545be870e25e3c00a90fc69e019c5bb leanSpec: git clone https://github.com/leanEthereum/leanSpec.git --single-branch From 5bec981ce5515c84634297c0457a37d787cd2b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 19 Jan 2026 12:49:45 -0300 Subject: [PATCH 4/6] feat: make justified_slots relative to latest finalized slot --- .../src/justified_slots_ops.rs | 86 +++++++++++++++++++ crates/blockchain/state_transition/src/lib.rs | 63 ++++++++------ 2 files changed, 121 insertions(+), 28 deletions(-) create mode 100644 crates/blockchain/state_transition/src/justified_slots_ops.rs diff --git a/crates/blockchain/state_transition/src/justified_slots_ops.rs b/crates/blockchain/state_transition/src/justified_slots_ops.rs new file mode 100644 index 0000000..fe031e6 --- /dev/null +++ b/crates/blockchain/state_transition/src/justified_slots_ops.rs @@ -0,0 +1,86 @@ +//! Helper functions for relative-indexed JustifiedSlots operations. +//! +//! The bitlist stores justification status relative to the finalized boundary: +//! - Index 0 = finalized_slot + 1 +//! - Slots โ‰ค finalized_slot are implicitly justified (no storage needed) + +use ethlambda_types::state::JustifiedSlots; + +/// Calculate relative index for a slot after finalization. +/// Returns None if slot <= finalized_slot (implicitly justified). +fn relative_index(target_slot: u64, finalized_slot: u64) -> Option { + if target_slot <= finalized_slot { + return None; + } + Some((target_slot - finalized_slot - 1) as usize) +} + +/// Check if a slot is justified (finalized slots are implicitly justified). +pub fn is_slot_justified(slots: &JustifiedSlots, finalized_slot: u64, target_slot: u64) -> bool { + match relative_index(target_slot, finalized_slot) { + None => true, // Finalized slots are implicitly justified + Some(idx) => slots.get(idx).unwrap_or(false), + } +} + +/// Set justification status for a slot. Returns true if set, false if slot is finalized. +pub fn set_justified( + slots: &mut JustifiedSlots, + finalized_slot: u64, + target_slot: u64, + value: bool, +) -> bool { + if let Some(idx) = relative_index(target_slot, finalized_slot) { + slots.set(idx, value).expect("index out of bounds"); + true + } else { + false + } +} + +/// Extend capacity to cover slots up to target_slot relative to finalized boundary. +/// New slots are initialized to the given default value. +pub fn extend_to_slot( + slots: &mut JustifiedSlots, + finalized_slot: u64, + target_slot: u64, + default: bool, +) { + if let Some(required_idx) = relative_index(target_slot, finalized_slot) { + let required_capacity = required_idx + 1; + if slots.len() >= required_capacity { + return; + } + // Create a new bitlist with the required capacity. + // All new bits are initialized to 0, then we optionally set them to 1 if default is true. + let mut extended = + JustifiedSlots::with_capacity(required_capacity).expect("capacity limit exceeded"); + if default { + for i in slots.len()..required_capacity { + extended.set(i, true).expect("within capacity"); + } + } + // Union preserves existing bits and adds new ones + *slots = slots.union(&extended); + } +} + +/// Shift window by dropping finalized slots when finalization advances. +pub fn shift_window(slots: &mut JustifiedSlots, delta: usize) { + if delta == 0 { + return; + } + if delta >= slots.len() { + *slots = JustifiedSlots::with_capacity(0).unwrap(); + return; + } + // Create new bitlist with shifted data + let remaining = slots.len() - delta; + let mut new_bits = JustifiedSlots::with_capacity(remaining).unwrap(); + for i in 0..remaining { + if slots.get(i + delta).unwrap_or(false) { + new_bits.set(i, true).unwrap(); + } + } + *slots = new_bits; +} diff --git a/crates/blockchain/state_transition/src/lib.rs b/crates/blockchain/state_transition/src/lib.rs index 4b25cd1..51a6a71 100644 --- a/crates/blockchain/state_transition/src/lib.rs +++ b/crates/blockchain/state_transition/src/lib.rs @@ -3,9 +3,11 @@ use std::collections::HashMap; use ethlambda_types::{ block::{AggregatedAttestations, Block, BlockHeader}, primitives::{H256, TreeHash}, - state::{Checkpoint, JustificationValidators, JustifiedSlots, State}, + state::{Checkpoint, JustificationValidators, State}, }; +mod justified_slots_ops; + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("target slot {target_slot} is in the past (current is {current_slot})")] @@ -130,19 +132,18 @@ fn process_block_header(state: &mut State, block: &Block) -> Result<(), Error> { .try_into() .expect("maximum slots reached"); - // Extend justified_slots with [is_genesis_parent] + [false] * num_empty_slots - // We do this by creating a new bitlist with enough capacity, which sets all bits to 0. - // Then we compute the AND/union of both bitlists. - // TODO: replace with a better API once we roll our own SSZ lib - let mut justified_slots = - JustifiedSlots::with_capacity(state.justified_slots.len() + 1 + num_empty_slots) - .expect("maximum justified slots reached"); - - justified_slots - .set(state.justified_slots.len(), is_genesis_parent) - .expect("we just created this with enough capacity"); - - state.justified_slots = state.justified_slots.union(&justified_slots); + // Extend justified_slots to cover slots up to (block.slot - 1) + // + // The storage is relative to the finalized boundary. + // The current block's slot is not materialized until processing completes, + // so we only extend up to the last materialized slot. + let last_materialized_slot = block.slot - 1; + justified_slots_ops::extend_to_slot( + &mut state.justified_slots, + state.latest_finalized.slot, + last_materialized_slot, + false, + ); let new_header = BlockHeader { slot: block.slot, @@ -203,21 +204,21 @@ fn process_attestations( let target = attestation_data.target; // Check that the source is already justified - if !state - .justified_slots - .get(source.slot as usize) - .unwrap_or(false) - { + if !justified_slots_ops::is_slot_justified( + &state.justified_slots, + state.latest_finalized.slot, + source.slot, + ) { // TODO: why doesn't this make the block invalid? continue; } // Ignore votes for targets that have already reached consensus - if state - .justified_slots - .get(target.slot as usize) - .unwrap_or(false) - { + if justified_slots_ops::is_slot_justified( + &state.justified_slots, + state.latest_finalized.slot, + target.slot, + ) { continue; } @@ -255,10 +256,12 @@ fn process_attestations( if 3 * vote_count >= 2 * validator_count { // The block becomes justified state.latest_justified = target; - state - .justified_slots - .set(target.slot as usize, true) - .expect("we already resized in process_block_header"); + justified_slots_ops::set_justified( + &mut state.justified_slots, + state.latest_finalized.slot, + target.slot, + true, + ); justifications.remove(&target.root); @@ -266,7 +269,11 @@ fn process_attestations( if !((source.slot + 1)..target.slot) .any(|slot| slot_is_justifiable_after(slot, state.latest_finalized.slot)) { + let old_finalized_slot = state.latest_finalized.slot; state.latest_finalized = source; + // Shift window to drop finalized slots from the front + let delta = (source.slot - old_finalized_slot) as usize; + justified_slots_ops::shift_window(&mut state.justified_slots, delta); } } } From 6287af435395398c8362577398d6eafdc4044ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:15:32 -0300 Subject: [PATCH 5/6] refactor: simplify a bit the justified_slots ops --- .../src/justified_slots_ops.rs | 65 +++++++------------ crates/blockchain/state_transition/src/lib.rs | 2 - 2 files changed, 23 insertions(+), 44 deletions(-) diff --git a/crates/blockchain/state_transition/src/justified_slots_ops.rs b/crates/blockchain/state_transition/src/justified_slots_ops.rs index fe031e6..a89ffcf 100644 --- a/crates/blockchain/state_transition/src/justified_slots_ops.rs +++ b/crates/blockchain/state_transition/src/justified_slots_ops.rs @@ -9,60 +9,41 @@ use ethlambda_types::state::JustifiedSlots; /// Calculate relative index for a slot after finalization. /// Returns None if slot <= finalized_slot (implicitly justified). fn relative_index(target_slot: u64, finalized_slot: u64) -> Option { - if target_slot <= finalized_slot { - return None; - } - Some((target_slot - finalized_slot - 1) as usize) + target_slot + .checked_sub(finalized_slot)? + .checked_sub(1) + .map(|idx| idx as usize) } /// Check if a slot is justified (finalized slots are implicitly justified). pub fn is_slot_justified(slots: &JustifiedSlots, finalized_slot: u64, target_slot: u64) -> bool { - match relative_index(target_slot, finalized_slot) { - None => true, // Finalized slots are implicitly justified - Some(idx) => slots.get(idx).unwrap_or(false), - } + relative_index(target_slot, finalized_slot) + .map(|idx| slots.get(idx).unwrap_or(false)) + .unwrap_or(true) // Finalized slots are implicitly justified } -/// Set justification status for a slot. Returns true if set, false if slot is finalized. -pub fn set_justified( - slots: &mut JustifiedSlots, - finalized_slot: u64, - target_slot: u64, - value: bool, -) -> bool { +/// Mark a slot as justified. No-op if slot is finalized. +pub fn set_justified(slots: &mut JustifiedSlots, finalized_slot: u64, target_slot: u64) { if let Some(idx) = relative_index(target_slot, finalized_slot) { - slots.set(idx, value).expect("index out of bounds"); - true - } else { - false + slots.set(idx, true).expect("index out of bounds"); } } /// Extend capacity to cover slots up to target_slot relative to finalized boundary. -/// New slots are initialized to the given default value. -pub fn extend_to_slot( - slots: &mut JustifiedSlots, - finalized_slot: u64, - target_slot: u64, - default: bool, -) { - if let Some(required_idx) = relative_index(target_slot, finalized_slot) { - let required_capacity = required_idx + 1; - if slots.len() >= required_capacity { - return; - } - // Create a new bitlist with the required capacity. - // All new bits are initialized to 0, then we optionally set them to 1 if default is true. - let mut extended = - JustifiedSlots::with_capacity(required_capacity).expect("capacity limit exceeded"); - if default { - for i in slots.len()..required_capacity { - extended.set(i, true).expect("within capacity"); - } - } - // Union preserves existing bits and adds new ones - *slots = slots.union(&extended); +/// New slots are initialized to false (unjustified). +pub fn extend_to_slot(slots: &mut JustifiedSlots, finalized_slot: u64, target_slot: u64) { + let Some(required_idx) = relative_index(target_slot, finalized_slot) else { + return; + }; + let required_capacity = required_idx + 1; + if slots.len() >= required_capacity { + return; } + // Create a new bitlist with the required capacity (all bits default to false). + // Union preserves existing bits and extends the length. + let extended = + JustifiedSlots::with_capacity(required_capacity).expect("capacity limit exceeded"); + *slots = slots.union(&extended); } /// Shift window by dropping finalized slots when finalization advances. diff --git a/crates/blockchain/state_transition/src/lib.rs b/crates/blockchain/state_transition/src/lib.rs index 51a6a71..87eb5b4 100644 --- a/crates/blockchain/state_transition/src/lib.rs +++ b/crates/blockchain/state_transition/src/lib.rs @@ -142,7 +142,6 @@ fn process_block_header(state: &mut State, block: &Block) -> Result<(), Error> { &mut state.justified_slots, state.latest_finalized.slot, last_materialized_slot, - false, ); let new_header = BlockHeader { @@ -260,7 +259,6 @@ fn process_attestations( &mut state.justified_slots, state.latest_finalized.slot, target.slot, - true, ); justifications.remove(&target.root); From dcf8a430237e132a7b33e22f55ff84de66811ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:49:07 -0300 Subject: [PATCH 6/6] fix: prune finalized justifications --- crates/blockchain/state_transition/src/lib.rs | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/blockchain/state_transition/src/lib.rs b/crates/blockchain/state_transition/src/lib.rs index 87eb5b4..260f2cd 100644 --- a/crates/blockchain/state_transition/src/lib.rs +++ b/crates/blockchain/state_transition/src/lib.rs @@ -197,6 +197,18 @@ fn process_attestations( }) .collect(); + // For is_justifiable_after checks (must use original value, not updated during iteration) + let original_finalized_slot = state.latest_finalized.slot; + + // Build root_to_slots mapping for justifications pruning. + // A root may appear at multiple slots (missed slots produce duplicate zero hashes). + let mut root_to_slots: HashMap> = HashMap::new(); + for slot in (state.latest_finalized.slot + 1)..state.historical_block_hashes.len() as u64 { + if let Some(root) = state.historical_block_hashes.get(slot as usize) { + root_to_slots.entry(*root).or_default().push(slot); + } + } + for attestation in attestations { let attestation_data = &attestation.data; let source = attestation_data.source; @@ -232,7 +244,7 @@ fn process_attestations( } // Ensure the target falls on a slot that can be justified after the finalized one. - if !slot_is_justifiable_after(target.slot, state.latest_finalized.slot) { + if !slot_is_justifiable_after(target.slot, original_finalized_slot) { continue; } @@ -263,15 +275,24 @@ fn process_attestations( justifications.remove(&target.root); - // Consider whether finalization can advance + // Consider whether finalization can advance. + // Use ORIGINAL finalized slot for is_justifiable_after check. if !((source.slot + 1)..target.slot) - .any(|slot| slot_is_justifiable_after(slot, state.latest_finalized.slot)) + .any(|slot| slot_is_justifiable_after(slot, original_finalized_slot)) { let old_finalized_slot = state.latest_finalized.slot; state.latest_finalized = source; + // Shift window to drop finalized slots from the front - let delta = (source.slot - old_finalized_slot) as usize; + let delta = (state.latest_finalized.slot - old_finalized_slot) as usize; justified_slots_ops::shift_window(&mut state.justified_slots, delta); + + // Prune justifications whose roots only appear at now-finalized slots + justifications.retain(|root, _| { + root_to_slots.get(root).is_some_and(|slots| { + slots.iter().any(|&slot| slot > state.latest_finalized.slot) + }) + }); } } }