Skip to content

Commit d96e524

Browse files
authored
fix(fork-choice): bound a block's slot before the state transition (leanSpec #1182) (#501)
## What Ports leanSpec [#1182](leanEthereum/leanSpec#1182): bound a block's slot in `on_block` before the state transition runs. ## Why `on_block` had a lower slot bound but no upper bound. The transition advances the state one slot at a time from the parent up to `block.slot`, and the proposer of a slot is `slot % num_validators`, so one key is a valid proposer for infinitely many slots. A single validly-signed block on a reachable parent (e.g. genesis) could therefore drive an unbounded empty-slot walk. ethlambda is not vulnerable to the unbounded *loop* (`process_slots` jumps straight to the target slot rather than iterating) and `process_block_header` already caps the `historical_block_hashes` allocation via `SlotGapTooLarge`. This PR adds the spec's two guards at the untrusted-input boundary so a crafted block is rejected *cheaply*, before signature verification. ## Changes `crates/blockchain/src/store.rs`, in `on_block_core` before signature verification: - **Parent-gap cap:** reject `block.slot - parent.slot > HISTORICAL_ROOTS_LIMIT` (`BlockSlotGapTooLarge`). Clock-independent; bounds the walk directly. - **Clock horizon:** reject `block.slot > current_slot + 1` (`BlockTooFarInFuture`). Whole-slot margin, so an intended early block still imports (mirrors the attestation future-slot guard, but with a whole-slot rather than one-interval margin). The existing STF-level `SlotGapTooLarge` in `process_block_header` is kept as defense-in-depth. ## Tests - `on_block_rejects_block_too_far_in_future` - `on_block_rejects_block_slot_gap_too_large` `cargo fmt`, `clippy -D warnings`, and the blockchain lib tests pass. > ⚠️ The horizon guard also runs via `on_block_without_verification` (fork-choice spec tests). The runner ticks to each block's slot before delivery (`tick_to_slot`), so normal progression fixtures are unaffected; only deliberate early/future vectors exercise it, and #1182 keeps a 1-slot margin for backward-compat. I could not run `forkchoice_spectests` here (`leanSpec/fixtures` not downloaded) — recommend running it against fresh fixtures before merge.
1 parent 699599c commit d96e524

1 file changed

Lines changed: 110 additions & 1 deletion

File tree

crates/blockchain/src/store.rs

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use ethlambda_types::{
1212
checkpoint::Checkpoint,
1313
primitives::{H256, HashTreeRoot as _},
1414
signature::{ValidatorPublicKey, ValidatorSignature},
15-
state::State,
15+
state::{HISTORICAL_ROOTS_LIMIT, State},
1616
};
1717
use tracing::{info, trace, warn};
1818

@@ -535,6 +535,31 @@ fn on_block_core(
535535
slot,
536536
})?;
537537

538+
// Bound the block's slot before the state transition runs (leanSpec #1182).
539+
//
540+
// The transition advances the state one slot at a time from the parent up to
541+
// `block.slot`, so a block far beyond its parent, or far in the future, would
542+
// drive that walk unboundedly. Both guards live here at the untrusted-input
543+
// boundary and run before the expensive signature verification, so a crafted
544+
// block is rejected cheaply.
545+
let slot_gap = slot.saturating_sub(parent_state.slot);
546+
if slot_gap > HISTORICAL_ROOTS_LIMIT as u64 {
547+
return Err(StoreError::BlockSlotGapTooLarge {
548+
gap: slot_gap,
549+
max: HISTORICAL_ROOTS_LIMIT as u64,
550+
});
551+
}
552+
// Horizon is the current slot plus one whole slot of margin, so an intended
553+
// early block still imports (mirrors the attestation future-slot guard, but
554+
// with a whole-slot rather than one-interval margin).
555+
let current_slot = store.time() / INTERVALS_PER_SLOT;
556+
if slot > current_slot + 1 {
557+
return Err(StoreError::BlockTooFarInFuture {
558+
block_slot: slot,
559+
current_slot,
560+
});
561+
}
562+
538563
// Each unique AttestationData must appear at most once per block.
539564
let attestations = &signed_block.message.body.attestations;
540565
let mut seen = HashSet::with_capacity(attestations.len());
@@ -949,6 +974,12 @@ pub enum StoreError {
949974

950975
#[error("Block contains {count} distinct AttestationData entries; maximum is {max}")]
951976
TooManyAttestationData { count: usize, max: usize },
977+
978+
#[error("Block slot gap {gap} beyond parent exceeds historical roots limit {max}")]
979+
BlockSlotGapTooLarge { gap: u64, max: u64 },
980+
981+
#[error("Block slot {block_slot} is beyond the future horizon (current slot: {current_slot})")]
982+
BlockTooFarInFuture { block_slot: u64, current_slot: u64 },
952983
}
953984

