Skip to content

Commit 1f0a0be

Browse files
fix(blockchain): allow older-but-justified sources in build_block (lambdaclass#366)
## Summary Mirrors [leanSpec#716](leanEthereum/leanSpec#716). The fixed-point attestation loop in `build_block` required `att.source` to equal `current_justified` exactly. When the store's justified checkpoint has advanced via a sibling fork while the canonical head's chain has only proven an earlier slot, the head chain's pool holds a gap-closing attestation whose source differs from the head's `latest_justified` checkpoint. The exact-match filter dropped it and block production aborted with `JustifiedDivergenceNotClosed` ("Fixed-point attestation loop did not converge"). ### Scenario (from the spec PR) ``` block_4(4) -- block_5(5) <-- head / genesis -- 1 -- 2 -- 3 \ block_6(6) ``` - `block_4`: 6/8 attest `target=block_1` (justifies slot 1). - `block_5`: 2/8 attest `target=block_4` (fork-choice weight only). - `block_6` (sibling of block_4): 6/8 attest `target=block_2` (justifies slot 2 on the store). Fork choice picks `block_5` as head; the store's `latest_justified` is `block_2`, but `block_5`'s post-state still sits at `block_1`. The proposer at slot 7 builds on `block_5` and must absorb `block_6`'s attestation from the gossip pool to close the gap. ## Changes In `build_block` (`crates/blockchain/src/store.rs`): - Relax the source filter from `att.source != current_justified` to `att.source.slot > current_justified.slot`, so older-but-already-justified sources can flow through. - Build a chain view that `process_block_header` would produce on the candidate block (the head's `historical_block_hashes` plus `parent_root` at the parent slot plus `H256::ZERO` for each empty slot between parent and the candidate) and reject attestations whose `source.root` or `target.root` do not match it. Prevents pulling in attestations about other forks now that the exact source check is gone. - Skip attestations whose target slot is already justified on the chain, with a genesis self-vote (`source.slot == 0 && target.slot == 0`) exception for fork-choice bootstrapping. - Track `current_finalized_slot` and `current_justified_slots` alongside `current_justified`, refreshing them from `post_state` whenever the STF iteration advances either checkpoint. In `state_transition`: - Add a public `attestation_data_matches_chain(&[H256], &AttestationData) -> bool` helper. - Make the `justified_slots_ops` module `pub` so `is_slot_justified` can be reused from `build_block` without duplicating the relative-index logic. The existing process_attestations path already factored the source/target chain match into `checkpoint_exists` and is left unchanged. ### Test update `build_block_caps_attestation_data_entries` previously used fake target roots that don't appear in the chain. Restructured the setup to build a head state at slot 51 with populated `historical_block_hashes` and vary `AttestationData.slot` (rather than the target root) to produce distinct entries. Still verifies the `MAX_ATTESTATIONS_DATA` cap and gossip-size bound. ## Test plan - [x] `cargo fmt --all` - [x] `cargo clippy --workspace --all-targets -- -D warnings` - [x] `cargo test --workspace --release` (including `forkchoice_spectests`, `stf_spectests`, `signature_spectests` against the pinned leanSpec fixtures) - [ ] Multi-client devnet run to confirm no regression in steady-state finalization > Note: the leanSpec spec test for this scenario (`test_block_production_justification_gap.py`) is not yet in the pinned `LEAN_SPEC_COMMIT_HASH`. A follow-up commit hash bump in `Makefile` will pick it up automatically. --------- Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
1 parent fc66a05 commit 1f0a0be

2 files changed

Lines changed: 264 additions & 55 deletions

File tree

crates/blockchain/src/store.rs

Lines changed: 228 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ use std::collections::{HashMap, HashSet};
22

33
use ethlambda_crypto::aggregate_proofs;
44
use ethlambda_state_transition::{
5-
is_proposer, process_block, process_slots, slot_is_justifiable_after,
5+
attestation_data_matches_chain, is_proposer, justified_slots_ops, process_block, process_slots,
6+
slot_is_justifiable_after,
67
};
78
use ethlambda_storage::{ForkCheckpoints, Store};
89
use ethlambda_types::{
@@ -1050,17 +1051,20 @@ fn build_block(
10501051
let mut selected: Vec<(AggregatedAttestation, AggregatedSignatureProof)> = Vec::new();
10511052

10521053
if !aggregated_payloads.is_empty() {
1053-
// Genesis edge case: when building on genesis (slot 0),
1054-
// process_block_header will set latest_justified.root = parent_root.
1055-
// Derive this upfront so attestation filtering matches.
1056-
let mut current_justified = if head_state.latest_block_header.slot == 0 {
1057-
Checkpoint {
1058-
root: parent_root,
1059-
slot: head_state.latest_justified.slot,
1060-
}
1061-
} else {
1062-
head_state.latest_justified
1063-
};
1054+
let mut current_justified = head_state.latest_justified;
1055+
let mut current_finalized_slot = head_state.latest_finalized.slot;
1056+
let mut current_justified_slots = head_state.justified_slots.clone();
1057+
1058+
// Chain view that `process_block_header` would produce on the candidate
1059+
// block: covering [0, slot - 1] with parent_root at parent.slot and
1060+
// ZERO_HASH for empty slots in between. Lets us validate source/target
1061+
// roots without waiting for the STF to drop mismatches.
1062+
let parent_slot = head_state.latest_block_header.slot;
1063+
let num_empty_slots = slot.saturating_sub(parent_slot).saturating_sub(1) as usize;
1064+
let mut extended_historical_block_hashes: Vec<H256> =
1065+
head_state.historical_block_hashes.iter().copied().collect();
1066+
extended_historical_block_hashes.push(parent_root);
1067+
extended_historical_block_hashes.extend(std::iter::repeat_n(H256::ZERO, num_empty_slots));
10641068

10651069
let mut processed_data_roots: HashSet<H256> = HashSet::new();
10661070

@@ -1082,7 +1086,30 @@ fn build_block(
10821086
if !known_block_roots.contains(&att_data.head.root) {
10831087
continue;
10841088
}
1085-
if att_data.source != current_justified {
1089+
if !justified_slots_ops::is_slot_justified(
1090+
&current_justified_slots,
1091+
current_finalized_slot,
1092+
att_data.source.slot,
1093+
) {
1094+
continue;
1095+
}
1096+
1097+
if !attestation_data_matches_chain(&extended_historical_block_hashes, att_data) {
1098+
continue;
1099+
}
1100+
1101+
// Skip attestations whose target slot is already justified on
1102+
// this chain (they wouldn't change post-state). Allow the
1103+
// genesis self-vote (source=target=0) for fork-choice
1104+
// bootstrapping.
1105+
let is_genesis_self_vote = att_data.source.slot == 0 && att_data.target.slot == 0;
1106+
if !is_genesis_self_vote
1107+
&& justified_slots_ops::is_slot_justified(
1108+
&current_justified_slots,
1109+
current_finalized_slot,
1110+
att_data.target.slot,
1111+
)
1112+
{
10861113
continue;
10871114
}
10881115

@@ -1096,7 +1123,7 @@ fn build_block(
10961123
break;
10971124
}
10981125

1099-
// Check if justification advanced
1126+
// Check if justification or finalization advanced
11001127
let attestations: AggregatedAttestations = selected
11011128
.iter()
11021129
.map(|(att, _)| att.clone())
@@ -1114,8 +1141,12 @@ fn build_block(
11141141
process_slots(&mut post_state, slot)?;
11151142
process_block(&mut post_state, &candidate)?;
11161143

1117-
if post_state.latest_justified != current_justified {
1144+
if post_state.latest_justified != current_justified
1145+
|| post_state.latest_finalized.slot != current_finalized_slot
1146+
{
11181147
current_justified = post_state.latest_justified;
1148+
current_justified_slots = post_state.justified_slots.clone();
1149+
current_finalized_slot = post_state.latest_finalized.slot;
11191150
// Continue: new checkpoint may unlock more attestation data
11201151
} else {
11211152
break;
@@ -1396,6 +1427,10 @@ mod tests {
13961427
/// at MAX_ATTESTATIONS_DATA (16) and stays under the gossip size limit.
13971428
#[test]
13981429
fn build_block_caps_attestation_data_entries() {
1430+
use ethlambda_types::{
1431+
block::BlockHeader,
1432+
state::{ChainConfig, JustificationValidators, JustifiedSlots},
1433+
};
13991434
use libssz::SszEncode;
14001435
use libssz_types::SszList;
14011436

@@ -1404,55 +1439,85 @@ mod tests {
14041439
const NUM_VALIDATORS: usize = 50;
14051440
const NUM_PAYLOAD_ENTRIES: usize = 50;
14061441

1407-
// Create genesis state with NUM_VALIDATORS validators.
1442+
const HEAD_SLOT: u64 = 51;
1443+
const TARGET_SLOT: u64 = 5;
1444+
14081445
let validators: Vec<_> = (0..NUM_VALIDATORS)
14091446
.map(|i| ethlambda_types::state::Validator {
14101447
attestation_pubkey: [i as u8; 52],
14111448
proposal_pubkey: [i as u8; 52],
14121449
index: i as u64,
14131450
})
14141451
.collect();
1415-
let head_state = State::from_genesis(1000, validators);
14161452

1417-
// process_slots fills in the genesis header's state_root before
1453+
// Build a head state at slot HEAD_SLOT with valid historical_block_hashes
1454+
// so attestations referencing in-range slots match the chain (the
1455+
// chain-match check in build_block now rejects mismatches).
1456+
let hashes: Vec<H256> = (0..HEAD_SLOT).map(|i| H256([(i + 1) as u8; 32])).collect();
1457+
let historical_block_hashes = SszList::try_from(hashes.clone()).unwrap();
1458+
1459+
let head_header = BlockHeader {
1460+
slot: HEAD_SLOT,
1461+
proposer_index: 0,
1462+
parent_root: H256::ZERO,
1463+
state_root: H256::ZERO,
1464+
body_root: BlockBody::default().hash_tree_root(),
1465+
};
1466+
1467+
let head_state = State {
1468+
config: ChainConfig { genesis_time: 1000 },
1469+
slot: HEAD_SLOT,
1470+
latest_block_header: head_header,
1471+
latest_justified: Checkpoint::default(),
1472+
latest_finalized: Checkpoint::default(),
1473+
historical_block_hashes,
1474+
justified_slots: JustifiedSlots::new(),
1475+
validators: SszList::try_from(validators).unwrap(),
1476+
justifications_roots: Default::default(),
1477+
justifications_validators: JustificationValidators::new(),
1478+
};
1479+
1480+
// process_slots fills in the parent header's state_root before
14181481
// process_block_header computes the parent hash. Simulate that here.
14191482
let mut header_for_root = head_state.latest_block_header.clone();
14201483
header_for_root.state_root = head_state.hash_tree_root();
14211484
let parent_root = header_for_root.hash_tree_root();
14221485

1423-
// Proposer for slot 1 with NUM_VALIDATORS validators: 1 % 50 = 1
1424-
let proposer_index = 1u64;
1425-
let slot = 1u64;
1486+
let slot = HEAD_SLOT + 1;
1487+
let proposer_index = slot % NUM_VALIDATORS as u64;
14261488

1427-
// The genesis edge case in build_block sets current_justified to:
1428-
// Checkpoint { root: parent_root, slot: 0 }
1489+
// Common source / target / head referencing valid chain entries so the
1490+
// chain-match check passes for every payload. We vary AttestationData.slot
1491+
// alone to produce 50 distinct data_roots.
14291492
let source = Checkpoint {
1430-
root: parent_root,
1493+
root: hashes[0],
1494+
slot: 0,
1495+
};
1496+
let target = Checkpoint {
1497+
root: hashes[TARGET_SLOT as usize],
1498+
slot: TARGET_SLOT,
1499+
};
1500+
let head = Checkpoint {
1501+
root: hashes[0],
14311502
slot: 0,
14321503
};
14331504

14341505
let mut known_block_roots = HashSet::new();
14351506
known_block_roots.insert(parent_root);
1507+
known_block_roots.insert(hashes[0]);
14361508

14371509
// Simulate a stall: populate the payload pool with many distinct entries.
1438-
// Each has a unique target (different slot) and a large proof payload.
1510+
// Each has a unique attestation slot and a large proof payload.
14391511
let mut aggregated_payloads: HashMap<
14401512
H256,
14411513
(AttestationData, Vec<AggregatedSignatureProof>),
14421514
> = HashMap::new();
14431515

14441516
for i in 0..NUM_PAYLOAD_ENTRIES {
1445-
let target_slot = (i + 1) as u64;
14461517
let att_data = AttestationData {
1447-
slot: target_slot,
1448-
head: Checkpoint {
1449-
root: parent_root,
1450-
slot: 0,
1451-
},
1452-
target: Checkpoint {
1453-
root: H256([target_slot as u8; 32]),
1454-
slot: target_slot,
1455-
},
1518+
slot: (i + 1) as u64,
1519+
head,
1520+
target,
14561521
source,
14571522
};
14581523

@@ -1513,6 +1578,134 @@ mod tests {
15131578
);
15141579
}
15151580

1581+
/// Regression test for leanSpec PR #716: build_block must absorb
1582+
/// gap-closing attestations whose source is justified on the head
1583+
/// chain but older than `latest_justified` (e.g., a sibling fork
1584+
/// advanced the store's justified past what the canonical head has
1585+
/// proven). Without the relaxed `is_slot_justified(source.slot)`
1586+
/// filter, the exact-equality check would drop the attestation and
1587+
/// justification would never converge on this chain.
1588+
#[test]
1589+
fn build_block_absorbs_older_but_justified_source() {
1590+
use ethlambda_state_transition::justified_slots_ops;
1591+
use ethlambda_types::{
1592+
block::BlockHeader,
1593+
state::{ChainConfig, JustificationValidators, JustifiedSlots},
1594+
};
1595+
use libssz_types::SszList;
1596+
1597+
const NUM_VALIDATORS: usize = 50;
1598+
const SUPERMAJORITY: usize = 34; // ceil(2 * 50 / 3)
1599+
const HEAD_SLOT: u64 = 5;
1600+
const JUSTIFIED_SLOT: u64 = 1;
1601+
const GAP_TARGET_SLOT: u64 = 2;
1602+
1603+
let validators: Vec<_> = (0..NUM_VALIDATORS)
1604+
.map(|i| ethlambda_types::state::Validator {
1605+
attestation_pubkey: [i as u8; 52],
1606+
proposal_pubkey: [i as u8; 52],
1607+
index: i as u64,
1608+
})
1609+
.collect();
1610+
1611+
let hashes: Vec<H256> = (0..HEAD_SLOT).map(|i| H256([(i + 1) as u8; 32])).collect();
1612+
1613+
let mut justified_slots = JustifiedSlots::new();
1614+
justified_slots_ops::extend_to_slot(&mut justified_slots, 0, JUSTIFIED_SLOT);
1615+
justified_slots_ops::set_justified(&mut justified_slots, 0, JUSTIFIED_SLOT);
1616+
1617+
let head_header = BlockHeader {
1618+
slot: HEAD_SLOT,
1619+
proposer_index: 0,
1620+
parent_root: H256::ZERO,
1621+
state_root: H256::ZERO,
1622+
body_root: BlockBody::default().hash_tree_root(),
1623+
};
1624+
1625+
let head_state = State {
1626+
config: ChainConfig { genesis_time: 1000 },
1627+
slot: HEAD_SLOT,
1628+
latest_block_header: head_header,
1629+
latest_justified: Checkpoint {
1630+
root: hashes[JUSTIFIED_SLOT as usize],
1631+
slot: JUSTIFIED_SLOT,
1632+
},
1633+
latest_finalized: Checkpoint::default(),
1634+
historical_block_hashes: SszList::try_from(hashes.clone()).unwrap(),
1635+
justified_slots,
1636+
validators: SszList::try_from(validators).unwrap(),
1637+
justifications_roots: Default::default(),
1638+
justifications_validators: JustificationValidators::new(),
1639+
};
1640+
1641+
let mut header_for_root = head_state.latest_block_header.clone();
1642+
header_for_root.state_root = head_state.hash_tree_root();
1643+
let parent_root = header_for_root.hash_tree_root();
1644+
1645+
let slot = HEAD_SLOT + 1;
1646+
let proposer_index = slot % NUM_VALIDATORS as u64;
1647+
1648+
// source = genesis (slot 0): older than head.latest_justified at
1649+
// slot 1. Pre-PR exact-equality filter would drop this; post-PR
1650+
// it's absorbed and the candidate justifies GAP_TARGET_SLOT.
1651+
let att_data = AttestationData {
1652+
slot,
1653+
head: Checkpoint {
1654+
root: hashes[0],
1655+
slot: 0,
1656+
},
1657+
target: Checkpoint {
1658+
root: hashes[GAP_TARGET_SLOT as usize],
1659+
slot: GAP_TARGET_SLOT,
1660+
},
1661+
source: Checkpoint {
1662+
root: hashes[0],
1663+
slot: 0,
1664+
},
1665+
};
1666+
let data_root = att_data.hash_tree_root();
1667+
1668+
let mut bits = AggregationBits::with_length(NUM_VALIDATORS).unwrap();
1669+
for i in 0..SUPERMAJORITY {
1670+
bits.set(i, true).unwrap();
1671+
}
1672+
let proof = AggregatedSignatureProof::new(bits, SszList::try_from(vec![0xAB; 64]).unwrap());
1673+
1674+
let mut aggregated_payloads = HashMap::new();
1675+
aggregated_payloads.insert(data_root, (att_data.clone(), vec![proof]));
1676+
1677+
let mut known_block_roots = HashSet::new();
1678+
known_block_roots.insert(parent_root);
1679+
known_block_roots.insert(hashes[0]);
1680+
1681+
let (block, _signatures, post_checkpoints) = build_block(
1682+
&head_state,
1683+
slot,
1684+
proposer_index,
1685+
parent_root,
1686+
&known_block_roots,
1687+
&aggregated_payloads,
1688+
)
1689+
.expect("build_block should succeed");
1690+
1691+
let targets: Vec<_> = block
1692+
.body
1693+
.attestations
1694+
.iter()
1695+
.map(|att| att.data.target)
1696+
.collect();
1697+
assert!(
1698+
targets.contains(&att_data.target),
1699+
"produced block missing gap-closing attestation: {targets:?}"
1700+
);
1701+
1702+
assert_eq!(post_checkpoints.justified.slot, GAP_TARGET_SLOT);
1703+
assert_eq!(
1704+
post_checkpoints.justified.root,
1705+
hashes[GAP_TARGET_SLOT as usize]
1706+
);
1707+
}
1708+
15161709
fn make_att_data(slot: u64) -> AttestationData {
15171710
AttestationData {
15181711
slot,

0 commit comments

Comments
 (0)