Merge branch 'unstable' of https://github.com/sigp/lighthouse into gloas-fc-proto

This commit is contained in:
Eitan Seri- Levi
2026-03-13 04:52:21 -07:00
66 changed files with 3735 additions and 908 deletions

View File

@@ -1053,6 +1053,240 @@ async fn proposer_duties_with_gossip_tolerance() {
);
}
// Test that a request for next epoch v2 proposer duties succeeds when the current slot clock is
// within gossip clock disparity (500ms) of the new epoch.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn proposer_duties_v2_with_gossip_tolerance() {
let validator_count = 24;
let tester = InteractiveTester::<E>::new(None, validator_count).await;
let harness = &tester.harness;
let spec = &harness.spec;
let client = &tester.client;
let num_initial = 4 * E::slots_per_epoch() - 1;
let next_epoch_start_slot = Slot::new(num_initial + 1);
harness.advance_slot();
harness
.extend_chain_with_sync(
num_initial as usize,
BlockStrategy::OnCanonicalHead,
AttestationStrategy::AllValidators,
SyncCommitteeStrategy::NoValidators,
LightClientStrategy::Disabled,
)
.await;
assert_eq!(harness.chain.slot().unwrap(), num_initial);
// Set the clock to just before the next epoch.
harness.chain.slot_clock.advance_time(
Duration::from_secs(spec.seconds_per_slot) - spec.maximum_gossip_clock_disparity(),
);
assert_eq!(
harness
.chain
.slot_clock
.now_with_future_tolerance(spec.maximum_gossip_clock_disparity())
.unwrap(),
next_epoch_start_slot
);
let head_state = harness.get_current_state();
let head_block_root = harness.head_block_root();
let tolerant_current_epoch = next_epoch_start_slot.epoch(E::slots_per_epoch());
// Prime the proposer shuffling cache with an incorrect entry (regression test).
let wrong_decision_root = head_state
.proposer_shuffling_decision_root(head_block_root, spec)
.unwrap();
let wrong_proposer_indices = vec![0; E::slots_per_epoch() as usize];
harness
.chain
.beacon_proposer_cache
.lock()
.insert(
tolerant_current_epoch,
wrong_decision_root,
wrong_proposer_indices.clone(),
head_state.fork(),
)
.unwrap();
// Request the v2 proposer duties.
let proposer_duties_tolerant_current_epoch = client
.get_validator_duties_proposer_v2(tolerant_current_epoch)
.await
.unwrap();
assert_eq!(
proposer_duties_tolerant_current_epoch.dependent_root,
head_state
.proposer_shuffling_decision_root_at_epoch(
tolerant_current_epoch,
head_block_root,
spec,
)
.unwrap()
);
assert_ne!(
proposer_duties_tolerant_current_epoch
.data
.iter()
.map(|data| data.validator_index as usize)
.collect::<Vec<_>>(),
wrong_proposer_indices,
);
// We should get the exact same result after properly advancing into the epoch.
harness
.chain
.slot_clock
.advance_time(spec.maximum_gossip_clock_disparity());
assert_eq!(harness.chain.slot().unwrap(), next_epoch_start_slot);
let proposer_duties_current_epoch = client
.get_validator_duties_proposer_v2(tolerant_current_epoch)
.await
.unwrap();
assert_eq!(
proposer_duties_tolerant_current_epoch,
proposer_duties_current_epoch
);
}
// Test that post-Fulu, v1 and v2 proposer duties return different dependent roots.
// Post-Fulu, the true dependent root shifts to the block root at the end of epoch N-2 (due to
// `min_seed_lookahead`), while the legacy v1 root remains at the end of epoch N-1.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn proposer_duties_v2_post_fulu_dependent_root() {
type E = MinimalEthSpec;
let spec = test_spec::<E>();
if !spec.is_fulu_scheduled() {
return;
}
let validator_count = 24;
let slots_per_epoch = E::slots_per_epoch();
let tester = InteractiveTester::<E>::new(Some(spec.clone()), validator_count).await;
let harness = &tester.harness;
let client = &tester.client;
let mock_el = harness.mock_execution_layer.as_ref().unwrap();
mock_el.server.all_payloads_valid();
// Build 3 full epochs of chain so we're in epoch 3.
let num_slots = 3 * slots_per_epoch;
harness.advance_slot();
harness
.extend_chain_with_sync(
num_slots as usize,
BlockStrategy::OnCanonicalHead,
AttestationStrategy::AllValidators,
SyncCommitteeStrategy::AllValidators,
LightClientStrategy::Disabled,
)
.await;
let current_epoch = harness.chain.epoch().unwrap();
assert_eq!(current_epoch, Epoch::new(3));
// For epoch 3 with min_seed_lookahead=1:
// Post-Fulu decision slot: end of epoch N-2 = end of epoch 1 = slot 15
// Legacy decision slot: end of epoch N-1 = end of epoch 2 = slot 23
let true_decision_slot = Epoch::new(1).end_slot(slots_per_epoch);
let legacy_decision_slot = Epoch::new(2).end_slot(slots_per_epoch);
assert_eq!(true_decision_slot, Slot::new(15));
assert_eq!(legacy_decision_slot, Slot::new(23));
// Fetch the block roots at these slots to compute expected dependent roots.
let expected_v2_root = harness
.chain
.block_root_at_slot(true_decision_slot, beacon_chain::WhenSlotSkipped::Prev)
.unwrap()
.unwrap();
let expected_v1_root = harness
.chain
.block_root_at_slot(legacy_decision_slot, beacon_chain::WhenSlotSkipped::Prev)
.unwrap()
.unwrap();
// Sanity check: the two roots should be different since they refer to different blocks.
assert_ne!(
expected_v1_root, expected_v2_root,
"legacy and true decision roots should differ post-Fulu"
);
// Query v1 and v2 proposer duties for the current epoch.
let v1_result = client
.get_validator_duties_proposer(current_epoch)
.await
.unwrap();
let v2_result = client
.get_validator_duties_proposer_v2(current_epoch)
.await
.unwrap();
// The proposer assignments (data) must be identical.
assert_eq!(v1_result.data, v2_result.data);
// The dependent roots must differ.
assert_ne!(
v1_result.dependent_root, v2_result.dependent_root,
"v1 and v2 dependent roots should differ post-Fulu"
);
// Verify each root matches the expected value.
assert_eq!(
v1_result.dependent_root, expected_v1_root,
"v1 dependent root should be block root at end of epoch N-1"
);
assert_eq!(
v2_result.dependent_root, expected_v2_root,
"v2 dependent root should be block root at end of epoch N-2"
);
// Also verify the next-epoch path (epoch 4).
let next_epoch = current_epoch + 1;
let v1_next = client
.get_validator_duties_proposer(next_epoch)
.await
.unwrap();
let v2_next = client
.get_validator_duties_proposer_v2(next_epoch)
.await
.unwrap();
assert_eq!(v1_next.data, v2_next.data);
assert_ne!(
v1_next.dependent_root, v2_next.dependent_root,
"v1 and v2 next-epoch dependent roots should differ post-Fulu"
);
// For epoch 4: true decision is end of epoch 2 (slot 23), legacy is end of epoch 3 (slot 31).
let expected_v2_next_root = harness
.chain
.block_root_at_slot(
Epoch::new(2).end_slot(slots_per_epoch),
beacon_chain::WhenSlotSkipped::Prev,
)
.unwrap()
.unwrap();
let expected_v1_next_root = harness
.chain
.block_root_at_slot(
Epoch::new(3).end_slot(slots_per_epoch),
beacon_chain::WhenSlotSkipped::Prev,
)
.unwrap()
.unwrap_or(harness.head_block_root());
assert_eq!(v1_next.dependent_root, expected_v1_next_root);
assert_eq!(v2_next.dependent_root, expected_v2_next_root);
assert_ne!(expected_v2_next_root, harness.head_block_root());
}
// Test that a request to `lighthouse/custody/backfill` succeeds by verifying that `CustodyContext` and `DataColumnCustodyInfo`
// have been updated with the correct values.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

