Skip to content

Commit 0cfdaea

Browse files
authored
Merge branch 'main' into fix/resume-db-without-checkpoint-url
2 parents b8f85a7 + 9e3719f commit 0cfdaea

3 files changed

Lines changed: 61 additions & 16 deletions

File tree

crates/blockchain/src/block_builder.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,11 +457,19 @@ impl ProjectedState {
457457
if !known_block_roots.contains(&att_data.head.root) {
458458
return Err("head_root_unknown");
459459
}
460+
// The projection is seeded from the head state, whose window stops at
461+
// `head.slot - 1`, so slots between the head and the candidate block are
462+
// legitimately untracked here. That is not a validity verdict: this is a
463+
// pre-filter over gossip candidates, so an untracked slot reads as "not
464+
// justified yet" and the STF stays the authority on
465+
// `JustifiedSlotOutOfRange`.
460466
if !justified_slots_ops::is_slot_justified(
461467
&self.justified_slots,
462468
self.finalized_slot,
463469
att_data.source.slot,
464-
) {
470+
)
471+
.unwrap_or(false)
472+
{
465473
return Err("source_not_justified");
466474
}
467475
if !attestation_data_matches_chain(extended_historical_block_hashes, att_data) {
@@ -471,12 +479,15 @@ impl ProjectedState {
471479
if !is_genesis_self_vote && att_data.target.slot <= att_data.source.slot {
472480
return Err("target_not_after_source");
473481
}
482+
// An untracked target slot (commonly the head block's own slot) is not yet
483+
// justified, so it stays eligible. Same reasoning as the source check above.
474484
if !is_genesis_self_vote
475485
&& justified_slots_ops::is_slot_justified(
476486
&self.justified_slots,
477487
self.finalized_slot,
478488
att_data.target.slot,
479489
)
490+
.unwrap_or(false)
480491
{
481492
return Err("target_already_justified");
482493
}

crates/blockchain/state_transition/src/justified_slots_ops.rs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
77
use ethlambda_types::state::JustifiedSlots;
88

9+
use crate::Error;
10+
911
/// Calculate relative index for a slot after finalization.
1012
/// Returns None if slot <= finalized_slot (implicitly justified).
1113
fn relative_index(target_slot: u64, finalized_slot: u64) -> Option<usize> {
@@ -16,10 +18,27 @@ fn relative_index(target_slot: u64, finalized_slot: u64) -> Option<usize> {
1618
}
1719

1820
/// Check if a slot is justified (finalized slots are implicitly justified).
19-
pub fn is_slot_justified(slots: &JustifiedSlots, finalized_slot: u64, target_slot: u64) -> bool {
20-
relative_index(target_slot, finalized_slot)
21-
.map(|idx| slots.get(idx).unwrap_or(false))
22-
.unwrap_or(true) // Finalized slots are implicitly justified
21+
///
22+
/// A slot past the finalized boundary but beyond the tracked bitlist has no
23+
/// justification status to report. The spec surfaces that as a domain rejection
24+
/// (leanSpec #1023, `JUSTIFIED_SLOT_OUT_OF_RANGE`) rather than reading it as
25+
/// "not justified", so a block carrying such a vote is invalid. Callers on the
26+
/// state transition path must propagate the error; callers that only pre-filter
27+
/// candidate votes may treat it as "unknown, so not justified".
28+
pub fn is_slot_justified(
29+
slots: &JustifiedSlots,
30+
finalized_slot: u64,
31+
target_slot: u64,
32+
) -> Result<bool, Error> {
33+
// Finalized slots are implicitly justified and need no tracked bit.
34+
let Some(idx) = relative_index(target_slot, finalized_slot) else {
35+
return Ok(true);
36+
};
37+
slots.get(idx).ok_or(Error::JustifiedSlotOutOfRange {
38+
slot: target_slot,
39+
finalized_slot,
40+
tracked_length: slots.len(),
41+
})
2342
}
2443

2544
/// Mark a slot as justified. No-op if slot is finalized.

crates/blockchain/state_transition/src/lib.rs

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ pub enum Error {
4848
index: usize,
4949
validator_count: usize,
5050
},
51+
#[error(
52+
"justified slot {slot} is outside the tracked range (finalized_boundary={finalized_slot}, tracked_length={tracked_length})"
53+
)]
54+
JustifiedSlotOutOfRange {
55+
slot: u64,
56+
finalized_slot: u64,
57+
tracked_length: usize,
58+
},
5159
}
5260

5361
/// Transition the given pre-state to the block's post-state.
@@ -309,7 +317,7 @@ fn process_attestations(
309317
let source = attestation_data.source;
310318
let target = attestation_data.target;
311319

312-
if !is_valid_vote(state, attestation_data) {
320+
if !is_valid_vote(state, attestation_data)? {
313321
continue;
314322
}
315323

@@ -392,7 +400,15 @@ fn process_attestations(
392400
/// rejects zero-hash source or target roots)
393401
/// 4. Target slot > source slot
394402
/// 5. Target slot is justifiable after the finalized slot
395-
fn is_valid_vote(state: &State, data: &AttestationData) -> bool {
403+
///
404+
/// A failed check drops the vote and leaves the block valid, matching the
405+
/// spec's `continue` semantics. The exception is a source or target slot past
406+
/// the tracked justification window: that has no justification status to read
407+
/// at all, so it invalidates the whole block via `JustifiedSlotOutOfRange`
408+
/// (leanSpec #1023). After `process_block_header` the window covers up to
409+
/// `block.slot - 1`, so this is exactly a vote whose source or target slot is
410+
/// at or beyond the importing block's own slot.
411+
fn is_valid_vote(state: &State, data: &AttestationData) -> Result<bool, Error> {
396412
let source = data.source;
397413
let target = data.target;
398414

@@ -401,37 +417,36 @@ fn is_valid_vote(state: &State, data: &AttestationData) -> bool {
401417
&state.justified_slots,
402418
state.latest_finalized.slot,
403419
source.slot,
404-
) {
405-
// TODO: why doesn't this make the block invalid?
406-
return false;
420+
)? {
421+
return Ok(false);
407422
}
408423

409424
// Ignore votes for targets that have already reached consensus
410425
if justified_slots_ops::is_slot_justified(
411426
&state.justified_slots,
412427
state.latest_finalized.slot,
413428
target.slot,
414-
) {
415-
return false;
429+
)? {
430+
return Ok(false);
416431
}
417432

418433
// Ensure the vote refers to blocks that actually exist on our chain;
419434
// also rejects zero-hash source or target inline.
420435
if !attestation_data_matches_chain(&state.historical_block_hashes, data) {
421-
return false;
436+
return Ok(false);
422437
}
423438

424439
// Ensure time flows forward
425440
if target.slot <= source.slot {
426-
return false;
441+
return Ok(false);
427442
}
428443

429444
// Ensure the target falls on a slot that can be justified after the finalized one.
430445
if !slot_is_justifiable_after(target.slot, state.latest_finalized.slot) {
431-
return false;
446+
return Ok(false);
432447
}
433448

434-
true
449+
Ok(true)
435450
}
436451

437452
/// Attempt to advance finalization from source to target.

0 commit comments

Comments
 (0)