From abac19c6957eeb8c7eb3d516ecbbb2c44b9e9804 Mon Sep 17 00:00:00 2001 From: conache Date: Wed, 22 Jul 2026 13:53:29 +0300 Subject: [PATCH 01/16] Add support for Heze block production --- .../src/block_production/gloas.rs | 56 ++++++++++++++----- beacon_node/beacon_chain/tests/store_tests.rs | 52 +++++++++++++++++ .../execution_layer/src/engine_api/http.rs | 10 +--- beacon_node/execution_layer/src/lib.rs | 4 -- 4 files changed, 97 insertions(+), 25 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index ed21c37df3c..dd7577c5ce6 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -29,12 +29,13 @@ use tree_hash::TreeHash; use types::consts::gloas::BUILDER_INDEX_SELF_BUILD; use types::{ Address, Attestation, AttestationElectra, AttesterSlashing, AttesterSlashingElectra, - BeaconBlock, BeaconBlockBodyGloas, BeaconBlockGloas, BeaconState, BeaconStateError, - BuilderIndex, ChainSpec, Deposit, Eth1Data, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, - ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequestsGloas, FullPayload, Graffiti, - Hash256, PayloadAttestation, ProposerSlashing, RelativeEpoch, SignedBeaconBlock, - SignedBlsToExecutionChange, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, - SignedVoluntaryExit, Slot, SyncAggregate, Withdrawal, Withdrawals, + BeaconBlock, BeaconBlockBodyGloas, BeaconBlockBodyHeze, BeaconBlockGloas, BeaconBlockHeze, + BeaconState, BeaconStateError, BuilderIndex, ChainSpec, Deposit, Eth1Data, EthSpec, + ExecutionBlockHash, ExecutionPayloadBid, ExecutionPayloadEnvelope, ExecutionPayloadGloas, + ExecutionRequestsGloas, FullPayload, Graffiti, Hash256, PayloadAttestation, ProposerSlashing, + RelativeEpoch, SignedBeaconBlock, SignedBlsToExecutionChange, SignedExecutionPayloadBid, + SignedExecutionPayloadEnvelope, SignedVoluntaryExit, Slot, SyncAggregate, Withdrawal, + Withdrawals, }; use crate::pending_payload_envelopes::PendingEnvelopeData; @@ -600,13 +601,42 @@ impl BeaconChain { _phantom: PhantomData::>, }, }), - // TODO(heze): construct a `BeaconBlockHeze` here once Heze block production is - // wired up end-to-end (get_payload, envelope handling, etc). - BeaconState::Heze(_) => { - return Err(BlockProductionError::InvalidBlockVariant( - "Block production disabled for Heze".to_owned(), - )); - } + BeaconState::Heze(_) => BeaconBlock::Heze(BeaconBlockHeze { + slot, + proposer_index, + parent_root, + state_root: Hash256::ZERO, + body: BeaconBlockBodyHeze { + randao_reveal, + eth1_data, + graffiti, + proposer_slashings: proposer_slashings + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + attester_slashings: attester_slashings + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + attestations: attestations + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + deposits: deposits + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + voluntary_exits: voluntary_exits + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + sync_aggregate, + bls_to_execution_changes: bls_to_execution_changes + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + parent_execution_requests, + signed_execution_payload_bid, + payload_attestations: payload_attestations + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + _phantom: PhantomData::>, + }, + }), }; let signed_beacon_block = SignedBeaconBlock::from_block( diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 98b2816dc11..0f56a2a3ac5 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -1929,6 +1929,58 @@ async fn proposer_lookahead_excludes_slashed_proposer_only_after_first_two_gloas ); } +/// Ensure the harness can produce and import a chain across the Heze fork boundary +#[tokio::test] +async fn heze_block_production_across_boundary() { + let gloas_fork_epoch = Epoch::new(1); + let heze_fork_epoch = Epoch::new(2); + let mut spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); + spec.gloas_fork_epoch = Some(gloas_fork_epoch); + spec.heze_fork_epoch = Some(heze_fork_epoch); + + let db_path = tempdir().unwrap(); + let store = get_store_generic(&db_path, Default::default(), spec.clone()); + let validators_keypairs = + types::test_utils::generate_deterministic_keypairs(LOW_VALIDATOR_COUNT); + let harness = TestHarness::builder(E::default()) + .spec(spec.into()) + .keypairs(validators_keypairs) + .fresh_disk_store(store) + .mock_execution_layer() + .build(); + let all_validators = harness.get_all_validators(); + + // Build a full epoch past the Heze boundary + let last_slot = (heze_fork_epoch + 1).start_slot(E::slots_per_epoch()); + let slots: Vec = (1..=last_slot.as_u64()).map(Into::into).collect(); + let state = harness.get_current_state(); + let (_, _, head_block_root, head_state) = harness + .add_attested_blocks_at_slots(state, &slots, &all_validators) + .await; + + assert_eq!(head_state.current_epoch(), heze_fork_epoch + 1); + + let head_block_root: Hash256 = head_block_root.into(); + let head_block = harness + .chain + .store + .get_blinded_block(&head_block_root) + .unwrap() + .expect("head block should be stored"); + assert!( + matches!(head_block, SignedBeaconBlock::Heze(_)), + "the head block should be a Heze block" + ); + assert!( + harness + .chain + .get_payload_envelope(&head_block_root) + .unwrap() + .is_some(), + "the Heze block's execution payload envelope should be stored" + ); +} + // Ensure blocks from abandoned forks are pruned from the Hot DB #[tokio::test] async fn prunes_abandoned_fork_between_two_finalized_checkpoints() { diff --git a/beacon_node/execution_layer/src/engine_api/http.rs b/beacon_node/execution_layer/src/engine_api/http.rs index 76f172e3f72..cf0290cb4f2 100644 --- a/beacon_node/execution_layer/src/engine_api/http.rs +++ b/beacon_node/execution_layer/src/engine_api/http.rs @@ -1066,7 +1066,7 @@ impl HttpJsonRpc { let params = json!([JsonPayloadIdRequest::from(payload_id)]); match fork_name { - ForkName::Gloas => { + ForkName::Gloas | ForkName::Heze => { let response: JsonGetPayloadResponseGloas = self .rpc_request( ENGINE_GET_PAYLOAD_V6, @@ -1078,7 +1078,6 @@ impl HttpJsonRpc { .try_into() .map_err(Error::BadResponse) } - // TODO(heze): add a Heze arm once Heze payload retrieval is implemented. _ => Err(Error::UnsupportedForkVariant(format!( "called get_payload_v6 with {}", fork_name @@ -1449,18 +1448,13 @@ impl HttpJsonRpc { Err(Error::RequiredMethodUnsupported("engine_getPayloadv5")) } } - ForkName::Gloas => { + ForkName::Gloas | ForkName::Heze => { if engine_capabilities.get_payload_v6 { self.get_payload_v6(fork_name, payload_id).await } else { Err(Error::RequiredMethodUnsupported("engine_getPayloadV6")) } } - // TODO(heze): implement the Heze getPayload path once the engine API for Heze - // is specified. - ForkName::Heze => Err(Error::UnsupportedForkVariant( - "getPayload not implemented for Heze".to_string(), - )), ForkName::Base | ForkName::Altair => Err(Error::UnsupportedForkVariant(format!( "called get_payload with {}", fork_name diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index c0a684611af..cf0829001f1 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -223,8 +223,6 @@ impl From> for BlockProposalContentsGloas } } -// TODO(heze): add a `BlockProposalContentsHeze` here once Heze block production is wired up. - pub enum BlockProposalContents> { Payload { payload: Payload, @@ -943,8 +941,6 @@ impl ExecutionLayer { Ok(payload_response.into()) } - // TODO(heze): add a `get_payload_heze` here once Heze block production is wired up. - /// Maps to the `engine_getPayload` JSON-RPC call. /// /// However, it will attempt to call `self.prepare_payload` if it cannot find an existing From 4b5074f4e05dcb058358e344712809f6845ac49c Mon Sep 17 00:00:00 2001 From: conache Date: Wed, 22 Jul 2026 16:12:15 +0300 Subject: [PATCH 02/16] Use latest fork for state_by_root_pruned_from_fork_choice --- beacon_node/http_api/tests/interactive_tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index e675ad68641..437da7d4ea6 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -61,8 +61,7 @@ async fn state_by_root_pruned_from_fork_choice() { type E = MinimalEthSpec; let validator_count = 24; - // TODO(heze): use `ForkName::latest()` once Heze block production is wired up. - let spec = ForkName::Gloas.make_genesis_spec(E::default_spec()); + let spec = ForkName::latest().make_genesis_spec(E::default_spec()); let tester = InteractiveTester::::new_with_initializer_and_mutator( Some(spec.clone()), From d47355600be44880115623382443c2f0f7e6b629 Mon Sep 17 00:00:00 2001 From: conache Date: Mon, 3 Aug 2026 10:48:28 +0300 Subject: [PATCH 03/16] Fix formatting --- beacon_node/beacon_chain/src/block_production/gloas.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 389bfe90546..96e35446a8a 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -30,12 +30,12 @@ use types::consts::gloas::BUILDER_INDEX_SELF_BUILD; use types::{ Address, Attestation, AttestationElectra, AttesterSlashing, AttesterSlashingElectra, BeaconBlock, BeaconBlockBodyGloas, BeaconBlockBodyHeze, BeaconBlockGloas, BeaconBlockHeze, - BeaconState, BeaconStateError,BlobsList, BuilderIndex, ChainSpec, Deposit, Eth1Data, EthSpec, + BeaconState, BeaconStateError, BlobsList, BuilderIndex, ChainSpec, Deposit, Eth1Data, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, ExecutionPayloadEnvelope, ExecutionPayloadGloas, - ExecutionRequestsGloas, FullPayload, Graffiti, Hash256, KzgProofs,PayloadAttestation, ProposerSlashing, - RelativeEpoch, SignedBeaconBlock, SignedBlsToExecutionChange, SignedExecutionPayloadBid, - SignedExecutionPayloadEnvelope, SignedVoluntaryExit, Slot, SyncAggregate, Uint256, Withdrawal, - Withdrawals, + ExecutionRequestsGloas, FullPayload, Graffiti, Hash256, KzgProofs, PayloadAttestation, + ProposerSlashing, RelativeEpoch, SignedBeaconBlock, SignedBlsToExecutionChange, + SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, SignedVoluntaryExit, Slot, + SyncAggregate, Uint256, Withdrawal, Withdrawals, }; use crate::pending_payload_envelopes::PendingEnvelopeData; From 26e43d0e03ac710f260f4b65ecea72ebe9075c86 Mon Sep 17 00:00:00 2001 From: rahulbarman Date: Sat, 11 Jul 2026 04:57:40 +0530 Subject: [PATCH 04/16] feat(types): add inclusion list committee derivation (EIP-7805) --- consensus/types/src/state/beacon_state.rs | 15 +++++ consensus/types/src/state/committee_cache.rs | 31 +++++++++ consensus/types/tests/committee_cache.rs | 71 +++++++++++++++++++- 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index 2c5628a5efd..81e6bbed687 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -975,6 +975,21 @@ impl BeaconState { cache.get_beacon_committees_at_slot(slot) } + /// Get the inclusion list committee for the given `slot` ([New in Heze:EIP7805]). + /// + /// Utilises the committee cache. Mirrors the spec's + /// `Vector[ValidatorIndex, INCLUSION_LIST_COMMITTEE_SIZE]` return type. + pub fn get_inclusion_list_committee( + &self, + slot: Slot, + ) -> Result, BeaconStateError> { + let cache = self.committee_cache_at_slot(slot)?; + let committee = + cache.get_inclusion_list_committee_at_slot(slot, E::inclusion_list_committee_size())?; + let committee: Vec = committee.into_iter().map(|index| index as u64).collect(); + Ok(FixedVector::new(committee)?) + } + /// Get all of the Beacon committees at a given relative epoch. /// /// Utilises the committee cache. diff --git a/consensus/types/src/state/committee_cache.rs b/consensus/types/src/state/committee_cache.rs index 2e74ab760cb..9eef1a3349a 100644 --- a/consensus/types/src/state/committee_cache.rs +++ b/consensus/types/src/state/committee_cache.rs @@ -246,6 +246,37 @@ impl CommitteeCache { .collect() } + /// Get the inclusion list committee for the given `slot` ([New in Heze:EIP7805]). + /// + /// Concatenates the slot's beacon committees in order and cycles over them + /// (`indices[i % len]`) to fill `inclusion_list_committee_size` entries, so a + /// validator may appear more than once when the slot holds fewer members. + pub fn get_inclusion_list_committee_at_slot( + &self, + slot: Slot, + inclusion_list_committee_size: usize, + ) -> Result, BeaconStateError> { + let indices: Vec = self + .get_beacon_committees_at_slot(slot)? + .iter() + .flat_map(|bc| bc.committee.iter().copied()) + .collect(); + + if indices.is_empty() { + return Err(BeaconStateError::NoCommittee { slot, index: 0 }); + } + + (0..inclusion_list_committee_size) + .map(|i| { + let position = i.safe_rem(indices.len())?; + indices + .get(position) + .copied() + .ok_or(BeaconStateError::NoCommittee { slot, index: 0 }) + }) + .collect() + } + /// Returns all committees for `self.initialized_epoch`. pub fn get_all_beacon_committees(&self) -> Result>, BeaconStateError> { let initialized_epoch = self diff --git a/consensus/types/tests/committee_cache.rs b/consensus/types/tests/committee_cache.rs index 5205446c713..9c74500e977 100644 --- a/consensus/types/tests/committee_cache.rs +++ b/consensus/types/tests/committee_cache.rs @@ -10,11 +10,11 @@ use types::*; use crate::test_utils::generate_deterministic_keypairs; -pub const VALIDATOR_COUNT: usize = 16; +pub const MAX_VALIDATOR_COUNT: usize = 160; /// A cached set of keys. static KEYPAIRS: LazyLock> = - LazyLock::new(|| generate_deterministic_keypairs(VALIDATOR_COUNT)); + LazyLock::new(|| generate_deterministic_keypairs(MAX_VALIDATOR_COUNT)); fn get_harness(validator_count: usize) -> BeaconChainHarness> { let harness = BeaconChainHarness::builder(E::default()) @@ -169,3 +169,70 @@ async fn min_randao_epoch_correct() { state.get_randao_mix(min_randao_epoch - 1).unwrap_err(); state.get_randao_mix(min_randao_epoch + 1).unwrap(); } + +/// 16 validators over 8 minimal slots gives ~2 members per slot, fewer than +/// `INCLUSION_LIST_COMMITTEE_SIZE`, so the committee wraps and validators repeat. +#[tokio::test] +async fn inclusion_list_committee_wraps_around_small_committees() { + let mut state = new_state::(16, Slot::new(0)).await; + let spec = &MinimalEthSpec::default_spec(); + state.build_all_committee_caches(spec).unwrap(); + + let size = MinimalEthSpec::inclusion_list_committee_size(); + + for slot in state + .current_epoch() + .slot_iter(MinimalEthSpec::slots_per_epoch()) + { + let concatenated: Vec = state + .get_beacon_committees_at_slot(slot) + .unwrap() + .iter() + .flat_map(|bc| bc.committee.iter().map(|i| *i as u64)) + .collect(); + assert!(concatenated.len() < size); + + let committee = state.get_inclusion_list_committee(slot).unwrap(); + + assert_eq!(committee.len(), size); + for (i, validator) in committee.iter().enumerate() { + assert_eq!(*validator, concatenated[i % concatenated.len()]); + } + + assert_eq!(committee, state.get_inclusion_list_committee(slot).unwrap()); + } +} + +/// 160 validators over 8 minimal slots gives ~20 members per slot, more than +/// `INCLUSION_LIST_COMMITTEE_SIZE`, so the committee takes the first `size` +/// concatenated members without wrapping. +#[tokio::test] +async fn inclusion_list_committee_truncates_large_committees() { + let mut state = new_state::(160, Slot::new(0)).await; + let spec = &MinimalEthSpec::default_spec(); + state.build_all_committee_caches(spec).unwrap(); + + let size = MinimalEthSpec::inclusion_list_committee_size(); + + for slot in state + .current_epoch() + .slot_iter(MinimalEthSpec::slots_per_epoch()) + { + let concatenated: Vec = state + .get_beacon_committees_at_slot(slot) + .unwrap() + .iter() + .flat_map(|bc| bc.committee.iter().map(|i| *i as u64)) + .collect(); + assert!(concatenated.len() > size); + + let committee = state.get_inclusion_list_committee(slot).unwrap(); + + assert_eq!(committee.len(), size); + for (i, validator) in committee.iter().enumerate() { + assert_eq!(*validator, concatenated[i]); + } + + assert_eq!(committee, state.get_inclusion_list_committee(slot).unwrap()); + } +} From 2d2aeafd7459933304262dd23cd27acd0be2d289 Mon Sep 17 00:00:00 2001 From: rahulbarman Date: Thu, 6 Aug 2026 03:06:46 +0530 Subject: [PATCH 05/16] Address comments --- consensus/types/src/state/beacon_state.rs | 5 ++--- consensus/types/src/state/committee_cache.rs | 6 ++---- consensus/types/tests/committee_cache.rs | 7 ++----- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index 06cbdfe3565..6d5a0475bea 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -1212,10 +1212,9 @@ impl BeaconState { cache.get_beacon_committees_at_slot(slot) } - /// Get the inclusion list committee for the given `slot` ([New in Heze:EIP7805]). + /// Get the inclusion list committee for the given `slot`. [New in Heze:EIP7805] /// - /// Utilises the committee cache. Mirrors the spec's - /// `Vector[ValidatorIndex, INCLUSION_LIST_COMMITTEE_SIZE]` return type. + /// Utilises the committee cache. pub fn get_inclusion_list_committee( &self, slot: Slot, diff --git a/consensus/types/src/state/committee_cache.rs b/consensus/types/src/state/committee_cache.rs index 9eef1a3349a..93f065866af 100644 --- a/consensus/types/src/state/committee_cache.rs +++ b/consensus/types/src/state/committee_cache.rs @@ -246,11 +246,9 @@ impl CommitteeCache { .collect() } - /// Get the inclusion list committee for the given `slot` ([New in Heze:EIP7805]). + /// Get the inclusion list committee for the given `slot`. [New in Heze:EIP7805] /// - /// Concatenates the slot's beacon committees in order and cycles over them - /// (`indices[i % len]`) to fill `inclusion_list_committee_size` entries, so a - /// validator may appear more than once when the slot holds fewer members. + /// Cycles over the slot's beacon committees, so a validator may appear more than once. pub fn get_inclusion_list_committee_at_slot( &self, slot: Slot, diff --git a/consensus/types/tests/committee_cache.rs b/consensus/types/tests/committee_cache.rs index 9c74500e977..d42a327e3b3 100644 --- a/consensus/types/tests/committee_cache.rs +++ b/consensus/types/tests/committee_cache.rs @@ -170,8 +170,7 @@ async fn min_randao_epoch_correct() { state.get_randao_mix(min_randao_epoch + 1).unwrap(); } -/// 16 validators over 8 minimal slots gives ~2 members per slot, fewer than -/// `INCLUSION_LIST_COMMITTEE_SIZE`, so the committee wraps and validators repeat. +/// 16 validators gives ~2 members per slot, so the committee wraps. #[tokio::test] async fn inclusion_list_committee_wraps_around_small_committees() { let mut state = new_state::(16, Slot::new(0)).await; @@ -203,9 +202,7 @@ async fn inclusion_list_committee_wraps_around_small_committees() { } } -/// 160 validators over 8 minimal slots gives ~20 members per slot, more than -/// `INCLUSION_LIST_COMMITTEE_SIZE`, so the committee takes the first `size` -/// concatenated members without wrapping. +/// 160 validators gives ~20 members per slot, so the committee truncates to the first 16. #[tokio::test] async fn inclusion_list_committee_truncates_large_committees() { let mut state = new_state::(160, Slot::new(0)).await; From 5aaf89edddb1c81f671ee69c6961e4ee220950e4 Mon Sep 17 00:00:00 2001 From: rahulbarman Date: Mon, 10 Aug 2026 21:58:23 +0530 Subject: [PATCH 06/16] Address feedback from @eserilev --- consensus/types/src/state/beacon_state.rs | 3 +++ consensus/types/src/state/committee_cache.rs | 17 +++++------- consensus/types/tests/committee_cache.rs | 27 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index 6d5a0475bea..422dfe4efcc 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -128,6 +128,9 @@ pub enum BeaconStateError { slot: Slot, index: CommitteeIndex, }, + NoInclusionListCommittee { + slot: Slot, + }, ZeroSlotsPerEpoch, PubkeyCacheInconsistent, PubkeyCacheIncomplete { diff --git a/consensus/types/src/state/committee_cache.rs b/consensus/types/src/state/committee_cache.rs index 93f065866af..09e1af06f5e 100644 --- a/consensus/types/src/state/committee_cache.rs +++ b/consensus/types/src/state/committee_cache.rs @@ -258,21 +258,18 @@ impl CommitteeCache { .get_beacon_committees_at_slot(slot)? .iter() .flat_map(|bc| bc.committee.iter().copied()) + .take(inclusion_list_committee_size) .collect(); if indices.is_empty() { - return Err(BeaconStateError::NoCommittee { slot, index: 0 }); + return Err(BeaconStateError::NoInclusionListCommittee { slot }); } - (0..inclusion_list_committee_size) - .map(|i| { - let position = i.safe_rem(indices.len())?; - indices - .get(position) - .copied() - .ok_or(BeaconStateError::NoCommittee { slot, index: 0 }) - }) - .collect() + Ok(indices + .into_iter() + .cycle() + .take(inclusion_list_committee_size) + .collect()) } /// Returns all committees for `self.initialized_epoch`. diff --git a/consensus/types/tests/committee_cache.rs b/consensus/types/tests/committee_cache.rs index d42a327e3b3..288c5ef7fb6 100644 --- a/consensus/types/tests/committee_cache.rs +++ b/consensus/types/tests/committee_cache.rs @@ -202,6 +202,33 @@ async fn inclusion_list_committee_wraps_around_small_committees() { } } +/// Inclusion list duties are looked up an epoch ahead, so the next epoch must resolve. +#[tokio::test] +async fn inclusion_list_committee_resolves_for_the_next_epoch() { + let mut state = new_state::(160, Slot::new(0)).await; + let spec = &MinimalEthSpec::default_spec(); + state.build_all_committee_caches(spec).unwrap(); + + let size = MinimalEthSpec::inclusion_list_committee_size(); + let next_epoch = state.next_epoch().unwrap(); + + for slot in next_epoch.slot_iter(MinimalEthSpec::slots_per_epoch()) { + let concatenated: Vec = state + .get_beacon_committees_at_slot(slot) + .unwrap() + .iter() + .flat_map(|bc| bc.committee.iter().map(|i| *i as u64)) + .collect(); + + let committee = state.get_inclusion_list_committee(slot).unwrap(); + + assert_eq!(committee.len(), size); + for (i, validator) in committee.iter().enumerate() { + assert_eq!(*validator, concatenated[i % concatenated.len()]); + } + } +} + /// 160 validators gives ~20 members per slot, so the committee truncates to the first 16. #[tokio::test] async fn inclusion_list_committee_truncates_large_committees() { From db57fb78b856ff20b1d2d1d40f6c9aa122c7b88c Mon Sep 17 00:00:00 2001 From: conache Date: Mon, 10 Aug 2026 20:11:16 +0300 Subject: [PATCH 07/16] Add clearer comments --- beacon_node/beacon_chain/tests/store_tests.rs | 2 +- beacon_node/execution_layer/src/engine_api/http.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 4ba340c4b75..f1285fa161d 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -1954,7 +1954,7 @@ async fn heze_block_production_across_boundary() { .build(); let all_validators = harness.get_all_validators(); - // Build a full epoch past the Heze boundary + // Build through the first Heze epoch, ending at the first slot of the next epoch let last_slot = (heze_fork_epoch + 1).start_slot(E::slots_per_epoch()); let slots: Vec = (1..=last_slot.as_u64()).map(Into::into).collect(); let state = harness.get_current_state(); diff --git a/beacon_node/execution_layer/src/engine_api/http.rs b/beacon_node/execution_layer/src/engine_api/http.rs index f88c5327b72..568d3ff1b15 100644 --- a/beacon_node/execution_layer/src/engine_api/http.rs +++ b/beacon_node/execution_layer/src/engine_api/http.rs @@ -1078,6 +1078,8 @@ impl HttpJsonRpc { let params = json!([JsonPayloadIdRequest::from(payload_id)]); match fork_name { + // TODO(heze): deliberately reusing the Gloas response containers while the Heze + // payload is identical to the Gloas one. Switch to the Heze types if it diverges ForkName::Gloas | ForkName::Heze => { let response: JsonGetPayloadResponseGloas = self .rpc_request( From 25089c8498c4a2980eb007f510d774a64cca3937 Mon Sep 17 00:00:00 2001 From: conache Date: Fri, 24 Jul 2026 22:18:51 +0300 Subject: [PATCH 08/16] Add main il duties to the beacon chain --- beacon_node/beacon_chain/src/beacon_chain.rs | 55 ++++- .../http_api/src/inclusion_list_duties.rs | 190 ++++++++++++++++++ beacon_node/http_api/src/lib.rs | 10 + beacon_node/http_api/src/validator/mod.rs | 40 +++- common/eth2/src/types.rs | 9 + 5 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 beacon_node/http_api/src/inclusion_list_duties.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index a0e3ac4ae2f..3172c499633 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -90,7 +90,7 @@ use crate::{ use bls::{PublicKey, PublicKeyBytes, Signature}; use eth2::beacon_response::ForkVersionedResponse; use eth2::types::{ - EventKind, PtcDuty, SseBlobSidecar, SseBlock, SseDataColumnSidecar, + EventKind, InclusionListDuty, PtcDuty, SseBlobSidecar, SseBlock, SseDataColumnSidecar, SseExtendedPayloadAttributes, SseHead, SseHeadV2, }; use execution_layer::{ @@ -1740,6 +1740,59 @@ impl BeaconChain { Ok((duties, dependent_root)) } + /// Get inclusion list committee duties for validators at a given epoch ([New in Heze:EIP7805]). + pub fn compute_inclusion_list_duties( + &self, + state: &BeaconState, + epoch: Epoch, + validator_indices: &[u64], + dependent_block_root: Hash256, + ) -> Result<(Vec>, Hash256), Error> { + // The committee cache only covers previous, current, and next epochs. + let relative_epoch = RelativeEpoch::from_epoch(state.current_epoch(), epoch) + .map_err(Error::IncorrectStateForAttestation)?; + + // The inclusion list committee is derived purely from the beacon committees, so its + // duties become stable at the attester shuffling decision block. + let dependent_root = + state.attester_shuffling_decision_root(dependent_block_root, relative_epoch)?; + + // Walk the epoch's slots once, deriving each slot's committee and its root. A validator + // belongs to exactly one beacon committee per epoch, so it has at most one duty slot; + // duplicates from committee wrap-around always point at the same slot. + let mut assignments: HashMap = HashMap::new(); + for slot in epoch.slot_iter(T::EthSpec::slots_per_epoch()) { + let committee = state.get_inclusion_list_committee(slot)?; + let committee_root = committee.tree_hash_root(); + for validator_index in &committee { + assignments + .entry(*validator_index) + .or_insert((slot, committee_root)); + } + } + + let pubkey_cache = self.validator_pubkey_cache.read(); + + let duties = validator_indices + .iter() + .map(|&validator_index| { + let Some(&pubkey) = pubkey_cache.get_pubkey_bytes(validator_index as usize) else { + return None; + }; + assignments + .get(&validator_index) + .map(|&(slot, inclusion_list_committee_root)| InclusionListDuty { + pubkey, + validator_index, + slot, + inclusion_list_committee_root, + }) + }) + .collect::>(); + + Ok((duties, dependent_root)) + } + pub fn get_aggregated_attestation( &self, attestation: AttestationRef, diff --git a/beacon_node/http_api/src/inclusion_list_duties.rs b/beacon_node/http_api/src/inclusion_list_duties.rs new file mode 100644 index 00000000000..ff377786ba3 --- /dev/null +++ b/beacon_node/http_api/src/inclusion_list_duties.rs @@ -0,0 +1,190 @@ +//! Contains the handler for the `POST validator/duties/inclusion_list/{epoch}` endpoint. + +use crate::state_id::StateId; +use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes}; +use eth2::types::{self as api_types, InclusionListDuty}; +use slot_clock::SlotClock; +use state_processing::state_advance::partial_state_advance; +use types::{BeaconState, ChainSpec, Epoch, EthSpec, Hash256}; + +type ApiDuties = api_types::DutiesResponse>; + +pub fn inclusion_list_duties( + request_epoch: Epoch, + request_indices: &[u64], + chain: &BeaconChain, +) -> Result { + // Inclusion list committees only exist from the Heze fork onwards. + if !chain.spec.fork_name_at_epoch(request_epoch).heze_enabled() { + return Err(warp_utils::reject::custom_bad_request(format!( + "request epoch {} is prior to the Heze fork", + request_epoch + ))); + } + + let current_epoch = chain + .slot_clock + .now_or_genesis() + .map(|slot| slot.epoch(T::EthSpec::slots_per_epoch())) + .ok_or(BeaconChainError::UnableToReadSlot) + .map_err(warp_utils::reject::unhandled_error)?; + + let tolerant_current_epoch = if chain.slot_clock.is_prior_to_genesis().unwrap_or(true) { + current_epoch + } else { + chain + .slot_clock + .now_with_future_tolerance(chain.spec.maximum_gossip_clock_disparity()) + .ok_or_else(|| { + warp_utils::reject::custom_server_error("unable to read slot clock".into()) + })? + .epoch(T::EthSpec::slots_per_epoch()) + }; + + let is_within_clock_tolerance = request_epoch == current_epoch + || request_epoch == current_epoch + 1 + || request_epoch == tolerant_current_epoch + 1; + + if is_within_clock_tolerance { + let head_epoch = chain + .canonical_head + .cached_head() + .snapshot + .beacon_state + .current_epoch(); + + let head_can_serve_request = request_epoch == head_epoch || request_epoch == head_epoch + 1; + + if head_can_serve_request { + compute_inclusion_list_duties_from_cached_head(request_epoch, request_indices, chain) + } else { + compute_inclusion_list_duties_from_state(request_epoch, request_indices, chain) + } + } else if request_epoch > current_epoch + 1 { + Err(warp_utils::reject::custom_bad_request(format!( + "request epoch {} is more than one epoch past the current epoch {}", + request_epoch, current_epoch + ))) + } else { + compute_inclusion_list_duties_from_state(request_epoch, request_indices, chain) + } +} + +fn compute_inclusion_list_duties_from_cached_head( + request_epoch: Epoch, + request_indices: &[u64], + chain: &BeaconChain, +) -> Result { + let (cached_head, execution_status) = chain + .canonical_head + .head_and_execution_status() + .map_err(warp_utils::reject::unhandled_error)?; + let state = &cached_head.snapshot.beacon_state; + let head_block_root = cached_head.head_block_root(); + + let (duties, dependent_root) = chain + .compute_inclusion_list_duties(state, request_epoch, request_indices, head_block_root) + .map_err(warp_utils::reject::unhandled_error)?; + + convert_to_api_response( + duties, + dependent_root, + execution_status.is_optimistic_or_invalid(), + ) +} + +fn compute_inclusion_list_duties_from_state( + request_epoch: Epoch, + request_indices: &[u64], + chain: &BeaconChain, +) -> Result { + let state_opt = { + let (cached_head, execution_status) = chain + .canonical_head + .head_and_execution_status() + .map_err(warp_utils::reject::unhandled_error)?; + let head = &cached_head.snapshot; + + if head.beacon_state.current_epoch() <= request_epoch { + Some(( + head.beacon_state_root(), + head.beacon_state.clone(), + execution_status.is_optimistic_or_invalid(), + )) + } else { + None + } + }; + + let (state, execution_optimistic) = + if let Some((state_root, mut state, execution_optimistic)) = state_opt { + ensure_state_can_determine_inclusion_list_duties( + &mut state, + state_root, + request_epoch, + &chain.spec, + )?; + (state, execution_optimistic) + } else { + let (state, execution_optimistic, _finalized) = + StateId::from_slot(request_epoch.start_slot(T::EthSpec::slots_per_epoch())) + .state(chain)?; + (state, execution_optimistic) + }; + + if !(state.current_epoch() == request_epoch || state.current_epoch() + 1 == request_epoch) { + return Err(warp_utils::reject::custom_server_error(format!( + "state epoch {} not suitable for request epoch {}", + state.current_epoch(), + request_epoch + ))); + } + + let (duties, dependent_root) = chain + .compute_inclusion_list_duties( + &state, + request_epoch, + request_indices, + chain.genesis_block_root, + ) + .map_err(warp_utils::reject::unhandled_error)?; + + convert_to_api_response(duties, dependent_root, execution_optimistic) +} + +fn ensure_state_can_determine_inclusion_list_duties( + state: &mut BeaconState, + state_root: Hash256, + target_epoch: Epoch, + spec: &ChainSpec, +) -> Result<(), warp::reject::Rejection> { + if state.current_epoch() > target_epoch { + return Err(warp_utils::reject::custom_server_error(format!( + "state epoch {} is later than target epoch {}", + state.current_epoch(), + target_epoch + ))); + } else if state.current_epoch() + 1 < target_epoch { + let target_slot = target_epoch + .saturating_sub(1_u64) + .start_slot(E::slots_per_epoch()); + + partial_state_advance(state, Some(state_root), target_slot, spec) + .map_err(BeaconChainError::from) + .map_err(warp_utils::reject::unhandled_error)?; + } + + Ok(()) +} + +fn convert_to_api_response( + duties: Vec>, + dependent_root: Hash256, + execution_optimistic: bool, +) -> Result { + Ok(api_types::DutiesResponse { + dependent_root, + execution_optimistic: Some(execution_optimistic), + data: duties.into_iter().flatten().collect(), + }) +} diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 03d0450627f..25c76016497 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -16,6 +16,7 @@ mod builders; mod caches; mod custody; mod database; +mod inclusion_list_duties; mod light_client; mod metrics; mod peer; @@ -2625,6 +2626,14 @@ pub async fn serve( task_spawner_filter.clone(), ); + // POST validator/duties/inclusion_list/{epoch} + let post_validator_duties_inclusion_list = post_validator_duties_inclusion_list( + eth_v1.clone(), + chain_filter.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + ); + // POST validator/duties/sync/{epoch} let post_validator_duties_sync = post_validator_duties_sync( eth_v1.clone(), @@ -3491,6 +3500,7 @@ pub async fn serve( .uor(post_beacon_rewards_sync_committee) .uor(post_validator_duties_attester) .uor(post_validator_duties_ptc) + .uor(post_validator_duties_inclusion_list) .uor(post_validator_duties_sync) .uor(post_validator_aggregate_and_proofs) .uor(post_validator_contribution_and_proofs) diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 5287a1a3974..3dff77245ef 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -7,7 +7,9 @@ use crate::utils::{ ResponseFilter, TaskSpawnerFilter, ValidatorSubscriptionTxFilter, publish_network_message, }; use crate::version::{V1, V2, V3, V4, unsupported_version_rejection}; -use crate::{StateId, attester_duties, proposer_duties, ptc_duties, sync_committees}; +use crate::{ + StateId, attester_duties, inclusion_list_duties, proposer_duties, ptc_duties, sync_committees, +}; use beacon_chain::attestation_verification::VerifiedAttestation; use beacon_chain::proposer_preferences_verification::ProposerPreferencesError; use beacon_chain::{AttestationError, BeaconChain, BeaconChainError, BeaconChainTypes}; @@ -209,6 +211,42 @@ pub fn post_validator_duties_ptc( .boxed() } +// POST validator/duties/inclusion_list/{epoch} +pub fn post_validator_duties_inclusion_list( + eth_v1: EthV1Filter, + chain_filter: ChainFilter, + not_while_syncing_filter: NotWhileSyncingFilter, + task_spawner_filter: TaskSpawnerFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("validator")) + .and(warp::path("duties")) + .and(warp::path("inclusion_list")) + .and(warp::path::param::().or_else(|_| async { + Err(warp_utils::reject::custom_bad_request( + "Invalid epoch".to_string(), + )) + })) + .and(warp::path::end()) + .and(not_while_syncing_filter.clone()) + .and(warp_utils::json::json()) + .and(task_spawner_filter.clone()) + .and(chain_filter.clone()) + .then( + |epoch: Epoch, + not_synced_filter: Result<(), Rejection>, + indices: ValidatorIndexData, + task_spawner: TaskSpawner, + chain: Arc>| { + task_spawner.blocking_json_task(Priority::P0, move || { + not_synced_filter?; + inclusion_list_duties::inclusion_list_duties(epoch, &indices.0, &chain) + }) + }, + ) + .boxed() +} + // GET validator/aggregate_attestation?attestation_data_root,slot pub fn get_validator_aggregate_attestation( any_version: AnyVersionFilter, diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index f941857d28f..2ef0ca4152c 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -862,6 +862,15 @@ pub struct PtcDuty { pub slot: Slot, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InclusionListDuty { + pub pubkey: PublicKeyBytes, + #[serde(with = "serde_utils::quoted_u64")] + pub validator_index: u64, + pub slot: Slot, + pub inclusion_list_committee_root: Hash256, +} + #[derive(Clone, Deserialize)] pub struct ValidatorBlocksQuery { pub randao_reveal: SignatureBytes, From 5f6a268812418a1133616cf88ff517d7399d7cd5 Mon Sep 17 00:00:00 2001 From: conache Date: Mon, 27 Jul 2026 19:57:58 +0300 Subject: [PATCH 09/16] Wire up il duties endpoint --- beacon_node/beacon_chain/src/beacon_chain.rs | 6 ----- common/eth2/src/lib.rs | 27 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 3172c499633..d0505f758f6 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -1748,18 +1748,12 @@ impl BeaconChain { validator_indices: &[u64], dependent_block_root: Hash256, ) -> Result<(Vec>, Hash256), Error> { - // The committee cache only covers previous, current, and next epochs. let relative_epoch = RelativeEpoch::from_epoch(state.current_epoch(), epoch) .map_err(Error::IncorrectStateForAttestation)?; - // The inclusion list committee is derived purely from the beacon committees, so its - // duties become stable at the attester shuffling decision block. let dependent_root = state.attester_shuffling_decision_root(dependent_block_root, relative_epoch)?; - // Walk the epoch's slots once, deriving each slot's committee and its root. A validator - // belongs to exactly one beacon committee per epoch, so it has at most one duty slot; - // duplicates from committee wrap-around always point at the same slot. let mut assignments: HashMap = HashMap::new(); for slot in epoch.slot_iter(T::EthSpec::slots_per_epoch()) { let committee = state.get_inclusion_list_committee(slot)?; diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index b216362b389..51a4324afeb 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -81,6 +81,7 @@ const HTTP_SYNC_DUTIES_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_SYNC_AGGREGATOR_TIMEOUT_QUOTIENT: u32 = 24; // For DVT involving middleware only // TODO(EIP-7732): Determine what this quotient should be const HTTP_PTC_DUTIES_TIMEOUT_QUOTIENT: u32 = 4; +const HTTP_INCLUSION_LIST_DUTIES_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_GET_BEACON_BLOCK_SSZ_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_GET_DEBUG_BEACON_STATE_QUOTIENT: u32 = 4; const HTTP_GET_DEPOSIT_SNAPSHOT_QUOTIENT: u32 = 4; @@ -103,6 +104,7 @@ pub struct Timeouts { pub sync_duties: Duration, pub sync_aggregators: Duration, pub ptc_duties: Duration, + pub inclusion_list_duties: Duration, pub get_beacon_blocks_ssz: Duration, pub get_debug_beacon_states: Duration, pub get_deposit_snapshot: Duration, @@ -125,6 +127,7 @@ impl Timeouts { sync_duties: timeout, sync_aggregators: timeout, ptc_duties: timeout, + inclusion_list_duties: timeout, get_beacon_blocks_ssz: timeout, get_debug_beacon_states: timeout, get_deposit_snapshot: timeout, @@ -149,6 +152,7 @@ impl Timeouts { sync_duties: base_timeout / HTTP_SYNC_DUTIES_TIMEOUT_QUOTIENT, sync_aggregators: base_timeout / HTTP_SYNC_AGGREGATOR_TIMEOUT_QUOTIENT, ptc_duties: base_timeout / HTTP_PTC_DUTIES_TIMEOUT_QUOTIENT, + inclusion_list_duties: base_timeout / HTTP_INCLUSION_LIST_DUTIES_TIMEOUT_QUOTIENT, get_beacon_blocks_ssz: base_timeout / HTTP_GET_BEACON_BLOCK_SSZ_TIMEOUT_QUOTIENT, get_debug_beacon_states: base_timeout / HTTP_GET_DEBUG_BEACON_STATE_QUOTIENT, get_deposit_snapshot: base_timeout / HTTP_GET_DEPOSIT_SNAPSHOT_QUOTIENT, @@ -3651,6 +3655,29 @@ impl BeaconNodeHttpClient { ) .await } + + /// `POST validator/duties/inclusion_list/{epoch}` + pub async fn post_validator_duties_inclusion_list( + &self, + epoch: Epoch, + indices: &[u64], + ) -> Result>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("duties") + .push("inclusion_list") + .push(&epoch.to_string()); + + self.post_with_timeout_and_response( + path, + &ValidatorIndexDataRef(indices), + self.timeouts.inclusion_list_duties, + ) + .await + } } #[cfg(test)] From 82506f4ddcb551f223b12744300aa9a73ba5b7d8 Mon Sep 17 00:00:00 2001 From: conache Date: Mon, 27 Jul 2026 20:30:01 +0300 Subject: [PATCH 10/16] Add endpoint tests --- beacon_node/http_api/tests/tests.rs | 112 ++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 6d71a1cdddc..15c36f385c4 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4204,6 +4204,95 @@ impl ApiTester { self } + pub async fn test_get_validator_duties_inclusion_list(self) -> Self { + let current_epoch = self.chain.epoch().unwrap().as_u64(); + + let half = current_epoch / 2; + let first = current_epoch - half; + let last = current_epoch + half; + + for epoch in first..=last { + for indices in self.interesting_validator_indices() { + let epoch = Epoch::from(epoch); + + // The endpoint does not allow getting duties past the next epoch. + if epoch > current_epoch + 1 { + assert_eq!( + self.client + .post_validator_duties_inclusion_list(epoch, indices.as_slice()) + .await + .unwrap_err() + .status() + .map(Into::into), + Some(400) + ); + continue; + } + + let results = self + .client + .post_validator_duties_inclusion_list(epoch, indices.as_slice()) + .await + .unwrap(); + + let dependent_root = self + .chain + .block_root_at_slot( + (epoch - 1).start_slot(E::slots_per_epoch()) - 1, + WhenSlotSkipped::Prev, + ) + .unwrap() + .unwrap_or(self.chain.head_beacon_block_root()); + + assert_eq!(results.dependent_root, dependent_root); + + let result_duties = results.data; + + 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 slot_committees: Vec<(Slot, Hash256, Vec)> = epoch + .slot_iter(E::slots_per_epoch()) + .map(|slot| { + let committee = state.get_inclusion_list_committee(slot).unwrap(); + (slot, committee.tree_hash_root(), committee.to_vec()) + }) + .collect(); + + let expected_duties: Vec = indices + .iter() + .filter_map(|&validator_index| { + let validator = state.validators().get(validator_index as usize)?; + let (slot, inclusion_list_committee_root, _) = slot_committees + .iter() + .find(|(_, _, committee)| committee.contains(&validator_index))?; + Some(InclusionListDuty { + pubkey: validator.pubkey, + validator_index, + slot: *slot, + inclusion_list_committee_root: *inclusion_list_committee_root, + }) + }) + .collect(); + + assert_eq!( + result_duties, expected_duties, + "inclusion list duties should exactly match state-derived committees" + ); + } + } + + self + } + pub async fn test_block_production(self) -> Self { // Pre-Gloas endpoint test; post-Gloas block production is v4-only. if self.chain.spec.is_gloas_scheduled() { @@ -9586,6 +9675,29 @@ async fn get_validator_duties_ptc_with_skip_slots() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_duties_inclusion_list() { + if !fork_name_from_env().is_some_and(|f| f.heze_enabled()) { + return; + } + ApiTester::new_with_hard_forks() + .await + .test_get_validator_duties_inclusion_list() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_duties_inclusion_list_with_skip_slots() { + if !fork_name_from_env().is_some_and(|f| f.heze_enabled()) { + return; + } + ApiTester::new_with_hard_forks() + .await + .skip_slots(E::slots_per_epoch() * 2) + .test_get_validator_duties_inclusion_list() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn block_production() { ApiTester::new().await.test_block_production().await; From 4357bbdfa5fc7ff8fbedab16df9ce2d22563abaf Mon Sep 17 00:00:00 2001 From: conache Date: Tue, 28 Jul 2026 16:43:55 +0300 Subject: [PATCH 11/16] Drop requests with no indices --- beacon_node/http_api/src/inclusion_list_duties.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/beacon_node/http_api/src/inclusion_list_duties.rs b/beacon_node/http_api/src/inclusion_list_duties.rs index ff377786ba3..1b4a112b595 100644 --- a/beacon_node/http_api/src/inclusion_list_duties.rs +++ b/beacon_node/http_api/src/inclusion_list_duties.rs @@ -14,6 +14,12 @@ pub fn inclusion_list_duties( request_indices: &[u64], chain: &BeaconChain, ) -> Result { + if request_indices.is_empty() { + return Err(warp_utils::reject::custom_bad_request( + "at least one validator index must be provided".to_string(), + )); + } + // Inclusion list committees only exist from the Heze fork onwards. if !chain.spec.fork_name_at_epoch(request_epoch).heze_enabled() { return Err(warp_utils::reject::custom_bad_request(format!( From 4d8a471b942185e62081f082b4ac9118dbaec67e Mon Sep 17 00:00:00 2001 From: conache Date: Tue, 28 Jul 2026 16:44:37 +0300 Subject: [PATCH 12/16] Add tests for behavior at the fork boundary --- .../http_api/tests/interactive_tests.rs | 110 +++++++++++++++++- beacon_node/http_api/tests/tests.rs | 14 +++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 675ef112523..ac231ad1037 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -9,7 +9,7 @@ use beacon_chain::{ }; use beacon_processor::{Work, WorkEvent, work_reprocessing_queue::ReprocessQueueMessage}; use eth2::types::ProduceBlockV3Response; -use eth2::types::{DepositContractData, StateId}; +use eth2::types::{DepositContractData, InclusionListDuty, StateId}; use execution_layer::{ForkchoiceState, PayloadAttributes}; use fixed_bytes::FixedBytesExtended; use http_api::test_utils::InteractiveTester; @@ -21,6 +21,7 @@ use state_processing::{ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; +use tree_hash::TreeHash; use types::{ Address, Epoch, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, Hash256, MainnetEthSpec, MinimalEthSpec, ProposerPreparationData, Slot, @@ -1386,3 +1387,110 @@ async fn lighthouse_custody_info() { info.custody_group_count as usize ); } + +/// Inclusion list duties across the Heze fork boundary: pre-Heze epochs are rejected, +/// and duties at the fork epoch resolve their dependent root from pre-fork history. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn inclusion_list_duties_across_fork_boundary() { + type E = MinimalEthSpec; + + let validator_count = 32; + let heze_fork_epoch = Epoch::new(2); + let mut spec = ForkName::Gloas.make_genesis_spec(E::default_spec()); + spec.heze_fork_epoch = Some(heze_fork_epoch); + + let tester = InteractiveTester::::new(Some(spec), validator_count).await; + let harness = &tester.harness; + let client = &tester.client; + + // Build the chain into the Heze fork epoch. + harness.advance_slot(); + let target_slot = heze_fork_epoch.start_slot(E::slots_per_epoch()) + 1; + harness + .extend_chain( + target_slot.as_u64() as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + assert_eq!(harness.chain.epoch().unwrap(), heze_fork_epoch); + + // Pre-Heze epochs are rejected. + for epoch in [Epoch::new(0), Epoch::new(1)] { + assert_eq!( + client + .post_validator_duties_inclusion_list(epoch, &[0]) + .await + .unwrap_err() + .status() + .map(Into::into), + Some(400), + "pre-Heze epoch {epoch} should be rejected" + ); + } + + // Duties at the fork epoch are served, with the dependent root resolved from + // pre-fork history + let indices: Vec = (0..validator_count as u64).collect(); + let duties = client + .post_validator_duties_inclusion_list(heze_fork_epoch, &indices) + .await + .expect("fork epoch duties should be served"); + + let expected_dependent_root = harness + .chain + .block_root_at_slot( + (heze_fork_epoch - 1).start_slot(E::slots_per_epoch()) - 1, + beacon_chain::WhenSlotSkipped::Prev, + ) + .unwrap() + .expect("pre-fork dependent block should exist"); + assert_eq!(duties.dependent_root, expected_dependent_root); + + // Verify the duties against committees derived from the fork epoch state. + let mut state = harness + .chain + .state_at_slot( + heze_fork_epoch.start_slot(E::slots_per_epoch()), + beacon_chain::StateSkipConfig::WithStateRoots, + ) + .unwrap(); + state + .build_committee_cache(types::RelativeEpoch::Current, &harness.chain.spec) + .unwrap(); + + let slot_committees: Vec<(Slot, Hash256, Vec)> = heze_fork_epoch + .slot_iter(E::slots_per_epoch()) + .map(|slot| { + let committee = state.get_inclusion_list_committee(slot).unwrap(); + (slot, committee.tree_hash_root(), committee.to_vec()) + }) + .collect(); + + let expected_duties: Vec = indices + .iter() + .filter_map(|&validator_index| { + let validator = state.validators().get(validator_index as usize)?; + let (slot, inclusion_list_committee_root, _) = slot_committees + .iter() + .find(|(_, _, committee)| committee.contains(&validator_index))?; + Some(InclusionListDuty { + pubkey: validator.pubkey, + validator_index, + slot: *slot, + inclusion_list_committee_root: *inclusion_list_committee_root, + }) + }) + .collect(); + + assert_eq!( + duties.data, expected_duties, + "fork epoch duties should match state-derived committees" + ); + + // The epoch after the fork is also served; its dependent root still lies pre-fork + client + .post_validator_duties_inclusion_list(heze_fork_epoch + 1, &[0]) + .await + .expect("duties for the epoch after the fork should be served"); +} diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 15c36f385c4..ca074000cac 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4215,6 +4215,20 @@ impl ApiTester { for indices in self.interesting_validator_indices() { let epoch = Epoch::from(epoch); + // The endpoint requires at least one validator index. + if indices.is_empty() { + assert_eq!( + self.client + .post_validator_duties_inclusion_list(epoch, indices.as_slice()) + .await + .unwrap_err() + .status() + .map(Into::into), + Some(400) + ); + continue; + } + // The endpoint does not allow getting duties past the next epoch. if epoch > current_epoch + 1 { assert_eq!( From b2a56da0ad5863d5d3b293e6133a635000c352a8 Mon Sep 17 00:00:00 2001 From: conache Date: Tue, 4 Aug 2026 19:36:43 +0300 Subject: [PATCH 13/16] Accept requests for pre-Heze epochs --- .../http_api/src/inclusion_list_duties.rs | 8 -------- beacon_node/http_api/tests/interactive_tests.rs | 17 ++++++++--------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/beacon_node/http_api/src/inclusion_list_duties.rs b/beacon_node/http_api/src/inclusion_list_duties.rs index 1b4a112b595..73a7f75949d 100644 --- a/beacon_node/http_api/src/inclusion_list_duties.rs +++ b/beacon_node/http_api/src/inclusion_list_duties.rs @@ -20,14 +20,6 @@ pub fn inclusion_list_duties( )); } - // Inclusion list committees only exist from the Heze fork onwards. - if !chain.spec.fork_name_at_epoch(request_epoch).heze_enabled() { - return Err(warp_utils::reject::custom_bad_request(format!( - "request epoch {} is prior to the Heze fork", - request_epoch - ))); - } - let current_epoch = chain .slot_clock .now_or_genesis() diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index ac231ad1037..286e9f4abae 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -1415,17 +1415,16 @@ async fn inclusion_list_duties_across_fork_boundary() { .await; assert_eq!(harness.chain.epoch().unwrap(), heze_fork_epoch); - // Pre-Heze epochs are rejected. + // Pre-Heze epochs are not rejected. for epoch in [Epoch::new(0), Epoch::new(1)] { + let duties = client + .post_validator_duties_inclusion_list(epoch, &[0]) + .await + .unwrap_or_else(|e| panic!("Failed to get duties on pre-Heze epoch {epoch}: {e:?}")); assert_eq!( - client - .post_validator_duties_inclusion_list(epoch, &[0]) - .await - .unwrap_err() - .status() - .map(Into::into), - Some(400), - "pre-Heze epoch {epoch} should be rejected" + duties.data.len(), + 1, + "validator 0 should have an IL duty in pre-Heze epoch {epoch}" ); } From d77ec9a8218138d20e9d9c1c47b4600df048e22c Mon Sep 17 00:00:00 2001 From: conache Date: Tue, 4 Aug 2026 19:37:00 +0300 Subject: [PATCH 14/16] Include formatting --- beacon_node/http_api/tests/interactive_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 286e9f4abae..31a3b9aa821 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -1417,12 +1417,12 @@ async fn inclusion_list_duties_across_fork_boundary() { // Pre-Heze epochs are not rejected. for epoch in [Epoch::new(0), Epoch::new(1)] { - let duties = client + let duties = client .post_validator_duties_inclusion_list(epoch, &[0]) .await .unwrap_or_else(|e| panic!("Failed to get duties on pre-Heze epoch {epoch}: {e:?}")); assert_eq!( - duties.data.len(), + duties.data.len(), 1, "validator 0 should have an IL duty in pre-Heze epoch {epoch}" ); From 23f2330cc4741bff6a4bd766476df708eadfce0e Mon Sep 17 00:00:00 2001 From: conache Date: Tue, 4 Aug 2026 19:44:23 +0300 Subject: [PATCH 15/16] Correct comment --- beacon_node/http_api/tests/interactive_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 31a3b9aa821..2b95d948ea3 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -1388,7 +1388,7 @@ async fn lighthouse_custody_info() { ); } -/// Inclusion list duties across the Heze fork boundary: pre-Heze epochs are rejected, +/// Inclusion list duties across the Heze fork boundary: pre-Heze epochs are not rejected, /// and duties at the fork epoch resolve their dependent root from pre-fork history. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn inclusion_list_duties_across_fork_boundary() { From 13214bf9230a7c258b72cb29d64c94c6e16f1055 Mon Sep 17 00:00:00 2001 From: conache Date: Fri, 21 Aug 2026 19:20:42 +0300 Subject: [PATCH 16/16] Drop inclusion_list_committee_root --- beacon_node/beacon_chain/src/beacon_chain.rs | 10 +++------ .../http_api/tests/interactive_tests.rs | 10 ++++----- beacon_node/http_api/tests/tests.rs | 21 ++++++++++++++----- common/eth2/src/types.rs | 1 - 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index d0505f758f6..89862a1a3a9 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -1754,14 +1754,11 @@ impl BeaconChain { let dependent_root = state.attester_shuffling_decision_root(dependent_block_root, relative_epoch)?; - let mut assignments: HashMap = HashMap::new(); + let mut assignments: HashMap = HashMap::new(); for slot in epoch.slot_iter(T::EthSpec::slots_per_epoch()) { let committee = state.get_inclusion_list_committee(slot)?; - let committee_root = committee.tree_hash_root(); for validator_index in &committee { - assignments - .entry(*validator_index) - .or_insert((slot, committee_root)); + assignments.entry(*validator_index).or_insert(slot); } } @@ -1775,11 +1772,10 @@ impl BeaconChain { }; assignments .get(&validator_index) - .map(|&(slot, inclusion_list_committee_root)| InclusionListDuty { + .map(|&slot| InclusionListDuty { pubkey, validator_index, slot, - inclusion_list_committee_root, }) }) .collect::>(); diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 2b95d948ea3..f787f8b7075 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -21,7 +21,6 @@ use state_processing::{ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use tree_hash::TreeHash; use types::{ Address, Epoch, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, Hash256, MainnetEthSpec, MinimalEthSpec, ProposerPreparationData, Slot, @@ -1458,11 +1457,11 @@ async fn inclusion_list_duties_across_fork_boundary() { .build_committee_cache(types::RelativeEpoch::Current, &harness.chain.spec) .unwrap(); - let slot_committees: Vec<(Slot, Hash256, Vec)> = heze_fork_epoch + let slot_committees: Vec<(Slot, Vec)> = heze_fork_epoch .slot_iter(E::slots_per_epoch()) .map(|slot| { let committee = state.get_inclusion_list_committee(slot).unwrap(); - (slot, committee.tree_hash_root(), committee.to_vec()) + (slot, committee.to_vec()) }) .collect(); @@ -1470,14 +1469,13 @@ async fn inclusion_list_duties_across_fork_boundary() { .iter() .filter_map(|&validator_index| { let validator = state.validators().get(validator_index as usize)?; - let (slot, inclusion_list_committee_root, _) = slot_committees + let (slot, _) = slot_committees .iter() - .find(|(_, _, committee)| committee.contains(&validator_index))?; + .find(|(_, committee)| committee.contains(&validator_index))?; Some(InclusionListDuty { pubkey: validator.pubkey, validator_index, slot: *slot, - inclusion_list_committee_root: *inclusion_list_committee_root, }) }) .collect(); diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index ca074000cac..ce3edadd9b8 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4273,11 +4273,11 @@ impl ApiTester { .build_committee_cache(RelativeEpoch::Current, &self.chain.spec) .unwrap(); - let slot_committees: Vec<(Slot, Hash256, Vec)> = epoch + let slot_committees: Vec<(Slot, Vec)> = epoch .slot_iter(E::slots_per_epoch()) .map(|slot| { let committee = state.get_inclusion_list_committee(slot).unwrap(); - (slot, committee.tree_hash_root(), committee.to_vec()) + (slot, committee.to_vec()) }) .collect(); @@ -4285,14 +4285,13 @@ impl ApiTester { .iter() .filter_map(|&validator_index| { let validator = state.validators().get(validator_index as usize)?; - let (slot, inclusion_list_committee_root, _) = slot_committees + let (slot, _) = slot_committees .iter() - .find(|(_, _, committee)| committee.contains(&validator_index))?; + .find(|(_, committee)| committee.contains(&validator_index))?; Some(InclusionListDuty { pubkey: validator.pubkey, validator_index, slot: *slot, - inclusion_list_committee_root: *inclusion_list_committee_root, }) }) .collect(); @@ -4301,6 +4300,18 @@ impl ApiTester { result_duties, expected_duties, "inclusion list duties should exactly match state-derived committees" ); + + // With 32 validators there are fewer unique members per slot than + // INCLUSION_LIST_COMMITTEE_SIZE, so every committee here cycles + // + // ensure that the endpoint returns at most one duty per validator even in that case + let mut seen_validators = std::collections::HashSet::new(); + assert!( + result_duties + .iter() + .all(|duty| seen_validators.insert(duty.validator_index)), + "each validator should appear at most once in the duties response" + ); } } diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 2ef0ca4152c..812d01eca4c 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -868,7 +868,6 @@ pub struct InclusionListDuty { #[serde(with = "serde_utils::quoted_u64")] pub validator_index: u64, pub slot: Slot, - pub inclusion_list_committee_root: Hash256, } #[derive(Clone, Deserialize)]