Skip to content

Commit c0d2938

Browse files
committed
feat(types): add ExecutionPayloadHeader plus payload→header projection
Phase 2b of the M6 plan. Adds the cached projection that the consensus state will carry between blocks (Capella+Deneb spec): every fixed-size field copies from the payload verbatim, and the two variable-length lists (transactions, withdrawals) collapse to their SSZ hash-tree roots so the header itself stays bounded. ExecutionPayloadV3::to_header() — explicit method From<&ExecutionPayloadV3> for ExecutionPayloadHeader — sugar Default — manual (same [u8; 256] reason as ExecutionPayloadV3) Genesis convention: ExecutionPayloadHeader::default() is all-zeros. The first Lean block carrying a real payload will assert its parent_hash matches state.latest_execution_payload_header.block_hash — which is H256::ZERO at genesis. Subsequent blocks chain forward normally. 35 ethlambda-types tests pass (3 new: header default, header SSZ+JSON roundtrip, to_header projects transactions/withdrawals to their hash tree roots and copies every other field verbatim).
1 parent c9e57c1 commit c0d2938

1 file changed

Lines changed: 175 additions & 3 deletions

File tree

crates/common/types/src/execution_payload.rs

Lines changed: 175 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use libssz_derive::{HashTreeRoot, SszDecode, SszEncode};
1717
use libssz_types::SszList;
1818
use serde::{Deserialize, Serialize};
1919

20-
use crate::primitives::{ByteList, H256};
20+
use crate::primitives::{ByteList, H256, HashTreeRoot as _};
2121

2222
/// `BYTES_PER_LOGS_BLOOM` — fixed-size logs bloom filter.
2323
pub const BYTES_PER_LOGS_BLOOM: usize = 256;
@@ -120,6 +120,105 @@ impl Default for ExecutionPayloadV3 {
120120
}
121121
}
122122