954985
/// Full verification of a signed block's merged multi-message aggregate proof.
@@ -1500,4 +1531,82 @@ mod tests {
15001531
"fully canonical vote must validate"
15011532
);
15021533
}
1534+
1535+
/// leanSpec #1182: a block whose slot is more than one slot past the store
1536+
/// clock is rejected before the state transition (and before signature
1537+
/// verification), keeping far-future blocks out of the store.
1538+
#[test]
1539+
fn on_block_rejects_block_too_far_in_future() {
1540+
use ethlambda_storage::backend::InMemoryBackend;
1541+
use std::sync::Arc;
1542+
1543+
let genesis_state = State::from_genesis(1000, vec![]);
1544+
let backend = Arc::new(InMemoryBackend::new());
1545+
let mut store = Store::from_anchor_state(backend, genesis_state);
1546+
store.set_time(0).expect("set_time should succeed");
1547+
1548+
// current_slot = 0, so the horizon is slot 1; a slot-2 block overshoots it.
1549+
let block = Block {
1550+
slot: 2,
1551+
proposer_index: 0,
1552+
parent_root: store.head(),
1553+
state_root: H256::ZERO,
1554+
body: BlockBody::default(),
1555+
};
1556+
let signed_block = SignedBlock {
1557+
message: block,
1558+
proof: MultiMessageAggregate::default(),
1559+
};
1560+
1561+
let result = on_block_without_verification(&mut store, signed_block);
1562+
assert!(
1563+
matches!(
1564+
result,
1565+
Err(StoreError::BlockTooFarInFuture {
1566+
block_slot: 2,
1567+
current_slot: 0,
1568+
})
1569+
),
1570+
"Expected BlockTooFarInFuture, got: {result:?}"
1571+
);
1572+
}
1573+
1574+
/// leanSpec #1182: a block whose slot runs more than HISTORICAL_ROOTS_LIMIT
1575+
/// beyond its parent is rejected up front, so the transition never walks the
1576+
/// empty-slot loop over an unbounded range. The gap guard runs before the
1577+
/// future-horizon guard, so it fires even with the clock at genesis.
1578+
#[test]
1579+
fn on_block_rejects_block_slot_gap_too_large() {
1580+
use ethlambda_storage::backend::InMemoryBackend;
1581+
use std::sync::Arc;
1582+
1583+
let genesis_state = State::from_genesis(1000, vec![]);
1584+
let backend = Arc::new(InMemoryBackend::new());
1585+
let mut store = Store::from_anchor_state(backend, genesis_state);
1586+
store.set_time(0).expect("set_time should succeed");
1587+
1588+
// Parent (genesis) sits at slot 0, so a slot one past the limit overshoots.
1589+
let gap_slot = HISTORICAL_ROOTS_LIMIT as u64 + 1;
1590+
let block = Block {
1591+
slot: gap_slot,
1592+
proposer_index: 0,
1593+
parent_root: store.head(),
1594+
state_root: H256::ZERO,
1595+
body: BlockBody::default(),
1596+
};
1597+
let signed_block = SignedBlock {
1598+
message: block,
1599+
proof: MultiMessageAggregate::default(),
1600+
};
1601+
1602+
let result = on_block_without_verification(&mut store, signed_block);
1603+
assert!(
1604+
matches!(
1605+
result,
1606+
Err(StoreError::BlockSlotGapTooLarge { gap, max })
1607+
if gap == gap_slot && max == HISTORICAL_ROOTS_LIMIT as u64
1608+
),
1609+
"Expected BlockSlotGapTooLarge, got: {result:?}"
1610+
);
1611+
}
15031612
}

0 commit comments

Comments
 (0)