Skip to content

Commit 718768e

Browse files
committed
fix: align fork-choice finality and aggregate validation with leanSpec
Three spec-test fixtures from the latest leanSpec release failed: - test_losing_fork_higher_finalized_does_not_latch: finalized was latched monotonically in on_block_core. The spec derives the finalized checkpoint from the chosen head's post-state in update_head and pins it to the head's own ancestry, so a fork switch can move finalization downward. Move that computation into update_head; keep justified monotonic (advance_to) as the spec does. - test_gossip_aggregated_attestation_empty_participants_rejected: an aggregate naming no participants slipped through the mocked-proof path. Reject empty participants (EMPTY_AGGREGATION_BITS) before bounds-check and verification, matching the spec's to_validator_indices(). - test_genesis_registry_holds_exact_validator_entries: the stf runner rejected the new post-state 'validators' registry check (deny_unknown_fields). Parse it and compare each validator's index and public keys.
1 parent 949abfd commit 718768e

3 files changed

Lines changed: 90 additions & 6 deletions

File tree

crates/blockchain/src/store.rs

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,17 @@ pub fn update_head(store: &mut Store, log_tree: bool) {
5252
metrics::observe_fork_choice_reorg_depth(depth);
5353
info!(%old_head, %new_head, depth, "Fork choice reorg detected");
5454
}
55-
store.update_checkpoints(ForkCheckpoints::head_only(new_head));
55+
56+
// Keep the finalized checkpoint on the head's chain (leanSpec `update_head`).
57+
//
58+
// Finalization is *not* a monotonic max over everything ever seen: it is
59+
// derived from the chosen head's post-state and pinned to the block at that
60+
// slot on the head's own ancestry. When fork choice switches to a fork that
61+
// finalized less than a now-losing fork, finalized must follow the head
62+
// downward rather than latch the higher value (which would let a losing
63+
// fork's finalization persist on a chain that never contained it).
64+
let finalized = recompute_finalized(store, new_head);
65+
store.update_checkpoints(ForkCheckpoints::new(new_head, None, finalized));
5666

5767
if old_head != new_head {
5868
let old_slot = store
@@ -88,6 +98,33 @@ pub fn update_head(store: &mut Store, log_tree: bool) {
8898
}
8999
}
90100

