Add getPayload v2 methods

This commit is contained in:
Pawan Dhananjay
2026-01-21 17:21:06 -08:00
parent 658163cfde
commit a17c447680
6 changed files with 435 additions and 27 deletions

View File

@@ -3,7 +3,8 @@ use crate::http::{
ENGINE_FORKCHOICE_UPDATED_V1, ENGINE_FORKCHOICE_UPDATED_V2, ENGINE_FORKCHOICE_UPDATED_V3,
ENGINE_FORKCHOICE_UPDATED_V4, ENGINE_GET_BLOBS_V1, ENGINE_GET_BLOBS_V2,
ENGINE_GET_CLIENT_VERSION_V1, ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1,
ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1, ENGINE_GET_PAYLOAD_V1, ENGINE_GET_PAYLOAD_V2,
ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V2, ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1,
ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_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,
@@ -425,10 +426,18 @@ impl<E: EthSpec> GetPayloadResponse<E> {
}
}
#[superstruct(
variants(V1, V2),
variant_attributes(derive(Clone, Debug)),
cast_error(ty = "Error", expr = "Error::IncorrectStateVariant"),
partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant")
)]
#[derive(Clone, Debug)]
pub struct ExecutionPayloadBodyV1<E: EthSpec> {
pub struct ExecutionPayloadBody<E: EthSpec> {
pub transactions: Transactions<E>,
pub withdrawals: Option<Withdrawals<E>>,
#[superstruct(only(V2))]
pub block_access_list: Option<VariableList<u8, E::MaxBytesPerTransaction>>,
}
impl<E: EthSpec> ExecutionPayloadBodyV1<E> {
@@ -608,6 +617,195 @@ impl<E: EthSpec> ExecutionPayloadBodyV1<E> {
}
}
impl<E: EthSpec> ExecutionPayloadBodyV2<E> {
pub fn to_payload(
self,
header: ExecutionPayloadHeader<E>,
) -> Result<ExecutionPayload<E>, String> {
match header {
ExecutionPayloadHeader::Bellatrix(header) => {
if self.withdrawals.is_some() {
return Err(format!(
"block {} is bellatrix but payload body has withdrawals",
header.block_hash
));
}
Ok(ExecutionPayload::Bellatrix(ExecutionPayloadBellatrix {
parent_hash: header.parent_hash,
fee_recipient: header.fee_recipient,
state_root: header.state_root,
receipts_root: header.receipts_root,
logs_bloom: header.logs_bloom,
prev_randao: header.prev_randao,
block_number: header.block_number,
gas_limit: header.gas_limit,
gas_used: header.gas_used,
timestamp: header.timestamp,
extra_data: header.extra_data,
base_fee_per_gas: header.base_fee_per_gas,
block_hash: header.block_hash,
transactions: self.transactions,
}))
}
ExecutionPayloadHeader::Capella(header) => {
if let Some(withdrawals) = self.withdrawals {
Ok(ExecutionPayload::Capella(ExecutionPayloadCapella {
parent_hash: header.parent_hash,
fee_recipient: header.fee_recipient,
state_root: header.state_root,
receipts_root: header.receipts_root,
logs_bloom: header.logs_bloom,
prev_randao: header.prev_randao,
block_number: header.block_number,
gas_limit: header.gas_limit,
gas_used: header.gas_used,
timestamp: header.timestamp,
extra_data: header.extra_data,
base_fee_per_gas: header.base_fee_per_gas,
block_hash: header.block_hash,
transactions: self.transactions,
withdrawals,
}))
} else {
Err(format!(
"block {} is capella but payload body doesn't have withdrawals",
header.block_hash
))
}
}
ExecutionPayloadHeader::Deneb(header) => {
if let Some(withdrawals) = self.withdrawals {
Ok(ExecutionPayload::Deneb(ExecutionPayloadDeneb {
parent_hash: header.parent_hash,
fee_recipient: header.fee_recipient,
state_root: header.state_root,
receipts_root: header.receipts_root,
logs_bloom: header.logs_bloom,
prev_randao: header.prev_randao,
block_number: header.block_number,
gas_limit: header.gas_limit,
gas_used: header.gas_used,
timestamp: header.timestamp,
extra_data: header.extra_data,
base_fee_per_gas: header.base_fee_per_gas,
block_hash: header.block_hash,
transactions: self.transactions,
withdrawals,
blob_gas_used: header.blob_gas_used,
excess_blob_gas: header.excess_blob_gas,
}))
} else {
Err(format!(
"block {} is post capella but payload body doesn't have withdrawals",
header.block_hash
))
}
}
ExecutionPayloadHeader::Electra(header) => {
if let Some(withdrawals) = self.withdrawals {
Ok(ExecutionPayload::Electra(ExecutionPayloadElectra {
parent_hash: header.parent_hash,
fee_recipient: header.fee_recipient,
state_root: header.state_root,
receipts_root: header.receipts_root,
logs_bloom: header.logs_bloom,
prev_randao: header.prev_randao,
block_number: header.block_number,
gas_limit: header.gas_limit,
gas_used: header.gas_used,
timestamp: header.timestamp,
extra_data: header.extra_data,
base_fee_per_gas: header.base_fee_per_gas,
block_hash: header.block_hash,
transactions: self.transactions,
withdrawals,
blob_gas_used: header.blob_gas_used,
excess_blob_gas: header.excess_blob_gas,
}))
} else {
Err(format!(
"block {} is post capella but payload body doesn't have withdrawals",
header.block_hash
))
}
}
ExecutionPayloadHeader::Fulu(header) => {
if let Some(withdrawals) = self.withdrawals {
Ok(ExecutionPayload::Fulu(ExecutionPayloadFulu {
parent_hash: header.parent_hash,
fee_recipient: header.fee_recipient,
state_root: header.state_root,
receipts_root: header.receipts_root,
logs_bloom: header.logs_bloom,
prev_randao: header.prev_randao,
block_number: header.block_number,
gas_limit: header.gas_limit,
gas_used: header.gas_used,
timestamp: header.timestamp,
extra_data: header.extra_data,
base_fee_per_gas: header.base_fee_per_gas,
block_hash: header.block_hash,
transactions: self.transactions,
withdrawals,
blob_gas_used: header.blob_gas_used,
excess_blob_gas: header.excess_blob_gas,
}))
} else {
Err(format!(
"block {} is post capella but payload body doesn't have withdrawals",
header.block_hash
))
}
}
ExecutionPayloadHeader::Gloas(header) => {
if let Some(withdrawals) = self.withdrawals {
Ok(ExecutionPayload::Gloas(ExecutionPayloadGloas {
parent_hash: header.parent_hash,
fee_recipient: header.fee_recipient,
state_root: header.state_root,
receipts_root: header.receipts_root,
logs_bloom: header.logs_bloom,
prev_randao: header.prev_randao,
block_number: header.block_number,
gas_limit: header.gas_limit,
gas_used: header.gas_used,
timestamp: header.timestamp,
extra_data: header.extra_data,
base_fee_per_gas: header.base_fee_per_gas,
block_hash: header.block_hash,
transactions: self.transactions,
withdrawals,
blob_gas_used: header.blob_gas_used,
excess_blob_gas: header.excess_blob_gas,
// V2 provides block_access_list from EL; use empty if not present
block_access_list: self
.block_access_list
.unwrap_or_else(VariableList::empty),
slot_number: header.slot_number,
}))
} else {
Err(format!(
"block {} is post capella but payload body doesn't have withdrawals",
header.block_hash
))
}
}
}
}
}
impl<E: EthSpec> ExecutionPayloadBody<E> {
pub fn to_payload(
self,
header: ExecutionPayloadHeader<E>,
) -> Result<ExecutionPayload<E>, String> {
match self {
ExecutionPayloadBody::V1(body) => body.to_payload(header),
ExecutionPayloadBody::V2(body) => body.to_payload(header),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct EngineCapabilities {
pub new_payload_v1: bool,
@@ -620,7 +818,9 @@ pub struct EngineCapabilities {
pub forkchoice_updated_v3: bool,
pub forkchoice_updated_v4: bool,
pub get_payload_bodies_by_hash_v1: bool,
pub get_payload_bodies_by_hash_v2: bool,
pub get_payload_bodies_by_range_v1: bool,
pub get_payload_bodies_by_range_v2: bool,
pub get_payload_v1: bool,
pub get_payload_v2: bool,
pub get_payload_v3: bool,
@@ -665,9 +865,15 @@ impl EngineCapabilities {
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_bodies_by_range_v1 {
response.push(ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1);
}
if self.get_payload_bodies_by_range_v2 {
response.push(ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V2);
}
if self.get_payload_v1 {
response.push(ENGINE_GET_PAYLOAD_V1);
}

View File

@@ -54,7 +54,9 @@ pub const ENGINE_FORKCHOICE_UPDATED_V4: &str = "engine_forkchoiceUpdatedV4";
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_BY_RANGE_V1: &str = "engine_getPayloadBodiesByRangeV1";
pub const ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V2: &str = "engine_getPayloadBodiesByRangeV2";
pub const ENGINE_GET_PAYLOAD_BODIES_TIMEOUT: Duration = Duration::from_secs(10);
pub const ENGINE_EXCHANGE_CAPABILITIES: &str = "engine_exchangeCapabilities";
@@ -90,7 +92,9 @@ pub static LIGHTHOUSE_CAPABILITIES: &[&str] = &[
ENGINE_FORKCHOICE_UPDATED_V3,
ENGINE_FORKCHOICE_UPDATED_V4,
ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1,
ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V2,
ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1,
ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V2,
ENGINE_GET_CLIENT_VERSION_V1,
ENGINE_GET_BLOBS_V1,
ENGINE_GET_BLOBS_V2,
@@ -1364,6 +1368,58 @@ impl HttpJsonRpc {
.collect::<Result<Vec<_>, _>>()
}
pub async fn get_payload_bodies_by_hash_v2<E: EthSpec>(
&self,
block_hashes: Vec<ExecutionBlockHash>,
) -> Result<Vec<Option<ExecutionPayloadBodyV2<E>>>, Error> {
let params = json!([block_hashes]);
let response: Vec<Option<JsonExecutionPayloadBodyV2<E>>> = self
.rpc_request(
ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V2,
params,
ENGINE_GET_PAYLOAD_BODIES_TIMEOUT * self.execution_timeout_multiplier,
)
.await?;
response
.into_iter()
.map(|opt_json| {
opt_json
.map(|json| json.try_into().map_err(Error::from))
.transpose()
})
.collect::<Result<Vec<_>, _>>()
}
pub async fn get_payload_bodies_by_range_v2<E: EthSpec>(
&self,
start: u64,
count: u64,
) -> Result<Vec<Option<ExecutionPayloadBodyV2<E>>>, Error> {
#[derive(Serialize)]
#[serde(transparent)]
struct Quantity(#[serde(with = "serde_utils::u64_hex_be")] u64);
let params = json!([Quantity(start), Quantity(count)]);
let response: Vec<Option<JsonExecutionPayloadBodyV2<E>>> = self
.rpc_request(
ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V2,
params,
ENGINE_GET_PAYLOAD_BODIES_TIMEOUT * self.execution_timeout_multiplier,
)
.await?;
response
.into_iter()
.map(|opt_json| {
opt_json
.map(|json| json.try_into().map_err(Error::from))
.transpose()
})
.collect::<Result<Vec<_>, _>>()
}
pub async fn exchange_capabilities(&self) -> Result<EngineCapabilities, Error> {
let params = json!([LIGHTHOUSE_CAPABILITIES]);
@@ -1387,8 +1443,12 @@ impl HttpJsonRpc {
forkchoice_updated_v4: capabilities.contains(ENGINE_FORKCHOICE_UPDATED_V4),
get_payload_bodies_by_hash_v1: capabilities
.contains(ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1),
get_payload_bodies_by_hash_v2: capabilities
.contains(ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V2),
get_payload_bodies_by_range_v1: capabilities
.contains(ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1),
get_payload_bodies_by_range_v2: capabilities
.contains(ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V2),
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),

View File

@@ -1020,12 +1020,24 @@ impl From<ForkchoiceUpdatedResponse> for JsonForkchoiceUpdatedV1Response {
}
}
#[superstruct(
variants(V1, V2),
variant_attributes(
derive(Clone, Debug, Serialize, Deserialize),
serde(bound = "E: EthSpec", rename_all = "camelCase")
),
cast_error(ty = "Error", expr = "Error::IncorrectStateVariant"),
partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant")
)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(bound = "E: EthSpec")]
pub struct JsonExecutionPayloadBodyV1<E: EthSpec> {
#[serde(bound = "E: EthSpec", rename_all = "camelCase")]
pub struct JsonExecutionPayloadBody<E: EthSpec> {
#[serde(with = "ssz_types::serde_utils::list_of_hex_var_list")]
pub transactions: Transactions<E>,
pub withdrawals: Option<VariableList<JsonWithdrawal, E::MaxWithdrawalsPerPayload>>,
#[superstruct(only(V2))]
#[serde(with = "optional_hex_var_list")]
pub block_access_list: Option<VariableList<u8, E::MaxBytesPerTransaction>>,
}
impl<E: EthSpec> TryFrom<JsonExecutionPayloadBodyV1<E>> for ExecutionPayloadBodyV1<E> {
@@ -1050,6 +1062,30 @@ impl<E: EthSpec> TryFrom<ExecutionPayloadBodyV1<E>> for JsonExecutionPayloadBody
}
}
impl<E: EthSpec> TryFrom<JsonExecutionPayloadBodyV2<E>> for ExecutionPayloadBodyV2<E> {
type Error = ssz_types::Error;
fn try_from(value: JsonExecutionPayloadBodyV2<E>) -> Result<Self, Self::Error> {
Ok(Self {
transactions: value.transactions,
withdrawals: value.withdrawals.map(withdrawals_from_json).transpose()?,
block_access_list: value.block_access_list,
})
}
}
impl<E: EthSpec> TryFrom<ExecutionPayloadBodyV2<E>> for JsonExecutionPayloadBodyV2<E> {
type Error = ssz_types::Error;
fn try_from(value: ExecutionPayloadBodyV2<E>) -> Result<Self, Self::Error> {
Ok(Self {
transactions: value.transactions,
withdrawals: value.withdrawals.map(withdrawals_to_json).transpose()?,
block_access_list: value.block_access_list,
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransitionConfigurationV1 {
@@ -1090,6 +1126,50 @@ pub mod serde_logs_bloom {
}
}
/// Serializes an optional hex variable list field (e.g., blockAccessList in EIP-7928).
/// JSON `null` maps to `None`, hex string maps to `Some(VariableList<u8, N>)`.
pub mod optional_hex_var_list {
use super::*;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S, N>(
opt_bytes: &Option<VariableList<u8, N>>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
N: Unsigned,
{
match opt_bytes {
Some(bytes) => {
let mut hex_string: String = "0x".to_string();
hex_string.push_str(&hex::encode(&bytes[..]));
serializer.serialize_str(&hex_string)
}
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D, N>(deserializer: D) -> Result<Option<VariableList<u8, N>>, D::Error>
where
D: Deserializer<'de>,
N: Unsigned,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
match opt {
Some(hex_str) => {
let hex_str = hex_str.strip_prefix("0x").unwrap_or(&hex_str);
let bytes = hex::decode(hex_str)
.map_err(|e| serde::de::Error::custom(format!("invalid hex: {:?}", e)))?;
VariableList::new(bytes)
.map(Some)
.map_err(|e| serde::de::Error::custom(format!("invalid var list: {:?}", e)))
}
None => Ok(None),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JsonClientVersionV1 {

View File

@@ -158,6 +158,7 @@ pub enum Error {
},
ZeroLengthTransaction,
PayloadBodiesByRangeNotSupported,
PayloadBodiesByHashNotSupported,
GetBlobsNotSupported,
InvalidJWTSecret(String),
InvalidForkForPayload,
@@ -1787,32 +1788,87 @@ impl<E: EthSpec> ExecutionLayer<E> {
pub async fn get_payload_bodies_by_hash(
&self,
hashes: Vec<ExecutionBlockHash>,
) -> Result<Vec<Option<ExecutionPayloadBodyV1<E>>>, Error> {
self.engine()
.request(|engine: &Engine| async move {
engine.api.get_payload_bodies_by_hash_v1(hashes).await
})
.await
.map_err(Box::new)
.map_err(Error::EngineError)
) -> Result<Vec<Option<ExecutionPayloadBody<E>>>, Error> {
let capabilities = self.get_engine_capabilities(None).await?;
if capabilities.get_payload_bodies_by_hash_v2 {
self.engine()
.request(|engine: &Engine| async move {
engine.api.get_payload_bodies_by_hash_v2(hashes).await
})
.await
.map(|bodies| {
bodies
.into_iter()
.map(|opt| opt.map(ExecutionPayloadBody::V2))
.collect()
})
.map_err(Box::new)
.map_err(Error::EngineError)
} else if capabilities.get_payload_bodies_by_hash_v1 {
self.engine()
.request(|engine: &Engine| async move {
engine.api.get_payload_bodies_by_hash_v1::<E>(hashes).await
})
.await
.map(|bodies| {
bodies
.into_iter()
.map(|opt| opt.map(ExecutionPayloadBody::V1))
.collect()
})
.map_err(Box::new)
.map_err(Error::EngineError)
} else {
Err(Error::PayloadBodiesByHashNotSupported)
}
}
pub async fn get_payload_bodies_by_range(
&self,
start: u64,
count: u64,
) -> Result<Vec<Option<ExecutionPayloadBodyV1<E>>>, Error> {
) -> Result<Vec<Option<ExecutionPayloadBody<E>>>, Error> {
let _timer = metrics::start_timer(&metrics::EXECUTION_LAYER_GET_PAYLOAD_BODIES_BY_RANGE);
self.engine()
.request(|engine: &Engine| async move {
engine
.api
.get_payload_bodies_by_range_v1(start, count)
.await
})
.await
.map_err(Box::new)
.map_err(Error::EngineError)
let capabilities = self.get_engine_capabilities(None).await?;
if capabilities.get_payload_bodies_by_range_v2 {
self.engine()
.request(|engine: &Engine| async move {
engine
.api
.get_payload_bodies_by_range_v2(start, count)
.await
})
.await
.map(|bodies| {
bodies
.into_iter()
.map(|opt| opt.map(ExecutionPayloadBody::V2))
.collect()
})
.map_err(Box::new)
.map_err(Error::EngineError)
} else if capabilities.get_payload_bodies_by_range_v1 {
self.engine()
.request(|engine: &Engine| async move {
engine
.api
.get_payload_bodies_by_range_v1::<E>(start, count)
.await
})
.await
.map(|bodies| {
bodies
.into_iter()
.map(|opt| opt.map(ExecutionPayloadBody::V1))
.collect()
})
.map_err(Box::new)
.map_err(Error::EngineError)
} else {
Err(Error::PayloadBodiesByRangeNotSupported)
}
}
/// Fetch a full payload from the execution node.
@@ -1845,7 +1901,9 @@ impl<E: EthSpec> ExecutionLayer<E> {
// Use efficient payload bodies by range method if supported.
let capabilities = self.get_engine_capabilities(None).await?;
if capabilities.get_payload_bodies_by_range_v1 {
if capabilities.get_payload_bodies_by_range_v2
|| capabilities.get_payload_bodies_by_range_v1
{
let mut payload_bodies = self.get_payload_bodies_by_range(block_number, 1).await?;
if payload_bodies.len() != 1 {

View File

@@ -51,7 +51,9 @@ pub const DEFAULT_ENGINE_CAPABILITIES: EngineCapabilities = EngineCapabilities {
forkchoice_updated_v3: true,
forkchoice_updated_v4: true,
get_payload_bodies_by_hash_v1: true,
get_payload_bodies_by_hash_v2: true,
get_payload_bodies_by_range_v1: true,
get_payload_bodies_by_range_v2: true,
get_payload_v1: true,
get_payload_v2: true,
get_payload_v3: true,