Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,4 @@ jobs:
uses: Swatinem/rust-cache@v2

- name: Run tests
run: cargo test --workspace
run: make test
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
.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}'

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
Expand Down
67 changes: 67 additions & 0 deletions crates/blockchain/state_transition/src/justified_slots_ops.rs
Original file line number Diff line number Diff line change
@@ -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<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 {
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;
}
88 changes: 57 additions & 31 deletions crates/blockchain/state_transition/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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})")]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<H256, Vec<u64>> = 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;
}

Expand All @@ -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;
}

Expand All @@ -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)
})
});
}
}
}
Expand Down