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 diff --git a/Makefile b/Makefile index 0802def..c999c2f 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,10 +6,14 @@ 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 . -LEAN_SPEC_COMMIT_HASH:=bf0f606a75095cf1853529bc770516b1464d9716 +LEAN_SPEC_COMMIT_HASH:=fbbacbea4545be870e25e3c00a90fc69e019c5bb leanSpec: git clone https://github.com/leanEthereum/leanSpec.git --single-branch 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..a89ffcf --- /dev/null +++ b/crates/blockchain/state_transition/src/justified_slots_ops.rs @@ -0,0 +1,67 @@ +//! 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 { + 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 { + relative_index(target_slot, finalized_slot) + .map(|idx| slots.get(idx).unwrap_or(false)) + .unwrap_or(true) // Finalized slots are implicitly justified +} + +/// 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, true).expect("index out of bounds"); + } +} + +/// Extend capacity to cover slots up to target_slot relative to finalized boundary. +/// 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. +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..260f2cd 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,17 @@ 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, + ); let new_header = BlockHeader { slot: block.slot, @@ -197,27 +197,39 @@ 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; 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; } @@ -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; } @@ -255,18 +267,32 @@ 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, + ); 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 = (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) + }) + }); } } }