//! This crate provides an abstraction over one or more *execution engines*. An execution engine
//! was formerly known as an "eth1 node", like Geth, Nethermind, Erigon, etc.
//!
//! This crate only provides useful functionality for "The Merge", it does not provide any of the
//! deposit-contract functionality that the `beacon_node/eth1` crate already provides.
use crate::json_structures::{BlobAndProofV1, BlobAndProofV2};
use crate::payload_cache::PayloadCache;
use arc_swap::ArcSwapOption;
use auth::{Auth, JwtKey, strip_prefix};
pub use block_hash::calculate_execution_block_hash;
use builder_client::BuilderHttpClient;
pub use engine_api::EngineCapabilities;
use engine_api::Error as ApiError;
pub use engine_api::*;
pub use engine_api::{http, http::HttpJsonRpc, http::deposit_methods};
use engines::{Engine, EngineError};
pub use engines::{EngineState, ForkchoiceState};
use eth2::types::{BlobsBundle, FullPayloadContents};
use eth2::types::{ForkVersionedResponse, builder_bid::SignedBuilderBid};
use ethers_core::types::Transaction as EthersTransaction;
use fixed_bytes::UintExtended;
use fork_choice::ForkchoiceUpdateParameters;
use logging::crit;
use lru::LruCache;
pub use payload_status::PayloadStatus;
use payload_status::process_payload_status;
use sensitive_url::SensitiveUrl;
use serde::{Deserialize, Serialize};
use slot_clock::SlotClock;
use std::collections::{HashMap, hash_map::Entry};
use std::fmt;
use std::future::Future;
use std::io::Write;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use strum::AsRefStr;
use task_executor::TaskExecutor;
use tokio::{
sync::{Mutex, MutexGuard, RwLock},
time::sleep,
};
use tokio_stream::wrappers::WatchStream;
use tracing::{Instrument, debug, debug_span, error, info, instrument, warn};
use tree_hash::TreeHash;
use types::beacon_block_body::KzgCommitments;
use types::builder_bid::BuilderBid;
use types::non_zero_usize::new_non_zero_usize;
use types::payload::BlockProductionVersion;
use types::{
AbstractExecPayload, BlobsList, ExecutionPayloadDeneb, ExecutionRequests, KzgProofs,
SignedBlindedBeaconBlock,
};
use types::{
BeaconStateError, BlindedPayload, ChainSpec, Epoch, ExecPayload, ExecutionPayloadBellatrix,
ExecutionPayloadCapella, ExecutionPayloadElectra, ExecutionPayloadFulu, ExecutionPayloadGloas,
FullPayload, ProposerPreparationData, PublicKeyBytes, Signature, Slot,
};
mod block_hash;
mod engine_api;
pub mod engines;
mod keccak;
mod metrics;
pub mod payload_cache;
mod payload_status;
pub mod test_utils;
pub mod versioned_hashes;
/// Indicates the default jwt authenticated execution endpoint.
pub const DEFAULT_EXECUTION_ENDPOINT: &str = "http://localhost:8551/";
/// Name for the default file used for the jwt secret.
pub const DEFAULT_JWT_FILE: &str = "jwt.hex";
/// Each time the `ExecutionLayer` retrieves a block from an execution node, it stores that block
/// in an LRU cache to avoid redundant lookups. This is the size of that cache.
const EXECUTION_BLOCKS_LRU_CACHE_SIZE: NonZeroUsize = new_non_zero_usize(128);
/// A fee recipient address for use during block production. Only used as a very last resort if
/// there is no address provided by the user.
///
/// ## Note
///
/// This is *not* the zero-address, since Geth has been known to return errors for a coinbase of
/// 0x00..00.
const DEFAULT_SUGGESTED_FEE_RECIPIENT: [u8; 20] =
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1];
/// A payload alongside some information about where it came from.
pub enum ProvenancedPayload
{
/// A good old fashioned farm-to-table payload from your local EE.
Local(P),
/// A payload from a builder (e.g. mev-boost).
Builder(P),
}
impl TryFrom> for ProvenancedPayload> {
type Error = Error;
fn try_from(value: BuilderBid) -> Result {
let block_proposal_contents = match value {
BuilderBid::Bellatrix(builder_bid) => BlockProposalContents::Payload {
payload: ExecutionPayloadHeader::Bellatrix(builder_bid.header).into(),
block_value: builder_bid.value,
},
BuilderBid::Capella(builder_bid) => BlockProposalContents::Payload {
payload: ExecutionPayloadHeader::Capella(builder_bid.header).into(),
block_value: builder_bid.value,
},
BuilderBid::Deneb(builder_bid) => BlockProposalContents::PayloadAndBlobs {
payload: ExecutionPayloadHeader::Deneb(builder_bid.header).into(),
block_value: builder_bid.value,
kzg_commitments: builder_bid.blob_kzg_commitments,
blobs_and_proofs: None,
requests: None,
},
BuilderBid::Electra(builder_bid) => BlockProposalContents::PayloadAndBlobs {
payload: ExecutionPayloadHeader::Electra(builder_bid.header).into(),
block_value: builder_bid.value,
kzg_commitments: builder_bid.blob_kzg_commitments,
blobs_and_proofs: None,
requests: Some(builder_bid.execution_requests),
},
BuilderBid::Fulu(builder_bid) => BlockProposalContents::PayloadAndBlobs {
payload: ExecutionPayloadHeader::Fulu(builder_bid.header).into(),
block_value: builder_bid.value,
kzg_commitments: builder_bid.blob_kzg_commitments,
blobs_and_proofs: None,
requests: Some(builder_bid.execution_requests),
},
BuilderBid::Gloas(builder_bid) => BlockProposalContents::PayloadAndBlobs {
payload: ExecutionPayloadHeader::Gloas(builder_bid.header).into(),
block_value: builder_bid.value,
kzg_commitments: builder_bid.blob_kzg_commitments,
blobs_and_proofs: None,
requests: Some(builder_bid.execution_requests),
},
};
Ok(ProvenancedPayload::Builder(
BlockProposalContentsType::Blinded(block_proposal_contents),
))
}
}
#[derive(Debug)]
pub enum Error {
NoEngine,
NoPayloadBuilder,
ApiError(ApiError),
Builder(builder_client::Error),
NoHeaderFromBuilder,
CannotProduceHeader,
EngineError(Box),
NotSynced,
ShuttingDown,
FeeRecipientUnspecified,
MissingLatestValidHash,
BlockHashMismatch {
computed: ExecutionBlockHash,
payload: ExecutionBlockHash,
transactions_root: Hash256,
},
ZeroLengthTransaction,
PayloadBodiesByRangeNotSupported,
GetBlobsNotSupported,
InvalidJWTSecret(String),
InvalidForkForPayload,
InvalidPayloadBody(String),
InvalidPayloadConversion,
InvalidBlobConversion(String),
BeaconStateError(BeaconStateError),
PayloadTypeMismatch,
VerifyingVersionedHashes(versioned_hashes::Error),
}
impl From for Error {
fn from(e: BeaconStateError) -> Self {
Error::BeaconStateError(e)
}
}
impl From for Error {
fn from(e: ApiError) -> Self {
Error::ApiError(e)
}
}
impl From for Error {
fn from(e: EngineError) -> Self {
match e {
// This removes an unnecessary layer of indirection.
// TODO (mark): consider refactoring these error enums
EngineError::Api { error } => Error::ApiError(error),
_ => Error::EngineError(Box::new(e)),
}
}
}
pub enum BlockProposalContentsType {
Full(BlockProposalContents>),
Blinded(BlockProposalContents>),
}
pub enum BlockProposalContents> {
Payload {
payload: Payload,
block_value: Uint256,
},
PayloadAndBlobs {
payload: Payload,
block_value: Uint256,
kzg_commitments: KzgCommitments,
/// `None` for blinded `PayloadAndBlobs`.
blobs_and_proofs: Option<(BlobsList, KzgProofs)>,
// TODO(electra): this should probably be a separate variant/superstruct
// See: https://github.com/sigp/lighthouse/issues/6981
requests: Option>,
},
}
impl From>>
for BlockProposalContents>
{
fn from(item: BlockProposalContents>) -> Self {
match item {
BlockProposalContents::Payload {
payload,
block_value,
} => BlockProposalContents::Payload {
payload: payload.execution_payload().into(),
block_value,
},
BlockProposalContents::PayloadAndBlobs {
payload,
block_value,
kzg_commitments,
blobs_and_proofs: _,
requests,
} => BlockProposalContents::PayloadAndBlobs {
payload: payload.execution_payload().into(),
block_value,
kzg_commitments,
blobs_and_proofs: None,
requests,
},
}
}
}
impl> TryFrom>
for BlockProposalContents
{
type Error = Error;
fn try_from(response: GetPayloadResponse) -> Result {
let (execution_payload, block_value, maybe_bundle, maybe_requests) = response.into();
match maybe_bundle {
Some(bundle) => Ok(Self::PayloadAndBlobs {
payload: execution_payload.into(),
block_value,
kzg_commitments: bundle.commitments,
blobs_and_proofs: Some((bundle.blobs, bundle.proofs)),
requests: maybe_requests,
}),
None => Ok(Self::Payload {
payload: execution_payload.into(),
block_value,
}),
}
}
}
impl TryFrom> for BlockProposalContentsType {
type Error = Error;
fn try_from(response_type: GetPayloadResponseType) -> Result {
match response_type {
GetPayloadResponseType::Full(response) => Ok(Self::Full(response.try_into()?)),
GetPayloadResponseType::Blinded(response) => Ok(Self::Blinded(response.try_into()?)),
}
}
}
#[allow(clippy::type_complexity)]
impl> BlockProposalContents {
pub fn deconstruct(
self,
) -> (
Payload,
Option>,
Option<(BlobsList, KzgProofs)>,
Option>,
Uint256,
) {
match self {
Self::Payload {
payload,
block_value,
} => (payload, None, None, None, block_value),
Self::PayloadAndBlobs {
payload,
block_value,
kzg_commitments,
blobs_and_proofs,
requests,
} => (
payload,
Some(kzg_commitments),
blobs_and_proofs,
requests,
block_value,
),
}
}
pub fn payload(&self) -> &Payload {
match self {
Self::Payload { payload, .. } => payload,
Self::PayloadAndBlobs { payload, .. } => payload,
}
}
pub fn to_payload(self) -> Payload {
match self {
Self::Payload { payload, .. } => payload,
Self::PayloadAndBlobs { payload, .. } => payload,
}
}
pub fn block_value(&self) -> &Uint256 {
match self {
Self::Payload { block_value, .. } => block_value,
Self::PayloadAndBlobs { block_value, .. } => block_value,
}
}
}
// This just groups together a bunch of parameters that commonly
// get passed around together in calls to get_payload.
#[derive(Clone, Copy, Debug)]
pub struct PayloadParameters<'a> {
pub parent_hash: ExecutionBlockHash,
pub parent_gas_limit: u64,
pub proposer_gas_limit: Option,
pub payload_attributes: &'a PayloadAttributes,
pub forkchoice_update_params: &'a ForkchoiceUpdateParameters,
pub current_fork: ForkName,
}
#[derive(Clone, PartialEq)]
pub struct ProposerPreparationDataEntry {
update_epoch: Epoch,
preparation_data: ProposerPreparationData,
gas_limit: Option,
}
impl ProposerPreparationDataEntry {
pub fn update(&mut self, updated: Self) -> bool {
let mut changed = false;
// Update `gas_limit` if `updated.gas_limit` is `Some` and:
// - `self.gas_limit` is `None`, or
// - both are `Some` but the values differ.
if let Some(updated_gas_limit) = updated.gas_limit
&& self.gas_limit != Some(updated_gas_limit)
{
self.gas_limit = Some(updated_gas_limit);
changed = true;
}
// Update `update_epoch` if it differs
if self.update_epoch != updated.update_epoch {
self.update_epoch = updated.update_epoch;
changed = true;
}
// Update `preparation_data` if it differs
if self.preparation_data != updated.preparation_data {
self.preparation_data = updated.preparation_data;
changed = true;
}
changed
}
}
#[derive(Hash, PartialEq, Eq)]
pub struct ProposerKey {
slot: Slot,
head_block_root: Hash256,
}
#[derive(PartialEq, Clone)]
pub struct Proposer {
validator_index: u64,
payload_attributes: PayloadAttributes,
}
/// Information from the beacon chain that is necessary for querying the builder API.
pub struct BuilderParams {
pub pubkey: PublicKeyBytes,
pub slot: Slot,
pub chain_health: ChainHealth,
}
#[derive(PartialEq)]
pub enum ChainHealth {
Healthy,
Unhealthy(FailedCondition),
Optimistic,
PreMerge,
}
#[derive(Debug, PartialEq)]
pub enum FailedCondition {
Skips,
SkipsPerEpoch,
EpochsSinceFinalization,
}
pub enum SubmitBlindedBlockResponse {
V1(Box>),
V2,
}
type PayloadContentsRefTuple<'a, E> = (ExecutionPayloadRef<'a, E>, Option<&'a BlobsBundle>);
struct Inner {
engine: Arc,
builder: ArcSwapOption,
execution_engine_forkchoice_lock: Mutex<()>,
suggested_fee_recipient: Option,
proposer_preparation_data: Mutex>,
execution_blocks: Mutex>,
proposers: RwLock>,
executor: TaskExecutor,
payload_cache: PayloadCache,
/// Track whether the last `newPayload` call errored.
///
/// This is used *only* in the informational sync status endpoint, so that a VC using this
/// node can prefer another node with a healthier EL.
last_new_payload_errored: RwLock,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Config {
/// Endpoint url for EL nodes that are running the engine api.
pub execution_endpoint: Option,
/// Endpoint urls for services providing the builder api.
pub builder_url: Option,
/// The timeout value used when making a request to fetch a block header
/// from the builder api.
pub builder_header_timeout: Option,
/// User agent to send with requests to the builder API.
pub builder_user_agent: Option,
/// Disable ssz requests on builder. Only use json.
pub disable_builder_ssz_requests: bool,
/// JWT secret for the above endpoint running the engine api.
pub secret_file: Option,
/// The default fee recipient to use on the beacon node if none if provided from
/// the validator client during block preparation.
pub suggested_fee_recipient: Option,
/// An optional id for the beacon node that will be passed to the EL in the JWT token claim.
pub jwt_id: Option,
/// An optional client version for the beacon node that will be passed to the EL in the JWT token claim.
pub jwt_version: Option,
/// Default directory for the jwt secret if not provided through cli.
pub default_datadir: PathBuf,
pub execution_timeout_multiplier: Option,
}
/// Provides access to one execution engine and provides a neat interface for consumption by the
/// `BeaconChain`.
#[derive(Clone)]
pub struct ExecutionLayer {
inner: Arc>,
}
impl ExecutionLayer {
/// Instantiate `Self` with an Execution engine specified in `Config`, using JSON-RPC via HTTP.
pub fn from_config(config: Config, executor: TaskExecutor) -> Result {
let Config {
execution_endpoint: url,
builder_url,
builder_user_agent,
builder_header_timeout,
disable_builder_ssz_requests,
secret_file,
suggested_fee_recipient,
jwt_id,
jwt_version,
default_datadir,
execution_timeout_multiplier,
} = config;
let execution_url = url.ok_or(Error::NoEngine)?;
// Use the default jwt secret path if not provided via cli.
let secret_file = secret_file.unwrap_or_else(|| default_datadir.join(DEFAULT_JWT_FILE));
let jwt_key = if secret_file.exists() {
// Read secret from file if it already exists
std::fs::read_to_string(&secret_file)
.map_err(|e| format!("Failed to read JWT secret file. Error: {:?}", e))
.and_then(|ref s| {
let secret = JwtKey::from_slice(
&hex::decode(strip_prefix(s.trim_end()))
.map_err(|e| format!("Invalid hex string: {:?}", e))?,
)?;
Ok(secret)
})
.map_err(Error::InvalidJWTSecret)
} else {
// Create a new file and write a randomly generated secret to it if file does not exist
warn!(path = %secret_file.display(),"No JWT found on disk. Generating");
std::fs::File::options()
.write(true)
.create_new(true)
.open(&secret_file)
.map_err(|e| format!("Failed to open JWT secret file. Error: {:?}", e))
.and_then(|mut f| {
let secret = auth::JwtKey::random();
f.write_all(secret.hex_string().as_bytes())
.map_err(|e| format!("Failed to write to JWT secret file: {:?}", e))?;
Ok(secret)
})
.map_err(Error::InvalidJWTSecret)
}?;
let engine: Engine = {
let auth = Auth::new(jwt_key, jwt_id, jwt_version);
debug!(endpoint = %execution_url, jwt_path = ?secret_file.as_path(),"Loaded execution endpoint");
let api = HttpJsonRpc::new_with_auth(execution_url, auth, execution_timeout_multiplier)
.map_err(Error::ApiError)?;
Engine::new(api, executor.clone())
};
let inner = Inner {
engine: Arc::new(engine),
builder: ArcSwapOption::empty(),
execution_engine_forkchoice_lock: <_>::default(),
suggested_fee_recipient,
proposer_preparation_data: Mutex::new(HashMap::new()),
proposers: RwLock::new(HashMap::new()),
execution_blocks: Mutex::new(LruCache::new(EXECUTION_BLOCKS_LRU_CACHE_SIZE)),
executor,
payload_cache: PayloadCache::default(),
last_new_payload_errored: RwLock::new(false),
};
let el = Self {
inner: Arc::new(inner),
};
if let Some(builder_url) = builder_url {
el.set_builder_url(
builder_url,
builder_user_agent,
builder_header_timeout,
disable_builder_ssz_requests,
)?;
}
Ok(el)
}
fn engine(&self) -> &Arc {
&self.inner.engine
}
pub fn builder(&self) -> Option> {
self.inner.builder.load_full()
}
/// Set the builder URL after initialization.
///
/// This is useful for breaking circular dependencies between mock ELs and mock builders in
/// tests.
pub fn set_builder_url(
&self,
builder_url: SensitiveUrl,
builder_user_agent: Option,
builder_header_timeout: Option,
disable_ssz: bool,
) -> Result<(), Error> {
let builder_client = BuilderHttpClient::new(
builder_url.clone(),
builder_user_agent,
builder_header_timeout,
disable_ssz,
)
.map_err(Error::Builder)?;
info!(
?builder_url,
local_user_agent = builder_client.get_user_agent(),
ssz_disabled = disable_ssz,
"Using external block builder"
);
self.inner.builder.swap(Some(Arc::new(builder_client)));
Ok(())
}
/// Cache a full payload, keyed on the `tree_hash_root` of the payload
fn cache_payload(
&self,
payload_and_blobs: PayloadContentsRefTuple,
) -> Option> {
let (payload_ref, maybe_json_blobs_bundle) = payload_and_blobs;
let payload = payload_ref.clone_from_ref();
let maybe_blobs_bundle = maybe_json_blobs_bundle.cloned();
self.inner
.payload_cache
.put(FullPayloadContents::new(payload, maybe_blobs_bundle))
}
/// Attempt to retrieve a full payload from the payload cache by the payload root
pub fn get_payload_by_root(&self, root: &Hash256) -> Option> {
self.inner.payload_cache.get(root)
}
pub fn executor(&self) -> &TaskExecutor {
&self.inner.executor
}
/// Get the current difficulty of the PoW chain.
pub async fn get_current_difficulty(&self) -> Result