diff --git a/beacon_node/beacon_chain/src/inclusion_list_verification/gossip_verified_inclusion_list.rs b/beacon_node/beacon_chain/src/inclusion_list_verification/gossip_verified_inclusion_list.rs new file mode 100644 index 00000000000..23f813cd479 --- /dev/null +++ b/beacon_node/beacon_chain/src/inclusion_list_verification/gossip_verified_inclusion_list.rs @@ -0,0 +1,69 @@ +use crate::inclusion_list_verification::InclusionListVerificationError; +use crate::{BeaconChain, BeaconChainTypes}; +use tracing::debug; +use types::{ChainSpec, SignedInclusionList}; + +pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { + // TODO(heze): complete while implementing the gossip verification of inclusion lists + pub slot_clock: &'a T::SlotClock, + pub spec: &'a ChainSpec, +} + +pub struct GossipVerifiedInclusionList { + pub signed_inclusion_list: SignedInclusionList, + pub is_timely: bool, +} + +impl GossipVerifiedInclusionList { + pub fn new( + signed_inclusion_list: SignedInclusionList, + _ctx: &GossipVerificationContext<'_, T>, + ) -> Result { + // TODO(heze): implement gossip verification for inclusion lists + Ok(Self { + signed_inclusion_list, + is_timely: true, + }) + } +} + +impl BeaconChain { + pub fn inclusion_list_gossip_verification_context(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + slot_clock: &self.slot_clock, + spec: &self.spec, + } + } + + pub fn verify_inclusion_list_for_gossip( + &self, + signed_inclusion_list: SignedInclusionList, + ) -> Result { + let slot = signed_inclusion_list.message.slot; + let validator_index = signed_inclusion_list.message.validator_index; + + let ctx = self.inclusion_list_gossip_verification_context(); + match GossipVerifiedInclusionList::new(signed_inclusion_list, &ctx) { + Ok(verified) => { + debug!( + %slot, + %validator_index, + "Successfully verified gossip inclusion list" + ); + + // TODO(heze): emit the inclusion_list SSE event + + Ok(verified) + } + Err(e) => { + debug!( + error = ?e, + %slot, + %validator_index, + "Rejected gossip inclusion list" + ); + Err(e) + } + } + } +} diff --git a/beacon_node/beacon_chain/src/inclusion_list_verification/mod.rs b/beacon_node/beacon_chain/src/inclusion_list_verification/mod.rs new file mode 100644 index 00000000000..3cb1a3dcd95 --- /dev/null +++ b/beacon_node/beacon_chain/src/inclusion_list_verification/mod.rs @@ -0,0 +1,35 @@ +use crate::BeaconChainError; +use std::sync::Arc; +use types::{BeaconStateError, Slot}; + +pub mod gossip_verified_inclusion_list; + +#[derive(Debug)] +pub enum InclusionListVerificationError { + /// Two valid inclusion lists were already seen from this validator for this slot. + AlreadySeenTwice { validator_index: u64, slot: Slot }, + /// The slot clock cannot read. + UnableToReadSlot, + /// Beacon Chain error + BeaconChainError(Arc), + /// Beacon State error + BeaconStateError(BeaconStateError), +} + +impl std::fmt::Display for InclusionListVerificationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for InclusionListVerificationError { + fn from(e: BeaconChainError) -> Self { + InclusionListVerificationError::BeaconChainError(Arc::new(e)) + } +} + +impl From for InclusionListVerificationError { + fn from(e: BeaconStateError) -> Self { + InclusionListVerificationError::BeaconStateError(e) + } +} diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index b4e71f07573..9ee1f424281 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -30,6 +30,7 @@ pub mod fork_choice_signal; pub mod graffiti_calculator; pub mod historical_blocks; pub mod historical_data_columns; +pub mod inclusion_list_verification; pub mod invariants; pub mod kzg_utils; pub mod light_client_finality_update_verification; diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 630e9d92118..599d44ae3c9 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -1906,6 +1906,27 @@ pub async fn serve( }, ); + /* + * inclusion lists + */ + + // POST validator/inclusion_list (JSON) + let post_validator_inclusion_list = post_validator_inclusion_list( + eth_v1.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + chain_filter.clone(), + network_tx_filter.clone(), + ); + + // POST validator/inclusion_list (SSZ) + let post_validator_inclusion_list_ssz = post_validator_inclusion_list_ssz( + eth_v1.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + chain_filter.clone(), + network_tx_filter.clone(), + ); /* * config */ @@ -3464,7 +3485,8 @@ pub async fn serve( .uor(post_beacon_execution_payload_envelopes_ssz) .uor(post_beacon_execution_payload_bids_ssz) .uor(post_beacon_pool_payload_attestations_ssz) - .uor(post_validator_proposer_preferences_ssz), + .uor(post_validator_proposer_preferences_ssz) + .uor(post_validator_inclusion_list_ssz), ) .uor(post_beacon_blocks) .uor(post_beacon_blinded_blocks) @@ -3478,6 +3500,7 @@ pub async fn serve( .uor(post_beacon_pool_payload_attestations) .uor(post_beacon_pool_bls_to_execution_changes) .uor(post_validator_proposer_preferences) + .uor(post_validator_inclusion_list) .uor(post_beacon_execution_payload_envelopes) .uor(post_beacon_execution_payload_bids) .uor(post_beacon_state_validators) diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 7b904260b8e..9c4ceacce05 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -9,6 +9,7 @@ use crate::utils::{ use crate::version::{V1, V2, V3, V4, add_ssz_content_type_header, unsupported_version_rejection}; use crate::{StateId, attester_duties, proposer_duties, ptc_duties, sync_committees}; use beacon_chain::attestation_verification::VerifiedAttestation; +use beacon_chain::inclusion_list_verification::InclusionListVerificationError; use beacon_chain::proposer_preferences_verification::ProposerPreferencesError; use beacon_chain::{AttestationError, BeaconChain, BeaconChainError, BeaconChainTypes}; use bls::PublicKeyBytes; @@ -31,8 +32,8 @@ use tokio::sync::oneshot; use tracing::{debug, error, info, warn}; use types::{ BeaconState, Epoch, EthSpec, ForkName, ProposerPreparationData, SignedAggregateAndProof, - SignedContributionAndProof, SignedProposerPreferences, SignedValidatorRegistrationData, Slot, - SyncContributionData, ValidatorSubscription, + SignedContributionAndProof, SignedInclusionList, SignedProposerPreferences, + SignedValidatorRegistrationData, Slot, SyncContributionData, ValidatorSubscription, }; use warp::{Filter, Rejection, Reply, http::response::Builder}; use warp_utils::reject::convert_rejection; @@ -71,7 +72,7 @@ pub fn get_validator_sync_committee_contribution( .and(warp::path("sync_committee_contribution")) .and(warp::path::end()) .and(warp::query::()) - .and(not_while_syncing_filter.clone()) + .and(not_while_syncing_filter) .and(task_spawner_filter.clone()) .and(chain_filter.clone()) .then( @@ -118,7 +119,7 @@ pub fn post_validator_duties_sync( )) })) .and(warp::path::end()) - .and(not_while_syncing_filter.clone()) + .and(not_while_syncing_filter) .and(warp_utils::json::json()) .and(task_spawner_filter.clone()) .and(chain_filter.clone()) @@ -1297,3 +1298,147 @@ fn publish_proposer_preferences( )) } } + +/// POST validator/inclusion_list (JSON) +pub fn post_validator_inclusion_list( + eth_v1: EthV1Filter, + not_while_syncing_filter: NotWhileSyncingFilter, + task_spawner_filter: TaskSpawnerFilter, + chain_filter: ChainFilter, + network_tx_filter: NetworkTxFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("validator")) + .and(warp::path("inclusion_list")) + .and(warp::path::end()) + .and(warp_utils::json::json()) + .and(warp::header::(CONSENSUS_VERSION_HEADER)) + .and(not_while_syncing_filter.clone()) + .and(task_spawner_filter) + .and(chain_filter) + .and(network_tx_filter) + .then( + |request_body: GenericResponse, + fork_name: ForkName, + not_synced_filter: Result<(), Rejection>, + task_spawner: TaskSpawner, + chain: Arc>, + network_tx: UnboundedSender>| { + task_spawner.blocking_response_task(Priority::P0, move || { + not_synced_filter?; + ensure_heze_consensus_version(fork_name)?; + publish_inclusion_list(&chain, &network_tx, request_body.data)?; + Ok(warp::reply()) + }) + }, + ) + .boxed() +} + +/// POST validator/inclusion_list (SSZ) +pub fn post_validator_inclusion_list_ssz( + eth_v1: EthV1Filter, + not_while_syncing_filter: NotWhileSyncingFilter, + task_spawner_filter: TaskSpawnerFilter, + chain_filter: ChainFilter, + network_tx_filter: NetworkTxFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("validator")) + .and(warp::path("inclusion_list")) + .and(warp::path::end()) + .and(warp::body::bytes()) + .and(warp::header::(CONSENSUS_VERSION_HEADER)) + .and(not_while_syncing_filter) + .and(task_spawner_filter) + .and(chain_filter) + .and(network_tx_filter) + .then( + |body_bytes: Bytes, + fork_name: ForkName, + not_synced_filter: Result<(), Rejection>, + task_spawner: TaskSpawner, + chain: Arc>, + network_tx: UnboundedSender>| { + task_spawner.blocking_response_task(Priority::P0, move || { + not_synced_filter?; + ensure_heze_consensus_version(fork_name)?; + let signed_inclusion_list = SignedInclusionList::from_ssz_bytes(&body_bytes) + .map_err(|e| { + warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")) + })?; + publish_inclusion_list(&chain, &network_tx, signed_inclusion_list)?; + Ok(warp::reply()) + }) + }, + ) + .boxed() +} + +fn ensure_heze_consensus_version(fork_name: ForkName) -> Result<(), Rejection> { + if !fork_name.heze_enabled() { + return Err(warp_utils::reject::custom_bad_request(format!( + "Eth-Consensus-Version {fork_name} is not supported for inclusion lists" + ))); + } + Ok(()) +} + +fn publish_inclusion_list( + chain: &BeaconChain, + network_tx: &UnboundedSender>, + signed_inclusion_list: SignedInclusionList, +) -> Result<(), warp::Rejection> { + let slot = signed_inclusion_list.message.slot; + let validator_index = signed_inclusion_list.message.validator_index; + let fork_name = chain.spec.fork_name_at_slot::(slot); + + if !fork_name.heze_enabled() { + return Err(warp_utils::reject::custom_bad_request( + "Inclusion lists publishing is not supported before the Heze fork".into(), + )); + } + + match chain.verify_inclusion_list_for_gossip(signed_inclusion_list) { + Ok(verified_inclusion_list) => { + crate::utils::publish_pubsub_message( + network_tx, + PubsubMessage::InclusionList(Box::new( + verified_inclusion_list.signed_inclusion_list, + )), + )?; + Ok(()) + } + Err(InclusionListVerificationError::AlreadySeenTwice { .. }) => { + debug!( + %slot, + %validator_index, + "Two valid inclusion lists were already seen" + ); + Ok(()) + } + Err( + e @ (InclusionListVerificationError::BeaconChainError(_) + | InclusionListVerificationError::BeaconStateError(_) + | InclusionListVerificationError::UnableToReadSlot), + ) => { + error!(%slot, error = ?e, "Internal error verifying inclusion list"); + Err(warp_utils::reject::custom_server_error(format!( + "internal error verifying inclusion list: {e}" + ))) + } + // TODO(heze): remove once the IL gossip verification errors are added to InclusionListVerificationError + #[allow(unreachable_patterns)] + Err(e) => { + warn!( + %slot, + %validator_index, + error = ?e, + "Inclusion list failed gossip verification" + ); + Err(warp_utils::reject::custom_bad_request(format!( + "inclusion list failed gossip verification: {e}" + ))) + } + } +} diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 38bcd05a77c..cd07a8d4bff 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -8314,7 +8314,7 @@ impl ApiTester { let trusted_peers = self.ctx.network_globals.as_ref().unwrap().trusted_peers(); // Check that there aren't any trusted peers on startup assert!(trusted_peers.is_empty()); - let enr = AdminPeer {enr: "enr:-QESuEDpVVjo8dmDuneRhLnXdIGY3e9NQiaG4sJR3GS-VMQCQDsmBYoQhJRaPeZzPlTsZj2F8v-iV4lKJEYIRIyztqexHodhdHRuZXRziAwAAAAAAAAAhmNsaWVudNiKTGlnaHRob3VzZYw3LjAuMC1iZXRhLjSEZXRoMpDS8Zl_YAAJEAAIAAAAAAAAgmlkgnY0gmlwhIe11XmDaXA2kCoBBPkAOitZAAAAAAAAAAKEcXVpY4IjKYVxdWljNoIjg4lzZWNwMjU2azGhA43ihEr9BUVVnIHIfFqBR3Izs4YRHHPsTqIbUgEb3Hc8iHN5bmNuZXRzD4N0Y3CCIyiEdGNwNoIjgoN1ZHCCIyiEdWRwNoIjgg".to_string()}; + let enr = AdminPeer { enr: "enr:-QESuEDpVVjo8dmDuneRhLnXdIGY3e9NQiaG4sJR3GS-VMQCQDsmBYoQhJRaPeZzPlTsZj2F8v-iV4lKJEYIRIyztqexHodhdHRuZXRziAwAAAAAAAAAhmNsaWVudNiKTGlnaHRob3VzZYw3LjAuMC1iZXRhLjSEZXRoMpDS8Zl_YAAJEAAIAAAAAAAAgmlkgnY0gmlwhIe11XmDaXA2kCoBBPkAOitZAAAAAAAAAAKEcXVpY4IjKYVxdWljNoIjg4lzZWNwMjU2azGhA43ihEr9BUVVnIHIfFqBR3Izs4YRHHPsTqIbUgEb3Hc8iHN5bmNuZXRzD4N0Y3CCIyiEdGNwNoIjgoN1ZHCCIyiEdWRwNoIjgg".to_string() }; self.client .post_lighthouse_add_peer(enr.clone()) .await @@ -9045,6 +9045,262 @@ impl ApiTester { assert_eq!(result.execution_optimistic, Some(true)); } + fn make_signed_inclusion_list(&self, slot: Slot) -> SignedInclusionList { + let epoch = self.chain.epoch().unwrap(); + let genesis_validators_root = self.chain.genesis_validators_root; + let head_state = self.chain.head_beacon_state_cloned(); + let dependent_root = self + .chain + .block_root_at_slot( + (epoch - 1).start_slot(E::slots_per_epoch()) - 1, + WhenSlotSkipped::Prev, + ) + .unwrap() + .unwrap_or(self.chain.head_beacon_block_root()); + // TODO(heze): use get_inclusion_list_committee from the beacon state when available + let beacon_committees = head_state.get_beacon_committees_at_slot(slot).unwrap(); + let validator_index = beacon_committees[0].committee[0] as u64; + let sk: &SecretKey = &self.validator_keypairs()[validator_index as usize].sk; + let inclusion_list = InclusionList { + slot, + validator_index, + dependent_root, + transactions: ProgressiveTransactions::new(Vec::new()), + }; + + self.sign_inclusion_list( + inclusion_list, + sk, + &head_state.fork(), + genesis_validators_root, + ) + } + + fn sign_inclusion_list( + &self, + inclusion_list: InclusionList, + sk: &SecretKey, + fork: &Fork, + genesis_validators_root: Hash256, + ) -> SignedInclusionList { + let epoch = inclusion_list.slot.epoch(E::slots_per_epoch()); + let domain = self.chain.spec.get_domain( + epoch, + Domain::InclusionListCommittee, + fork, + genesis_validators_root, + ); + let signing_root = inclusion_list.signing_root(domain); + let signature = sk.sign(signing_root); + + SignedInclusionList { + message: inclusion_list, + signature, + } + } + + pub async fn test_inclusion_list_post_pre_heze_returns_400(mut self) -> Self { + let slot = self.chain.slot().unwrap(); + let signed_il = self.make_signed_inclusion_list(slot); + + let response = self + .client + .post_validator_inclusion_list(&signed_il, self.chain.spec.fork_name_at_slot::(slot)) + .await + .expect_err("publishing inclusion list pre-Heze should fail"); + + assert_eq!(response.status(), Some(StatusCode::BAD_REQUEST)); + assert!(self.network_rx.network_recv.recv().now_or_never().is_none()); + + self + } + + pub async fn test_inclusion_list_post_ssz_pre_heze_returns_400(mut self) -> Self { + let slot = self.chain.slot().unwrap(); + let signed_il = self.make_signed_inclusion_list(slot); + + let response = self + .client + .post_validator_inclusion_list_ssz( + &signed_il, + self.chain.spec.fork_name_at_slot::(slot), + ) + .await + .expect_err("publishing inclusion list pre-Heze should fail"); + + assert_eq!(response.status(), Some(StatusCode::BAD_REQUEST)); + assert!(self.network_rx.network_recv.recv().now_or_never().is_none()); + + self + } + + pub async fn test_inclusion_list_post_fork_name_invalid_returns_400(mut self) -> Self { + if !self.chain.spec.is_heze_scheduled() { + return self; + } + + let slot = self.chain.slot().unwrap(); + let signed_il = self.make_signed_inclusion_list(slot); + let err = self + .client + .post_validator_inclusion_list(&signed_il, ForkName::Gloas) + .await + .expect_err("publishing inclusion list should fail"); + + assert_eq!(err.status(), Some(StatusCode::BAD_REQUEST)); + assert!(self.network_rx.network_recv.recv().now_or_never().is_none()); + + self + } + + pub async fn test_inclusion_list_post_ssz_fork_name_invalid_returns_400(mut self) -> Self { + if !self.chain.spec.is_heze_scheduled() { + return self; + } + + let slot = self.chain.slot().unwrap(); + let signed_il = self.make_signed_inclusion_list(slot); + let err = self + .client + .post_validator_inclusion_list_ssz(&signed_il, ForkName::Gloas) + .await + .expect_err("publishing inclusion list should fail"); + + assert_eq!(err.status(), Some(StatusCode::BAD_REQUEST)); + assert!(self.network_rx.network_recv.recv().now_or_never().is_none()); + + self + } + + pub async fn test_inclusion_list_post_while_syncing_returns_503(mut self) -> Self { + if !self.chain.spec.is_heze_scheduled() { + return self; + } + + let original_slot = self.chain.slot().unwrap(); + let signed_il = self.make_signed_inclusion_list(original_slot); + + let network_globals = self.ctx.network_globals.as_ref().unwrap(); + *network_globals.sync_state.write() = SyncState::SyncingFinalized { + start_slot: Slot::new(0), + target_slot: Slot::new(u64::MAX), + }; + + let head_slot = self.chain.canonical_head.cached_head().head_slot(); + let tolerance = self.chain.config.sync_tolerance_epochs * E::slots_per_epoch(); + self.chain + .slot_clock + .set_slot(head_slot.as_u64() + tolerance + 1); + + while self.network_rx.network_recv.recv().now_or_never().is_some() {} + + let err = self + .client + .post_validator_inclusion_list( + &signed_il, + self.chain.spec.fork_name_at_slot::(original_slot), + ) + .await + .expect_err("publishing inclusion list should fail while syncing"); + + assert_eq!(err.status(), Some(StatusCode::SERVICE_UNAVAILABLE)); + assert!(self.network_rx.network_recv.recv().now_or_never().is_none()); + + *network_globals.sync_state.write() = SyncState::Synced; + self.chain.slot_clock.set_slot(original_slot.as_u64() + 1); + + self + } + + pub async fn test_inclusion_list_post_ssz_while_syncing_returns_503(mut self) -> Self { + if !self.chain.spec.is_heze_scheduled() { + return self; + } + + let original_slot = self.chain.slot().unwrap(); + let signed_il = self.make_signed_inclusion_list(original_slot); + + let network_globals = self.ctx.network_globals.as_ref().unwrap(); + *network_globals.sync_state.write() = SyncState::SyncingFinalized { + start_slot: Slot::new(0), + target_slot: Slot::new(u64::MAX), + }; + + let head_slot = self.chain.canonical_head.cached_head().head_slot(); + let tolerance = self.chain.config.sync_tolerance_epochs * E::slots_per_epoch(); + self.chain + .slot_clock + .set_slot(head_slot.as_u64() + tolerance + 1); + + while self.network_rx.network_recv.recv().now_or_never().is_some() {} + + let err = self + .client + .post_validator_inclusion_list_ssz( + &signed_il, + self.chain.spec.fork_name_at_slot::(original_slot), + ) + .await + .expect_err("publishing inclusion list should fail while syncing"); + + assert_eq!(err.status(), Some(StatusCode::SERVICE_UNAVAILABLE)); + assert!(self.network_rx.network_recv.recv().now_or_never().is_none()); + + *network_globals.sync_state.write() = SyncState::Synced; + self.chain.slot_clock.set_slot(original_slot.as_u64() + 1); + + self + } + + pub async fn test_inclusion_list_post_valid(mut self) -> Self { + if !self.chain.spec.is_heze_scheduled() { + return self; + } + + let slot = self.chain.slot().unwrap(); + let signed_il = self.make_signed_inclusion_list(slot); + + self.client + .post_validator_inclusion_list(&signed_il, self.chain.spec.fork_name_at_slot::(slot)) + .await + .expect("publishing valid inclusion list (json) should be successful"); + + assert!( + self.network_rx.network_recv.recv().await.is_some(), + "valid inclusion list should be sent to network" + ); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + + self + } + + pub async fn test_inclusion_list_post_ssz_valid(mut self) -> Self { + if !self.chain.spec.is_heze_scheduled() { + return self; + } + + let slot = self.chain.slot().unwrap(); + let signed_il = self.make_signed_inclusion_list(slot); + + self.client + .post_validator_inclusion_list_ssz( + &signed_il, + self.chain.spec.fork_name_at_slot::(slot), + ) + .await + .expect("publishing valid inclusion list (ssz) should be successful"); + + assert!( + self.network_rx.network_recv.recv().await.is_some(), + "valid inclusion list (SSZ) should be sent to network" + ); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + + self + } + async fn test_get_beacon_rewards_blocks_at_head( &self, ) -> ExecutionOptimisticFinalizedResponse { @@ -10914,3 +11170,39 @@ async fn post_beacon_execution_payload_bids() { .test_post_beacon_execution_payload_bids_ssz() .await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn inclusion_list_publish_pre_heze() { + if fork_name_from_env().is_some_and(|f| f.heze_enabled()) { + return; + } + ApiTester::new_with_hard_forks() + .await + .test_inclusion_list_post_pre_heze_returns_400() + .await + .test_inclusion_list_post_ssz_pre_heze_returns_400() + .await; +} + +// TODO(heze): once IL gossip verification lands, cover the endpoint's error mapping +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn inclusion_list_api() { + if !fork_name_from_env().is_some_and(|f| f.heze_enabled()) { + return; + } + + ApiTester::new_with_hard_forks() + .await + .test_inclusion_list_post_fork_name_invalid_returns_400() + .await + .test_inclusion_list_post_ssz_fork_name_invalid_returns_400() + .await + .test_inclusion_list_post_while_syncing_returns_503() + .await + .test_inclusion_list_post_ssz_while_syncing_returns_503() + .await + .test_inclusion_list_post_valid() + .await + .test_inclusion_list_post_ssz_valid() + .await; +} diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index a02b948a909..556fca5c97e 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -3668,6 +3668,48 @@ impl BeaconNodeHttpClient { AttestationData::from_ssz_bytes(&response_bytes).map_err(Error::InvalidSsz) } + /// `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(()) + } + /// `GET validator/payload_attestation_data/{slot}` /// Returns `None` if no block has been received for the requested slot (404). pub async fn get_validator_payload_attestation_data(