Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
290 changes: 130 additions & 160 deletions crates/common/types/eip8025_ssz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,38 @@
//! `NewPayloadRequest` and producing the `PublicInput` committed to by
//! execution proofs.

use bytes::Bytes;
use libssz::{SszDecode, SszEncode};
use libssz_derive::{HashTreeRoot, SszDecode, SszEncode};
use libssz_merkle::{HashTreeRoot, Sha256Hasher};
use libssz_types::{SszList, SszVector};

use super::requests::EncodedRequests;

// ── Spec limits (Electra) ──────────────────────────────────────────

/// MAX_TRANSACTIONS_PER_PAYLOAD (Electra).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some renamings to conform to the consensus-spec names, but also some fixes that I'll comment in particular.

const MAX_TRANSACTIONS: usize = 1_048_576;
/// MAX_WITHDRAWALS_PER_PAYLOAD (Electra).
const MAX_WITHDRAWALS: usize = 16;
/// MAX_BYTES_PER_TRANSACTION.
/// `MAX_TRANSACTIONS_PER_PAYLOAD` (Electra).
const MAX_TRANSACTIONS_PER_PAYLOAD: usize = 1_048_576;
/// `MAX_WITHDRAWALS_PER_PAYLOAD` (Electra).
const MAX_WITHDRAWALS_PER_PAYLOAD: usize = 16;
/// `MAX_BYTES_PER_TRANSACTION`.
const MAX_BYTES_PER_TRANSACTION: usize = 1_073_741_824;
/// MAX_EXTRA_DATA_BYTES.
/// `MAX_EXTRA_DATA_BYTES`.
const MAX_EXTRA_DATA_BYTES: usize = 32;
/// MAX_DEPOSIT_REQUESTS_PER_PAYLOAD (Electra).
const MAX_DEPOSIT_REQUESTS: usize = 8192;
/// MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD (Electra).
const MAX_WITHDRAWAL_REQUESTS: usize = 16;
/// MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD (Electra).
const MAX_CONSOLIDATION_REQUESTS: usize = 1;
Comment on lines -26 to -27

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the previous 1 value wasn't correct, this was fixed below L30 with value 2 as defined in the specs.

/// MAX_BLOB_COMMITMENTS_PER_BLOCK (Electra).
const MAX_BLOB_COMMITMENTS: usize = 4096;
/// MAX_EXECUTION_REQUESTS (EIP-7685).
const MAX_EXECUTION_REQUESTS: usize = 16;
/// MAX_EXECUTION_REQUEST_BYTES.
const MAX_EXECUTION_REQUEST_BYTES: usize = 1_073_741_824;
/// `MAX_DEPOSIT_REQUESTS_PER_PAYLOAD` (Electra).
const MAX_DEPOSIT_REQUESTS_PER_PAYLOAD: usize = 8192;
/// `MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD` (Electra).
const MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD: usize = 16;
/// `MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD` (Electra).
const MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD: usize = 2;
/// `MAX_BLOB_COMMITMENTS_PER_BLOCK` (Electra).
const MAX_BLOB_COMMITMENTS_PER_BLOCK: usize = 4096;

// ── EIP-7685 request type prefixes ─────────────────────────────────

const DEPOSIT_REQUEST_TYPE: u8 = 0x00;
const WITHDRAWAL_REQUEST_TYPE: u8 = 0x01;
const CONSOLIDATION_REQUEST_TYPE: u8 = 0x02;

