From 77c41f99fa42bc014e994da8a35d6fee20f651fd Mon Sep 17 00:00:00 2001 From: conache Date: Thu, 16 Jul 2026 20:07:18 +0300 Subject: [PATCH 01/17] Add method to produce ILs for the current slot --- beacon_node/beacon_chain/src/beacon_chain.rs | 33 ++++++++++++++++++++ beacon_node/beacon_chain/src/errors.rs | 2 ++ 2 files changed, 35 insertions(+) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index ea85859cc62..d63638598c4 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -2205,6 +2205,39 @@ impl BeaconChain { }) } + /// Produce the inclusion list transactions for `request_slot`. + /// + /// The transactions are requested from the execution layer via + /// `getInclusionListV1`, built on top of the current head. + pub async fn produce_inclusion_list( + &self, + request_slot: Slot, + ) -> Result, Error> { + // Inclusion lists are only produced for the current slot. + let current_slot = self.slot()?; + if request_slot != current_slot { + return Err(Error::InvalidSlot(request_slot)); + } + + let execution_layer = self + .execution_layer + .as_ref() + .ok_or(Error::ExecutionLayerMissing)?; + + let fcu_params = self + .canonical_head + .cached_head() + .forkchoice_update_parameters(); + let head_hash = fcu_params + .head_hash + .ok_or(Error::ExecutionHashMissingFromHead(fcu_params.head_root))?; + + execution_layer + .get_inclusion_list_v1(head_hash) + .await + .map_err(|e| Error::ExecutionLayerGetInclusionListFailed(Box::new(e))) + } + /// Performs the same validation as `Self::verify_unaggregated_attestation_for_gossip`, but for /// multiple attestations using batch BLS verification. Batch verification can provide /// significant CPU-time savings compared to individual verification. diff --git a/beacon_node/beacon_chain/src/errors.rs b/beacon_node/beacon_chain/src/errors.rs index a37d6703f9f..69ef4ee423d 100644 --- a/beacon_node/beacon_chain/src/errors.rs +++ b/beacon_node/beacon_chain/src/errors.rs @@ -152,6 +152,8 @@ pub enum BeaconChainError { EngineGetCapabilititesFailed(Box), ExecutionLayerGetBlockByNumberFailed(Box), ExecutionLayerGetBlockByHashFailed(Box), + ExecutionLayerGetInclusionListFailed(Box), + ExecutionHashMissingFromHead(Hash256), BlockHashMissingFromExecutionLayer(ExecutionBlockHash), InconsistentPayloadReconstructed { slot: Slot, From 9a5256af5f3b60bed90124d68c82c58f6dd6bb84 Mon Sep 17 00:00:00 2001 From: conache Date: Mon, 20 Jul 2026 13:19:38 +0300 Subject: [PATCH 02/17] Complete http api endpoint support --- beacon_node/http_api/src/lib.rs | 9 ++++ beacon_node/http_api/src/validator/mod.rs | 63 ++++++++++++++++++++++- common/eth2/src/lib.rs | 18 +++++++ common/eth2/src/types.rs | 13 +++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index e088ee08205..ad6e97d0535 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -2595,6 +2595,14 @@ pub async fn serve( task_spawner_filter.clone(), ); + // GET validator/inclusion_list?slot + let get_validator_inclusion_list = get_validator_inclusion_list( + eth_v1.clone(), + chain_filter.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + ); + // GET validator/aggregate_attestation?attestation_data_root,slot let get_validator_aggregate_attestation = get_validator_aggregate_attestation( any_version.clone(), @@ -3422,6 +3430,7 @@ pub async fn serve( .uor(get_validator_execution_payload_envelopes) .uor(get_validator_attestation_data) .uor(get_validator_payload_attestation_data) + .uor(get_validator_inclusion_list) .uor(get_validator_aggregate_attestation) .uor(get_validator_sync_committee_contribution) .uor(get_lighthouse_health) diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index b37bd7d37db..289d3cf43e4 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -16,8 +16,9 @@ use bytes::Bytes; use eth2::CONSENSUS_VERSION_HEADER; use eth2::types::{ Accept, BeaconCommitteeSubscription, EndpointVersion, Failure, GenericResponse, - StandardLivenessResponseData, StateId as CoreStateId, ValidatorAggregateAttestationQuery, - ValidatorAttestationDataQuery, ValidatorBlocksQuery, ValidatorIndexData, ValidatorStatus, + InclusionListTransactions, StandardLivenessResponseData, StateId as CoreStateId, + ValidatorAggregateAttestationQuery, ValidatorAttestationDataQuery, ValidatorBlocksQuery, + ValidatorInclusionListQuery, ValidatorIndexData, ValidatorStatus, }; use lighthouse_network::PubsubMessage; use network::{NetworkMessage, ValidatorSubscriptionMessage}; @@ -390,6 +391,64 @@ pub fn get_validator_payload_attestation_data( .boxed() } +// GET validator/inclusion_list?slot +pub fn get_validator_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("inclusion_list")) + .and(warp::path::end()) + .and(warp::query::()) + .and(not_while_syncing_filter) + .and(task_spawner_filter) + .and(chain_filter) + .then( + |query: ValidatorInclusionListQuery, + not_synced_filter: Result<(), Rejection>, + task_spawner: TaskSpawner, + chain: Arc>| { + task_spawner.spawn_async_with_rejection(Priority::P0, async move { + not_synced_filter?; + + let slot = query.slot; + let fork_name = chain.spec.fork_name_at_slot::(slot); + + // Inclusion lists are only valid for Heze and later forks. + if !fork_name.heze_enabled() { + return Err(warp_utils::reject::custom_bad_request(format!( + "Inclusion lists are not supported for fork: {fork_name}" + ))); + } + + let transactions = + chain + .produce_inclusion_list(slot) + .await + .map_err(|e| match e { + BeaconChainError::InvalidSlot(_) => { + warp_utils::reject::custom_bad_request(format!( + "Unable to produce inclusion list: {e:?}" + )) + } + _ => warp_utils::reject::custom_server_error(format!( + "Unable to produce inclusion list: {e:?}" + )), + })?; + + let response = GenericResponse::from(InclusionListTransactions:: { + transactions, + }); + Ok(warp::reply::json(&response).into_response()) + }) + }, + ) + .boxed() +} + // GET validator/blinded_blocks/{slot} pub fn get_validator_blinded_blocks( eth_v1: EthV1Filter, diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 3bda02935fb..06f2e6af006 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -3167,6 +3167,24 @@ impl BeaconNodeHttpClient { .transpose() } + /// `GET validator/inclusion_list?slot` + pub async fn get_validator_inclusion_list( + &self, + slot: Slot, + ) -> Result>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("inclusion_list"); + + path.query_pairs_mut() + .append_pair("slot", &slot.to_string()); + + self.get(path).await + } + /// `GET v1/validator/aggregate_attestation?slot,attestation_data_root` pub async fn get_validator_aggregate_attestation_v1( &self, diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 2f52013aa80..3101f5f9b6f 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -806,6 +806,19 @@ impl TryFrom> for SkipRandaoVerification { } } +#[derive(Clone, Serialize, Deserialize)] +pub struct ValidatorInclusionListQuery { + pub slot: Slot, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(bound = "E: EthSpec")] +#[serde(transparent)] +pub struct InclusionListTransactions { + #[serde(with = "ssz_types::serde_utils::list_of_hex_var_list")] + pub transactions: Transactions, +} + #[derive(Clone, Serialize, Deserialize)] pub struct ValidatorAttestationDataQuery { pub slot: Slot, From 22623d991d880a09d47540297a0b4f6ab06eea51 Mon Sep 17 00:00:00 2001 From: conache Date: Mon, 20 Jul 2026 13:47:14 +0300 Subject: [PATCH 03/17] Add pre-fork test --- beacon_node/http_api/tests/tests.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index bb20123665b..15246a76fcb 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -5268,6 +5268,18 @@ impl ApiTester { self } + pub async fn test_get_validator_inclusion_list_pre_heze(self) -> Self { + let slot = self.chain.slot().unwrap(); + + // The endpoint should return a 400 error for pre-Heze forks. + match self.client.get_validator_inclusion_list::(slot).await { + Ok(result) => panic!("query for a pre-Heze slot should fail, got: {result:?}"), + Err(e) => assert_eq!(e.status().unwrap(), 400), + } + + self + } + pub async fn test_get_validator_payload_attestation_data_no_block(self) -> Self { // Advance the slot clock without producing a block self.harness.advance_slot(); @@ -9026,6 +9038,23 @@ async fn get_validator_payload_attestation_data_pre_gloas() { .await; } +// TODO(heze): add tests for: +// - happy-path +// - bad-slot +// - EL-failure +// +// The above tests should be added once the harness supports building a Heze chain. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_inclusion_list_pre_heze() { + if fork_name_from_env().is_some_and(|f| f.heze_enabled()) { + return; + } + ApiTester::new() + .await + .test_get_validator_inclusion_list_pre_heze() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_validator_payload_attestation_data_no_block() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { From d971140cae5ed1f475d475ae8f8b9ab2789282f9 Mon Sep 17 00:00:00 2001 From: conache Date: Mon, 20 Jul 2026 15:35:00 +0300 Subject: [PATCH 04/17] Tweak todo --- beacon_node/http_api/tests/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 15246a76fcb..544d2edeca2 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -9038,10 +9038,10 @@ async fn get_validator_payload_attestation_data_pre_gloas() { .await; } -// TODO(heze): add tests for: +// TODO(heze): add IL fetching tests for: // - happy-path // - bad-slot -// - EL-failure +// - EL call failure // // The above tests should be added once the harness supports building a Heze chain. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From 932885fde138d660a1f5e03ecf686de40879f111 Mon Sep 17 00:00:00 2001 From: conache Date: Wed, 22 Jul 2026 13:53:29 +0300 Subject: [PATCH 05/17] 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 f9f9ce96071..1ed158cff11 100644 --- a/beacon_node/execution_layer/src/engine_api/http.rs +++ b/beacon_node/execution_layer/src/engine_api/http.rs @@ -1085,7 +1085,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, @@ -1097,7 +1097,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 @@ -1469,18 +1468,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 830ea8e98cd..974392f0975 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -224,8 +224,6 @@ impl From> for BlockProposalContentsGloas } } -// TODO(heze): add a `BlockProposalContentsHeze` here once Heze block production is wired up. - pub enum BlockProposalContents> { Payload { payload: Payload, @@ -944,8 +942,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 750a50c7b1432e4623420bc3aa08e48c2dc700d0 Mon Sep 17 00:00:00 2001 From: conache Date: Wed, 22 Jul 2026 16:12:15 +0300 Subject: [PATCH 06/17] 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 5763128b7bb6113128f26c73fec4acdc1d9d44d6 Mon Sep 17 00:00:00 2001 From: conache Date: Wed, 22 Jul 2026 17:09:58 +0300 Subject: [PATCH 07/17] Add IL fetching handler tests --- .../http_api/tests/interactive_tests.rs | 114 +++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 437da7d4ea6..40ccfed9e22 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -10,7 +10,7 @@ use beacon_chain::{ use beacon_processor::{Work, WorkEvent, work_reprocessing_queue::ReprocessQueueMessage}; use eth2::types::ProduceBlockV3Response; use eth2::types::{DepositContractData, StateId}; -use execution_layer::{ForkchoiceState, PayloadAttributes}; +use execution_layer::{ForkchoiceState, PayloadAttributes, test_utils::static_valid_tx}; use fixed_bytes::FixedBytesExtended; use http_api::test_utils::InteractiveTester; use parking_lot::Mutex; @@ -23,7 +23,7 @@ use std::sync::Arc; use std::time::Duration; use types::{ Address, Epoch, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, Hash256, MainnetEthSpec, - MinimalEthSpec, ProposerPreparationData, Slot, + MinimalEthSpec, ProposerPreparationData, Slot, Transactions, }; type E = MainnetEthSpec; @@ -1375,3 +1375,113 @@ async fn lighthouse_custody_info() { info.custody_group_count as usize ); } + +// Test that the validator inclusion list endpoint returns the transactions provided by the EL for +// the current slot. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_inclusion_list() { + let fork_name = fork_name_from_env().unwrap_or_else(ForkName::latest); + if !fork_name.heze_enabled() { + return; + } + + let validator_count = 64; + let spec = fork_name.make_genesis_spec(E::default_spec()); + let tester = InteractiveTester::::new(Some(spec), validator_count).await; + let client = &tester.client; + let harness = &tester.harness; + + // Build a short chain so the head references a known execution payload. + harness.advance_slot(); + harness + .extend_chain( + E::slots_per_epoch() as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Configure the IL transactions returned by the mock EL. + let transactions: Transactions = vec![static_valid_tx::().unwrap()].try_into().unwrap(); + let mock_el = harness.mock_execution_layer.as_ref().unwrap(); + mock_el + .server + .execution_block_generator() + .set_inclusion_list(transactions.clone()); + + let slot = harness.chain.slot().unwrap(); + let response = client + .get_validator_inclusion_list::(slot) + .await + .unwrap(); + assert_eq!(response.data.transactions, transactions); +} + +// Test that the validator inclusion list endpoint rejects requests for non-current slots. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_inclusion_list_invalid_slot() { + let fork_name = fork_name_from_env().unwrap_or_else(ForkName::latest); + if !fork_name.heze_enabled() { + return; + } + + let validator_count = 64; + let spec = fork_name.make_genesis_spec(E::default_spec()); + let tester = InteractiveTester::::new(Some(spec), validator_count).await; + let client = &tester.client; + let harness = &tester.harness; + + harness.advance_slot(); + harness + .extend_chain( + E::slots_per_epoch() as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Inclusion lists are only produced for the current slot: past and future slots are rejected. + let current_slot = harness.chain.slot().unwrap(); + for slot in [current_slot - 1, current_slot + 1] { + match client.get_validator_inclusion_list::(slot).await { + Ok(response) => panic!("query for slot {slot} should fail, got: {response:?}"), + Err(e) => assert_eq!(e.status().unwrap(), 400), + } + } +} + +// Test that the validator inclusion list endpoint returns a server error when the EL fails to +// provide the inclusion list. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_inclusion_list_el_failure() { + let fork_name = fork_name_from_env().unwrap_or_else(ForkName::latest); + if !fork_name.heze_enabled() { + return; + } + + let validator_count = 64; + let spec = fork_name.make_genesis_spec(E::default_spec()); + let tester = InteractiveTester::::new(Some(spec), validator_count).await; + let client = &tester.client; + let harness = &tester.harness; + + harness.advance_slot(); + harness + .extend_chain( + E::slots_per_epoch() as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Drop the mock EL's blocks so the head block hash is unknown to it, + // making the getInclusionList call fail + let mock_el = harness.mock_execution_layer.as_ref().unwrap(); + mock_el.server.drop_all_blocks(); + + let slot = harness.chain.slot().unwrap(); + match client.get_validator_inclusion_list::(slot).await { + Ok(response) => panic!("query should fail when the EL errors, got: {response:?}"), + Err(e) => assert_eq!(e.status().unwrap(), 500), + } +} From abac19c6957eeb8c7eb3d516ecbbb2c44b9e9804 Mon Sep 17 00:00:00 2001 From: conache Date: Wed, 22 Jul 2026 13:53:29 +0300 Subject: [PATCH 08/17] 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 09/17] 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 10/17] 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 b9d0fdc5e7e70523bc69361b32f4057e3d8c1e3c Mon Sep 17 00:00:00 2001 From: conache Date: Tue, 4 Aug 2026 15:00:11 +0300 Subject: [PATCH 11/17] Tests cleanup --- Cargo.lock | 1 + .../beacon_chain/src/block_production/gloas.rs | 12 ++++++------ beacon_node/http_api/Cargo.toml | 1 + beacon_node/http_api/src/validator/mod.rs | 5 ++--- beacon_node/http_api/tests/interactive_tests.rs | 16 ++++++++-------- beacon_node/http_api/tests/tests.rs | 2 +- 6 files changed, 19 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d162a44228..b9d740ed7fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4354,6 +4354,7 @@ dependencies = [ "serde", "serde_json", "slot_clock", + "ssz_types", "state_processing", "store", "sysinfo", diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index e51c2fe773b..d51123da29a 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -30,13 +30,13 @@ use tree_hash::TreeHash; use types::consts::gloas::BUILDER_INDEX_SELF_BUILD; use types::{ Address, Attestation, AttestationGloas, AttesterSlashing, AttesterSlashingGloas, BeaconBlock, - BeaconBlockBodyGloas, BeaconBlockBodyHeze, BeaconBlockGloas, BeaconBlockHeze, - BeaconState, BeaconStateError, BlobsList, BuilderIndex, ChainSpec, Deposit, Eth1Data, EthSpec, + BeaconBlockBodyGloas, BeaconBlockBodyHeze, BeaconBlockGloas, BeaconBlockHeze, BeaconState, + BeaconStateError, BlobsList, BuilderIndex, ChainSpec, Deposit, Eth1Data, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, ExecutionPayloadEnvelope, ExecutionPayloadGloas, - ExecutionRequestsGloas, FullPayload, Graffiti, Hash256, IndexedAttestation, KzgProofs,PayloadAttestation, ProposerSlashing, - RelativeEpoch, SignedBeaconBlock, SignedBlsToExecutionChange, SignedExecutionPayloadBid, - SignedExecutionPayloadEnvelope, SignedVoluntaryExit, Slot, SyncAggregate, Uint256,Withdrawal, - Withdrawals, + ExecutionRequestsGloas, FullPayload, Graffiti, Hash256, IndexedAttestation, KzgProofs, + PayloadAttestation, ProposerSlashing, RelativeEpoch, SignedBeaconBlock, + SignedBlsToExecutionChange, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, + SignedVoluntaryExit, Slot, SyncAggregate, Uint256, Withdrawal, Withdrawals, }; use crate::pending_payload_envelopes::PendingEnvelopeData; diff --git a/beacon_node/http_api/Cargo.toml b/beacon_node/http_api/Cargo.toml index 62b316beda4..1195f12ce4a 100644 --- a/beacon_node/http_api/Cargo.toml +++ b/beacon_node/http_api/Cargo.toml @@ -41,6 +41,7 @@ sensitive_url = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } slot_clock = { workspace = true } +ssz_types = { workspace = true } state_processing = { workspace = true } store = { workspace = true } sysinfo = { workspace = true } diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 1761e792546..c94d3ac7882 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -440,9 +440,8 @@ pub fn get_validator_inclusion_list( )), })?; - let response = GenericResponse::from(InclusionListTransactions { - transactions, - }); + let response = + GenericResponse::from(InclusionListTransactions { transactions }); Ok(warp::reply::json(&response).into_response()) }) }, diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 9c0a7e5da7a..471a8883327 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -15,6 +15,7 @@ use fixed_bytes::FixedBytesExtended; use http_api::test_utils::InteractiveTester; use parking_lot::Mutex; use slot_clock::SlotClock; +use ssz_types::ProgressiveVariableList; use state_processing::{ per_block_processing::get_expected_withdrawals, state_advance::complete_state_advance, }; @@ -23,7 +24,7 @@ use std::sync::Arc; use std::time::Duration; use types::{ Address, Epoch, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, Hash256, MainnetEthSpec, - MinimalEthSpec, ProposerPreparationData, Slot, Transactions, + MinimalEthSpec, ProgressiveTransactions, ProposerPreparationData, Slot, }; type E = MainnetEthSpec; @@ -1413,7 +1414,9 @@ async fn get_validator_inclusion_list() { .await; // Configure the IL transactions returned by the mock EL. - let transactions: Transactions = vec![static_valid_tx::().unwrap()].try_into().unwrap(); + let transactions = ProgressiveTransactions::new(vec![ProgressiveVariableList::new( + static_valid_tx::().unwrap().to_vec(), + )]); let mock_el = harness.mock_execution_layer.as_ref().unwrap(); mock_el .server @@ -1421,10 +1424,7 @@ async fn get_validator_inclusion_list() { .set_inclusion_list(transactions.clone()); let slot = harness.chain.slot().unwrap(); - let response = client - .get_validator_inclusion_list::(slot) - .await - .unwrap(); + let response = client.get_validator_inclusion_list(slot).await.unwrap(); assert_eq!(response.data.transactions, transactions); } @@ -1454,7 +1454,7 @@ async fn get_validator_inclusion_list_invalid_slot() { // Inclusion lists are only produced for the current slot: past and future slots are rejected. let current_slot = harness.chain.slot().unwrap(); for slot in [current_slot - 1, current_slot + 1] { - match client.get_validator_inclusion_list::(slot).await { + match client.get_validator_inclusion_list(slot).await { Ok(response) => panic!("query for slot {slot} should fail, got: {response:?}"), Err(e) => assert_eq!(e.status().unwrap(), 400), } @@ -1491,7 +1491,7 @@ async fn get_validator_inclusion_list_el_failure() { mock_el.server.drop_all_blocks(); let slot = harness.chain.slot().unwrap(); - match client.get_validator_inclusion_list::(slot).await { + match client.get_validator_inclusion_list(slot).await { Ok(response) => panic!("query should fail when the EL errors, got: {response:?}"), Err(e) => assert_eq!(e.status().unwrap(), 500), } diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index b68e71e0ff5..bb0dbccc5d0 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -5771,7 +5771,7 @@ impl ApiTester { let slot = self.chain.slot().unwrap(); // The endpoint should return a 400 error for pre-Heze forks. - match self.client.get_validator_inclusion_list::(slot).await { + match self.client.get_validator_inclusion_list(slot).await { Ok(result) => panic!("query for a pre-Heze slot should fail, got: {result:?}"), Err(e) => assert_eq!(e.status().unwrap(), 400), } From 4a9a6589646b406524c974bc0d9e9293c78bc04b Mon Sep 17 00:00:00 2001 From: conache Date: Thu, 16 Jul 2026 20:07:18 +0300 Subject: [PATCH 12/17] Add method to produce ILs for the current slot --- beacon_node/beacon_chain/src/beacon_chain.rs | 33 ++++++++++++++++++++ beacon_node/beacon_chain/src/errors.rs | 2 ++ 2 files changed, 35 insertions(+) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index a0e3ac4ae2f..2a5d187bb2c 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -2206,6 +2206,39 @@ impl BeaconChain { }) } + /// Produce the inclusion list transactions for `request_slot`. + /// + /// The transactions are requested from the execution layer via + /// `getInclusionListV1`, built on top of the current head. + pub async fn produce_inclusion_list( + &self, + request_slot: Slot, + ) -> Result, Error> { + // Inclusion lists are only produced for the current slot. + let current_slot = self.slot()?; + if request_slot != current_slot { + return Err(Error::InvalidSlot(request_slot)); + } + + let execution_layer = self + .execution_layer + .as_ref() + .ok_or(Error::ExecutionLayerMissing)?; + + let fcu_params = self + .canonical_head + .cached_head() + .forkchoice_update_parameters(); + let head_hash = fcu_params + .head_hash + .ok_or(Error::ExecutionHashMissingFromHead(fcu_params.head_root))?; + + execution_layer + .get_inclusion_list_v1(head_hash) + .await + .map_err(|e| Error::ExecutionLayerGetInclusionListFailed(Box::new(e))) + } + /// Performs the same validation as `Self::verify_unaggregated_attestation_for_gossip`, but for /// multiple attestations using batch BLS verification. Batch verification can provide /// significant CPU-time savings compared to individual verification. diff --git a/beacon_node/beacon_chain/src/errors.rs b/beacon_node/beacon_chain/src/errors.rs index a37d6703f9f..69ef4ee423d 100644 --- a/beacon_node/beacon_chain/src/errors.rs +++ b/beacon_node/beacon_chain/src/errors.rs @@ -152,6 +152,8 @@ pub enum BeaconChainError { EngineGetCapabilititesFailed(Box), ExecutionLayerGetBlockByNumberFailed(Box), ExecutionLayerGetBlockByHashFailed(Box), + ExecutionLayerGetInclusionListFailed(Box), + ExecutionHashMissingFromHead(Hash256), BlockHashMissingFromExecutionLayer(ExecutionBlockHash), InconsistentPayloadReconstructed { slot: Slot, From e94dd02a4ff7cde957abc50d5f64cf170c4cf0b9 Mon Sep 17 00:00:00 2001 From: conache Date: Mon, 20 Jul 2026 13:19:38 +0300 Subject: [PATCH 13/17] Complete http api endpoint support --- beacon_node/http_api/src/lib.rs | 9 ++++ beacon_node/http_api/src/validator/mod.rs | 63 ++++++++++++++++++++++- common/eth2/src/lib.rs | 18 +++++++ common/eth2/src/types.rs | 13 +++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 03d0450627f..eceffb209df 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -2601,6 +2601,14 @@ pub async fn serve( task_spawner_filter.clone(), ); + // GET validator/inclusion_list?slot + let get_validator_inclusion_list = get_validator_inclusion_list( + eth_v1.clone(), + chain_filter.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + ); + // GET validator/aggregate_attestation?attestation_data_root,slot let get_validator_aggregate_attestation = get_validator_aggregate_attestation( any_version.clone(), @@ -3429,6 +3437,7 @@ pub async fn serve( .uor(get_validator_execution_payload_envelopes) .uor(get_validator_attestation_data) .uor(get_validator_payload_attestation_data) + .uor(get_validator_inclusion_list) .uor(get_validator_aggregate_attestation) .uor(get_validator_sync_committee_contribution) .uor(get_lighthouse_health) diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 5287a1a3974..c6e19080dfe 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -17,8 +17,9 @@ use context_deserialize::ContextDeserialize; use eth2::CONSENSUS_VERSION_HEADER; use eth2::types::{ Accept, BeaconCommitteeSubscription, EndpointVersion, Failure, GenericResponse, - StandardLivenessResponseData, StateId as CoreStateId, ValidatorAggregateAttestationQuery, - ValidatorAttestationDataQuery, ValidatorBlocksQuery, ValidatorIndexData, ValidatorStatus, + InclusionListTransactions, StandardLivenessResponseData, StateId as CoreStateId, + ValidatorAggregateAttestationQuery, ValidatorAttestationDataQuery, ValidatorBlocksQuery, + ValidatorInclusionListQuery, ValidatorIndexData, ValidatorStatus, }; use lighthouse_network::PubsubMessage; use network::{NetworkMessage, ValidatorSubscriptionMessage}; @@ -391,6 +392,64 @@ pub fn get_validator_payload_attestation_data( .boxed() } +// GET validator/inclusion_list?slot +pub fn get_validator_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("inclusion_list")) + .and(warp::path::end()) + .and(warp::query::()) + .and(not_while_syncing_filter) + .and(task_spawner_filter) + .and(chain_filter) + .then( + |query: ValidatorInclusionListQuery, + not_synced_filter: Result<(), Rejection>, + task_spawner: TaskSpawner, + chain: Arc>| { + task_spawner.spawn_async_with_rejection(Priority::P0, async move { + not_synced_filter?; + + let slot = query.slot; + let fork_name = chain.spec.fork_name_at_slot::(slot); + + // Inclusion lists are only valid for Heze and later forks. + if !fork_name.heze_enabled() { + return Err(warp_utils::reject::custom_bad_request(format!( + "Inclusion lists are not supported for fork: {fork_name}" + ))); + } + + let transactions = + chain + .produce_inclusion_list(slot) + .await + .map_err(|e| match e { + BeaconChainError::InvalidSlot(_) => { + warp_utils::reject::custom_bad_request(format!( + "Unable to produce inclusion list: {e:?}" + )) + } + _ => warp_utils::reject::custom_server_error(format!( + "Unable to produce inclusion list: {e:?}" + )), + })?; + + let response = GenericResponse::from(InclusionListTransactions:: { + transactions, + }); + Ok(warp::reply::json(&response).into_response()) + }) + }, + ) + .boxed() +} + // GET validator/blinded_blocks/{slot} pub fn get_validator_blinded_blocks( eth_v1: EthV1Filter, diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index b216362b389..df17c8228f1 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -3288,6 +3288,24 @@ impl BeaconNodeHttpClient { .transpose() } + /// `GET validator/inclusion_list?slot` + pub async fn get_validator_inclusion_list( + &self, + slot: Slot, + ) -> Result>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("inclusion_list"); + + path.query_pairs_mut() + .append_pair("slot", &slot.to_string()); + + self.get(path).await + } + /// `GET v1/validator/aggregate_attestation?slot,attestation_data_root` pub async fn get_validator_aggregate_attestation_v1( &self, diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index f941857d28f..e03736a7eba 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -895,6 +895,19 @@ impl TryFrom> for SkipRandaoVerification { } } +#[derive(Clone, Serialize, Deserialize)] +pub struct ValidatorInclusionListQuery { + pub slot: Slot, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(bound = "E: EthSpec")] +#[serde(transparent)] +pub struct InclusionListTransactions { + #[serde(with = "ssz_types::serde_utils::list_of_hex_var_list")] + pub transactions: Transactions, +} + #[derive(Clone, Serialize, Deserialize)] pub struct ValidatorAttestationDataQuery { pub slot: Slot, From 1ef78e7bc18c76cccb609cb5a0c6b68a254128bc Mon Sep 17 00:00:00 2001 From: conache Date: Mon, 20 Jul 2026 13:47:14 +0300 Subject: [PATCH 14/17] Add pre-fork test --- beacon_node/http_api/tests/tests.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 6d71a1cdddc..b4d96cc9aab 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -5767,6 +5767,18 @@ impl ApiTester { self } + pub async fn test_get_validator_inclusion_list_pre_heze(self) -> Self { + let slot = self.chain.slot().unwrap(); + + // The endpoint should return a 400 error for pre-Heze forks. + match self.client.get_validator_inclusion_list::(slot).await { + Ok(result) => panic!("query for a pre-Heze slot should fail, got: {result:?}"), + Err(e) => assert_eq!(e.status().unwrap(), 400), + } + + self + } + pub async fn test_get_validator_payload_attestation_data_no_block(self) -> Self { // Advance the slot clock without producing a block self.harness.advance_slot(); @@ -9774,6 +9786,23 @@ async fn get_validator_payload_attestation_data_pre_gloas() { .await; } +// TODO(heze): add tests for: +// - happy-path +// - bad-slot +// - EL-failure +// +// The above tests should be added once the harness supports building a Heze chain. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_inclusion_list_pre_heze() { + if fork_name_from_env().is_some_and(|f| f.heze_enabled()) { + return; + } + ApiTester::new() + .await + .test_get_validator_inclusion_list_pre_heze() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_validator_payload_attestation_data_no_block() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { From 5503f08db62b25c8d387374beace36b3c70f047c Mon Sep 17 00:00:00 2001 From: conache Date: Mon, 20 Jul 2026 15:35:00 +0300 Subject: [PATCH 15/17] Tweak todo --- beacon_node/http_api/tests/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index b4d96cc9aab..b68e71e0ff5 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -9786,10 +9786,10 @@ async fn get_validator_payload_attestation_data_pre_gloas() { .await; } -// TODO(heze): add tests for: +// TODO(heze): add IL fetching tests for: // - happy-path // - bad-slot -// - EL-failure +// - EL call failure // // The above tests should be added once the harness supports building a Heze chain. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From e6641b87d5d2f9837edc8dabf7f4383bd6ef4f4b Mon Sep 17 00:00:00 2001 From: conache Date: Thu, 6 Aug 2026 12:36:35 +0300 Subject: [PATCH 16/17] Use ProgressiveTransactions in the InclusionListTransactions struct --- beacon_node/beacon_chain/src/beacon_chain.rs | 2 +- beacon_node/http_api/src/validator/mod.rs | 5 ++--- common/eth2/src/lib.rs | 4 ++-- common/eth2/src/types.rs | 7 +++---- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 2a5d187bb2c..4adbcf44c0a 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -2213,7 +2213,7 @@ impl BeaconChain { pub async fn produce_inclusion_list( &self, request_slot: Slot, - ) -> Result, Error> { + ) -> Result { // Inclusion lists are only produced for the current slot. let current_slot = self.slot()?; if request_slot != current_slot { diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index c6e19080dfe..c94d3ac7882 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -440,9 +440,8 @@ pub fn get_validator_inclusion_list( )), })?; - let response = GenericResponse::from(InclusionListTransactions:: { - transactions, - }); + let response = + GenericResponse::from(InclusionListTransactions { transactions }); Ok(warp::reply::json(&response).into_response()) }) }, diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index df17c8228f1..ef3ac9f0261 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -3289,10 +3289,10 @@ impl BeaconNodeHttpClient { } /// `GET validator/inclusion_list?slot` - pub async fn get_validator_inclusion_list( + pub async fn get_validator_inclusion_list( &self, slot: Slot, - ) -> Result>, Error> { + ) -> Result, Error> { let mut path = self.eth_path(V1)?; path.path_segments_mut() diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index e03736a7eba..230a97b2a40 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -901,11 +901,10 @@ pub struct ValidatorInclusionListQuery { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(bound = "E: EthSpec")] #[serde(transparent)] -pub struct InclusionListTransactions { - #[serde(with = "ssz_types::serde_utils::list_of_hex_var_list")] - pub transactions: Transactions, +pub struct InclusionListTransactions { + #[serde(with = "ssz_types::serde_utils::prog_list_of_hex_prog_var_list")] + pub transactions: ProgressiveTransactions, } #[derive(Clone, Serialize, Deserialize)] From db57fb78b856ff20b1d2d1d40f6c9aa122c7b88c Mon Sep 17 00:00:00 2001 From: conache Date: Mon, 10 Aug 2026 20:11:16 +0300 Subject: [PATCH 17/17] 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(