diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index e823ffd2e8b..3d021e98a77 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -82,6 +82,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; @@ -105,6 +106,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, @@ -128,6 +130,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, @@ -153,6 +156,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, @@ -3851,6 +3855,66 @@ 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) + .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"); + + // 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_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("inclusion_list"); + + 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, diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index eedcfc8ff30..75ab0bb91f8 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -904,6 +904,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/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index 6aed6a9d502..72a0f690ed4 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -134,6 +134,7 @@ pub struct ChainSpec { sync_message_due_gloas: Duration, contribution_and_proof_due: Duration, contribution_and_proof_due_gloas: Duration, + inclusion_list_due: Duration, /* * Reward and penalty quotients @@ -985,6 +986,12 @@ impl ChainSpec { } } + /// 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, @@ -1101,6 +1108,9 @@ impl ChainSpec { self.contribution_and_proof_due_gloas = self .compute_slot_component_duration(self.contribution_due_bps_gloas) .expect("invalid chain spec: cannot compute contribution_and_proof_due_gloas"); + 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, @@ -1246,6 +1256,7 @@ impl ChainSpec { sync_message_due_gloas: Duration::from_millis(3000), contribution_and_proof_due: Duration::from_millis(8000), contribution_and_proof_due_gloas: Duration::from_millis(6000), + inclusion_list_due: Duration::from_millis(8000), /* * Reward and penalty quotients @@ -1591,6 +1602,7 @@ impl ChainSpec { sync_message_due: Duration::from_millis(1999), sync_message_due_gloas: Duration::from_millis(1500), contribution_and_proof_due: Duration::from_millis(4000), + inclusion_list_due: Duration::from_millis(4000), contribution_and_proof_due_gloas: Duration::from_millis(3000), // Networking Fulu @@ -1697,6 +1709,7 @@ impl ChainSpec { sync_message_due: Duration::from_millis(1666), sync_message_due_gloas: Duration::from_millis(1250), contribution_and_proof_due: Duration::from_millis(3333), + inclusion_list_due: Duration::from_millis(3333), contribution_and_proof_due_gloas: Duration::from_millis(2500), /* 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/testing/validator_test_rig/src/mock_validator_store.rs b/testing/validator_test_rig/src/mock_validator_store.rs index e4ce9772647..67357489b26 100644 --- a/testing/validator_test_rig/src/mock_validator_store.rs +++ b/testing/validator_test_rig/src/mock_validator_store.rs @@ -4,11 +4,12 @@ use futures::{Stream, stream}; use std::future::Future; use std::sync::Arc; use types::{ - Address, Epoch, ExecutionPayloadEnvelope, Graffiti, MainnetEthSpec, PayloadAttestationData, - PayloadAttestationMessage, ProposerPreferences, SelectionProof, SignedAggregateAndProof, - SignedContributionAndProof, SignedExecutionPayloadEnvelope, SignedProposerPreferences, - SignedValidatorRegistrationData, SingleAttestation, Slot, SyncCommitteeMessage, - SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, + Address, Epoch, ExecutionPayloadEnvelope, Graffiti, InclusionList, MainnetEthSpec, + PayloadAttestationData, PayloadAttestationMessage, ProposerPreferences, SelectionProof, + SignedAggregateAndProof, SignedContributionAndProof, SignedExecutionPayloadEnvelope, + SignedInclusionList, SignedProposerPreferences, SignedValidatorRegistrationData, + SingleAttestation, Slot, SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, + ValidatorRegistrationData, }; use validator_store::{ AggregateToSign, AttestationToSign, ContributionToSign, DoppelgangerStatus, @@ -187,4 +188,12 @@ impl ValidatorStore for MockValidatorStore { fn proposal_data(&self, _pubkey: &PublicKeyBytes) -> Option { panic!("MockValidatorStore::proposal_data called without a hook") } + + async fn sign_inclusion_list( + &self, + _validator_pubkey: PublicKeyBytes, + _inclusion_list: InclusionList, + ) -> Result> { + panic!("MockValidatorStore::sign_inclusion_list called without a hook") + } } diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index ce2b85f3af5..36ff0aa3d88 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,33 @@ impl ValidatorStore for LighthouseValidatorS signature, }) } + + async fn sign_inclusion_list( + &self, + validator_pubkey: PublicKeyBytes, + inclusion_list: InclusionList, + ) -> Result { + 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, } } } 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..50dc979de2f --- /dev/null +++ b/validator_client/validator_services/src/inclusion_list_service.rs @@ -0,0 +1,844 @@ +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 tokio::time::sleep; +use tracing::{debug, error, info}; +use types::{ChainSpec, EthSpec, ForkName, Hash256, InclusionList, SignedInclusionList, Slot}; +use validator_store::ValidatorStore; + +type DependentRoot = Hash256; + +struct InclusionListData { + dependent_root: DependentRoot, + transactions: InclusionListTransactions, +} + +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(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, inclusion_list_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, inclusion_list_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 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, + ) -> 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 inclusion_list_data = InclusionListData { + dependent_root, + transactions, + }; + + Ok(Some((duties, inclusion_list_data))) + } + + async fn sign_and_publish( + &self, + slot: Slot, + duties: Vec, + inclusion_list_data: InclusionListData, + ) -> Result<(), String> { + 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()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::duties_service::DutiesServiceBuilder; + use futures::FutureExt; + use slot_clock::ManualSlotClock; + use std::time::Duration; + 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; + + 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, + }) + .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(); + } + + #[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); + } + // 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] + 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()); + } + + #[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, + }; + 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(); + } +} 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; diff --git a/validator_client/validator_store/src/lib.rs b/validator_client/validator_store/src/lib.rs index dde82a2a5bb..e5462b2aabe 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, + inclusion_list: InclusionList, + ) -> impl Future>> + Send; } #[derive(Debug)]