// ── Bytes20 wrapper (address) ──────────────────────────────────────
//
Expand Down Expand Up @@ -95,7 +100,7 @@ impl From<Bytes20> for [u8; 20] {
// `logs_bloom` is `ByteVector[BYTES_PER_LOGS_BLOOM]` in the CL spec —
// a fixed-length SSZ vector of 256 bytes.

/// BYTES_PER_LOGS_BLOOM from the CL spec.
/// `BYTES_PER_LOGS_BLOOM` from the CL spec.
pub const BYTES_PER_LOGS_BLOOM: usize = 256;

/// `ByteVector[256]` — the logs bloom as a fixed-size SSZ vector.
Expand Down Expand Up @@ -158,41 +163,52 @@ pub struct ExecutionPayload {
/// `base_fee_per_gas` encoded as a 256-bit unsigned integer (little-endian).
pub base_fee_per_gas: [u8; 32],
pub block_hash: [u8; 32],
pub transactions: SszList<SszList<u8, MAX_BYTES_PER_TRANSACTION>, MAX_TRANSACTIONS>,
pub withdrawals: SszList<Withdrawal, MAX_WITHDRAWALS>,
pub transactions: SszList<SszList<u8, MAX_BYTES_PER_TRANSACTION>, MAX_TRANSACTIONS_PER_PAYLOAD>,
pub withdrawals: SszList<Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD>,
pub blob_gas_used: u64,
pub excess_blob_gas: u64,
pub deposit_requests: SszList<DepositRequest, MAX_DEPOSIT_REQUESTS>,
pub withdrawal_requests: SszList<WithdrawalRequest, MAX_WITHDRAWAL_REQUESTS>,
pub consolidation_requests: SszList<ConsolidationRequest, MAX_CONSOLIDATION_REQUESTS>,
Comment on lines -165 to -167

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the specs, ExecutionPayload does not have these three fields. They are actually part of the NewPayloadRequest.

}

// ── ExecutionPayloadHeader ─────────────────────────────────────────
// ── ExecutionRequests ──────────────────────────────────────────────

/// Headerized version of `ExecutionPayload`: variable-length lists are
/// replaced by their `hash_tree_root`.
/// SSZ `ExecutionRequests` container (Electra) — the typed EIP-7685 bundle
/// that the CL commits to alongside `ExecutionPayload`.
#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
pub struct ExecutionPayloadHeader {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't strictly required so we can remove.

pub parent_hash: [u8; 32],
pub fee_recipient: Bytes20,
pub state_root: [u8; 32],
pub receipts_root: [u8; 32],
pub logs_bloom: LogsBloom,
pub prev_randao: [u8; 32],
pub block_number: u64,
pub gas_limit: u64,
pub gas_used: u64,
pub timestamp: u64,
pub extra_data: SszList<u8, MAX_EXTRA_DATA_BYTES>,
pub base_fee_per_gas: [u8; 32],
pub block_hash: [u8; 32],
pub transactions_root: [u8; 32],
pub withdrawals_root: [u8; 32],
pub blob_gas_used: u64,
pub excess_blob_gas: u64,
pub deposit_requests_root: [u8; 32],
pub withdrawal_requests_root: [u8; 32],
pub consolidation_requests_root: [u8; 32],
pub struct ExecutionRequests {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my next comment to understand this.

pub deposits: SszList<DepositRequest, MAX_DEPOSIT_REQUESTS_PER_PAYLOAD>,
pub withdrawals: SszList<WithdrawalRequest, MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD>,
pub consolidations: SszList<ConsolidationRequest, MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD>,
}

impl ExecutionRequests {
/// Produce the EIP-7685 encoded form: three `EncodedRequests` entries,
/// one per request type, each `[type_byte] ++ concat(ssz_encode(item))`.
///
/// The three request types are all fixed-size SSZ containers, so their
/// SSZ encoding is byte-for-byte the EL wire concatenation that
/// `compute_requests_hash` expects.
pub fn to_encoded_requests(&self) -> Vec<EncodedRequests> {
fn encode<T: SszEncode>(
type_byte: u8,
items: impl IntoIterator<Item = T>,
) -> EncodedRequests {
let mut buf = Vec::new();
buf.push(type_byte);
for item in items {
item.ssz_append(&mut buf);
}
EncodedRequests(Bytes::from(buf))
}

vec![
encode(DEPOSIT_REQUEST_TYPE, self.deposits.iter().cloned()),
encode(WITHDRAWAL_REQUEST_TYPE, self.withdrawals.iter().cloned()),
encode(
CONSOLIDATION_REQUEST_TYPE,
self.consolidations.iter().cloned(),
),
]
}
Comment on lines +183 to +211

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExecutionRequests::to_encoded_requests() currently always returns 3 EncodedRequests entries, even when a request list is empty (yielding a 1-byte [type] entry). Elsewhere in the repo the EIP-7685 rules are enforced such that entries with empty request_data must be excluded (see validate_execution_requests, which rejects len() < 2). Consider emitting only non-empty request types (and/or filtering out EncodedRequests where is_empty() is true) so the helper is spec-compliant if reused beyond compute_requests_hash.

Copilot uses AI. Check for mistakes.
}

// ── NewPayloadRequest ──────────────────────────────────────────────
Expand All @@ -202,20 +218,9 @@ pub struct ExecutionPayloadHeader {
#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
pub struct NewPayloadRequest {
pub execution_payload: ExecutionPayload,
pub versioned_hashes: SszList<[u8; 32], MAX_BLOB_COMMITMENTS>,
pub parent_beacon_block_root: [u8; 32],
pub execution_requests:
SszList<SszList<u8, MAX_EXECUTION_REQUEST_BYTES>, MAX_EXECUTION_REQUESTS>,
}

/// Headerized version of `NewPayloadRequest`.
#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, HashTreeRoot)]
pub struct NewPayloadRequestHeader {
pub execution_payload_header: ExecutionPayloadHeader,
pub versioned_hashes: SszList<[u8; 32], MAX_BLOB_COMMITMENTS>,
pub versioned_hashes: SszList<[u8; 32], MAX_BLOB_COMMITMENTS_PER_BLOCK>,
pub parent_beacon_block_root: [u8; 32],
pub execution_requests:
SszList<SszList<u8, MAX_EXECUTION_REQUEST_BYTES>, MAX_EXECUTION_REQUESTS>,
pub execution_requests: ExecutionRequests,
Comment on lines -217 to +223

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

// ── PublicInput ────────────────────────────────────────────────────
Expand All @@ -227,37 +232,6 @@ pub struct PublicInput {
pub new_payload_request_root: [u8; 32],
}

// ── Conversion helpers ─────────────────────────────────────────────

impl ExecutionPayload {
/// Produce the headerized version by computing `hash_tree_root` of
/// each variable-length list field.
pub fn to_header(&self, hasher: &impl Sha256Hasher) -> ExecutionPayloadHeader {
ExecutionPayloadHeader {
parent_hash: self.parent_hash,
fee_recipient: self.fee_recipient,
state_root: self.state_root,
receipts_root: self.receipts_root,
logs_bloom: self.logs_bloom.clone(),
prev_randao: self.prev_randao,
block_number: self.block_number,
gas_limit: self.gas_limit,
gas_used: self.gas_used,
timestamp: self.timestamp,
extra_data: self.extra_data.clone(),
base_fee_per_gas: self.base_fee_per_gas,
block_hash: self.block_hash,
transactions_root: self.transactions.hash_tree_root(hasher),
withdrawals_root: self.withdrawals.hash_tree_root(hasher),
blob_gas_used: self.blob_gas_used,
excess_blob_gas: self.excess_blob_gas,
deposit_requests_root: self.deposit_requests.hash_tree_root(hasher),
withdrawal_requests_root: self.withdrawal_requests.hash_tree_root(hasher),
consolidation_requests_root: self.consolidation_requests.hash_tree_root(hasher),
}
}
}

impl NewPayloadRequest {
/// Compute the `hash_tree_root` of this request — the value that
/// becomes the execution proof's public input.
Expand All @@ -266,16 +240,6 @@ impl NewPayloadRequest {
new_payload_request_root: self.hash_tree_root(hasher),
}
}

/// Produce the headerized version.
pub fn to_header(&self, hasher: &impl Sha256Hasher) -> NewPayloadRequestHeader {
NewPayloadRequestHeader {
execution_payload_header: self.execution_payload.to_header(hasher),
versioned_hashes: self.versioned_hashes.clone(),
parent_beacon_block_root: self.parent_beacon_block_root,
execution_requests: self.execution_requests.clone(),
}
}
}

#[cfg(test)]
Expand All @@ -291,61 +255,56 @@ mod tests {
fee_recipient: Bytes20([2u8; 20]),
state_root: [3u8; 32],
receipts_root: [4u8; 32],
logs_bloom: vec![0u8; 256].try_into().unwrap(),
logs_bloom: vec![0u8; 256].try_into().expect("logs_bloom length"),
prev_randao: [5u8; 32],
block_number: 42,
gas_limit: 30_000_000,
gas_used: 21_000,
timestamp: 1_700_000_000,
extra_data: vec![0xAB, 0xCD].try_into().unwrap(),
extra_data: vec![0xAB, 0xCD].try_into().expect("extra_data fits"),
base_fee_per_gas: {
let mut b = [0u8; 32];
b[0] = 7; // 7 in LE
b
},
block_hash: [6u8; 32],
transactions: vec![vec![0xDE, 0xAD, 0xBE, 0xEF].try_into().unwrap()]
.try_into()
.unwrap(),
transactions: vec![
vec![0xDE, 0xAD, 0xBE, 0xEF]
.try_into()
.expect("tx bytes fit"),
]
.try_into()
.expect("txs fit"),
withdrawals: vec![Withdrawal {
index: 0,
validator_index: 1,
address: Bytes20([7u8; 20]),
amount: 1_000_000,
}]
.try_into()
.unwrap(),
.expect("withdrawals fit"),
blob_gas_used: 0,
excess_blob_gas: 0,
deposit_requests: vec![].try_into().unwrap(),
withdrawal_requests: vec![].try_into().unwrap(),
consolidation_requests: vec![].try_into().unwrap(),
}
}

fn empty_requests() -> ExecutionRequests {
ExecutionRequests {
deposits: vec![].try_into().expect("empty deposits"),
withdrawals: vec![].try_into().expect("empty withdrawals"),
consolidations: vec![].try_into().expect("empty consolidations"),
}
}

fn sample_request() -> NewPayloadRequest {
NewPayloadRequest {
execution_payload: sample_payload(),
versioned_hashes: vec![].try_into().unwrap(),
versioned_hashes: vec![].try_into().expect("empty versioned_hashes"),
parent_beacon_block_root: [8u8; 32],
execution_requests: vec![].try_into().unwrap(),
execution_requests: empty_requests(),
}
}

#[test]
fn test_ssz_root_roundtrip_payload_vs_header() {
let request = sample_request();
let header = request.to_header(&HASHER);

let request_root = request.hash_tree_root(&HASHER);
let header_root = header.hash_tree_root(&HASHER);

assert_eq!(
request_root, header_root,
"NewPayloadRequest root must equal NewPayloadRequestHeader root"
);
}

#[test]
fn test_ssz_root_changes_with_different_data() {
let request1 = sample_request();
Expand All @@ -359,44 +318,55 @@ mod tests {
);
}

#[test]
fn test_empty_vs_nonempty_list_roots_differ() {
let payload = sample_payload();
let header = payload.to_header(&HASHER);

let empty_payload = ExecutionPayload {
transactions: vec![].try_into().unwrap(),
withdrawals: vec![].try_into().unwrap(),
deposit_requests: vec![].try_into().unwrap(),
withdrawal_requests: vec![].try_into().unwrap(),
consolidation_requests: vec![].try_into().unwrap(),
..sample_payload()
};
let empty_header = empty_payload.to_header(&HASHER);

// Non-empty lists (sample has 1 tx + 1 withdrawal) produce different roots
// than empty lists.
assert_ne!(
header.transactions_root, empty_header.transactions_root,
"Non-empty transactions root must differ from empty"
);
assert_ne!(
header.withdrawals_root, empty_header.withdrawals_root,
"Non-empty withdrawals root must differ from empty"
);

// deposit/withdrawal/consolidation requests are empty in both, so roots match.
assert_eq!(
header.deposit_requests_root, empty_header.deposit_requests_root,
"Both-empty deposit_requests roots must match"
);
}

#[test]
fn test_ssz_root_is_deterministic() {
let request = sample_request();
let root1 = request.hash_tree_root(&HASHER);
let root2 = request.hash_tree_root(&HASHER);
assert_eq!(root1, root2, "Same request must produce same root");
}

#[test]
fn test_execution_requests_to_encoded_bytes() {
let requests = ExecutionRequests {
deposits: vec![DepositRequest {
pubkey: [0x11; 48],
withdrawal_credentials: [0x22; 32],
amount: 32_000_000_000,
signature: [0x33; 96],
index: 7,
}]
.try_into()
.expect("one deposit fits"),
withdrawals: vec![WithdrawalRequest {
source_address: Bytes20([0x44; 20]),
validator_pubkey: [0x55; 48],
amount: 1_000_000,
}]
.try_into()
.expect("one withdrawal fits"),
consolidations: vec![ConsolidationRequest {
source_address: Bytes20([0x66; 20]),
source_pubkey: [0x77; 48],
target_pubkey: [0x88; 48],
}]
.try_into()
.expect("one consolidation fits"),
};

let encoded = requests.to_encoded_requests();
assert_eq!(encoded.len(), 3, "must emit 3 EIP-7685 entries");

// Deposit: [0x00] ++ 192 bytes
assert_eq!(encoded[0].0[0], DEPOSIT_REQUEST_TYPE);
assert_eq!(encoded[0].0.len(), 1 + 192);

// Withdrawal: [0x01] ++ 76 bytes
assert_eq!(encoded[1].0[0], WITHDRAWAL_REQUEST_TYPE);
assert_eq!(encoded[1].0.len(), 1 + 76);

// Consolidation: [0x02] ++ 116 bytes
assert_eq!(encoded[2].0[0], CONSOLIDATION_REQUEST_TYPE);
assert_eq!(encoded[2].0.len(), 1 + 116);
}
}
Loading
Loading