From e766740b71fa296cd38e98bd4a13de82ece78bee Mon Sep 17 00:00:00 2001 From: conache Date: Wed, 12 Aug 2026 19:29:14 +0300 Subject: [PATCH 01/13] Add inclusion_list_due wiring to the chain specs --- consensus/types/src/core/chain_spec.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index 817517de2e5..24466520248 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -131,6 +131,7 @@ pub struct ChainSpec { pub aggregate_attestation_due: Duration, pub sync_message_due: Duration, pub contribution_and_proof_due: Duration, + pub inclusion_list_due: Duration, /* * Reward and penalty quotients @@ -966,6 +967,12 @@ impl ChainSpec { self.sync_message_due } + /// Spec: `get_inclusion_list_due_ms`. + /// Get the duration into a slot in which an inclusion list production duty is due. + pub fn get_inclusion_list_due(&self) -> Duration { + self.inclusion_list_due + } + /// Calculate the duration into a slot for a given slot component pub fn compute_slot_component_duration( &self, @@ -1043,6 +1050,9 @@ impl ChainSpec { self.contribution_and_proof_due = self .compute_slot_component_duration(self.contribution_due_bps) .expect("invalid chain spec: cannot compute contribution_and_proof_due"); + self.inclusion_list_due = self + .compute_slot_component_duration(self.inclusion_list_due_bps) + .expect("invalid chain spec: cannot compute inclusion_list_due"); self.attestation_subnet_prefix_bits = compute_attestation_subnet_prefix_bits( self.attestation_subnet_count, @@ -1185,6 +1195,7 @@ impl ChainSpec { aggregate_attestation_due: Duration::from_millis(8000), sync_message_due: Duration::from_millis(3999), contribution_and_proof_due: Duration::from_millis(8000), + inclusion_list_due: Duration::from_millis(8000), /* * Reward and penalty quotients @@ -1528,6 +1539,7 @@ impl ChainSpec { aggregate_attestation_due: Duration::from_millis(4000), sync_message_due: Duration::from_millis(1999), contribution_and_proof_due: Duration::from_millis(4000), + inclusion_list_due: Duration::from_millis(4000), // Networking Fulu blob_schedule: BlobSchedule::default(), @@ -1631,6 +1643,7 @@ impl ChainSpec { aggregate_attestation_due: Duration::from_millis(3333), sync_message_due: Duration::from_millis(1666), contribution_and_proof_due: Duration::from_millis(3333), + inclusion_list_due: Duration::from_millis(3333), /* * Reward and penalty quotients From cb4f4981c07ce6d5759cda28d54d13a794f5539f Mon Sep 17 00:00:00 2001 From: conache Date: Thu, 13 Aug 2026 12:38:03 +0300 Subject: [PATCH 02/13] Add basic il service and basic wiring to the validator client --- validator_client/src/lib.rs | 19 ++++ .../src/inclusion_list_service.rs | 89 +++++++++++++++++++ .../validator_services/src/lib.rs | 1 + 3 files changed, 109 insertions(+) create mode 100644 validator_client/validator_services/src/inclusion_list_service.rs diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs index 88844918431..0c0bfc2f2e6 100644 --- a/validator_client/src/lib.rs +++ b/validator_client/src/lib.rs @@ -44,6 +44,7 @@ use validator_services::{ attestation_service::{AttestationService, AttestationServiceBuilder}, block_service::{BlockService, BlockServiceBuilder}, duties_service::{self, DutiesService, DutiesServiceBuilder}, + inclusion_list_service::InclusionListService, latency_service, payload_attestation_service::PayloadAttestationService, preparation_service::{PreparationService, PreparationServiceBuilder}, @@ -90,6 +91,7 @@ pub struct ProductionValidatorClient { ProposerPreferencesService, SystemTimeSlotClock>, doppelganger_service: Option>, preparation_service: PreparationService, SystemTimeSlotClock>, + inclusion_list_service: InclusionListService, SystemTimeSlotClock>, validator_store: Arc>, slot_clock: SystemTimeSlotClock, http_api_listen_addr: Option, @@ -577,6 +579,15 @@ impl ProductionValidatorClient { context.eth2_config.spec.clone(), ); + let inclusion_list_service = InclusionListService::new( + duties_service.clone(), + validator_store.clone(), + slot_clock.clone(), + beacon_nodes.clone(), + context.executor.clone(), + context.eth2_config.spec.clone(), + ); + Ok(Self { context, duties_service, @@ -585,6 +596,7 @@ impl ProductionValidatorClient { sync_committee_service, payload_attestation_service, proposer_preferences_service, + inclusion_list_service, doppelganger_service, preparation_service, validator_store, @@ -669,6 +681,13 @@ impl ProductionValidatorClient { .map_err(|e| format!("Unable to start proposer preferences service: {}", e))?; } + if self.context.eth2_config.spec.is_heze_scheduled() { + self.inclusion_list_service + .clone() + .start_update_service() + .map_err(|e| format!("Unable to start inclusion list service: {}", e))?; + } + self.preparation_service .clone() .start_update_service(&self.context.eth2_config.spec) diff --git a/validator_client/validator_services/src/inclusion_list_service.rs b/validator_client/validator_services/src/inclusion_list_service.rs new file mode 100644 index 00000000000..374756b2388 --- /dev/null +++ b/validator_client/validator_services/src/inclusion_list_service.rs @@ -0,0 +1,89 @@ +use crate::duties_service::DutiesService; +use beacon_node_fallback::BeaconNodeFallback; +use slot_clock::SlotClock; +use std::ops::Deref; +use std::sync::Arc; +use task_executor::TaskExecutor; +use tracing::{error, info}; +use types::ChainSpec; +use validator_store::ValidatorStore; + +pub struct Inner { + duties_service: Arc>, + validator_store: Arc, + slot_clock: T, + beacon_nodes: Arc>, + executor: TaskExecutor, + chain_spec: Arc, +} + +pub struct InclusionListService { + inner: Arc>, +} + +impl Clone for InclusionListService { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl Deref for InclusionListService { + type Target = Inner; + + fn deref(&self) -> &Self::Target { + self.inner.deref() + } +} + +impl InclusionListService +where + S: ValidatorStore + 'static, + T: SlotClock + 'static, +{ + pub fn new( + duties_service: Arc>, + validator_store: Arc, + slot_clock: T, + beacon_nodes: Arc>, + executor: TaskExecutor, + chain_spec: Arc, + ) -> Self { + Self { + inner: Arc::new(Inner { + duties_service, + validator_store, + slot_clock, + beacon_nodes, + executor, + chain_spec, + }), + } + } + + pub fn start_update_service(self) -> Result<(), String> { + info!( + inclusion_list_due_ms = self.chain_spec.get_inclusion_list_due().as_millis(), + "Inclusion list service started" + ); + let executor = self.executor.clone(); + + let interval_fut = async move { + loop { + if let Err(e) = self.spawn_inclusion_list_tasks().await { + error!(error = e, "Failed to produce inclusion lists"); + } + } + }; + + executor.spawn(interval_fut, "inclusion_list_service"); + + Ok(()) + } + + async fn spawn_inclusion_list_tasks(&self) -> Result<(), String> { + todo!("spawn_inclusion_list_tasks not yet implemented"); + Ok(()) + } +} diff --git a/validator_client/validator_services/src/lib.rs b/validator_client/validator_services/src/lib.rs index c39ef4499b7..0b4f12b6ec2 100644 --- a/validator_client/validator_services/src/lib.rs +++ b/validator_client/validator_services/src/lib.rs @@ -1,6 +1,7 @@ pub mod attestation_service; pub mod block_service; pub mod duties_service; +pub mod inclusion_list_service; pub mod latency_service; pub mod notifier_service; pub mod payload_attestation_service; From 4153d76521cd06d5fe4a950aaaa872491b5c9593 Mon Sep 17 00:00:00 2001 From: conache Date: Thu, 13 Aug 2026 19:40:56 +0300 Subject: [PATCH 03/13] Setup il tasks per slot --- common/eth2/src/lib.rs | 18 +++ common/eth2/src/types.rs | 7 + .../src/inclusion_list_service.rs | 143 +++++++++++++++++- 3 files changed, 165 insertions(+), 3 deletions(-) diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 979b6ba3f09..08036f041b6 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -3555,6 +3555,24 @@ impl BeaconNodeHttpClient { self.get_opt(path).await } + /// `GET validator/inclusion_list` + 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_with_timeout(path, self.timeouts.inclusion_list_duties) + .await + } + /// `POST lighthouse/liveness` pub async fn post_lighthouse_liveness( &self, diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 6cac94189bd..fe74f856211 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -902,6 +902,13 @@ impl TryFrom> for SkipRandaoVerification { } } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct InclusionListTransactions { + #[serde(with = "ssz_types::serde_utils::prog_list_of_hex_prog_var_list")] + pub transactions: ProgressiveTransactions, +} + #[derive(Clone, Serialize, Deserialize)] pub struct ValidatorAttestationDataQuery { pub slot: Slot, diff --git a/validator_client/validator_services/src/inclusion_list_service.rs b/validator_client/validator_services/src/inclusion_list_service.rs index 374756b2388..078ef9b4c35 100644 --- a/validator_client/validator_services/src/inclusion_list_service.rs +++ b/validator_client/validator_services/src/inclusion_list_service.rs @@ -1,13 +1,24 @@ use crate::duties_service::DutiesService; use beacon_node_fallback::BeaconNodeFallback; +use eth2::types::{InclusionListDuty, InclusionListTransactions}; +use logging::crit; use slot_clock::SlotClock; use std::ops::Deref; use std::sync::Arc; use task_executor::TaskExecutor; -use tracing::{error, info}; -use types::ChainSpec; +use tokio::time::sleep; +use tracing::{debug, error, info}; +use types::{ChainSpec, EthSpec, Hash256, Slot}; use validator_store::ValidatorStore; +type DependentRoot = Hash256; + +struct InclusionListData { + slot: Slot, + dependent_root: DependentRoot, + transactions: InclusionListTransactions, +} + pub struct Inner { duties_service: Arc>, validator_store: Arc, @@ -83,7 +94,133 @@ where } async fn spawn_inclusion_list_tasks(&self) -> Result<(), String> { - todo!("spawn_inclusion_list_tasks not yet implemented"); + let Some(slot) = self.wait_to_next_slot().await else { + return Ok(()); + }; + + let Some((duties, attestation_data)) = + self.produce_inclusion_list_duties_data(slot).await? + else { + return Ok(()); + }; + + let service = self.clone(); + self.executor.spawn( + async move { + if let Err(e) = service + .sign_and_publish(slot, duties, attestation_data) + .await + { + crit!(error = e, %slot, "Failed to publish inclusion lists"); + } + }, + "inclusion_list_producer", + ); Ok(()) } + + async fn wait_to_next_slot(&self) -> Option { + let slot_duration = self.chain_spec.get_slot_duration(); + + let Some(duration_to_next_slot) = self.slot_clock.duration_to_next_slot() else { + error!("Failed to read slot clock"); + sleep(slot_duration).await; + return None; + }; + + let Some(current_slot) = self.slot_clock.now() else { + error!("Failed to read slot clock after trigger"); + return None; + }; + + // Ensure that the current slot is in the Heze fork + if !self + .chain_spec + .fork_name_at_slot::(current_slot) + .heze_enabled() + { + let duration_to_next_epoch = self + .slot_clock + .duration_to_next_epoch(S::E::slots_per_epoch()) + .unwrap_or_else(|| { + self.chain_spec.get_slot_duration() * S::E::slots_per_epoch() as u32 + }); + sleep(duration_to_next_epoch).await; + return None; + } + + sleep(duration_to_next_slot).await; + + let Some(current_slot) = self.slot_clock.now() else { + error!("Failed to read slot clock after sleep"); + return None; + }; + + Some(current_slot) + } + + /// Produce the inclusion list data for `slot`, returned alongside the duties to sign. + /// + /// Returns `Ok(None)` when there is nothing to publish (no duties, or no inclusion list data for slot) + /// and `Err` when data production failed. + async fn produce_inclusion_list_duties_data( + &self, + slot: Slot, + ) -> Result, InclusionListData)>, String> { + let Some((dependent_root, duties)) = self.duties_service.get_il_duties_for_slot(slot) + else { + return Ok(None); + }; + + if duties.is_empty() { + return Ok(None); + } + + debug!( + %slot, + il_duties_count = duties.len(), + "Producing inclusion lists" + ); + + // Fetch the inclusion list transactions for the given slot + let transactions = self + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .get_validator_inclusion_list(slot) + .await + .map(|resp| resp.data) + }) + .await + .map_err(|e| e.to_string())?; + + debug!( + %slot, + ?dependent_root, + tx_count = transactions.transactions.len(), + tx_bytes = transactions + .transactions + .iter() + .map(|tx| tx.len()) + .sum::(), + "Received inclusion list transactions" + ); + + let il_duties = InclusionListData { + dependent_root, + slot, + transactions, + }; + + Ok(Some((duties, il_duties))) + } + + async fn sign_and_publish( + &self, + slot: Slot, + duties: Vec, + inclusion_list_data: InclusionListData, + ) -> Result<(), String> { + todo!("Signing and publishing ILs is not yet implemented") + } } From 3fe2945c31975c0eb08d402545150e8615420e93 Mon Sep 17 00:00:00 2001 From: conache Date: Fri, 14 Aug 2026 12:08:33 +0300 Subject: [PATCH 04/13] Add client support for publishing inclusion lists --- common/eth2/src/lib.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 08036f041b6..69b5abf0014 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -3573,6 +3573,46 @@ impl BeaconNodeHttpClient { .await } + /// `POST validator/inclusion_list` + pub async fn post_validator_inclusion_list( + &self, + signed_inclusion_list: SignedInclusionList, + fork_name: ForkName, + ) -> 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"); + + self.post_generic_with_consensus_version(path, &signed_inclusion_list, None, fork_name) + .await?; + + Ok(()) + } + + /// `POST validator/inclusion_list` (SSZ) + pub async fn post_validator_inclusion_list_ssz( + &self, + signed_inclusion_list: SignedInclusionList, + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("proposer_preferences"); + + let ssz_body = signed_inclusion_list.as_ssz_bytes(); + + self.post_generic_with_consensus_version_and_ssz_body(path, ssz_body, None, fork_name) + .await?; + + Ok(()) + } + /// `POST lighthouse/liveness` pub async fn post_lighthouse_liveness( &self, From 5d02da0e7996384ef3686fa7e6ca43f53e887831 Mon Sep 17 00:00:00 2001 From: conache Date: Fri, 14 Aug 2026 12:31:17 +0300 Subject: [PATCH 05/13] Update endpoints --- common/eth2/src/lib.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 69b5abf0014..233ff5d4d85 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -3574,9 +3574,9 @@ impl BeaconNodeHttpClient { } /// `POST validator/inclusion_list` - pub async fn post_validator_inclusion_list( + pub async fn post_validator_inclusion_lists( &self, - signed_inclusion_list: SignedInclusionList, + signed_inclusion_lists: &[SignedInclusionList], fork_name: ForkName, ) -> Result<(), Error> { let mut path = self.eth_path(V1)?; @@ -3586,16 +3586,16 @@ impl BeaconNodeHttpClient { .push("validator") .push("inclusion_list"); - self.post_generic_with_consensus_version(path, &signed_inclusion_list, None, fork_name) + self.post_generic_with_consensus_version(path, &signed_inclusion_lists, None, fork_name) .await?; Ok(()) } /// `POST validator/inclusion_list` (SSZ) - pub async fn post_validator_inclusion_list_ssz( + pub async fn post_validator_inclusion_lists_ssz( &self, - signed_inclusion_list: SignedInclusionList, + signed_inclusion_lists: &Vec, fork_name: ForkName, ) -> Result<(), Error> { let mut path = self.eth_path(V1)?; @@ -3603,9 +3603,9 @@ impl BeaconNodeHttpClient { path.path_segments_mut() .map_err(|()| Error::InvalidUrl(self.server.clone()))? .push("validator") - .push("proposer_preferences"); + .push("inclusion_list"); - let ssz_body = signed_inclusion_list.as_ssz_bytes(); + let ssz_body = signed_inclusion_lists.as_ssz_bytes(); self.post_generic_with_consensus_version_and_ssz_body(path, ssz_body, None, fork_name) .await?; From a8e58672e17436625b3ff0ba4de0f85046185a5b Mon Sep 17 00:00:00 2001 From: conache Date: Fri, 14 Aug 2026 12:55:48 +0300 Subject: [PATCH 06/13] Correct publish path function to be aligned with the spec --- common/eth2/src/lib.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 233ff5d4d85..99b52d014e2 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -3574,9 +3574,9 @@ impl BeaconNodeHttpClient { } /// `POST validator/inclusion_list` - pub async fn post_validator_inclusion_lists( + pub async fn post_validator_inclusion_list( &self, - signed_inclusion_lists: &[SignedInclusionList], + signed_inclusion_list: &SignedInclusionList, fork_name: ForkName, ) -> Result<(), Error> { let mut path = self.eth_path(V1)?; @@ -3586,16 +3586,18 @@ impl BeaconNodeHttpClient { .push("validator") .push("inclusion_list"); - self.post_generic_with_consensus_version(path, &signed_inclusion_lists, None, fork_name) + // The request body is wrapped in a `data` object, per the beacon-APIs specs + let body = serde_json::json!({ "data": signed_inclusion_list }); + self.post_generic_with_consensus_version(path, &body, None, fork_name) .await?; Ok(()) } /// `POST validator/inclusion_list` (SSZ) - pub async fn post_validator_inclusion_lists_ssz( + pub async fn post_validator_inclusion_list_ssz( &self, - signed_inclusion_lists: &Vec, + signed_inclusion_list: &SignedInclusionList, fork_name: ForkName, ) -> Result<(), Error> { let mut path = self.eth_path(V1)?; @@ -3605,7 +3607,7 @@ impl BeaconNodeHttpClient { .push("validator") .push("inclusion_list"); - let ssz_body = signed_inclusion_lists.as_ssz_bytes(); + let ssz_body = signed_inclusion_list.as_ssz_bytes(); self.post_generic_with_consensus_version_and_ssz_body(path, ssz_body, None, fork_name) .await?; From 8f78f9011bb3ed6b87312c92a7285b0080f97768 Mon Sep 17 00:00:00 2001 From: conache Date: Fri, 14 Aug 2026 14:20:11 +0300 Subject: [PATCH 07/13] Implement sign and publish logic in the inclusion list service --- .../lighthouse_validator_store/src/lib.rs | 23 +++- .../src/inclusion_list_service.rs | 124 ++++++++++++++++-- validator_client/validator_store/src/lib.rs | 17 ++- 3 files changed, 142 insertions(+), 22 deletions(-) diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index ce2b85f3af5..c27ae935e06 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -21,13 +21,14 @@ use tracing::{Instrument, debug, error, info, info_span, instrument, warn}; use types::{ AbstractExecPayload, Address, AggregateAndProof, Attestation, AttestationData, BeaconBlock, BlindedPayload, ChainSpec, ContributionAndProof, Domain, Epoch, EthSpec, - ExecutionPayloadEnvelope, Fork, FullPayload, Graffiti, Hash256, PayloadAttestationData, - PayloadAttestationMessage, ProposerPreferences, SelectionProof, SignedAggregateAndProof, - SignedBeaconBlock, SignedContributionAndProof, SignedExecutionPayloadEnvelope, - SignedProposerPreferences, SignedRoot, SignedValidatorRegistrationData, SignedVoluntaryExit, - SingleAttestation, Slot, SyncAggregatorSelectionData, SyncCommitteeContribution, - SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, - VoluntaryExit, graffiti::GraffitiString, + ExecutionPayloadEnvelope, Fork, FullPayload, Graffiti, Hash256, InclusionList, + PayloadAttestationData, PayloadAttestationMessage, ProposerPreferences, SelectionProof, + SignedAggregateAndProof, SignedBeaconBlock, SignedContributionAndProof, + SignedExecutionPayloadEnvelope, SignedInclusionList, SignedProposerPreferences, SignedRoot, + SignedValidatorRegistrationData, SignedVoluntaryExit, SingleAttestation, Slot, + SyncAggregatorSelectionData, SyncCommitteeContribution, SyncCommitteeMessage, + SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, VoluntaryExit, + graffiti::GraffitiString, }; use validator_store::{ AggregateToSign, AttestationToSign, ContributionToSign, DoppelgangerStatus, @@ -1502,4 +1503,12 @@ impl ValidatorStore for LighthouseValidatorS signature, }) } + + async fn sign_inclusion_list( + &self, + validator_pubkey: PublicKeyBytes, + inclusion_list: InclusionList, + ) -> Result { + todo!("sign_inclusion_list not implemented yet"); + } } diff --git a/validator_client/validator_services/src/inclusion_list_service.rs b/validator_client/validator_services/src/inclusion_list_service.rs index 078ef9b4c35..25be9d4b55e 100644 --- a/validator_client/validator_services/src/inclusion_list_service.rs +++ b/validator_client/validator_services/src/inclusion_list_service.rs @@ -8,13 +8,12 @@ use std::sync::Arc; use task_executor::TaskExecutor; use tokio::time::sleep; use tracing::{debug, error, info}; -use types::{ChainSpec, EthSpec, Hash256, Slot}; +use types::{ChainSpec, EthSpec, ForkName, Hash256, InclusionList, SignedInclusionList, Slot}; use validator_store::ValidatorStore; type DependentRoot = Hash256; struct InclusionListData { - slot: Slot, dependent_root: DependentRoot, transactions: InclusionListTransactions, } @@ -94,11 +93,17 @@ where } async fn spawn_inclusion_list_tasks(&self) -> Result<(), String> { + // TODO(heze): consider producing the inclusion list after the slot's envelope is + // revealed instead of right at the start of the slot, keeping the current approach + // as a fallback. Producing at slot start means the list can include transactions + // that the current slot's payload already includes. These would mean redundant constraints + // that put no pressure on the next builder. Building after the envelopes reveal would + // keep only still-pending transactions let Some(slot) = self.wait_to_next_slot().await else { return Ok(()); }; - let Some((duties, attestation_data)) = + let Some((duties, inclusion_list_data)) = self.produce_inclusion_list_duties_data(slot).await? else { return Ok(()); @@ -108,7 +113,7 @@ where self.executor.spawn( async move { if let Err(e) = service - .sign_and_publish(slot, duties, attestation_data) + .sign_and_publish(slot, duties, inclusion_list_data) .await { crit!(error = e, %slot, "Failed to publish inclusion lists"); @@ -161,8 +166,9 @@ where /// Produce the inclusion list data for `slot`, returned alongside the duties to sign. /// - /// Returns `Ok(None)` when there is nothing to publish (no duties, or no inclusion list data for slot) - /// and `Err` when data production failed. + /// Returns `Ok(None)` when there is nothing to produce (the slot's duties have not been + /// downloaded yet, or no local validator has an IL duty at the slot) and `Err` when + /// fetching the inclusion list transactions failed. async fn produce_inclusion_list_duties_data( &self, slot: Slot, @@ -206,13 +212,12 @@ where "Received inclusion list transactions" ); - let il_duties = InclusionListData { + let inclusion_list_data = InclusionListData { dependent_root, - slot, transactions, }; - Ok(Some((duties, il_duties))) + Ok(Some((duties, inclusion_list_data))) } async fn sign_and_publish( @@ -221,6 +226,105 @@ where duties: Vec, inclusion_list_data: InclusionListData, ) -> Result<(), String> { - todo!("Signing and publishing ILs is not yet implemented") + let mut signed_ils = Vec::with_capacity(duties.len()); + + for duty in duties { + let inclusion_list = InclusionList { + slot, + validator_index: duty.validator_index, + dependent_root: inclusion_list_data.dependent_root, + transactions: inclusion_list_data.transactions.transactions.clone(), + }; + + match self + .validator_store + .sign_inclusion_list(duty.pubkey, inclusion_list) + .await + { + Ok(signed_il) => signed_ils.push(signed_il), + Err(e) => { + crit!( + error = ?e, + validator = ?duty.pubkey, + %slot, + "Failed to sign inclusion list" + ); + } + } + } + + if signed_ils.is_empty() { + return Ok(()); + } + + let mut ils_published = 0; + let fork_name = self.chain_spec.fork_name_at_slot::(slot); + for signed_il in &signed_ils { + match self.publish_inclusion_list(signed_il, fork_name).await { + Ok(()) => ils_published += 1, + Err(e) => error!( + %slot, + validator_index = signed_il.message.validator_index, + error = %e, + "Failed to publish inclusion list" + ), + } + } + + if ils_published == 0 { + return Err(format!( + "Failed to publish any of the {} signed inclusion lists", + signed_ils.len() + )); + } + + info!( + %slot, + published = ils_published, + total = signed_ils.len(), + "Published inclusion lists" + ); + + Ok(()) + } + + async fn publish_inclusion_list( + &self, + signed_il: &SignedInclusionList, + fork_name: ForkName, + ) -> Result<(), String> { + let result = self + .beacon_nodes + .first_success(|beacon_node| { + let inclusion_list = signed_il.clone(); + async move { + beacon_node + .post_validator_inclusion_list_ssz(&inclusion_list, fork_name) + .await + .map_err(|e| format!("Failed to publish inclusion list (SSZ): {e:?}")) + } + }) + .await; + + match result { + Ok(()) => Ok(()), + Err(ssz_err) => { + debug!(error = %ssz_err, "SSZ publish failed, falling back to JSON"); + self.beacon_nodes + .first_success(|beacon_node| { + let inclusion_list = signed_il.clone(); + async move { + beacon_node + .post_validator_inclusion_list(&inclusion_list, fork_name) + .await + .map_err(|e| { + format!("Failed to publish inclusion list (JSON): {e:?}") + }) + } + }) + .await + .map_err(|e| e.to_string()) + } + } } } diff --git a/validator_client/validator_store/src/lib.rs b/validator_client/validator_store/src/lib.rs index dde82a2a5bb..eaaa2c8364a 100644 --- a/validator_client/validator_store/src/lib.rs +++ b/validator_client/validator_store/src/lib.rs @@ -7,11 +7,12 @@ use std::future::Future; use std::sync::Arc; use types::{ Address, Attestation, AttestationData, BlindedBeaconBlock, Epoch, EthSpec, - ExecutionPayloadEnvelope, Graffiti, Hash256, PayloadAttestationData, PayloadAttestationMessage, - ProposerPreferences, SelectionProof, SignedAggregateAndProof, SignedBlindedBeaconBlock, - SignedContributionAndProof, SignedExecutionPayloadEnvelope, SignedProposerPreferences, - SignedValidatorRegistrationData, SingleAttestation, Slot, SyncCommitteeContribution, - SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, + ExecutionPayloadEnvelope, Graffiti, Hash256, InclusionList, PayloadAttestationData, + PayloadAttestationMessage, ProposerPreferences, SelectionProof, SignedAggregateAndProof, + SignedBlindedBeaconBlock, SignedContributionAndProof, SignedExecutionPayloadEnvelope, + SignedInclusionList, SignedProposerPreferences, SignedValidatorRegistrationData, + SingleAttestation, Slot, SyncCommitteeContribution, SyncCommitteeMessage, SyncSelectionProof, + SyncSubnetId, ValidatorRegistrationData, }; #[derive(Debug, PartialEq, Clone)] @@ -217,6 +218,12 @@ pub trait ValidatorStore: Send + Sync { /// `ProposalData` fields include defaulting logic described in `get_fee_recipient_defaulting`, /// `get_gas_limit_defaulting`, and `get_builder_proposals_defaulting`. fn proposal_data(&self, pubkey: &PublicKeyBytes) -> Option; + + fn sign_inclusion_list( + &self, + validator_pubkey: PublicKeyBytes, + data: InclusionList, + ) -> impl Future>> + Send; } #[derive(Debug)] From f8e7a9601800db908983184a7bf101cb4cd28e19 Mon Sep 17 00:00:00 2001 From: conache Date: Fri, 14 Aug 2026 15:02:54 +0300 Subject: [PATCH 08/13] Add signature wiring --- .../lighthouse_validator_store/src/lib.rs | 23 ++++++++++++++++++- validator_client/signing_method/src/lib.rs | 3 +++ .../signing_method/src/web3signer.rs | 4 ++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index c27ae935e06..36ff0aa3d88 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -1509,6 +1509,27 @@ impl ValidatorStore for LighthouseValidatorS validator_pubkey: PublicKeyBytes, inclusion_list: InclusionList, ) -> Result { - todo!("sign_inclusion_list not implemented yet"); + let signing_context = self.signing_context( + Domain::InclusionListCommittee, + inclusion_list.slot.epoch(E::slots_per_epoch()), + ); + + // Inclusion list signing is not slashable, bypass doppelganger protection. + let signing_method = self.doppelganger_bypassed_signing_method(validator_pubkey)?; + + let signature = signing_method + .get_signature::>( + SignableMessage::InclusionList(&inclusion_list), + signing_context, + &self.spec, + &self.task_executor, + ) + .await + .map_err(Error::SpecificError)?; + + Ok(SignedInclusionList { + message: inclusion_list, + signature, + }) } } diff --git a/validator_client/signing_method/src/lib.rs b/validator_client/signing_method/src/lib.rs index 0dfde989464..aafbccfe367 100644 --- a/validator_client/signing_method/src/lib.rs +++ b/validator_client/signing_method/src/lib.rs @@ -52,6 +52,7 @@ pub enum SignableMessage<'a, E: EthSpec, Payload: AbstractExecPayload = FullP ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), PayloadAttestationData(&'a PayloadAttestationData), ProposerPreferences(&'a ProposerPreferences), + InclusionList(&'a InclusionList), } impl> SignableMessage<'_, E, Payload> { @@ -76,6 +77,7 @@ impl> SignableMessage<'_, E, Payload SignableMessage::ExecutionPayloadEnvelope(e) => e.signing_root(domain), SignableMessage::PayloadAttestationData(d) => d.signing_root(domain), SignableMessage::ProposerPreferences(p) => p.signing_root(domain), + SignableMessage::InclusionList(l) => l.signing_root(domain), } } } @@ -248,6 +250,7 @@ impl SigningMethod { SignableMessage::ProposerPreferences(p) => { Web3SignerObject::ProposerPreferences(p) } + SignableMessage::InclusionList(l) => Web3SignerObject::InclusionList(l), }; // Determine the Web3Signer message type. diff --git a/validator_client/signing_method/src/web3signer.rs b/validator_client/signing_method/src/web3signer.rs index 8548a933e66..2c0b7660970 100644 --- a/validator_client/signing_method/src/web3signer.rs +++ b/validator_client/signing_method/src/web3signer.rs @@ -23,6 +23,8 @@ pub enum MessageType { ExecutionPayloadEnvelope, PayloadAttestation, ProposerPreferences, + // TODO(heze) verify w/ web3signer specs + InclusionList, } #[derive(Debug, PartialEq, Copy, Clone, Serialize)] @@ -83,6 +85,7 @@ pub enum Web3SignerObject<'a, E: EthSpec, Payload: AbstractExecPayload> { ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), PayloadAttestationData(&'a PayloadAttestationData), ProposerPreferences(&'a ProposerPreferences), + InclusionList(&'a InclusionList), } impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Payload> { @@ -156,6 +159,7 @@ impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Pa Web3SignerObject::ExecutionPayloadEnvelope(_) => MessageType::ExecutionPayloadEnvelope, Web3SignerObject::PayloadAttestationData(_) => MessageType::PayloadAttestation, Web3SignerObject::ProposerPreferences(_) => MessageType::ProposerPreferences, + Web3SignerObject::InclusionList(_) => MessageType::InclusionList, } } } From 84c9ec13a33cb8dc6a457475e23e663de99a266b Mon Sep 17 00:00:00 2001 From: conache Date: Fri, 14 Aug 2026 19:06:57 +0300 Subject: [PATCH 09/13] Scaffold test harness and add tests for slot advancement and production path error cases --- .../src/mock_beacon_node.rs | 110 +++++++- .../src/inclusion_list_service.rs | 238 ++++++++++++++++++ 2 files changed, 346 insertions(+), 2 deletions(-) diff --git a/testing/validator_test_rig/src/mock_beacon_node.rs b/testing/validator_test_rig/src/mock_beacon_node.rs index d01905c0c7e..74973979c40 100644 --- a/testing/validator_test_rig/src/mock_beacon_node.rs +++ b/testing/validator_test_rig/src/mock_beacon_node.rs @@ -1,4 +1,4 @@ -use eth2::types::{GenericResponse, PublishBlockRequest, SyncingData}; +use eth2::types::{GenericResponse, InclusionListTransactions, PublishBlockRequest, SyncingData}; use eth2::{BLOB_DATA_INCLUDED_HEADER, BeaconNodeHttpClient, CONSENSUS_VERSION_HEADER, Timeouts}; use mockito::{Matcher, Mock, Server, ServerGuard}; use regex::Regex; @@ -13,7 +13,7 @@ use tracing::info; use types::{ BeaconBlock, ChainSpec, ConfigAndPreset, EthSpec, ExecutionPayloadEnvelope, ForkName, Hash256, PayloadAttestationData, PayloadAttestationMessage, SignedBlindedBeaconBlock, - SignedExecutionPayloadEnvelope, Slot, + SignedExecutionPayloadEnvelope, SignedInclusionList, Slot, }; pub struct MockBeaconNode { @@ -24,6 +24,7 @@ pub struct MockBeaconNode { pub received_full_blocks: Arc>>>, pub execution_payload_envelope: Arc>>>, pub payload_attestation_message: Arc>>, + pub received_inclusion_lists: Arc>>, } impl MockBeaconNode { @@ -42,6 +43,7 @@ impl MockBeaconNode { received_full_blocks: Arc::new(Mutex::new(Vec::new())), execution_payload_envelope: Arc::new(Mutex::new(Vec::new())), payload_attestation_message: Arc::new(Mutex::new(Vec::new())), + received_inclusion_lists: Arc::new(Mutex::new(Vec::new())), } } @@ -270,6 +272,38 @@ impl MockBeaconNode { .create() } + /// Mocks `GET /eth/v1/validator/inclusion_list` + pub fn mock_get_validator_inclusion_list( + &mut self, + transactions: &InclusionListTransactions, + slot: Slot, + ) -> Mock { + let path_pattern = Regex::new(r"^/eth/v1/validator/inclusion_list$").unwrap(); + + let data = GenericResponse::from(transactions.clone()); + + self.server + .mock("GET", Matcher::Regex(path_pattern.to_string())) + .match_query(Matcher::UrlEncoded("slot".into(), slot.to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&data).unwrap()) + .create() + } + + /// Mocks `GET /eth/v1/validator/inclusion_list` returning error + pub fn mock_get_validator_inclusion_list_error(&mut self, slot: Slot) -> Mock { + let path_pattern = Regex::new(r"^/eth/v1/validator/inclusion_list$").unwrap(); + + self.server + .mock("GET", Matcher::Regex(path_pattern.to_string())) + .match_query(Matcher::UrlEncoded("slot".into(), slot.to_string())) + .with_status(500) + .with_header("content-type", "application/json") + .with_body(r#"{"message":"Internal server error"}"#) + .create() + } + /// Mocks the `post_beacon_blinded_blocks_v2_ssz` response with an optional `delay`. pub fn mock_post_beacon_blinded_blocks_v2_ssz(&mut self, delay: Duration) -> Mock { let path_pattern = Regex::new(r"^/eth/v2/beacon/blinded_blocks$").unwrap(); @@ -459,4 +493,76 @@ impl MockBeaconNode { .with_body(r#"{"message":"Internal server error"}"#) .create() } + + /// Mocks `POST /eth/v1/validator/inclusion_list` (JSON) to receive signed inclusion lists. + pub fn mock_post_validator_inclusion_list_json(&mut self, fork_name: ForkName) -> Mock { + let path_pattern = Regex::new(r"^/eth/v1/validator/inclusion_list$").unwrap(); + + let received_inclusion_lists = Arc::clone(&self.received_inclusion_lists); + + self.server + .mock("POST", Matcher::Regex(path_pattern.to_string())) + .match_header("content-type", "application/json") + .match_header(CONSENSUS_VERSION_HEADER, fork_name.to_string().as_str()) + .with_status(200) + .with_body_from_request(move |request| { + let body = request.body().expect("Failed to get request body"); + // The request body is wrapped in a `data` object, per the beacon-APIs specs + let wrapper: GenericResponse = serde_json::from_slice(body) + .expect("Failed to deserialize SignedInclusionList from JSON"); + received_inclusion_lists.lock().unwrap().push(wrapper.data); + vec![] + }) + .create() + } + + /// Mocks `POST /eth/v1/validator/inclusion_list` (JSON) returning error + pub fn mock_post_validator_inclusion_list_json_error(&mut self, fork_name: ForkName) -> Mock { + let path_pattern = Regex::new(r"^/eth/v1/validator/inclusion_list$").unwrap(); + + self.server + .mock("POST", Matcher::Regex(path_pattern.to_string())) + .match_header("content-type", "application/json") + .match_header(CONSENSUS_VERSION_HEADER, fork_name.to_string().as_str()) + .with_status(500) + .with_body(r#"{"message":"Internal server error"}"#) + .create() + } + + /// Mocks `POST /eth/v1/validator/inclusion_list` (SSZ) to receive signed inclusion lists. + pub fn mock_post_validator_inclusion_list_ssz(&mut self, fork_name: ForkName) -> Mock { + let path_pattern = Regex::new(r"^/eth/v1/validator/inclusion_list$").unwrap(); + + let received_inclusion_lists = Arc::clone(&self.received_inclusion_lists); + + self.server + .mock("POST", Matcher::Regex(path_pattern.to_string())) + .match_header("content-type", "application/octet-stream") + .match_header(CONSENSUS_VERSION_HEADER, fork_name.to_string().as_str()) + .with_status(200) + .with_body_from_request(move |request| { + let body = request.body().expect("Failed to get request body"); + let inclusion_list = SignedInclusionList::from_ssz_bytes(body) + .expect("Failed to deserialize SignedInclusionList from SSZ"); + received_inclusion_lists + .lock() + .unwrap() + .push(inclusion_list); + vec![] + }) + .create() + } + + /// Mocks `POST /eth/v1/validator/inclusion_list` (SSZ) returning error + pub fn mock_post_validator_inclusion_list_ssz_error(&mut self, fork_name: ForkName) -> Mock { + let path_pattern = Regex::new(r"^/eth/v1/validator/inclusion_list$").unwrap(); + + self.server + .mock("POST", Matcher::Regex(path_pattern.to_string())) + .match_header("content-type", "application/octet-stream") + .match_header(CONSENSUS_VERSION_HEADER, fork_name.to_string().as_str()) + .with_status(500) + .with_body(r#"{"message":"Internal server error"}"#) + .create() + } } diff --git a/validator_client/validator_services/src/inclusion_list_service.rs b/validator_client/validator_services/src/inclusion_list_service.rs index 25be9d4b55e..f32014867ee 100644 --- a/validator_client/validator_services/src/inclusion_list_service.rs +++ b/validator_client/validator_services/src/inclusion_list_service.rs @@ -328,3 +328,241 @@ where } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::duties_service::DutiesServiceBuilder; + use futures::FutureExt; + use slot_clock::ManualSlotClock; + use std::time::Duration; + use types::{Epoch, MainnetEthSpec}; + use validator_test_rig::validator_client_harness::{S, ValidatorClientHarness}; + + type E = MainnetEthSpec; + + struct TestHarness { + harness: ValidatorClientHarness, + service: InclusionListService, + } + + impl TestHarness { + async fn new_with_validators(num_validators: usize) -> Self { + Self::new_with_heze_at(num_validators, Epoch::new(0)).await + } + + async fn new_with_heze_at(num_validators: usize, heze_fork_epoch: Epoch) -> Self { + let harness = ValidatorClientHarness::new(num_validators).await; + + let mut spec = (*harness.spec).clone(); + spec.heze_fork_epoch = Some(heze_fork_epoch); + let spec = Arc::new(spec); + + let duties_service = Arc::new( + DutiesServiceBuilder::new() + .validator_store(harness.validator_store.clone()) + .slot_clock(harness.slot_clock.clone()) + .beacon_nodes(harness.beacon_nodes.clone()) + .executor(harness.test_runtime.task_executor.clone()) + .spec(spec.clone()) + .build() + .unwrap(), + ); + + let service = InclusionListService::new( + duties_service, + harness.validator_store.clone(), + harness.slot_clock.clone(), + harness.beacon_nodes.clone(), + harness.test_runtime.task_executor.clone(), + spec, + ); + + Self { harness, service } + } + + fn insert_il_duties(&self, slot: Slot, dependent_root: Hash256) { + let duties = self + .harness + .pubkeys + .iter() + .enumerate() + .map(|(i, pubkey)| InclusionListDuty { + pubkey: *pubkey, + validator_index: i as u64, + slot, + inclusion_list_committee_root: Hash256::ZERO, + }) + .collect(); + self.service + .duties_service + .il_duties + .write() + .insert(slot.epoch(E::slots_per_epoch()), (dependent_root, duties)); + } + } + + async fn advance_time(slot_clock: &ManualSlotClock, duration: Duration) { + slot_clock.advance_time(duration); + tokio::time::advance(duration).await; + } + + /// Pre-Heze the wait sleeps to the next epoch and returns `None` + /// No duties are read and no BN request is made + #[tokio::test] + async fn waits_until_next_epoch_before_heze_fork() { + tokio::time::pause(); + + let harness = TestHarness::new_with_heze_at(1, Epoch::new(1)).await; + let service = &harness.service; + + // Add duties for a pre-Heze slot + // If the il task execution leaks past the wait_for_next_slot check, + // it would fetch the transactions from the mock BNs and fail the final assertion + // in the test + harness.insert_il_duties(Slot::new(1), Hash256::repeat_byte(0xab)); + let service_wait = service.spawn_inclusion_list_tasks(); + tokio::pin!(service_wait); + + // This first call of .now_or_never() starts the timer and registers the sleep timer with tokio + // It calls sleep(duration_to_next_epoch).await which registers a timer with a deadline of 12s * 32 + assert!(service_wait.as_mut().now_or_never().is_none()); + + // Advance both slot_clock and tokio::time slot by slot up to 384s (the sleep deadline) + // This verifies that wait_to_next_slot waits a whole epoch (not just a slot) before completing + for _ in 0..E::slots_per_epoch() { + let duration_to_next_slot = harness.service.slot_clock.duration_to_next_slot().unwrap(); + advance_time(&harness.service.slot_clock, duration_to_next_slot).await; + assert!( + service_wait.as_mut().now_or_never().is_none(), + "Function should return None before the sleep duration has elapsed" + ); + } + + // Advance time for 1 more second, past the epoch boundary. + // The epoch sleep should have completed and the execution should complete as a no-op. + // This call should yield no slot, so nothing should be produced, signed or published. + advance_time(&harness.service.slot_clock, Duration::from_secs(1)).await; + assert_eq!(service_wait.as_mut().now_or_never(), Some(Ok(()))); + } + + #[tokio::test] + async fn waits_until_next_slot() { + tokio::time::pause(); + + let harness = TestHarness::new_with_validators(1).await; + let service = &harness.service; + let service_wait = service.wait_to_next_slot(); + tokio::pin!(service_wait); + + // Start the timer and registers the sleep timer with tokio + assert!(service_wait.as_mut().now_or_never().is_none()); + + let duration_to_wait = harness.service.slot_clock.duration_to_next_slot().unwrap(); + // Advance both slot_clock and tokio::time to 12s + advance_time(&harness.service.slot_clock, duration_to_wait).await; + assert!( + service_wait.as_mut().now_or_never().is_none(), + "Function should return None before the sleep duration has elapsed" + ); + + advance_time(&harness.service.slot_clock, Duration::from_secs(1)).await; + assert_eq!( + service_wait.as_mut().now_or_never().unwrap(), + Some(Slot::new(1)) + ); + } + + #[tokio::test] + async fn no_duties_no_fetch_no_publish() { + let mut harness = TestHarness::new_with_validators(3).await; + let current_slot = harness.service.slot_clock.now().unwrap(); + + let transactions = InclusionListTransactions { + transactions: Default::default(), + }; + let fetch_mock = harness + .harness + .mock_beacon_node_1 + .mock_get_validator_inclusion_list(&transactions, current_slot); + let publish_mock = harness + .harness + .mock_beacon_node_1 + .mock_post_validator_inclusion_list_ssz(ForkName::Heze); + + // Duties for the slot's epoch not downloaded yet + let result = harness + .service + .produce_inclusion_list_duties_data(current_slot) + .await; + assert!(result.unwrap().is_none()); + + // Add duty for next slot, not for the current one + harness.insert_il_duties(current_slot + 1, Hash256::repeat_byte(0xab)); + let result = harness + .service + .produce_inclusion_list_duties_data(current_slot) + .await; + assert!(result.unwrap().is_none()); + + fetch_mock.expect(0).assert(); + publish_mock.expect(0).assert(); + } + + /// Produce endpoint fails on all BNs: produce returns `Err`, nothing is signed or + /// published, and the main service loop handles the error + #[tokio::test] + async fn produce_fetch_error_aborts_slot() { + let mut harness = TestHarness::new_with_validators(3).await; + let slot = Slot::new(1); + harness.insert_il_duties(slot, Hash256::repeat_byte(0xab)); + + harness + .harness + .mock_beacon_node_1 + .mock_get_validator_inclusion_list_error(slot); + harness + .harness + .mock_beacon_node_2 + .mock_get_validator_inclusion_list_error(slot); + + let result = harness + .service + .produce_inclusion_list_duties_data(slot) + .await; + assert!(result.is_err()); + } + + /// First BN errors on the produce endpoint, second serves: `first_success` walks + /// past and transactions come from the second BN. + #[tokio::test] + async fn produce_falls_back_to_second_bn() { + let mut harness = TestHarness::new_with_validators(3).await; + let slot = Slot::new(1); + let dependent_root = Hash256::repeat_byte(0xab); + harness.insert_il_duties(slot, dependent_root); + + let transactions = InclusionListTransactions { + transactions: vec![vec![0xaa; 3].into()].into(), + }; + harness + .harness + .mock_beacon_node_1 + .mock_get_validator_inclusion_list_error(slot); + let bn2_mock = harness + .harness + .mock_beacon_node_2 + .mock_get_validator_inclusion_list(&transactions, slot); + + let (duties, data) = harness + .service + .produce_inclusion_list_duties_data(slot) + .await + .unwrap() + .unwrap(); + assert_eq!(duties.len(), 3); + assert_eq!(data.dependent_root, dependent_root); + assert_eq!(data.transactions, transactions); + bn2_mock.expect(1).assert(); + } +} From 26b931949801cb2b76617788d3b7c8cdf9d58f26 Mon Sep 17 00:00:00 2001 From: conache Date: Fri, 14 Aug 2026 19:44:45 +0300 Subject: [PATCH 10/13] Add publish tests --- .../src/inclusion_list_service.rs | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/validator_client/validator_services/src/inclusion_list_service.rs b/validator_client/validator_services/src/inclusion_list_service.rs index f32014867ee..cb2f047abc5 100644 --- a/validator_client/validator_services/src/inclusion_list_service.rs +++ b/validator_client/validator_services/src/inclusion_list_service.rs @@ -565,4 +565,204 @@ mod tests { assert_eq!(data.transactions, transactions); bn2_mock.expect(1).assert(); } + + #[tokio::test] + async fn publishes_each_inclusion_list_via_ssz() { + let mut harness = TestHarness::new_with_validators(3).await; + let slot = Slot::new(1); + let dependent_root = Hash256::repeat_byte(0xab); + harness.insert_il_duties(slot, dependent_root); + + let transactions = InclusionListTransactions { + transactions: vec![vec![0xaa; 3].into()].into(), + }; + harness + .harness + .mock_beacon_node_1 + .mock_get_validator_inclusion_list(&transactions, slot); + let ssz_mock = harness + .harness + .mock_beacon_node_1 + .mock_post_validator_inclusion_list_ssz(ForkName::Heze); + let json_mock = harness + .harness + .mock_beacon_node_1 + .mock_post_validator_inclusion_list_json(ForkName::Heze); + + let (duties, data) = harness + .service + .produce_inclusion_list_duties_data(slot) + .await + .unwrap() + .unwrap(); + harness + .service + .sign_and_publish(slot, duties, data) + .await + .unwrap(); + + // One POST per duty,and no JSON fallback + ssz_mock.expect(3).assert(); + json_mock.expect(0).assert(); + + let messages = harness + .harness + .mock_beacon_node_1 + .received_inclusion_lists + .lock() + .unwrap(); + assert_eq!(messages.len(), 3); + + // Check each message + for (i, signed_il) in messages.iter().enumerate() { + assert_eq!(signed_il.message.validator_index, i as u64); + assert_eq!(signed_il.message.slot, slot); + assert_eq!(signed_il.message.dependent_root, dependent_root); + assert_eq!(signed_il.message.transactions, transactions.transactions); + } + } + + #[tokio::test] + async fn inclusion_list_ssz_publish_falls_back_to_json() { + let mut harness = TestHarness::new_with_validators(1).await; + let slot = Slot::new(1); + harness.insert_il_duties(slot, Hash256::repeat_byte(0xab)); + + let transactions = InclusionListTransactions { + transactions: Default::default(), + }; + harness + .harness + .mock_beacon_node_1 + .mock_get_validator_inclusion_list(&transactions, slot); + let ssz_mock = harness + .harness + .mock_beacon_node_1 + .mock_post_validator_inclusion_list_ssz_error(ForkName::Heze); + let json_mock = harness + .harness + .mock_beacon_node_1 + .mock_post_validator_inclusion_list_json(ForkName::Heze); + + let (duties, data) = harness + .service + .produce_inclusion_list_duties_data(slot) + .await + .unwrap() + .unwrap(); + harness + .service + .sign_and_publish(slot, duties, data) + .await + .unwrap(); + + // `first_success` makes two passes over the BNs, so the failing SSZ mock is hit twice + ssz_mock.expect(2).assert(); + json_mock.expect(1).assert(); + + let messages = harness + .harness + .mock_beacon_node_1 + .received_inclusion_lists + .lock() + .unwrap(); + assert_eq!(messages.len(), 1); + } + + /// One duty's publish fails on both encodings + /// so the succeeded calls are total - 1 + #[tokio::test] + async fn partial_publish_failure_still_publishes_siblings() { + let mut harness = TestHarness::new_with_validators(2).await; + let slot = Slot::new(1); + harness.insert_il_duties(slot, Hash256::repeat_byte(0xab)); + + let transactions = InclusionListTransactions { + transactions: Default::default(), + }; + harness + .harness + .mock_beacon_node_1 + .mock_get_validator_inclusion_list(&transactions, slot); + + // Each error mock answers exactly one request: the first duty's publish fails on + // both `first_success` passes of both encodings + let ssz_err_1 = harness + .harness + .mock_beacon_node_1 + .mock_post_validator_inclusion_list_ssz_error(ForkName::Heze); + let json_err_1 = harness + .harness + .mock_beacon_node_1 + .mock_post_validator_inclusion_list_json_error(ForkName::Heze); + let ssz_err_2 = harness + .harness + .mock_beacon_node_1 + .mock_post_validator_inclusion_list_ssz_error(ForkName::Heze); + let json_err_2 = harness + .harness + .mock_beacon_node_1 + .mock_post_validator_inclusion_list_json_error(ForkName::Heze); + // With the error mocks above spent, the sibling duty publishes here + let ssz_mock = harness + .harness + .mock_beacon_node_1 + .mock_post_validator_inclusion_list_ssz(ForkName::Heze); + + let (duties, data) = harness + .service + .produce_inclusion_list_duties_data(slot) + .await + .unwrap() + .unwrap(); + harness + .service + .sign_and_publish(slot, duties, data) + .await + .unwrap(); + + ssz_err_1.expect(1).assert(); + ssz_err_2.expect(1).assert(); + json_err_1.expect(1).assert(); + json_err_2.expect(1).assert(); + // successful call + ssz_mock.expect(1).assert(); + + let messages = harness + .harness + .mock_beacon_node_1 + .received_inclusion_lists + .lock() + .unwrap(); + let indices: Vec = messages + .iter() + .map(|il| il.message.validator_index) + .collect(); + assert_eq!(indices, vec![1]); + } + + #[tokio::test] + async fn total_publish_failure_returns_error() { + let mut harness = TestHarness::new_with_validators(2).await; + let slot = Slot::new(1); + harness.insert_il_duties(slot, Hash256::repeat_byte(0xab)); + + let transactions = InclusionListTransactions { + transactions: Default::default(), + }; + // No POST routes are registered: every publish fails on both encodings + harness + .harness + .mock_beacon_node_1 + .mock_get_validator_inclusion_list(&transactions, slot); + + let (duties, data) = harness + .service + .produce_inclusion_list_duties_data(slot) + .await + .unwrap() + .unwrap(); + let result = harness.service.sign_and_publish(slot, duties, data).await; + assert!(result.is_err()); + } } From 1995a483db347688af3ba62c94bb6601693e6600 Mon Sep 17 00:00:00 2001 From: conache Date: Sun, 16 Aug 2026 12:53:00 +0300 Subject: [PATCH 11/13] Add signature check tests --- .../src/inclusion_list_service.rs | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/validator_client/validator_services/src/inclusion_list_service.rs b/validator_client/validator_services/src/inclusion_list_service.rs index cb2f047abc5..3c9de0b4a90 100644 --- a/validator_client/validator_services/src/inclusion_list_service.rs +++ b/validator_client/validator_services/src/inclusion_list_service.rs @@ -336,7 +336,8 @@ mod tests { use futures::FutureExt; use slot_clock::ManualSlotClock; use std::time::Duration; - use types::{Epoch, MainnetEthSpec}; + use types::test_utils::generate_deterministic_keypair; + use types::{Domain, Epoch, MainnetEthSpec, SignedRoot}; use validator_test_rig::validator_client_harness::{S, ValidatorClientHarness}; type E = MainnetEthSpec; @@ -620,6 +621,9 @@ mod tests { assert_eq!(signed_il.message.dependent_root, dependent_root); assert_eq!(signed_il.message.transactions, transactions.transactions); } + // Each message carries its own validator's signature + assert_ne!(messages[0].signature, messages[1].signature); + assert_ne!(messages[1].signature, messages[2].signature); } #[tokio::test] @@ -765,4 +769,78 @@ mod tests { let result = harness.service.sign_and_publish(slot, duties, data).await; assert!(result.is_err()); } + + #[tokio::test] + async fn signed_inclusion_list_verifies_against_consensus_signature_check() { + let harness = TestHarness::new_with_validators(1).await; + let slot = Slot::new(1); + let pubkey = harness.harness.pubkeys[0]; + + let inclusion_list = InclusionList { + slot, + validator_index: 0, + dependent_root: Hash256::repeat_byte(0xab), + transactions: Default::default(), + }; + let signed = harness + .harness + .validator_store + .sign_inclusion_list(pubkey, inclusion_list) + .await + .unwrap(); + + // Independent consensus-side derivation + let spec = &harness.harness.spec; + let epoch = slot.epoch(E::slots_per_epoch()); + let fork = spec.fork_at_epoch(epoch); + let domain = spec.get_domain(epoch, Domain::InclusionListCommittee, &fork, Hash256::ZERO); + let signing_root = signed.message.signing_root(domain); + assert!( + signed + .signature + .verify(&pubkey.decompress().unwrap(), signing_root) + ); + } + + #[tokio::test] + async fn sign_failure_for_unknown_validator_skips_publish() { + let mut harness = TestHarness::new_with_validators(1).await; + let slot = Slot::new(1); + + // A duty for a validator the store does not hold + let duty = InclusionListDuty { + pubkey: generate_deterministic_keypair(99).pk.into(), + validator_index: 99, + slot, + inclusion_list_committee_root: Hash256::ZERO, + }; + harness.service.duties_service.il_duties.write().insert( + slot.epoch(E::slots_per_epoch()), + (Hash256::repeat_byte(0xab), vec![duty]), + ); + + let transactions = InclusionListTransactions { + transactions: Default::default(), + }; + harness + .harness + .mock_beacon_node_1 + .mock_get_validator_inclusion_list(&transactions, slot); + let ssz_mock = harness + .harness + .mock_beacon_node_1 + .mock_post_validator_inclusion_list_ssz(ForkName::Heze); + + let (duties, data) = harness + .service + .produce_inclusion_list_duties_data(slot) + .await + .unwrap() + .unwrap(); + let result = harness.service.sign_and_publish(slot, duties, data).await; + + // Nothing signed means nothing to publish, not a total publish failure + assert_eq!(result, Ok(())); + ssz_mock.expect(0).assert(); + } } From 66d0a9ef33be4adc4f066d2d75c36f89697af48c Mon Sep 17 00:00:00 2001 From: conache Date: Sun, 16 Aug 2026 13:56:04 +0300 Subject: [PATCH 12/13] Cleanup --- common/eth2/src/lib.rs | 6 +++++- validator_client/validator_store/src/lib.rs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 99b52d014e2..a35eeae499a 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_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; @@ -104,6 +105,7 @@ pub struct Timeouts { pub sync_duties: Duration, pub sync_aggregators: Duration, pub ptc_duties: Duration, + pub inclusion_list: Duration, pub inclusion_list_duties: Duration, pub get_beacon_blocks_ssz: Duration, pub get_debug_beacon_states: Duration, @@ -127,6 +129,7 @@ impl Timeouts { sync_duties: timeout, sync_aggregators: timeout, ptc_duties: timeout, + inclusion_list: timeout, inclusion_list_duties: timeout, get_beacon_blocks_ssz: timeout, get_debug_beacon_states: timeout, @@ -152,6 +155,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: base_timeout / HTTP_INCLUSION_LIST_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, @@ -3569,7 +3573,7 @@ impl BeaconNodeHttpClient { path.query_pairs_mut() .append_pair("slot", &slot.to_string()); - self.get_with_timeout(path, self.timeouts.inclusion_list_duties) + self.get_with_timeout(path, self.timeouts.inclusion_list) .await } diff --git a/validator_client/validator_store/src/lib.rs b/validator_client/validator_store/src/lib.rs index eaaa2c8364a..e5462b2aabe 100644 --- a/validator_client/validator_store/src/lib.rs +++ b/validator_client/validator_store/src/lib.rs @@ -222,7 +222,7 @@ pub trait ValidatorStore: Send + Sync { fn sign_inclusion_list( &self, validator_pubkey: PublicKeyBytes, - data: InclusionList, + inclusion_list: InclusionList, ) -> impl Future>> + Send; } From d651d8ebe7a48810223da0da91d1235e7b2b33d0 Mon Sep 17 00:00:00 2001 From: conache Date: Fri, 21 Aug 2026 19:31:55 +0300 Subject: [PATCH 13/13] Drop inclusion_list_committee_root --- .../validator_services/src/inclusion_list_service.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/validator_client/validator_services/src/inclusion_list_service.rs b/validator_client/validator_services/src/inclusion_list_service.rs index 3c9de0b4a90..50dc979de2f 100644 --- a/validator_client/validator_services/src/inclusion_list_service.rs +++ b/validator_client/validator_services/src/inclusion_list_service.rs @@ -392,7 +392,6 @@ mod tests { pubkey: *pubkey, validator_index: i as u64, slot, - inclusion_list_committee_root: Hash256::ZERO, }) .collect(); self.service @@ -812,7 +811,6 @@ mod tests { pubkey: generate_deterministic_keypair(99).pk.into(), validator_index: 99, slot, - inclusion_list_committee_root: Hash256::ZERO, }; harness.service.duties_service.il_duties.write().insert( slot.epoch(E::slots_per_epoch()),