diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 8746e3c063c..2703faa5ef3 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -9,6 +9,8 @@ pub mod beacon_response; pub mod error; +use ssz::{Decode, Encode}; + #[cfg(feature = "lighthouse")] pub mod lighthouse; #[cfg(feature = "lighthouse")] @@ -27,7 +29,9 @@ pub use reqwest::{StatusCode, Url}; pub use sensitive_url::SensitiveUrl; use self::mixin::{RequestAccept, ResponseOptional}; +use self::types::ExecutionPayloadEnvelopeMetadata; use self::types::*; +use ::types::{ExecutionPayloadEnvelope, ForkName}; use bls::SignatureBytes; use context_deserialize::ContextDeserialize; use educe::Educe; @@ -42,7 +46,6 @@ use reqwest::{ #[cfg(feature = "events")] use reqwest_eventsource::{Event, EventSource}; use serde::{Serialize, de::DeserializeOwned}; -use ssz::Encode; use std::fmt; use std::future::Future; use std::time::Duration; @@ -50,6 +53,7 @@ use std::time::Duration; pub const V1: EndpointVersion = EndpointVersion(1); pub const V2: EndpointVersion = EndpointVersion(2); pub const V3: EndpointVersion = EndpointVersion(3); +pub const V4: EndpointVersion = EndpointVersion(4); pub const CONSENSUS_VERSION_HEADER: &str = "Eth-Consensus-Version"; pub const EXECUTION_PAYLOAD_BLINDED_HEADER: &str = "Eth-Execution-Payload-Blinded"; @@ -76,6 +80,9 @@ const HTTP_GET_BEACON_BLOCK_SSZ_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_GET_DEBUG_BEACON_STATE_QUOTIENT: u32 = 4; const HTTP_GET_DEPOSIT_SNAPSHOT_QUOTIENT: u32 = 4; const HTTP_GET_VALIDATOR_BLOCK_TIMEOUT_QUOTIENT: u32 = 4; +// TODO(EIP-7732): determine what the envelope timeout should be +const HTTP_GET_EXECUTION_PAYLOAD_ENVELOPE_TIMEOUT_QUOTIENT: u32 = 4; +const HTTP_POST_EXECUTION_PAYLOAD_ENVELOPE_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_DEFAULT_TIMEOUT_QUOTIENT: u32 = 4; /// A struct to define a variety of different timeouts for different validator tasks to ensure @@ -96,6 +103,8 @@ pub struct Timeouts { pub get_debug_beacon_states: Duration, pub get_deposit_snapshot: Duration, pub get_validator_block: Duration, + pub get_execution_payload_envelope: Duration, + pub post_execution_payload_envelope: Duration, pub default: Duration, } @@ -116,6 +125,8 @@ impl Timeouts { get_debug_beacon_states: timeout, get_deposit_snapshot: timeout, get_validator_block: timeout, + get_execution_payload_envelope: timeout, + post_execution_payload_envelope: timeout, default: timeout, } } @@ -138,6 +149,10 @@ impl Timeouts { get_debug_beacon_states: base_timeout / HTTP_GET_DEBUG_BEACON_STATE_QUOTIENT, get_deposit_snapshot: base_timeout / HTTP_GET_DEPOSIT_SNAPSHOT_QUOTIENT, get_validator_block: base_timeout / HTTP_GET_VALIDATOR_BLOCK_TIMEOUT_QUOTIENT, + get_execution_payload_envelope: base_timeout + / HTTP_GET_EXECUTION_PAYLOAD_ENVELOPE_TIMEOUT_QUOTIENT, + post_execution_payload_envelope: base_timeout + / HTTP_POST_EXECUTION_PAYLOAD_ENVELOPE_TIMEOUT_QUOTIENT, default: base_timeout / HTTP_DEFAULT_TIMEOUT_QUOTIENT, } } @@ -1211,6 +1226,8 @@ impl BeaconNodeHttpClient { } /// `POST v2/beacon/blocks` + /// TODO(EIP-7732): Modify beacon node response endpoint per + /// https://github.com/ethereum/beacon-APIs/pull/552. pub async fn post_beacon_blocks_v2_ssz( &self, block_contents: &PublishBlockRequest, @@ -2199,17 +2216,18 @@ impl BeaconNodeHttpClient { Ok(path) } - /// returns `GET v3/validator/blocks/{slot}` URL path - pub async fn get_validator_blocks_v3_path( + /// returns `GET v3/validator/blocks/{slot}` or `GET v4/validator/blocks/{slot}` URL path + pub async fn get_validator_blocks_v3_and_v4_path( &self, + version: EndpointVersion, slot: Slot, randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, skip_randao_verification: SkipRandaoVerification, - builder_booster_factor: Option, + builder_boost_factor: Option, graffiti_policy: Option, ) -> Result { - let mut path = self.eth_path(V3)?; + let mut path = self.eth_path(version)?; path.path_segments_mut() .map_err(|()| Error::InvalidUrl(self.server.clone()))? @@ -2230,9 +2248,9 @@ impl BeaconNodeHttpClient { .append_pair("skip_randao_verification", ""); } - if let Some(builder_booster_factor) = builder_booster_factor { + if let Some(builder_boost_factor) = builder_boost_factor { path.query_pairs_mut() - .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); + .append_pair("builder_boost_factor", &builder_boost_factor.to_string()); } // Only append the HTTP URL request if the graffiti_policy is to AppendClientVersions @@ -2246,13 +2264,35 @@ impl BeaconNodeHttpClient { Ok(path) } + /// returns `GET v3/validator/blocks/{slot}` URL path (convenience method) + pub async fn get_validator_blocks_v3_path( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + skip_randao_verification: SkipRandaoVerification, + builder_boost_factor: Option, + graffiti_policy: Option, + ) -> Result { + self.get_validator_blocks_v3_and_v4_path( + V3, + slot, + randao_reveal, + graffiti, + skip_randao_verification, + builder_boost_factor, + graffiti_policy, + ) + .await + } + /// `GET v3/validator/blocks/{slot}` pub async fn get_validator_blocks_v3( &self, slot: Slot, randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, - builder_booster_factor: Option, + builder_boost_factor: Option, graffiti_policy: Option, ) -> Result<(JsonProduceBlockV3Response, ProduceBlockV3Metadata), Error> { self.get_validator_blocks_v3_modular( @@ -2260,7 +2300,7 @@ impl BeaconNodeHttpClient { randao_reveal, graffiti, SkipRandaoVerification::No, - builder_booster_factor, + builder_boost_factor, graffiti_policy, ) .await @@ -2273,16 +2313,17 @@ impl BeaconNodeHttpClient { randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, skip_randao_verification: SkipRandaoVerification, - builder_booster_factor: Option, + builder_boost_factor: Option, graffiti_policy: Option, ) -> Result<(JsonProduceBlockV3Response, ProduceBlockV3Metadata), Error> { let path = self - .get_validator_blocks_v3_path( + .get_validator_blocks_v3_and_v4_path( + V3, slot, randao_reveal, graffiti, skip_randao_verification, - builder_booster_factor, + builder_boost_factor, graffiti_policy, ) .await?; @@ -2325,7 +2366,7 @@ impl BeaconNodeHttpClient { slot: Slot, randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, - builder_booster_factor: Option, + builder_boost_factor: Option, graffiti_policy: Option, ) -> Result<(ProduceBlockV3Response, ProduceBlockV3Metadata), Error> { self.get_validator_blocks_v3_modular_ssz::( @@ -2333,7 +2374,7 @@ impl BeaconNodeHttpClient { randao_reveal, graffiti, SkipRandaoVerification::No, - builder_booster_factor, + builder_boost_factor, graffiti_policy, ) .await @@ -2346,16 +2387,17 @@ impl BeaconNodeHttpClient { randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, skip_randao_verification: SkipRandaoVerification, - builder_booster_factor: Option, + builder_boost_factor: Option, graffiti_policy: Option, ) -> Result<(ProduceBlockV3Response, ProduceBlockV3Metadata), Error> { let path = self - .get_validator_blocks_v3_path( + .get_validator_blocks_v3_and_v4_path( + V3, slot, randao_reveal, graffiti, skip_randao_verification, - builder_booster_factor, + builder_boost_factor, graffiti_policy, ) .await?; @@ -2535,6 +2577,296 @@ impl BeaconNodeHttpClient { .await } + /// `GET v1/validator/execution_payload_envelope/{slot}/{builder_index}` + /// TODO(EIP-7732): Build out beacon node response endpoint per + /// https://github.com/ethereum/beacon-APIs/pull/552 + /// Only client side request is implemented so far. + pub async fn get_execution_payload_envelope( + &self, + slot: Slot, + builder_index: u64, + ) -> Result>, Error> { + let path = self + .get_execution_payload_envelope_path(slot, builder_index) + .await?; + + self.get_with_timeout(path, self.timeouts.get_execution_payload_envelope) + .await + } + + /// `GET v1/validator/execution_payload_envelope/{slot}/{builder_index}` in SSZ format + /// TODO(EIP-7732): Build out beacon node response endpoint per + /// https://github.com/ethereum/beacon-APIs/pull/552 + /// Only client side request is implemented so far. + pub async fn get_execution_payload_envelope_ssz( + &self, + slot: Slot, + builder_index: u64, + ) -> Result, Error> { + let path = self + .get_execution_payload_envelope_path(slot, builder_index) + .await?; + + let opt_response = self + .get_response_with_response_headers( + path, + Accept::Ssz, + self.timeouts.get_execution_payload_envelope, + |response, headers| async move { + let metadata = ExecutionPayloadEnvelopeMetadata::try_from(&headers) + .map_err(Error::InvalidHeaders)?; + let response_bytes = response.bytes().await?; + + if !metadata.consensus_version.gloas_enabled() { + return Err(Error::InvalidHeaders(format!( + "ExecutionPayloadEnvelope not supported for fork: {:?}", + metadata.consensus_version + ))); + } + + let envelope = ExecutionPayloadEnvelope::from_ssz_bytes(&response_bytes) + .map_err(Error::InvalidSsz)?; + + Ok(envelope) + }, + ) + .await?; + + // This route should never 404 unless unimplemented, so treat that as an error. + opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) + } + + /// returns `GET v1/validator/execution_payload_envelope/{slot}/{builder_index}` URL path + pub async fn get_execution_payload_envelope_path( + &self, + slot: Slot, + builder_index: u64, + ) -> Result { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("execution_payload_envelope") + .push(&slot.to_string()) + .push(&builder_index.to_string()); + + Ok(path) + } + + /// `POST v1/beacon/execution_payload_envelope` + /// TODO(EIP-7732): Build out beacon node response endpoint per + /// https://github.com/ethereum/beacon-APIs/pull/552 + /// Only client side request is implemented so far. + pub async fn post_execution_payload_envelope( + &self, + envelope: &types::SignedExecutionPayloadEnvelope, + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("execution_payload_envelope"); + + self.post_generic_with_consensus_version( + path, + envelope, + Some(self.timeouts.post_execution_payload_envelope), + fork_name, + ) + .await?; + + Ok(()) + } + + /// `POST v1/beacon/execution_payload_envelope` in SSZ format + /// TODO(EIP-7732): Build out beacon node response endpoint per + /// https://github.com/ethereum/beacon-APIs/pull/552 + /// Only client side request is implemented so far. + pub async fn post_execution_payload_envelope_ssz( + &self, + envelope: &types::SignedExecutionPayloadEnvelope, + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("execution_payload_envelope"); + + self.post_generic_with_consensus_version_and_ssz_body( + path, + envelope.as_ssz_bytes(), + Some(self.timeouts.post_execution_payload_envelope), + fork_name, + ) + .await?; + + Ok(()) + } + + /// returns `GET v4/validator/blocks/{slot}` URL path + pub async fn get_validator_blocks_v4_path( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + skip_randao_verification: SkipRandaoVerification, + builder_boost_factor: Option, + graffiti_policy: Option, + ) -> Result { + self.get_validator_blocks_v3_and_v4_path( + V4, + slot, + randao_reveal, + graffiti, + skip_randao_verification, + builder_boost_factor, + graffiti_policy, + ) + .await + } + + /// `GET v4/validator/blocks/{slot}` - Post-Gloas block production endpoint + /// This endpoint is specific to post-Gloas forks and only returns full blocks (no blinded concept) + /// TODO(EIP-7732): Build out beacon node response endpoint per + /// https://github.com/ethereum/beacon-APIs/pull/552 + /// Only client side request is implemented so far. + pub async fn get_validator_blocks_v4( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + builder_boost_factor: Option, + graffiti_policy: Option, + ) -> Result<(JsonProduceBlockV4Response, ProduceBlockV4Metadata), Error> { + self.get_validator_blocks_v4_modular( + slot, + randao_reveal, + graffiti, + SkipRandaoVerification::No, + builder_boost_factor, + graffiti_policy, + ) + .await + } + + /// `GET v4/validator/blocks/{slot}` - Post-Gloas block production endpoint (modular version) + pub async fn get_validator_blocks_v4_modular( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + skip_randao_verification: SkipRandaoVerification, + builder_boost_factor: Option, + graffiti_policy: Option, + ) -> Result<(JsonProduceBlockV4Response, ProduceBlockV4Metadata), Error> { + let path = self + .get_validator_blocks_v4_path( + slot, + randao_reveal, + graffiti, + skip_randao_verification, + builder_boost_factor, + graffiti_policy, + ) + .await?; + + let opt_result = self + .get_response_with_response_headers( + path, + Accept::Json, + self.timeouts.get_validator_block, + |response, headers| async move { + let header_metadata = ProduceBlockV4Metadata::try_from(&headers) + .map_err(Error::InvalidHeaders)?; + + let block_response = response + .json::, ProduceBlockV4Metadata>>() + .await?; + + Ok((block_response, header_metadata)) + }, + ) + .await?; + + // This route should never 404 unless unimplemented, so treat that as an error. + opt_result.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) + } + + /// `GET v4/validator/blocks/{slot}` in SSZ format + /// TODO(EIP-7732): Build out beacon node response endpoint per + /// https://github.com/ethereum/beacon-APIs/pull/552 + /// Only client side request is implemented so far. + pub async fn get_validator_blocks_v4_ssz( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + builder_boost_factor: Option, + graffiti_policy: Option, + ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { + self.get_validator_blocks_v4_modular_ssz::( + slot, + randao_reveal, + graffiti, + SkipRandaoVerification::No, + builder_boost_factor, + graffiti_policy, + ) + .await + } + + /// `GET v4/validator/blocks/{slot}` in SSZ format + pub async fn get_validator_blocks_v4_modular_ssz( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + skip_randao_verification: SkipRandaoVerification, + builder_boost_factor: Option, + graffiti_policy: Option, + ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { + let path = self + .get_validator_blocks_v4_path( + slot, + randao_reveal, + graffiti, + skip_randao_verification, + builder_boost_factor, + graffiti_policy, + ) + .await?; + + let opt_response = self + .get_response_with_response_headers( + path, + Accept::Ssz, + self.timeouts.get_validator_block, + |response, headers| async move { + let metadata = ProduceBlockV4Metadata::try_from(&headers) + .map_err(Error::InvalidHeaders)?; + let response_bytes = response.bytes().await?; + + // Parse SSZ bytes directly as BeaconBlock (ProduceBlockV4Response is just a type alias) + let response = BeaconBlock::from_ssz_bytes_for_fork( + &response_bytes, + metadata.consensus_version, + ) + .map_err(Error::InvalidSsz)?; + + Ok((response, metadata)) + }, + ) + .await?; + + // This route should never 404 unless unimplemented, so treat that as an error. + opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) + } + /// `GET validator/attestation_data?slot,committee_index` pub async fn get_validator_attestation_data( &self, diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index b1a61ce00cc..5ebeff18f0b 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1742,6 +1742,41 @@ pub struct ProduceBlockV3Metadata { pub consensus_block_value: Uint256, } +/// Metadata about a `ExecutionPayloadEnvelope` response which is returned in the headers. +#[derive(Debug, Deserialize, Serialize)] +pub struct ExecutionPayloadEnvelopeMetadata { + // The consensus version is serialized & deserialized by `ForkVersionedResponse`. + #[serde( + skip_serializing, + skip_deserializing, + default = "dummy_consensus_version" + )] + pub consensus_version: ForkName, +} + +/// Response from the `/eth/v4/validator/blocks/{slot}` endpoint. +/// +/// V4 is specific to post-Gloas forks and always returns a BeaconBlock directly. +/// No blinded/unblinded concept exists in Gloas. +pub type ProduceBlockV4Response = BeaconBlock; + +pub type JsonProduceBlockV4Response = + ForkVersionedResponse, ProduceBlockV4Metadata>; + +/// Metadata about a `ProduceBlockV4Response` which is returned in the body & headers. +#[derive(Debug, Deserialize, Serialize)] +pub struct ProduceBlockV4Metadata { + // The consensus version is serialized & deserialized by `ForkVersionedResponse`. + #[serde( + skip_serializing, + skip_deserializing, + default = "dummy_consensus_version" + )] + pub consensus_version: ForkName, + #[serde(with = "serde_utils::u256_dec")] + pub consensus_block_value: Uint256, +} + impl FullBlockContents { pub fn new(block: BeaconBlock, blob_data: Option<(KzgProofs, BlobsList)>) -> Self { match blob_data { @@ -1898,6 +1933,40 @@ impl TryFrom<&HeaderMap> for ProduceBlockV3Metadata { } } +impl TryFrom<&HeaderMap> for ExecutionPayloadEnvelopeMetadata { + type Error = String; + + fn try_from(headers: &HeaderMap) -> Result { + let consensus_version = parse_required_header(headers, CONSENSUS_VERSION_HEADER, |s| { + s.parse::() + .map_err(|e| format!("invalid {CONSENSUS_VERSION_HEADER}: {e:?}")) + })?; + + Ok(ExecutionPayloadEnvelopeMetadata { consensus_version }) + } +} + +impl TryFrom<&HeaderMap> for ProduceBlockV4Metadata { + type Error = String; + + fn try_from(headers: &HeaderMap) -> Result { + let consensus_version = parse_required_header(headers, CONSENSUS_VERSION_HEADER, |s| { + s.parse::() + .map_err(|e| format!("invalid {CONSENSUS_VERSION_HEADER}: {e:?}")) + })?; + let consensus_block_value = + parse_required_header(headers, CONSENSUS_BLOCK_VALUE_HEADER, |s| { + Uint256::from_str_radix(s, 10) + .map_err(|e| format!("invalid {CONSENSUS_BLOCK_VALUE_HEADER}: {e:?}")) + })?; + + Ok(ProduceBlockV4Metadata { + consensus_version, + consensus_block_value, + }) + } +} + /// A wrapper over a [`SignedBeaconBlock`] or a [`SignedBlockContents`]. #[derive(Clone, Debug, PartialEq, Encode, Serialize)] #[serde(untagged)] diff --git a/validator_client/beacon_node_fallback/src/lib.rs b/validator_client/beacon_node_fallback/src/lib.rs index 2d75df2fa34..6d18da5036e 100644 --- a/validator_client/beacon_node_fallback/src/lib.rs +++ b/validator_client/beacon_node_fallback/src/lib.rs @@ -1004,6 +1004,7 @@ mod tests { spec.clone(), ); + // TODO(EIP-7732): discuss whether we should replace this with a new `mock_post_beacon__blocks_v2_ssz` for post-gloas blocks since no blinded blocks anymore. mock_beacon_node_1.mock_post_beacon_blinded_blocks_v2_ssz(Duration::from_secs(0)); mock_beacon_node_2.mock_post_beacon_blinded_blocks_v2_ssz(Duration::from_secs(0)); diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index 3bea21a05d8..73db45d0a71 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -19,12 +19,12 @@ use task_executor::TaskExecutor; use tracing::{error, info, instrument, warn}; use types::{ AbstractExecPayload, Address, AggregateAndProof, Attestation, BeaconBlock, BlindedPayload, - ChainSpec, ContributionAndProof, Domain, Epoch, EthSpec, Fork, Graffiti, Hash256, - SelectionProof, SignedAggregateAndProof, SignedBeaconBlock, SignedContributionAndProof, - SignedRoot, SignedValidatorRegistrationData, SignedVoluntaryExit, Slot, - SyncAggregatorSelectionData, SyncCommitteeContribution, SyncCommitteeMessage, - SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, VoluntaryExit, - graffiti::GraffitiString, + ChainSpec, ContributionAndProof, Domain, Epoch, EthSpec, ExecutionPayloadEnvelope, Fork, + Graffiti, Hash256, SelectionProof, SignedAggregateAndProof, SignedBeaconBlock, + SignedContributionAndProof, SignedExecutionPayloadEnvelope, SignedRoot, + SignedValidatorRegistrationData, SignedVoluntaryExit, Slot, SyncAggregatorSelectionData, + SyncCommitteeContribution, SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, + ValidatorRegistrationData, VoluntaryExit, graffiti::GraffitiString, }; use validator_store::{ DoppelgangerStatus, Error as ValidatorStoreError, ProposalData, SignedBlock, UnsignedBlock, @@ -747,6 +747,45 @@ impl ValidatorStore for LighthouseValidatorS } } + async fn sign_block_gloas( + &self, + validator_pubkey: PublicKeyBytes, + block: &BeaconBlock, + current_slot: Slot, + ) -> Result>, Error> { + let beacon_block = block.clone(); + self.sign_abstract_block(validator_pubkey, beacon_block, current_slot) + .await + .map(|signed_block| Arc::new(signed_block)) + } + + async fn sign_execution_payload_envelope( + &self, + validator_pubkey: PublicKeyBytes, + envelope: &ExecutionPayloadEnvelope, + current_slot: Slot, + ) -> Result, Error> { + let signing_epoch = current_slot.epoch(E::slots_per_epoch()); + let signing_context = self.signing_context(Domain::BeaconBuilder, signing_epoch); + + let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?; + + let signature = signing_method + .get_signature::>( + SignableMessage::ExecutionPayloadEnvelope(envelope), + signing_context, + &self.spec, + &self.task_executor, + ) + .await + .map_err(Error::SpecificError)?; + + Ok(SignedExecutionPayloadEnvelope { + message: envelope.clone(), + signature, + }) + } + #[instrument(skip_all)] async fn sign_attestation( &self, diff --git a/validator_client/signing_method/src/lib.rs b/validator_client/signing_method/src/lib.rs index d0d98689526..865d93fe76c 100644 --- a/validator_client/signing_method/src/lib.rs +++ b/validator_client/signing_method/src/lib.rs @@ -49,6 +49,7 @@ pub enum SignableMessage<'a, E: EthSpec, Payload: AbstractExecPayload = FullP SignedContributionAndProof(&'a ContributionAndProof), ValidatorRegistration(&'a ValidatorRegistrationData), VoluntaryExit(&'a VoluntaryExit), + ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), } impl> SignableMessage<'_, E, Payload> { @@ -70,6 +71,7 @@ impl> SignableMessage<'_, E, Payload SignableMessage::SignedContributionAndProof(c) => c.signing_root(domain), SignableMessage::ValidatorRegistration(v) => v.signing_root(domain), SignableMessage::VoluntaryExit(exit) => exit.signing_root(domain), + SignableMessage::ExecutionPayloadEnvelope(envelope) => envelope.signing_root(domain), } } } @@ -231,6 +233,9 @@ impl SigningMethod { Web3SignerObject::ValidatorRegistration(v) } SignableMessage::VoluntaryExit(e) => Web3SignerObject::VoluntaryExit(e), + SignableMessage::ExecutionPayloadEnvelope(envelope) => { + Web3SignerObject::ExecutionPayloadEnvelope(envelope) + } }; // Determine the Web3Signer message type. diff --git a/validator_client/signing_method/src/web3signer.rs b/validator_client/signing_method/src/web3signer.rs index 246d9e9e091..7bf953aaeb8 100644 --- a/validator_client/signing_method/src/web3signer.rs +++ b/validator_client/signing_method/src/web3signer.rs @@ -19,6 +19,7 @@ pub enum MessageType { SyncCommitteeSelectionProof, SyncCommitteeContributionAndProof, ValidatorRegistration, + ExecutionPayloadEnvelope, } #[derive(Debug, PartialEq, Copy, Clone, Serialize)] @@ -75,6 +76,7 @@ pub enum Web3SignerObject<'a, E: EthSpec, Payload: AbstractExecPayload> { SyncAggregatorSelectionData(&'a SyncAggregatorSelectionData), ContributionAndProof(&'a ContributionAndProof), ValidatorRegistration(&'a ValidatorRegistrationData), + ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), } impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Payload> { @@ -140,6 +142,7 @@ impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Pa MessageType::SyncCommitteeContributionAndProof } Web3SignerObject::ValidatorRegistration(_) => MessageType::ValidatorRegistration, + Web3SignerObject::ExecutionPayloadEnvelope(_) => MessageType::ExecutionPayloadEnvelope, } } } diff --git a/validator_client/validator_metrics/src/lib.rs b/validator_client/validator_metrics/src/lib.rs index 060d8a4edd2..8e81535b640 100644 --- a/validator_client/validator_metrics/src/lib.rs +++ b/validator_client/validator_metrics/src/lib.rs @@ -9,6 +9,10 @@ pub const BEACON_BLOCK: &str = "beacon_block"; pub const BEACON_BLOCK_HTTP_GET: &str = "beacon_block_http_get"; pub const BEACON_BLOCK_HTTP_POST: &str = "beacon_block_http_post"; pub const BLINDED_BEACON_BLOCK_HTTP_POST: &str = "blinded_beacon_block_http_post"; +pub const EXECUTION_PAYLOAD_ENVELOPE_HTTP_GET: &str = "execution_payload_envelope_http_get"; +pub const EXECUTION_PAYLOAD_ENVELOPE_HTTP_POST: &str = "execution_payload_envelope_http_post"; +pub const EXECUTION_PAYLOAD_ENVELOPE_SIGN: &str = "execution_payload_envelope_sign"; +pub const EXECUTION_PAYLOAD_ENVELOPE: &str = "execution_payload_envelope"; pub const ATTESTATIONS: &str = "attestations"; pub const ATTESTATIONS_HTTP_GET: &str = "attestations_http_get"; pub const ATTESTATIONS_HTTP_POST: &str = "attestations_http_post"; @@ -237,6 +241,12 @@ pub static BLOCK_SIGNING_TIMES: LazyLock> = LazyLock::new(|| { "Duration to obtain a signature for a block", ) }); +pub static ENVELOPE_SIGNING_TIMES: LazyLock> = LazyLock::new(|| { + try_create_histogram( + "vc_envelope_signing_times_seconds", + "Duration to obtain a signature for an execution payload envelope", + ) +}); pub static ATTESTATION_DUTY: LazyLock> = LazyLock::new(|| { try_create_int_gauge_vec( diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index 625f8db7cb9..87c42f00ff6 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -13,7 +13,7 @@ use std::time::Duration; use task_executor::TaskExecutor; use tokio::sync::mpsc; use tracing::{Instrument, debug, error, info, info_span, instrument, trace, warn}; -use types::{BlockType, ChainSpec, EthSpec, Graffiti, Slot}; +use types::{BeaconBlock, BlockType, ChainSpec, EthSpec, Graffiti, SignedBeaconBlock, Slot}; use validator_store::{Error as ValidatorStoreError, SignedBlock, UnsignedBlock, ValidatorStore}; #[derive(Debug)] @@ -189,6 +189,41 @@ impl ProposerFallback { (Err(_), Some(proposer_nodes)) => proposer_nodes.first_success(func).await, } } + + // Try `func` on `self.beacon_nodes` first. If that doesn't work, try `self.proposer_nodes`. + // Returns both the result and the beacon node client that provided it. + pub async fn request_proposers_last_with_source( + &self, + func: F, + ) -> Result<(O, BeaconNodeHttpClient), Errors> + where + F: Fn(BeaconNodeHttpClient) -> R + Clone, + R: Future>, + Err: Debug, + { + // Create a wrapper function that returns both the result and the source client + let wrapper_func = move |client: BeaconNodeHttpClient| { + let client_clone = client.clone(); + let func_clone = func.clone(); + async move { + func_clone(client) + .await + .map(|result| (result, client_clone)) + } + }; + + // Try running wrapper_func on the non-proposer beacon nodes first + let beacon_nodes_result = self.beacon_nodes.first_success(wrapper_func.clone()).await; + + match (beacon_nodes_result, &self.proposer_nodes) { + // The non-proposer node call succeeded, return the result and source. + (Ok(success), _) => Ok(success), + // The non-proposer node call failed, but we don't have any proposer nodes. Return an error. + (Err(e), None) => Err(e), + // The non-proposer node call failed, try the same call on the proposer nodes. + (Err(_), Some(proposer_nodes)) => proposer_nodes.first_success(wrapper_func).await, + } + } } /// Helper to minimise `Arc` usage. @@ -300,6 +335,8 @@ impl BlockService { ) } + let current_epoch = slot.epoch(S::E::slots_per_epoch()); + for validator_pubkey in proposers { let builder_boost_factor = self .validator_store @@ -307,9 +344,15 @@ impl BlockService { let service = self.clone(); self.inner.executor.spawn( async move { - let result = service - .get_validator_block_and_publish_block(slot, validator_pubkey, builder_boost_factor) - .await; + let result = if service.chain_spec.fork_name_at_epoch(current_epoch).gloas_enabled() { + service + .get_validator_block_and_publish_block_gloas(slot, validator_pubkey, builder_boost_factor) + .await + } else { + service + .get_validator_block_and_publish_block(slot, validator_pubkey, builder_boost_factor) + .await + }; match result { Ok(_) => {} @@ -401,6 +444,79 @@ impl BlockService { Ok(()) } + #[allow(clippy::too_many_arguments)] + #[instrument(skip_all, fields(%slot, ?validator_pubkey))] + async fn sign_and_publish_block_gloas( + &self, + proposer_fallback: ProposerFallback, + slot: Slot, + graffiti: Option, + validator_pubkey: &PublicKeyBytes, + unsigned_block: &BeaconBlock, + ) -> Result<(), BlockError> { + let signing_timer = validator_metrics::start_timer(&validator_metrics::BLOCK_SIGNING_TIMES); + + let res = self + .validator_store + .sign_block_gloas(*validator_pubkey, unsigned_block, slot) + .instrument(info_span!("sign_block")) + .await; + + let signed_block = match res { + Ok(signed_block) => signed_block, + Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { + // A pubkey can be missing when a validator was recently removed + // via the API. + warn!( + info = "a validator may have recently been removed from this VC", + ?pubkey, + ?slot, + "Missing pubkey for block" + ); + return Ok(()); + } + Err(e) => { + return Err(BlockError::Recoverable(format!( + "Unable to sign block: {e:?}" + ))); + } + }; + + let signing_time_ms = + Duration::from_secs_f64(signing_timer.map_or(0.0, |t| t.stop_and_record())).as_millis(); + + info!( + slot = slot.as_u64(), + signing_time_ms = signing_time_ms, + "Publishing signed block" + ); + + // Publish block with first available beacon node. + // + // Try the proposer nodes first, since we've likely gone to efforts to + // protect them from DoS attacks and they're most likely to successfully + // publish a block. + proposer_fallback + .request_proposers_first(|beacon_node| async { + self.publish_signed_block_contents_gloas(&signed_block, beacon_node) + .await + }) + .await?; + + let metadata = BlockMetadata::from(&signed_block); + info!( + block_type = ?metadata.block_type, + deposits = metadata.num_deposits, + attestations = metadata.num_attestations, + graffiti = ?graffiti.map(|g| g.as_utf8_lossy()), + slot = metadata.slot.as_u64(), + "Successfully published block" + ); + Ok(()) + } + + // TODO(EIP-7732): Remove this sometime after gloas is live and make publish_block_gloas the default + // TODO(EIP-7732): Seems like block production testing is done through `simulator`. Discuss with team if that is sufficient for all this new gloas code. #[instrument( name = "block_proposal_duty_cycle", skip_all, @@ -549,6 +665,172 @@ impl BlockService { Ok(()) } + #[instrument( + name = "block_proposal_duty_cycle", + skip_all, + fields(%slot, ?validator_pubkey) + )] + async fn get_validator_block_and_publish_block_gloas( + self, + slot: Slot, + validator_pubkey: PublicKeyBytes, + builder_boost_factor: Option, + ) -> Result<(), BlockError> { + let timer = validator_metrics::start_timer_vec( + &validator_metrics::BLOCK_SERVICE_TIMES, + &[validator_metrics::BEACON_BLOCK], + ); + + let randao_reveal = match self + .validator_store + .randao_reveal(validator_pubkey, slot.epoch(S::E::slots_per_epoch())) + .await + { + Ok(signature) => signature.into(), + Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { + // A pubkey can be missing when a validator was recently removed + // via the API. + warn!( + info = "a validator may have recently been removed from this VC", + ?pubkey, + ?slot, + "Missing pubkey for block randao" + ); + return Ok(()); + } + Err(e) => { + return Err(BlockError::Recoverable(format!( + "Unable to produce randao reveal signature: {:?}", + e + ))); + } + }; + + let graffiti = determine_graffiti( + &validator_pubkey, + self.graffiti_file.clone(), + self.validator_store.graffiti(&validator_pubkey), + self.graffiti, + ); + + let randao_reveal_ref = &randao_reveal; + let self_ref = &self; + let proposer_index = self.validator_store.validator_index(&validator_pubkey); + let proposer_fallback = ProposerFallback { + beacon_nodes: self.beacon_nodes.clone(), + proposer_nodes: self.proposer_nodes.clone(), + }; + + info!(slot = slot.as_u64(), "Requesting unsigned block"); + + // Request an SSZ block from all beacon nodes in order, returning on the first successful response. + // If all nodes fail, run a second pass falling back to JSON. + // + // IMPORTANT: We use request_proposers_last_with_source here to track which + // beacon node provided the block. This is critical for Gloas because the + // ExecutionPayloadEnvelope must come from the same beacon node that built + // the block (only that node will have the envelope cached). + let ssz_block_response = proposer_fallback + .request_proposers_last_with_source(|beacon_node| async move { + let _get_timer = validator_metrics::start_timer_vec( + &validator_metrics::BLOCK_SERVICE_TIMES, + &[validator_metrics::BEACON_BLOCK_HTTP_GET], + ); + beacon_node + .get_validator_blocks_v4_ssz::( + slot, + randao_reveal_ref, + graffiti.as_ref(), + builder_boost_factor, + self_ref.graffiti_policy, + ) + .await + }) + .await; + + let (unsigned_block, block_source_node) = match ssz_block_response { + Ok(((ssz_block_response, _metadata), source_node)) => (ssz_block_response, source_node), + Err(e) => { + warn!( + slot = slot.as_u64(), + error = %e, + "SSZ v4 block production failed, falling back to JSON" + ); + + let ((json_block_response, _metadata), source_node) = proposer_fallback + .request_proposers_last_with_source(|beacon_node| async move { + let _get_timer = validator_metrics::start_timer_vec( + &validator_metrics::BLOCK_SERVICE_TIMES, + &[validator_metrics::BEACON_BLOCK_HTTP_GET], + ); + beacon_node + .get_validator_blocks_v4::( + slot, + randao_reveal_ref, + graffiti.as_ref(), + builder_boost_factor, + self_ref.graffiti_policy, + ) + .await + .map_err(|e| { + BlockError::Recoverable(format!( + "Error from beacon node when producing v4 block: {:?}", + e + )) + }) + }) + .await + .map_err(BlockError::from)?; + + (json_block_response.data, source_node) + } + }; + + let block_proposer = unsigned_block.proposer_index(); + + info!(slot = slot.as_u64(), "Received unsigned v4 block"); + if proposer_index != Some(block_proposer) { + return Err(BlockError::Recoverable( + "Proposer index does not match block proposer. Beacon chain re-orged".to_string(), + )); + } + + self_ref + .sign_and_publish_block_gloas( + proposer_fallback, + slot, + graffiti, + &validator_pubkey, + &unsigned_block, + ) + .await?; + + drop(timer); + + // Check if this validator is also the builder for this block. If so, publish envelope + if let (Ok(bid), Some(validator_idx)) = ( + unsigned_block.body().signed_execution_payload_bid(), + self_ref.validator_store.validator_index(&validator_pubkey), + ) { + if bid.message.builder_index == validator_idx { + info!( + validator_index = validator_idx, + builder_index = bid.message.builder_index, + "Proposer is also the builder, will publish execution payload envelope after block" + ); + self_ref + .publish_execution_payload_envelope( + slot, + validator_pubkey, + Some(block_source_node), + ) + .await?; + } + } + + Ok(()) + } + #[instrument(skip_all)] async fn publish_signed_block_contents( &self, @@ -584,6 +866,208 @@ impl BlockService { } Ok::<_, BlockError>(()) } + + #[instrument(skip_all)] + async fn publish_signed_block_contents_gloas( + &self, + signed_block: &Arc>, + beacon_node: BeaconNodeHttpClient, + ) -> Result<(), BlockError> { + let _post_timer = validator_metrics::start_timer_vec( + &validator_metrics::BLOCK_SERVICE_TIMES, + &[validator_metrics::BEACON_BLOCK_HTTP_POST], + ); + let publish_request = eth2::types::PublishBlockRequest::Block(signed_block.clone()); + beacon_node + .post_beacon_blocks_v2_ssz(&publish_request, None) + .await + .map(|_| ()) + .or_else(|e| handle_block_post_error(e, signed_block.message().slot()))?; + Ok(()) + } + + async fn publish_signed_envelope_contents( + &self, + signed_envelope: &types::SignedExecutionPayloadEnvelope, + slot: Slot, + ) -> Result<(), BlockError> { + let _post_timer = validator_metrics::start_timer_vec( + &validator_metrics::BLOCK_SERVICE_TIMES, + &[validator_metrics::EXECUTION_PAYLOAD_ENVELOPE_HTTP_POST], + ); + + let proposer_fallback = ProposerFallback { + beacon_nodes: self.beacon_nodes.clone(), + proposer_nodes: self.proposer_nodes.clone(), + }; + + // Publish envelope with first available beacon node. + // Try the proposer nodes first, since we've likely gone to efforts to + // protect them from DoS attacks and they're most likely to successfully + // publish an envelope. + let fork_name = self.chain_spec.fork_name_at_slot::(slot); + + proposer_fallback + .request_proposers_first(|beacon_node| async move { + beacon_node + .post_execution_payload_envelope_ssz(signed_envelope, fork_name) + .await + .map_err(|e| { + BlockError::Recoverable(format!( + "Error from beacon node when publishing execution payload envelope: {:?}", + e + )) + }) + }) + .await?; + + Ok(()) + } + + async fn sign_execution_payload_envelope( + &self, + envelope: &types::ExecutionPayloadEnvelope, + validator_pubkey: PublicKeyBytes, + slot: Slot, + ) -> Result, BlockError> { + let envelope_signing_timer = + validator_metrics::start_timer(&validator_metrics::ENVELOPE_SIGNING_TIMES); + + info!( + slot = slot.as_u64(), + pub_key = %validator_pubkey, + "Signing execution payload envelope using ValidatorStore" + ); + + let signed_envelope = self + .validator_store + .sign_execution_payload_envelope(validator_pubkey, envelope, slot) + .await + .map_err(|e| { + BlockError::Irrecoverable(format!( + "Failed to sign execution payload envelope: {:?}", + e + )) + })?; + + let envelope_signing_time_ms = + Duration::from_secs_f64(envelope_signing_timer.map_or(0.0, |t| t.stop_and_record())) + .as_millis(); + + info!( + slot = slot.as_u64(), + envelope_signing_time_ms = envelope_signing_time_ms, + pub_key = %validator_pubkey, + "Successfully signed execution payload envelope" + ); + + Ok(signed_envelope) + } + + async fn get_execution_payload_envelope( + &self, + slot: Slot, + validator_pubkey: &PublicKeyBytes, + source_beacon_node: Option, + ) -> Result, BlockError> { + let _timer = validator_metrics::start_timer_vec( + &validator_metrics::BLOCK_SERVICE_TIMES, + &[validator_metrics::EXECUTION_PAYLOAD_ENVELOPE_HTTP_GET], + ); + + let builder_index = match self.validator_store.validator_index(validator_pubkey) { + Some(index) => index, + None => { + return Err(BlockError::Irrecoverable(format!( + "Cannot find validator index {} for execution payload envelope", + validator_pubkey + ))); + } + }; + + // CRITICAL: We MUST use the same beacon node that provided the block. + // Only that beacon node will have the corresponding ExecutionPayloadEnvelope + // cached. If we don't have the source beacon node, this will fail. + let beacon_node = source_beacon_node.ok_or_else(|| { + BlockError::Irrecoverable( + "Cannot get execution payload envelope: no source beacon node available. \ + The ExecutionPayloadEnvelope must come from the same beacon node that built the block." + .to_string(), + ) + })?; + + info!( + slot = slot.as_u64(), + pub_key = %validator_pubkey, + "Getting execution payload envelope from same beacon node that provided the block" + ); + + // Try SSZ first, fallback to JSON if it fails + match beacon_node + .get_execution_payload_envelope_ssz::(slot, builder_index) + .await + { + Ok(envelope) => Ok(envelope), + Err(e) => { + warn!( + slot = slot.as_u64(), + error = %e, + "Beacon node does not support SSZ for execution payload envelope, falling back to JSON" + ); + + let json_response = beacon_node + .get_execution_payload_envelope::(slot, builder_index) + .await + .map_err(|e| { + BlockError::Recoverable(format!( + "Error getting execution payload envelope from source beacon node: {:?}", + e + )) + })?; + + Ok(json_response.data) + } + } + } + + async fn publish_execution_payload_envelope( + &self, + slot: Slot, + validator_pubkey: PublicKeyBytes, + source_beacon_node: Option, + ) -> Result<(), BlockError> { + let _timer = validator_metrics::start_timer_vec( + &validator_metrics::BLOCK_SERVICE_TIMES, + &[validator_metrics::EXECUTION_PAYLOAD_ENVELOPE], + ); + + info!(slot = slot.as_u64(), pub_key = %validator_pubkey, "Submitting payload envelope"); + + let envelope = self + .get_execution_payload_envelope(slot, &validator_pubkey, source_beacon_node) + .await?; + + let signed_envelope = self + .sign_execution_payload_envelope(&envelope, validator_pubkey, slot) + .await?; + + info!( + ?slot, + pub_key = %validator_pubkey, + "Successfully signed execution payload envelope" + ); + + self.publish_signed_envelope_contents(&signed_envelope, slot) + .await?; + + info!( + ?slot, + pub_key = %validator_pubkey, + "Successfully published execution payload envelope" + ); + + Ok(()) + } } /// Wrapper for values we want to log about a block we signed, for easy extraction from the possible @@ -614,6 +1098,17 @@ impl From<&SignedBlock> for BlockMetadata { } } +impl From<&Arc>> for BlockMetadata { + fn from(block: &Arc>) -> Self { + Self { + block_type: BlockType::Full, + slot: block.message().slot(), + num_deposits: block.message().body().deposits().len(), + num_attestations: block.message().body().attestations_len(), + } + } +} + fn handle_block_post_error(err: eth2::Error, slot: Slot) -> Result<(), BlockError> { // Handle non-200 success codes. if let Some(status) = err.status() { diff --git a/validator_client/validator_store/src/lib.rs b/validator_client/validator_store/src/lib.rs index 2b472799d24..9ce4354c478 100644 --- a/validator_client/validator_store/src/lib.rs +++ b/validator_client/validator_store/src/lib.rs @@ -5,10 +5,12 @@ use std::fmt::Debug; use std::future::Future; use std::sync::Arc; use types::{ - Address, Attestation, AttestationError, BlindedBeaconBlock, Epoch, EthSpec, Graffiti, Hash256, - SelectionProof, SignedAggregateAndProof, SignedBlindedBeaconBlock, SignedContributionAndProof, - SignedValidatorRegistrationData, Slot, SyncCommitteeContribution, SyncCommitteeMessage, - SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, + Address, Attestation, AttestationError, BeaconBlock, BlindedBeaconBlock, Epoch, EthSpec, + ExecutionPayloadEnvelope, Graffiti, Hash256, SelectionProof, SignedAggregateAndProof, + SignedBeaconBlock, SignedBlindedBeaconBlock, SignedContributionAndProof, + SignedExecutionPayloadEnvelope, SignedValidatorRegistrationData, Slot, + SyncCommitteeContribution, SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, + ValidatorRegistrationData, }; #[derive(Debug, PartialEq, Clone)] @@ -103,6 +105,20 @@ pub trait ValidatorStore: Send + Sync { current_slot: Slot, ) -> impl Future, Error>> + Send; + fn sign_block_gloas( + &self, + validator_pubkey: PublicKeyBytes, + block: &BeaconBlock, + current_slot: Slot, + ) -> impl Future>, Error>> + Send; + + fn sign_execution_payload_envelope( + &self, + validator_pubkey: PublicKeyBytes, + envelope: &ExecutionPayloadEnvelope, + current_slot: Slot, + ) -> impl Future, Error>> + Send; + fn sign_attestation( &self, validator_pubkey: PublicKeyBytes,