diff --git a/Cargo.lock b/Cargo.lock index 40c550f4c68..a2973b4003c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3372,6 +3372,7 @@ dependencies = [ "eth2", "ethereum_serde_utils", "ethereum_ssz", + "ethereum_ssz_derive", "fixed_bytes", "fork_choice", "hash-db", diff --git a/beacon_node/execution_layer/Cargo.toml b/beacon_node/execution_layer/Cargo.toml index c443e945743..46f34e20f73 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"] } 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" diff --git a/beacon_node/execution_layer/src/engine_api.rs b/beacon_node/execution_layer/src/engine_api.rs index 32090bccfc9..5890f1c30ac 100644 --- a/beacon_node/execution_layer/src/engine_api.rs +++ b/beacon_node/execution_layer/src/engine_api.rs @@ -34,6 +34,8 @@ pub mod auth; pub mod http; pub mod json_structures; mod new_payload_request; +pub mod ssz_rest; +pub mod ssz_rest_encoding; pub use new_payload_request::{ NewPayloadRequest, NewPayloadRequestBellatrix, NewPayloadRequestCapella, diff --git a/beacon_node/execution_layer/src/engine_api/http.rs b/beacon_node/execution_layer/src/engine_api/http.rs index c421491f808..1c58a592eaf 100644 --- a/beacon_node/execution_layer/src/engine_api/http.rs +++ b/beacon_node/execution_layer/src/engine_api/http.rs @@ -13,6 +13,7 @@ use std::sync::LazyLock; use tokio::sync::Mutex; use std::time::{Duration, Instant}; +use tracing::debug; pub use deposit_log::{DepositLog, Log}; pub use reqwest::Client; @@ -606,6 +607,10 @@ pub struct HttpJsonRpc { pub engine_capabilities_cache: Mutex>>, pub engine_version_cache: Mutex>>>, auth: Option, + /// Optional SSZ-REST client for EIP-8161 transport. + /// When set, engine methods will try SSZ-REST first and fall back to JSON-RPC + /// on network errors. + pub ssz_rest_client: Option, } impl HttpJsonRpc { @@ -620,6 +625,7 @@ impl HttpJsonRpc { engine_capabilities_cache: Mutex::new(None), engine_version_cache: Mutex::new(None), auth: None, + ssz_rest_client: None, }) } @@ -635,9 +641,18 @@ impl HttpJsonRpc { engine_capabilities_cache: Mutex::new(None), engine_version_cache: Mutex::new(None), auth: Some(auth), + ssz_rest_client: None, }) } + /// Set the SSZ-REST client for EIP-8161 transport. + pub fn set_ssz_rest_client( + &mut self, + ssz_rest_client: crate::engine_api::ssz_rest::SszRestClient, + ) { + self.ssz_rest_client = Some(ssz_rest_client); + } + pub async fn rpc_request( &self, method: &str, @@ -803,6 +818,24 @@ impl HttpJsonRpc { &self, new_payload_request_deneb: NewPayloadRequestDeneb<'_, E>, ) -> Result { + // Try SSZ-REST first if configured + if let Some(ref ssz) = self.ssz_rest_client { + match ssz + .new_payload_v3( + types::ExecutionPayloadRef::Deneb(new_payload_request_deneb.execution_payload), + &new_payload_request_deneb.versioned_hashes, + new_payload_request_deneb.parent_beacon_block_root, + ) + .await + { + Ok(result) => return Ok(result), + Err(e) if e.is_network_error() => { + debug!("SSZ-REST new_payload_v3 failed, falling back to JSON-RPC: {}", e); + } + Err(e) => return Err(e.into()), + } + } + let params = json!([ JsonExecutionPayload::Deneb( new_payload_request_deneb @@ -829,6 +862,27 @@ impl HttpJsonRpc { &self, new_payload_request_electra: NewPayloadRequestElectra<'_, E>, ) -> Result { + // Try SSZ-REST first if configured + if let Some(ref ssz) = self.ssz_rest_client { + match ssz + .new_payload_v4( + types::ExecutionPayloadRef::Electra( + new_payload_request_electra.execution_payload, + ), + &new_payload_request_electra.versioned_hashes, + new_payload_request_electra.parent_beacon_block_root, + new_payload_request_electra.execution_requests, + ) + .await + { + Ok(result) => return Ok(result), + Err(e) if e.is_network_error() => { + debug!("SSZ-REST new_payload_v4 failed, falling back to JSON-RPC: {}", e); + } + Err(e) => return Err(e.into()), + } + } + let params = json!([ JsonExecutionPayload::Electra( new_payload_request_electra @@ -858,6 +912,25 @@ impl HttpJsonRpc { &self, new_payload_request_fulu: NewPayloadRequestFulu<'_, E>, ) -> Result { + // Try SSZ-REST first if configured + if let Some(ref ssz) = self.ssz_rest_client { + match ssz + .new_payload_v4( + types::ExecutionPayloadRef::Fulu(new_payload_request_fulu.execution_payload), + &new_payload_request_fulu.versioned_hashes, + new_payload_request_fulu.parent_beacon_block_root, + new_payload_request_fulu.execution_requests, + ) + .await + { + Ok(result) => return Ok(result), + Err(e) if e.is_network_error() => { + debug!("SSZ-REST new_payload_v4 failed, falling back to JSON-RPC: {}", e); + } + Err(e) => return Err(e.into()), + } + } + let params = json!([ JsonExecutionPayload::Fulu( new_payload_request_fulu @@ -887,6 +960,25 @@ impl HttpJsonRpc { &self, new_payload_request_gloas: NewPayloadRequestGloas<'_, E>, ) -> Result { + // Try SSZ-REST first if configured + if let Some(ref ssz) = self.ssz_rest_client { + match ssz + .new_payload_v4( + types::ExecutionPayloadRef::Gloas(new_payload_request_gloas.execution_payload), + &new_payload_request_gloas.versioned_hashes, + new_payload_request_gloas.parent_beacon_block_root, + new_payload_request_gloas.execution_requests, + ) + .await + { + Ok(result) => return Ok(result), + Err(e) if e.is_network_error() => { + debug!("SSZ-REST new_payload_v4 failed, falling back to JSON-RPC: {}", e); + } + Err(e) => return Err(e.into()), + } + } + let params = json!([ JsonExecutionPayload::Gloas( new_payload_request_gloas @@ -1114,6 +1206,20 @@ impl HttpJsonRpc { forkchoice_state: ForkchoiceState, payload_attributes: Option, ) -> Result { + // Try SSZ-REST first if configured + if let Some(ref ssz) = self.ssz_rest_client { + match ssz + .forkchoice_updated(forkchoice_state, payload_attributes.clone()) + .await + { + Ok(result) => return Ok(result), + Err(e) if e.is_network_error() => { + debug!("SSZ-REST forkchoice_updated_v3 failed, falling back to JSON-RPC: {}", e); + } + Err(e) => return Err(e.into()), + } + } + let params = json!([ JsonForkchoiceStateV1::from(forkchoice_state), payload_attributes.map(JsonPayloadAttributes::from) @@ -1183,6 +1289,44 @@ impl HttpJsonRpc { } pub async fn exchange_capabilities(&self) -> Result { + // Try SSZ-REST first if configured + if let Some(ref ssz) = self.ssz_rest_client { + match ssz.exchange_capabilities(LIGHTHOUSE_CAPABILITIES).await { + Ok(caps) => { + let capabilities: HashSet = caps.into_iter().collect(); + return Ok(EngineCapabilities { + 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), + new_payload_v4: capabilities.contains(ENGINE_NEW_PAYLOAD_V4), + forkchoice_updated_v1: capabilities + .contains(ENGINE_FORKCHOICE_UPDATED_V1), + forkchoice_updated_v2: capabilities + .contains(ENGINE_FORKCHOICE_UPDATED_V2), + forkchoice_updated_v3: capabilities + .contains(ENGINE_FORKCHOICE_UPDATED_V3), + get_payload_bodies_by_hash_v1: capabilities + .contains(ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1), + get_payload_bodies_by_range_v1: capabilities + .contains(ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1), + get_payload_v1: capabilities.contains(ENGINE_GET_PAYLOAD_V1), + get_payload_v2: capabilities.contains(ENGINE_GET_PAYLOAD_V2), + get_payload_v3: capabilities.contains(ENGINE_GET_PAYLOAD_V3), + get_payload_v4: capabilities.contains(ENGINE_GET_PAYLOAD_V4), + get_payload_v5: capabilities.contains(ENGINE_GET_PAYLOAD_V5), + get_client_version_v1: capabilities + .contains(ENGINE_GET_CLIENT_VERSION_V1), + get_blobs_v1: capabilities.contains(ENGINE_GET_BLOBS_V1), + get_blobs_v2: capabilities.contains(ENGINE_GET_BLOBS_V2), + }); + } + Err(e) if e.is_network_error() => { + debug!("SSZ-REST exchange_capabilities failed, falling back to JSON-RPC: {}", e); + } + Err(e) => return Err(e.into()), + } + } + let params = json!([LIGHTHOUSE_CAPABILITIES]); let capabilities: HashSet = self @@ -1369,6 +1513,17 @@ impl HttpJsonRpc { fork_name: ForkName, payload_id: PayloadId, ) -> Result, Error> { + // Try SSZ-REST first if configured + if let Some(ref ssz) = self.ssz_rest_client { + match ssz.get_payload::(fork_name, payload_id).await { + Ok(result) => return Ok(result), + Err(e) if e.is_network_error() => { + debug!("SSZ-REST get_payload failed, falling back to JSON-RPC: {}", e); + } + Err(e) => return Err(e.into()), + } + } + let engine_capabilities = self.get_engine_capabilities(None).await?; match fork_name { ForkName::Bellatrix | ForkName::Capella => { diff --git a/beacon_node/execution_layer/src/engine_api/ssz_rest.rs b/beacon_node/execution_layer/src/engine_api/ssz_rest.rs new file mode 100644 index 00000000000..48f23ed74b0 --- /dev/null +++ b/beacon_node/execution_layer/src/engine_api/ssz_rest.rs @@ -0,0 +1,281 @@ +//! SSZ-REST client for Engine API (EIP-8161). +//! +//! Provides an alternative SSZ-REST transport for Engine API calls alongside +//! the existing JSON-RPC. When configured, the client tries SSZ-REST first +//! and falls back to JSON-RPC on network errors. + +use super::*; +use crate::auth::Auth; +use crate::engine_api::ssz_rest_encoding::{self, SszRestCodecError}; +use reqwest::header::CONTENT_TYPE; +use std::time::Duration; + +pub use reqwest::Client; + +/// Timeout for SSZ-REST requests. +const SSZ_REST_TIMEOUT: Duration = Duration::from_secs(8); + +/// SSZ-REST specific error type. +#[derive(Debug)] +pub enum SszRestError { + /// Network-level error (connection refused, timeout, DNS failure). + /// These are suitable for fallback to JSON-RPC. + Network(String), + /// SSZ decoding/encoding error. + Codec(SszRestCodecError), + /// HTTP error that is not a network error (e.g., 401, 500). + Http { status: u16, body: String }, + /// JWT auth error. + Auth(auth::Error), + /// Other errors. + Other(String), +} + +impl std::fmt::Display for SszRestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Network(msg) => write!(f, "SSZ-REST network error: {}", msg), + Self::Codec(e) => write!(f, "SSZ-REST codec error: {}", e), + Self::Http { status, body } => { + write!(f, "SSZ-REST HTTP error (status {}): {}", status, body) + } + Self::Auth(e) => write!(f, "SSZ-REST auth error: {:?}", e), + Self::Other(msg) => write!(f, "SSZ-REST error: {}", msg), + } + } +} + +impl SszRestError { + /// Returns true if this error is a network-level error suitable for + /// fallback to JSON-RPC. Includes actual network errors and HTTP + /// errors (e.g. 404) which indicate the SSZ-REST endpoint may not + /// support the requested path. + pub fn is_network_error(&self) -> bool { + matches!(self, Self::Network(_) | Self::Http { .. }) + } +} + +impl From for SszRestError { + fn from(e: SszRestCodecError) -> Self { + Self::Codec(e) + } +} + +impl From for SszRestError { + fn from(e: auth::Error) -> Self { + Self::Auth(e) + } +} + +impl From for Error { + fn from(e: SszRestError) -> Self { + match e { + SszRestError::Network(msg) => Error::RequestFailed(format!("SSZ-REST: {}", msg)), + SszRestError::Codec(c) => Error::BadResponse(c.to_string()), + SszRestError::Http { status, body } => { + Error::RequestFailed(format!("HTTP {}: {}", status, body)) + } + SszRestError::Auth(e) => Error::Auth(e), + SszRestError::Other(msg) => Error::BadResponse(msg), + } + } +} + +/// SSZ-REST client for Engine API calls. +pub struct SszRestClient { + client: Client, + base_url: String, + auth: Option, +} + +impl SszRestClient { + /// Create a new SSZ-REST client. + pub fn new(base_url: String, auth: Option) -> Result { + let client = Client::builder() + .build() + .map_err(|e| SszRestError::Other(format!("Failed to build reqwest client: {}", e)))?; + + // Trim trailing slash from base URL + let base_url = base_url.trim_end_matches('/').to_string(); + + Ok(Self { + client, + base_url, + auth, + }) + } + + /// Perform a POST request with SSZ body and return the response bytes. + async fn do_request(&self, path: &str, body: Vec) -> Result, SszRestError> { + self.do_http("POST", path, Some(body)).await + } + + /// Perform a GET request (no body) and return the response bytes. + async fn do_get_request(&self, path: &str) -> Result, SszRestError> { + self.do_http("GET", path, None).await + } + + /// Common HTTP request implementation for both POST and GET. + async fn do_http( + &self, + method: &str, + path: &str, + body: Option>, + ) -> Result, SszRestError> { + let url = format!("{}{}", self.base_url, path); + + let mut request = match method { + "GET" => self.client.get(&url), + _ => { + let b = body.unwrap_or_default(); + self.client + .post(&url) + .header(CONTENT_TYPE, "application/octet-stream") + .body(b) + } + }; + + request = request + .timeout(SSZ_REST_TIMEOUT) + .header("Accept", "application/octet-stream"); + + // Add JWT auth if configured + if let Some(auth) = &self.auth { + let token = auth.generate_token().map_err(SszRestError::Auth)?; + request = request.bearer_auth(token); + } + + let response = request.send().await.map_err(|e| { + if e.is_connect() || e.is_timeout() || e.is_request() { + SszRestError::Network(e.to_string()) + } else { + SszRestError::Other(e.to_string()) + } + })?; + + let status = response.status(); + if status.is_success() { + let bytes = response + .bytes() + .await + .map_err(|e| SszRestError::Network(e.to_string()))?; + Ok(bytes.to_vec()) + } else { + // Error responses use text/plain per execution-apis SSZ spec + let body_bytes = response + .bytes() + .await + .map_err(|e| SszRestError::Network(e.to_string()))?; + + let body_str = + String::from_utf8(body_bytes.to_vec()).unwrap_or_else(|_| "".into()); + Err(SszRestError::Http { + status: status.as_u16(), + body: body_str, + }) + } + } + + /// Send a NewPayloadV3 request via SSZ-REST. + pub async fn new_payload_v3( + &self, + execution_payload: ExecutionPayloadRef<'_, E>, + versioned_hashes: &[types::VersionedHash], + parent_beacon_block_root: Hash256, + ) -> Result { + let body = ssz_rest_encoding::encode_new_payload_v3( + execution_payload, + versioned_hashes, + parent_beacon_block_root, + ); + + let response_bytes = self.do_request("/engine/v3/payloads", body).await?; + let status = ssz_rest_encoding::decode_payload_status(&response_bytes)?; + Ok(status) + } + + /// Send a NewPayloadV4 request via SSZ-REST. + pub async fn new_payload_v4( + &self, + execution_payload: ExecutionPayloadRef<'_, E>, + versioned_hashes: &[types::VersionedHash], + parent_beacon_block_root: Hash256, + execution_requests: &types::ExecutionRequests, + ) -> Result { + let body = ssz_rest_encoding::encode_new_payload_v4( + execution_payload, + versioned_hashes, + parent_beacon_block_root, + execution_requests, + ); + + let response_bytes = self.do_request("/engine/v4/payloads", body).await?; + let status = ssz_rest_encoding::decode_payload_status(&response_bytes)?; + Ok(status) + } + + /// Send a ForkchoiceUpdated request via SSZ-REST. + pub async fn forkchoice_updated( + &self, + forkchoice_state: ForkchoiceState, + payload_attributes: Option, + ) -> Result { + let body = ssz_rest_encoding::encode_forkchoice_updated_request( + &forkchoice_state, + &payload_attributes, + ); + + let response_bytes = self + .do_request("/engine/v3/forkchoice", body) + .await?; + let response = ssz_rest_encoding::decode_forkchoice_updated_response(&response_bytes)?; + Ok(response) + } + + /// Send a GetPayload request via SSZ-REST. + pub async fn get_payload( + &self, + fork_name: ForkName, + payload_id: PayloadId, + ) -> Result, SszRestError> { + let version = match fork_name { + ForkName::Fulu | ForkName::Gloas => 5, + ForkName::Electra => 4, + ForkName::Deneb => 3, + ForkName::Capella => 2, + _ => 1, + }; + let payload_id_hex = format!("0x{}", hex::encode(payload_id)); + let path = format!("/engine/v{}/payloads/{}", version, payload_id_hex); + let response_bytes = self.do_get_request(&path).await?; + let resp = + ssz_rest_encoding::decode_get_payload_response::(&response_bytes, fork_name)?; + Ok(resp) + } + + /// Send a GetBlobs request via SSZ-REST. + /// + /// Note: Blob decoding is complex - fall back to JSON-RPC for now. + pub async fn get_blobs( + &self, + _versioned_hashes: Vec, + ) -> Result>>, SszRestError> { + Err(SszRestError::Network( + "SSZ-REST GetBlobs not yet implemented, falling back to JSON-RPC".to_string(), + )) + } + + /// Send an ExchangeCapabilities request via SSZ-REST. + pub async fn exchange_capabilities( + &self, + capabilities: &[&str], + ) -> Result, SszRestError> { + let body = ssz_rest_encoding::encode_exchange_capabilities(capabilities); + + let response_bytes = self + .do_request("/engine/v1/capabilities", body) + .await?; + let caps = ssz_rest_encoding::decode_exchange_capabilities(&response_bytes)?; + Ok(caps) + } +} diff --git a/beacon_node/execution_layer/src/engine_api/ssz_rest_encoding.rs b/beacon_node/execution_layer/src/engine_api/ssz_rest_encoding.rs new file mode 100644 index 00000000000..a450c331951 --- /dev/null +++ b/beacon_node/execution_layer/src/engine_api/ssz_rest_encoding.rs @@ -0,0 +1,676 @@ +//! SSZ encoding/decoding for the Engine API SSZ-REST transport (EIP-8161). +//! +//! This module handles the wire format for SSZ-REST Engine API calls, +//! matching the format used by geth/erigon/nethermind servers. + +use crate::engine_api::{ + BlobsBundle, ForkchoiceUpdatedResponse, GetPayloadResponse, GetPayloadResponseDeneb, + GetPayloadResponseElectra, GetPayloadResponseFulu, GetPayloadResponseGloas, PayloadAttributes, + PayloadId, PayloadStatusV1, PayloadStatusV1Status, +}; +use crate::engines::ForkchoiceState; +use ssz::{Decode, Encode}; +use ssz_derive::{Decode as SszDecode, Encode as SszEncode}; +use ssz_types::VariableList; +use typenum::{U64, U128}; +use types::{ + EthSpec, ExecutionBlockHash, ExecutionPayloadDeneb, ExecutionPayloadElectra, + ExecutionPayloadFulu, ExecutionPayloadGloas, ExecutionPayloadRef, ExecutionRequests, ForkName, + Hash256, Uint256, VersionedHash, +}; + +/// Errors that can occur during SSZ-REST encoding/decoding. +#[derive(Debug)] +pub enum SszRestCodecError { + /// The response buffer is too short. + BufferTooShort { expected: usize, actual: usize }, + /// An offset points outside the buffer. + InvalidOffset { offset: usize, len: usize }, + /// Unknown status byte value. + UnknownStatus(u8), + /// UTF-8 decoding failed. + Utf8Error(std::string::FromUtf8Error), + /// Generic decoding error. + Other(String), +} + +impl std::fmt::Display for SszRestCodecError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::BufferTooShort { expected, actual } => { + write!( + f, + "SSZ-REST buffer too short: expected at least {} bytes, got {}", + expected, actual + ) + } + Self::InvalidOffset { offset, len } => { + write!( + f, + "SSZ-REST invalid offset: {} exceeds buffer length {}", + offset, len + ) + } + Self::UnknownStatus(s) => write!(f, "SSZ-REST unknown status byte: {}", s), + Self::Utf8Error(e) => write!(f, "SSZ-REST UTF-8 error: {}", e), + Self::Other(msg) => write!(f, "SSZ-REST error: {}", msg), + } + } +} + +impl From for SszRestCodecError { + fn from(e: std::string::FromUtf8Error) -> Self { + Self::Utf8Error(e) + } +} + +// --------------------------------------------------------------------------- +// Helper functions +// --------------------------------------------------------------------------- + +fn read_u32_le(buf: &[u8], offset: usize) -> Result { + if buf.len() < offset + 4 { + return Err(SszRestCodecError::BufferTooShort { + expected: offset + 4, + actual: buf.len(), + }); + } + Ok(u32::from_le_bytes([ + buf[offset], + buf[offset + 1], + buf[offset + 2], + buf[offset + 3], + ])) +} + +fn read_u64_le(buf: &[u8], offset: usize) -> Result { + if buf.len() < offset + 8 { + return Err(SszRestCodecError::BufferTooShort { + expected: offset + 8, + actual: buf.len(), + }); + } + Ok(u64::from_le_bytes([ + buf[offset], + buf[offset + 1], + buf[offset + 2], + buf[offset + 3], + buf[offset + 4], + buf[offset + 5], + buf[offset + 6], + buf[offset + 7], + ])) +} + +fn write_u32_le(buf: &mut Vec, val: u32) { + buf.extend_from_slice(&val.to_le_bytes()); +} + +fn write_u64_le(buf: &mut Vec, val: u64) { + buf.extend_from_slice(&val.to_le_bytes()); +} + +/// Write a Hash256 (B256) as 32 bytes. +fn write_hash256(buf: &mut Vec, hash: &Hash256) { + buf.extend_from_slice(hash.as_slice()); +} + +/// Write an ExecutionBlockHash as 32 bytes. +fn write_execution_block_hash(buf: &mut Vec, hash: &ExecutionBlockHash) { + buf.extend_from_slice(hash.0.as_slice()); +} + +/// Encode an ExecutionPayloadRef to SSZ bytes by matching on the variant. +fn encode_execution_payload_ref(payload: ExecutionPayloadRef<'_, E>) -> Vec { + match payload { + ExecutionPayloadRef::Bellatrix(p) => p.as_ssz_bytes(), + ExecutionPayloadRef::Capella(p) => p.as_ssz_bytes(), + ExecutionPayloadRef::Deneb(p) => p.as_ssz_bytes(), + ExecutionPayloadRef::Electra(p) => p.as_ssz_bytes(), + ExecutionPayloadRef::Fulu(p) => p.as_ssz_bytes(), + ExecutionPayloadRef::Gloas(p) => p.as_ssz_bytes(), + } +} + +// --------------------------------------------------------------------------- +// PayloadStatus decoding +// --------------------------------------------------------------------------- + +fn decode_status_byte(b: u8) -> Result { + match b { + 0 => Ok(PayloadStatusV1Status::Valid), + 1 => Ok(PayloadStatusV1Status::Invalid), + 2 => Ok(PayloadStatusV1Status::Syncing), + 3 => Ok(PayloadStatusV1Status::Accepted), + 4 => Ok(PayloadStatusV1Status::InvalidBlockHash), + _ => Err(SszRestCodecError::UnknownStatus(b)), + } +} + +/// Decode a PayloadStatus from SSZ bytes. +/// +/// Fixed part (9 bytes): +/// - status: uint8 (1 byte) +/// - latest_valid_hash offset: uint32 LE (4 bytes) +/// - validation_error offset: uint32 LE (4 bytes) +/// +/// Variable part: +/// - latest_valid_hash: List[Hash32, 1] (0 bytes = absent, 32 bytes = present) +/// - validation_error: List[uint8, 1024] +pub fn decode_payload_status(buf: &[u8]) -> Result { + if buf.len() < 9 { + return Err(SszRestCodecError::BufferTooShort { + expected: 9, + actual: buf.len(), + }); + } + + let status = decode_status_byte(buf[0])?; + let lvh_offset = read_u32_le(buf, 1)? as usize; + let ve_offset = read_u32_le(buf, 5)? as usize; + + if lvh_offset > buf.len() || ve_offset > buf.len() { + return Err(SszRestCodecError::InvalidOffset { + offset: std::cmp::max(lvh_offset, ve_offset), + len: buf.len(), + }); + } + + // List[Hash32, 1]: 0 bytes = absent, 32 bytes = present + let lvh_data = &buf[lvh_offset..ve_offset]; + let latest_valid_hash = if lvh_data.len() == 32 { + Some(ExecutionBlockHash::from_root(Hash256::from_slice(lvh_data))) + } else { + None + }; + + let ve_data = &buf[ve_offset..]; + let validation_error = if ve_data.is_empty() { + None + } else { + Some(String::from_utf8(ve_data.to_vec())?) + }; + + Ok(PayloadStatusV1 { + status, + latest_valid_hash, + validation_error, + }) +} + +// --------------------------------------------------------------------------- +// ForkchoiceUpdated request encoding +// --------------------------------------------------------------------------- + +/// Encode a ForkchoiceUpdated request. +/// +/// Fixed part (100 bytes): +/// - forkchoice_state: 96 bytes (3 x Hash32) +/// - payload_attributes offset: uint32 LE (4 bytes) +/// +/// Variable part: +/// - payload_attributes: List[PayloadAttributes, 1] (empty = absent, offset+data = present) +pub fn encode_forkchoice_updated_request( + fcs: &ForkchoiceState, + payload_attributes: &Option, +) -> Vec { + let fixed_size: u32 = 100; + let mut buf = Vec::with_capacity(256); + + // ForkchoiceState (96 bytes) + write_execution_block_hash(&mut buf, &fcs.head_block_hash); + write_execution_block_hash(&mut buf, &fcs.safe_block_hash); + write_execution_block_hash(&mut buf, &fcs.finalized_block_hash); + + // Offset to payload_attributes list + write_u32_le(&mut buf, fixed_size); + + // List[PayloadAttributes, 1]: empty for None, offset(4)+data for present + if let Some(pa) = payload_attributes { + // PayloadAttributes is variable-size, so the list uses a 4-byte item offset + write_u32_le(&mut buf, 4); // offset to element data (past the single offset) + encode_payload_attributes_into(&mut buf, pa); + } + // If None, no list data (empty list) + + buf +} + +/// Encode PayloadAttributes into a buffer. +/// +/// Fixed part: +/// - timestamp: uint64 LE (8) +/// - prev_randao: 32 bytes +/// - suggested_fee_recipient: 20 bytes +/// - withdrawals offset: uint32 LE (4) +/// - parent_beacon_block_root: 32 bytes +/// +/// Variable: +/// - withdrawals: each 44 bytes (index:8 + validator_index:8 + address:20 + amount:8) +fn encode_payload_attributes_into(buf: &mut Vec, pa: &PayloadAttributes) { + let timestamp = pa.timestamp(); + let prev_randao = pa.prev_randao(); + let suggested_fee_recipient = pa.suggested_fee_recipient(); + + // Fixed part size: 8 + 32 + 20 + 4 + 32 = 96 + let pa_fixed_size: u32 = 96; + + write_u64_le(buf, timestamp); + write_hash256(buf, &prev_randao); + buf.extend_from_slice(suggested_fee_recipient.as_slice()); + + match pa { + PayloadAttributes::V3(v3) => { + write_u32_le(buf, pa_fixed_size); + write_hash256(buf, &v3.parent_beacon_block_root); + + for w in &v3.withdrawals { + write_u64_le(buf, w.index); + write_u64_le(buf, w.validator_index); + buf.extend_from_slice(w.address.as_slice()); + write_u64_le(buf, w.amount); + } + } + PayloadAttributes::V2(v2) => { + write_u32_le(buf, pa_fixed_size); + buf.extend_from_slice(&[0u8; 32]); + + for w in &v2.withdrawals { + write_u64_le(buf, w.index); + write_u64_le(buf, w.validator_index); + buf.extend_from_slice(w.address.as_slice()); + write_u64_le(buf, w.amount); + } + } + PayloadAttributes::V1(_) => { + write_u32_le(buf, pa_fixed_size); + buf.extend_from_slice(&[0u8; 32]); + } + } +} + +// --------------------------------------------------------------------------- +// ForkchoiceUpdated response decoding +// --------------------------------------------------------------------------- + +/// Decode a ForkchoiceUpdated response. +/// +/// Fixed part (8 bytes): +/// - payload_status offset: uint32 LE (4) +/// - payload_id offset: uint32 LE (4) +/// +/// Variable: +/// - payload_status (same layout as PayloadStatus) +/// - payload_id: List[Bytes8, 1] (0 bytes = absent, 8 bytes = present) +pub fn decode_forkchoice_updated_response( + buf: &[u8], +) -> Result { + if buf.len() < 8 { + return Err(SszRestCodecError::BufferTooShort { + expected: 8, + actual: buf.len(), + }); + } + + let ps_offset = read_u32_le(buf, 0)? as usize; + let pid_offset = read_u32_le(buf, 4)? as usize; + + if ps_offset > buf.len() || pid_offset > buf.len() { + return Err(SszRestCodecError::InvalidOffset { + offset: std::cmp::max(ps_offset, pid_offset), + len: buf.len(), + }); + } + + let ps_data = &buf[ps_offset..pid_offset]; + let payload_status = decode_payload_status(ps_data)?; + + // List[Bytes8, 1]: 0 bytes = absent, 8 bytes = present + let pid_data = &buf[pid_offset..]; + let payload_id = if pid_data.len() == 8 { + let mut arr = [0u8; 8]; + arr.copy_from_slice(pid_data); + Some(arr) + } else { + None + }; + + Ok(ForkchoiceUpdatedResponse { + payload_status, + payload_id, + }) +} + +// --------------------------------------------------------------------------- +// NewPayload V3 request encoding +// --------------------------------------------------------------------------- + +/// Encode a NewPayloadV3 request. +/// +/// Fixed part: +/// - execution_payload offset: uint32 LE (4) +/// - versioned_hashes offset: uint32 LE (4) +/// - parent_beacon_block_root: 32 bytes +/// +/// Variable: +/// - execution_payload SSZ bytes +/// - versioned_hashes: concatenated 32-byte hashes +pub fn encode_new_payload_v3( + execution_payload: ExecutionPayloadRef<'_, E>, + versioned_hashes: &[VersionedHash], + parent_beacon_block_root: Hash256, +) -> Vec { + let payload_ssz = encode_execution_payload_ref(execution_payload); + + // Fixed part: 4 + 4 + 32 = 40 bytes + let fixed_size: u32 = 40; + let payload_offset = fixed_size; + let hashes_offset = payload_offset + payload_ssz.len() as u32; + + let mut buf = Vec::with_capacity(40 + payload_ssz.len() + versioned_hashes.len() * 32); + + write_u32_le(&mut buf, payload_offset); + write_u32_le(&mut buf, hashes_offset); + write_hash256(&mut buf, &parent_beacon_block_root); + + buf.extend_from_slice(&payload_ssz); + + for vh in versioned_hashes { + buf.extend_from_slice(vh.as_slice()); + } + + buf +} + +// --------------------------------------------------------------------------- +// NewPayload V4 request encoding +// --------------------------------------------------------------------------- + +/// Encode a NewPayloadV4 request (V3 + execution_requests). +/// +/// Fixed part: +/// - execution_payload offset: uint32 LE (4) +/// - versioned_hashes offset: uint32 LE (4) +/// - parent_beacon_block_root: 32 bytes +/// - execution_requests offset: uint32 LE (4) +/// +/// Variable: +/// - execution_payload SSZ bytes +/// - versioned_hashes: concatenated 32-byte hashes +/// - execution_requests SSZ bytes +pub fn encode_new_payload_v4( + execution_payload: ExecutionPayloadRef<'_, E>, + versioned_hashes: &[VersionedHash], + parent_beacon_block_root: Hash256, + execution_requests: &types::ExecutionRequests, +) -> Vec { + let payload_ssz = encode_execution_payload_ref(execution_payload); + let requests_list = execution_requests.get_execution_requests_list(); + + let mut requests_ssz = Vec::new(); + for req_bytes in &requests_list { + requests_ssz.extend_from_slice(req_bytes); + } + + // Fixed part: 4 + 4 + 32 + 4 = 44 bytes + let fixed_size: u32 = 44; + let payload_offset = fixed_size; + let hashes_offset = payload_offset + payload_ssz.len() as u32; + let requests_offset = hashes_offset + (versioned_hashes.len() as u32) * 32; + + let mut buf = Vec::with_capacity( + 44 + payload_ssz.len() + versioned_hashes.len() * 32 + requests_ssz.len(), + ); + + write_u32_le(&mut buf, payload_offset); + write_u32_le(&mut buf, hashes_offset); + write_hash256(&mut buf, &parent_beacon_block_root); + write_u32_le(&mut buf, requests_offset); + + buf.extend_from_slice(&payload_ssz); + + for vh in versioned_hashes { + buf.extend_from_slice(vh.as_slice()); + } + + buf.extend_from_slice(&requests_ssz); + + buf +} + +// --------------------------------------------------------------------------- +// GetPayload request encoding (8 bytes) +// --------------------------------------------------------------------------- + +/// Encode a GetPayload request: 8 bytes (payload ID as uint64 LE). +pub fn encode_get_payload_request(payload_id: &PayloadId) -> Vec { + payload_id.to_vec() +} + +// --------------------------------------------------------------------------- +// GetBlobs request encoding +// --------------------------------------------------------------------------- + +/// Encode a GetBlobs request. +/// +/// Container: +/// - versioned_hashes offset: uint32 LE (4) -> concatenated 32-byte hashes +pub fn encode_get_blobs_request(versioned_hashes: &[Hash256]) -> Vec { + let fixed_size: u32 = 4; + let mut buf = Vec::with_capacity(4 + versioned_hashes.len() * 32); + write_u32_le(&mut buf, fixed_size); + for vh in versioned_hashes { + buf.extend_from_slice(vh.as_slice()); + } + buf +} + +// --------------------------------------------------------------------------- +// ExchangeCapabilities encoding/decoding +// --------------------------------------------------------------------------- + +/// SSZ type: Container { capabilities: List[List[uint8, 64], 128] } +type Capability = VariableList; + +#[derive(Debug, Clone, SszEncode, SszDecode)] +struct ExchangeCapabilitiesRequest { + capabilities: VariableList, +} + +/// Encode capabilities as an SSZ ExchangeCapabilitiesRequest container. +pub fn encode_exchange_capabilities(capabilities: &[&str]) -> Vec { + let caps: Vec = capabilities + .iter() + .map(|s| VariableList::new(s.as_bytes().to_vec()).expect("capability fits in 64 bytes")) + .collect(); + let request = ExchangeCapabilitiesRequest { + capabilities: VariableList::new(caps).expect("capabilities list fits in 128 items"), + }; + request.as_ssz_bytes() +} + +/// Decode capabilities from an SSZ ExchangeCapabilitiesRequest container. +pub fn decode_exchange_capabilities(buf: &[u8]) -> Result, SszRestCodecError> { + let request = ExchangeCapabilitiesRequest::from_ssz_bytes(buf) + .map_err(|e| SszRestCodecError::Other(format!("SSZ decode error: {:?}", e)))?; + let mut result = Vec::with_capacity(request.capabilities.len()); + for cap in request.capabilities.iter() { + let s = String::from_utf8(cap.to_vec())?; + result.push(s); + } + Ok(result) +} + +// --------------------------------------------------------------------------- +// GetPayload response decoding (stub - complex, fork-dependent) +// --------------------------------------------------------------------------- + +/// Decode Uint256 from 32 bytes LE. +fn decode_uint256_le(buf: &[u8]) -> Result { + if buf.len() < 32 { + return Err(SszRestCodecError::BufferTooShort { + expected: 32, + actual: buf.len(), + }); + } + Ok(Uint256::from_le_slice(&buf[..32])) +} + +/// Information extracted from a GetPayload response fixed part. +pub struct GetPayloadFixedPart { + pub execution_payload_offset: usize, + pub block_value: Uint256, + pub blobs_bundle_offset: usize, + pub should_override_builder: bool, + pub execution_requests_offset: Option, +} + +/// Parse the fixed part of a GetPayload response (V3+ format). +/// +/// Fixed part: +/// - execution_payload offset: uint32 LE (4) +/// - block_value: uint256 LE (32) +/// - blobs_bundle offset: uint32 LE (4) +/// - should_override_builder: bool (1) +/// - execution_requests offset: uint32 LE (4) [V4+ only] +pub fn decode_get_payload_fixed( + buf: &[u8], + has_execution_requests: bool, +) -> Result { + let min_size = if has_execution_requests { 45 } else { 41 }; + if buf.len() < min_size { + return Err(SszRestCodecError::BufferTooShort { + expected: min_size, + actual: buf.len(), + }); + } + + let ep_offset = read_u32_le(buf, 0)? as usize; + let block_value = decode_uint256_le(&buf[4..36])?; + let bb_offset = read_u32_le(buf, 36)? as usize; + let should_override_builder = buf[40] != 0; + + let er_offset = if has_execution_requests { + Some(read_u32_le(buf, 41)? as usize) + } else { + None + }; + + Ok(GetPayloadFixedPart { + execution_payload_offset: ep_offset, + block_value, + blobs_bundle_offset: bb_offset, + should_override_builder, + execution_requests_offset: er_offset, + }) +} + +/// Decode a full GetPayload SSZ-REST response into a `GetPayloadResponse`. +/// +/// The response format depends on the fork: +/// - V3 (Deneb): fixed(41) = ep_offset(4) + block_value(32) + bb_offset(4) + override(1) +/// - V4+ (Electra/Fulu/Gloas): fixed(45) = above + er_offset(4) +pub fn decode_get_payload_response( + buf: &[u8], + fork_name: ForkName, +) -> Result, SszRestCodecError> { + let has_requests = matches!( + fork_name, + ForkName::Electra | ForkName::Fulu | ForkName::Gloas + ); + let fixed = decode_get_payload_fixed(buf, has_requests)?; + + let ep_end = fixed.blobs_bundle_offset; + let ep_bytes = buf + .get(fixed.execution_payload_offset..ep_end) + .ok_or(SszRestCodecError::InvalidOffset { + offset: ep_end, + len: buf.len(), + })?; + + let bb_end = fixed + .execution_requests_offset + .unwrap_or(buf.len()); + let bb_bytes = buf + .get(fixed.blobs_bundle_offset..bb_end) + .ok_or(SszRestCodecError::InvalidOffset { + offset: bb_end, + len: buf.len(), + })?; + + let blobs_bundle = BlobsBundle::::from_ssz_bytes(bb_bytes) + .map_err(|e| SszRestCodecError::Other(format!("BlobsBundle SSZ decode: {:?}", e)))?; + + match fork_name { + ForkName::Deneb => { + let ep = ExecutionPayloadDeneb::::from_ssz_bytes(ep_bytes) + .map_err(|e| { + SszRestCodecError::Other(format!("ExecutionPayload SSZ decode: {:?}", e)) + })?; + Ok(GetPayloadResponse::Deneb(GetPayloadResponseDeneb { + execution_payload: ep, + block_value: fixed.block_value, + blobs_bundle, + should_override_builder: fixed.should_override_builder, + })) + } + ForkName::Electra => { + let ep = ExecutionPayloadElectra::::from_ssz_bytes(ep_bytes) + .map_err(|e| { + SszRestCodecError::Other(format!("ExecutionPayload SSZ decode: {:?}", e)) + })?; + let er_bytes = &buf[bb_end..]; + let requests = ExecutionRequests::::from_ssz_bytes(er_bytes) + .map_err(|e| { + SszRestCodecError::Other(format!("ExecutionRequests SSZ decode: {:?}", e)) + })?; + Ok(GetPayloadResponse::Electra(GetPayloadResponseElectra { + execution_payload: ep, + block_value: fixed.block_value, + blobs_bundle, + should_override_builder: fixed.should_override_builder, + requests, + })) + } + ForkName::Fulu => { + let ep = ExecutionPayloadFulu::::from_ssz_bytes(ep_bytes) + .map_err(|e| { + SszRestCodecError::Other(format!("ExecutionPayload SSZ decode: {:?}", e)) + })?; + let er_bytes = &buf[bb_end..]; + let requests = ExecutionRequests::::from_ssz_bytes(er_bytes) + .map_err(|e| { + SszRestCodecError::Other(format!("ExecutionRequests SSZ decode: {:?}", e)) + })?; + Ok(GetPayloadResponse::Fulu(GetPayloadResponseFulu { + execution_payload: ep, + block_value: fixed.block_value, + blobs_bundle, + should_override_builder: fixed.should_override_builder, + requests, + })) + } + ForkName::Gloas => { + let ep = ExecutionPayloadGloas::::from_ssz_bytes(ep_bytes) + .map_err(|e| { + SszRestCodecError::Other(format!("ExecutionPayload SSZ decode: {:?}", e)) + })?; + let er_bytes = &buf[bb_end..]; + let requests = ExecutionRequests::::from_ssz_bytes(er_bytes) + .map_err(|e| { + SszRestCodecError::Other(format!("ExecutionRequests SSZ decode: {:?}", e)) + })?; + Ok(GetPayloadResponse::Gloas(GetPayloadResponseGloas { + execution_payload: ep, + block_value: fixed.block_value, + blobs_bundle, + should_override_builder: fixed.should_override_builder, + requests, + })) + } + _ => Err(SszRestCodecError::Other(format!( + "SSZ-REST get_payload not supported for fork {}", + fork_name + ))), + } +} diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 33b83aab09f..eeac128128c 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -528,10 +528,31 @@ impl ExecutionLayer { }?; let engine: Engine = { - let auth = Auth::new(jwt_key, jwt_id, jwt_version); + let auth = Auth::new(jwt_key.clone(), jwt_id, jwt_version); debug!(endpoint = %execution_url, jwt_path = ?secret_file.as_path(),"Loaded execution endpoint"); - let api = HttpJsonRpc::new_with_auth(execution_url, auth, execution_timeout_multiplier) - .map_err(Error::ApiError)?; + let mut api = + HttpJsonRpc::new_with_auth(execution_url.clone(), auth, execution_timeout_multiplier) + .map_err(Error::ApiError)?; + + // EIP-8161: Configure SSZ-REST client using the same execution URL. + // SSZ-REST routes are served on the same port under /engine/* paths. + { + let ssz_url = execution_url.to_string().trim_end_matches('/').to_string(); + let ssz_auth = Auth::new(jwt_key, None, None); + match crate::engine_api::ssz_rest::SszRestClient::new( + ssz_url.clone(), + Some(ssz_auth), + ) { + Ok(ssz_client) => { + info!(ssz_rest_url = %ssz_url, "SSZ-REST Engine API transport configured (EIP-8161)"); + api.set_ssz_rest_client(ssz_client); + } + Err(e) => { + warn!(ssz_rest_url = %ssz_url, error = %e, "Failed to create SSZ-REST client, continuing with JSON-RPC only"); + } + } + } + Engine::new(api, executor.clone()) };