diff --git a/Cargo.lock b/Cargo.lock index 65ddfb19555..1bb7a2f24e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3528,9 +3528,11 @@ dependencies = [ "bls", "builder_client", "bytes", + "criterion", "eth2", "ethereum_serde_utils", "ethereum_ssz", + "ethereum_ssz_derive", "fixed_bytes", "fork_choice", "hash-db", @@ -7702,6 +7704,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", diff --git a/Cargo.toml b/Cargo.toml index c6eb154d614..bd99801bcc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -214,7 +214,7 @@ rand = "0.9.0" rand_xorshift = "0.4.0" rayon = "1.7" regex = "1" -reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "stream", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "stream", "rustls-tls", "http2"] } ring = "0.17" rpds = "0.11" rusqlite = { version = "0.38", features = ["bundled"] } diff --git a/beacon_node/beacon_chain/src/beacon_block_streamer.rs b/beacon_node/beacon_chain/src/beacon_block_streamer.rs index 7643b97a5ce..ea6c4d7cbe6 100644 --- a/beacon_node/beacon_chain/src/beacon_block_streamer.rs +++ b/beacon_node/beacon_chain/src/beacon_block_streamer.rs @@ -1,5 +1,6 @@ use crate::{BeaconChain, BeaconChainError, BeaconChainTypes, BlockProcessStatus, metrics}; use execution_layer::{ExecutionLayer, ExecutionPayloadBodyV1}; +use std::collections::HashMap; use std::sync::Arc; use store::{DatabaseBlock, ExecutionPayloadDeneb}; use tokio::sync::mpsc::{self, UnboundedSender}; @@ -27,10 +28,6 @@ pub enum Error { InvalidPayloadBodiesResponse { expected: usize, received: usize }, } -/// The engine API only requires execution layers to support payload body requests for at least -/// 32 blocks, so larger requests are split into chunks of this size. -const MAX_PAYLOAD_BODIES_PER_REQUEST: usize = 32; - /// A block loaded from the caches or database, which is either complete or requires its /// execution payload to be fetched from the execution layer. enum LoadedBlock { @@ -42,16 +39,19 @@ type BlockResult = Result>>, BeaconChainError // stores the components of a block for future re-construction in a small form struct BlockParts { + fork: ForkName, blinded_block: Box>, header: Box>, } impl BlockParts { pub fn new( + fork: ForkName, blinded: Box>, header: ExecutionPayloadHeader, ) -> Self { Self { + fork, blinded_block: blinded, header: Box::new(header), } @@ -64,6 +64,10 @@ impl BlockParts { pub fn block_hash(&self) -> ExecutionBlockHash { self.header.block_hash() } + + pub fn fork(&self) -> ForkName { + self.fork + } } fn reconstruct_default_header_block( @@ -211,7 +215,17 @@ impl BeaconBlockStreamer { &self.beacon_chain.spec, )) } else { - LoadedBlock::NeedsPayload(BlockParts::new(Box::new(block), header)) + // Carry the fork for single-fork REST body batching. + match block.fork_name(&self.beacon_chain.spec) { + Ok(fork) => LoadedBlock::NeedsPayload(BlockParts::new( + fork, + Box::new(block), + header, + )), + Err(e) => LoadedBlock::Complete(Err( + BeaconChainError::InconsistentFork(e), + )), + } } } } @@ -219,29 +233,50 @@ impl BeaconBlockStreamer { } } - /// Fetch payload bodies for `block_hashes` from the execution layer. + /// Fetch payload bodies for `blocks` (hash + fork) from the execution layer. /// - /// The returned vec has the same length and order as `block_hashes`. A body the execution - /// layer doesn't know about is `None`. + /// The returned vec has the same length and order as `blocks`. A body the execution layer + /// doesn't know about is `None`. Requests are grouped by fork so each REST `/bodies/hash` + /// request is single-fork (decodable at one `Eth-Execution-Version`); JSON-RPC ignores the + /// fork, so the grouping is a no-op there. async fn fetch_payload_bodies( &self, - block_hashes: Vec, + blocks: Vec<(ExecutionBlockHash, ForkName)>, ) -> Result>>, Error> { - let mut bodies = Vec::with_capacity(block_hashes.len()); - for chunk in block_hashes.chunks(MAX_PAYLOAD_BODIES_PER_REQUEST) { - let chunk_bodies = self - .execution_layer - .get_payload_bodies_by_hash(chunk.to_vec()) - .await - .map_err(|e| Error::BlocksByHashFailure(Box::new(e)))?; + // Result slots by original index, so the returned order matches `blocks`. + let mut bodies: Vec>> = + (0..blocks.len()).map(|_| None).collect(); + + let mut by_fork: HashMap> = HashMap::new(); + for (i, (_, fork)) in blocks.iter().enumerate() { + by_fork.entry(*fork).or_default().push(i); + } - if chunk_bodies.len() != chunk.len() { - return Err(Error::InvalidPayloadBodiesResponse { - expected: chunk.len(), - received: chunk_bodies.len(), - }); + let max_count = self + .execution_layer + .max_payload_bodies_per_request() + .await + .map_err(|e| Error::BlocksByHashFailure(Box::new(e)))?; + + for (fork, indices) in by_fork { + for chunk in indices.chunks(max_count) { + let hashes = chunk.iter().map(|&i| blocks[i].0).collect::>(); + let chunk_bodies = self + .execution_layer + .get_payload_bodies_by_hash_v1(fork, hashes) + .await + .map_err(|e| Error::BlocksByHashFailure(Box::new(e)))?; + + if chunk_bodies.len() != chunk.len() { + return Err(Error::InvalidPayloadBodiesResponse { + expected: chunk.len(), + received: chunk_bodies.len(), + }); + } + for (&i, body) in chunk.iter().zip(chunk_bodies) { + bodies[i] = body; + } } - bodies.extend(chunk_bodies); } Ok(bodies) } @@ -278,17 +313,19 @@ impl BeaconBlockStreamer { } }; - let block_hashes = loaded_blocks + let blocks = loaded_blocks .iter() .filter_map(|(_, loaded)| match loaded { - LoadedBlock::NeedsPayload(block_parts) => Some(block_parts.block_hash()), + LoadedBlock::NeedsPayload(block_parts) => { + Some((block_parts.block_hash(), block_parts.fork())) + } LoadedBlock::Complete(_) => None, }) .collect::>(); // On failure, complete blocks are still sent and each block requiring a payload gets // the (shared) error as its result, so that the stream terminates with an error // rather than looking like a complete response. - let mut bodies = match self.fetch_payload_bodies(block_hashes).await { + let mut bodies = match self.fetch_payload_bodies(blocks).await { Ok(bodies) => Ok(bodies.into_iter()), Err(e) => { let block_result: Arc> = Arc::new(Err(e.into())); diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 95996333b67..7f65e2c0498 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -6979,6 +6979,8 @@ impl BeaconChain { return Ok(()); }; + let prepare_fork = self.spec.fork_name_at_slot::(current_slot + 1); + let forkchoice_updated_response = execution_layer .notify_forkchoice_updated( head_hash, @@ -6988,6 +6990,7 @@ impl BeaconChain { head_block_root, head_payload_status, custody_columns, + prepare_fork, ) .await .map_err(Error::ExecutionForkChoiceUpdateFailed); diff --git a/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs b/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs index db4d956e3f7..a5cf68dbcc9 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs @@ -161,7 +161,7 @@ impl FetchBlobsBeaconAdapter { .get_engine_capabilities(None) .await .map_err(FetchEngineBlobError::RequestFailed) - .map(|caps| caps.get_blobs_v3) + .map(|caps| caps.get_blobs_v3()) } pub(crate) async fn supports_get_blobs_v4(&self) -> Result { @@ -175,6 +175,6 @@ impl FetchBlobsBeaconAdapter { .get_engine_capabilities(None) .await .map_err(FetchEngineBlobError::RequestFailed) - .map(|caps| caps.get_blobs_v4) + .map(|caps| caps.get_blobs_v4()) } } diff --git a/beacon_node/beacon_chain/src/graffiti_calculator.rs b/beacon_node/beacon_chain/src/graffiti_calculator.rs index 705121824c0..779b75dd9fe 100644 --- a/beacon_node/beacon_chain/src/graffiti_calculator.rs +++ b/beacon_node/beacon_chain/src/graffiti_calculator.rs @@ -256,8 +256,7 @@ mod tests { use crate::test_utils::{BeaconChainHarness, EphemeralHarnessType, test_spec}; use bls::Keypair; use eth2::types::GraffitiPolicy; - use execution_layer::EngineCapabilities; - use execution_layer::test_utils::{DEFAULT_CLIENT_VERSION, DEFAULT_ENGINE_CAPABILITIES}; + use execution_layer::test_utils::DEFAULT_CLIENT_VERSION; use std::sync::Arc; use std::sync::LazyLock; use std::time::Duration; @@ -293,12 +292,7 @@ mod tests { let harness = get_harness(VALIDATOR_COUNT, spec, None); // modify execution engine so it doesn't support engine_getClientVersionV1 method let mock_execution_layer = harness.mock_execution_layer.as_ref().unwrap(); - mock_execution_layer - .server - .set_engine_capabilities(EngineCapabilities { - get_client_version_v1: false, - ..DEFAULT_ENGINE_CAPABILITIES - }); + mock_execution_layer.server.disable_client_version(); // refresh capabilities cache harness .chain diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs index fabbd7093a0..06c703501ed 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs @@ -5,7 +5,8 @@ use execution_layer::ExecutionPayloadBodyV2; use mockall::automock; use task_executor::TaskExecutor; use types::{ - ExecutionBlockHash, ExecutionPayloadBody, Hash256, SignedExecutionPayloadEnvelopeSummary, Slot, + ExecutionBlockHash, ExecutionPayloadBody, ForkName, Hash256, + SignedExecutionPayloadEnvelopeSummary, Slot, }; use super::Error; @@ -43,6 +44,7 @@ impl EnvelopeStreamerBeaconAdapter { pub(crate) async fn get_payload_bodies_by_hash_v2( &self, + fork: ForkName, block_hashes: Vec, ) -> Result>>, BeaconChainError> { let execution_layer = self @@ -52,11 +54,28 @@ impl EnvelopeStreamerBeaconAdapter { .ok_or(BeaconChainError::ExecutionLayerMissing)?; execution_layer - .get_payload_bodies_by_hash_v2(block_hashes) + .get_payload_bodies_by_hash_v2(fork, block_hashes) .await .map_err(|error| Error::PayloadBodiesByHashV2Failure(Box::new(error)).into()) } + pub(crate) async fn max_payload_bodies_per_request(&self) -> Result { + let execution_layer = self + .chain + .execution_layer + .as_ref() + .ok_or(BeaconChainError::ExecutionLayerMissing)?; + + execution_layer + .max_payload_bodies_per_request() + .await + .map_err(|error| Error::PayloadBodiesByHashV2Failure(Box::new(error)).into()) + } + + pub(crate) fn fork_name_at_slot(&self, slot: Slot) -> ForkName { + self.chain.spec.fork_name_at_slot::(slot) + } + pub(crate) fn get_split(&self) -> (Slot, Hash256) { let split = self.chain.store.get_split_info(); (split.slot, split.block_root) diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs index 06d97531794..408d732e7ba 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs @@ -2,6 +2,7 @@ mod beacon_chain_adapter; #[cfg(test)] mod tests; +use std::collections::HashMap; use std::sync::Arc; #[cfg_attr(test, double)] @@ -14,7 +15,7 @@ use tokio::sync::mpsc::{self, UnboundedSender}; use tokio_stream::wrappers::UnboundedReceiverStream; use tracing::{debug, error, warn}; use types::{ - EthSpec, ExecutionBlockHash, ExecutionPayloadRef, ExecutionRequestsRef, Hash256, + EthSpec, ExecutionBlockHash, ExecutionPayloadRef, ExecutionRequestsRef, ForkName, Hash256, SignedExecutionPayloadEnvelope, SignedExecutionPayloadEnvelopeSummary, Slot, }; @@ -163,7 +164,10 @@ impl PayloadEnvelopeStreamer { let payload_requests = loaded_envelopes .iter() .filter_map(|(_, loaded)| match loaded { - LoadedEnvelope::NeedsPayload(summary) => Some(summary.block_hash()), + LoadedEnvelope::NeedsPayload(summary) => Some(( + summary.block_hash(), + self.adapter.fork_name_at_slot(summary.slot()), + )), LoadedEnvelope::Complete(_) => None, }) .collect::>(); @@ -196,28 +200,49 @@ impl PayloadEnvelopeStreamer { .collect()) } - /// Fetch Gloas payload bodies using `engine_getPayloadBodiesByHashV2`. + /// Fetch payload bodies for `blocks` (hash + fork) using `engine_getPayloadBodiesByHashV2` /// - /// The returned vector has the same length and order as `block_hashes`. An unknown body is - /// represented by `None`. + /// The returned vector has the same length and order as `blocks`. An unknown body is + /// represented by `None`. Requests are grouped by fork so each REST-selected engine request + /// is single-fork. async fn fetch_payload_bodies( &self, - block_hashes: Vec, + blocks: Vec<(ExecutionBlockHash, ForkName)>, ) -> Result>>, BeaconChainError> { - let mut payload_bodies = Vec::with_capacity(block_hashes.len()); - for chunk in block_hashes.chunks(MAX_PAYLOAD_BODIES_PER_REQUEST) { - let chunk_payload_bodies = self - .adapter - .get_payload_bodies_by_hash_v2(chunk.to_vec()) - .await?; - if chunk_payload_bodies.len() != chunk.len() { - return Err(Error::InvalidPayloadBodiesResponse { - expected: chunk.len(), - received: chunk_payload_bodies.len(), + if blocks.is_empty() { + return Ok(Vec::new()); + } + + // Result slots by original index, so the returned order matches `blocks`. + let mut payload_bodies: Vec>> = + vec![None; blocks.len()]; + + let mut by_fork: HashMap> = HashMap::new(); + for (i, (_, fork)) in blocks.iter().enumerate() { + by_fork.entry(*fork).or_default().push(i); + } + + let max_count = self.adapter.max_payload_bodies_per_request().await?; + + for (fork, indices) in by_fork { + for chunk in indices.chunks(max_count) { + let hashes = chunk.iter().map(|&i| blocks[i].0).collect::>(); + let chunk_payload_bodies = self + .adapter + .get_payload_bodies_by_hash_v2(fork, hashes) + .await?; + + if chunk_payload_bodies.len() != chunk.len() { + return Err(Error::InvalidPayloadBodiesResponse { + expected: chunk.len(), + received: chunk_payload_bodies.len(), + } + .into()); + } + for (&i, body) in chunk.iter().zip(chunk_payload_bodies) { + payload_bodies[i] = body; } - .into()); } - payload_bodies.extend(chunk_payload_bodies); } Ok(payload_bodies) } @@ -272,9 +297,6 @@ pub fn launch_payload_envelope_stream( PayloadEnvelopeStreamer::new(adapter, request_source).launch_stream(block_roots) } -/// The Engine API only guarantees support for 32 hashes per payload-body request. -const MAX_PAYLOAD_BODIES_PER_REQUEST: usize = 32; - fn reconstruct_envelope_from_body( summary: SignedExecutionPayloadEnvelopeSummary, payload_body: ExecutionPayloadBodyV2, diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs index 380d5389302..de6aed4fffa 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs @@ -10,13 +10,15 @@ use task_executor::test_utils::TestRuntime; use types::{ BlockAccessList, ExecutionBlockHash, ExecutionPayloadBody, ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionPayloadRef, ExecutionRequestsGloas, ExecutionRequestsRef, - Hash256, MinimalEthSpec, SignedExecutionPayloadEnvelope, SignedExecutionPayloadEnvelopeSummary, - Slot, + ForkName, Hash256, MinimalEthSpec, SignedExecutionPayloadEnvelope, + SignedExecutionPayloadEnvelopeSummary, Slot, }; type E = MinimalEthSpec; type T = EphemeralHarnessType; +const MAX_PAYLOAD_BODIES_PER_REQUEST: usize = 32; + struct SlotEntry { block_root: Hash256, slot: Slot, @@ -167,6 +169,15 @@ fn mock_canonical_head(mock: &mut MockEnvelopeStreamerBeaconAdapter, chain: & .returning(move |root| Ok(!non_canonical.contains(root))); } +/// Expectations for tests that trigger an EL payload-body fetch: the advertised batch limit and the +/// per-slot fork lookup (all test envelopes are Gloas). +fn mock_fetch_capabilities(mock: &mut MockEnvelopeStreamerBeaconAdapter) { + mock.expect_max_payload_bodies_per_request() + .returning(|| Ok(MAX_PAYLOAD_BODIES_PER_REQUEST)); + mock.expect_fork_name_at_slot() + .return_const(ForkName::Gloas); +} + fn unwrap_result( result: &Arc>, ) -> &Option>> { @@ -338,9 +349,10 @@ async fn stream_reconstructs_pruned_envelopes() { (payload.block_hash, payload_body(&payload)) }) .collect(); + mock_fetch_capabilities(&mut mock); mock.expect_get_payload_bodies_by_hash_v2() .times(1) - .returning(move |block_hashes| { + .returning(move |_fork, block_hashes| { Ok(block_hashes .into_iter() .map(|block_hash| payload_bodies.get(&block_hash).cloned()) @@ -371,9 +383,10 @@ async fn stream_batches_pruned_envelope_requests() { (payload.block_hash, payload_body(&payload)) }) .collect(); + mock_fetch_capabilities(&mut mock); mock.expect_get_payload_bodies_by_hash_v2() .times(3) - .returning(move |block_hashes| { + .returning(move |_fork, block_hashes| { assert!(block_hashes.len() <= MAX_PAYLOAD_BODIES_PER_REQUEST); Ok(block_hashes .into_iter() @@ -395,9 +408,10 @@ async fn stream_rejects_invalid_payload_body_response_length() { .return_const((Slot::new(2), Hash256::ZERO)); mock_envelopes_with_pruned_payloads(&mut mock, &chain, &[1]); mock.expect_block_has_canonical_payload().times(0); + mock_fetch_capabilities(&mut mock); mock.expect_get_payload_bodies_by_hash_v2() .times(1) - .returning(|_| Ok(vec![])); + .returning(|_, _| Ok(vec![])); let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); let mut stream = streamer.launch_stream(roots(&chain)); @@ -423,9 +437,10 @@ async fn stream_handles_payload_missing_from_execution_layer() { .return_const((Slot::new(2), Hash256::ZERO)); mock_envelopes_with_pruned_payloads(&mut mock, &chain, &[1]); mock.expect_block_has_canonical_payload().times(0); + mock_fetch_capabilities(&mut mock); mock.expect_get_payload_bodies_by_hash_v2() .times(1) - .returning(|block_hashes| Ok(vec![None; block_hashes.len()])); + .returning(|_, block_hashes| Ok(vec![None; block_hashes.len()])); let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); let mut stream = streamer.launch_stream(roots(&chain)); @@ -452,9 +467,10 @@ async fn stream_rejects_payload_body_with_wrong_hash() { let mut wrong_body = payload_body(&chain[0].envelope.as_ref().unwrap().message.payload); wrong_body.block_access_list = Some(BlockAccessList::new(vec![1]).expect("valid block access list")); + mock_fetch_capabilities(&mut mock); mock.expect_get_payload_bodies_by_hash_v2() .times(1) - .return_once(move |_| Ok(vec![Some(wrong_body)])); + .return_once(move |_, _| Ok(vec![Some(wrong_body)])); let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); let mut stream = streamer.launch_stream(roots(&chain)); @@ -480,9 +496,10 @@ async fn stream_rejects_payload_body_without_withdrawals() { let mut body = payload_body(&chain[0].envelope.as_ref().unwrap().message.payload); body.withdrawals = None; + mock_fetch_capabilities(&mut mock); mock.expect_get_payload_bodies_by_hash_v2() .times(1) - .return_once(move |_| Ok(vec![Some(body)])); + .return_once(move |_, _| Ok(vec![Some(body)])); let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); let mut stream = streamer.launch_stream(roots(&chain)); @@ -508,9 +525,10 @@ async fn stream_rejects_payload_body_without_block_access_list() { let mut body = payload_body(&chain[0].envelope.as_ref().unwrap().message.payload); body.block_access_list = None; + mock_fetch_capabilities(&mut mock); mock.expect_get_payload_bodies_by_hash_v2() .times(1) - .return_once(move |_| Ok(vec![Some(body)])); + .return_once(move |_, _| Ok(vec![Some(body)])); let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); let mut stream = streamer.launch_stream(roots(&chain)); diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 92c53c87b72..7eacbeb759e 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -697,6 +697,12 @@ where let chain = builder.build().expect("should build"); + if execution_layer::test_utils::mock_rest_ssz_enabled() + && let Some(mock) = &self.mock_execution_layer + { + mock.el.spawn_watchdog_routine(chain.slot_clock.clone()); + } + BeaconChainHarness { spec: chain.spec.clone(), chain: Arc::new(chain), diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index c8dfefffca9..8f8f65d7069 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -10,10 +10,7 @@ use beacon_chain::{ canonical_head::CachedHead, test_utils::{BeaconChainHarness, EphemeralHarnessType, fork_name_from_env, test_spec}, }; -use execution_layer::{ - ExecutionLayer, ForkchoiceState, PayloadAttributes, - json_structures::{JsonForkchoiceStateV1, JsonPayloadAttributes, JsonPayloadAttributesV1}, -}; +use execution_layer::{ExecutionLayer, ForkchoiceState, PayloadAttributes}; use fork_choice::{Error as ForkChoiceError, InvalidationOperation, PayloadVerificationStatus}; use proto_array::{Error as ProtoArrayError, ExecutionStatus, ExecutionVerdict}; use slot_clock::SlotClock; @@ -126,24 +123,22 @@ impl InvalidPayloadRig { fn previous_forkchoice_update_params(&self) -> (ForkchoiceState, PayloadAttributes) { let mock_execution_layer = self.harness.mock_execution_layer.as_ref().unwrap(); - let json = mock_execution_layer + let (state, attributes) = mock_execution_layer .server - .take_previous_request() - .expect("no previous request"); - let params = json.get("params").expect("no params"); - - let fork_choice_state_json = params.get(0).expect("no payload param"); - let fork_choice_state: JsonForkchoiceStateV1 = - serde_json::from_value(fork_choice_state_json.clone()).unwrap(); - - let payload_param_json = params.get(1).expect("no payload param"); - let attributes: JsonPayloadAttributesV1 = - serde_json::from_value(payload_param_json.clone()).unwrap(); - - ( - fork_choice_state.into(), - JsonPayloadAttributes::V1(attributes).into(), - ) + .take_previous_forkchoice_request() + .expect("no previous forkchoice request"); + let attributes = attributes.expect("no payload attributes"); + let attributes = PayloadAttributes::new( + attributes.timestamp(), + attributes.prev_randao(), + attributes.suggested_fee_recipient(), + None, + None, + None, + None, + None, + ); + (state, attributes) } fn previous_payload_attributes(&self) -> PayloadAttributes { diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index 77c2db0e7cd..a10c30250db 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -12,8 +12,6 @@ use beacon_chain::{ }; use bls::{Keypair, Signature}; use eth2::types::{GraffitiPolicy, ProposerPreparationData}; -use execution_layer::http::{ENGINE_FORKCHOICE_UPDATED_V4, ENGINE_FORKCHOICE_UPDATED_V5}; -use execution_layer::json_structures::{JsonPayloadAttributesV4, JsonPayloadAttributesV5}; use execution_layer::{DEFAULT_GAS_LIMIT, PayloadAttributes}; use fork_choice::PayloadStatus; use logging::create_test_tracing_subscriber; @@ -896,32 +894,27 @@ async fn prepare_payload_around_heze_boundary(prepare_slot: Slot, heze_fork_epoc .await .unwrap(); - // Inspect the fcU request received by the mock execution layer - let request = harness + // Inspect the fcU request received by the mock execution layer. Captured by the shared mock + // core, so this covers both JSON-RPC and REST-SSZ. + let (_state, attributes) = harness .mock_execution_layer .as_ref() .unwrap() .server - .take_previous_request() - .expect("no previous request"); - let method = request.get("method").expect("no method"); - let params = request.get("params").expect("no params"); - let payload_attributes_json = params.get(1).expect("no payload attributes param"); + .take_previous_forkchoice_request() + .expect("no previous forkchoice request"); + let attributes = attributes.expect("no payload attributes"); if prepare_slot_is_heze { - assert_eq!(method, ENGINE_FORKCHOICE_UPDATED_V5); - let attributes: JsonPayloadAttributesV5 = - serde_json::from_value(payload_attributes_json.clone()).unwrap(); + let PayloadAttributes::V5(attributes) = &attributes else { + panic!("expected V5 payload attributes at heze, got {attributes:?}"); + }; // We are currently sending the V5 shape with an empty inclusion list assert!(attributes.inclusion_list_transactions.is_empty()); } else { - assert_eq!(method, ENGINE_FORKCHOICE_UPDATED_V4); - let _attributes: JsonPayloadAttributesV4 = - serde_json::from_value(payload_attributes_json.clone()).unwrap(); assert!( - payload_attributes_json - .get("inclusionListTransactions") - .is_none() + matches!(attributes, PayloadAttributes::V4(_)), + "expected V4 payload attributes before heze, got {attributes:?}" ); } diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index cde2372d649..b6815315b04 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -4148,6 +4148,7 @@ fn assert_anchor_data_restored( ); } +#[allow(clippy::large_stack_frames)] async fn weak_subjectivity_sync_test( slots: Vec, checkpoint_slot: Slot, diff --git a/beacon_node/client/src/notifier.rs b/beacon_node/client/src/notifier.rs index df3d6a78051..fcf644d5e4c 100644 --- a/beacon_node/client/src/notifier.rs +++ b/beacon_node/client/src/notifier.rs @@ -3,14 +3,7 @@ use beacon_chain::{ BeaconChain, BeaconChainTypes, ExecutionVerdict, bellatrix_readiness::GenesisExecutionPayloadStatus, }; -use execution_layer::{ - EngineCapabilities, - http::{ - ENGINE_FORKCHOICE_UPDATED_V2, ENGINE_FORKCHOICE_UPDATED_V3, ENGINE_GET_PAYLOAD_V2, - ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, ENGINE_GET_PAYLOAD_V5, ENGINE_GET_PAYLOAD_V6, - ENGINE_NEW_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_V3, ENGINE_NEW_PAYLOAD_V4, ENGINE_NEW_PAYLOAD_V5, - }, -}; +use execution_layer::EngineCapabilities; use lighthouse_network::{NetworkGlobals, types::SyncState}; use logging::crit; use slot_clock::SlotClock; @@ -446,7 +439,7 @@ async fn post_bellatrix_readiness_logging( { Err(e) => Err(format!("Exchange capabilities failed: {e:?}")), Ok(capabilities) => { - let missing_methods = methods_required_for_fork(fork, capabilities); + let missing_methods = methods_required_for_fork(fork, &capabilities); if missing_methods.is_empty() { Ok(()) } else { @@ -515,9 +508,10 @@ fn find_next_fork_to_prepare( fn methods_required_for_fork( fork: ForkName, - capabilities: EngineCapabilities, + capabilities: &EngineCapabilities, ) -> Vec<&'static str> { let mut missing_methods = vec![]; + match fork { ForkName::Base | ForkName::Altair | ForkName::Bellatrix => { warn!( @@ -525,50 +519,15 @@ fn methods_required_for_fork( "Invalid methods_required_for_fork call" ); } - ForkName::Capella => { - if !capabilities.get_payload_v2 { - missing_methods.push(ENGINE_GET_PAYLOAD_V2); - } - if !capabilities.forkchoice_updated_v2 { - missing_methods.push(ENGINE_FORKCHOICE_UPDATED_V2); - } - if !capabilities.new_payload_v2 { - missing_methods.push(ENGINE_NEW_PAYLOAD_V2); - } - } - ForkName::Deneb => { - if !capabilities.get_payload_v3 { - missing_methods.push(ENGINE_GET_PAYLOAD_V3); - } - if !capabilities.forkchoice_updated_v3 { - missing_methods.push(ENGINE_FORKCHOICE_UPDATED_V3); - } - if !capabilities.new_payload_v3 { - missing_methods.push(ENGINE_NEW_PAYLOAD_V3); + fork => { + if !capabilities.get_payload(fork) { + missing_methods.push("getPayload") } - } - ForkName::Electra => { - if !capabilities.get_payload_v4 { - missing_methods.push(ENGINE_GET_PAYLOAD_V4); - } - if !capabilities.new_payload_v4 { - missing_methods.push(ENGINE_NEW_PAYLOAD_V4); - } - } - ForkName::Fulu => { - if !capabilities.get_payload_v5 { - missing_methods.push(ENGINE_GET_PAYLOAD_V5); - } - if !capabilities.new_payload_v4 { - missing_methods.push(ENGINE_NEW_PAYLOAD_V4); - } - } - ForkName::Gloas | ForkName::Heze => { - if !capabilities.get_payload_v6 { - missing_methods.push(ENGINE_GET_PAYLOAD_V6); + if !capabilities.forkchoice_updated(fork) { + missing_methods.push("forkchoice_update"); } - if !capabilities.new_payload_v5 { - missing_methods.push(ENGINE_NEW_PAYLOAD_V5); + if !capabilities.new_payload(fork) { + missing_methods.push("newPayload"); } } } diff --git a/beacon_node/execution_layer/Cargo.toml b/beacon_node/execution_layer/Cargo.toml index a04e065aa9f..53ce69e8b45 100644 --- a/beacon_node/execution_layer/Cargo.toml +++ b/beacon_node/execution_layer/Cargo.toml @@ -16,6 +16,7 @@ bytes = { workspace = true } eth2 = { workspace = true, features = ["events", "lighthouse", "network"] } ethereum_serde_utils = { workspace = true } ethereum_ssz = { workspace = true } +ethereum_ssz_derive = { workspace = true } fixed_bytes = { workspace = true } fork_choice = { workspace = true } hash-db = "0.15.2" @@ -56,3 +57,10 @@ typenum = { workspace = true } types = { workspace = true } warp = { workspace = true } zeroize = { workspace = true } + +[dev-dependencies] +criterion = { workspace = true } + +[[bench]] +name = "engine_codec" +harness = false diff --git a/beacon_node/execution_layer/benches/engine_codec.rs b/beacon_node/execution_layer/benches/engine_codec.rs new file mode 100644 index 00000000000..8183325a25f --- /dev/null +++ b/beacon_node/execution_layer/benches/engine_codec.rs @@ -0,0 +1,217 @@ +//! Engine REST/SSZ vs JSON-RPC codec microbenchmarks (Paris -> Osaka). +//! +//! Measures the pure serde cost (encode/decode) of the engine API wire formats, +//! isolated from HTTP, auth, EL execution and network. +#![allow(clippy::unwrap_used)] + +#[path = "fixtures.rs"] +mod fixtures; + +use criterion::{Criterion, SamplingMode, Throughput, criterion_group, criterion_main}; +use execution_layer::json_structures::*; +use execution_layer::ssz_structures::*; +use fixtures::*; +use ssz::{Decode, Encode}; +use std::hint::black_box; +use types::ForkName; + +macro_rules! transport_group { + ($c:expr, $name:expr, $ssz:expr, $ssz_decode:expr, $json:expr, $json_ty:ty) => {{ + let ssz = $ssz; + let json = $json; + + let ssz_bytes = ssz.as_ssz_bytes(); + let json_bytes = serde_json::to_vec(&json).unwrap(); + + eprintln!( + "{}: ssz={} json={} ({:.0}% of json)", + $name, + ssz_bytes.len(), + json_bytes.len(), + 100.0 * ssz_bytes.len() as f64 / json_bytes.len() as f64, + ); + + let mut group = $c.benchmark_group($name); + group.sampling_mode(SamplingMode::Flat); + + group.throughput(Throughput::Bytes(ssz_bytes.len() as u64)); + group.bench_function("ssz_encode", |b| b.iter(|| black_box(ssz.as_ssz_bytes()))); + group.bench_function("ssz_decode", |b| { + b.iter(|| black_box($ssz_decode(&ssz_bytes))) + }); + + group.throughput(Throughput::Bytes(json_bytes.len() as u64)); + group.bench_function("json_encode", |b| { + b.iter(|| black_box(serde_json::to_vec(&json).unwrap())) + }); + group.bench_function("json_decode_via_value", |b| { + b.iter(|| { + let value: serde_json::Value = serde_json::from_slice(&json_bytes).unwrap(); + black_box(serde_json::from_value::<$json_ty>(value).unwrap()) + }) + }); + + group.finish(); + }}; +} + +fn bench_new_payload(c: &mut Criterion) { + let fixture = bellatrix_new_payload(DEFAULT_TX_COUNT); + transport_group!( + c, + "newPayload_bellatrix", + fixture.ssz, + |b: &[u8]| SszExecutionPayloadEnvelopeBellatrix::::from_ssz_bytes(b).unwrap(), + fixture.json, + JsonExecutionPayloadBellatrix + ); + + let fixture = capella_new_payload(DEFAULT_TX_COUNT); + transport_group!( + c, + "newPayload_capella", + fixture.ssz, + |b: &[u8]| SszExecutionPayloadEnvelopeCapella::::from_ssz_bytes(b).unwrap(), + fixture.json, + JsonExecutionPayloadCapella + ); + + let fixture = deneb_new_payload(DEFAULT_TX_COUNT); + transport_group!( + c, + "newPayload_deneb", + fixture.ssz, + |b: &[u8]| SszExecutionPayloadEnvelopeDeneb::::from_ssz_bytes(b).unwrap(), + fixture.json, + JsonExecutionPayloadDeneb + ); + + let fixture = electra_new_payload(DEFAULT_TX_COUNT); + transport_group!( + c, + "newPayload_electra", + fixture.ssz, + |b: &[u8]| SszExecutionPayloadEnvelopeElectra::::from_ssz_bytes(b).unwrap(), + fixture.json, + JsonExecutionPayloadElectra + ); + + let fixture = fulu_new_payload(DEFAULT_TX_COUNT); + transport_group!( + c, + "newPayload_fulu", + fixture.ssz, + |b: &[u8]| SszExecutionPayloadEnvelopeFulu::::from_ssz_bytes(b).unwrap(), + fixture.json, + JsonExecutionPayloadFulu + ); +} + +fn bench_get_payload(c: &mut Criterion) { + let fixture = bellatrix_get_payload(DEFAULT_TX_COUNT); + transport_group!( + c, + "getPayload_bellatrix", + fixture.ssz, + |b: &[u8]| SszGetPayloadResponse::::from_ssz_bytes_by_fork(b, ForkName::Bellatrix) + .unwrap(), + fixture.json, + JsonGetPayloadResponseBellatrix + ); + + let fixture = capella_get_payload(DEFAULT_TX_COUNT); + transport_group!( + c, + "getPayload_capella", + fixture.ssz, + |b: &[u8]| SszGetPayloadResponse::::from_ssz_bytes_by_fork(b, ForkName::Capella) + .unwrap(), + fixture.json, + JsonGetPayloadResponseCapella + ); + + let fixture = deneb_get_payload(DEFAULT_TX_COUNT, DEFAULT_BUNDLE_BLOB_COUNT); + transport_group!( + c, + "getPayload_deneb", + fixture.ssz, + |b: &[u8]| SszGetPayloadResponse::::from_ssz_bytes_by_fork(b, ForkName::Deneb).unwrap(), + fixture.json, + JsonGetPayloadResponseDeneb + ); + + let fixture = electra_get_payload(DEFAULT_TX_COUNT, DEFAULT_BUNDLE_BLOB_COUNT); + transport_group!( + c, + "getPayload_electra", + fixture.ssz, + |b: &[u8]| SszGetPayloadResponse::::from_ssz_bytes_by_fork(b, ForkName::Electra) + .unwrap(), + fixture.json, + JsonGetPayloadResponseElectra + ); + + for &n in &TX_COUNT_SWEEP { + let fixture = fulu_get_payload(n, DEFAULT_BUNDLE_BLOB_COUNT); + transport_group!( + c, + format!("getPayload_fulu_{n}txs"), + fixture.ssz, + |b: &[u8]| SszGetPayloadResponse::::from_ssz_bytes_by_fork(b, ForkName::Fulu) + .unwrap(), + fixture.json, + JsonGetPayloadResponseFulu + ); + } +} + +fn bench_get_blobs(c: &mut Criterion) { + let fixture = fulu_blobs(DEFAULT_BUNDLE_BLOB_COUNT); + transport_group!( + c, + "getBlobs_v2_fulu", + fixture.ssz.clone(), + |b: &[u8]| SszBlobsResponse::::from_ssz_bytes(b).unwrap(), + fixture.json_v2, + Vec> + ); + transport_group!( + c, + "getBlobs_v3_fulu", + fixture.ssz, + |b: &[u8]| SszBlobsResponse::::from_ssz_bytes(b).unwrap(), + fixture.json_v3, + Vec> + ); +} + +fn bench_get_payload_bodies(c: &mut Criterion) { + let fixture = bellatrix_bodies(DEFAULT_BODIES_COUNT, DEFAULT_TX_COUNT); + transport_group!( + c, + "getPayloadBodies_bellatrix", + fixture.ssz, + |b: &[u8]| SszBodiesResponse::::from_ssz_bytes_by_fork(b, ForkName::Bellatrix).unwrap(), + fixture.json, + Vec>> + ); + + let fixture = fulu_bodies(DEFAULT_BODIES_COUNT, DEFAULT_TX_COUNT); + transport_group!( + c, + "getPayloadBodies_fulu", + fixture.ssz, + |b: &[u8]| SszBodiesResponse::::from_ssz_bytes_by_fork(b, ForkName::Fulu).unwrap(), + fixture.json, + Vec>> + ); +} + +criterion_group!( + benches, + bench_new_payload, + bench_get_payload, + bench_get_blobs, + bench_get_payload_bodies +); +criterion_main!(benches); diff --git a/beacon_node/execution_layer/benches/fixtures.rs b/beacon_node/execution_layer/benches/fixtures.rs new file mode 100644 index 00000000000..365788e5c53 --- /dev/null +++ b/beacon_node/execution_layer/benches/fixtures.rs @@ -0,0 +1,369 @@ +//! Deterministic fixtures for the engine-codec benchmarks, assembled from the +//! reused mock-EL generators in `execution_layer::test_utils`. + +use execution_layer::json_structures::*; +use execution_layer::ssz_structures::*; +use execution_layer::test_utils::*; +use execution_layer::*; +use ssz_types::VariableList; +use types::{ + EthSpec, ExecutionPayloadBellatrix, ExecutionPayloadCapella, ExecutionPayloadDeneb, + ExecutionPayloadElectra, ExecutionPayloadFulu, ExecutionRequestsElectra, ForkName, Hash256, + MainnetEthSpec, Transactions, Uint256, +}; + +/// EthSpec used across the benchmarks. +pub type E = MainnetEthSpec; + +/// Default transaction count — a small-to-mid mainnet block. +pub const DEFAULT_TX_COUNT: usize = 150; +/// Transaction-count scaling sweep: near-empty, small-mid, busy block. +pub const TX_COUNT_SWEEP: [usize; 3] = [10, 150, 500]; +/// Blobs per bundle (get_blobs / get_payload bundle); decoupled from tx count. +pub const DEFAULT_BUNDLE_BLOB_COUNT: usize = 6; +/// Bodies per getPayloadBodies response. +pub const DEFAULT_BODIES_COUNT: usize = 32; + +/// `n_txs` copies of a real, valid transaction. +fn transactions(n_txs: usize) -> Transactions { + let tx = static_valid_tx::().expect("static valid tx"); + vec![tx; n_txs].try_into().expect("transactions within cap") +} + +pub fn bellatrix_execution_payload(n_txs: usize) -> ExecutionPayloadBellatrix { + ExecutionPayloadBellatrix { + transactions: transactions(n_txs), + ..Default::default() + } +} + +pub fn capella_execution_payload(n_txs: usize) -> ExecutionPayloadCapella { + ExecutionPayloadCapella { + transactions: transactions(n_txs), + ..Default::default() + } +} + +pub fn deneb_execution_payload(n_txs: usize) -> ExecutionPayloadDeneb { + ExecutionPayloadDeneb { + transactions: transactions(n_txs), + ..Default::default() + } +} + +pub fn electra_execution_payload(n_txs: usize) -> ExecutionPayloadElectra { + ExecutionPayloadElectra { + transactions: transactions(n_txs), + ..Default::default() + } +} + +/// A Fulu execution payload carrying `n_txs` real transactions. +pub fn fulu_execution_payload(n_txs: usize) -> ExecutionPayloadFulu { + ExecutionPayloadFulu { + transactions: transactions(n_txs), + ..Default::default() + } +} + +/// Paired SSZ/JSON wire objects for the Fulu `new_payload` request (encode-focus). +/// +/// SSZ is the REST envelope (payload + parent root + requests); JSON is the payload the JSON-RPC +/// params array carries. Both are built from one source payload. +pub struct NewPayloadFixture { + pub ssz: S, + pub json: J, +} + +pub fn fulu_new_payload( + n_txs: usize, +) -> NewPayloadFixture, JsonExecutionPayloadFulu> { + let payload = fulu_execution_payload(n_txs); + let requests = ExecutionRequestsElectra::::default(); + + let json = payload.clone().try_into().expect("json payload"); + + let request = NewPayloadRequestFulu { + execution_payload: &payload, + versioned_hashes: vec![], + parent_beacon_block_root: Hash256::default(), + execution_requests: &requests, + }; + let ssz = SszExecutionPayloadEnvelopeFulu::try_from(request).expect("ssz envelope"); + + NewPayloadFixture { ssz, json } +} + +pub fn bellatrix_new_payload( + n_txs: usize, +) -> NewPayloadFixture, JsonExecutionPayloadBellatrix> { + let payload = bellatrix_execution_payload(n_txs); + + let json = payload.clone().into(); + + let request = NewPayloadRequestBellatrix { + execution_payload: &payload, + }; + let ssz = SszExecutionPayloadEnvelopeBellatrix::from(request); + + NewPayloadFixture { ssz, json } +} + +pub fn capella_new_payload( + n_txs: usize, +) -> NewPayloadFixture, JsonExecutionPayloadCapella> { + let payload = capella_execution_payload(n_txs); + + let json = payload.clone().try_into().expect("json payload"); + + let request = NewPayloadRequestCapella { + execution_payload: &payload, + }; + let ssz = SszExecutionPayloadEnvelopeCapella::from(request); + + NewPayloadFixture { ssz, json } +} + +pub fn deneb_new_payload( + n_txs: usize, +) -> NewPayloadFixture, JsonExecutionPayloadDeneb> { + let payload = deneb_execution_payload(n_txs); + + let json = payload.clone().try_into().expect("json payload"); + + let request = NewPayloadRequestDeneb { + execution_payload: &payload, + versioned_hashes: vec![], + parent_beacon_block_root: Hash256::default(), + }; + let ssz = SszExecutionPayloadEnvelopeDeneb::from(request); + + NewPayloadFixture { ssz, json } +} + +pub fn electra_new_payload( + n_txs: usize, +) -> NewPayloadFixture, JsonExecutionPayloadElectra> { + let payload = electra_execution_payload(n_txs); + let requests = ExecutionRequestsElectra::::default(); + + let json = payload.clone().try_into().expect("json payload"); + + let request = NewPayloadRequestElectra { + execution_payload: &payload, + versioned_hashes: vec![], + parent_beacon_block_root: Hash256::default(), + execution_requests: &requests, + }; + let ssz = SszExecutionPayloadEnvelopeElectra::try_from(request).expect("ssz envelope"); + + NewPayloadFixture { ssz, json } +} + +pub struct GetPayloadFixture { + pub ssz: SszGetPayloadResponse, + pub json: J, +} + +pub fn fulu_get_payload( + n_txs: usize, + n_blobs: usize, +) -> GetPayloadFixture> { + let (blobs_bundle, _) = generate_blobs::(n_blobs, ForkName::Fulu).expect("blobs bundle"); + + let response = GetPayloadResponse::Fulu(GetPayloadResponseFulu { + execution_payload: fulu_execution_payload(n_txs), + block_value: Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI), + blobs_bundle, + should_override_builder: false, + requests: ExecutionRequestsElectra::::default(), + }); + + let ssz = SszGetPayloadResponse::try_from(response.clone()).expect("ssz get_payload"); + let JsonGetPayloadResponse::Fulu(json) = + JsonGetPayloadResponse::try_from(response).expect("json get_payload") + else { + unreachable!("fulu response yields fulu json") + }; + + GetPayloadFixture { ssz, json } +} + +pub fn bellatrix_get_payload( + n_txs: usize, +) -> GetPayloadFixture> { + let response = GetPayloadResponse::Bellatrix(GetPayloadResponseBellatrix { + execution_payload: bellatrix_execution_payload(n_txs), + block_value: Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI), + }); + + let ssz = SszGetPayloadResponse::try_from(response.clone()).expect("ssz get_payload"); + let JsonGetPayloadResponse::Bellatrix(json) = + JsonGetPayloadResponse::try_from(response).expect("json get_payload") + else { + unreachable!("bellatrix response yields bellatrix json") + }; + + GetPayloadFixture { ssz, json } +} + +pub fn capella_get_payload(n_txs: usize) -> GetPayloadFixture> { + let response = GetPayloadResponse::Capella(GetPayloadResponseCapella { + execution_payload: capella_execution_payload(n_txs), + block_value: Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI), + }); + + let ssz = SszGetPayloadResponse::try_from(response.clone()).expect("ssz get_payload"); + let JsonGetPayloadResponse::Capella(json) = + JsonGetPayloadResponse::try_from(response).expect("json get_payload") + else { + unreachable!("capella response yields capella json") + }; + + GetPayloadFixture { ssz, json } +} + +pub fn deneb_get_payload( + n_txs: usize, + n_blobs: usize, +) -> GetPayloadFixture> { + let (blobs_bundle, _) = generate_blobs::(n_blobs, ForkName::Deneb).expect("blobs bundle"); + + let response = GetPayloadResponse::Deneb(GetPayloadResponseDeneb { + execution_payload: deneb_execution_payload(n_txs), + block_value: Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI), + blobs_bundle, + should_override_builder: false, + }); + + let ssz = SszGetPayloadResponse::try_from(response.clone()).expect("ssz get_payload"); + let JsonGetPayloadResponse::Deneb(json) = + JsonGetPayloadResponse::try_from(response).expect("json get_payload") + else { + unreachable!("deneb response yields deneb json") + }; + + GetPayloadFixture { ssz, json } +} + +pub fn electra_get_payload( + n_txs: usize, + n_blobs: usize, +) -> GetPayloadFixture> { + let (blobs_bundle, _) = generate_blobs::(n_blobs, ForkName::Electra).expect("blobs bundle"); + + let response = GetPayloadResponse::Electra(GetPayloadResponseElectra { + execution_payload: electra_execution_payload(n_txs), + block_value: Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI), + blobs_bundle, + should_override_builder: false, + requests: ExecutionRequestsElectra::::default(), + }); + + let ssz = SszGetPayloadResponse::try_from(response.clone()).expect("ssz get_payload"); + let JsonGetPayloadResponse::Electra(json) = + JsonGetPayloadResponse::try_from(response).expect("json get_payload") + else { + unreachable!("electra response yields electra json") + }; + + GetPayloadFixture { ssz, json } +} + +pub struct BlobsFixture { + pub ssz: SszBlobsResponseV2, + pub json_v2: Vec>, + pub json_v3: Vec>, +} + +pub fn fulu_blobs(n_blobs: usize) -> BlobsFixture { + let (bundle, _) = generate_blobs::(n_blobs, ForkName::Fulu).expect("blobs bundle"); + let cells = E::cells_per_ext_blob(); + + let contents: Vec> = (0..n_blobs) + .map(|i| { + let blob = bundle.blobs.get(i).expect("blob present").clone(); + let start = i * cells; + let proofs = bundle + .proofs + .get(start..start + cells) + .expect("cell proofs present") + .to_vec() + .try_into() + .expect("cell proofs within cap"); + BlobAndProofV2 { blob, proofs } + }) + .collect(); + + let entries = contents + .iter() + .cloned() + .map(|contents| BlobsEntryV2 { + available: true, + contents, + }) + .collect::>(); + let ssz = SszBlobsResponseV2 { + entries: VariableList::new(entries).expect("entries within cap"), + }; + + let json_v3 = contents.iter().cloned().map(Some).collect(); + + BlobsFixture { + ssz, + json_v2: contents, + json_v3, + } +} + +pub struct BodiesFixture { + pub ssz: S, + pub json: Vec>>, +} + +pub fn fulu_bodies(n_bodies: usize, n_txs: usize) -> BodiesFixture> { + let payload = fulu_execution_payload(n_txs); + let body = ExecutionPayloadBodyV1 { + transactions: payload.transactions.clone(), + withdrawals: Some(payload.withdrawals.clone()), + }; + + let entries = (0..n_bodies) + .map(|_| SszBodyEntryV2 { + available: true, + body: SszExecutionPayloadBodyV2::try_from(body.clone()).expect("ssz body"), + }) + .collect::>(); + let ssz = SszBodiesResponseV2 { + entries: VariableList::new(entries).expect("entries within cap"), + }; + + let json = (0..n_bodies) + .map(|_| Some(JsonExecutionPayloadBodyV1::try_from(body.clone()).expect("json body"))) + .collect(); + + BodiesFixture { ssz, json } +} + +pub fn bellatrix_bodies(n_bodies: usize, n_txs: usize) -> BodiesFixture> { + let body = ExecutionPayloadBodyV1 { + transactions: transactions(n_txs), + withdrawals: None, + }; + + let entries = (0..n_bodies) + .map(|_| SszBodyEntryV1 { + available: true, + body: SszExecutionPayloadBodyV1::from(body.clone()), + }) + .collect::>(); + let ssz = SszBodiesResponseV1 { + entries: VariableList::new(entries).expect("entries within cap"), + }; + + let json = (0..n_bodies) + .map(|_| Some(JsonExecutionPayloadBodyV1::try_from(body.clone()).expect("json body"))) + .collect(); + + BodiesFixture { ssz, json } +} diff --git a/beacon_node/execution_layer/src/engine_api.rs b/beacon_node/execution_layer/src/engine_api.rs index 8bc835f8ca9..2f98d37011c 100644 --- a/beacon_node/execution_layer/src/engine_api.rs +++ b/beacon_node/execution_layer/src/engine_api.rs @@ -1,14 +1,6 @@ use crate::engines::ForkchoiceState; -use crate::http::{ - ENGINE_FORKCHOICE_UPDATED_V1, ENGINE_FORKCHOICE_UPDATED_V2, ENGINE_FORKCHOICE_UPDATED_V3, - ENGINE_FORKCHOICE_UPDATED_V4, ENGINE_FORKCHOICE_UPDATED_V5, ENGINE_GET_BLOBS_V2, - ENGINE_GET_BLOBS_V3, ENGINE_GET_BLOBS_V4, ENGINE_GET_CLIENT_VERSION_V1, - ENGINE_GET_INCLUSION_LIST_V1, ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1, - ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V2, ENGINE_GET_PAYLOAD_V1, ENGINE_GET_PAYLOAD_V2, - ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, ENGINE_GET_PAYLOAD_V5, ENGINE_GET_PAYLOAD_V6, - ENGINE_NEW_PAYLOAD_V1, ENGINE_NEW_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_V3, ENGINE_NEW_PAYLOAD_V4, - ENGINE_NEW_PAYLOAD_V5, ENGINE_NEW_PAYLOAD_V6, -}; +pub use crate::json_structures::JsonRpcCapabilities; +use crate::ssz_structures::{MaxBodiesRequest, SszCapabilities}; use eth2::types::{ BlobsBundle, SsePayloadAttributes, SsePayloadAttributesV1, SsePayloadAttributesV2, SsePayloadAttributesV3, @@ -18,6 +10,8 @@ pub use json_structures::{JsonWithdrawal, TransitionConfigurationV1}; use pretty_reqwest_error::PrettyReqwestError; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use ssz_types::typenum::Unsigned; use strum::IntoStaticStr; use superstruct::superstruct; pub use types::{ @@ -32,10 +26,15 @@ use types::{ }; use types::{GRAFFITI_BYTES_LEN, Graffiti}; +use std::time::Duration; + pub mod auth; pub mod http; pub mod json_structures; mod new_payload_request; +pub mod rest; +pub mod ssz_structures; +pub mod transport; pub use new_payload_request::{ NewPayloadRequest, NewPayloadRequestBellatrix, NewPayloadRequestCapella, @@ -45,6 +44,18 @@ pub use new_payload_request::{ pub const LATEST_TAG: &str = "latest"; +// Per-method request timeouts, shared by the JSON-RPC and REST-SSZ. +pub const ETH_GET_BLOCK_BY_NUMBER_TIMEOUT: Duration = Duration::from_secs(1); +pub const ETH_SYNCING_TIMEOUT: Duration = Duration::from_secs(1); +pub const ENGINE_NEW_PAYLOAD_TIMEOUT: Duration = Duration::from_secs(8); +pub const ENGINE_GET_PAYLOAD_TIMEOUT: Duration = Duration::from_secs(2); +pub const ENGINE_FORKCHOICE_UPDATED_TIMEOUT: Duration = Duration::from_secs(8); +pub const ENGINE_GET_PAYLOAD_BODIES_TIMEOUT: Duration = Duration::from_secs(10); +pub const ENGINE_EXCHANGE_CAPABILITIES_TIMEOUT: Duration = Duration::from_secs(1); +pub const ENGINE_GET_CLIENT_VERSION_TIMEOUT: Duration = Duration::from_secs(1); +pub const ENGINE_GET_BLOBS_TIMEOUT: Duration = Duration::from_secs(1); +pub const ENGINE_GET_INCLUSION_LIST_TIMEOUT: Duration = Duration::from_secs(1); + pub type PayloadId = [u8; 8]; #[derive(Debug)] @@ -56,7 +67,10 @@ pub enum Error { InvalidExecutePayloadResponse(&'static str), JsonRpc(RpcError), Json(serde_json::Error), - ServerMessage { code: i64, message: String }, + ServerMessage { + code: i64, + message: String, + }, Eip155Failure, IsSyncing, ExecutionBlockNotFound(ExecutionBlockHash), @@ -72,6 +86,20 @@ pub enum Error { UnsupportedForkVariant(String), InvalidClientVersion(String), TooManyConsolidationRequests(usize), + SszDecode(ssz::DecodeError), + RestProblem { + status: u16, + problem: String, + detail: Option, + }, + TransportUnreachable(String), + TransportAlreadyResolved(transport::Transport), +} + +impl From for Error { + fn from(transport: transport::Transport) -> Self { + Error::TransportAlreadyResolved(transport) + } } impl From for Error { @@ -105,6 +133,17 @@ impl From for Error { } } +impl Error { + /// A REST `GET /payloads/{id}` 404 whose id the EL has expired (build-bound TTL) + pub fn is_unknown_payload(&self) -> bool { + matches!(self, Error::RestProblem { status: 404, problem, .. } if problem == "unknown-payload") + } + + pub fn is_transport_unreachable(&self) -> bool { + matches!(self, Error::TransportUnreachable(_)) + } +} + #[derive(Clone, Copy, Debug, PartialEq, IntoStaticStr)] #[strum(serialize_all = "snake_case")] pub enum PayloadStatusV1Status { @@ -160,11 +199,12 @@ impl ExecutionBlock { #[superstruct( variants(V1, V2, V3, V4, V5), - variant_attributes(derive(Clone, Debug, Eq, Hash, PartialEq),), + variant_attributes(derive(Clone, Debug, Eq, Encode, Decode, Hash, PartialEq),), cast_error(ty = "Error", expr = "Error::IncorrectStateVariant"), partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant") )] -#[derive(Clone, Debug, Eq, Hash, PartialEq)] +#[derive(Clone, Debug, Eq, Encode, Decode, Hash, PartialEq)] +#[ssz(enum_behaviour = "transparent")] pub struct PayloadAttributes { #[superstruct(getter(copy))] pub timestamp: u64, @@ -646,111 +686,120 @@ impl ExecutionPayloadBodyV1 { } } -#[derive(Clone, Copy, Debug)] -pub struct EngineCapabilities { - pub new_payload_v1: bool, - pub new_payload_v2: bool, - pub new_payload_v3: bool, - pub new_payload_v4: bool, - pub new_payload_v5: bool, - pub new_payload_v6: bool, - pub forkchoice_updated_v1: bool, - pub forkchoice_updated_v2: bool, - pub forkchoice_updated_v3: bool, - pub forkchoice_updated_v4: bool, - pub forkchoice_updated_v5: bool, - pub get_payload_bodies_by_hash_v1: bool, - pub get_payload_bodies_by_hash_v2: bool, - pub get_payload_v1: bool, - pub get_payload_v2: bool, - pub get_payload_v3: bool, - pub get_payload_v4: bool, - pub get_payload_v5: bool, - pub get_payload_v6: bool, - pub get_client_version_v1: bool, - pub get_blobs_v2: bool, - pub get_blobs_v3: bool, - pub get_blobs_v4: bool, - pub get_inclusion_list_v1: bool, +#[derive(Clone, Debug)] +pub enum EngineCapabilities { + JsonRpc(JsonRpcCapabilities), + Ssz(SszCapabilities), } impl EngineCapabilities { - pub fn to_response(&self) -> Vec<&str> { - let mut response = Vec::new(); - if self.new_payload_v1 { - response.push(ENGINE_NEW_PAYLOAD_V1); - } - if self.new_payload_v2 { - response.push(ENGINE_NEW_PAYLOAD_V2); - } - if self.new_payload_v3 { - response.push(ENGINE_NEW_PAYLOAD_V3); - } - if self.new_payload_v4 { - response.push(ENGINE_NEW_PAYLOAD_V4); - } - if self.new_payload_v5 { - response.push(ENGINE_NEW_PAYLOAD_V5); - } - if self.new_payload_v6 { - response.push(ENGINE_NEW_PAYLOAD_V6); - } - if self.forkchoice_updated_v1 { - response.push(ENGINE_FORKCHOICE_UPDATED_V1); + pub fn new_payload(&self, fork: ForkName) -> bool { + match self { + Self::JsonRpc(capabilities) => capabilities.new_payload(fork), + Self::Ssz(capabilities) => capabilities.new_payload(fork), } - if self.forkchoice_updated_v2 { - response.push(ENGINE_FORKCHOICE_UPDATED_V2); - } - if self.forkchoice_updated_v3 { - response.push(ENGINE_FORKCHOICE_UPDATED_V3); - } - if self.forkchoice_updated_v4 { - response.push(ENGINE_FORKCHOICE_UPDATED_V4); - } - if self.forkchoice_updated_v5 { - response.push(ENGINE_FORKCHOICE_UPDATED_V5); - } - if self.get_payload_bodies_by_hash_v1 { - response.push(ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1); - } - if self.get_payload_bodies_by_hash_v2 { - response.push(ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V2); - } - if self.get_payload_v1 { - response.push(ENGINE_GET_PAYLOAD_V1); + } + + pub fn get_payload(&self, fork: ForkName) -> bool { + match self { + Self::JsonRpc(capabilities) => capabilities.get_payload(fork), + Self::Ssz(capabilities) => capabilities.get_payload(fork), } - if self.get_payload_v2 { - response.push(ENGINE_GET_PAYLOAD_V2); + } + + pub fn forkchoice_updated(&self, fork: ForkName) -> bool { + match self { + Self::JsonRpc(capabilities) => capabilities.forkchoice_updated(fork), + Self::Ssz(capabilities) => capabilities.forkchoice_updated(fork), } - if self.get_payload_v3 { - response.push(ENGINE_GET_PAYLOAD_V3); + } + + pub fn get_payload_bodies_by_hash_v1(&self, fork: ForkName) -> bool { + match self { + Self::JsonRpc(capabilities) => capabilities.get_payload_bodies_by_hash_v1, + Self::Ssz(capabilities) => { + capabilities.get_payload_bodies(fork) && self.bodies_max_count() > 0 + } } - if self.get_payload_v4 { - response.push(ENGINE_GET_PAYLOAD_V4); + } + + pub fn get_payload_bodies_by_hash_v2(&self, fork: ForkName) -> bool { + match self { + Self::JsonRpc(capabilities) => capabilities.get_payload_bodies_by_hash_v2, + Self::Ssz(capabilities) => { + capabilities.get_payload_bodies(fork) && self.bodies_max_count() > 0 + } } - if self.get_payload_v5 { - response.push(ENGINE_GET_PAYLOAD_V5); + } + + pub fn bodies_max_count(&self) -> usize { + let ceiling = MaxBodiesRequest::to_usize(); + match self { + Self::JsonRpc(_) => ceiling, + Self::Ssz(capabilities) => capabilities + .limits + .bodies_max_count + .map_or(ceiling, |advertised| { + advertised.min(ceiling as u64) as usize + }), } - if self.get_payload_v6 { - response.push(ENGINE_GET_PAYLOAD_V6); + } + + pub fn get_inclusion_list_v1(&self) -> bool { + match self { + Self::JsonRpc(capabilities) => capabilities.get_inclusion_list_v1, + // Heze fork REST-SSZ spec does not exist yet + Self::Ssz(_) => false, } - if self.get_client_version_v1 { - response.push(ENGINE_GET_CLIENT_VERSION_V1); + } + + pub fn get_blobs_v2(&self) -> bool { + match self { + Self::JsonRpc(capabilities) => capabilities.get_blobs_v2, + Self::Ssz(capabilities) => capabilities.get_blobs_v2(), } - if self.get_blobs_v2 { - response.push(ENGINE_GET_BLOBS_V2); + } + + pub fn get_blobs_v3(&self) -> bool { + match self { + Self::JsonRpc(capabilities) => capabilities.get_blobs_v3, + Self::Ssz(capabilities) => capabilities.get_blobs_v3(), } - if self.get_blobs_v3 { - response.push(ENGINE_GET_BLOBS_V3); + } + + pub fn get_blobs_v4(&self) -> bool { + match self { + Self::JsonRpc(capabilities) => capabilities.get_blobs_v4, + Self::Ssz(capabilities) => capabilities.get_blobs_v4(), } - if self.get_blobs_v4 { - response.push(ENGINE_GET_BLOBS_V4); + } + + pub fn blobs_max_versioned_hashes(&self) -> usize { + let ceiling = E::MaxVersionedHashesPerRequest::to_usize(); + match self { + Self::JsonRpc(_) => ceiling, + Self::Ssz(capabilities) => capabilities + .limits + .blobs_max_versioned_hashes + .map_or(ceiling, |advertised| { + advertised.min(ceiling as u64) as usize + }), } - if self.get_inclusion_list_v1 { - response.push(ENGINE_GET_INCLUSION_LIST_V1); + } + + pub fn get_client_version_v1(&self) -> bool { + match self { + Self::JsonRpc(capabilities) => capabilities.get_client_version_v1, + Self::Ssz(capabilities) => capabilities.get_client_version_v1(), } + } - response + pub fn to_response(&self) -> Vec<&str> { + match self { + Self::JsonRpc(capabilities) => capabilities.to_response(), + // SSZ uses the structured /engine/v1/capabilities schema, not a method-name list. + Self::Ssz(_capabilities) => vec![], + } } } @@ -938,3 +987,78 @@ impl ClientVersionV1 { Graffiti::from(graffiti_bytes) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn rest_problem(status: u16, problem: &str) -> Error { + Error::RestProblem { + status, + problem: problem.to_string(), + detail: None, + } + } + + #[test] + fn is_unknown_payload_matches_404_unknown_payload() { + // The one case the reconcile path retries on: a REST 404 with the `unknown-payload` slug. + assert!(rest_problem(404, "unknown-payload").is_unknown_payload()); + } + + #[test] + fn is_unknown_payload_rejects_other_404_slugs() { + // A 404 with any other slug is a different failure a re-fcU can't fix. + assert!(!rest_problem(404, "invalid-request").is_unknown_payload()); + assert!(!rest_problem(404, "").is_unknown_payload()); + } + + #[test] + fn is_unknown_payload_rejects_unknown_payload_with_other_status() { + // The slug alone is not enough; only a 404 counts. + assert!(!rest_problem(400, "unknown-payload").is_unknown_payload()); + assert!(!rest_problem(500, "unknown-payload").is_unknown_payload()); + } + + #[test] + fn is_unknown_payload_rejects_non_rest_errors() { + // JSON-RPC / non-REST errors never match, so the JSON-RPC path never enters the retry arm. + // `ServerMessage` is a JSON-RPC error response from the EL — the closest JSON-RPC analogue. + assert!( + !Error::ServerMessage { + code: -38001, + message: "Unknown payload".to_string(), + } + .is_unknown_payload() + ); + assert!(!Error::PayloadIdUnavailable.is_unknown_payload()); + assert!(!Error::IsSyncing.is_unknown_payload()); + } + + #[test] + fn is_transport_unreachable_matches_transport_unreachable() { + // The h2c -> HTTP/1.1 fallback trigger: a statusless, non-timeout REST send failure. + assert!( + Error::TransportUnreachable("connection reset".to_string()).is_transport_unreachable() + ); + } + + #[test] + fn is_transport_unreachable_rejects_status_bearing_errors() { + // Any error carrying an HTTP status means the peer responded over the attempted + // transport, so it must never trigger the HTTP/2 -> HTTP/1.1 fallback. A non-2xx status + // surfaces as `RestProblem`; a 401/403 surfaces as `Auth` (see `From`). + assert!(!rest_problem(400, "unsupported-fork").is_transport_unreachable()); + assert!(!rest_problem(415, "").is_transport_unreachable()); + assert!( + !Error::Auth(crate::auth::Error::InvalidToken("401".to_string())) + .is_transport_unreachable() + ); + } + + #[test] + fn is_transport_unreachable_rejects_non_rest_errors() { + assert!(!Error::PayloadIdUnavailable.is_transport_unreachable()); + assert!(!Error::IsSyncing.is_transport_unreachable()); + } +} diff --git a/beacon_node/execution_layer/src/engine_api/http.rs b/beacon_node/execution_layer/src/engine_api/http.rs index 40a79d2f552..7b39776da2e 100644 --- a/beacon_node/execution_layer/src/engine_api/http.rs +++ b/beacon_node/execution_layer/src/engine_api/http.rs @@ -3,6 +3,7 @@ use super::*; use crate::auth::Auth; use crate::json_structures::*; +use crate::metrics; use lighthouse_version::{COMMIT_PREFIX, VERSION}; use opentelemetry::global; use opentelemetry_http::HeaderInjector; @@ -30,10 +31,8 @@ pub const JSONRPC_VERSION: &str = "2.0"; pub const RETURN_FULL_TRANSACTION_OBJECTS: bool = false; pub const ETH_GET_BLOCK_BY_NUMBER: &str = "eth_getBlockByNumber"; -pub const ETH_GET_BLOCK_BY_NUMBER_TIMEOUT: Duration = Duration::from_secs(1); pub const ETH_SYNCING: &str = "eth_syncing"; -pub const ETH_SYNCING_TIMEOUT: Duration = Duration::from_secs(1); pub const ENGINE_NEW_PAYLOAD_V1: &str = "engine_newPayloadV1"; pub const ENGINE_NEW_PAYLOAD_V2: &str = "engine_newPayloadV2"; @@ -41,7 +40,6 @@ pub const ENGINE_NEW_PAYLOAD_V3: &str = "engine_newPayloadV3"; pub const ENGINE_NEW_PAYLOAD_V4: &str = "engine_newPayloadV4"; pub const ENGINE_NEW_PAYLOAD_V5: &str = "engine_newPayloadV5"; pub const ENGINE_NEW_PAYLOAD_V6: &str = "engine_newPayloadV6"; -pub const ENGINE_NEW_PAYLOAD_TIMEOUT: Duration = Duration::from_secs(8); pub const ENGINE_GET_PAYLOAD_V1: &str = "engine_getPayloadV1"; pub const ENGINE_GET_PAYLOAD_V2: &str = "engine_getPayloadV2"; @@ -49,32 +47,25 @@ pub const ENGINE_GET_PAYLOAD_V3: &str = "engine_getPayloadV3"; pub const ENGINE_GET_PAYLOAD_V4: &str = "engine_getPayloadV4"; pub const ENGINE_GET_PAYLOAD_V5: &str = "engine_getPayloadV5"; pub const ENGINE_GET_PAYLOAD_V6: &str = "engine_getPayloadV6"; -pub const ENGINE_GET_PAYLOAD_TIMEOUT: Duration = Duration::from_secs(2); pub const ENGINE_FORKCHOICE_UPDATED_V1: &str = "engine_forkchoiceUpdatedV1"; pub const ENGINE_FORKCHOICE_UPDATED_V2: &str = "engine_forkchoiceUpdatedV2"; pub const ENGINE_FORKCHOICE_UPDATED_V3: &str = "engine_forkchoiceUpdatedV3"; pub const ENGINE_FORKCHOICE_UPDATED_V4: &str = "engine_forkchoiceUpdatedV4"; pub const ENGINE_FORKCHOICE_UPDATED_V5: &str = "engine_forkchoiceUpdatedV5"; -pub const ENGINE_FORKCHOICE_UPDATED_TIMEOUT: Duration = Duration::from_secs(8); pub const ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1: &str = "engine_getPayloadBodiesByHashV1"; pub const ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V2: &str = "engine_getPayloadBodiesByHashV2"; -pub const ENGINE_GET_PAYLOAD_BODIES_TIMEOUT: Duration = Duration::from_secs(10); pub const ENGINE_EXCHANGE_CAPABILITIES: &str = "engine_exchangeCapabilities"; -pub const ENGINE_EXCHANGE_CAPABILITIES_TIMEOUT: Duration = Duration::from_secs(1); pub const ENGINE_GET_CLIENT_VERSION_V1: &str = "engine_getClientVersionV1"; -pub const ENGINE_GET_CLIENT_VERSION_TIMEOUT: Duration = Duration::from_secs(1); pub const ENGINE_GET_BLOBS_V2: &str = "engine_getBlobsV2"; pub const ENGINE_GET_BLOBS_V3: &str = "engine_getBlobsV3"; pub const ENGINE_GET_BLOBS_V4: &str = "engine_getBlobsV4"; -pub const ENGINE_GET_BLOBS_TIMEOUT: Duration = Duration::from_secs(1); pub const ENGINE_GET_INCLUSION_LIST_V1: &str = "engine_getInclusionListV1"; -pub const ENGINE_GET_INCLUSION_LIST_TIMEOUT: Duration = Duration::from_secs(1); /// This error is returned during a `chainId` call by Geth. pub const EIP155_ERROR_STR: &str = "chain not synced beyond EIP-155 replay-protection fork block"; @@ -623,7 +614,7 @@ pub struct HttpJsonRpc { pub client: Client, pub url: SensitiveUrl, pub execution_timeout_multiplier: u32, - pub engine_capabilities_cache: Mutex>>, + pub engine_capabilities_cache: Mutex>>, pub engine_version_cache: Mutex>>>, auth: Option, } @@ -664,19 +655,21 @@ impl HttpJsonRpc { params: serde_json::Value, timeout: Duration, ) -> Result { - let body = JsonRequestBody { + let request_body = JsonRequestBody { jsonrpc: JSONRPC_VERSION, method, params, id: json!(STATIC_ID), }; + let raw_request = serde_json::to_vec(&request_body)?; + let request_len = raw_request.len(); let mut request = self .client .post(self.url.expose_full().clone()) .timeout(timeout) .header(CONTENT_TYPE, "application/json") - .json(&body); + .body(raw_request); // Generate and add a jwt token to the header if auth is defined. if let Some(auth) = &self.auth { @@ -691,7 +684,30 @@ impl HttpJsonRpc { }); request = request.headers(headers); - let body: JsonResponseBody = request.send().await?.error_for_status()?.json().await?; + let response_bytes = request.send().await?.error_for_status()?.bytes().await?; + + if let Some(label) = metrics::engine_method_label(method) { + metrics::observe_vec( + &metrics::EXECUTION_LAYER_ENGINE_BODY_SIZE_BYTES, + &[ + label, + metrics::TRANSPORT_JSON_RPC, + metrics::DIRECTION_REQUEST, + ], + request_len as f64, + ); + metrics::observe_vec( + &metrics::EXECUTION_LAYER_ENGINE_BODY_SIZE_BYTES, + &[ + label, + metrics::TRANSPORT_JSON_RPC, + metrics::DIRECTION_RESPONSE, + ], + response_bytes.len() as f64, + ); + } + + let body: JsonResponseBody = serde_json::from_slice(&response_bytes)?; match (body.result, body.error) { (result, None) => serde_json::from_value(result).map_err(Into::into), @@ -1327,7 +1343,7 @@ impl HttpJsonRpc { .collect::>()?) } - pub async fn exchange_capabilities(&self) -> Result { + pub async fn exchange_capabilities(&self) -> Result { let params = json!([LIGHTHOUSE_CAPABILITIES]); let capabilities: HashSet = self @@ -1338,7 +1354,7 @@ impl HttpJsonRpc { ) .await?; - Ok(EngineCapabilities { + Ok(JsonRpcCapabilities { new_payload_v1: capabilities.contains(ENGINE_NEW_PAYLOAD_V1), new_payload_v2: capabilities.contains(ENGINE_NEW_PAYLOAD_V2), new_payload_v3: capabilities.contains(ENGINE_NEW_PAYLOAD_V3), @@ -1385,6 +1401,15 @@ impl HttpJsonRpc { &self, age_limit: Option, ) -> Result { + Ok(EngineCapabilities::JsonRpc( + self.json_rpc_capabilities(age_limit).await?, + )) + } + + async fn json_rpc_capabilities( + &self, + age_limit: Option, + ) -> Result { let mut lock = self.engine_capabilities_cache.lock().await; if let Some(lock) = lock @@ -1439,7 +1464,7 @@ impl HttpJsonRpc { age_limit: Option, ) -> Result, Error> { // check engine capabilities first (avoids holding two locks at once) - let engine_capabilities = self.get_engine_capabilities(None).await?; + let engine_capabilities = self.json_rpc_capabilities(None).await?; if !engine_capabilities.get_client_version_v1 { // We choose an empty vec to denote that this method is not // supported instead of an error since this method is optional @@ -1469,7 +1494,7 @@ impl HttpJsonRpc { &self, new_payload_request: NewPayloadRequest<'_, E>, ) -> Result { - let engine_capabilities = self.get_engine_capabilities(None).await?; + let engine_capabilities = self.json_rpc_capabilities(None).await?; match new_payload_request { NewPayloadRequest::Bellatrix(_) | NewPayloadRequest::Capella(_) => { if engine_capabilities.new_payload_v2 { @@ -1528,7 +1553,7 @@ impl HttpJsonRpc { fork_name: ForkName, payload_id: PayloadId, ) -> Result, Error> { - let engine_capabilities = self.get_engine_capabilities(None).await?; + let engine_capabilities = self.json_rpc_capabilities(None).await?; match fork_name { ForkName::Bellatrix | ForkName::Capella => { if engine_capabilities.get_payload_v2 { @@ -1582,7 +1607,7 @@ impl HttpJsonRpc { maybe_payload_attributes: Option, custody_columns: Option<&[ColumnIndex]>, ) -> Result { - let engine_capabilities = self.get_engine_capabilities(None).await?; + let engine_capabilities = self.json_rpc_capabilities(None).await?; if let Some(payload_attributes) = maybe_payload_attributes.as_ref() { match payload_attributes { PayloadAttributes::V1(_) | PayloadAttributes::V2(_) => { diff --git a/beacon_node/execution_layer/src/engine_api/json_structures.rs b/beacon_node/execution_layer/src/engine_api/json_structures.rs index 29d7523196d..85f59781c13 100644 --- a/beacon_node/execution_layer/src/engine_api/json_structures.rs +++ b/beacon_node/execution_layer/src/engine_api/json_structures.rs @@ -1,7 +1,18 @@ use super::*; +use crate::http::{ + ENGINE_FORKCHOICE_UPDATED_V1, ENGINE_FORKCHOICE_UPDATED_V2, ENGINE_FORKCHOICE_UPDATED_V3, + ENGINE_FORKCHOICE_UPDATED_V4, ENGINE_FORKCHOICE_UPDATED_V5, ENGINE_GET_BLOBS_V2, + ENGINE_GET_BLOBS_V3, ENGINE_GET_BLOBS_V4, ENGINE_GET_CLIENT_VERSION_V1, + ENGINE_GET_INCLUSION_LIST_V1, ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1, + ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V2, ENGINE_GET_PAYLOAD_V1, ENGINE_GET_PAYLOAD_V2, + ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, ENGINE_GET_PAYLOAD_V5, ENGINE_GET_PAYLOAD_V6, + ENGINE_NEW_PAYLOAD_V1, ENGINE_NEW_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_V3, ENGINE_NEW_PAYLOAD_V4, + ENGINE_NEW_PAYLOAD_V5, ENGINE_NEW_PAYLOAD_V6, +}; use alloy_rlp::RlpEncodable; use serde::{Deserialize, Serialize}; use ssz::{Decode, TryFromIter}; +use ssz_derive::{Decode, Encode}; use ssz_types::{FixedVector, ProgressiveVariableList, VariableList, typenum::Unsigned}; use strum::EnumString; use superstruct::superstruct; @@ -13,6 +24,151 @@ use types::execution::{ use types::kzg_ext::KzgCommitments; use types::{Blob, KzgProof}; +#[derive(Clone, Copy, Debug)] +pub struct JsonRpcCapabilities { + pub new_payload_v1: bool, + pub new_payload_v2: bool, + pub new_payload_v3: bool, + pub new_payload_v4: bool, + pub new_payload_v5: bool, + pub new_payload_v6: bool, + pub forkchoice_updated_v1: bool, + pub forkchoice_updated_v2: bool, + pub forkchoice_updated_v3: bool, + pub forkchoice_updated_v4: bool, + pub forkchoice_updated_v5: bool, + pub get_payload_bodies_by_hash_v1: bool, + pub get_payload_bodies_by_hash_v2: bool, + pub get_payload_v1: bool, + pub get_payload_v2: bool, + pub get_payload_v3: bool, + pub get_payload_v4: bool, + pub get_payload_v5: bool, + pub get_payload_v6: bool, + pub get_client_version_v1: bool, + pub get_blobs_v2: bool, + pub get_blobs_v3: bool, + pub get_blobs_v4: bool, + pub get_inclusion_list_v1: bool, +} + +impl JsonRpcCapabilities { + pub fn new_payload(&self, fork: ForkName) -> bool { + match fork { + ForkName::Bellatrix => self.new_payload_v1, + ForkName::Capella => self.new_payload_v2, + ForkName::Deneb => self.new_payload_v3, + ForkName::Electra | ForkName::Fulu => self.new_payload_v4, + ForkName::Gloas => self.new_payload_v5, + ForkName::Heze => self.new_payload_v6, + ForkName::Base | ForkName::Altair => false, + } + } + + pub fn get_payload(&self, fork: ForkName) -> bool { + match fork { + ForkName::Bellatrix => self.get_payload_v1, + ForkName::Capella => self.get_payload_v2, + ForkName::Deneb => self.get_payload_v3, + ForkName::Electra => self.get_payload_v4, + ForkName::Fulu => self.get_payload_v5, + ForkName::Gloas => self.get_payload_v6, + // TODO(heze): add heze arm to appropriate get_payload version once JSON engine API spec is finalized + ForkName::Base | ForkName::Altair | ForkName::Heze => false, + } + } + + pub fn forkchoice_updated(&self, fork: ForkName) -> bool { + match fork { + ForkName::Bellatrix => self.forkchoice_updated_v1, + ForkName::Capella => self.forkchoice_updated_v2, + ForkName::Deneb | ForkName::Electra | ForkName::Fulu => self.forkchoice_updated_v3, + ForkName::Gloas => self.forkchoice_updated_v4, + ForkName::Heze => self.forkchoice_updated_v5, + // TODO(heze): add heze arm to appropriate forkchoice_updated version once JSON engine API spec is finalized + ForkName::Base | ForkName::Altair => false, + } + } + + pub fn to_response(&self) -> Vec<&str> { + let mut response = Vec::new(); + if self.new_payload_v1 { + response.push(ENGINE_NEW_PAYLOAD_V1); + } + if self.new_payload_v2 { + response.push(ENGINE_NEW_PAYLOAD_V2); + } + if self.new_payload_v3 { + response.push(ENGINE_NEW_PAYLOAD_V3); + } + if self.new_payload_v4 { + response.push(ENGINE_NEW_PAYLOAD_V4); + } + if self.new_payload_v5 { + response.push(ENGINE_NEW_PAYLOAD_V5); + } + if self.new_payload_v6 { + response.push(ENGINE_NEW_PAYLOAD_V6); + } + if self.forkchoice_updated_v1 { + response.push(ENGINE_FORKCHOICE_UPDATED_V1); + } + if self.forkchoice_updated_v2 { + response.push(ENGINE_FORKCHOICE_UPDATED_V2); + } + if self.forkchoice_updated_v3 { + response.push(ENGINE_FORKCHOICE_UPDATED_V3); + } + if self.forkchoice_updated_v4 { + response.push(ENGINE_FORKCHOICE_UPDATED_V4); + } + if self.forkchoice_updated_v5 { + response.push(ENGINE_FORKCHOICE_UPDATED_V5); + } + if self.get_payload_bodies_by_hash_v1 { + response.push(ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1); + } + if self.get_payload_bodies_by_hash_v2 { + response.push(ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V2); + } + if self.get_payload_v1 { + response.push(ENGINE_GET_PAYLOAD_V1); + } + if self.get_payload_v2 { + response.push(ENGINE_GET_PAYLOAD_V2); + } + if self.get_payload_v3 { + response.push(ENGINE_GET_PAYLOAD_V3); + } + if self.get_payload_v4 { + response.push(ENGINE_GET_PAYLOAD_V4); + } + if self.get_payload_v5 { + response.push(ENGINE_GET_PAYLOAD_V5); + } + if self.get_payload_v6 { + response.push(ENGINE_GET_PAYLOAD_V6); + } + if self.get_client_version_v1 { + response.push(ENGINE_GET_CLIENT_VERSION_V1); + } + if self.get_blobs_v2 { + response.push(ENGINE_GET_BLOBS_V2); + } + if self.get_blobs_v3 { + response.push(ENGINE_GET_BLOBS_V3); + } + if self.get_blobs_v4 { + response.push(ENGINE_GET_BLOBS_V4); + } + if self.get_inclusion_list_v1 { + response.push(ENGINE_GET_INCLUSION_LIST_V1); + } + + response + } +} + #[derive(Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct JsonRequestBody<'a> { @@ -688,7 +844,7 @@ impl TryFrom for ExecutionRequestsGloas { partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant") )] #[derive(Debug, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] +#[serde(bound = "E: EthSpec", untagged)] pub struct JsonGetPayloadResponse { #[superstruct( only(Bellatrix), @@ -717,6 +873,71 @@ pub struct JsonGetPayloadResponse { pub execution_requests: JsonExecutionRequests, } +impl TryFrom> for JsonGetPayloadResponse { + type Error = ssz_types::Error; + + fn try_from(response: GetPayloadResponse) -> Result { + match response { + GetPayloadResponse::Bellatrix(inner) => Ok(JsonGetPayloadResponse::Bellatrix( + JsonGetPayloadResponseBellatrix { + execution_payload: inner.execution_payload.into(), + block_value: inner.block_value, + }, + )), + GetPayloadResponse::Capella(inner) => Ok(JsonGetPayloadResponse::Capella( + JsonGetPayloadResponseCapella { + execution_payload: inner.execution_payload.try_into()?, + block_value: inner.block_value, + }, + )), + GetPayloadResponse::Deneb(inner) => { + Ok(JsonGetPayloadResponse::Deneb(JsonGetPayloadResponseDeneb { + execution_payload: inner.execution_payload.try_into()?, + block_value: inner.block_value, + blobs_bundle: inner.blobs_bundle.into(), + should_override_builder: inner.should_override_builder, + })) + } + GetPayloadResponse::Electra(inner) => Ok(JsonGetPayloadResponse::Electra( + JsonGetPayloadResponseElectra { + execution_payload: inner.execution_payload.try_into()?, + block_value: inner.block_value, + blobs_bundle: inner.blobs_bundle.into(), + should_override_builder: inner.should_override_builder, + execution_requests: ExecutionRequests::Electra(inner.requests).into(), + }, + )), + GetPayloadResponse::Fulu(inner) => { + Ok(JsonGetPayloadResponse::Fulu(JsonGetPayloadResponseFulu { + execution_payload: inner.execution_payload.try_into()?, + block_value: inner.block_value, + blobs_bundle: inner.blobs_bundle.into(), + should_override_builder: inner.should_override_builder, + execution_requests: ExecutionRequests::Electra(inner.requests).into(), + })) + } + GetPayloadResponse::Gloas(inner) => { + Ok(JsonGetPayloadResponse::Gloas(JsonGetPayloadResponseGloas { + execution_payload: inner.execution_payload.try_into()?, + block_value: inner.block_value, + blobs_bundle: inner.blobs_bundle.into(), + should_override_builder: inner.should_override_builder, + execution_requests: ExecutionRequests::Gloas(inner.requests).into(), + })) + } + GetPayloadResponse::Heze(inner) => { + Ok(JsonGetPayloadResponse::Heze(JsonGetPayloadResponseHeze { + execution_payload: inner.execution_payload.try_into()?, + block_value: inner.block_value, + blobs_bundle: inner.blobs_bundle.into(), + should_override_builder: inner.should_override_builder, + execution_requests: ExecutionRequests::Gloas(inner.requests).into(), + })) + } + } + } +} + impl TryFrom> for GetPayloadResponse { type Error = String; fn try_from(json_get_payload_response: JsonGetPayloadResponse) -> Result { @@ -1025,11 +1246,12 @@ impl From> for BlobsBundle { #[superstruct( variants(V1, V2), variant_attributes( - derive(Debug, Clone, PartialEq, Serialize, Deserialize), + derive(Debug, Clone, PartialEq, Encode, Decode, Serialize, Deserialize), serde(bound = "E: EthSpec", rename_all = "camelCase") ) )] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Encode, Decode, Serialize, Deserialize)] +#[ssz(enum_behaviour = "transparent")] pub struct BlobAndProof { #[serde(with = "ssz_types::serde_utils::hex_fixed_vec")] pub blob: Blob, diff --git a/beacon_node/execution_layer/src/engine_api/rest.rs b/beacon_node/execution_layer/src/engine_api/rest.rs new file mode 100644 index 00000000000..5b38745a0eb --- /dev/null +++ b/beacon_node/execution_layer/src/engine_api/rest.rs @@ -0,0 +1,1413 @@ +//! Contains an implementation of the Engine API over REST + SSZ (execution-apis#793). + +use crate::EngineCapabilities; +use crate::auth::Auth; +use crate::engine_api::{ + ClientVersionV1, ENGINE_EXCHANGE_CAPABILITIES_TIMEOUT, ENGINE_FORKCHOICE_UPDATED_TIMEOUT, + ENGINE_GET_BLOBS_TIMEOUT, ENGINE_GET_CLIENT_VERSION_TIMEOUT, ENGINE_GET_PAYLOAD_BODIES_TIMEOUT, + ENGINE_GET_PAYLOAD_TIMEOUT, ENGINE_NEW_PAYLOAD_TIMEOUT, Error, ExecutionPayloadBodyV1, + ExecutionPayloadBodyV2, ForkchoiceUpdatedResponse, GetPayloadResponse, NewPayloadRequest, + PayloadAttributes, PayloadId, PayloadStatusV1, PayloadStatusV1Status, +}; +use crate::engines::ForkchoiceState; +use crate::http::{CachedResponse, LIGHTHOUSE_JSON_CLIENT_VERSION}; +use crate::json_structures::{ + BlobAndProofV2, BlobAndProofV3, CustodyColumnsBitArray, GetBlobsV4List, JsonClientVersionV1, +}; +use crate::metrics; +use crate::ssz_structures::*; +use bytes::Bytes; +use reqwest::header::{ACCEPT, CONTENT_TYPE}; +use reqwest::{Client, Method, StatusCode}; +use sensitive_url::SensitiveUrl; +use serde::Deserialize; +use ssz::{Decode, Encode}; +use std::sync::{LazyLock, OnceLock}; +use std::time::Duration; +use tokio::sync::Mutex; +use tracing::{error, info}; +use types::{ColumnIndex, EthSpec, ExecutionBlockHash, ForkName, Hash256}; + +const BASE: &str = "/engine/v1"; +const ETH_EXECUTION_VERSION: &str = "Eth-Execution-Version"; +const X_ENGINE_CLIENT_VERSION: &str = "X-Engine-Client-Version"; + +// SSZ for hot-path bodies/responses, JSON for the diagnostic endpoints. +const OCTET_STREAM: &str = "application/octet-stream"; +const APPLICATION_JSON: &str = "application/json"; +const H2_CONNECTION_WINDOW: u32 = 1024 * 1024; +const H2_STREAM_WINDOW: u32 = 1024 * 1024; + +// REST reuses the JSON-RPC per-method timeouts from `http.rs`, applied per request rather than as +// a single client-wide default, so each endpoint gets a deadline matched to its work. + +// The CL's `X-Engine-Client-Version` header value, e.g. `LH/v6.2.1`. +static CLIENT_VERSION_HEADER: LazyLock = LazyLock::new(|| { + format!( + "{}/{}", + LIGHTHOUSE_JSON_CLIENT_VERSION.code, LIGHTHOUSE_JSON_CLIENT_VERSION.version + ) +}); + +#[derive(Debug, Copy, Clone)] +pub enum HttpVersion { + Http2, + Http1, +} + +impl HttpVersion { + fn as_str(&self) -> &'static str { + match self { + HttpVersion::Http2 => "http2", + HttpVersion::Http1 => "http1", + } + } +} + +#[derive(Deserialize)] +struct JsonProblem { + #[serde(rename = "type")] + type_uri: String, + detail: Option, +} + +/// A REST + SSZ Engine API client. Mirrors `HttpJsonRpc`, but speaks `/engine/v1/...` with +/// `application/octet-stream` SSZ bodies and RFC 7807 problem responses. +pub struct HttpRestSsz { + pub client_h2c: Client, + pub client_h1: Client, + pub http_version: OnceLock, + pub url: SensitiveUrl, + pub execution_timeout_multiplier: u32, + pub ssz_capabilities_cache: Mutex>>, + pub engine_version_cache: Mutex>>>, + auth: Option, +} + +impl HttpRestSsz { + pub fn new( + url: SensitiveUrl, + execution_timeout_multiplier: Option, + ) -> Result { + let execution_timeout_multiplier = execution_timeout_multiplier.unwrap_or(1); + Ok(Self { + client_h2c: Client::builder() + .http2_prior_knowledge() + .http2_initial_stream_window_size(H2_STREAM_WINDOW) + .http2_initial_connection_window_size(H2_CONNECTION_WINDOW) + .build()?, + client_h1: Client::builder().build()?, + http_version: OnceLock::new(), + url, + execution_timeout_multiplier, + ssz_capabilities_cache: Mutex::new(None), + engine_version_cache: Mutex::new(None), + auth: None, + }) + } + + pub fn new_with_auth( + url: SensitiveUrl, + auth: Auth, + execution_timeout_multiplier: Option, + ) -> Result { + Ok(Self { + auth: Some(auth), + ..Self::new(url, execution_timeout_multiplier)? + }) + } + + fn selected_client(&self) -> &Client { + match self.http_version.get() { + None | Some(HttpVersion::Http2) => &self.client_h2c, + Some(HttpVersion::Http1) => &self.client_h1, + } + } + + /// Probe h2c then latch the REST transport (HTTP/2, else HTTP/1.1), returning capabilities. + pub async fn resolve_http_and_get_capabilities( + &self, + age_limit: Option, + ) -> Result { + let capabilities = match self.get_engine_capabilities(age_limit).await { + Err(e) if e.is_transport_unreachable() => { + let _ = self.http_version.set(HttpVersion::Http1); + self.get_engine_capabilities(age_limit).await + } + other => { + let _ = self.http_version.set(HttpVersion::Http2); + other + } + }; + if capabilities.is_ok() + && let Some(version) = self.http_version.get() + { + info!( + transport = version.as_str(), + "Selected REST-SSZ engine transport" + ); + } + capabilities + } + + /// The single REST-SSZ transport chokepoint. `Some(bytes)` on `200`, `None` on `204`. + #[allow(clippy::too_many_arguments)] + pub async fn rest_request( + &self, + method: Method, + path: &str, + fork: Option, + body: Option>, + accept: &str, + method_label: Option<&'static str>, + timeout: Duration, + ) -> Result, Error> { + if let (Some(label), Some(body)) = (method_label, &body) { + metrics::observe_vec( + &metrics::EXECUTION_LAYER_ENGINE_BODY_SIZE_BYTES, + &[label, metrics::TRANSPORT_REST, metrics::DIRECTION_REQUEST], + body.len() as f64, + ); + } + let client = self.selected_client(); + let url = self + .url + .expose_full() + .join(&format!("{BASE}/{path}")) + .map_err(|e| Error::RequestFailed(format!("failed to build REST url: {e}")))?; + + let mut request = client + .request(method, url) + .timeout(timeout) + .header(ACCEPT, accept) + .header(X_ENGINE_CLIENT_VERSION, CLIENT_VERSION_HEADER.as_str()); + + if let Some(fork) = fork { + request = request.header(ETH_EXECUTION_VERSION, fork_to_header(fork)?); + } + if let Some(body) = body { + request = request.header(CONTENT_TYPE, OCTET_STREAM).body(body); + } + if let Some(auth) = &self.auth { + request = request.bearer_auth(auth.generate_token()?); + } + + let response = match request.send().await { + Ok(response) => response, + Err(e) if e.status().is_none() && !e.is_timeout() => { + return Err(Error::TransportUnreachable(e.to_string())); + } + Err(e) => return Err(e.into()), + }; + // 401/403 are auth failures, outside the RFC 7807 model — reuse the JSON-RPC `From` mapping. + if let Err(e) = response.error_for_status_ref() + && matches!( + e.status(), + Some(StatusCode::UNAUTHORIZED) | Some(StatusCode::FORBIDDEN) + ) + { + return Err(e.into()); + } + + match response.status() { + StatusCode::OK => { + let bytes = response.bytes().await?; + if let Some(label) = method_label { + metrics::observe_vec( + &metrics::EXECUTION_LAYER_ENGINE_BODY_SIZE_BYTES, + &[label, metrics::TRANSPORT_REST, metrics::DIRECTION_RESPONSE], + bytes.len() as f64, + ); + } + Ok(Some(bytes)) + } + StatusCode::NO_CONTENT => Ok(None), + status => { + let (problem, detail) = match response.json::().await { + Ok(body) => ( + body.type_uri + .rsplit('/') + .next() + .unwrap_or_default() + .to_string(), + body.detail, + ), + Err(_) => (String::new(), None), + }; + Err(Error::RestProblem { + status: status.as_u16(), + problem, + detail, + }) + } + } + } + + pub async fn get_engine_capabilities( + &self, + age_limit: Option, + ) -> Result { + Ok(EngineCapabilities::Ssz( + self.get_ssz_capabilities(age_limit).await?, + )) + } + + pub async fn get_ssz_capabilities( + &self, + age_limit: Option, + ) -> Result { + let mut lock = self.ssz_capabilities_cache.lock().await; + if let Some(cached) = lock.as_ref().filter(|c| !c.older_than(age_limit)) { + Ok(cached.data()) + } else { + let capabilities = self.fetch_ssz_capabilities().await?; + *lock = Some(CachedResponse::new(capabilities.clone())); + Ok(capabilities) + } + } + + pub async fn clear_ssz_capabilities_cache(&self) { + *self.ssz_capabilities_cache.lock().await = None; + } + + async fn fetch_ssz_capabilities(&self) -> Result { + let body = self + .rest_request( + Method::GET, + "capabilities", + None, + None, + APPLICATION_JSON, + None, + ENGINE_EXCHANGE_CAPABILITIES_TIMEOUT * self.execution_timeout_multiplier, + ) + .await? + .ok_or_else(|| Error::BadResponse("unexpected 204 on /capabilities".to_string()))?; + let capabilities: JsonCapabilities = serde_json::from_slice(&body)?; + Ok(capabilities.into()) + } + + pub async fn get_client_version_v1(&self) -> Result, Error> { + let body = self + .rest_request( + Method::GET, + "identity", + None, + None, + APPLICATION_JSON, + None, + ENGINE_GET_CLIENT_VERSION_TIMEOUT * self.execution_timeout_multiplier, + ) + .await? + .ok_or_else(|| Error::BadResponse("unexpected 204 on /identity".to_string()))?; + let versions: Vec = serde_json::from_slice(&body)?; + versions + .into_iter() + .map(TryInto::try_into) + .collect::, _>>() + .map_err(Error::InvalidClientVersion) + } + + pub async fn get_engine_version( + &self, + age_limit: Option, + ) -> Result, Error> { + if !self + .get_ssz_capabilities(None) + .await? + .get_client_version_v1() + { + return Ok(vec![]); + } + let mut lock = self.engine_version_cache.lock().await; + if let Some(cached) = lock.as_ref().filter(|c| !c.older_than(age_limit)) { + Ok(cached.data()) + } else { + let versions = self.get_client_version_v1().await?; + *lock = Some(CachedResponse::new(versions.clone())); + Ok(versions) + } + } + + pub async fn clear_engine_version_cache(&self) { + *self.engine_version_cache.lock().await = None; + } + + pub async fn new_payload( + &self, + new_payload_request: NewPayloadRequest<'_, E>, + ) -> Result { + let (fork, body) = match new_payload_request { + NewPayloadRequest::Bellatrix(request) => ( + ForkName::Bellatrix, + SszExecutionPayloadEnvelopeBellatrix::from(request).as_ssz_bytes(), + ), + NewPayloadRequest::Capella(request) => ( + ForkName::Capella, + SszExecutionPayloadEnvelopeCapella::from(request).as_ssz_bytes(), + ), + NewPayloadRequest::Deneb(request) => ( + ForkName::Deneb, + SszExecutionPayloadEnvelopeDeneb::from(request).as_ssz_bytes(), + ), + NewPayloadRequest::Electra(request) => ( + ForkName::Electra, + SszExecutionPayloadEnvelopeElectra::try_from(request)?.as_ssz_bytes(), + ), + NewPayloadRequest::Fulu(request) => ( + ForkName::Fulu, + SszExecutionPayloadEnvelopeFulu::try_from(request)?.as_ssz_bytes(), + ), + NewPayloadRequest::Gloas(request) => ( + ForkName::Gloas, + SszExecutionPayloadEnvelopeGloas::try_from(request)?.as_ssz_bytes(), + ), + NewPayloadRequest::Heze(request) => ( + ForkName::Heze, + SszExecutionPayloadEnvelopeHeze::try_from(request)?.as_ssz_bytes(), + ), + }; + + let response = self + .rest_request( + Method::POST, + "payloads", + Some(fork), + Some(body), + OCTET_STREAM, + Some(metrics::NEW_PAYLOAD), + ENGINE_NEW_PAYLOAD_TIMEOUT * self.execution_timeout_multiplier, + ) + .await? + .ok_or_else(|| Error::BadResponse("unexpected 204 on /payloads".to_string()))?; + + let status = + SszPayloadStatusV1::::from_ssz_bytes(&response).map_err(Error::SszDecode)?; + PayloadStatusV1::try_from(status) + } + + pub async fn forkchoice_updated( + &self, + fork: ForkName, + forkchoice_state: ForkchoiceState, + payload_attributes: Option, + custody_columns: Option<&[ColumnIndex]>, + ) -> Result { + let custody_columns = Self::custody_columns_param(custody_columns, fork); + + let body = if fork >= ForkName::Gloas { + SszForkchoiceUpdateCustodyColumns::::new( + fork, + forkchoice_state, + payload_attributes, + custody_columns, + )? + .as_ssz_bytes() + } else { + SszForkchoiceUpdate::new(fork, forkchoice_state, payload_attributes)?.as_ssz_bytes() + }; + + let response = self + .rest_request( + Method::POST, + "forkchoice", + Some(fork), + Some(body), + OCTET_STREAM, + Some(metrics::FORKCHOICE_UPDATED), + ENGINE_FORKCHOICE_UPDATED_TIMEOUT * self.execution_timeout_multiplier, + ) + .await? + .ok_or_else(|| Error::BadResponse("unexpected 204 on /forkchoice".to_string()))?; + + let ssz_response = SszForkchoiceUpdatedResponse::::from_ssz_bytes(&response) + .map_err(Error::SszDecode)?; + let response = ForkchoiceUpdatedResponse::try_from(ssz_response)?; + + // `/forkchoice` MUST NOT return ACCEPTED; treat a `3` as a non-conformant EL error. + if matches!( + response.payload_status.status, + PayloadStatusV1Status::Accepted + ) { + return Err(Error::BadResponse( + "forkchoiceUpdated returned ACCEPTED, which is not a valid forkchoice status" + .to_string(), + )); + } + + Ok(response) + } + + fn custody_columns_param( + custody_columns: Option<&[ColumnIndex]>, + fork: ForkName, + ) -> Option { + custody_columns + .map(CustodyColumnsBitArray::try_from) + .transpose() + .unwrap_or_else(|err| { + error!( + ?err, + %fork, "Failed to convert custody columns for forkchoice update" + ); + None + }) + } + + pub async fn get_payload( + &self, + fork: ForkName, + payload_id: PayloadId, + ) -> Result, Error> { + let path = format!("payloads/0x{}", hex::encode(payload_id)); + let response = self + .rest_request( + Method::GET, + &path, + Some(fork), + None, + OCTET_STREAM, + Some(metrics::GET_PAYLOAD), + ENGINE_GET_PAYLOAD_TIMEOUT * self.execution_timeout_multiplier, + ) + .await? + .ok_or_else(|| Error::BadResponse("unexpected 204 on /payloads/{id}".to_string()))?; + + let built_payload = SszGetPayloadResponse::::from_ssz_bytes_by_fork(&response, fork) + .map_err(Error::SszDecode)?; + GetPayloadResponse::try_from(built_payload) + .map_err(|e| Error::BadResponse(format!("{e:?}"))) + } + + pub async fn get_blobs_v2( + &self, + versioned_hashes: Vec, + ) -> Result>>, Error> { + let body = SszBlobsRequest::::new_blobs_request_v1(versioned_hashes)?.as_ssz_bytes(); + let Some(response) = self + .rest_request( + Method::POST, + "blobs/v2", + None, + Some(body), + OCTET_STREAM, + Some(metrics::GET_BLOBS_V2), + ENGINE_GET_BLOBS_TIMEOUT * self.execution_timeout_multiplier, + ) + .await? + else { + return Ok(None); + }; + let response = + SszBlobsResponseV2::::from_ssz_bytes(&response).map_err(Error::SszDecode)?; + Ok(Some(response.into_v2())) + } + + pub async fn get_blobs_v3( + &self, + versioned_hashes: Vec, + ) -> Result>>, Error> { + let body = SszBlobsRequest::::new_blobs_request_v1(versioned_hashes)?.as_ssz_bytes(); + let Some(response) = self + .rest_request( + Method::POST, + "blobs/v3", + None, + Some(body), + OCTET_STREAM, + Some(metrics::GET_BLOBS_V3), + ENGINE_GET_BLOBS_TIMEOUT * self.execution_timeout_multiplier, + ) + .await? + else { + return Ok(None); + }; + let response = + SszBlobsResponseV2::::from_ssz_bytes(&response).map_err(Error::SszDecode)?; + Ok(Some(response.into_v3())) + } + + pub async fn get_blobs_v4( + &self, + versioned_hashes: Vec, + indices_bitarray: CustodyColumnsBitArray, + ) -> Result>, Error> { + let body = SszBlobsRequest::::new_blobs_request_v2(versioned_hashes, indices_bitarray)? + .as_ssz_bytes(); + let Some(response) = self + .rest_request( + Method::POST, + "blobs/v4", + None, + Some(body), + OCTET_STREAM, + Some(metrics::GET_BLOBS_V4), + ENGINE_GET_BLOBS_TIMEOUT * self.execution_timeout_multiplier, + ) + .await? + else { + return Ok(None); + }; + let response = + SszBlobsResponseV4::::from_ssz_bytes(&response).map_err(Error::SszDecode)?; + Ok(Some(response.into_v4())) + } + + pub async fn get_payload_bodies_by_hash_v1( + &self, + fork: ForkName, + block_hashes: Vec, + ) -> Result>>, Error> { + let block_hashes = block_hashes + .into_iter() + .map(|hash| hash.into_root()) + .collect(); + let body = SszBodiesByHashRequest::new(block_hashes)?.as_ssz_bytes(); + let response = self + .rest_request( + Method::POST, + "bodies/hash", + Some(fork), + Some(body), + OCTET_STREAM, + Some(metrics::GET_PAYLOAD_BODIES_BY_HASH), + ENGINE_GET_PAYLOAD_BODIES_TIMEOUT * self.execution_timeout_multiplier, + ) + .await? + .ok_or_else(|| Error::BadResponse("unexpected 204 on /bodies/hash".to_string()))?; + + SszBodiesResponse::::from_ssz_bytes_by_fork(&response, fork) + .map_err(Error::SszDecode)? + .into_bodies() + } + + pub async fn get_payload_bodies_by_hash_v2( + &self, + fork: ForkName, + block_hashes: Vec, + ) -> Result>>, Error> { + let block_hashes = block_hashes + .into_iter() + .map(|hash| hash.into_root()) + .collect(); + let body = SszBodiesByHashRequest::new(block_hashes)?.as_ssz_bytes(); + let response = self + .rest_request( + Method::POST, + "bodies/hash", + Some(fork), + Some(body), + OCTET_STREAM, + Some(metrics::GET_PAYLOAD_BODIES_BY_HASH), + ENGINE_GET_PAYLOAD_BODIES_TIMEOUT * self.execution_timeout_multiplier, + ) + .await? + .ok_or_else(|| Error::BadResponse("unexpected 204 on /bodies/hash".to_string()))?; + + SszBodiesResponse::::from_ssz_bytes_by_fork(&response, fork) + .map_err(Error::SszDecode)? + .into_bodies_v2() + .map_err(Error::SszError) + } +} + +fn fork_to_header(fork: ForkName) -> Result<&'static str, Error> { + Ok(match fork { + ForkName::Bellatrix => "paris", + ForkName::Capella => "shanghai", + ForkName::Deneb => "cancun", + ForkName::Electra => "prague", + ForkName::Fulu => "osaka", + ForkName::Gloas => "amsterdam", + ForkName::Heze => "bogota", + ForkName::Base | ForkName::Altair => { + return Err(Error::UnsupportedForkVariant(format!( + "no engine-api fork header for {fork}" + ))); + } + }) +} + +pub fn header_to_fork(header: &str) -> Option { + Some(match header { + "paris" => ForkName::Bellatrix, + "shanghai" => ForkName::Capella, + "cancun" => ForkName::Deneb, + "prague" => ForkName::Electra, + "osaka" => ForkName::Fulu, + "amsterdam" => ForkName::Gloas, + "bogota" => ForkName::Heze, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::JwtKey; + use crate::engine_api::{ + NewPayloadRequestDeneb, NewPayloadRequestFulu, NewPayloadRequestGloas, + }; + use crate::test_utils::{Config, DEFAULT_JWT_SECRET, MockExecutionConfig, MockServer}; + use std::future::Future; + use std::sync::Arc; + use tokio::runtime; + use types::{ + Address, ExecutionPayloadDeneb, ExecutionPayloadFulu, ExecutionPayloadGloas, + ExecutionRequestsElectra, ExecutionRequestsGloas, MainnetEthSpec, + }; + + #[test] + fn fork_to_header_maps_every_fork() { + assert_eq!(fork_to_header(ForkName::Bellatrix).unwrap(), "paris"); + assert_eq!(fork_to_header(ForkName::Capella).unwrap(), "shanghai"); + assert_eq!(fork_to_header(ForkName::Deneb).unwrap(), "cancun"); + assert_eq!(fork_to_header(ForkName::Electra).unwrap(), "prague"); + assert_eq!(fork_to_header(ForkName::Fulu).unwrap(), "osaka"); + assert_eq!(fork_to_header(ForkName::Gloas).unwrap(), "amsterdam"); + assert!(fork_to_header(ForkName::Base).is_err()); + assert!(fork_to_header(ForkName::Altair).is_err()); + } + + #[test] + fn header_to_fork_maps_every_header() { + assert_eq!(header_to_fork("paris"), Some(ForkName::Bellatrix)); + assert_eq!(header_to_fork("shanghai"), Some(ForkName::Capella)); + assert_eq!(header_to_fork("cancun"), Some(ForkName::Deneb)); + assert_eq!(header_to_fork("prague"), Some(ForkName::Electra)); + assert_eq!(header_to_fork("osaka"), Some(ForkName::Fulu)); + assert_eq!(header_to_fork("amsterdam"), Some(ForkName::Gloas)); + assert_eq!(header_to_fork("frontier"), None); + assert_eq!(header_to_fork(""), None); + } + + #[test] + fn capabilities_parse_full_body() { + let body = r#"{ + "supported_forks": ["paris","shanghai","cancun","prague","osaka","amsterdam"], + "fork_scoped_endpoints": ["payloads","forkchoice","bodies"], + "independently_versioned": { "blobs": ["v1","v2","v3","v4"] }, + "unscoped_endpoints": ["capabilities","identity"], + "limits": { + "bodies.max_count": 32, + "blobs.max_versioned_hashes": 128, + "payload.max_bytes": 67108864 + } + }"#; + let capabilities: SszCapabilities = serde_json::from_str::(body) + .unwrap() + .into(); + + for fork in [ + ForkName::Bellatrix, + ForkName::Capella, + ForkName::Deneb, + ForkName::Electra, + ForkName::Fulu, + ForkName::Gloas, + ] { + assert!(capabilities.supported_forks.contains(&fork)); + } + assert!(capabilities.payloads && capabilities.forkchoice && capabilities.bodies); + assert!( + capabilities.blobs_v1 + && capabilities.blobs_v2 + && capabilities.blobs_v3 + && capabilities.blobs_v4 + ); + assert!(capabilities.get_client_version_v1()); + assert_eq!(capabilities.limits.bodies_max_count, Some(32)); + assert_eq!(capabilities.limits.blobs_max_versioned_hashes, Some(128)); + assert_eq!(capabilities.limits.payload_max_bytes, Some(67108864)); + + assert!(capabilities.new_payload(ForkName::Gloas)); + assert!(!capabilities.new_payload(ForkName::Base)); + assert_eq!(capabilities.highest_supported_fork(), Some(ForkName::Gloas)); + } + + #[test] + fn capabilities_ignore_unknowns_and_default_absent_fields() { + let body = r#"{ + "supported_forks": ["amsterdam","futurefork"], + "fork_scoped_endpoints": ["payloads"], + "independently_versioned": { "blobs": [] }, + "unscoped_endpoints": [] + }"#; + let capabilities: SszCapabilities = serde_json::from_str::(body) + .unwrap() + .into(); + + // Unrecognised forks are dropped rather than rejected. + assert!(capabilities.supported_forks.contains(&ForkName::Gloas)); + assert_eq!(capabilities.supported_forks.len(), 1); + assert!(capabilities.payloads && !capabilities.forkchoice && !capabilities.bodies); + assert!(!capabilities.blobs_v2); + assert!(!capabilities.get_client_version_v1()); + assert_eq!(capabilities.limits.payload_max_bytes, None); + } + + #[test] + fn problem_body_parses_with_and_without_detail() { + let with_detail = r#"{"type":"/engine-api/errors/unknown-payload","detail":"no such id"}"#; + let problem: JsonProblem = serde_json::from_str(with_detail).unwrap(); + assert_eq!(problem.type_uri, "/engine-api/errors/unknown-payload"); + assert_eq!(problem.detail.as_deref(), Some("no such id")); + + let canned = r#"{"type":"/engine-api/errors/ssz-decode-error"}"#; + let problem: JsonProblem = serde_json::from_str(canned).unwrap(); + assert_eq!(problem.detail, None); + } + + struct ExpectedRest { + method: &'static str, + path: String, + fork_header: Option, + body: Bytes, + } + + struct RestTester { + server: MockServer, + client: Arc, + } + + impl RestTester { + fn new(with_auth: bool) -> Self { + let config = MockExecutionConfig { + server_config: Config::default(), + jwt_key: JwtKey::from_slice(&DEFAULT_JWT_SECRET).unwrap(), + shanghai_time: None, + cancun_time: None, + prague_time: None, + osaka_time: None, + amsterdam_time: None, + serve_rest_ssz: true, + heze_time: None, + }; + let server = MockServer::new_with_config(&runtime::Handle::current(), config, None); + let url = SensitiveUrl::parse(&server.url()).unwrap(); + let client = if with_auth { + let auth = Auth::new(JwtKey::from_slice(&DEFAULT_JWT_SECRET).unwrap(), None, None); + Arc::new(HttpRestSsz::new_with_auth(url, auth, None).unwrap()) + } else { + Arc::new(HttpRestSsz::new(url, None).unwrap()) + }; + Self { server, client } + } + + /// Asserts the request the client put on the wire: fork header + path/query + exact SSZ bytes. + async fn assert_ssz_request_equals( + self, + request_func: R, + expected: ExpectedRest, + ) -> Self + where + R: Fn(Arc) -> F, + F: Future, + { + request_func(self.client.clone()).await; + let capture = self.server.last_rest_request(); + assert_eq!(capture.method, expected.method); + assert_eq!(capture.path, expected.path); + assert_eq!(capture.eth_execution_version, expected.fork_header); + assert_eq!(capture.body, expected.body); + assert!(capture.client_version.is_some()); + self + } + + async fn assert_auth_failure(self, request_func: R) -> Self + where + R: Fn(Arc) -> F, + F: Future>, + T: std::fmt::Debug, + { + let result = request_func(self.client.clone()).await; + assert!( + matches!(result, Err(Error::Auth(_))), + "expected an auth failure, got {result:?}" + ); + self + } + + /// Asserts the typed value the client decoded from the mock's SSZ response. + async fn assert_ssz_response(self, request_func: R, check: impl FnOnce(T)) -> Self + where + R: Fn(Arc) -> F, + F: Future>, + { + let value = request_func(self.client.clone()) + .await + .expect("rest request should succeed"); + check(value); + self + } + } + + #[tokio::test] + async fn identity_request_conformance() { + RestTester::new(true) + .assert_ssz_request_equals( + |client| async move { + let _ = client.get_client_version_v1().await; + }, + ExpectedRest { + method: "GET", + path: "/engine/v1/identity".to_string(), + fork_header: None, + body: Bytes::new(), + }, + ) + .await; + } + + #[tokio::test] + async fn rest_request_without_auth_fails() { + RestTester::new(false) + .assert_auth_failure(|client| async move { client.get_client_version_v1().await }) + .await; + } + + #[tokio::test] + async fn get_payload_request_conformance() { + let payload_id: PayloadId = [1, 2, 3, 4, 5, 6, 7, 8]; + RestTester::new(true) + .assert_ssz_request_equals( + move |client| async move { + let _ = client + .get_payload::(ForkName::Deneb, payload_id) + .await; + }, + ExpectedRest { + method: "GET", + path: "/engine/v1/payloads/0x0102030405060708".to_string(), + fork_header: Some("cancun".to_string()), + body: Bytes::new(), + }, + ) + .await; + } + + #[tokio::test] + async fn get_blobs_v2_request_conformance() { + let versioned_hashes = vec![Hash256::repeat_byte(1), Hash256::repeat_byte(2)]; + let expected_body = Bytes::from( + SszBlobsRequest::::new_blobs_request_v1(versioned_hashes.clone()) + .unwrap() + .as_ssz_bytes(), + ); + RestTester::new(true) + .assert_ssz_request_equals( + move |client| { + let versioned_hashes = versioned_hashes.clone(); + async move { + let _ = client + .get_blobs_v2::(versioned_hashes) + .await; + } + }, + ExpectedRest { + method: "POST", + path: "/engine/v1/blobs/v2".to_string(), + fork_header: None, + body: expected_body, + }, + ) + .await; + } + + #[tokio::test] + async fn get_blobs_v3_request_conformance() { + let versioned_hashes = vec![Hash256::repeat_byte(1), Hash256::repeat_byte(2)]; + let expected_body = Bytes::from( + SszBlobsRequest::::new_blobs_request_v1(versioned_hashes.clone()) + .unwrap() + .as_ssz_bytes(), + ); + RestTester::new(true) + .assert_ssz_request_equals( + move |client| { + let versioned_hashes = versioned_hashes.clone(); + async move { + let _ = client + .get_blobs_v3::(versioned_hashes) + .await; + } + }, + ExpectedRest { + method: "POST", + path: "/engine/v1/blobs/v3".to_string(), + fork_header: None, + body: expected_body, + }, + ) + .await; + } + + #[tokio::test] + async fn get_blobs_v4_request_conformance() { + let versioned_hashes = vec![Hash256::repeat_byte(1), Hash256::repeat_byte(2)]; + let indices_bitarray = CustodyColumnsBitArray::try_from([0u64, 1, 2].as_slice()).unwrap(); + let expected_body = Bytes::from( + SszBlobsRequest::::new_blobs_request_v2( + versioned_hashes.clone(), + indices_bitarray, + ) + .unwrap() + .as_ssz_bytes(), + ); + RestTester::new(true) + .assert_ssz_request_equals( + move |client| { + let versioned_hashes = versioned_hashes.clone(); + async move { + let _ = client + .get_blobs_v4::(versioned_hashes, indices_bitarray) + .await; + } + }, + ExpectedRest { + method: "POST", + path: "/engine/v1/blobs/v4".to_string(), + fork_header: None, + body: expected_body, + }, + ) + .await; + } + + #[tokio::test] + async fn get_payload_bodies_by_hash_request_conformance() { + let block_hashes = vec![ + ExecutionBlockHash::repeat_byte(3), + ExecutionBlockHash::repeat_byte(4), + ]; + let roots = block_hashes + .clone() + .into_iter() + .map(|hash| hash.into_root()) + .collect::>(); + let expected_body = Bytes::from(SszBodiesByHashRequest::new(roots).unwrap().as_ssz_bytes()); + RestTester::new(true) + .assert_ssz_request_equals( + move |client| { + let block_hashes = block_hashes.clone(); + async move { + let _ = client + .get_payload_bodies_by_hash_v1::( + ForkName::Fulu, + block_hashes, + ) + .await; + } + }, + ExpectedRest { + method: "POST", + path: "/engine/v1/bodies/hash".to_string(), + fork_header: Some("osaka".to_string()), + body: expected_body, + }, + ) + .await; + } + + #[tokio::test] + async fn get_payload_bodies_by_hash_v2_request_conformance() { + let block_hashes = vec![ + ExecutionBlockHash::repeat_byte(3), + ExecutionBlockHash::repeat_byte(4), + ]; + let roots = block_hashes + .clone() + .into_iter() + .map(|hash| hash.into_root()) + .collect::>(); + let expected_body = Bytes::from(SszBodiesByHashRequest::new(roots).unwrap().as_ssz_bytes()); + RestTester::new(true) + .assert_ssz_request_equals( + move |client| { + let block_hashes = block_hashes.clone(); + async move { + let _ = client + .get_payload_bodies_by_hash_v2::( + ForkName::Gloas, + block_hashes, + ) + .await; + } + }, + ExpectedRest { + method: "POST", + path: "/engine/v1/bodies/hash".to_string(), + fork_header: Some("amsterdam".to_string()), + body: expected_body, + }, + ) + .await; + } + + fn forkchoice_state() -> ForkchoiceState { + ForkchoiceState { + head_block_hash: ExecutionBlockHash::repeat_byte(1), + safe_block_hash: ExecutionBlockHash::repeat_byte(2), + finalized_block_hash: ExecutionBlockHash::repeat_byte(3), + } + } + + #[tokio::test] + async fn forkchoice_updated_no_attrs_request_conformance() { + let state = forkchoice_state(); + let expected_body = Bytes::from( + SszForkchoiceUpdate::new(ForkName::Deneb, state, None) + .unwrap() + .as_ssz_bytes(), + ); + RestTester::new(true) + .assert_ssz_request_equals( + move |client| async move { + let _ = client + .forkchoice_updated::(ForkName::Deneb, state, None, None) + .await; + }, + ExpectedRest { + method: "POST", + path: "/engine/v1/forkchoice".to_string(), + fork_header: Some("cancun".to_string()), + body: expected_body, + }, + ) + .await; + } + + #[tokio::test] + async fn forkchoice_updated_with_attrs_request_conformance() { + let state = forkchoice_state(); + let attributes = PayloadAttributes::new( + 1_000, + Hash256::repeat_byte(4), + Address::repeat_byte(9), + Some(vec![]), + Some(Hash256::repeat_byte(5)), + None, + None, + None, + ); + let expected_body = Bytes::from( + SszForkchoiceUpdate::new(ForkName::Deneb, state, Some(attributes.clone())) + .unwrap() + .as_ssz_bytes(), + ); + RestTester::new(true) + .assert_ssz_request_equals( + move |client| { + let attributes = attributes.clone(); + async move { + let _ = client + .forkchoice_updated::( + ForkName::Deneb, + state, + Some(attributes), + None, + ) + .await; + } + }, + ExpectedRest { + method: "POST", + path: "/engine/v1/forkchoice".to_string(), + fork_header: Some("cancun".to_string()), + body: expected_body, + }, + ) + .await; + } + + #[tokio::test] + async fn forkchoice_updated_amsterdam_request_conformance() { + let state = forkchoice_state(); + let expected_body = Bytes::from( + SszForkchoiceUpdateCustodyColumns::::new( + ForkName::Gloas, + state, + None, + None, + ) + .unwrap() + .as_ssz_bytes(), + ); + RestTester::new(true) + .assert_ssz_request_equals( + move |client| async move { + let _ = client + .forkchoice_updated::(ForkName::Gloas, state, None, None) + .await; + }, + ExpectedRest { + method: "POST", + path: "/engine/v1/forkchoice".to_string(), + fork_header: Some("amsterdam".to_string()), + body: expected_body, + }, + ) + .await; + } + + #[tokio::test] + async fn new_payload_deneb_request_conformance() { + let payload = ExecutionPayloadDeneb::::default(); + let versioned_hashes = vec![Hash256::repeat_byte(7)]; + let parent_beacon_block_root = Hash256::repeat_byte(8); + // The envelope drops `versioned_hashes`; the byte-compare confirms they are not sent. + let expected_body = Bytes::from( + SszExecutionPayloadEnvelopeDeneb::from(NewPayloadRequestDeneb { + execution_payload: &payload, + versioned_hashes: versioned_hashes.clone(), + parent_beacon_block_root, + }) + .as_ssz_bytes(), + ); + RestTester::new(true) + .assert_ssz_request_equals( + move |client| { + let payload = payload.clone(); + let versioned_hashes = versioned_hashes.clone(); + async move { + let request = NewPayloadRequest::Deneb(NewPayloadRequestDeneb { + execution_payload: &payload, + versioned_hashes, + parent_beacon_block_root, + }); + let _ = client.new_payload::(request).await; + } + }, + ExpectedRest { + method: "POST", + path: "/engine/v1/payloads".to_string(), + fork_header: Some("cancun".to_string()), + body: expected_body, + }, + ) + .await; + } + + #[tokio::test] + async fn new_payload_gloas_request_conformance() { + let payload = ExecutionPayloadGloas::::default(); + let versioned_hashes = vec![Hash256::repeat_byte(7)]; + let parent_beacon_block_root = Hash256::repeat_byte(8); + let execution_requests = ExecutionRequestsGloas::::default(); + let expected_body = Bytes::from( + SszExecutionPayloadEnvelopeGloas::try_from(NewPayloadRequestGloas { + execution_payload: &payload, + versioned_hashes: versioned_hashes.clone(), + parent_beacon_block_root, + execution_requests: &execution_requests, + }) + .unwrap() + .as_ssz_bytes(), + ); + RestTester::new(true) + .assert_ssz_request_equals( + move |client| { + let payload = payload.clone(); + let versioned_hashes = versioned_hashes.clone(); + let execution_requests = execution_requests.clone(); + async move { + let request = NewPayloadRequest::Gloas(NewPayloadRequestGloas { + execution_payload: &payload, + versioned_hashes, + parent_beacon_block_root, + execution_requests: &execution_requests, + }); + let _ = client.new_payload::(request).await; + } + }, + ExpectedRest { + method: "POST", + path: "/engine/v1/payloads".to_string(), + fork_header: Some("amsterdam".to_string()), + body: expected_body, + }, + ) + .await; + } + + #[tokio::test] + async fn new_payload_valid_round_trip() { + let payload = ExecutionPayloadFulu::::default(); + let execution_requests = ExecutionRequestsElectra::::default(); + let tester = RestTester::new(true); + tester.server.all_payloads_valid_on_new_payload(); + tester + .assert_ssz_response( + move |client| { + let payload = payload.clone(); + let execution_requests = execution_requests.clone(); + async move { + let request = NewPayloadRequest::Fulu(NewPayloadRequestFulu { + execution_payload: &payload, + versioned_hashes: vec![], + parent_beacon_block_root: Hash256::repeat_byte(0), + execution_requests: &execution_requests, + }); + client.new_payload::(request).await + } + }, + |status| assert_eq!(status.status, PayloadStatusV1Status::Valid), + ) + .await; + } + + #[tokio::test] + async fn new_payload_syncing_round_trip() { + let payload = ExecutionPayloadFulu::::default(); + let execution_requests = ExecutionRequestsElectra::::default(); + let tester = RestTester::new(true); + tester.server.all_payloads_syncing_on_new_payload(false); + tester + .assert_ssz_response( + move |client| { + let payload = payload.clone(); + let execution_requests = execution_requests.clone(); + async move { + let request = NewPayloadRequest::Fulu(NewPayloadRequestFulu { + execution_payload: &payload, + versioned_hashes: vec![], + parent_beacon_block_root: Hash256::repeat_byte(0), + execution_requests: &execution_requests, + }); + client.new_payload::(request).await + } + }, + |status| assert_eq!(status.status, PayloadStatusV1Status::Syncing), + ) + .await; + } + + #[tokio::test] + async fn new_payload_invalid_block_hash_folds_to_invalid_round_trip() { + let payload = ExecutionPayloadFulu::::default(); + let execution_requests = ExecutionRequestsElectra::::default(); + let tester = RestTester::new(true); + tester + .server + .all_payloads_invalid_block_hash_on_new_payload(); + tester + .assert_ssz_response( + move |client| { + let payload = payload.clone(); + let execution_requests = execution_requests.clone(); + async move { + let request = NewPayloadRequest::Fulu(NewPayloadRequestFulu { + execution_payload: &payload, + versioned_hashes: vec![], + parent_beacon_block_root: Hash256::repeat_byte(0), + execution_requests: &execution_requests, + }); + client.new_payload::(request).await + } + }, + |status| assert_eq!(status.status, PayloadStatusV1Status::Invalid), + ) + .await; + } + + #[tokio::test] + async fn forkchoice_updated_no_attrs_round_trip() { + let state = forkchoice_state(); + let tester = RestTester::new(true); + tester.server.set_fcu_payload_status( + state.head_block_hash, + PayloadStatusV1 { + status: PayloadStatusV1Status::Valid, + latest_valid_hash: None, + validation_error: None, + inclusion_list_satisfied: None, + }, + ); + tester + .assert_ssz_response( + move |client| async move { + client + .forkchoice_updated::(ForkName::Fulu, state, None, None) + .await + }, + |response| { + assert_eq!(response.payload_status.status, PayloadStatusV1Status::Valid); + assert!(response.payload_id.is_none()); + }, + ) + .await; + } + + #[tokio::test] + async fn forkchoice_updated_with_attrs_round_trip() { + let state = forkchoice_state(); + let attributes = PayloadAttributes::new( + 1_000, + Hash256::repeat_byte(4), + Address::repeat_byte(9), + Some(vec![]), + Some(Hash256::repeat_byte(5)), + None, + None, + None, + ); + let tester = RestTester::new(true); + tester.server.set_fcu_payload_status( + state.head_block_hash, + PayloadStatusV1 { + status: PayloadStatusV1Status::Valid, + latest_valid_hash: None, + validation_error: None, + inclusion_list_satisfied: None, + }, + ); + tester + .assert_ssz_response( + move |client| { + let attributes = attributes.clone(); + async move { + client + .forkchoice_updated::( + ForkName::Fulu, + state, + Some(attributes), + None, + ) + .await + } + }, + |response| { + assert_eq!(response.payload_status.status, PayloadStatusV1Status::Valid); + }, + ) + .await; + } + + #[tokio::test] + async fn get_payload_unknown_id_returns_404() { + let tester = RestTester::new(true); + let payload_id: PayloadId = [9; 8]; + let result = tester + .client + .get_payload::(ForkName::Fulu, payload_id) + .await; + assert!( + matches!(&result, Err(e) if e.is_unknown_payload()), + "expected unknown-payload error, got {result:?}" + ); + } + + #[tokio::test] + async fn get_blobs_v2_miss_returns_none() { + RestTester::new(true) + .assert_ssz_response( + |client| async move { + client + .get_blobs_v2::(vec![Hash256::repeat_byte(1)]) + .await + }, + |blobs| assert!(blobs.is_none()), + ) + .await; + } +} diff --git a/beacon_node/execution_layer/src/engine_api/ssz_structures.rs b/beacon_node/execution_layer/src/engine_api/ssz_structures.rs new file mode 100644 index 00000000000..940ec387f13 --- /dev/null +++ b/beacon_node/execution_layer/src/engine_api/ssz_structures.rs @@ -0,0 +1,1515 @@ +use crate::json_structures::{BlobAndProofV2, BlobAndProofV3}; + +use super::json_structures::{ + BlobCellsAndProofsV1, CustodyColumnsBitArray, GetBlobsV4List, JsonCell, RequestsError, +}; +use super::*; +use serde::Deserialize; +use ssz::{Decode, DecodeError}; +use ssz_derive::{Decode, Encode}; +use ssz_types::{BitVector, ProgressiveVariableList, VariableList}; +use std::collections::HashSet; +use superstruct::superstruct; +use typenum::{U1, U32}; +use types::data::Cell; +use types::execution::{ + BuilderDepositRequests, BuilderExitRequests, ConsolidationRequests, DepositRequests, + ExecutionRequestsElectra, ExecutionRequestsGloas, RequestType, WithdrawalRequests, +}; +use types::{EthSpec, KzgProof, ProgressiveTransactions, Transactions}; +use types::{ + ExecutionPayloadBellatrix, ExecutionPayloadCapella, ExecutionPayloadDeneb, + ExecutionPayloadElectra, ExecutionPayloadFulu, ExecutionPayloadGloas, ExecutionPayloadHeze, + ExecutionRequests, ForkName, +}; + +#[superstruct( + variants(V1, V2, V3), + variant_attributes(derive(Clone, Debug, Encode, Decode, PartialEq),), + cast_error(ty = "Error", expr = "Error::IncorrectStateVariant"), + partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant") +)] +#[derive(Clone, Debug, Encode, Decode, PartialEq)] +#[ssz(enum_behaviour = "transparent")] +pub struct SszExecutionPayloadBody { + pub transactions: Transactions, + #[superstruct(only(V2, V3))] + pub withdrawals: Withdrawals, + #[superstruct(only(V3))] + pub block_access_list: VariableList, +} + +// SszExecutionPayloadBodyV1 <-> ExecutionPayloadBodyV1 + +impl From> for ExecutionPayloadBodyV1 { + fn from(value: SszExecutionPayloadBodyV1) -> Self { + Self { + transactions: value.transactions, + withdrawals: None, + } + } +} + +impl From> for SszExecutionPayloadBodyV1 { + fn from(value: ExecutionPayloadBodyV1) -> Self { + Self { + transactions: value.transactions, + } + } +} + +// SszExecutionPayloadBodyV2 <-> ExecutionPayloadBodyV1 + +impl From> for ExecutionPayloadBodyV1 { + fn from(value: SszExecutionPayloadBodyV2) -> Self { + Self { + transactions: value.transactions, + withdrawals: Some(value.withdrawals), + } + } +} + +impl TryFrom> for SszExecutionPayloadBodyV2 { + type Error = Error; + + fn try_from(value: ExecutionPayloadBodyV1) -> Result { + Ok(Self { + transactions: value.transactions, + withdrawals: value.withdrawals.ok_or_else(|| { + Error::UnsupportedForkVariant("no withdrawals for a Shanghai (V2) body".to_string()) + })?, + }) + } +} + +// SszExecutionPayloadBodyV1 <-> ExecutionPayloadBodyV2 + +/// Repackage the bounded SSZ wire transactions into the progressive (Gloas) container. +fn progressive_transactions_from_wire( + transactions: Transactions, +) -> Result { + transactions + .into_iter() + .map(|transaction| ProgressiveVariableList::new(transaction.to_vec())) + .collect::, _>>() + .and_then(ProgressiveVariableList::new) +} + +fn wire_transactions_from_progressive( + transactions: ProgressiveTransactions, +) -> Result, ssz_types::Error> { + transactions + .into_iter() + .map(|transaction| VariableList::new(transaction.to_vec())) + .collect::, _>>() + .and_then(VariableList::new) +} + +impl TryFrom> for ExecutionPayloadBodyV2 { + type Error = ssz_types::Error; + + fn try_from(value: SszExecutionPayloadBodyV1) -> Result { + Ok(Self { + transactions: progressive_transactions_from_wire::(value.transactions)?, + withdrawals: None, + block_access_list: None, + }) + } +} + +impl TryFrom> for SszExecutionPayloadBodyV1 { + type Error = Error; + + fn try_from(value: ExecutionPayloadBodyV2) -> Result { + if value.withdrawals.is_some() || value.block_access_list.is_some() { + return Err(Error::UnsupportedForkVariant( + "withdrawals or block access list in a Paris (V1) body".to_string(), + )); + } + + Ok(Self { + transactions: wire_transactions_from_progressive::(value.transactions)?, + }) + } +} + +// SszExecutionPayloadBodyV2 <-> ExecutionPayloadBodyV2 + +impl TryFrom> for ExecutionPayloadBodyV2 { + type Error = ssz_types::Error; + + fn try_from(value: SszExecutionPayloadBodyV2) -> Result { + let withdrawals = ProgressiveVariableList::new(value.withdrawals.to_vec())?; + Ok(Self { + transactions: progressive_transactions_from_wire::(value.transactions)?, + withdrawals: Some(withdrawals), + block_access_list: None, + }) + } +} + +impl TryFrom> for SszExecutionPayloadBodyV2 { + type Error = Error; + + fn try_from(value: ExecutionPayloadBodyV2) -> Result { + if value.block_access_list.is_some() { + return Err(Error::UnsupportedForkVariant( + "block access list in a Shanghai (V2) body".to_string(), + )); + } + let withdrawals = value.withdrawals.ok_or_else(|| { + Error::UnsupportedForkVariant("no withdrawals for a Shanghai (V2) body".to_string()) + })?; + + Ok(Self { + transactions: wire_transactions_from_progressive::(value.transactions)?, + withdrawals: VariableList::new(withdrawals.to_vec())?, + }) + } +} + +// SszExecutionPayloadBodyV3 <-> ExecutionPayloadBodyV2 + +impl TryFrom> for ExecutionPayloadBodyV2 { + type Error = ssz_types::Error; + + fn try_from(value: SszExecutionPayloadBodyV3) -> Result { + let withdrawals = ProgressiveVariableList::new(value.withdrawals.to_vec())?; + let block_access_list = ProgressiveVariableList::new(value.block_access_list.to_vec())?; + Ok(Self { + transactions: progressive_transactions_from_wire::(value.transactions)?, + withdrawals: Some(withdrawals), + block_access_list: Some(block_access_list), + }) + } +} + +impl TryFrom> for SszExecutionPayloadBodyV3 { + type Error = Error; + + fn try_from(value: ExecutionPayloadBodyV2) -> Result { + let withdrawals = value.withdrawals.ok_or_else(|| { + Error::UnsupportedForkVariant("no withdrawals for an Amsterdam (V3) body".to_string()) + })?; + let block_access_list = value.block_access_list.ok_or_else(|| { + Error::UnsupportedForkVariant( + "no block access list for an Amsterdam (V3) body".to_string(), + ) + })?; + + // Repackage the unbounded progressive containers into the bounded wire lists, surfacing + // any element that overflows an SSZ length bound as an error. + Ok(Self { + transactions: wire_transactions_from_progressive::(value.transactions)?, + withdrawals: VariableList::new(withdrawals.to_vec())?, + block_access_list: VariableList::new(block_access_list.to_vec())?, + }) + } +} + +type SszExecutionRequests = VariableList< + VariableList::MaxBytesPerTransaction>, + ::MaxExecutionRequestsPerPayload, +>; + +#[superstruct( + variants(Bellatrix, Capella, Deneb, Electra, Fulu, Gloas, Heze), + variant_attributes(derive(Clone, Debug, Encode, Decode, PartialEq),), + map_into(ExecutionPayload), + cast_error(ty = "Error", expr = "Error::IncorrectStateVariant"), + partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant") +)] +#[derive(Clone, Debug, Encode, Decode, PartialEq)] +#[ssz(enum_behaviour = "transparent")] +pub struct SszExecutionPayloadEnvelope { + #[superstruct( + only(Bellatrix), + partial_getter(rename = "execution_payload_bellatrix") + )] + pub execution_payload: ExecutionPayloadBellatrix, + #[superstruct(only(Capella), partial_getter(rename = "execution_payload_capella"))] + pub execution_payload: ExecutionPayloadCapella, + #[superstruct(only(Deneb), partial_getter(rename = "execution_payload_deneb"))] + pub execution_payload: ExecutionPayloadDeneb, + #[superstruct(only(Electra), partial_getter(rename = "execution_payload_electra"))] + pub execution_payload: ExecutionPayloadElectra, + #[superstruct(only(Fulu), partial_getter(rename = "execution_payload_fulu"))] + pub execution_payload: ExecutionPayloadFulu, + #[superstruct(only(Gloas), partial_getter(rename = "execution_payload_gloas"))] + pub execution_payload: ExecutionPayloadGloas, + #[superstruct(only(Heze), partial_getter(rename = "execution_payload_heze"))] + pub execution_payload: ExecutionPayloadHeze, + #[superstruct(only(Deneb, Electra, Fulu, Gloas, Heze))] + pub parent_beacon_block_root: Hash256, + #[superstruct(only(Electra, Fulu, Gloas, Heze))] + pub execution_requests: SszExecutionRequests, +} + +impl<'block, E: EthSpec> From> + for SszExecutionPayloadEnvelopeBellatrix +{ + fn from(value: NewPayloadRequestBellatrix) -> Self { + Self { + execution_payload: value.execution_payload.clone(), + } + } +} + +impl<'block, E: EthSpec> From> + for SszExecutionPayloadEnvelopeCapella +{ + fn from(value: NewPayloadRequestCapella) -> Self { + Self { + execution_payload: value.execution_payload.clone(), + } + } +} + +impl<'block, E: EthSpec> From> + for SszExecutionPayloadEnvelopeDeneb +{ + fn from(value: NewPayloadRequestDeneb<'block, E>) -> Self { + Self { + execution_payload: value.execution_payload.clone(), + parent_beacon_block_root: value.parent_beacon_block_root, + } + } +} + +impl<'block, E: EthSpec> TryFrom> + for SszExecutionPayloadEnvelopeElectra +{ + type Error = ssz_types::Error; + fn try_from(value: NewPayloadRequestElectra<'block, E>) -> Result { + Ok(Self { + execution_payload: value.execution_payload.clone(), + parent_beacon_block_root: value.parent_beacon_block_root, + execution_requests: execution_requests_to_ssz(ExecutionRequests::Electra( + value.execution_requests.clone(), + ))?, + }) + } +} + +impl<'block, E: EthSpec> TryFrom> + for SszExecutionPayloadEnvelopeFulu +{ + type Error = ssz_types::Error; + fn try_from(value: NewPayloadRequestFulu<'block, E>) -> Result { + Ok(Self { + execution_payload: value.execution_payload.clone(), + parent_beacon_block_root: value.parent_beacon_block_root, + execution_requests: execution_requests_to_ssz(ExecutionRequests::Electra( + value.execution_requests.clone(), + ))?, + }) + } +} + +impl<'block, E: EthSpec> TryFrom> + for SszExecutionPayloadEnvelopeGloas +{ + type Error = ssz_types::Error; + fn try_from(value: NewPayloadRequestGloas<'block, E>) -> Result { + Ok(Self { + execution_payload: value.execution_payload.clone(), + parent_beacon_block_root: value.parent_beacon_block_root, + execution_requests: execution_requests_to_ssz(ExecutionRequests::Gloas( + value.execution_requests.clone(), + ))?, + }) + } +} + +impl<'block, E: EthSpec> TryFrom> + for SszExecutionPayloadEnvelopeHeze +{ + type Error = ssz_types::Error; + fn try_from(value: NewPayloadRequestHeze<'block, E>) -> Result { + Ok(Self { + execution_payload: value.execution_payload.clone(), + parent_beacon_block_root: value.parent_beacon_block_root, + execution_requests: execution_requests_to_ssz(ExecutionRequests::Gloas( + value.execution_requests.clone(), + ))?, + }) + } +} + +pub fn execution_requests_to_ssz( + req: ExecutionRequests, +) -> Result, ssz_types::Error> +where + E: EthSpec, +{ + let requests = req + .get_execution_requests_list() + .into_iter() + .map(|bytes| VariableList::new(bytes.to_vec())) + .collect::, _>>()?; + + VariableList::new(requests) +} + +/// Parse an EIP-7685 SSZ requests list into its component request lists. +/// +/// Returns the deposit, withdrawal, consolidation, builder deposit and builder exit lists. +/// Builder lists are empty pre-gloas or post-gloas when no builder requests are present. +#[allow(clippy::type_complexity)] +fn parse_execution_requests_ssz( + ssz_requests: SszExecutionRequests, +) -> Result< + ( + DepositRequests, + WithdrawalRequests, + ConsolidationRequests, + BuilderDepositRequests, + BuilderExitRequests, + ), + RequestsError, +> +where + E: EthSpec, +{ + let mut deposits = DepositRequests::::default(); + let mut withdrawals = WithdrawalRequests::::default(); + let mut consolidations = ConsolidationRequests::::default(); + let mut builder_deposits = BuilderDepositRequests::::default(); + let mut builder_exits = BuilderExitRequests::::default(); + let mut prev_prefix: Option = None; + + for (i, request) in ssz_requests.iter().enumerate() { + let request_bytes: &[u8] = request; + + // The first byte is the `request_type`; the remaining bytes are the `request_data`. + // Elements with empty `request_data` **MUST** be excluded from the list. + let Some((prefix_byte, request_data)) = request_bytes.split_first() else { + return Err(RequestsError::EmptyRequest(i)); + }; + if request_data.is_empty() { + return Err(RequestsError::EmptyRequest(i)); + } + + // Elements of the list **MUST** be ordered by `request_type` in ascending order. + let current_prefix = + RequestType::from_u8(*prefix_byte).ok_or(RequestsError::InvalidPrefix(*prefix_byte))?; + if let Some(prev) = prev_prefix + && prev.to_u8() >= current_prefix.to_u8() + { + return Err(RequestsError::InvalidOrdering); + } + prev_prefix = Some(current_prefix); + + match current_prefix { + RequestType::Deposit => { + deposits = DepositRequests::::from_ssz_bytes(request_data).map_err(|e| { + RequestsError::DecodeError(format!( + "Failed to decode DepositRequest from EL: {:?}", + e + )) + })?; + } + RequestType::Withdrawal => { + withdrawals = + WithdrawalRequests::::from_ssz_bytes(request_data).map_err(|e| { + RequestsError::DecodeError(format!( + "Failed to decode WithdrawalRequest from EL: {:?}", + e + )) + })?; + } + RequestType::Consolidation => { + consolidations = + ConsolidationRequests::::from_ssz_bytes(request_data).map_err(|e| { + RequestsError::DecodeError(format!( + "Failed to decode ConsolidationRequest from EL: {:?}", + e + )) + })?; + } + RequestType::BuilderDeposit => { + builder_deposits = BuilderDepositRequests::::from_ssz_bytes(request_data) + .map_err(|e| { + RequestsError::DecodeError(format!( + "Failed to decode BuilderDepositRequest from EL: {:?}", + e + )) + })?; + } + RequestType::BuilderExit => { + builder_exits = + BuilderExitRequests::::from_ssz_bytes(request_data).map_err(|e| { + RequestsError::DecodeError(format!( + "Failed to decode BuilderExitRequest from EL: {:?}", + e + )) + })?; + } + } + } + + Ok(( + deposits, + withdrawals, + consolidations, + builder_deposits, + builder_exits, + )) +} + +fn execution_requests_electra_from_ssz( + ssz_requests: SszExecutionRequests, +) -> Result, RequestsError> +where + E: EthSpec, +{ + let (deposits, withdrawals, consolidations, builder_deposits, builder_exits) = + parse_execution_requests_ssz::(ssz_requests)?; + // Builder requests are not valid pre-Gloas. + if !builder_deposits.is_empty() || !builder_exits.is_empty() { + return Err(RequestsError::VariantMismatch); + } + Ok(ExecutionRequestsElectra { + deposits, + withdrawals, + consolidations, + }) +} + +fn execution_requests_gloas_from_ssz( + ssz_requests: SszExecutionRequests, +) -> Result, RequestsError> +where + E: EthSpec, +{ + let (deposits, withdrawals, consolidations, builder_deposits, builder_exits) = + parse_execution_requests_ssz::(ssz_requests)?; + // Gloas requests use progressive lists (deposits are unlimited; the rest carry a length bound). + let to_progressive = |name: &str, e: ssz_types::Error| { + RequestsError::DecodeError(format!("{name} exceed SSZ bound: {e:?}")) + }; + Ok(ExecutionRequestsGloas { + deposits: ProgressiveVariableList::new(deposits.to_vec()) + .map_err(|e| to_progressive("deposits", e))?, + withdrawals: ProgressiveVariableList::new(withdrawals.to_vec()) + .map_err(|e| to_progressive("withdrawals", e))?, + consolidations: ProgressiveVariableList::new(consolidations.to_vec()) + .map_err(|e| to_progressive("consolidations", e))?, + builder_deposits: ProgressiveVariableList::new(builder_deposits.to_vec()) + .map_err(|e| to_progressive("builder_deposits", e))?, + builder_exits: ProgressiveVariableList::new(builder_exits.to_vec()) + .map_err(|e| to_progressive("builder_exits", e))?, + }) +} + +#[derive(Debug, PartialEq, Encode, Decode, Clone)] +pub struct SszPayloadStatusV1 { + pub payload_status: u8, + pub latest_valid_hash: VariableList, + pub validation_error: VariableList, U1>, +} + +impl TryFrom> for PayloadStatusV1 { + type Error = Error; + + fn try_from(value: SszPayloadStatusV1) -> Result { + let status = match value.payload_status { + 0 => PayloadStatusV1Status::Valid, + 1 => PayloadStatusV1Status::Invalid, + 2 => PayloadStatusV1Status::Syncing, + 3 => PayloadStatusV1Status::Accepted, + other => { + return Err(Error::BadResponse(format!( + "invalid payload status {other} from EL" + ))); + } + }; + + Ok(Self { + status, + latest_valid_hash: value + .latest_valid_hash + .first() + .copied() + .map(ExecutionBlockHash::from_root), + validation_error: value + .validation_error + .first() + .map(|bytes| String::from_utf8_lossy(bytes).into_owned()), + inclusion_list_satisfied: None, + }) + } +} + +impl TryFrom for SszPayloadStatusV1 { + type Error = ssz_types::Error; + + fn try_from(value: PayloadStatusV1) -> Result { + let status = match value.status { + PayloadStatusV1Status::Valid => 0, + PayloadStatusV1Status::Invalid => 1, + PayloadStatusV1Status::Syncing => 2, + PayloadStatusV1Status::Accepted => 3, + // INVALID_BLOCK_HASH is folded into INVALID on the wire + PayloadStatusV1Status::InvalidBlockHash => 1, + }; + let latest_valid_hash = VariableList::new( + value + .latest_valid_hash + .map(|h| h.into_root()) + .into_iter() + .collect(), + )?; + + let validation_error = VariableList::new( + value + .validation_error + .map(|s| VariableList::new(s.into_bytes())) + .transpose()? + .into_iter() + .collect(), + )?; + + Ok(Self { + payload_status: status, + latest_valid_hash, + validation_error, + }) + } +} + +#[superstruct( + variants(Bellatrix, Capella, Deneb, Electra, Fulu, Gloas, Heze), + variant_attributes(derive(Debug, PartialEq, Encode, Decode),), + cast_error(ty = "Error", expr = "Error::IncorrectStateVariant"), + partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant") +)] +#[derive(Debug, PartialEq, Encode, Decode)] +#[ssz(enum_behaviour = "transparent")] +pub struct SszGetPayloadResponse { + #[superstruct( + only(Bellatrix), + partial_getter(rename = "execution_payload_bellatrix") + )] + pub execution_payload: ExecutionPayloadBellatrix, + #[superstruct(only(Capella), partial_getter(rename = "execution_payload_capella"))] + pub execution_payload: ExecutionPayloadCapella, + #[superstruct(only(Deneb), partial_getter(rename = "execution_payload_deneb"))] + pub execution_payload: ExecutionPayloadDeneb, + #[superstruct(only(Electra), partial_getter(rename = "execution_payload_electra"))] + pub execution_payload: ExecutionPayloadElectra, + #[superstruct(only(Fulu), partial_getter(rename = "execution_payload_fulu"))] + pub execution_payload: ExecutionPayloadFulu, + #[superstruct(only(Gloas), partial_getter(rename = "execution_payload_gloas"))] + pub execution_payload: ExecutionPayloadGloas, + #[superstruct(only(Heze), partial_getter(rename = "execution_payload_heze"))] + pub execution_payload: ExecutionPayloadHeze, + pub block_value: Uint256, + #[superstruct(only(Deneb, Electra, Fulu, Gloas, Heze))] + pub blobs_bundle: BlobsBundle, + #[superstruct(only(Electra, Fulu, Gloas, Heze))] + pub requests: SszExecutionRequests, + #[superstruct(only(Deneb, Electra, Fulu, Gloas, Heze), partial_getter(copy))] + pub should_override_builder: bool, +} + +impl SszGetPayloadResponse { + pub fn from_ssz_bytes_by_fork(bytes: &[u8], fork: ForkName) -> Result { + match fork { + ForkName::Bellatrix => { + SszGetPayloadResponseBellatrix::from_ssz_bytes(bytes).map(Self::Bellatrix) + } + ForkName::Capella => { + SszGetPayloadResponseCapella::from_ssz_bytes(bytes).map(Self::Capella) + } + ForkName::Deneb => SszGetPayloadResponseDeneb::from_ssz_bytes(bytes).map(Self::Deneb), + ForkName::Electra => { + SszGetPayloadResponseElectra::from_ssz_bytes(bytes).map(Self::Electra) + } + ForkName::Fulu => SszGetPayloadResponseFulu::from_ssz_bytes(bytes).map(Self::Fulu), + ForkName::Gloas | ForkName::Heze => { + SszGetPayloadResponseGloas::from_ssz_bytes(bytes).map(Self::Gloas) + } + ForkName::Base | ForkName::Altair => Err(DecodeError::BytesInvalid(format!( + "unsupported fork for get_payload response: {fork}" + ))), + } + } +} + +impl TryFrom> for GetPayloadResponse { + type Error = RequestsError; + + fn try_from(value: SszGetPayloadResponse) -> Result { + match value { + SszGetPayloadResponse::Bellatrix(response) => { + Ok(GetPayloadResponse::Bellatrix(GetPayloadResponseBellatrix { + execution_payload: response.execution_payload, + block_value: response.block_value, + })) + } + SszGetPayloadResponse::Capella(response) => { + Ok(GetPayloadResponse::Capella(GetPayloadResponseCapella { + execution_payload: response.execution_payload, + block_value: response.block_value, + })) + } + SszGetPayloadResponse::Deneb(response) => { + Ok(GetPayloadResponse::Deneb(GetPayloadResponseDeneb { + execution_payload: response.execution_payload, + block_value: response.block_value, + blobs_bundle: response.blobs_bundle, + should_override_builder: response.should_override_builder, + })) + } + SszGetPayloadResponse::Electra(response) => { + Ok(GetPayloadResponse::Electra(GetPayloadResponseElectra { + execution_payload: response.execution_payload, + block_value: response.block_value, + blobs_bundle: response.blobs_bundle, + should_override_builder: response.should_override_builder, + requests: execution_requests_electra_from_ssz(response.requests)?, + })) + } + SszGetPayloadResponse::Fulu(response) => { + Ok(GetPayloadResponse::Fulu(GetPayloadResponseFulu { + execution_payload: response.execution_payload, + block_value: response.block_value, + blobs_bundle: response.blobs_bundle, + should_override_builder: response.should_override_builder, + requests: execution_requests_electra_from_ssz(response.requests)?, + })) + } + SszGetPayloadResponse::Gloas(response) => { + Ok(GetPayloadResponse::Gloas(GetPayloadResponseGloas { + execution_payload: response.execution_payload, + block_value: response.block_value, + blobs_bundle: response.blobs_bundle, + should_override_builder: response.should_override_builder, + requests: execution_requests_gloas_from_ssz(response.requests)?, + })) + } + SszGetPayloadResponse::Heze(response) => { + Ok(GetPayloadResponse::Heze(GetPayloadResponseHeze { + execution_payload: response.execution_payload, + block_value: response.block_value, + blobs_bundle: response.blobs_bundle, + should_override_builder: response.should_override_builder, + requests: execution_requests_gloas_from_ssz(response.requests)?, + })) + } + } + } +} + +impl TryFrom> for SszGetPayloadResponse { + type Error = ssz_types::Error; + + fn try_from(value: GetPayloadResponse) -> Result { + match value { + GetPayloadResponse::Bellatrix(response) => Ok(SszGetPayloadResponse::Bellatrix( + SszGetPayloadResponseBellatrix { + execution_payload: response.execution_payload, + block_value: response.block_value, + }, + )), + GetPayloadResponse::Capella(response) => Ok(SszGetPayloadResponse::Capella( + SszGetPayloadResponseCapella { + execution_payload: response.execution_payload, + block_value: response.block_value, + }, + )), + GetPayloadResponse::Deneb(response) => { + Ok(SszGetPayloadResponse::Deneb(SszGetPayloadResponseDeneb { + execution_payload: response.execution_payload, + block_value: response.block_value, + blobs_bundle: response.blobs_bundle, + should_override_builder: response.should_override_builder, + })) + } + GetPayloadResponse::Electra(response) => Ok(SszGetPayloadResponse::Electra( + SszGetPayloadResponseElectra { + execution_payload: response.execution_payload, + block_value: response.block_value, + blobs_bundle: response.blobs_bundle, + requests: execution_requests_to_ssz(ExecutionRequests::Electra( + response.requests, + ))?, + should_override_builder: response.should_override_builder, + }, + )), + GetPayloadResponse::Fulu(response) => { + Ok(SszGetPayloadResponse::Fulu(SszGetPayloadResponseFulu { + execution_payload: response.execution_payload, + block_value: response.block_value, + blobs_bundle: response.blobs_bundle, + requests: execution_requests_to_ssz(ExecutionRequests::Electra( + response.requests, + ))?, + should_override_builder: response.should_override_builder, + })) + } + GetPayloadResponse::Gloas(response) => { + Ok(SszGetPayloadResponse::Gloas(SszGetPayloadResponseGloas { + execution_payload: response.execution_payload, + block_value: response.block_value, + blobs_bundle: response.blobs_bundle, + requests: execution_requests_to_ssz(ExecutionRequests::Gloas( + response.requests, + ))?, + should_override_builder: response.should_override_builder, + })) + } + GetPayloadResponse::Heze(response) => { + Ok(SszGetPayloadResponse::Heze(SszGetPayloadResponseHeze { + execution_payload: response.execution_payload, + block_value: response.block_value, + blobs_bundle: response.blobs_bundle, + requests: execution_requests_to_ssz(ExecutionRequests::Gloas( + response.requests, + ))?, + should_override_builder: response.should_override_builder, + })) + } + } + } +} + +/// Pre-Amsterdam SSZ `engine_forkchoiceUpdated` request body. Per-fork so the SSZ list element is +/// the concrete `PayloadAttributesV{N}` rather than the mixed-size transparent `PayloadAttributes`. +#[superstruct( + variants(Bellatrix, Capella, Deneb, Electra, Fulu), + variant_attributes(derive(Clone, Debug, Encode, Decode, PartialEq),), + cast_error(ty = "Error", expr = "Error::IncorrectStateVariant"), + partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant") +)] +#[derive(Clone, Debug, Encode, Decode, PartialEq)] +#[ssz(enum_behaviour = "transparent")] +pub struct SszForkchoiceUpdate { + pub forkchoice_state: ForkchoiceState, + #[superstruct( + only(Bellatrix), + partial_getter(rename = "payload_attributes_bellatrix") + )] + pub payload_attributes: VariableList, + #[superstruct(only(Capella), partial_getter(rename = "payload_attributes_capella"))] + pub payload_attributes: VariableList, + #[superstruct(only(Deneb), partial_getter(rename = "payload_attributes_deneb"))] + pub payload_attributes: VariableList, + #[superstruct(only(Electra), partial_getter(rename = "payload_attributes_electra"))] + pub payload_attributes: VariableList, + #[superstruct(only(Fulu), partial_getter(rename = "payload_attributes_fulu"))] + pub payload_attributes: VariableList, +} + +impl SszForkchoiceUpdate { + pub fn new( + fork: ForkName, + forkchoice_state: ForkchoiceState, + payload_attributes: Option, + ) -> Result { + let update = match fork { + ForkName::Bellatrix => Self::Bellatrix(SszForkchoiceUpdateBellatrix { + forkchoice_state, + payload_attributes: VariableList::new( + payload_attributes + .map(|attributes| attributes.as_v1().cloned()) + .transpose()? + .into_iter() + .collect(), + )?, + }), + ForkName::Capella => Self::Capella(SszForkchoiceUpdateCapella { + forkchoice_state, + payload_attributes: VariableList::new( + payload_attributes + .map(|attributes| attributes.as_v2().cloned()) + .transpose()? + .into_iter() + .collect(), + )?, + }), + ForkName::Deneb | ForkName::Electra | ForkName::Fulu => { + let payload_attributes = VariableList::new( + payload_attributes + .map(|attributes| attributes.as_v3().cloned()) + .transpose()? + .into_iter() + .collect(), + )?; + match fork { + ForkName::Deneb => Self::Deneb(SszForkchoiceUpdateDeneb { + forkchoice_state, + payload_attributes, + }), + ForkName::Electra => Self::Electra(SszForkchoiceUpdateElectra { + forkchoice_state, + payload_attributes, + }), + _ => Self::Fulu(SszForkchoiceUpdateFulu { + forkchoice_state, + payload_attributes, + }), + } + } + other => { + return Err(Error::UnsupportedForkVariant(format!( + "no pre-Amsterdam forkchoice update for {other}" + ))); + } + }; + Ok(update) + } +} + +/// Amsterdam SSZ `engine_forkchoiceUpdated` request body (adds `custody_columns`). +#[superstruct( + variants(Gloas, Heze), + variant_attributes(derive(Clone, Debug, Encode, Decode, PartialEq),), + cast_error(ty = "Error", expr = "Error::IncorrectStateVariant"), + partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant") +)] +#[derive(Clone, Debug, Encode, Decode, PartialEq)] +#[ssz(enum_behaviour = "transparent")] +pub struct SszForkchoiceUpdateCustodyColumns { + pub forkchoice_state: ForkchoiceState, + #[superstruct(only(Gloas), partial_getter(rename = "payload_attributes_gloas"))] + pub payload_attributes: VariableList, + #[superstruct(only(Heze), partial_getter(rename = "payload_attributes_heze"))] + pub payload_attributes: VariableList, + #[superstruct(only(Gloas, Heze))] + pub custody_columns: VariableList, U1>, +} + +impl SszForkchoiceUpdateCustodyColumns { + pub fn new( + fork: ForkName, + forkchoice_state: ForkchoiceState, + payload_attributes: Option, + custody_columns: Option, + ) -> Result { + let update = match fork { + ForkName::Gloas => { + let custody_columns: Option> = + if let Some(bit_array) = custody_columns { + Some(custody_columns_to_bit_vector::(bit_array)?) + } else { + None + }; + + let payload_attributes = VariableList::new( + payload_attributes + .map(|attributes| attributes.as_v4().cloned()) + .transpose()? + .into_iter() + .collect(), + )?; + + Self::Gloas(SszForkchoiceUpdateCustodyColumnsGloas { + forkchoice_state, + payload_attributes, + custody_columns: VariableList::new(custody_columns.into_iter().collect())?, + }) + } + ForkName::Heze => { + let custody_columns: Option> = + if let Some(bit_array) = custody_columns { + Some(custody_columns_to_bit_vector::(bit_array)?) + } else { + None + }; + + let payload_attributes = VariableList::new( + payload_attributes + .map(|attributes| attributes.as_v5().cloned()) + .transpose()? + .into_iter() + .collect(), + )?; + + Self::Heze(SszForkchoiceUpdateCustodyColumnsHeze { + forkchoice_state, + payload_attributes, + custody_columns: VariableList::new(custody_columns.into_iter().collect())?, + }) + } + other => { + return Err(Error::UnsupportedForkVariant(format!( + "no post-Amsterdam forkchoice update for {other}" + ))); + } + }; + Ok(update) + } +} + +#[derive(Debug, Clone, PartialEq, Encode, Decode)] +pub struct SszForkchoiceUpdatedResponse { + pub payload_status: SszPayloadStatusV1, + pub payload_id: VariableList, +} + +impl TryFrom> for ForkchoiceUpdatedResponse { + type Error = Error; + + fn try_from(value: SszForkchoiceUpdatedResponse) -> Result { + Ok(Self { + payload_status: PayloadStatusV1::try_from(value.payload_status)?, + payload_id: value.payload_id.first().copied(), + }) + } +} + +impl TryFrom for SszForkchoiceUpdatedResponse { + type Error = ssz_types::Error; + + fn try_from(value: ForkchoiceUpdatedResponse) -> Result { + Ok(Self { + payload_status: SszPayloadStatusV1::try_from(value.payload_status)?, + payload_id: VariableList::new(value.payload_id.into_iter().collect())?, + }) + } +} + +#[superstruct( + variants(V1, V2), + variant_attributes(derive(Clone, Debug, Encode, Decode, PartialEq),), + cast_error(ty = "Error", expr = "Error::IncorrectStateVariant"), + partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant") +)] +#[derive(Clone, Debug, Encode, Decode, PartialEq)] +#[ssz(enum_behaviour = "transparent")] +pub struct SszBlobsRequest { + pub versioned_hashes: VariableList, + #[superstruct(only(V2))] + pub indices_bitarray: BitVector, +} + +impl SszBlobsRequest { + pub fn new_blobs_request_v1( + versioned_hashes: Vec, + ) -> Result, ssz_types::Error> { + Ok(SszBlobsRequestV1 { + versioned_hashes: VariableList::new(versioned_hashes)?, + }) + } + + pub fn new_blobs_request_v2( + versioned_hashes: Vec, + indices_bitarray: CustodyColumnsBitArray, + ) -> Result, ssz_types::Error> { + let indices = custody_columns_to_bit_vector::(indices_bitarray)?; + Ok(SszBlobsRequestV2 { + versioned_hashes: VariableList::new(versioned_hashes)?, + indices_bitarray: indices, + }) + } +} + +#[superstruct( + variants(V2, V4), + variant_attributes(derive(Clone, Debug, Encode, Decode, PartialEq),), + cast_error(ty = "Error", expr = "Error::IncorrectStateVariant"), + partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant") +)] +#[derive(Clone, Debug, Encode, Decode, PartialEq)] +#[ssz(enum_behaviour = "transparent")] +pub struct BlobsEntry { + #[superstruct(only(V2, V4))] + pub available: bool, + #[superstruct(only(V2), partial_getter(rename = "contents_v2"))] + pub contents: BlobAndProofV2, + #[superstruct(only(V4), partial_getter(rename = "contents_v4"))] + pub contents: SszBlobCellsAndProofs, +} + +#[derive(Clone, Debug, Encode, Decode, PartialEq)] +pub struct SszBlobCellsAndProofs { + pub blob_cells: VariableList, U1>, E::CellsPerExtBlob>, + pub proofs: VariableList, E::CellsPerExtBlob>, +} + +impl From> for BlobCellsAndProofsV1 { + fn from(value: SszBlobCellsAndProofs) -> Self { + Self { + blob_cells: value + .blob_cells + .into_iter() + .map(|cell| cell.into_iter().next().map(JsonCell)) + .collect(), + proofs: value + .proofs + .into_iter() + .map(|proof| proof.into_iter().next()) + .collect(), + } + } +} + +impl TryFrom> for SszBlobCellsAndProofs { + type Error = ssz_types::Error; + + fn try_from(value: BlobCellsAndProofsV1) -> Result { + let blob_cells = value + .blob_cells + .into_iter() + .map(|cell| VariableList::new(cell.map(|json_cell| json_cell.0).into_iter().collect())) + .collect::, _>>()?; + let proofs = value + .proofs + .into_iter() + .map(|proof| VariableList::new(proof.into_iter().collect())) + .collect::, _>>()?; + Ok(Self { + blob_cells: VariableList::new(blob_cells)?, + proofs: VariableList::new(proofs)?, + }) + } +} + +#[superstruct( + variants(V2, V4), + variant_attributes(derive(Clone, Debug, Encode, Decode, PartialEq),), + cast_error(ty = "Error", expr = "Error::IncorrectStateVariant"), + partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant") +)] +#[derive(Clone, Debug, Encode, Decode, PartialEq)] +#[ssz(enum_behaviour = "transparent")] +pub struct SszBlobsResponse { + #[superstruct(only(V2), partial_getter(rename = "entries_v2"))] + pub entries: VariableList, E::MaxVersionedHashesPerRequest>, + #[superstruct(only(V4), partial_getter(rename = "entries_v4"))] + pub entries: VariableList, E::MaxVersionedHashesPerRequest>, +} + +impl SszBlobsResponseV2 { + /// `/blobs/v2` (all-or-nothing): a miss is a `204`, so every entry in a `200` body is present. + pub fn into_v2(self) -> Vec> { + self.entries + .into_iter() + .map(|entry| entry.contents) + .collect() + } + + /// `/blobs/v3` (partial): `available == false` → zero-valued contents ignored → `None`. + pub fn into_v3(self) -> Vec> { + self.entries + .into_iter() + .map(|entry| entry.available.then_some(entry.contents)) + .collect() + } +} + +impl SszBlobsResponseV4 { + /// `/blobs/v4` (partial): `available == false` → `None`; else the cell/proof contents, + /// converted to the transport-neutral `BlobCellsAndProofsV1`. + pub fn into_v4(self) -> GetBlobsV4List { + self.entries + .into_iter() + .map(|entry| entry.available.then(|| entry.contents.into())) + .collect() + } +} + +pub type MaxBodiesRequest = U32; + +#[derive(Clone, Debug, Encode, Decode, PartialEq)] +pub struct SszBodiesByHashRequest { + pub block_hashes: VariableList, +} + +impl SszBodiesByHashRequest { + pub fn new(block_hashes: Vec) -> Result { + Ok(Self { + block_hashes: VariableList::new(block_hashes)?, + }) + } +} + +/// SSZ `BodyEntry`. `V1` = Paris (Bellatrix), `V2` = Shanghai..Osaka (Capella..Fulu), +/// `V3` = Amsterdam (Gloas), Bogota (Heze) — the `body` variant tracks the fork's body schema. +#[superstruct( + variants(V1, V2, V3), + variant_attributes(derive(Clone, Debug, Encode, Decode, PartialEq),), + cast_error(ty = "Error", expr = "Error::IncorrectStateVariant"), + partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant") +)] +#[derive(Clone, Debug, Encode, Decode, PartialEq)] +#[ssz(enum_behaviour = "transparent")] +pub struct SszBodyEntry { + pub available: bool, + #[superstruct(only(V1), partial_getter(rename = "body_v1"))] + pub body: SszExecutionPayloadBodyV1, + #[superstruct(only(V2), partial_getter(rename = "body_v2"))] + pub body: SszExecutionPayloadBodyV2, + #[superstruct(only(V3), partial_getter(rename = "body_v3"))] + pub body: SszExecutionPayloadBodyV3, +} + +/// SSZ `BodiesResponse` for `engine_getPayloadBodiesByHash`. +/// +/// The response is fork-homogeneous: every entry is serialised against the fork +/// named by the `Eth-Execution-Version` request header, so the variant is known +/// from the request and is never inferred from the bytes (see `from_ssz_bytes_by_fork`). +#[superstruct( + variants(V1, V2, V3), + variant_attributes(derive(Clone, Debug, Encode, Decode, PartialEq),), + cast_error(ty = "Error", expr = "Error::IncorrectStateVariant"), + partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant") +)] +#[derive(Clone, Debug, Encode, Decode, PartialEq)] +#[ssz(enum_behaviour = "transparent")] +pub struct SszBodiesResponse { + #[superstruct(only(V1), partial_getter(rename = "entries_v1"))] + pub entries: VariableList, MaxBodiesRequest>, + #[superstruct(only(V2), partial_getter(rename = "entries_v2"))] + pub entries: VariableList, MaxBodiesRequest>, + #[superstruct(only(V3), partial_getter(rename = "entries_v3"))] + pub entries: VariableList, MaxBodiesRequest>, +} + +impl SszBodiesResponse { + pub fn from_ssz_bytes_by_fork(bytes: &[u8], fork_name: ForkName) -> Result { + match fork_name { + ForkName::Base | ForkName::Altair => Err(DecodeError::BytesInvalid(format!( + "unsupported fork for execution payload bodies: {fork_name}" + ))), + ForkName::Bellatrix => SszBodiesResponseV1::from_ssz_bytes(bytes).map(Self::V1), + ForkName::Capella | ForkName::Deneb | ForkName::Electra | ForkName::Fulu => { + SszBodiesResponseV2::from_ssz_bytes(bytes).map(Self::V2) + } + ForkName::Gloas | ForkName::Heze => { + SszBodiesResponseV3::from_ssz_bytes(bytes).map(Self::V3) + } + } + } + + pub fn into_bodies(self) -> Result>>, Error> { + match self { + Self::V1(resp) => Ok(resp + .entries + .into_iter() + .map(|entry| { + entry + .available + .then(|| ExecutionPayloadBodyV1::from(entry.body)) + }) + .collect()), + Self::V2(resp) => Ok(resp + .entries + .into_iter() + .map(|entry| { + entry + .available + .then(|| ExecutionPayloadBodyV1::from(entry.body)) + }) + .collect()), + Self::V3(_) => Err(Error::UnsupportedForkVariant( + "V3 bodies have no ExecutionPayloadBodyV1 form, use into_bodies_v2".to_string(), + )), + } + } + + pub fn into_bodies_v2( + self, + ) -> Result>>, ssz_types::Error> { + match self { + Self::V1(resp) => resp + .entries + .into_iter() + .map(|entry| { + entry + .available + .then(|| ExecutionPayloadBodyV2::try_from(entry.body)) + .transpose() + }) + .collect(), + Self::V2(resp) => resp + .entries + .into_iter() + .map(|entry| { + entry + .available + .then(|| ExecutionPayloadBodyV2::try_from(entry.body)) + .transpose() + }) + .collect(), + Self::V3(resp) => resp + .entries + .into_iter() + .map(|entry| { + entry + .available + .then(|| ExecutionPayloadBodyV2::try_from(entry.body)) + .transpose() + }) + .collect(), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SszLimits { + pub bodies_max_count: Option, + pub blobs_max_versioned_hashes: Option, + pub payload_max_bytes: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SszCapabilities { + pub supported_forks: HashSet, + pub payloads: bool, + pub forkchoice: bool, + pub bodies: bool, + pub blobs_v1: bool, + pub blobs_v2: bool, + pub blobs_v3: bool, + pub blobs_v4: bool, + pub unscoped_endpoints: Vec, + pub limits: SszLimits, +} + +impl SszCapabilities { + pub fn new_payload(&self, fork: ForkName) -> bool { + self.supported_forks.contains(&fork) && self.payloads + } + + pub fn get_payload(&self, fork: ForkName) -> bool { + self.supported_forks.contains(&fork) && self.payloads + } + + pub fn forkchoice_updated(&self, fork: ForkName) -> bool { + self.supported_forks.contains(&fork) && self.forkchoice + } + + pub fn get_payload_bodies(&self, fork: ForkName) -> bool { + self.supported_forks.contains(&fork) && self.bodies + } + + pub fn get_blobs_v2(&self) -> bool { + self.blobs_v2 + } + + pub fn get_blobs_v3(&self) -> bool { + self.blobs_v3 + } + + pub fn get_blobs_v4(&self) -> bool { + self.blobs_v4 + } + + pub fn get_client_version_v1(&self) -> bool { + self.unscoped_endpoints + .iter() + .any(|endpoint| endpoint == "identity") + } + + pub fn highest_supported_fork(&self) -> Option { + self.supported_forks.iter().copied().max() + } +} + +#[derive(Deserialize)] +pub struct JsonCapabilities { + #[serde(default)] + supported_forks: Vec, + #[serde(default)] + fork_scoped_endpoints: Vec, + #[serde(default)] + independently_versioned: JsonIndependentlyVersioned, + #[serde(default)] + unscoped_endpoints: Vec, + #[serde(default)] + limits: JsonLimits, +} + +#[derive(Deserialize, Default)] +struct JsonIndependentlyVersioned { + #[serde(default)] + blobs: Vec, +} + +#[derive(Deserialize, Default)] +struct JsonLimits { + #[serde(rename = "bodies.max_count")] + bodies_max_count: Option, + #[serde(rename = "blobs.max_versioned_hashes")] + blobs_max_versioned_hashes: Option, + #[serde(rename = "payload.max_bytes")] + payload_max_bytes: Option, +} + +fn fork_from_header(header: &str) -> Option { + Some(match header { + "paris" => ForkName::Bellatrix, + "shanghai" => ForkName::Capella, + "cancun" => ForkName::Deneb, + "prague" => ForkName::Electra, + "osaka" => ForkName::Fulu, + "amsterdam" => ForkName::Gloas, + "bogota" => ForkName::Heze, + _ => return None, + }) +} + +fn custody_columns_to_bit_vector( + bit_array: CustodyColumnsBitArray, +) -> Result, ssz_types::Error> { + let mut indices = BitVector::::new(); + let len = indices.len(); + for column in bit_array.iter_set_bits() { + let column = column as usize; + indices + .set(column, true) + .map_err(|_| ssz_types::Error::OutOfBounds { i: column, len })?; + } + Ok(indices) +} + +impl From for SszCapabilities { + fn from(capabilities: JsonCapabilities) -> Self { + let supported_forks = capabilities + .supported_forks + .iter() + .filter_map(|fork| fork_from_header(fork)) + .collect(); + let endpoint = |name: &str| capabilities.fork_scoped_endpoints.iter().any(|e| e == name); + let blob = |rev: &str| { + capabilities + .independently_versioned + .blobs + .iter() + .any(|b| b == rev) + }; + SszCapabilities { + supported_forks, + payloads: endpoint("payloads"), + forkchoice: endpoint("forkchoice"), + bodies: endpoint("bodies"), + blobs_v1: blob("v1"), + blobs_v2: blob("v2"), + blobs_v3: blob("v3"), + blobs_v4: blob("v4"), + unscoped_endpoints: capabilities.unscoped_endpoints, + limits: SszLimits { + bodies_max_count: capabilities.limits.bodies_max_count, + blobs_max_versioned_hashes: capabilities.limits.blobs_max_versioned_hashes, + payload_max_bytes: capabilities.limits.payload_max_bytes, + }, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use bls::{PublicKeyBytes, SignatureBytes}; + use ssz::Encode; + use ssz_types::ProgressiveVariableList; + use types::{ + Address, BuilderDepositRequest, BuilderExitRequest, ConsolidationRequest, DepositRequest, + Hash256, MainnetEthSpec, WithdrawalRequest, + }; + + type E = MainnetEthSpec; + + fn deposit_request() -> DepositRequest { + DepositRequest { + pubkey: PublicKeyBytes::empty(), + withdrawal_credentials: Hash256::repeat_byte(1), + amount: 32, + signature: SignatureBytes::empty(), + index: 3, + } + } + + fn withdrawal_request() -> WithdrawalRequest { + WithdrawalRequest { + source_address: Address::repeat_byte(2), + validator_pubkey: PublicKeyBytes::empty(), + amount: 33, + } + } + + fn consolidation_request() -> ConsolidationRequest { + ConsolidationRequest { + source_address: Address::repeat_byte(3), + source_pubkey: PublicKeyBytes::empty(), + target_pubkey: PublicKeyBytes::empty(), + } + } + + fn electra_requests() -> ExecutionRequestsElectra { + ExecutionRequestsElectra { + deposits: VariableList::new(vec![deposit_request()]).unwrap(), + withdrawals: VariableList::new(vec![withdrawal_request()]).unwrap(), + consolidations: VariableList::new(vec![consolidation_request()]).unwrap(), + } + } + + #[test] + fn fulu_new_payload_envelope_folds_requests_and_drops_versioned_hashes() { + let payload = ExecutionPayloadFulu::::default(); + let requests = electra_requests(); + let parent_beacon_block_root = Hash256::repeat_byte(9); + + let request = NewPayloadRequestFulu { + execution_payload: &payload, + versioned_hashes: vec![Hash256::repeat_byte(1), Hash256::repeat_byte(2)], + parent_beacon_block_root, + execution_requests: &requests, + }; + + let envelope = SszExecutionPayloadEnvelopeFulu::try_from(request).unwrap(); + let decoded = + SszExecutionPayloadEnvelopeFulu::::from_ssz_bytes(&envelope.as_ssz_bytes()).unwrap(); + + assert_eq!(decoded.parent_beacon_block_root, parent_beacon_block_root); + assert_eq!(decoded.execution_payload, payload); + assert_eq!( + execution_requests_electra_from_ssz(decoded.execution_requests).unwrap(), + requests + ); + } + + #[test] + fn gloas_new_payload_envelope_folds_builder_requests() { + let payload = ExecutionPayloadGloas::::default(); + let requests = ExecutionRequestsGloas { + deposits: ProgressiveVariableList::new(vec![deposit_request()]).unwrap(), + withdrawals: ProgressiveVariableList::new(vec![withdrawal_request()]).unwrap(), + consolidations: ProgressiveVariableList::new(vec![consolidation_request()]).unwrap(), + builder_deposits: ProgressiveVariableList::new(vec![BuilderDepositRequest { + pubkey: PublicKeyBytes::empty(), + withdrawal_credentials: Hash256::repeat_byte(4), + amount: 34, + signature: SignatureBytes::empty(), + }]) + .unwrap(), + builder_exits: ProgressiveVariableList::new(vec![BuilderExitRequest { + source_address: Address::repeat_byte(5), + pubkey: PublicKeyBytes::empty(), + }]) + .unwrap(), + }; + let parent_beacon_block_root = Hash256::repeat_byte(9); + + let request = NewPayloadRequestGloas { + execution_payload: &payload, + versioned_hashes: vec![Hash256::repeat_byte(1)], + parent_beacon_block_root, + execution_requests: &requests, + }; + + let envelope = SszExecutionPayloadEnvelopeGloas::try_from(request).unwrap(); + let decoded = + SszExecutionPayloadEnvelopeGloas::::from_ssz_bytes(&envelope.as_ssz_bytes()) + .unwrap(); + + assert_eq!(decoded.parent_beacon_block_root, parent_beacon_block_root); + assert_eq!( + execution_requests_gloas_from_ssz(decoded.execution_requests).unwrap(), + requests + ); + } +} diff --git a/beacon_node/execution_layer/src/engine_api/transport.rs b/beacon_node/execution_layer/src/engine_api/transport.rs new file mode 100644 index 00000000000..8acfea9d060 --- /dev/null +++ b/beacon_node/execution_layer/src/engine_api/transport.rs @@ -0,0 +1,359 @@ +//! The Engine API transport seam: dispatch between JSON-RPC and the optional REST-SSZ client. + +use crate::engine_api::{ + BlockByNumberQuery, EngineCapabilities, Error as EngineApiError, ExecutionBlock, + ExecutionPayloadBodyV1, ExecutionPayloadBodyV2, ForkchoiceUpdatedResponse, GetPayloadResponse, + NewPayloadRequest, PayloadAttributes, PayloadId, PayloadStatusV1, +}; +use crate::engines::ForkchoiceState; +use crate::json_structures::{ + BlobAndProofV2, BlobAndProofV3, CustodyColumnsBitArray, GetBlobsV4List, +}; +use crate::metrics; +use crate::rest::HttpRestSsz; +use crate::{ClientVersionV1, HttpJsonRpc}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex; +use tracing::warn; +use types::{ColumnIndex, EthSpec, ExecutionBlockHash, ForkName, Hash256, ProgressiveTransactions}; + +/// Resolved `engine_*` transport. Only set when `rest` is `Some`; `eth_*` always use JSON-RPC. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum Transport { + Rest, + JsonRpcOnly, +} + +/// Mandatory JSON-RPC plus optional, opt-in REST-SSZ. `engine_*` dispatch to `rest` once +/// `decision` is `Rest`; `eth_*` always use `json_rpc`. +pub struct EngineApi { + json_rpc: HttpJsonRpc, + rest: Option, + decision: OnceLock, + fcu_lock: Mutex<()>, +} + +impl EngineApi { + pub fn new(json_rpc: HttpJsonRpc, rest: Option) -> Self { + Self { + json_rpc, + rest, + decision: OnceLock::new(), + fcu_lock: Mutex::new(()), + } + } + + /// The REST client iff it is the resolved transport. `decision` is only ever set to `Rest` + /// when `rest` is `Some`, so `Rest` implies a client is present. + fn active_rest(&self) -> Option<&HttpRestSsz> { + match self.decision.get() { + Some(Transport::Rest) => self.rest.as_ref(), + _ => None, + } + } + + pub fn get_decision(&self) -> Option<&Transport> { + self.decision.get() + } + + pub async fn new_payload( + &self, + new_payload_request: NewPayloadRequest<'_, E>, + ) -> Result { + let start_time = Instant::now(); + let (transport, result) = match self.active_rest() { + Some(rest) => ( + metrics::TRANSPORT_REST, + rest.new_payload(new_payload_request).await, + ), + None => ( + metrics::TRANSPORT_JSON_RPC, + self.json_rpc.new_payload(new_payload_request).await, + ), + }; + metrics::observe_timer_vec( + &metrics::EXECUTION_LAYER_ENGINE_REQUEST_TIMES, + &[metrics::NEW_PAYLOAD, transport], + start_time.elapsed(), + ); + result + } + + pub async fn get_payload( + &self, + fork_name: ForkName, + payload_id: PayloadId, + ) -> Result, EngineApiError> { + let start_time = Instant::now(); + let (transport, result) = match self.active_rest() { + Some(rest) => ( + metrics::TRANSPORT_REST, + rest.get_payload(fork_name, payload_id).await, + ), + None => ( + metrics::TRANSPORT_JSON_RPC, + self.json_rpc.get_payload(fork_name, payload_id).await, + ), + }; + metrics::observe_timer_vec( + &metrics::EXECUTION_LAYER_ENGINE_REQUEST_TIMES, + &[metrics::GET_PAYLOAD, transport], + start_time.elapsed(), + ); + result + } + + pub async fn forkchoice_updated( + &self, + forkchoice_state: ForkchoiceState, + payload_attributes: Option, + custody_columns: Option<&[ColumnIndex]>, + fork: ForkName, + ) -> Result { + let start_time = Instant::now(); + let (transport, result) = match self.active_rest() { + Some(rest) => { + // At most one POST /forkchoice in flight based on REST-SSZ spec. + let _fcu_guard = self.fcu_lock.lock().await; + ( + metrics::TRANSPORT_REST, + rest.forkchoice_updated::( + fork, + forkchoice_state, + payload_attributes, + custody_columns, + ) + .await, + ) + } + None => ( + metrics::TRANSPORT_JSON_RPC, + self.json_rpc + .forkchoice_updated(forkchoice_state, payload_attributes, custody_columns) + .await, + ), + }; + metrics::observe_timer_vec( + &metrics::EXECUTION_LAYER_ENGINE_REQUEST_TIMES, + &[metrics::FORKCHOICE_UPDATED, transport], + start_time.elapsed(), + ); + result + } + + pub async fn get_payload_bodies_by_hash_v1( + &self, + fork: ForkName, + block_hashes: Vec, + ) -> Result>>, EngineApiError> { + let start_time = Instant::now(); + let (transport, result) = match self.active_rest() { + Some(rest) => ( + metrics::TRANSPORT_REST, + rest.get_payload_bodies_by_hash_v1::(fork, block_hashes) + .await, + ), + None => ( + metrics::TRANSPORT_JSON_RPC, + self.json_rpc + .get_payload_bodies_by_hash_v1(block_hashes) + .await, + ), + }; + metrics::observe_timer_vec( + &metrics::EXECUTION_LAYER_ENGINE_REQUEST_TIMES, + &[metrics::GET_PAYLOAD_BODIES_BY_HASH, transport], + start_time.elapsed(), + ); + result + } + + pub async fn get_payload_bodies_by_hash_v2( + &self, + fork: ForkName, + block_hashes: Vec, + ) -> Result>>, EngineApiError> { + let start_time = Instant::now(); + let (transport, result) = match self.active_rest() { + Some(rest) => ( + metrics::TRANSPORT_REST, + rest.get_payload_bodies_by_hash_v2::(fork, block_hashes) + .await, + ), + None => ( + metrics::TRANSPORT_JSON_RPC, + self.json_rpc + .get_payload_bodies_by_hash_v2(block_hashes) + .await, + ), + }; + + metrics::observe_timer_vec( + &metrics::EXECUTION_LAYER_ENGINE_REQUEST_TIMES, + &[metrics::GET_PAYLOAD_BODIES_BY_HASH, transport], + start_time.elapsed(), + ); + result + } + + pub async fn get_inclusion_list_v1( + &self, + ) -> Result { + match self.active_rest() { + Some(_) => Err(EngineApiError::RequiredMethodUnsupported( + "get_inclusion_list_v1 not supported for REST-SSZ", + )), + None => self.json_rpc.get_inclusion_list_v1().await, + } + } + + pub async fn get_blobs_v2( + &self, + versioned_hashes: Vec, + ) -> Result>>, EngineApiError> { + let start_time = Instant::now(); + let (transport, result) = match self.active_rest() { + Some(rest) => ( + metrics::TRANSPORT_REST, + rest.get_blobs_v2(versioned_hashes).await, + ), + None => ( + metrics::TRANSPORT_JSON_RPC, + self.json_rpc.get_blobs_v2(versioned_hashes).await, + ), + }; + metrics::observe_timer_vec( + &metrics::EXECUTION_LAYER_ENGINE_REQUEST_TIMES, + &[metrics::GET_BLOBS_V2, transport], + start_time.elapsed(), + ); + result + } + + pub async fn get_blobs_v3( + &self, + versioned_hashes: Vec, + ) -> Result>>, EngineApiError> { + let start_time = Instant::now(); + let (transport, result) = match self.active_rest() { + Some(rest) => ( + metrics::TRANSPORT_REST, + rest.get_blobs_v3(versioned_hashes).await, + ), + None => ( + metrics::TRANSPORT_JSON_RPC, + self.json_rpc.get_blobs_v3(versioned_hashes).await, + ), + }; + metrics::observe_timer_vec( + &metrics::EXECUTION_LAYER_ENGINE_REQUEST_TIMES, + &[metrics::GET_BLOBS_V3, transport], + start_time.elapsed(), + ); + result + } + + pub async fn get_blobs_v4( + &self, + versioned_hashes: Vec, + indices_bitarray: CustodyColumnsBitArray, + ) -> Result>, EngineApiError> { + let start_time = Instant::now(); + let (transport, result) = match self.active_rest() { + Some(rest) => ( + metrics::TRANSPORT_REST, + rest.get_blobs_v4(versioned_hashes, indices_bitarray).await, + ), + None => ( + metrics::TRANSPORT_JSON_RPC, + self.json_rpc + .get_blobs_v4(versioned_hashes, indices_bitarray) + .await, + ), + }; + metrics::observe_timer_vec( + &metrics::EXECUTION_LAYER_ENGINE_REQUEST_TIMES, + &[metrics::GET_BLOBS_V4, transport], + start_time.elapsed(), + ); + result + } + + pub async fn get_engine_capabilities( + &self, + age_limit: Option, + ) -> Result { + if self.decision.get().is_none() { + self.resolve_transport(age_limit).await + } else { + match self.active_rest() { + Some(rest) => rest.get_engine_capabilities(age_limit).await, + None => self.json_rpc.get_engine_capabilities(age_limit).await, + } + } + } + + pub async fn get_engine_version( + &self, + age_limit: Option, + ) -> Result, EngineApiError> { + match self.active_rest() { + Some(rest) => rest.get_engine_version(age_limit).await, + None => self.json_rpc.get_engine_version(age_limit).await, + } + } + + pub async fn upcheck(&self) -> Result<(), EngineApiError> { + self.json_rpc.upcheck().await + } + + pub async fn get_block_by_number( + &self, + query: BlockByNumberQuery<'_>, + ) -> Result, EngineApiError> { + self.json_rpc.get_block_by_number(query).await + } + + pub async fn clear_exchange_capabilties_cache(&self) { + match self.active_rest() { + Some(rest) => rest.clear_ssz_capabilities_cache().await, + None => self.json_rpc.clear_exchange_capabilties_cache().await, + } + } + + pub async fn clear_engine_version_cache(&self) { + match self.active_rest() { + Some(rest) => rest.clear_engine_version_cache().await, + None => self.json_rpc.clear_engine_version_cache().await, + } + } + + async fn resolve_transport( + &self, + age_limit: Option, + ) -> Result { + match self.rest.as_ref() { + Some(rest) => { + match rest.resolve_http_and_get_capabilities(age_limit).await { + Ok(capabilities) => { + self.decision.set(Transport::Rest)?; + Ok(capabilities) + } + Err(e) => { + // Any probe failure falls back to JSON-RPC for the rest of the process run. + warn!(error = ?e, "REST-SSZ capabilities probe failed; falling back to JSON-RPC"); + let capabilities = self.json_rpc.get_engine_capabilities(age_limit).await?; + self.decision.set(Transport::JsonRpcOnly)?; + Ok(capabilities) + } + } + } + None => { + let capabilities = self.json_rpc.get_engine_capabilities(age_limit).await?; + self.decision.set(Transport::JsonRpcOnly)?; + Ok(capabilities) + } + } + } +} diff --git a/beacon_node/execution_layer/src/engines.rs b/beacon_node/execution_layer/src/engines.rs index c3f923b0bb3..dd126262b39 100644 --- a/beacon_node/execution_layer/src/engines.rs +++ b/beacon_node/execution_layer/src/engines.rs @@ -1,19 +1,22 @@ //! Provides generic behaviour for multiple execution engines, specifically fallback behaviour. +use crate::ClientVersionV1; +use crate::engine_api::transport::EngineApi; use crate::engine_api::{ EngineCapabilities, Error as EngineApiError, ForkchoiceUpdatedResponse, PayloadAttributes, PayloadId, }; -use crate::{ClientVersionV1, HttpJsonRpc}; use hashlink::lru_cache::LruCache; +use ssz_derive::{Decode, Encode}; use std::future::Future; +use std::marker::PhantomData; use std::sync::Arc; use std::time::Duration; use task_executor::TaskExecutor; use tokio::sync::{Mutex, RwLock, watch}; use tokio_stream::wrappers::WatchStream; use tracing::{debug, error, info, warn}; -use types::{ColumnIndex, ExecutionBlockHash}; +use types::{ColumnIndex, EthSpec, ExecutionBlockHash, ForkName}; /// The number of payload IDs that will be stored for each `Engine`. /// @@ -98,7 +101,7 @@ impl State { } } -#[derive(Copy, Clone, PartialEq, Debug)] +#[derive(Copy, Clone, PartialEq, Encode, Decode, Debug)] pub struct ForkchoiceState { pub head_block_hash: ExecutionBlockHash, pub safe_block_hash: ExecutionBlockHash, @@ -119,23 +122,25 @@ pub enum EngineError { } /// An execution engine. -pub struct Engine { - pub api: HttpJsonRpc, +pub struct Engine { + pub api: EngineApi, payload_id_cache: Mutex>, state: RwLock, - latest_forkchoice_state: RwLock>, + latest_forkchoice_state: RwLock>, executor: TaskExecutor, + _phantom: PhantomData, } -impl Engine { +impl Engine { /// Creates a new, offline engine. - pub fn new(api: HttpJsonRpc, executor: TaskExecutor) -> Self { + pub fn new(api: EngineApi, executor: TaskExecutor) -> Self { Self { api, payload_id_cache: Mutex::new(LruCache::new(PAYLOAD_ID_LRU_CACHE_SIZE)), state: Default::default(), latest_forkchoice_state: Default::default(), executor, + _phantom: PhantomData, } } @@ -158,18 +163,31 @@ impl Engine { .cloned() } + pub async fn invalidate_payload_id( + &self, + head_block_hash: &ExecutionBlockHash, + payload_attributes: &PayloadAttributes, + ) { + self.payload_id_cache + .lock() + .await + .remove(&PayloadIdCacheKey::new(head_block_hash, payload_attributes)); + } + pub async fn notify_forkchoice_updated( &self, forkchoice_state: ForkchoiceState, payload_attributes: Option, custody_columns: Option<&[ColumnIndex]>, + fork: ForkName, ) -> Result { let response = self .api - .forkchoice_updated( + .forkchoice_updated::( forkchoice_state, payload_attributes.clone(), custody_columns, + fork, ) .await?; @@ -186,18 +204,18 @@ impl Engine { Ok(response) } - async fn get_latest_forkchoice_state(&self) -> Option { + async fn get_latest_forkchoice_state(&self) -> Option<(ForkchoiceState, ForkName)> { *self.latest_forkchoice_state.read().await } - pub async fn set_latest_forkchoice_state(&self, state: ForkchoiceState) { - *self.latest_forkchoice_state.write().await = Some(state); + pub async fn set_latest_forkchoice_state(&self, state: ForkchoiceState, fork: ForkName) { + *self.latest_forkchoice_state.write().await = Some((state, fork)); } async fn send_latest_forkchoice_state(&self) { let latest_forkchoice_state = self.get_latest_forkchoice_state().await; - if let Some(forkchoice_state) = latest_forkchoice_state { + if let Some((forkchoice_state, fork)) = latest_forkchoice_state { if forkchoice_state.head_block_hash == ExecutionBlockHash::zero() { debug!( msg = "head does not have execution enabled", @@ -212,7 +230,7 @@ impl Engine { // call. It may be reasonable to include them in the future. if let Err(e) = self .api - .forkchoice_updated(forkchoice_state, None, None) + .forkchoice_updated::(forkchoice_state, None, None, fork) .await { debug!( @@ -351,7 +369,7 @@ impl Engine { /// deadlock. pub async fn request<'a, F, G, H>(self: &'a Arc, func: F) -> Result where - F: FnOnce(&'a Engine) -> G, + F: FnOnce(&'a Engine) -> G, G: Future>, { match func(self).await { diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 8a1d867848e..35962898f0d 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -15,8 +15,11 @@ use bls::{PublicKeyBytes, Signature}; use builder_client::PreGloasBuilderHttpClient; pub use engine_api::EngineCapabilities; use engine_api::Error as ApiError; +use engine_api::transport::EngineApi; pub use engine_api::*; -pub use engine_api::{http, http::HttpJsonRpc, http::deposit_methods}; +pub use engine_api::{ + http, http::HttpJsonRpc, http::deposit_methods, rest::HttpRestSsz, transport::Transport, +}; use engines::{Engine, EngineError}; pub use engines::{EngineState, ForkchoiceState}; use eth2::types::{BlobsBundle, FullPayloadContents}; @@ -158,7 +161,12 @@ pub enum Error { ZeroLengthTransaction, PayloadBodiesByHashV2NotSupported, PayloadBodiesByHashNotSupported, + PayloadBodiesNotSupportedForFork(ForkName), GetBlobsNotSupported, + BlobsRequestTooLarge { + requested: usize, + max: usize, + }, GetInclusionListNotSupported, InvalidJWTSecret(String), InvalidForkForPayload, @@ -471,7 +479,7 @@ pub enum SubmitBlindedBlockResponse { type PayloadContentsRefTuple<'a, E> = (ExecutionPayloadRef<'a, E>, Option<&'a BlobsBundle>); struct Inner { - engine: Arc, + engine: Arc>, builder: ArcSwapOption, execution_engine_forkchoice_lock: Mutex<()>, suggested_fee_recipient: Option
, @@ -511,6 +519,8 @@ pub struct Config { /// Default directory for the jwt secret if not provided through cli. pub default_datadir: PathBuf, pub execution_timeout_multiplier: Option, + /// Use the REST-SSZ Engine API transport instead of JSON-RPC (opt-in). + pub engine_api_rest_ssz: bool, } /// Provides access to one execution engine and provides a neat interface for consumption by the @@ -535,6 +545,7 @@ impl ExecutionLayer { jwt_version, default_datadir, execution_timeout_multiplier, + engine_api_rest_ssz, } = config; let execution_url = url.ok_or(Error::NoEngine)?; @@ -571,11 +582,26 @@ impl ExecutionLayer { .map_err(Error::InvalidJWTSecret) }?; - let engine: Engine = { - let auth = Auth::new(jwt_key, jwt_id, jwt_version); + let engine: Engine = { 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)?; + let rest_ssz = if engine_api_rest_ssz { + let rest_auth = Auth::new(jwt_key.clone(), jwt_id.clone(), None); + Some( + HttpRestSsz::new_with_auth( + execution_url.clone(), + rest_auth, + execution_timeout_multiplier, + ) + .map_err(Error::ApiError)?, + ) + } else { + None + }; + let json_auth = Auth::new(jwt_key, jwt_id, jwt_version); + let json_rpc = + HttpJsonRpc::new_with_auth(execution_url, json_auth, execution_timeout_multiplier) + .map_err(Error::ApiError)?; + let api = EngineApi::new(json_rpc, rest_ssz); Engine::new(api, executor.clone()) }; @@ -607,7 +633,7 @@ impl ExecutionLayer { Ok(el) } - fn engine(&self) -> &Arc { + fn engine(&self) -> &Arc> { &self.inner.engine } @@ -1335,53 +1361,59 @@ impl ExecutionLayer { self.engine() .request(move |engine| async move { + let fork_choice_state = ForkchoiceState { + head_block_hash: parent_hash, + safe_block_hash: forkchoice_update_params + .justified_hash + .unwrap_or_else(ExecutionBlockHash::zero), + finalized_block_hash: forkchoice_update_params + .finalized_hash + .unwrap_or_else(ExecutionBlockHash::zero), + }; + + // Reused by the initial cache MISS and the REST `unknown-payload` recovery below. + let mint_payload_id = move || async move { + let response = engine + .notify_forkchoice_updated( + fork_choice_state, + Some(payload_attributes.clone()), + None, + current_fork, + ) + .await?; + + match response.payload_id { + Some(payload_id) => Ok(payload_id), + None => { + error!( + msg = "No payload ID, the engine is likely syncing. \ + This has the potential to cause a missed block proposal.", + status = ?response.payload_status, + "Exec engine unable to produce payload" + ); + Err(ApiError::PayloadIdUnavailable) + } + } + }; + let payload_id = if let Some(id) = engine .get_payload_id(&parent_hash, payload_attributes) .await { - // The payload id has been cached for this engine. + // The payload id has been cached metrics::inc_counter_vec( &metrics::EXECUTION_LAYER_PRE_PREPARED_PAYLOAD_ID, &[metrics::HIT], ); id } else { - // The payload id has *not* been cached. Trigger an artificial - // fork choice update to retrieve a payload ID. + // The payload id not cached. Trigger an artificial fork choice update metrics::inc_counter_vec( &metrics::EXECUTION_LAYER_PRE_PREPARED_PAYLOAD_ID, &[metrics::MISS], ); - let fork_choice_state = ForkchoiceState { - head_block_hash: parent_hash, - safe_block_hash: forkchoice_update_params - .justified_hash - .unwrap_or_else(ExecutionBlockHash::zero), - finalized_block_hash: forkchoice_update_params - .finalized_hash - .unwrap_or_else(ExecutionBlockHash::zero), - }; - let response = engine - .notify_forkchoice_updated( - fork_choice_state, - Some(payload_attributes.clone()), - None, - ) - .await?; - - match response.payload_id { - Some(payload_id) => payload_id, - None => { - error!( - msg = "No payload ID, the engine is likely syncing. \ - This has the potential to cause a missed block proposal.", - status = ?response.payload_status, - "Exec engine unable to produce payload" - ); - return Err(ApiError::PayloadIdUnavailable); - } - } + mint_payload_id().await? }; let payload_response = async { @@ -1392,11 +1424,40 @@ impl ExecutionLayer { ?parent_hash, "Issuing engine_getPayload" ); - let _timer = metrics::start_timer_vec( - &metrics::EXECUTION_LAYER_REQUEST_TIMES, - &[metrics::GET_PAYLOAD], - ); - engine.api.get_payload::(current_fork, payload_id).await + + let first_attempt = { + let _timer = metrics::start_timer_vec( + &metrics::EXECUTION_LAYER_REQUEST_TIMES, + &[metrics::GET_PAYLOAD], + ); + engine.api.get_payload::(current_fork, payload_id).await + }; + match first_attempt { + Ok(response) => Ok(response), + Err(e) if e.is_unknown_payload() => { + // REST build-bound TTL: the cached id is unknown to the EL. Invalidate + // it, mint a fresh id, and retry the GET exactly once. + debug!( + ?payload_id, + "Cached payload id unknown to execution engine; re-issuing forkchoice update" + ); + + metrics::inc_counter_vec( + &metrics::EXECUTION_LAYER_PRE_PREPARED_PAYLOAD_ID, + &[metrics::EXPIRED], + ); + engine + .invalidate_payload_id(&parent_hash, payload_attributes) + .await; + let fresh_payload_id = mint_payload_id().await?; + let _timer = metrics::start_timer_vec( + &metrics::EXECUTION_LAYER_REQUEST_TIMES, + &[metrics::GET_PAYLOAD], + ); + engine.api.get_payload::(current_fork, fresh_payload_id).await + } + Err(e) => Err(e), + } } .await?; @@ -1556,6 +1617,7 @@ impl ExecutionLayer { head_block_root: Hash256, head_payload_status: fork_choice::PayloadStatus, custody_columns: &[ColumnIndex], + fork: ForkName, ) -> Result { let _timer = metrics::start_timer_vec( &metrics::EXECUTION_LAYER_REQUEST_TIMES, @@ -1598,7 +1660,7 @@ impl ExecutionLayer { }; self.engine() - .set_latest_forkchoice_state(forkchoice_state) + .set_latest_forkchoice_state(forkchoice_state, fork) .await; let result = self @@ -1609,6 +1671,7 @@ impl ExecutionLayer { forkchoice_state, payload_attributes, Some(custody_columns), + fork, ) .await }) @@ -1671,13 +1734,31 @@ impl ExecutionLayer { Ok(versions) } - pub async fn get_payload_bodies_by_hash( + pub async fn max_payload_bodies_per_request(&self) -> Result { + let max_count = self.get_engine_capabilities(None).await?.bodies_max_count(); + if max_count == 0 { + return Err(Error::PayloadBodiesByHashNotSupported); + } + + Ok(max_count) + } + + pub async fn get_payload_bodies_by_hash_v1( &self, + fork: ForkName, hashes: Vec, ) -> Result>>, Error> { + let capabilities = self.get_engine_capabilities(None).await?; + if !capabilities.get_payload_bodies_by_hash_v1(fork) { + return Err(Error::PayloadBodiesNotSupportedForFork(fork)); + } + self.engine() - .request(|engine: &Engine| async move { - engine.api.get_payload_bodies_by_hash_v1(hashes).await + .request(|engine: &Engine| async move { + engine + .api + .get_payload_bodies_by_hash_v1::(fork, hashes) + .await }) .await .map_err(Box::new) @@ -1687,16 +1768,20 @@ impl ExecutionLayer { /// Fetch execution payload bodies using the Gloas V2 response format. pub async fn get_payload_bodies_by_hash_v2( &self, + fork: ForkName, hashes: Vec, ) -> Result>>, Error> { let capabilities = self.get_engine_capabilities(None).await?; - if !capabilities.get_payload_bodies_by_hash_v2 { + if !capabilities.get_payload_bodies_by_hash_v2(fork) { return Err(Error::PayloadBodiesByHashV2NotSupported); } self.engine() - .request(|engine: &Engine| async move { - engine.api.get_payload_bodies_by_hash_v2(hashes).await + .request(|engine: &Engine| async move { + engine + .api + .get_payload_bodies_by_hash_v2::(fork, hashes) + .await }) .await .map_err(Box::new) @@ -1733,9 +1818,9 @@ impl ExecutionLayer { } let capabilities = self.get_engine_capabilities(None).await?; - if capabilities.get_payload_bodies_by_hash_v1 { + if capabilities.get_payload_bodies_by_hash_v1(fork) { let mut payload_bodies = self - .get_payload_bodies_by_hash(vec![header.block_hash()]) + .get_payload_bodies_by_hash_v1(fork, vec![header.block_hash()]) .await?; if payload_bodies.len() != 1 { @@ -1754,13 +1839,30 @@ impl ExecutionLayer { } } + fn check_blobs_request_size( + &self, + capabilities: &EngineCapabilities, + requested: usize, + ) -> Result<(), Error> { + let max = capabilities.blobs_max_versioned_hashes::(); + if max == 0 { + return Err(Error::GetBlobsNotSupported); + } + if requested > max { + return Err(Error::BlobsRequestTooLarge { requested, max }); + } + + Ok(()) + } + pub async fn get_blobs_v2( &self, query: Vec, ) -> Result>>, Error> { let capabilities = self.get_engine_capabilities(None).await?; - if capabilities.get_blobs_v2 { + if capabilities.get_blobs_v2() { + self.check_blobs_request_size(&capabilities, query.len())?; self.engine() .request(|engine| async move { engine.api.get_blobs_v2(query).await }) .await @@ -1777,7 +1879,8 @@ impl ExecutionLayer { ) -> Result>>, Error> { let capabilities = self.get_engine_capabilities(None).await?; - if capabilities.get_blobs_v3 { + if capabilities.get_blobs_v3() { + self.check_blobs_request_size(&capabilities, query.len())?; self.engine() .request(|engine| async move { engine.api.get_blobs_v3(query).await }) .await @@ -1795,7 +1898,8 @@ impl ExecutionLayer { ) -> Result>, Error> { let capabilities = self.get_engine_capabilities(None).await?; - if capabilities.get_blobs_v4 { + if capabilities.get_blobs_v4() { + self.check_blobs_request_size(&capabilities, query.len())?; self.engine() .request( |engine| async move { engine.api.get_blobs_v4(query, custody_columns).await }, @@ -1811,9 +1915,9 @@ impl ExecutionLayer { pub async fn get_inclusion_list_v1(&self) -> Result { let capabilities = self.get_engine_capabilities(None).await?; - if capabilities.get_inclusion_list_v1 { + if capabilities.get_inclusion_list_v1() { self.engine() - .request(|engine| async move { engine.api.get_inclusion_list_v1().await }) + .request(|engine| async move { engine.api.get_inclusion_list_v1::().await }) .await .map_err(Box::new) .map_err(Error::EngineError) @@ -1992,6 +2096,10 @@ impl ExecutionLayer { Err(Error::NoPayloadBuilder) } } + + pub(crate) fn resolved_transport(&self) -> Option { + self.engine().api.get_decision().copied() + } } #[derive(AsRefStr)] @@ -2215,6 +2323,8 @@ mod test { async fn produce_three_valid_pos_execution_blocks() { let runtime = TestRuntime::default(); MockExecutionLayer::default_params(runtime.task_executor.clone()) + .resolve_and_assert_transport() + .await .produce_valid_execution_payload_on_head() .await .produce_valid_execution_payload_on_head() @@ -2268,7 +2378,10 @@ mod test { let bodies_by_hash = mock .el - .get_payload_bodies_by_hash_v2(vec![block_hash, ExecutionBlockHash::zero()]) + .get_payload_bodies_by_hash_v2( + ForkName::Gloas, + vec![block_hash, ExecutionBlockHash::zero()], + ) .await .expect("payload body request by hash should succeed"); assert_eq!(bodies_by_hash, vec![Some(expected_body.clone()), None]); diff --git a/beacon_node/execution_layer/src/metrics.rs b/beacon_node/execution_layer/src/metrics.rs index 95a6f64f813..c18c884f606 100644 --- a/beacon_node/execution_layer/src/metrics.rs +++ b/beacon_node/execution_layer/src/metrics.rs @@ -3,6 +3,7 @@ use std::sync::LazyLock; pub const HIT: &str = "hit"; pub const MISS: &str = "miss"; +pub const EXPIRED: &str = "expired"; pub const GET_PAYLOAD: &str = "get_payload"; pub const GET_BLINDED_PAYLOAD: &str = "get_blinded_payload"; pub const GET_BLINDED_PAYLOAD_LOCAL: &str = "get_blinded_payload_local"; @@ -15,6 +16,17 @@ pub const BUILDER: &str = "builder"; pub const SUCCESS: &str = "success"; pub const FAILURE: &str = "failure"; +pub const TRANSPORT_REST: &str = "rest"; +pub const TRANSPORT_JSON_RPC: &str = "json-rpc"; + +pub const DIRECTION_REQUEST: &str = "request"; +pub const DIRECTION_RESPONSE: &str = "response"; + +pub const GET_PAYLOAD_BODIES_BY_HASH: &str = "get_payload_bodies_by_hash"; +pub const GET_BLOBS_V2: &str = "get_blobs_v2"; +pub const GET_BLOBS_V3: &str = "get_blobs_v3"; +pub const GET_BLOBS_V4: &str = "get_blobs_v4"; + pub static EXECUTION_LAYER_PROPOSER_INSERTED: LazyLock> = LazyLock::new(|| { try_create_int_counter( "execution_layer_proposer_inserted", @@ -36,6 +48,31 @@ pub static EXECUTION_LAYER_REQUEST_TIMES: LazyLock> = LazyL &["method"], ) }); +pub static EXECUTION_LAYER_ENGINE_REQUEST_TIMES: LazyLock> = + LazyLock::new(|| { + try_create_histogram_vec_with_buckets( + "execution_layer_engine_request_times", + "Duration of engine_* Engine API calls at the transport seam, by method and transport", + Ok(vec![ + 50e-6, 100e-6, 250e-6, 500e-6, 1e-3, 2.5e-3, 5e-3, 10e-3, 25e-3, 50e-3, 100e-3, + 250e-3, 500e-3, 1.0, 2.5, + ]), + &["method", "transport"], + ) + }); +pub static EXECUTION_LAYER_ENGINE_BODY_SIZE_BYTES: LazyLock> = LazyLock::new( + || { + try_create_histogram_vec_with_buckets( + "execution_layer_engine_body_size_bytes", + "Wire size of engine_* Engine API request/response bodies, by method, transport and direction", + Ok(vec![ + 256.0, 1024.0, 4096.0, 16384.0, 65536.0, 262144.0, 1048576.0, 4194304.0, + 16777216.0, 67108864.0, + ]), + &["method", "transport", "direction"], + ) + }, +); pub static EXECUTION_LAYER_PAYLOAD_ATTRIBUTES_LOOKAHEAD: LazyLock> = LazyLock::new(|| { try_create_histogram( @@ -115,6 +152,19 @@ pub static EXECUTION_LAYER_INFO: LazyLock> = LazyLock::new(| ) }); +pub fn engine_method_label(jsonrpc_method: &str) -> Option<&'static str> { + match jsonrpc_method { + m if m.starts_with("engine_newPayload") => Some(NEW_PAYLOAD), + m if m.starts_with("engine_forkchoiceUpdated") => Some(FORKCHOICE_UPDATED), + m if m.starts_with("engine_getPayloadBodiesByHash") => Some(GET_PAYLOAD_BODIES_BY_HASH), + "engine_getBlobsV2" => Some(GET_BLOBS_V2), + "engine_getBlobsV3" => Some(GET_BLOBS_V3), + "engine_getBlobsV4" => Some(GET_BLOBS_V4), + m if m.starts_with("engine_getPayload") => Some(GET_PAYLOAD), + _ => None, + } +} + pub fn reset_execution_layer_info_gauge() { let _ = EXECUTION_LAYER_INFO.as_ref().map(|gauge| gauge.reset()); } diff --git a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs index 914c1dffd76..21dda50e6ee 100644 --- a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs +++ b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs @@ -494,6 +494,10 @@ impl ExecutionBlockGenerator { self.payload_ids.get(id).cloned() } + pub fn expire_all_payload_ids(&mut self) { + self.payload_ids.clear(); + } + pub fn get_blobs_bundle(&mut self, id: &PayloadId) -> Option> { self.blobs_bundles.get(id).cloned() } diff --git a/beacon_node/execution_layer/src/test_utils/handle_rest.rs b/beacon_node/execution_layer/src/test_utils/handle_rest.rs new file mode 100644 index 00000000000..71d7c761f50 --- /dev/null +++ b/beacon_node/execution_layer/src/test_utils/handle_rest.rs @@ -0,0 +1,625 @@ +use bytes::Bytes; +use serde_json::json; +use ssz::{Decode, DecodeError, Encode}; +use ssz_types::{ProgressiveVariableList, VariableList}; +use types::{ + EthSpec, ExecutionBlockHash, ExecutionPayload, ForkName, Hash256, ProgressiveTransactions, + ProgressiveWithdrawals, +}; +use warp::http::Response; + +use crate::engine_api::rest::header_to_fork; +use crate::engine_api::{ExecutionPayloadBodyV2, ForkchoiceUpdatedResponse, PayloadAttributes}; +use crate::engines::ForkchoiceState; +use crate::json_structures::{BlobAndProof, BlobAndProofV2, BlobCellsAndProofsV1, JsonCell}; +use crate::ssz_structures::*; + +use super::handle_rpc::UNKNOWN_PAYLOAD_ERROR_CODE; +use super::mock_engine_core::CorePayload; +use super::{Context, DEFAULT_CLIENT_VERSION, STUB_CAPABILITIES_JSON}; + +pub(crate) fn handle_rest( + method: &str, + path: &str, + query: Option<&str>, + eth_execution_version: Option<&str>, + body: &Bytes, + ctx: &Context, +) -> Response { + match (method, path) { + ("GET", "/engine/v1/capabilities") => { + let mut capabilities: serde_json::Value = + serde_json::from_str(STUB_CAPABILITIES_JSON).unwrap(); + if !ctx.engine_capabilities.read().get_client_version_v1() + && let Some(endpoints) = capabilities["unscoped_endpoints"].as_array_mut() + { + endpoints.retain(|e| e.as_str() != Some("identity")); + } + json_response(Bytes::from(serde_json::to_vec(&capabilities).unwrap())) + } + ("GET", "/engine/v1/identity") => json_response(Bytes::from( + serde_json::to_vec(std::slice::from_ref(&*DEFAULT_CLIENT_VERSION)).unwrap(), + )), + ("POST", "/engine/v1/payloads") => { + let Some(fork) = eth_execution_version.and_then(header_to_fork) else { + return problem_response( + RestProblemKind::UnsupportedFork, + eth_execution_version.map(|header| header.to_string()), + ); + }; + + let Ok(payload) = decode_new_payload::(body, fork) else { + return problem_response(RestProblemKind::SszDecodeError, None); + }; + + let status = match ctx.core_new_payload(payload) { + Ok(status) => status, + Err(err) => return core_error_response(err), + }; + + match SszPayloadStatusV1::::try_from(status) { + Ok(ssz) => ssz_response(Bytes::from(ssz.as_ssz_bytes())), + Err(e) => problem_response(RestProblemKind::Internal, Some(format!("{e:?}"))), + } + } + ("POST", "/engine/v1/forkchoice") => { + let Some(fork) = eth_execution_version.and_then(header_to_fork) else { + return problem_response( + RestProblemKind::UnsupportedFork, + eth_execution_version.map(|header| header.to_string()), + ); + }; + + let Ok((state, attributes)) = decode_forkchoice_updated::(body, fork) else { + return problem_response(RestProblemKind::SszDecodeError, None); + }; + + let response = match ctx.core_forkchoice_updated(state, attributes) { + Ok(response) => response, + Err(err) => return core_error_response(err), + }; + + match SszForkchoiceUpdatedResponse::::try_from(ForkchoiceUpdatedResponse::from( + response, + )) { + Ok(ssz) => ssz_response(Bytes::from(ssz.as_ssz_bytes())), + Err(e) => problem_response(RestProblemKind::Internal, Some(format!("{e:?}"))), + } + } + ("GET", path) if path.starts_with("/engine/v1/payloads/") => { + if eth_execution_version.and_then(header_to_fork).is_none() { + return problem_response( + RestProblemKind::UnsupportedFork, + eth_execution_version.map(|header| header.to_string()), + ); + } + + let Some(payload_id) = path + .strip_prefix("/engine/v1/payloads/") + .and_then(|segment| segment.strip_prefix("0x")) + .and_then(|hex| hex::decode(hex).ok()) + .and_then(|bytes| <[u8; 8]>::try_from(bytes).ok()) + else { + return problem_response( + RestProblemKind::InvalidRequest, + Some(format!("malformed payload id: {path}")), + ); + }; + + let core = match ctx.core_get_payload(payload_id) { + Ok(core) => core, + Err(err) => return core_error_response(err), + }; + + match encode_get_payload(core) { + Ok(bytes) => ssz_response_no_store(Bytes::from(bytes)), + Err(e) => problem_response(RestProblemKind::Internal, Some(e)), + } + } + ("POST", "/engine/v1/blobs/v2") => handle_get_blobs(ctx, body, true), + ("POST", "/engine/v1/blobs/v3") => handle_get_blobs(ctx, body, false), + ("POST", "/engine/v1/blobs/v4") => handle_get_blobs_v4(ctx, body), + ("POST", "/engine/v1/bodies/hash") => { + let Some(fork) = eth_execution_version.and_then(header_to_fork) else { + return problem_response( + RestProblemKind::UnsupportedFork, + eth_execution_version.map(|header| header.to_string()), + ); + }; + + let Ok(block_hashes) = decode_bodies_by_hash_request(body) else { + return problem_response(RestProblemKind::SszDecodeError, None); + }; + + let generator = ctx.execution_block_generator.read(); + let payloads = block_hashes + .into_iter() + .map(|hash| { + generator.execution_payload_by_hash(ExecutionBlockHash::from_root(hash)) + }) + .collect(); + drop(generator); + + match encode_bodies_response(payloads, fork) { + Ok(bytes) => ssz_response(Bytes::from(bytes)), + Err(e) => problem_response(RestProblemKind::Internal, Some(e)), + } + } + ("GET", "/engine/v1/bodies") => { + let Some(fork) = eth_execution_version.and_then(header_to_fork) else { + return problem_response( + RestProblemKind::UnsupportedFork, + eth_execution_version.map(|header| header.to_string()), + ); + }; + + let Some((from, count)) = parse_from_count(query) else { + return problem_response( + RestProblemKind::InvalidRequest, + Some("malformed bodies range query".to_string()), + ); + }; + + let generator = ctx.execution_block_generator.read(); + let payloads = (from..from.saturating_add(count)) + .map(|number| generator.execution_payload_by_number(number)) + .collect(); + drop(generator); + + match encode_bodies_response(payloads, fork) { + Ok(bytes) => ssz_response(Bytes::from(bytes)), + Err(e) => problem_response(RestProblemKind::Internal, Some(e)), + } + } + _ => problem_response(RestProblemKind::MethodNotFound, None), + } +} + +fn json_response(body: Bytes) -> Response { + Response::builder() + .status(200) + .header("Content-Type", "application/json") + .body(body) + .unwrap() +} + +fn ssz_response(body: Bytes) -> Response { + Response::builder() + .status(200) + .header("Content-Type", "application/octet-stream") + .body(body) + .unwrap() +} + +fn ssz_response_no_store(body: Bytes) -> Response { + Response::builder() + .status(200) + .header("Content-Type", "application/octet-stream") + .header("Cache-Control", "no-store") + .body(body) + .unwrap() +} + +fn decode_new_payload( + body: &[u8], + fork: ForkName, +) -> Result, DecodeError> { + Ok(match fork { + ForkName::Bellatrix => ExecutionPayload::Bellatrix( + SszExecutionPayloadEnvelopeBellatrix::from_ssz_bytes(body)?.execution_payload, + ), + ForkName::Capella => ExecutionPayload::Capella( + SszExecutionPayloadEnvelopeCapella::from_ssz_bytes(body)?.execution_payload, + ), + ForkName::Deneb => ExecutionPayload::Deneb( + SszExecutionPayloadEnvelopeDeneb::from_ssz_bytes(body)?.execution_payload, + ), + ForkName::Electra => ExecutionPayload::Electra( + SszExecutionPayloadEnvelopeElectra::from_ssz_bytes(body)?.execution_payload, + ), + ForkName::Fulu => ExecutionPayload::Fulu( + SszExecutionPayloadEnvelopeFulu::from_ssz_bytes(body)?.execution_payload, + ), + ForkName::Gloas => ExecutionPayload::Gloas( + SszExecutionPayloadEnvelopeGloas::from_ssz_bytes(body)?.execution_payload, + ), + ForkName::Heze => ExecutionPayload::Heze( + SszExecutionPayloadEnvelopeHeze::from_ssz_bytes(body)?.execution_payload, + ), + ForkName::Base | ForkName::Altair => { + return Err(DecodeError::BytesInvalid(format!( + "unsupported fork for new_payload envelope: {fork}" + ))); + } + }) +} + +fn decode_forkchoice_updated( + body: &[u8], + fork: ForkName, +) -> Result<(ForkchoiceState, Option), DecodeError> { + Ok(match fork { + ForkName::Bellatrix => { + let update = SszForkchoiceUpdateBellatrix::from_ssz_bytes(body)?; + let attributes = update.payload_attributes.first().cloned(); + ( + update.forkchoice_state, + attributes.map(PayloadAttributes::V1), + ) + } + ForkName::Capella => { + let update = SszForkchoiceUpdateCapella::from_ssz_bytes(body)?; + let attributes = update.payload_attributes.first().cloned(); + ( + update.forkchoice_state, + attributes.map(PayloadAttributes::V2), + ) + } + ForkName::Deneb => { + let update = SszForkchoiceUpdateDeneb::from_ssz_bytes(body)?; + let attributes = update.payload_attributes.first().cloned(); + ( + update.forkchoice_state, + attributes.map(PayloadAttributes::V3), + ) + } + ForkName::Electra => { + let update = SszForkchoiceUpdateElectra::from_ssz_bytes(body)?; + let attributes = update.payload_attributes.first().cloned(); + ( + update.forkchoice_state, + attributes.map(PayloadAttributes::V3), + ) + } + ForkName::Fulu => { + let update = SszForkchoiceUpdateFulu::from_ssz_bytes(body)?; + let attributes = update.payload_attributes.first().cloned(); + ( + update.forkchoice_state, + attributes.map(PayloadAttributes::V3), + ) + } + ForkName::Gloas => { + let update = SszForkchoiceUpdateCustodyColumnsGloas::::from_ssz_bytes(body)?; + let attributes = update.payload_attributes.first().cloned(); + ( + update.forkchoice_state, + attributes.map(PayloadAttributes::V4), + ) + } + ForkName::Heze => { + let update = SszForkchoiceUpdateCustodyColumnsHeze::::from_ssz_bytes(body)?; + let attributes = update.payload_attributes.first().cloned(); + ( + update.forkchoice_state, + attributes.map(PayloadAttributes::V5), + ) + } + ForkName::Base | ForkName::Altair => { + return Err(DecodeError::BytesInvalid(format!( + "unsupported fork for forkchoice update: {fork}" + ))); + } + }) +} + +fn encode_get_payload(core: CorePayload) -> Result, String> { + Ok( + SszGetPayloadResponse::try_from(core.into_get_payload_response()) + .map_err(|e| format!("{e:?}"))? + .as_ssz_bytes(), + ) +} + +fn decode_blobs_request(body: &[u8]) -> Result, DecodeError> { + Ok(SszBlobsRequestV1::::from_ssz_bytes(body)? + .versioned_hashes + .to_vec()) +} + +fn no_content_response() -> Response { + Response::builder().status(204).body(Bytes::new()).unwrap() +} + +/// `all_or_nothing = true` for `/blobs/v2` (any miss ⇒ `204`), `false` for `/blobs/v3` (partial). +fn handle_get_blobs( + ctx: &Context, + body: &[u8], + all_or_nothing: bool, +) -> Response { + let Ok(versioned_hashes) = decode_blobs_request::(body) else { + return problem_response(RestProblemKind::SszDecodeError, None); + }; + + let generator = ctx.execution_block_generator.read(); + let mut entries = Vec::with_capacity(versioned_hashes.len()); + let mut all_present = true; + for hash in &versioned_hashes { + match generator.get_blob_and_proof(hash) { + Some(BlobAndProof::V2(contents)) => entries.push(BlobsEntryV2 { + available: true, + contents, + }), + _ => { + all_present = false; + entries.push(BlobsEntryV2 { + available: false, + contents: BlobAndProofV2 { + blob: Default::default(), + proofs: Default::default(), + }, + }); + } + } + } + + if all_or_nothing && !all_present { + return no_content_response(); + } + + let entries = match VariableList::new(entries) { + Ok(entries) => entries, + Err(e) => return problem_response(RestProblemKind::Internal, Some(format!("{e:?}"))), + }; + ssz_response(Bytes::from(SszBlobsResponseV2 { entries }.as_ssz_bytes())) +} + +/// `/blobs/v4` serves each blob's requested-column cells + proofs, with a blob the mock +/// doesn't have marked `available = false`. +fn handle_get_blobs_v4(ctx: &Context, body: &[u8]) -> Response { + let Ok(request) = SszBlobsRequestV2::::from_ssz_bytes(body) else { + return problem_response(RestProblemKind::SszDecodeError, None); + }; + let versioned_hashes = request.versioned_hashes.to_vec(); + let requested_indices: Vec = request + .indices_bitarray + .iter() + .enumerate() + .filter_map(|(index, set)| set.then_some(index)) + .collect(); + + let generator = ctx.execution_block_generator.read(); + let mut entries = Vec::with_capacity(versioned_hashes.len()); + for hash in &versioned_hashes { + let entry = match generator.get_blob_cells_and_proofs(hash) { + Some((cells, cell_proofs)) => { + let mut blob_cells = Vec::with_capacity(requested_indices.len()); + let mut proofs = Vec::with_capacity(requested_indices.len()); + for &index in &requested_indices { + let Some((cell, proof)) = cells.get(index).zip(cell_proofs.get(index)) else { + return problem_response( + RestProblemKind::Internal, + Some(format!("cell index {index} out of range")), + ); + }; + blob_cells.push(Some(JsonCell(cell.clone()))); + proofs.push(Some(*proof)); + } + let contents = match SszBlobCellsAndProofs::try_from(BlobCellsAndProofsV1:: { + blob_cells, + proofs, + }) { + Ok(contents) => contents, + Err(e) => { + return problem_response(RestProblemKind::Internal, Some(format!("{e:?}"))); + } + }; + BlobsEntryV4 { + available: true, + contents, + } + } + None => BlobsEntryV4 { + available: false, + contents: SszBlobCellsAndProofs { + blob_cells: VariableList::empty(), + proofs: VariableList::empty(), + }, + }, + }; + entries.push(entry); + } + + let entries = match VariableList::new(entries) { + Ok(entries) => entries, + Err(e) => return problem_response(RestProblemKind::Internal, Some(format!("{e:?}"))), + }; + ssz_response(Bytes::from(SszBlobsResponseV4 { entries }.as_ssz_bytes())) +} + +fn encode_bodies_response( + payloads: Vec>>, + fork: ForkName, +) -> Result, String> { + let bodies: Vec>> = payloads + .into_iter() + .map(|maybe_payload| { + maybe_payload + .map(|payload| -> Result, String> { + let transactions = payload + .transactions() + .iter() + .map(|transaction| ProgressiveVariableList::new(transaction.to_vec())) + .collect::, _>>() + .and_then(ProgressiveTransactions::new) + .map_err(|e| { + format!("failed to build progressive transactions list: {e:?}") + })?; + let withdrawals = payload + .withdrawals() + .ok() + .map(|withdrawals| ProgressiveWithdrawals::::new(withdrawals.to_vec())) + .transpose() + .map_err(|e| format!("withdrawals exceed SSZ bound: {e:?}"))?; + Ok(ExecutionPayloadBodyV2 { + transactions, + withdrawals, + block_access_list: payload.block_access_list().ok().cloned(), + }) + }) + .transpose() + }) + .collect::, String>>()?; + + let bytes = match fork { + ForkName::Bellatrix => { + let entries = bodies + .into_iter() + .map(|body| { + Ok(match body { + Some(body) => SszBodyEntryV1 { + available: true, + body: SszExecutionPayloadBodyV1::try_from(body) + .map_err(|e| format!("{e:?}"))?, + }, + None => SszBodyEntryV1 { + available: false, + body: SszExecutionPayloadBodyV1 { + transactions: VariableList::empty(), + }, + }, + }) + }) + .collect::>, String>>()?; + SszBodiesResponseV1 { + entries: VariableList::new(entries).map_err(|e| format!("{e:?}"))?, + } + .as_ssz_bytes() + } + ForkName::Capella | ForkName::Deneb | ForkName::Electra | ForkName::Fulu => { + let entries = bodies + .into_iter() + .map(|body| { + Ok(match body { + Some(body) => SszBodyEntryV2 { + available: true, + body: SszExecutionPayloadBodyV2::try_from(body) + .map_err(|e| format!("{e:?}"))?, + }, + None => SszBodyEntryV2 { + available: false, + body: SszExecutionPayloadBodyV2 { + transactions: VariableList::empty(), + withdrawals: VariableList::empty(), + }, + }, + }) + }) + .collect::>, String>>()?; + SszBodiesResponseV2 { + entries: VariableList::new(entries).map_err(|e| format!("{e:?}"))?, + } + .as_ssz_bytes() + } + ForkName::Gloas | ForkName::Heze => { + let entries = bodies + .into_iter() + .map(|body| { + Ok(match body { + Some(body) => SszBodyEntryV3 { + available: true, + body: SszExecutionPayloadBodyV3::try_from(body) + .map_err(|e| format!("{e:?}"))?, + }, + None => SszBodyEntryV3 { + available: false, + body: SszExecutionPayloadBodyV3 { + transactions: VariableList::empty(), + withdrawals: VariableList::empty(), + block_access_list: VariableList::empty(), + }, + }, + }) + }) + .collect::>, String>>()?; + SszBodiesResponseV3 { + entries: VariableList::new(entries).map_err(|e| format!("{e:?}"))?, + } + .as_ssz_bytes() + } + ForkName::Base | ForkName::Altair => { + return Err(format!( + "unsupported fork for execution payload bodies: {fork}" + )); + } + }; + + Ok(bytes) +} + +fn decode_bodies_by_hash_request(body: &[u8]) -> Result, DecodeError> { + Ok(SszBodiesByHashRequest::from_ssz_bytes(body)? + .block_hashes + .to_vec()) +} + +fn parse_from_count(query: Option<&str>) -> Option<(u64, u64)> { + let query = query?; + let mut from = None; + let mut count = None; + for pair in query.split('&') { + let (key, value) = pair.split_once('=')?; + match key { + "from" => from = value.parse().ok(), + "count" => count = value.parse().ok(), + _ => {} + } + } + Some((from?, count?)) +} + +pub enum RestProblemKind { + UnsupportedFork, + SszDecodeError, + InvalidRequest, + MethodNotFound, + UnknownPayload, + Internal, +} + +impl RestProblemKind { + fn status(&self) -> u16 { + match self { + RestProblemKind::UnsupportedFork => 400, + RestProblemKind::SszDecodeError => 400, + RestProblemKind::InvalidRequest => 400, + RestProblemKind::MethodNotFound => 404, + RestProblemKind::UnknownPayload => 404, + RestProblemKind::Internal => 500, + } + } + + fn slug(&self) -> &'static str { + match self { + RestProblemKind::UnsupportedFork => "unsupported-fork", + RestProblemKind::SszDecodeError => "ssz-decode-error", + RestProblemKind::InvalidRequest => "invalid-request", + RestProblemKind::MethodNotFound => "method-not-found", + RestProblemKind::UnknownPayload => "unknown-payload", + RestProblemKind::Internal => "internal", + } + } +} + +pub fn problem_response(problem: RestProblemKind, detail: Option) -> Response { + let body = json!({ + "type": format!("/engine-api/errors/{}", problem.slug()), + "detail": detail, + }); + + Response::builder() + .status(problem.status()) + .header("Content-Type", "application/problem+json") + .body(Bytes::from(serde_json::to_vec(&body).unwrap())) + .unwrap() +} + +pub fn core_error_response((message, code): (String, i64)) -> Response { + let problem = if code == UNKNOWN_PAYLOAD_ERROR_CODE { + RestProblemKind::UnknownPayload + } else { + RestProblemKind::Internal + }; + + problem_response(problem, Some(message)) +} diff --git a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs index 9e443ae30a8..6e3ecbafa2d 100644 --- a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs +++ b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs @@ -1,7 +1,7 @@ use super::Context; use crate::engine_api::{http::*, *}; use crate::json_structures::*; -use crate::test_utils::{DEFAULT_CLIENT_VERSION, DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI}; +use crate::test_utils::DEFAULT_CLIENT_VERSION; use serde::de::DeserializeOwned; use serde_json::Value as JsonValue; use std::sync::Arc; @@ -221,45 +221,17 @@ pub async fn handle_rpc( _ => unreachable!(), }; - // Canned responses set by block hash take priority. - if let Some(status) = ctx.get_new_payload_status(request.block_hash()) { - return status - .map(|status| serde_json::to_value(JsonPayloadStatusV1::from(status)).unwrap()) - .map_err(|message| (message, GENERIC_ERROR_CODE)); - } - - let (static_response, should_import) = - if let Some(mut response) = ctx.static_new_payload_response.lock().clone() { - if response.status.status == PayloadStatusV1Status::Valid { - response.status.latest_valid_hash = Some(*request.block_hash()) - } - - (Some(response.status), response.should_import) - } else { - (None, true) - }; - - let dynamic_response = if should_import { - Some( - ctx.execution_block_generator - .write() - .new_payload(request.try_into().unwrap()), - ) - } else { - None - }; - - let mut response = static_response.or(dynamic_response).unwrap(); + let mut status = ctx.core_new_payload(request.try_into().unwrap())?; if method == ENGINE_NEW_PAYLOAD_V6 { // TODO(heze): make this configurable so enforcement tests can exercise an // unsatisfied payload. - if response.status == PayloadStatusV1Status::Valid { - response.inclusion_list_satisfied = Some(true); + if status.status == PayloadStatusV1Status::Valid { + status.inclusion_list_satisfied = Some(true); } - Ok(serde_json::to_value(JsonPayloadStatusV2::from(response)).unwrap()) + Ok(serde_json::to_value(JsonPayloadStatusV2::from(status)).unwrap()) } else { - Ok(serde_json::to_value(JsonPayloadStatusV1::from(response)).unwrap()) + Ok(serde_json::to_value(JsonPayloadStatusV1::from(status)).unwrap()) } } ENGINE_GET_PAYLOAD_V1 @@ -272,42 +244,18 @@ pub async fn handle_rpc( get_param(params, 0).map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?; let id = request.into(); - let response = ctx - .execution_block_generator - .write() - .get_payload(&id) - .ok_or_else(|| { - ( - format!("no payload for id {:?}", id), - UNKNOWN_PAYLOAD_ERROR_CODE, - ) - })?; - - let maybe_blobs = ctx.execution_block_generator.write().get_blobs_bundle(&id); - let maybe_execution_requests = ctx - .execution_block_generator - .read() - .get_execution_requests(&id); + let core = ctx.core_get_payload(id)?; + let fork = core.fork; // validate method called correctly according to shanghai fork time - if ctx - .execution_block_generator - .read() - .get_fork_at_timestamp(response.timestamp()) - == ForkName::Capella - && method == ENGINE_GET_PAYLOAD_V1 - { + if fork == ForkName::Capella && method == ENGINE_GET_PAYLOAD_V1 { return Err(( format!("{} called after Capella fork!", method), FORK_REQUEST_MISMATCH_ERROR_CODE, )); } // validate method called correctly according to cancun fork time - if ctx - .execution_block_generator - .read() - .get_fork_at_timestamp(response.timestamp()) - == ForkName::Deneb + if fork == ForkName::Deneb && (method == ENGINE_GET_PAYLOAD_V1 || method == ENGINE_GET_PAYLOAD_V2) { return Err(( @@ -316,11 +264,7 @@ pub async fn handle_rpc( )); } // validate method called correctly according to prague fork time - if ctx - .execution_block_generator - .read() - .get_fork_at_timestamp(response.timestamp()) - == ForkName::Electra + if fork == ForkName::Electra && (method == ENGINE_GET_PAYLOAD_V1 || method == ENGINE_GET_PAYLOAD_V2 || method == ENGINE_GET_PAYLOAD_V3) @@ -332,11 +276,7 @@ pub async fn handle_rpc( } // validate method called correctly according to osaka fork time - if ctx - .execution_block_generator - .read() - .get_fork_at_timestamp(response.timestamp()) - == ForkName::Fulu + if fork == ForkName::Fulu && (method == ENGINE_GET_PAYLOAD_V1 || method == ENGINE_GET_PAYLOAD_V2 || method == ENGINE_GET_PAYLOAD_V3 @@ -349,11 +289,7 @@ pub async fn handle_rpc( } // validate method called correctly according to amsterdam fork time - if ctx - .execution_block_generator - .read() - .get_fork_at_timestamp(response.timestamp()) - == ForkName::Gloas + if fork == ForkName::Gloas && (method == ENGINE_GET_PAYLOAD_V1 || method == ENGINE_GET_PAYLOAD_V2 || method == ENGINE_GET_PAYLOAD_V3 @@ -367,11 +303,7 @@ pub async fn handle_rpc( } // validate method called correctly according to heze fork time - if ctx - .execution_block_generator - .read() - .get_fork_at_timestamp(response.timestamp()) - == ForkName::Heze + if fork == ForkName::Heze && (method == ENGINE_GET_PAYLOAD_V1 || method == ENGINE_GET_PAYLOAD_V2 || method == ENGINE_GET_PAYLOAD_V3 @@ -386,144 +318,17 @@ pub async fn handle_rpc( match method { ENGINE_GET_PAYLOAD_V1 => Ok(serde_json::to_value( - JsonExecutionPayload::try_from(response).unwrap(), + JsonExecutionPayload::try_from(core.payload).unwrap(), ) .unwrap()), - ENGINE_GET_PAYLOAD_V2 => { - Ok(match JsonExecutionPayload::try_from(response).unwrap() { - JsonExecutionPayload::Bellatrix(execution_payload) => { - serde_json::to_value(JsonGetPayloadResponseBellatrix { - execution_payload, - block_value: Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI), - }) - .unwrap() - } - JsonExecutionPayload::Capella(execution_payload) => { - serde_json::to_value(JsonGetPayloadResponseCapella { - execution_payload, - block_value: Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI), - }) - .unwrap() - } - _ => unreachable!(), - }) - } - // From v3 onwards, we use the getPayload version only for the corresponding - // ExecutionPayload version. So we return an error if the ExecutionPayload version - // we get does not correspond to the getPayload version. - ENGINE_GET_PAYLOAD_V3 => { - Ok(match JsonExecutionPayload::try_from(response).unwrap() { - JsonExecutionPayload::Deneb(execution_payload) => { - serde_json::to_value(JsonGetPayloadResponseDeneb { - execution_payload, - block_value: Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI), - blobs_bundle: maybe_blobs - .ok_or(( - "No blobs returned despite V3 Payload".to_string(), - GENERIC_ERROR_CODE, - ))? - .into(), - should_override_builder: false, - }) - .unwrap() - } - _ => unreachable!(), - }) - } - ENGINE_GET_PAYLOAD_V4 => { - Ok(match JsonExecutionPayload::try_from(response).unwrap() { - JsonExecutionPayload::Electra(execution_payload) => { - serde_json::to_value(JsonGetPayloadResponseElectra { - execution_payload, - block_value: Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI), - blobs_bundle: maybe_blobs - .ok_or(( - "No blobs returned despite V4 Payload".to_string(), - GENERIC_ERROR_CODE, - ))? - .into(), - should_override_builder: false, - execution_requests: maybe_execution_requests - .clone() - .unwrap_or_else(|| { - types::ExecutionRequests::Electra(Default::default()) - }) - .into(), - }) - .unwrap() - } - _ => unreachable!(), - }) - } - ENGINE_GET_PAYLOAD_V5 => { - Ok(match JsonExecutionPayload::try_from(response).unwrap() { - JsonExecutionPayload::Fulu(execution_payload) => { - serde_json::to_value(JsonGetPayloadResponseFulu { - execution_payload, - block_value: Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI), - blobs_bundle: maybe_blobs - .ok_or(( - "No blobs returned despite V5 Payload".to_string(), - GENERIC_ERROR_CODE, - ))? - .into(), - should_override_builder: false, - execution_requests: maybe_execution_requests - .clone() - .unwrap_or_else(|| { - types::ExecutionRequests::Electra(Default::default()) - }) - .into(), - }) - .unwrap() - } - _ => unreachable!(), - }) - } - ENGINE_GET_PAYLOAD_V6 => { - Ok(match JsonExecutionPayload::try_from(response).unwrap() { - JsonExecutionPayload::Gloas(execution_payload) => { - serde_json::to_value(JsonGetPayloadResponseGloas { - execution_payload, - block_value: Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI), - blobs_bundle: maybe_blobs - .ok_or(( - "No blobs returned despite V6 Payload".to_string(), - GENERIC_ERROR_CODE, - ))? - .into(), - should_override_builder: false, - execution_requests: maybe_execution_requests - .unwrap_or_else(|| { - types::ExecutionRequests::Electra(Default::default()) - }) - .into(), - }) - .unwrap() - } - JsonExecutionPayload::Heze(execution_payload) => { - serde_json::to_value(JsonGetPayloadResponseHeze { - execution_payload, - block_value: Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI), - blobs_bundle: maybe_blobs - .ok_or(( - "No blobs returned despite V6 Payload".to_string(), - GENERIC_ERROR_CODE, - ))? - .into(), - should_override_builder: false, - execution_requests: maybe_execution_requests - .unwrap_or_else(|| { - types::ExecutionRequests::Electra(Default::default()) - }) - .into(), - }) - .unwrap() - } - _ => unreachable!(), - }) + // V2 onwards wraps the payload in a getPayload response; the version is validated + // against the fork above, so the shared conversion yields the matching variant. + _ => { + let response = + JsonGetPayloadResponse::try_from(core.into_get_payload_response()) + .map_err(|e| (format!("{e:?}"), GENERIC_ERROR_CODE))?; + Ok(serde_json::to_value(response).unwrap()) } - _ => unreachable!(), } } ENGINE_GET_BLOBS_V2 => { @@ -729,45 +534,10 @@ pub async fn handle_rpc( }; } - if let Some(hook_response) = ctx - .hook - .lock() - .on_forkchoice_updated(forkchoice_state.clone(), payload_attributes.clone()) - { - return Ok(serde_json::to_value(hook_response).unwrap()); - } - - let head_block_hash = forkchoice_state.head_block_hash; - - // Canned responses set by block hash take priority. - if let Some(status) = ctx.get_fcu_payload_status(&head_block_hash) { - return status - .map(|status| { - let response = JsonForkchoiceUpdatedV1Response { - payload_status: JsonPayloadStatusV1::from(status), - payload_id: None, - }; - serde_json::to_value(response).unwrap() - }) - .map_err(|message| (message, GENERIC_ERROR_CODE)); - } - - let mut response = ctx - .execution_block_generator - .write() - .forkchoice_updated( - forkchoice_state.into(), - payload_attributes.map(|json| json.into()), - ) - .map_err(|s| (s, GENERIC_ERROR_CODE))?; - - if let Some(mut status) = ctx.static_forkchoice_updated_response.lock().clone() { - if status.status == PayloadStatusV1Status::Valid { - status.latest_valid_hash = Some(head_block_hash) - } - - response.payload_status = status.into(); - } + let response = ctx.core_forkchoice_updated( + forkchoice_state.into(), + payload_attributes.map(|json| json.into()), + )?; Ok(serde_json::to_value(response).unwrap()) } diff --git a/beacon_node/execution_layer/src/test_utils/hook.rs b/beacon_node/execution_layer/src/test_utils/hook.rs index 4653811ac90..be3704d60d5 100644 --- a/beacon_node/execution_layer/src/test_utils/hook.rs +++ b/beacon_node/execution_layer/src/test_utils/hook.rs @@ -1,9 +1,8 @@ +use crate::engine_api::PayloadAttributes; +use crate::engines::ForkchoiceState; use crate::json_structures::*; -type ForkChoiceUpdatedHook = dyn Fn( - JsonForkchoiceStateV1, - Option, - ) -> Option +type ForkChoiceUpdatedHook = dyn Fn(ForkchoiceState, Option) -> Option + Send + Sync; @@ -15,8 +14,8 @@ pub struct Hook { impl Hook { pub fn on_forkchoice_updated( &self, - state: JsonForkchoiceStateV1, - payload_attributes: Option, + state: ForkchoiceState, + payload_attributes: Option, ) -> Option { (self.forkchoice_updated.as_ref()?)(state, payload_attributes) } diff --git a/beacon_node/execution_layer/src/test_utils/mock_builder.rs b/beacon_node/execution_layer/src/test_utils/mock_builder.rs index 38ddaf1766c..c5d25ddcd98 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_builder.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_builder.rs @@ -998,6 +998,7 @@ impl MockBuilder { head_block_root, head_payload_status, &[], + fork, ) .await .map_err(|e| format!("fcu call failed : {:?}", e))?; diff --git a/beacon_node/execution_layer/src/test_utils/mock_engine_core.rs b/beacon_node/execution_layer/src/test_utils/mock_engine_core.rs new file mode 100644 index 00000000000..0b55fbe885b --- /dev/null +++ b/beacon_node/execution_layer/src/test_utils/mock_engine_core.rs @@ -0,0 +1,213 @@ +use crate::engine_api::{ + GetPayloadResponse, GetPayloadResponseBellatrix, GetPayloadResponseCapella, + GetPayloadResponseDeneb, GetPayloadResponseElectra, GetPayloadResponseFulu, + GetPayloadResponseGloas, GetPayloadResponseHeze, PayloadAttributes, PayloadId, + PayloadStatusV1Status, +}; +use crate::engines::ForkchoiceState; +use crate::json_structures::{JsonForkchoiceUpdatedV1Response, JsonPayloadStatusV1}; +use crate::test_utils::handle_rpc::{GENERIC_ERROR_CODE, UNKNOWN_PAYLOAD_ERROR_CODE}; +use crate::{BlobsBundle, PayloadStatusV1}; +use types::{ + EthSpec, ExecutionPayload, ExecutionRequests, ExecutionRequestsElectra, ExecutionRequestsGloas, + ForkName, Uint256, +}; + +use super::{Context, DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI}; + +pub(crate) struct CorePayload { + pub payload: ExecutionPayload, + pub blobs: Option>, + pub requests: Option>, + pub fork: ForkName, +} + +impl CorePayload { + pub(crate) fn into_get_payload_response(self) -> GetPayloadResponse { + let CorePayload { + payload, + blobs, + requests, + fork: _, + } = self; + let block_value = Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI); + let blobs_bundle = blobs.unwrap_or_default(); + + match payload { + ExecutionPayload::Bellatrix(execution_payload) => { + GetPayloadResponse::Bellatrix(GetPayloadResponseBellatrix { + execution_payload, + block_value, + }) + } + ExecutionPayload::Capella(execution_payload) => { + GetPayloadResponse::Capella(GetPayloadResponseCapella { + execution_payload, + block_value, + }) + } + ExecutionPayload::Deneb(execution_payload) => { + GetPayloadResponse::Deneb(GetPayloadResponseDeneb { + execution_payload, + block_value, + blobs_bundle, + should_override_builder: false, + }) + } + ExecutionPayload::Electra(execution_payload) => { + GetPayloadResponse::Electra(GetPayloadResponseElectra { + execution_payload, + block_value, + blobs_bundle, + should_override_builder: false, + requests: electra_requests(requests), + }) + } + ExecutionPayload::Fulu(execution_payload) => { + GetPayloadResponse::Fulu(GetPayloadResponseFulu { + execution_payload, + block_value, + blobs_bundle, + should_override_builder: false, + requests: electra_requests(requests), + }) + } + ExecutionPayload::Gloas(execution_payload) => { + GetPayloadResponse::Gloas(GetPayloadResponseGloas { + execution_payload, + block_value, + blobs_bundle, + should_override_builder: false, + requests: gloas_requests(requests), + }) + } + ExecutionPayload::Heze(execution_payload) => { + GetPayloadResponse::Heze(GetPayloadResponseHeze { + execution_payload, + block_value, + blobs_bundle, + should_override_builder: false, + requests: gloas_requests(requests), + }) + } + } + } +} + +fn electra_requests( + requests: Option>, +) -> ExecutionRequestsElectra { + match requests { + Some(ExecutionRequests::Electra(inner)) => inner, + _ => ExecutionRequestsElectra::default(), + } +} + +fn gloas_requests(requests: Option>) -> ExecutionRequestsGloas { + match requests { + Some(ExecutionRequests::Gloas(inner)) => inner, + _ => ExecutionRequestsGloas::default(), + } +} + +impl Context { + pub(crate) fn core_new_payload( + &self, + payload: ExecutionPayload, + ) -> Result { + // Canned responses set by block hash take priority. + if let Some(status) = self.get_new_payload_status(&payload.block_hash()) { + return status.map_err(|message| (message, GENERIC_ERROR_CODE)); + } + + let (static_response, should_import) = + if let Some(mut response) = self.static_new_payload_response.lock().clone() { + if response.status.status == PayloadStatusV1Status::Valid { + response.status.latest_valid_hash = Some(payload.block_hash()) + } + + (Some(response.status), response.should_import) + } else { + (None, true) + }; + + let dynamic_response = if should_import { + Some(self.execution_block_generator.write().new_payload(payload)) + } else { + None + }; + + Ok(static_response.or(dynamic_response).unwrap()) + } + + pub(crate) fn core_forkchoice_updated( + &self, + state: ForkchoiceState, + attrs: Option, + ) -> Result { + *self.previous_forkchoice_request.lock() = Some((state, attrs.clone())); + + if let Some(hook_response) = self.hook.lock().on_forkchoice_updated(state, attrs.clone()) { + return Ok(hook_response); + } + + let head_block_hash = state.head_block_hash; + + // Canned responses set by block hash take priority. + if let Some(status) = self.get_fcu_payload_status(&head_block_hash) { + return status + .map(|status| JsonForkchoiceUpdatedV1Response { + payload_status: JsonPayloadStatusV1::from(status), + payload_id: None, + }) + .map_err(|message| (message, GENERIC_ERROR_CODE)); + } + + let mut response = self + .execution_block_generator + .write() + .forkchoice_updated(state, attrs) + .map_err(|s| (s, GENERIC_ERROR_CODE))?; + + if let Some(mut status) = self.static_forkchoice_updated_response.lock().clone() { + if status.status == PayloadStatusV1Status::Valid { + status.latest_valid_hash = Some(head_block_hash); + } + + response.payload_status = status.into(); + } + + Ok(response) + } + + pub(crate) fn core_get_payload(&self, id: PayloadId) -> Result, (String, i64)> { + let payload = self + .execution_block_generator + .write() + .get_payload(&id) + .ok_or_else(|| { + ( + format!("no payload for id {:?}", id), + UNKNOWN_PAYLOAD_ERROR_CODE, + ) + })?; + + let blobs = self.execution_block_generator.write().get_blobs_bundle(&id); + let requests = self + .execution_block_generator + .read() + .get_execution_requests(&id); + + let fork = self + .execution_block_generator + .read() + .get_fork_at_timestamp(payload.timestamp()); + + Ok(CorePayload { + payload, + blobs, + requests, + fork, + }) + } +} diff --git a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs index d250e495a39..19c5eec18de 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs @@ -1,4 +1,6 @@ -use crate::{test_utils::DEFAULT_JWT_SECRET, test_utils::MockServer, *}; +use crate::{ + test_utils::DEFAULT_JWT_SECRET, test_utils::MockExecutionConfig, test_utils::MockServer, *, +}; use alloy_primitives::B256 as H256; use fixed_bytes::FixedBytesExtended; use kzg::Kzg; @@ -12,6 +14,30 @@ pub struct MockExecutionLayer { pub spec: Arc, } +/// Env var that selects the engine transport for every test-infra `MockExecutionLayer`. +/// +/// Unset or `JSON` runs JSON-RPC (the default); `SSZ` runs the REST-SSZ mock engine. Any other +/// value panics rather than silently falling back. Matching is case-insensitive. Because all +/// mocks — including the `beacon_chain` harness's `mock_execution_layer_from_parts` — funnel +/// through `MockExecutionLayer::new`, setting this flips the transport across the whole test tree +/// with no per-crate Cargo plumbing, e.g. `LH_ENGINE_TRANSPORT=SSZ cargo nextest run`. +pub const ENGINE_TRANSPORT_ENV_VAR: &str = "LH_ENGINE_TRANSPORT"; + +/// `true` when `LH_ENGINE_TRANSPORT` selects the REST-SSZ mock transport (`SSZ`); `false` when +/// unset or `JSON`. Panics on any other value so a typo can't quietly run the wrong transport. +pub fn mock_rest_ssz_enabled() -> bool { + match std::env::var(ENGINE_TRANSPORT_ENV_VAR) { + Ok(value) => match value.trim().to_ascii_uppercase().as_str() { + "SSZ" => true, + "" | "JSON" => false, + other => { + panic!("invalid {ENGINE_TRANSPORT_ENV_VAR}={other:?}; expected \"JSON\" or \"SSZ\"") + } + }, + Err(_) => false, + } +} + impl MockExecutionLayer { pub fn default_params(executor: TaskExecutor) -> Self { let mut spec = MainnetEthSpec::default_spec(); @@ -31,6 +57,46 @@ impl MockExecutionLayer { ) } + pub fn fulu_params(executor: TaskExecutor) -> Self { + let mut spec = MainnetEthSpec::default_spec(); + spec.terminal_block_hash = ExecutionBlockHash::zero(); + spec.terminal_block_hash_activation_epoch = Epoch::new(0); + Self::new( + executor, + Some(0), + Some(0), + Some(0), + Some(0), + None, + None, + Some(JwtKey::from_slice(&DEFAULT_JWT_SECRET).unwrap()), + Arc::new(spec), + None, + ) + } + + /// Build a mock whose client advertises REST-SSZ but whose server does not serve it. The + /// capabilities probe then 404s over REST, forcing the transport to fall back to JSON-RPC. + pub fn rest_client_without_rest_server(executor: TaskExecutor) -> Self { + let mut spec = MainnetEthSpec::default_spec(); + spec.terminal_block_hash = ExecutionBlockHash::zero(); + spec.terminal_block_hash_activation_epoch = Epoch::new(0); + Self::new_with_transport( + executor, + None, + None, + None, + None, + None, + None, + Some(JwtKey::from_slice(&DEFAULT_JWT_SECRET).unwrap()), + Arc::new(spec), + None, + true, + false, + ) + } + #[allow(clippy::too_many_arguments)] pub fn new( executor: TaskExecutor, @@ -44,18 +110,54 @@ impl MockExecutionLayer { spec: Arc, kzg: Option>, ) -> Self { - let handle = executor.handle().unwrap(); - - let jwt_key = jwt_key.unwrap_or_else(JwtKey::random); - let server = MockServer::new( - &handle, - jwt_key, + let rest_ssz = mock_rest_ssz_enabled(); + Self::new_with_transport( + executor, shanghai_time, cancun_time, prague_time, osaka_time, amsterdam_time, heze_time, + jwt_key, + spec, + kzg, + rest_ssz, + rest_ssz, + ) + } + + #[allow(clippy::too_many_arguments)] + fn new_with_transport( + executor: TaskExecutor, + shanghai_time: Option, + cancun_time: Option, + prague_time: Option, + osaka_time: Option, + amsterdam_time: Option, + heze_time: Option, + jwt_key: Option, + spec: Arc, + kzg: Option>, + client_rest_ssz: bool, + server_rest_ssz: bool, + ) -> Self { + let handle = executor.handle().unwrap(); + + let jwt_key = jwt_key.unwrap_or_else(JwtKey::random); + let server = MockServer::new_with_config( + &handle, + MockExecutionConfig { + jwt_key, + shanghai_time, + cancun_time, + prague_time, + osaka_time, + amsterdam_time, + heze_time, + serve_rest_ssz: server_rest_ssz, + ..Default::default() + }, kzg, ); @@ -69,6 +171,7 @@ impl MockExecutionLayer { execution_endpoint: Some(url), secret_file: Some(path), suggested_fee_recipient: Some(Address::repeat_byte(42)), + engine_api_rest_ssz: client_rest_ssz, ..Default::default() }; let el = ExecutionLayer::from_config(config, executor.clone()).unwrap(); @@ -81,6 +184,19 @@ impl MockExecutionLayer { } } + /// Resolve the engine transport with an **awaited** upcheck, then assert it matches the mode + /// selected by `LH_ENGINE_TRANSPORT` (`Rest` when `SSZ`, `JsonRpcOnly` otherwise). + pub async fn resolve_and_assert_transport(self) -> Self { + self.el.upcheck().await; + let expected = if mock_rest_ssz_enabled() { + Transport::Rest + } else { + Transport::JsonRpcOnly + }; + assert_eq!(self.el.resolved_transport(), Some(expected)); + self + } + pub async fn produce_valid_execution_payload_on_head(self) -> Self { let latest_execution_block = { let block_gen = self.server.execution_block_generator(); @@ -134,6 +250,7 @@ impl MockExecutionLayer { head_block_root, head_payload_status, &[], + ForkName::Bellatrix, ) .await .unwrap(); @@ -243,6 +360,7 @@ impl MockExecutionLayer { block_number, timestamp, prev_randao, + ForkName::Bellatrix, ) .await; } @@ -256,6 +374,7 @@ impl MockExecutionLayer { block_number, timestamp, prev_randao, + ForkName::Bellatrix, ) .await; } @@ -264,6 +383,106 @@ impl MockExecutionLayer { self } + pub async fn reconcile_unknown_payload_on_head(self) -> Self { + let latest_execution_block = { + let block_gen = self.server.execution_block_generator(); + block_gen.latest_block().unwrap() + }; + + let parent_hash = latest_execution_block.block_hash(); + let parent_gas_limit = latest_execution_block.gas_limit(); + let block_number = latest_execution_block.block_number() + 1; + let timestamp = block_number; + let prev_randao = Hash256::from_low_u64_be(block_number); + let head_block_root = Hash256::repeat_byte(42); + let slot = Slot::new(0); + let validator_index = 0; + + let current_fork = self + .server + .execution_block_generator() + .get_fork_at_timestamp(timestamp); + + let forkchoice_update_params = ForkchoiceUpdateParameters { + head_root: head_block_root, + head_hash: Some(parent_hash), + justified_hash: None, + finalized_hash: None, + }; + let suggested_fee_recipient = self.el.get_suggested_fee_recipient(validator_index).await; + let payload_attributes = PayloadAttributes::new( + timestamp, + prev_randao, + suggested_fee_recipient, + current_fork.capella_enabled().then(Vec::new), + current_fork.deneb_enabled().then(Hash256::zero), + None, + None, + None, + ); + + let build_params = || PayloadParameters { + parent_hash, + parent_gas_limit: Some(parent_gas_limit), + proposer_gas_limit: None, + payload_attributes: &payload_attributes, + forkchoice_update_params: &forkchoice_update_params, + current_fork, + }; + let builder_params = || BuilderParams { + pubkey: PublicKeyBytes::empty(), + slot, + chain_health: ChainHealth::Healthy, + }; + + self.el + .get_payload( + build_params(), + builder_params(), + &self.spec, + None, + BlockProductionVersion::FullV2, + ) + .await + .unwrap(); + + let expired_before = crate::metrics::get_int_counter( + &crate::metrics::EXECUTION_LAYER_PRE_PREPARED_PAYLOAD_ID, + &[crate::metrics::EXPIRED], + ) + .map(|counter| counter.get()) + .unwrap_or(0); + + // Make the cached id unknown to the EL so the next `get_payload` reconciles. + self.server.expire_all_payload_ids(); + + // Same `(parent_hash, attrs)`: cache HIT the stale id -> unknown-payload -> re-fcU -> retry once. + self.el + .get_payload( + build_params(), + builder_params(), + &self.spec, + None, + BlockProductionVersion::FullV2, + ) + .await + .unwrap(); + + let expired_after = crate::metrics::get_int_counter( + &crate::metrics::EXECUTION_LAYER_PRE_PREPARED_PAYLOAD_ID, + &[crate::metrics::EXPIRED], + ) + .map(|counter| counter.get()) + .unwrap_or(0); + assert_eq!( + expired_after, + expired_before + 1, + "reconcile did not fire: cached id was not observed as unknown-payload" + ); + + self + } + #[allow(clippy::too_many_arguments)] pub async fn assert_valid_execution_payload_on_head>( &self, @@ -274,6 +493,7 @@ impl MockExecutionLayer { block_number: u64, timestamp: u64, prev_randao: H256, + fork: ForkName, ) { assert_eq!(payload_header.block_hash(), block_hash); assert_eq!(payload_header.parent_hash(), parent_hash); @@ -309,6 +529,7 @@ impl MockExecutionLayer { head_block_root, fork_choice::PayloadStatus::Pending, &[], + fork, ) .await .unwrap(); diff --git a/beacon_node/execution_layer/src/test_utils/mod.rs b/beacon_node/execution_layer/src/test_utils/mod.rs index 50e0f66f0a8..de2e9a80be6 100644 --- a/beacon_node/execution_layer/src/test_utils/mod.rs +++ b/beacon_node/execution_layer/src/test_utils/mod.rs @@ -25,7 +25,7 @@ use tracing::info; use types::{EthSpec, ExecutionBlockHash, Uint256}; use warp::{Filter, Rejection, http::StatusCode}; -use crate::EngineCapabilities; +use crate::{EngineCapabilities, ForkchoiceState, JsonRpcCapabilities, PayloadAttributes}; pub use execution_block_generator::DEFAULT_GAS_LIMIT; pub use execution_block_generator::{ Block, ExecutionBlockGenerator, generate_blobs, generate_genesis_block, @@ -34,11 +34,12 @@ pub use execution_block_generator::{ pub use hook::Hook; pub use mock_builder::{MockBuilder, Operation, mock_builder_extra_data}; pub use mock_execution_layer::MockExecutionLayer; +pub use mock_execution_layer::mock_rest_ssz_enabled; pub const DEFAULT_JWT_SECRET: [u8; 32] = [42; 32]; pub const DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI: u128 = 10_000_000_000_000_000; pub const DEFAULT_BUILDER_PAYLOAD_VALUE_WEI: u128 = 20_000_000_000_000_000; -pub const DEFAULT_ENGINE_CAPABILITIES: EngineCapabilities = EngineCapabilities { +pub const DEFAULT_JSON_RPC_CAPABILITIES: JsonRpcCapabilities = JsonRpcCapabilities { new_payload_v1: true, new_payload_v2: true, new_payload_v3: true, @@ -67,6 +68,9 @@ pub const DEFAULT_ENGINE_CAPABILITIES: EngineCapabilities = EngineCapabilities { get_inclusion_list_v1: true, }; +pub const DEFAULT_ENGINE_CAPABILITIES: EngineCapabilities = + EngineCapabilities::JsonRpc(DEFAULT_JSON_RPC_CAPABILITIES); + pub static DEFAULT_CLIENT_VERSION: LazyLock = LazyLock::new(|| JsonClientVersionV1 { code: "MC".to_string(), // "mock client" @@ -76,9 +80,11 @@ pub static DEFAULT_CLIENT_VERSION: LazyLock = }); mod execution_block_generator; +mod handle_rest; mod handle_rpc; mod hook; mod mock_builder; +mod mock_engine_core; mod mock_execution_layer; /// Configuration for the MockExecutionLayer. @@ -91,6 +97,7 @@ pub struct MockExecutionConfig { pub prague_time: Option, pub osaka_time: Option, pub amsterdam_time: Option, + pub serve_rest_ssz: bool, pub heze_time: Option, } @@ -104,6 +111,7 @@ impl Default for MockExecutionConfig { prague_time: None, osaka_time: None, amsterdam_time: None, + serve_rest_ssz: false, heze_time: None, } } @@ -145,6 +153,7 @@ impl MockServer { prague_time, osaka_time, amsterdam_time, + serve_rest_ssz, heze_time, } = config; let last_echo_request = Arc::new(RwLock::new(None)); @@ -163,8 +172,11 @@ impl MockServer { config: server_config, jwt_key, last_echo_request: last_echo_request.clone(), + last_rest_request: <_>::default(), + serve_rest_ssz, execution_block_generator: RwLock::new(execution_block_generator), previous_request: <_>::default(), + previous_forkchoice_request: <_>::default(), preloaded_responses, static_new_payload_response: <_>::default(), static_forkchoice_updated_response: <_>::default(), @@ -207,6 +219,19 @@ impl MockServer { *self.ctx.engine_capabilities.write() = engine_capabilities; } + pub fn disable_client_version(&self) { + match &mut *self.ctx.engine_capabilities.write() { + EngineCapabilities::JsonRpc(capabilities) => { + capabilities.get_client_version_v1 = false; + } + EngineCapabilities::Ssz(capabilities) => { + capabilities + .unscoped_endpoints + .retain(|e| e.as_str() != "identity"); + } + } + } + #[allow(clippy::too_many_arguments)] pub fn new( handle: &runtime::Handle, @@ -229,6 +254,7 @@ impl MockServer { prague_time, osaka_time, amsterdam_time, + serve_rest_ssz: false, heze_time, }, kzg, @@ -239,6 +265,13 @@ impl MockServer { self.ctx.execution_block_generator.write() } + pub fn expire_all_payload_ids(&self) { + self.ctx + .execution_block_generator + .write() + .expire_all_payload_ids(); + } + pub fn url(&self) -> String { format!( "http://{}:{}", @@ -254,6 +287,14 @@ impl MockServer { .expect("last echo request is none") } + pub fn last_rest_request(&self) -> RestCapture { + self.ctx + .last_rest_request + .write() + .take() + .expect("last rest request is none") + } + pub fn push_preloaded_response(&self, response: serde_json::Value) { self.ctx.preloaded_responses.lock().push(response) } @@ -262,6 +303,10 @@ impl MockServer { self.ctx.previous_request.lock().take() } + pub fn take_previous_forkchoice_request(&self) -> Option { + self.ctx.previous_forkchoice_request.lock().take() + } + pub fn set_new_payload_response(&self, response: StaticNewPayloadResponse) { *self.ctx.static_new_payload_response.lock() = Some(response) } @@ -518,11 +563,25 @@ pub struct StaticNewPayloadResponse { status: PayloadStatusV1, should_import: bool, } + +/// A captured REST-SSZ request (header + path/query + body) recorded by the `/engine/v1/...` routes. +#[derive(Debug, Clone)] +pub struct RestCapture { + pub method: String, + pub path: String, + pub eth_execution_version: Option, + pub client_version: Option, + pub content_type: Option, + pub body: Bytes, +} #[derive(Debug)] struct AuthError(String); impl warp::reject::Reject for AuthError {} +/// A captured `forkchoice_updated` request: the state plus any payload attributes. +pub type CapturedForkchoiceRequest = (ForkchoiceState, Option); + /// A wrapper around all the items required to spawn the HTTP server. /// /// The server will gracefully handle the case where any fields are `None`. @@ -531,9 +590,12 @@ pub struct Context { pub jwt_key: JwtKey, pub last_echo_request: Arc>>, + pub last_rest_request: Arc>>, + pub serve_rest_ssz: bool, pub execution_block_generator: RwLock>, pub preloaded_responses: Arc>>, pub previous_request: Arc>>, + pub previous_forkchoice_request: Arc>>, pub static_new_payload_response: Arc>>, pub static_forkchoice_updated_response: Arc>>, pub hook: Arc>, @@ -646,6 +708,52 @@ async fn handle_rejection(err: Rejection) -> Result( + method: &'static str, + full: warp::path::FullPath, + query: Option, + eth_execution_version: Option, + client_version: Option, + content_type: Option, + body: Bytes, + ctx: Arc>, +) -> Result, warp::Rejection> { + let full_path = full.as_str(); + + if !ctx.serve_rest_ssz { + return Ok(warp::http::Response::builder() + .status(404) + .body(Bytes::new()) + .unwrap()); + } + + let path = match &query { + Some(query) => format!("{full_path}?{query}"), + None => full_path.to_string(), + }; + *ctx.last_rest_request.write() = Some(RestCapture { + method: method.to_string(), + path, + eth_execution_version: eth_execution_version.clone(), + client_version, + content_type, + body: body.clone(), + }); + + Ok(handle_rest::handle_rest( + method, + full_path, + query.as_deref(), + eth_execution_version.as_deref(), + &body, + &ctx, + )) +} + /// Creates a server that will serve requests using information from `ctx`. /// /// The server will shut down gracefully when the `shutdown` future resolves. @@ -722,7 +830,7 @@ pub fn serve( // Sends the body of the request to `ctx.last_echo_request` so we can inspect requests. let echo = warp::path("echo") .and(warp::body::bytes()) - .and(ctx_filter) + .and(ctx_filter.clone()) .and_then(|bytes: Bytes, ctx: Arc>| async move { *ctx.last_echo_request.write() = Some(bytes.clone()); Ok::<_, warp::reject::Rejection>( @@ -730,9 +838,63 @@ pub fn serve( ) }); - let routes = warp::post() - .and(auth_header_filter(ctx.jwt_key.clone())) - .and(root.or(echo)) + // `/engine/v1/...` REST-SSZ routes. + let opt_query = || { + warp::query::raw() + .map(Some) + .or(warp::any().map(|| None)) + .unify() + }; + let rest_post = warp::post() + .and(warp::path("engine")) + .and(warp::path("v1")) + .and(warp::path::full()) + .and(opt_query()) + .and(warp::header::optional::("Eth-Execution-Version")) + .and(warp::header::optional::("X-Engine-Client-Version")) + .and(warp::header::optional::("Content-Type")) + .and(warp::body::bytes()) + .and(ctx_filter.clone()) + .and_then( + |full, query, eth_version, client_version, content_type, body, ctx| { + handle_rest_capture( + "POST", + full, + query, + eth_version, + client_version, + content_type, + body, + ctx, + ) + }, + ); + let rest_get = warp::get() + .and(warp::path("engine")) + .and(warp::path("v1")) + .and(warp::path::full()) + .and(opt_query()) + .and(warp::header::optional::("Eth-Execution-Version")) + .and(warp::header::optional::("X-Engine-Client-Version")) + .and(warp::header::optional::("Content-Type")) + .and(ctx_filter) + .and_then( + |full, query, eth_version, client_version, content_type, ctx| { + handle_rest_capture( + "GET", + full, + query, + eth_version, + client_version, + content_type, + Bytes::new(), + ctx, + ) + }, + ); + + let routes = auth_header_filter(ctx.jwt_key.clone()) + .and(warp::post().and(root.or(echo)).or(rest_post).or(rest_get)) .recover(handle_rejection) // Add a `Server` header. .map(|reply| warp::reply::with_header(reply, "Server", "lighthouse-mock-execution-client")); diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index f31746ddf70..36dd23ae73e 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -514,8 +514,6 @@ pub async fn proposer_boost_re_org_test( .lock() .set_forkchoice_updated_hook(Box::new(move |state, payload_attributes| { let received_at = chain_inner.slot_clock.now_duration().unwrap(); - let state = ForkchoiceState::from(state); - let payload_attributes = payload_attributes.map(Into::into); let update = ForkChoiceUpdateMetadata { received_at, state, diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 71aef5760d1..8ccd53bc5f7 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -464,8 +464,6 @@ pub async fn proposer_boost_re_org_test( .lock() .set_forkchoice_updated_hook(Box::new(move |state, payload_attributes| { let received_at = chain_inner.slot_clock.now_duration().unwrap(); - let state = ForkchoiceState::from(state); - let payload_attributes = payload_attributes.map(Into::into); let update = ForkChoiceUpdateMetadata { received_at, state, diff --git a/beacon_node/src/cli.rs b/beacon_node/src/cli.rs index 39da4fea0ab..cf5886aee36 100644 --- a/beacon_node/src/cli.rs +++ b/beacon_node/src/cli.rs @@ -845,6 +845,16 @@ pub fn cli_app() -> Command { .action(ArgAction::Set) .display_order(0) ) + .arg( + Arg::new("enable-rest-ssz-engine") + .long("enable-rest-ssz-engine") + .help("Use the REST-SSZ Engine API transport instead of JSON-RPC. Requires an \ + execution endpoint that supports the REST-SSZ Engine API; falls back to \ + JSON-RPC if the capabilities probe fails.") + .requires("execution-endpoint") + .action(ArgAction::SetTrue) + .display_order(0) + ) .arg( Arg::new("proof-engine-endpoint") .long("proof-engine-endpoint") diff --git a/beacon_node/src/config.rs b/beacon_node/src/config.rs index 7eb1d013373..cd7260967b5 100644 --- a/beacon_node/src/config.rs +++ b/beacon_node/src/config.rs @@ -365,6 +365,7 @@ pub fn get_config( let execution_timeout_multiplier = clap_utils::parse_required(cli_args, "execution-timeout-multiplier")?; el_config.execution_timeout_multiplier = Some(execution_timeout_multiplier); + el_config.engine_api_rest_ssz = cli_args.get_flag("enable-rest-ssz-engine"); // Store the EL config in the client config. client_config.execution_layer = Some(el_config); diff --git a/book/src/help_bn.md b/book/src/help_bn.md index 3e367f86d19..0d07771c752 100644 --- a/book/src/help_bn.md +++ b/book/src/help_bn.md @@ -92,6 +92,10 @@ Options: Enable partial messages for data columns. This can reduce the amount of data sent over the network. Enabled by default; set to "false" to opt out. [default: true] + --enable-rest-ssz-engine + Use the REST-SSZ Engine API transport instead of JSON-RPC. Requires an + execution endpoint that supports the REST-SSZ Engine API; falls back + to JSON-RPC if the capabilities probe fails. --enr-address
... The IP address/ DNS address to broadcast to other peers on how to reach this node. If a DNS address is provided, the enr-address is set diff --git a/consensus/types/src/core/eth_spec.rs b/consensus/types/src/core/eth_spec.rs index 3cdfa310fee..148f90f8960 100644 --- a/consensus/types/src/core/eth_spec.rs +++ b/consensus/types/src/core/eth_spec.rs @@ -178,7 +178,9 @@ pub trait EthSpec: 'static + Default + Sync + Send + Clone + Debug + PartialEq + type MaxBuildersPerWithdrawalsSweep: Unsigned + Clone + Sync + Send + Debug + PartialEq; type MaxBuilderDepositRequestsPerPayload: Unsigned + Clone + Sync + Send + Debug + PartialEq; type MaxBuilderExitRequestsPerPayload: Unsigned + Clone + Sync + Send + Debug + PartialEq; - + type MaxExecutionRequestsPerPayload: Unsigned + Clone + Sync + Send + Debug + PartialEq; + type MaxErrorBytes: Unsigned + Clone + Sync + Send + Debug + PartialEq; + type MaxVersionedHashesPerRequest: Unsigned + Clone + Sync + Send + Debug + PartialEq; /* * New in Heze */ @@ -553,6 +555,9 @@ impl EthSpec for MainnetEthSpec { type MaxBuildersPerWithdrawalsSweep = U16384; type MaxBuilderDepositRequestsPerPayload = U64; type MaxBuilderExitRequestsPerPayload = U16; + type MaxExecutionRequestsPerPayload = U256; + type MaxErrorBytes = U1024; + type MaxVersionedHashesPerRequest = U128; type InclusionListCommitteeSize = U16; fn default_spec() -> ChainSpec { @@ -616,6 +621,9 @@ impl EthSpec for MinimalEthSpec { type PTCSize = U16; type PtcWindowLength = U24; // (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH type MaxBuildersPerWithdrawalsSweep = U16; + type MaxExecutionRequestsPerPayload = U256; + type MaxErrorBytes = U1024; + type MaxVersionedHashesPerRequest = U128; params_from_eth_spec!(MainnetEthSpec { JustificationBitsLength, @@ -742,6 +750,9 @@ impl EthSpec for GnosisEthSpec { type MaxBuildersPerWithdrawalsSweep = U16384; type MaxBuilderDepositRequestsPerPayload = U64; type MaxBuilderExitRequestsPerPayload = U16; + type MaxExecutionRequestsPerPayload = U256; + type MaxErrorBytes = U1024; + type MaxVersionedHashesPerRequest = U128; type InclusionListCommitteeSize = U16; fn default_spec() -> ChainSpec { diff --git a/lcli/src/mock_el.rs b/lcli/src/mock_el.rs index 628f2cd4d56..5fae9ac0256 100644 --- a/lcli/src/mock_el.rs +++ b/lcli/src/mock_el.rs @@ -52,6 +52,7 @@ pub fn run(mut env: Environment, matches: &ArgMatches) -> Result< prague_time, osaka_time, amsterdam_time, + serve_rest_ssz: false, heze_time, }; let kzg = None; diff --git a/testing/execution_engine_integration/src/test_rig.rs b/testing/execution_engine_integration/src/test_rig.rs index cb64c355f0b..96f2d9b2b30 100644 --- a/testing/execution_engine_integration/src/test_rig.rs +++ b/testing/execution_engine_integration/src/test_rig.rs @@ -340,6 +340,7 @@ impl TestRig { Hash256::zero(), head_payload_status, &[], + TEST_FORK, ) .await .unwrap(); @@ -423,6 +424,7 @@ impl TestRig { head_block_root, head_payload_status, &[], + TEST_FORK, ) .await .unwrap(); @@ -466,6 +468,7 @@ impl TestRig { head_block_root, head_payload_status, &[], + TEST_FORK, ) .await .unwrap(); @@ -624,6 +627,7 @@ impl TestRig { head_block_root, head_payload_status, &[], + TEST_FORK, ) .await .unwrap(); @@ -663,6 +667,7 @@ impl TestRig { head_block_root, head_payload_status, &[], + TEST_FORK, ) .await .unwrap(); @@ -718,6 +723,7 @@ impl TestRig { head_block_root, head_payload_status, &[], + TEST_FORK, ) .await .unwrap(); @@ -740,14 +746,14 @@ async fn check_payload_reconstruction( .unwrap(); assert!( - // if the engine doesn't have this capability, we need to update the client in our tests - capabilities.get_payload_bodies_by_hash_v1, + // if the engine doesn't have these capabilities, we need to update the client in our tests + capabilities.get_payload_bodies_by_hash_v1(TEST_FORK), "Testing engine does not support payload bodies by hash" ); let mut bodies = ee .execution_layer - .get_payload_bodies_by_hash(vec![payload.block_hash()]) + .get_payload_bodies_by_hash_v1(TEST_FORK, vec![payload.block_hash()]) .await .unwrap(); assert_eq!(bodies.len(), 1);