diff --git a/beacon_node/http_api/src/beacon/states.rs b/beacon_node/http_api/src/beacon/states.rs index 0aabf0ccfe2..2aa549afab9 100644 --- a/beacon_node/http_api/src/beacon/states.rs +++ b/beacon_node/http_api/src/beacon/states.rs @@ -9,8 +9,8 @@ use crate::version::{ }; use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes, WhenSlotSkipped}; use eth2::types::{ - self as api_types, ValidatorBalancesRequestBody, ValidatorId, ValidatorIdentitiesRequestBody, - ValidatorIndexData, ValidatorsRequestBody, + self as api_types, BuildersRequestBody, ValidatorBalancesRequestBody, ValidatorId, + ValidatorIdentitiesRequestBody, ValidatorIndexData, ValidatorsRequestBody, }; use ssz::Encode; use std::sync::Arc; @@ -619,6 +619,33 @@ pub fn post_beacon_state_validators( .boxed() } +// POST beacon/states/{state_id}/builders +pub fn post_beacon_state_builders( + beacon_states_path: BeaconStatesPath, +) -> ResponseFilter { + beacon_states_path + .clone() + .and(warp::path("builders")) + .and(warp::path::end()) + .and(warp_utils::json::json_no_body()) + .then( + |state_id: StateId, + task_spawner: TaskSpawner, + chain: Arc>, + query: BuildersRequestBody| { + task_spawner.blocking_json_task(Priority::P1, move || { + crate::builders::get_beacon_state_builders( + state_id, + chain, + &query.ids, + &query.statuses, + ) + }) + }, + ) + .boxed() +} + // GET beacon/states/{state_id}/validators?id,status pub fn get_beacon_state_validators( beacon_states_path: BeaconStatesPath, diff --git a/beacon_node/http_api/src/builders.rs b/beacon_node/http_api/src/builders.rs new file mode 100644 index 00000000000..8e26d1f5ce0 --- /dev/null +++ b/beacon_node/http_api/src/builders.rs @@ -0,0 +1,70 @@ +use crate::state_id::StateId; +use beacon_chain::{BeaconChain, BeaconChainTypes}; +use eth2::types::{BuilderData, BuilderId, BuilderStatus, ExecutionOptimisticFinalizedResponse}; +use std::{collections::HashSet, sync::Arc}; + +pub fn get_beacon_state_builders( + state_id: StateId, + chain: Arc>, + query_ids: &Option>, + query_statuses: &Option>, +) -> Result>, warp::Rejection> { + let (data, execution_optimistic, finalized) = state_id + .map_state_and_execution_optimistic_and_finalized( + &chain, + |state, execution_optimistic, finalized| { + let builders = state.builders().map_err(|_| { + warp_utils::reject::custom_bad_request( + "Builders are not available for pre-Gloas states".to_string(), + ) + })?; + let finalized_epoch = state.finalized_checkpoint().epoch; + let far_future_epoch = chain.spec.far_future_epoch; + + let ids_filter_set: Option> = query_ids + .as_ref() + .filter(|ids| !ids.is_empty()) + .map(HashSet::from_iter); + let statuses_filter_set: Option> = query_statuses + .as_ref() + .filter(|statuses| !statuses.is_empty()) + .map(HashSet::from_iter); + + Ok(( + builders + .iter() + .enumerate() + .filter(|(index, builder)| { + ids_filter_set.as_ref().is_none_or(|ids| { + ids.contains(&BuilderId::PublicKey(builder.pubkey)) + || ids.contains(&BuilderId::Index(*index as u64)) + }) + }) + .filter_map(|(index, builder)| { + let status = BuilderStatus::from_builder( + builder, + finalized_epoch, + far_future_epoch, + ); + statuses_filter_set + .as_ref() + .is_none_or(|statuses| statuses.contains(&status)) + .then(|| BuilderData { + index: index as u64, + status, + builder: builder.clone(), + }) + }) + .collect(), + execution_optimistic, + finalized, + )) + }, + )?; + + Ok(ExecutionOptimisticFinalizedResponse { + execution_optimistic: Some(execution_optimistic), + finalized: Some(finalized), + data, + }) +} diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index e088ee08205..ebbf201805a 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -12,6 +12,7 @@ mod beacon; mod block_id; mod build_block_contents; mod builder_states; +mod builders; mod caches; mod custody; mod database; @@ -626,6 +627,9 @@ pub async fn serve( let post_beacon_state_validators = states::post_beacon_state_validators(beacon_states_path.clone()); + // POST beacon/states/{state_id}/builders + let post_beacon_state_builders = states::post_beacon_state_builders(beacon_states_path.clone()); + // GET beacon/states/{state_id}/validators/{validator_id} let get_beacon_state_validators_id = states::get_beacon_state_validators_id(beacon_states_path.clone()); @@ -3477,6 +3481,7 @@ pub async fn serve( .uor(post_beacon_execution_payload_envelopes) .uor(post_beacon_execution_payload_bids) .uor(post_beacon_state_validators) + .uor(post_beacon_state_builders) .uor(post_beacon_state_validator_balances) .uor(post_beacon_state_validator_identities) .uor(post_beacon_rewards_attestations) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 9b4dfbba873..04b10bd6e9d 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -49,10 +49,11 @@ use tokio::time::Duration; use tree_hash::TreeHash; use types::ApplicationDomain; use types::{ - Address, Domain, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, Hash256, MainnetEthSpec, - ProposerPreferences, RelativeEpoch, SelectionProof, SignedExecutionPayloadBid, + Address, Builder, Domain, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, Hash256, + MainnetEthSpec, ProposerPreferences, RelativeEpoch, SelectionProof, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, SignedProposerPreferences, SignedRoot, SingleAttestation, Slot, - attestation::AttestationBase, consts::gloas::BUILDER_INDEX_SELF_BUILD, + attestation::AttestationBase, + consts::gloas::{BUILDER_INDEX_SELF_BUILD, PAYLOAD_BUILDER_VERSION}, }; type E = MainnetEthSpec; @@ -101,6 +102,11 @@ struct ApiTesterConfig { node_custody_type: NodeCustodyType, } +struct BuilderStateTestFixture { + state_id: CoreStateId, + pending_id: BuilderId, +} + impl Default for ApiTesterConfig { fn default() -> Self { let mut spec = E::default_spec(); @@ -1178,6 +1184,107 @@ impl ApiTester { self } + fn builder_state_test_fixture(&self) -> BuilderStateTestFixture { + let mut state = self.chain.head_snapshot().beacon_state.clone(); + let finalized_epoch = state.finalized_checkpoint().epoch; + assert!( + finalized_epoch > 0, + "precondition: finalized epoch permits an active builder" + ); + + let builder = + |keypair_index: usize, deposit_epoch: Epoch, withdrawable_epoch: Epoch| Builder { + pubkey: self.validator_keypairs()[keypair_index].pk.clone().into(), + version: PAYLOAD_BUILDER_VERSION, + execution_address: Address::repeat_byte(keypair_index as u8 + 1), + balance: self.chain.spec.min_deposit_amount, + deposit_epoch, + withdrawable_epoch, + }; + + let active = builder(0, Epoch::new(0), self.chain.spec.far_future_epoch); + let pending = builder(1, finalized_epoch, self.chain.spec.far_future_epoch); + let pending_id = BuilderId::PublicKey(pending.pubkey); + let exited = builder(2, Epoch::new(0), state.current_epoch()); + let builders = state.builders_mut().unwrap(); + assert!(builders.is_empty()); + builders.push(active).unwrap(); + builders.push(pending).unwrap(); + builders.push(exited).unwrap(); + + let state_root = state.update_tree_hash_cache().unwrap(); + self.chain.store.put_state(&state_root, &state).unwrap(); + + BuilderStateTestFixture { + state_id: CoreStateId::Root(state_root), + pending_id, + } + } + + pub async fn test_beacon_state_builders_filters(self) -> Self { + let fixture = self.builder_state_test_fixture(); + let all_builders = self + .client + .post_beacon_states_builders(fixture.state_id, None, None) + .await + .unwrap() + .unwrap(); + assert_eq!(all_builders.execution_optimistic, Some(false)); + assert_eq!(all_builders.finalized, Some(false)); + assert_eq!( + all_builders + .data + .iter() + .map(|builder| (builder.index, builder.status)) + .collect::>(), + vec![ + (0, BuilderStatus::Active), + (1, BuilderStatus::Pending), + (2, BuilderStatus::Exited), + ] + ); + + let empty_filters = self + .client + .post_beacon_states_builders(fixture.state_id, Some(vec![]), Some(vec![])) + .await + .unwrap() + .unwrap(); + assert_eq!(empty_filters, all_builders); + + let filtered_by_index = self + .client + .post_beacon_states_builders( + fixture.state_id, + Some(vec![BuilderId::Index(0), BuilderId::Index(u64::MAX)]), + Some(vec![BuilderStatus::Active]), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(filtered_by_index.data, vec![all_builders.data[0].clone()]); + + let filtered_by_pubkey = self + .client + .post_beacon_states_builders(fixture.state_id, Some(vec![fixture.pending_id]), None) + .await + .unwrap() + .unwrap(); + assert_eq!(filtered_by_pubkey.data, vec![all_builders.data[1].clone()]); + + self + } + + pub async fn test_beacon_state_builders_pre_gloas(self) -> Self { + let error = self + .client + .post_beacon_states_builders(CoreStateId::Head, None, None) + .await + .unwrap_err(); + assert_eq!(error.status(), Some(StatusCode::BAD_REQUEST)); + self + } + pub async fn test_beacon_states_validator_id(self) -> Self { for state_id in self.interesting_state_ids() { let state_opt = state_id @@ -8454,6 +8561,30 @@ async fn beacon_get_state_info_fulu() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn beacon_state_builders_gloas() { + let mut config = ApiTesterConfig::default(); + config.spec.altair_fork_epoch = Some(Epoch::new(0)); + config.spec.bellatrix_fork_epoch = Some(Epoch::new(0)); + config.spec.capella_fork_epoch = Some(Epoch::new(0)); + config.spec.deneb_fork_epoch = Some(Epoch::new(0)); + config.spec.electra_fork_epoch = Some(Epoch::new(0)); + config.spec.fulu_fork_epoch = Some(Epoch::new(0)); + config.spec.gloas_fork_epoch = Some(Epoch::new(0)); + ApiTester::new_from_config(config) + .await + .test_beacon_state_builders_filters() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn beacon_state_builders_pre_gloas() { + ApiTester::new() + .await + .test_beacon_state_builders_pre_gloas() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn beacon_get_blocks() { ApiTester::new() diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 3bda02935fb..4502e23c615 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -744,6 +744,29 @@ impl BeaconNodeHttpClient { self.post_with_opt_response(path, &request).await } + /// `POST beacon/states/{state_id}/builders` + /// + /// Returns `Ok(None)` on a 404 error. + pub async fn post_beacon_states_builders( + &self, + state_id: StateId, + ids: Option>, + statuses: Option>, + ) -> Result>>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("states") + .push(&state_id.to_string()) + .push("builders"); + + let request = BuildersRequestBody { ids, statuses }; + + self.post_with_opt_response(path, &request).await + } + /// `GET beacon/states/{state_id}/committees?slot,index,epoch` /// /// Returns `Ok(None)` on a 404 error. diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index d69e99293a9..fe0da82b48c 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -300,6 +300,53 @@ impl From for String { } } +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +#[serde(into = "String")] +#[serde(try_from = "std::borrow::Cow")] +pub enum BuilderId { + PublicKey(PublicKeyBytes), + Index(u64), +} + +impl TryFrom> for BuilderId { + type Error = String; + + fn try_from(s: std::borrow::Cow) -> Result { + Self::from_str(&s) + } +} + +impl FromStr for BuilderId { + type Err = String; + + fn from_str(s: &str) -> Result { + if s.starts_with("0x") { + PublicKeyBytes::from_str(s) + .map(BuilderId::PublicKey) + .map_err(|e| format!("{} cannot be parsed as a public key: {}", s, e)) + } else { + u64::from_str(s) + .map(BuilderId::Index) + .map_err(|e| format!("{} cannot be parsed as a builder index: {}", s, e)) + } + } +} + +impl fmt::Display for BuilderId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BuilderId::PublicKey(pubkey) => write!(f, "{:?}", pubkey), + BuilderId::Index(index) => write!(f, "{}", index), + } + } +} + +impl From for String { + fn from(id: BuilderId) -> String { + id.to_string() + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ValidatorData { #[serde(with = "serde_utils::quoted_u64")] @@ -326,6 +373,38 @@ pub struct ValidatorIdentityData { pub activation_epoch: Epoch, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BuilderData { + #[serde(with = "serde_utils::quoted_u64")] + pub index: u64, + pub status: BuilderStatus, + pub builder: Builder, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BuilderStatus { + Pending, + Active, + Exited, +} + +impl BuilderStatus { + pub fn from_builder( + builder: &Builder, + finalized_epoch: Epoch, + far_future_epoch: Epoch, + ) -> Self { + if builder.withdrawable_epoch != far_future_epoch { + Self::Exited + } else if builder.deposit_epoch < finalized_epoch { + Self::Active + } else { + Self::Pending + } + } +} + // Implemented according to what is described here: // // https://hackmd.io/ofFJ5gOmQpu1jjHilHbdQQ @@ -492,6 +571,15 @@ pub struct ValidatorsRequestBody { pub statuses: Option>, } +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BuildersRequestBody { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ids: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub statuses: Option>, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CommitteeData { #[serde(with = "serde_utils::quoted_u64")]