123+
impl ExecutionPayloadV3 {
124+
/// Project this payload into its `ExecutionPayloadHeader`.
125+
///
126+
/// Capella spec (`process_execution_payload`): variable-length `transactions`
127+
/// and `withdrawals` collapse to their SSZ hash tree roots; every other
128+
/// field copies verbatim. This is what the state caches between blocks
129+
/// so the next payload's `parent_hash` can be validated without re-hashing
130+
/// the prior block body.
131+
pub fn to_header(&self) -> ExecutionPayloadHeader {
132+
ExecutionPayloadHeader {
133+
parent_hash: self.parent_hash,
134+
fee_recipient: self.fee_recipient,
135+
state_root: self.state_root,
136+
receipts_root: self.receipts_root,
137+
logs_bloom: self.logs_bloom,
138+
prev_randao: self.prev_randao,
139+
block_number: self.block_number,
140+
gas_limit: self.gas_limit,
141+
gas_used: self.gas_used,
142+
timestamp: self.timestamp,
143+
extra_data: self.extra_data.clone(),
144+
base_fee_per_gas: self.base_fee_per_gas,
145+
block_hash: self.block_hash,
146+
transactions_root: self.transactions.hash_tree_root(),
147+
withdrawals_root: self.withdrawals.hash_tree_root(),
148+
blob_gas_used: self.blob_gas_used,
149+
excess_blob_gas: self.excess_blob_gas,
150+
}
151+
}
152+
}
153+
154+
/// Cached projection of an `ExecutionPayloadV3` that the consensus state
155+
/// carries between blocks. Mirrors the Capella+Deneb `ExecutionPayloadHeader`:
156+
/// every fixed-size field copies from the payload verbatim; the two
157+
/// variable-length lists (`transactions`, `withdrawals`) collapse to their
158+
/// SSZ hash-tree roots so the header itself stays fixed-size-bounded.
159+
#[derive(Debug, Clone, Serialize, Deserialize, SszEncode, SszDecode, HashTreeRoot)]
160+
#[serde(rename_all = "camelCase")]
161+
pub struct ExecutionPayloadHeader {
162+
pub parent_hash: H256,
163+
#[serde(with = "hex_address")]
164+
pub fee_recipient: [u8; 20],
165+
pub state_root: H256,
166+
pub receipts_root: H256,
167+
#[serde(with = "hex_bytes_fixed")]
168+
pub logs_bloom: [u8; BYTES_PER_LOGS_BLOOM],
169+
pub prev_randao: H256,
170+
#[serde(with = "hex_u64")]
171+
pub block_number: u64,
172+
#[serde(with = "hex_u64")]
173+
pub gas_limit: u64,
174+
#[serde(with = "hex_u64")]
175+
pub gas_used: u64,
176+
#[serde(with = "hex_u64")]
177+
pub timestamp: u64,
178+
#[serde(with = "byte_list_hex")]
179+
pub extra_data: ByteList<MAX_EXTRA_DATA_BYTES>,
180+
#[serde(with = "hex_u256")]
181+
pub base_fee_per_gas: [u8; 32],
182+
pub block_hash: H256,
183+
pub transactions_root: H256,
184+
pub withdrawals_root: H256,
185+
#[serde(with = "hex_u64")]
186+
pub blob_gas_used: u64,
187+
#[serde(with = "hex_u64")]
188+
pub excess_blob_gas: u64,
189+
}
190+
191+
/// Manual `Default` (same reason as `ExecutionPayloadV3`: `[u8; 256]`).
192+
impl Default for ExecutionPayloadHeader {
193+
fn default() -> Self {
194+
Self {
195+
parent_hash: H256::default(),
196+
fee_recipient: [0u8; 20],
197+
state_root: H256::default(),
198+
receipts_root: H256::default(),
199+
logs_bloom: [0u8; BYTES_PER_LOGS_BLOOM],
200+
prev_randao: H256::default(),
201+
block_number: 0,
202+
gas_limit: 0,
203+
gas_used: 0,
204+
timestamp: 0,
205+
extra_data: ByteList::default(),
206+
base_fee_per_gas: [0u8; 32],
207+
block_hash: H256::default(),
208+
transactions_root: H256::default(),
209+
withdrawals_root: H256::default(),
210+
blob_gas_used: 0,
211+
excess_blob_gas: 0,
212+
}
213+
}
214+
}
215+
216+
impl From<&ExecutionPayloadV3> for ExecutionPayloadHeader {
217+
fn from(p: &ExecutionPayloadV3) -> Self {
218+
p.to_header()
219+
}
220+
}
221+
123222
// ---------- Hex serde helpers ----------
124223
//
125224
// `pub` so engine-API wire types living in `ethlambda-ethrex-client`
@@ -311,8 +410,6 @@ pub mod withdrawals_serde {
311410
mod tests {
312411
use super::*;
313412

314-
use crate::primitives::HashTreeRoot as _;
315-
316413
#[test]
317414
fn hex_u64_roundtrip() {
318415
#[derive(Serialize, Deserialize)]
@@ -470,4 +567,79 @@ mod tests {
470567
let back = Withdrawal::from_ssz_bytes(&bytes).unwrap();
471568
assert_eq!(back.hash_tree_root(), original.hash_tree_root());
472569
}
570+
571+
#[test]
572+
fn execution_payload_header_default_is_zero_init() {
573+
let h = ExecutionPayloadHeader::default();
574+
assert!(h.parent_hash.is_zero());
575+
assert!(h.block_hash.is_zero());
576+
assert!(h.transactions_root.is_zero());
577+
assert!(h.withdrawals_root.is_zero());
578+
assert_eq!(h.fee_recipient, [0u8; 20]);
579+
assert_eq!(h.block_number, 0);
580+
}
581+
582+
#[test]
583+
fn execution_payload_header_ssz_and_json_roundtrip() {
584+
use libssz::{SszDecode, SszEncode};
585+
let header = ExecutionPayloadHeader {
586+
parent_hash: H256([1u8; 32]),
587+
block_hash: H256([2u8; 32]),
588+
transactions_root: H256([3u8; 32]),
589+
withdrawals_root: H256([4u8; 32]),
590+
block_number: 42,
591+
timestamp: 1_700_000_000,
592+
..Default::default()
593+
};
594+
595+
let json = serde_json::to_string(&header).unwrap();
596+
let from_json: ExecutionPayloadHeader = serde_json::from_str(&json).unwrap();
597+
assert_eq!(from_json.hash_tree_root(), header.hash_tree_root());
598+
599+
let ssz_bytes = header.to_ssz();
600+
let from_ssz = ExecutionPayloadHeader::from_ssz_bytes(&ssz_bytes).unwrap();
601+
assert_eq!(from_ssz.hash_tree_root(), header.hash_tree_root());
602+
}
603+
604+
#[test]
605+
fn to_header_projects_lists_to_their_roots() {
606+
let payload = ExecutionPayloadV3 {
607+
transactions: Transactions::try_from(vec![
608+
ByteList::<MAX_BYTES_PER_TRANSACTION>::try_from(vec![0x01, 0x02]).unwrap(),
609+
ByteList::<MAX_BYTES_PER_TRANSACTION>::try_from(vec![0x03, 0x04, 0x05]).unwrap(),
610+
])
611+
.unwrap(),
612+
withdrawals: Withdrawals::try_from(vec![Withdrawal {
613+
index: 1,
614+
validator_index: 2,
615+
address: [9u8; 20],
616+
amount: 100,
617+
}])
618+
.unwrap(),
619+
block_number: 7,
620+
..Default::default()
621+
};
622+
let header = payload.to_header();
623+
624+
// The variable-length fields collapse to their hash tree roots.
625+
assert_eq!(
626+
header.transactions_root,
627+
payload.transactions.hash_tree_root()
628+
);
629+
assert_eq!(
630+
header.withdrawals_root,
631+
payload.withdrawals.hash_tree_root()
632+
);
633+
// Non-zero because both lists are non-empty.
634+
assert!(!header.transactions_root.is_zero());
635+
assert!(!header.withdrawals_root.is_zero());
636+
// Every other field copies verbatim.
637+
assert_eq!(header.block_number, payload.block_number);
638+
assert_eq!(header.parent_hash, payload.parent_hash);
639+
assert_eq!(header.fee_recipient, payload.fee_recipient);
640+
641+
// `From<&ExecutionPayloadV3>` and `to_header()` are equivalent.
642+
let header_via_from: ExecutionPayloadHeader = (&payload).into();
643+
assert_eq!(header_via_from.hash_tree_root(), header.hash_tree_root());
644+
}
473645
}

0 commit comments

Comments
 (0)