101+
/// Resolve the finalized checkpoint that sits on `head`'s chain.
102+
///
103+
/// Mirrors leanSpec `update_head`: take the finalized slot recorded in the
104+
/// head's post-state, then climb the head's parent chain to the block at that
105+
/// slot. Returns `None` (caller keeps the existing checkpoint) when no block is
106+
/// stored at that slot, which happens only for a checkpoint-sync anchor whose
107+
/// pre-anchor history is absent.
108+
fn recompute_finalized(store: &Store, head: H256) -> Option<Checkpoint> {
109+
let finalized_slot = store.get_state(&head)?.latest_finalized.slot;
110+
111+
let mut finalized_root = head;
112+
while let Some(header) = store.get_block_header(&finalized_root) {
113+
if header.slot <= finalized_slot {
114+
break;
115+
}
116+
finalized_root = header.parent_root;
117+
}
118+
119+
store
120+
.get_block_header(&finalized_root)
121+
.filter(|header| header.slot == finalized_slot)
122+
.map(|_| Checkpoint {
123+
root: finalized_root,
124+
slot: finalized_slot,
125+
})
126+
}
127+
91128
/// Update the safe target for attestation.
92129
///
93130
/// Safe target is an *availability* signal, not a durable-knowledge signal:
@@ -406,6 +443,12 @@ fn on_gossip_aggregated_attestation_core(
406443
let num_validators = validators.len() as u64;
407444

408445
let participant_indices: Vec<u64> = aggregated.proof.participant_indices().collect();
446+
// An aggregate must name at least one signer. The spec rejects empty
447+
// participants (EMPTY_AGGREGATION_BITS) when resolving validator indices,
448+
// before any bounds check or signature verification.
449+
if participant_indices.is_empty() {
450+
return Err(StoreError::EmptyAggregationBits);
451+
}
409452
if participant_indices.iter().any(|&vid| vid >= num_validators) {
410453
return Err(StoreError::InvalidValidatorIndex);
411454
}
@@ -547,14 +590,14 @@ fn on_block_core(
547590
let state_root = block.state_root;
548591
post_state.latest_block_header.state_root = state_root;
549592

550-
// Update justified/finalized checkpoints if they have higher slots
593+
// Advance the justified checkpoint when the post-state names a higher one
594+
// (leanSpec `advance_to`: monotonic by slot). Finalized is intentionally not
595+
// latched here; it is recomputed from the head's chain in `update_head`.
551596
let justified = (post_state.latest_justified.slot > store.latest_justified().slot)
552597
.then_some(post_state.latest_justified);
553-
let finalized = (post_state.latest_finalized.slot > store.latest_finalized().slot)
554-
.then_some(post_state.latest_finalized);
555598

556-
if justified.is_some() || finalized.is_some() {
557-
store.update_checkpoints(ForkCheckpoints::new(store.head(), justified, finalized));
599+
if let Some(justified) = justified {
600+
store.update_checkpoints(ForkCheckpoints::new(store.head(), Some(justified), None));
558601
}
559602

560603
// Store signed block and state
@@ -866,6 +909,9 @@ pub enum StoreError {
866909
#[error("Missing target state for block: {0}")]
867910
MissingTargetState(H256),
868911

912+
#[error("Aggregated attestation references no participants")]
913+
EmptyAggregationBits,
914+
869915
#[error("Validator {validator_index} is not the proposer for slot {slot}")]
870916
NotProposer { validator_index: u64, slot: u64 },
871917

crates/blockchain/state_transition/tests/stf_spectests.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ fn compare_post_states(
110110
justifications_roots,
111111
justifications_validators,
112112
validator_count,
113+
validators,
113114
latest_justified_root_label,
114115
latest_finalized_root_label,
115116
justifications_roots_labels,
@@ -275,6 +276,39 @@ fn compare_post_states(
275276
.into());
276277
}
277278
}
279+
if let Some(validators) = validators {
280+
let expected = &validators.data;
281+
let post_validators: Vec<_> = post_state.validators.iter().collect();
282+
if post_validators.len() != expected.len() {
283+
return Err(format!(
284+
"validators count mismatch: expected {}, got {}",
285+
expected.len(),
286+
post_validators.len()
287+
)
288+
.into());
289+
}
290+
for (i, expected_validator) in expected.iter().enumerate() {
291+
let expected_domain: ethlambda_types::state::Validator =
292+
expected_validator.clone().into();
293+
let actual = post_validators[i];
294+
if actual.index != expected_domain.index
295+
|| actual.attestation_pubkey != expected_domain.attestation_pubkey
296+
|| actual.proposal_pubkey != expected_domain.proposal_pubkey
297+
{
298+
return Err(format!(
299+
"validator[{i}] mismatch: expected index={} att={} prop={}, \
300+
got index={} att={} prop={}",
301+
expected_domain.index,
302+
hex::encode(expected_domain.attestation_pubkey),
303+
hex::encode(expected_domain.proposal_pubkey),
304+
actual.index,
305+
hex::encode(actual.attestation_pubkey),
306+
hex::encode(actual.proposal_pubkey),
307+
)
308+
.into());
309+
}
310+
}
311+
}
278312
if let Some(label) = latest_justified_root_label {
279313
let expected = resolve_label(label, block_registry)?;
280314
if post_state.latest_justified.root != expected {

crates/blockchain/state_transition/tests/types.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ pub struct PostState {
8282
#[serde(rename = "validatorCount")]
8383
pub validator_count: Option<u64>,
8484

85+
/// Full validator registry check: each entry's index and public keys must
86+
/// match the post-state registry exactly.
87+
pub validators: Option<Container<Validator>>,
88+
8589
// Label-based root checks: "block_N" labels resolved to hash_tree_root of the Nth block.
8690
#[serde(rename = "latestJustifiedRootLabel")]
8791
pub latest_justified_root_label: Option<String>,

0 commit comments

Comments
 (0)