View File

@@ -37,7 +37,7 @@ use proto_array::{ExecutionStatus, core::ProtoNode};
use reqwest::{RequestBuilder, Response, StatusCode};
use sensitive_url::SensitiveUrl;
use slot_clock::SlotClock;
use ssz::BitList;
use ssz::{BitList, Decode};
use state_processing::per_block_processing::get_expected_withdrawals;
use state_processing::per_slot_processing;
use state_processing::state_advance::partial_state_advance;
@@ -1409,6 +1409,73 @@ impl ApiTester {
self
}
pub async fn test_beacon_states_proposer_lookahead(self) -> Self {
for state_id in self.interesting_state_ids() {
let mut state_opt = state_id
.state(&self.chain)
.ok()
.map(|(state, _execution_optimistic, _finalized)| state);
let result = match self
.client
.get_beacon_states_proposer_lookahead(state_id.0)
.await
{
Ok(response) => response,
Err(e) => panic!("query failed incorrectly: {e:?}"),
};
if result.is_none() && state_opt.is_none() {
continue;
}
let state = state_opt.as_mut().expect("result should be none");
let expected = state.proposer_lookahead().unwrap().to_vec();
let response = result.unwrap();
// Compare Vec<u64> directly, not Vec<String>
assert_eq!(response.data().0, expected);
// Check that the version header is returned in the response
let fork_name = state.fork_name(&self.chain.spec).unwrap();
assert_eq!(response.version(), Some(fork_name),);
}
self
}
pub async fn test_beacon_states_proposer_lookahead_ssz(self) -> Self {
for state_id in self.interesting_state_ids() {
let mut state_opt = state_id
.state(&self.chain)
.ok()
.map(|(state, _execution_optimistic, _finalized)| state);
let result = match self
.client
.get_beacon_states_proposer_lookahead_ssz(state_id.0)
.await
{
Ok(response) => response,
Err(e) => panic!("query failed incorrectly: {e:?}"),
};
if result.is_none() && state_opt.is_none() {
continue;
}
let state = state_opt.as_mut().expect("result should be none");
let expected = state.proposer_lookahead().unwrap();
let ssz_bytes = result.unwrap();
let decoded = Vec::<u64>::from_ssz_bytes(&ssz_bytes)
.expect("should decode SSZ proposer lookahead");
assert_eq!(decoded, expected.to_vec());
}
self
}
pub async fn test_beacon_headers_all_slots(self) -> Self {
for slot in 0..CHAIN_LENGTH {
let slot = Slot::from(slot);
@@ -3402,6 +3469,80 @@ impl ApiTester {
self
}
pub async fn test_get_validator_duties_proposer_v2(self) -> Self {
let current_epoch = self.chain.epoch().unwrap();
for epoch in 0..=current_epoch.as_u64() + 1 {
let epoch = Epoch::from(epoch);
// Compute the true dependent root using the spec's decision slot.
let decision_slot = self.chain.spec.proposer_shuffling_decision_slot::<E>(epoch);
let dependent_root = self
.chain
.block_root_at_slot(decision_slot, WhenSlotSkipped::Prev)
.unwrap()
.unwrap_or(self.chain.head_beacon_block_root());
let result = self
.client
.get_validator_duties_proposer_v2(epoch)
.await
.unwrap();
let mut state = self
.chain
.state_at_slot(
epoch.start_slot(E::slots_per_epoch()),
StateSkipConfig::WithStateRoots,
)
.unwrap();
state
.build_committee_cache(RelativeEpoch::Current, &self.chain.spec)
.unwrap();
let expected_duties = epoch
.slot_iter(E::slots_per_epoch())
.map(|slot| {
let index = state
.get_beacon_proposer_index(slot, &self.chain.spec)
.unwrap();
let pubkey = state.validators().get(index).unwrap().pubkey;
ProposerData {
pubkey,
validator_index: index as u64,
slot,
}
})
.collect::<Vec<_>>();
let expected = DutiesResponse {
data: expected_duties,
execution_optimistic: Some(false),
dependent_root,
};
assert_eq!(result, expected);
// v1 and v2 should return the same data.
let v1_result = self
.client
.get_validator_duties_proposer(epoch)
.await
.unwrap();
assert_eq!(result.data, v1_result.data);
}
// Requests to the epochs after the next epoch should fail.
self.client
.get_validator_duties_proposer_v2(current_epoch + 2)
.await
.unwrap_err();
self
}
pub async fn test_get_validator_duties_early(self) -> Self {
let current_epoch = self.chain.epoch().unwrap();
let next_epoch = current_epoch + 1;
@@ -7297,6 +7438,23 @@ async fn beacon_get_state_info_electra() {
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn beacon_get_state_info_fulu() {
let mut config = ApiTesterConfig::default();
config.spec.altair_fork_epoch = Some(Epoch::new(0));
config.spec.bellatrix_fork_epoch = Some(Epoch::new(0));
config.spec.capella_fork_epoch = Some(Epoch::new(0));
config.spec.deneb_fork_epoch = Some(Epoch::new(0));
config.spec.electra_fork_epoch = Some(Epoch::new(0));
config.spec.fulu_fork_epoch = Some(Epoch::new(0));
ApiTester::new_from_config(config)
.await
.test_beacon_states_proposer_lookahead()
.await
.test_beacon_states_proposer_lookahead_ssz()
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn beacon_get_blocks() {
ApiTester::new()
@@ -7628,6 +7786,31 @@ async fn get_validator_duties_proposer_with_skip_slots() {
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_validator_duties_proposer_v2() {
ApiTester::new_from_config(ApiTesterConfig {
spec: test_spec::<E>(),
retain_historic_states: true,
..ApiTesterConfig::default()
})
.await
.test_get_validator_duties_proposer_v2()
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_validator_duties_proposer_v2_with_skip_slots() {
ApiTester::new_from_config(ApiTesterConfig {
spec: test_spec::<E>(),
retain_historic_states: true,
..ApiTesterConfig::default()
})
.await
.skip_slots(E::slots_per_epoch() * 2)
.test_get_validator_duties_proposer_v2()
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn block_production() {
ApiTester::new().await.test_block_production().await;